Skip to main content

Infrared Python SDK

Python SDK for the Infrared City simulation platform. Run urban microclimate analyses — wind, solar, thermal comfort — from a few lines of code.

📚 SDK Documentation  ·  🧪 Notebooks + agent skills  ·  🌐 infrared.city  ·  📊 Knowledge base

PyPI version Python License: Apache-2.0

Upgrading from 0.5.3? Read UPGRADING.md (in the repository and in the source distribution) before you install.

What changed in 1.0.0

  • Values keep the type the server sent. AreaResult.merged_grid is float16, float32, int16 (UTCI, with value_divisor and a valid bitmap) or float64. SurfaceColumns.values keeps the type in the same way. Use result.physical_grid() and columns.physical_values() for physical values; test a surface cell with columns.has_value(cell). UTCI read without the divisor is 10 times too large.
  • The SDK merges area grids. The merge keeps no float64 canvas of every tile.
  • merged_grid is read-only. grid += x raises ValueError. Use result.physical_grid() (a new array) or merged_grid.copy(). merge_area_jobs(..., dtype=...) is removed.
  • Render buffers. columns.render_buffers() gives the arrays a renderer needs, with no mesh for each cell.
  • emit_cell_tris is off by default in synthesize_facade_layout. A layout saved before 1.0.0 cannot be loaded (format version 3): build and save it again.
  • Save a result. columns.to_bytes() and SurfaceColumns.from_bytes() (see Save and reload a facade result).
  • Year-wrap windows. A weather window from December to February is one window.

The full list of changes and the steps to change your code are in UPGRADING.md. It is in the source distribution and in the repository (public/python/UPGRADING.md). The wheel does not contain it; this section is complete without it.

Install

Two installs, because only ONE capability needs the heavy libraries.

pip install infrared-sdk                # base
pip install "infrared-sdk[geodata]"     # + the Overture Maps parquet reader
base [geodata]
Extra wheels — pyarrow, shapely
site-packages (CPython 3.12, linux x86-64) 109 MB 277 MB
Payloads, run_area with your own buildings/trees/ground ✅ ✅
Results, IRBF binary results, local images ✅ ✅
Static weather catalog + EPW (geo.infrared.city, over requests) ✅ ✅
Trees and city overlays (FlatGeobuf, read by the SDK) ✅ ✅
vegetation.get_area, buildings.get_area inside a registered city ✅ ✅
Overture buildings, land use, land cover and water (parquet) ❌ ✅

Without the extra an Overture read raises one GeodataDependencyError — an ImportError subclass — naming the exact command:

from infrared_sdk import GeodataDependencyError, InfraredClient

try:
    ground = InfraredClient(api_key="...").ground_materials.get_area(polygon)
except GeodataDependencyError as error:
    print(error.install_command)   # pip install "infrared-sdk[geodata]"

It never returns an empty area and never falls back to another decoder.

The sizes are approximate (measured on a development build). infrared-sdk 0.5.3 shipped neither library, so the extra adds a capability; it removes nothing from the base install.

Other extras: [fast] adds orjson for quicker tile decoding. [direct] still resolves and still installs the parquet stack; the name is the earlier spelling on this line and was never published on PyPI. See

Rhino and Grasshopper

Rhino 8 runs CPython 3.9. The Windows wheel is cp39-abi3-win_amd64, so it installs there with no compiler. In the Rhino 8 Script Editor, or in a Grasshopper Python 3 component, put this line at the top of the script:

# r: infrared-sdk==1.0.0

Rhino installs the package on the first run. Use the base package there; add [geodata] only if you read Overture data inside Rhino.

Requirements

Python 3.9 or later. The 3.9 floor is deliberate, not a lag: it matches the abi3-py39 core wheel (infrared-core) so the SDK installs everywhere the core does — including embedded interpreters that ship CPython 3.9 (Rhino 8.x, Houdini 19, QGIS LTS). Those hosts are why the floor stays at 3.9 despite 3.9 being upstream-EOL; it is intentionally not bumped to reach newer stable-ABI symbols, which would lock those hosts out.

Supplied scenes on terrain

When an analysis request includes terrain and caller-supplied buildings or trees, the SDK sends terrain-alignment="as-is" by default. The server keeps every supplied coordinate unchanged and does not assert that solids are seated on the terrain. Select "auto-align" to seat them, or "assume-aligned" to keep the coordinates and ask the server to validate their seating.

Wind and pedestrian-wind-comfort spell the same option with their own two values, because the mechanism is not the same one: those two models take no terrain at all, and the worker reads a building's HEIGHT straight off the raw z of its mesh. terrain_alignment="to-ground" (opt-in since 0.9.7; the default is "as-is") drops every supplied mesh so its own lowest point sits at z = 0 before the request is built, which is the documented contract for that channel (Z = height above local grade). Send "as-is" when your buildings are already flat-based; a scene that is already at grade is submitted byte-for-byte unchanged either way. ground_geometry is still refused on both models. run_area is the one place this happens; a direct client.analyses.execute(...) submission is sent as given. See

A building or vegetation map that you fetch and then pass to run_area is caller-supplied input too; the SDK cannot infer its provenance. Select "auto-align" explicitly when that fetched map needs seating. Raw requests that omit terrain-alignment keep the server's existing default. Saved area schedules retain the behavior recorded by their configuration hash.

GeoJSON point trees keep the existing registry-mesh path: their input carries XY and size, not a supplied XYZ seating promise. A tree that is already a mesh retains its supplied XYZ coordinates under "as-is".

How geometry reaches the API

An asynchronous submission (run_area, run, anything under /async/*) uploads its geometry once and sends the API a reference to it, instead of repeating the mesh inside every request body. One site analysed three ways uploads its buildings once, not three times — measured at 66.1 MB → 26.8 MB over one benchmark's three analyses, with an identical grid.

This is on by default. To send geometry inline in the request body, as older versions did, set:

export INFRARED_GEOMETRY_REF_ENABLED=false

Two things worth knowing, because they are about money and about trust:

  • The SDK never submits a job of its own. It learns whether a deployment resolves the reference from the acknowledgement on your FIRST real submission, and remembers the answer per API URL and account.
  • A deployment that does not acknowledge the reference costs one job. It would otherwise simulate a scene with no buildings in it and return plausible, entirely wrong results. That tile is listed under uncertain_submissions with the accepted job ID, so it can be reconciled against your bill; it is never resubmitted automatically, and the other tiles of the run submit their geometry inline.

Synchronous routes always carry geometry inline, whatever the variable says.

Upload time rule. Every request that sends a body (an upload, a submit) obeys two limits, and no other:

  • the stall guard: if no byte of the body moves for 10 s, the send stops;
  • the size budget: the body must be sent within min(1800 s, max(60 s, bytes / 64 KiB/s)).

A slow upload that still moves is never stopped before its budget, so a large scene on a slow uplink completes. After the last byte, the request's own timeout applies to the answer. The SDK owns both numbers, so the Python and TypeScript SDKs use the same rule. A paid submit that the guard stops is recorded as uncertain and is never sent again automatically. See

Result encoding

The SDK reads ordinary JSON results and strict IRBF binary results. The retired mixed JSON/base64 result fields are rejected.

Binary is the default transport. When you give no transport (on InfraredClient, run_area, run_area_and_wait or jobs.submit), the SDK uses the strict binary transport. JSON is available on request: pass transport="json". energy-balance always uses JSON. A daylight-factor request that run_and_wait / run_parts split into two or more parts uses the binary route when the server supports it (the scene is uploaded once and each part sends only its control), and JSON otherwise; a one-part daylight-factor request uses JSON.

Binary results carry the server's stored precision: F16 for sky view factors, solar radiation, thermal comfort statistics and wind, 0.1 steps for the thermal comfort index, F32 for the other floating outputs. A value can differ from the JSON result by one such step. If the binary route refuses a request, the SDK raises that error; it does not resubmit the job as JSON. A retry_from run keeps the transport of the saved schedule.

An area grid keeps that type. AreaResult.merged_grid is a read-only numpy array of float16, float32, int16 (thermal comfort index: stored value = physical value x value_divisor, valid bitmap) or float64 (a run that mixes types). Use result.physical_grid() for physical values, NaN for no value:

result = sdk.run_area_and_wait(payload, polygon, buildings=buildings)
grid = result.physical_grid()          # float64, divisor applied, NaN = no value
low, high = np.nanmin(grid), np.nanmax(grid)

Less memory: the allocator settings (opt-in)

The SDK's Rust core and pyarrow (the Overture parquet reader of the [geodata] extra) each use their own copy of the mimalloc allocator. The core sets its own copy to give freed memory back to the operating system at once. The SDK cannot set pyarrow's copy. To set every copy in the process, set these two environment variables BEFORE Python starts (or before the first import infrared_sdk and import pyarrow):

export MIMALLOC_PURGE_DELAY=0          # give freed pages back at once (default: 10 ms)
export MIMALLOC_ARENA_EAGER_COMMIT=0   # do not commit a whole arena at once (default: 2)

Measured on a 32-core Linux build machine against staging (median of 3 runs):

Workload Peak RSS, core setting only Peak RSS, with the two variables
Design loop: one Vienna tile with Overture data, 5 iterations of 24 runs 800 MB 587 MB

The speed did not change in this measurement (warm submit 0.35 s and 0.33 s per run, merge 0.11 s). A workload that frees and allocates large blocks very often can be slower, because the allocator returns pages and asks for them again.

Side effect. The variables apply to EVERY library in the process that uses mimalloc, not only to this SDK: pyarrow, other Rust extensions, and child processes that inherit the environment. That is why the SDK does not set them for you. A value that you set always wins over the SDK's own setting.

How many tiles go out at once

run_area submits its tiles from a pool of 8 threads, and merge_area_jobs downloads the finished tiles with 8 of its own. They are two separate knobs, both spelled max_workers, and lowering one does not lower the other.

Eight is where the submit pool stops getting faster: on a 49-tile area it posts 2.7 tiles/s with one thread, 7.5 with four, 9.8 with eight and 9.4 with twenty. Past eight the extra threads only queue — the twenty-thread run raised per-request latency from 236 ms to 307 ms at the median. The speed is set by the network, which sends up to max_workers requests at once. It is not set by encoding or zipping on the client. A 429 reply makes the SDK send that tile again.

A caller who has measured their own workload can still ask for more: run_area(..., max_workers=20) is honoured up to 20. The merge default has no such tuning behind it — the sweep only established that 1 is too few (7.3 s against 2.5 s for the same 6 MB) — and on staging 16 or 20 download workers were no faster than 8 on a 16-tile run.

Polling. One poll engine serves every wait: jobs.wait_for_completion (and so every single-job model) and run_area_and_wait. The SDK owns its numbers.

  • The first status request goes 0.5 s after the wait starts.
  • While the jobs are healthy, the engine polls every 1 s for the first 10 s, then every 2 s.
  • An area run asks for up to 50 jobs in one batched status request where the gateway has that route, else one request per job. A sweep of n requests waits at least n / 2 s.
  • After HTTP 429, a 5xx or a network error, the engine backs off (up to 30 s, never shorter than Retry-After). The first healthy sweep resets it.
  • wait_for_completion waits 900 s by default; timeout accepts fractional seconds and must be greater than 0. A 401 or 404 raises JobPollError at once. A wait that does not recover from 429/5xx/network errors raises JobTimeoutError at its deadline. The area wait (area_timeout) stays 3600 s.
  • backoff_cap (a JobsServiceClient constructor argument) caps the healthy interval of a single-job wait only. It never shortens an error backoff or a Retry-After, and it must be greater than 0.

One thing to know about the convenience method: run_area_and_wait() runs both pools, but its single max_workers reaches the submit one only. Its merge always runs at the default width. Once polling confirms a successful job, the method can download, decode, and fold that tile while other jobs are still running. It returns only after the final job states and result checks pass.

To size the merge pool, submit with run_area(), poll for completion, then call merge_area_jobs(schedule, max_workers=...). That manual sequence does not overlap result processing with polling.

How big a site can be

A run is capped at 100 non-empty tiles. The cap is a cost guard, not a platform limit: one tile is one separately billed job. Over it, nothing is submitted and the refusal prices the run you asked for.

Polygon produces 196 non-empty tiles on the wind grid (256 m step), over the
limit of 100. Each tile is one billed job at about 10 tokens, so this run would
cost about 1,960 tokens. To run it, pass max_tiles_override=196; to spend less,
shrink the polygon or size it first with preview_area().

Three things are worth reading out of that sentence.

The grid decides the count, not the polygon alone. wind-speed and pedestrian-wind-comfort step at 256 m with 50 % overlap; solar, daylight and thermal-comfort types step at 512 m with none. The same 3.5 km square is 49 tiles on solar and 196 on wind. Being over the cap on wind says nothing about whether you are over it on solar.

The number it names is the number to pass. max_tiles_override=196 runs this polygon, and so does anything larger. The override is not a workaround being phased out — it is how you say yes to the price.

Price it before you run it. preview_area submits nothing, takes the same override, and returns the same numbers the refusal quotes:

preview = client.preview_area(
    polygon, analysis_type="wind-speed", max_tiles_override=400
)
print(preview.tile_count, preview.estimated_cost_tokens)  # 196 1960

schedule = client.run_area(payload, polygon, max_tiles_override=400)

Pass analysis_type there. Without it the preview falls back to the wind grid and over-counts a solar run about fourfold.

A facade run bills per building batch, not per tile — pass payload= to price it correctly. The token figure above is tile_count * 10, one job per tile — right for a grid analysis, wrong for a facade (analysis_surfaces) one: the server splits a dense tile's target buildings into several separately billed sub-batches, and a tile with no target building submits no job at all. Pass the SAME payload= (and buildings=, if the geometry is not embedded on the payload) a run_area() call would, and the preview runs that same split offline — no HTTP — to report would_bill_jobs, the real planned job count, and sensor_count, the total synthesized sensors:

preview = client.preview_area(
    polygon,
    analysis_type="sky-view-factors",
    payload=facade_payload,       # analysis_surfaces="facades", say
    buildings=buildings,
)
print(preview.tile_count, preview.would_bill_jobs, preview.sensor_count)

Without payload=, would_bill_jobs falls back to tile_count — an UNDER-estimate for a facade run, the pre-0.9.3 behavior kept for backward compatibility. Running the identical facade request twice bills every job again, in full — there is no result cache. geometry-$ref reuses an upload, never a result; see docs/sdk-data-flow.md for why, and why that is a platform feature to ask for, not an SDK one.

Trees on a binary wind or PWC run

A wind-speed or pedestrian-wind-comfort run sends its trees exactly like every other outdoor model: Point features ride as vegetation source metadata, meshes go in the binary vegetation group, and vegetation-instances goes through unchanged. The server's wind worker boxes the trees itself when the model needs that simplification. The SDK keeps no capability gate and no client-side substitution for this.

Where the data comes from

The SDK reads the public data sources itself and computes in the bundled Rust code. It calls no utilities-service route: /ground-material/collect, /ground-material/clean-v3, /gis/vegetation, /convert/geojson-to-mesh, /analysis/generate-image, the three /weather/* routes and the gateway's POST /buildings are all gone, together with the flags that selected them (acquisition, cleaner, converter, renderer, weather source). Each removed argument raises TypeError naming its replacement for one minor version — see MIGRATION.md, "Breaking: utility paths removed". There is no flag to set and nothing to opt into.

from infrared_sdk import InfraredClient

sdk = InfraredClient(api_key="...")

trees  = sdk.vegetation.get_area(polygon)          # public FlatGeobufs
ground = sdk.ground_materials.get_area(polygon)    # roads FGB + Overture, cleaned in the SDK
bldgs  = sdk.buildings.get_area(polygon)           # city overlay, else Overture footprints
land   = sdk.landuse.get_area(polygon)             # Overture land_use, classified in the SDK
meshes = sdk.vegetation.convert_to_mesh(tree_fc)   # tree meshes in the SDK

Acquiring a district this way — buildings, ground materials or land use for a real site, as opposed to geometry you already have — reads Overture parquet and needs the optional [geodata] extra (pip install "infrared-sdk[geodata]" — see Install). Without it, get_area raises ImportError naming the install command. vegetation.get_area alone does not need it: it reads the FlatGeobuf layers on geo.infrared.city, which the SDK reads directly.

landuse.get_area() is a CONTEXT read, not a simulation layer: it answers "what is the land around this site used for" and is not passed to run_area(). It reads the same Overture land_use rows the ground-material composition reads — same collection, same rectangle, same release — and hands them to the SDK's shared classification table instead of to composition, so the categories cannot drift between this SDK and any other host. Each feature keeps its source overture_class beside the assigned category, and the answer reports what the classification saw: how many features were read, how many survived, the count per category, and a sample of source spellings the table did not know.

The read margin follows the analysis. All four take an optional analysis_type, and it sizes the read: a solar, daylight or thermal model needs the buildings and trees that cast a shadow into a tile, and they stand further away than the obstacles a wind model needs. The half extent is the core preset's context_size_m / 2 — 256 m for wind-speed and pedestrian-wind-comfort, 384 m for every other analysis — and ground materials, trees and land use read the tile half diagonal of that, 363 m and 544 m.

Pass nothing and the read uses the WIDEST margin. That is correct for every analysis and costs a wind caller extra reading; the reverse default would drop the outer band of context with no error and no other symptom. Name the analysis to read exactly what it simulates:

bldgs = sdk.buildings.get_area(polygon, analysis_type="wind-speed")   # 256 m
bldgs = sdk.buildings.get_area(polygon)                               # 384 m

Each result records read_margin_m and analysis_type. run_area compares them with what its own analysis needs and raises ReadMarginError — import it from infrared_sdk, beside the other refusals — naming both margins, rather than running a scene with a band of context missing. Equal or wider passes, so one default acquisition serves every analysis. Pass the whole object — run_area(buildings=bldgs, vegetation=trees, ground_materials=ground) — for that check to happen; a bare map records nothing and is never refused.

Three hostnames are reachable, and no API key is ever sent to one of them: geo.infrared.city, registry.infrared.city and overturemaps-us-west-2.s3.us-west-2.amazonaws.com. The Overture host is the same whether the parquet is read through pyarrow's S3 filesystem or, where that cannot load (Windows QGIS), over plain HTTPS: firewall rules do not change.

Each area read opens the public Overture files once per get_area, for the whole site, however many read chunks the site has. It then assigns the rows to the chunks. It does not read once per tile and no longer once per chunk: a parquet row group spans far more ground than a 2 km chunk, so a per-chunk read fetched the shared row groups again for each chunk. A 10 km × 300 m corridor plans 93.5 MiB of compressed parquet instead of 1,041 MiB, and a 5 km² box 85 MiB instead of 289 MiB, for the same answer to the byte. A 4 km² ground read falls from about three minutes to about ten seconds. At most TWO read chunks are ever COMPOSED at once, whatever max_workers says: it is a ceiling, so the knob can only ask for fewer (max_workers=1 composes the chunks one at a time). timeout is the per-REQUEST budget and total_timeout the deadline over the whole site read.

Reading Overture parquet needs the optional [geodata] extra — see Install. The FlatGeobuf layers (geo.infrared.city) do not: the bundled code reads them.

Ground materials

get_area reads the whole site — one rectangle up to 4 km², a grid of read chunks above it — and composes, merges and cleans it in the SDK. z_step (metres per material layer, default 0.05) is accepted. A failure raises GroundMaterialsServiceError instead of returning uncleaned layers.

There is no cleaner selector and no injection seam: one core operation cleans, and z_step is its only knob. There is also no partial answer: a read chunk that fails raises TiledRunError, whose failed_tiles names the failed chunks (chunk-r0c1, or site for a single-chunk read) and whose __cause__ is the first underlying error. A returned result covers the whole polygon. Recorded differences from the retired service:

Bulk callers can skip the host object tree entirely — these take and return JSON documents as bytes, and the SDK releases the GIL while it works:

  • LocalGroundMaterialCleaner.clean_json(layers_json, *, latitude, longitude, distance, default_layer, z_step=None) -> bytes — clean-v3 over one layers document.
  • LocalGroundMaterialCleaner.merge_and_clean_json(tile_layers_json, ...) -> bytes — merge per-tile documents AND clean them in one core call; this is what get_area uses.
  • infrared_sdk.vegetation.convert_local.convert_points_to_meshes_json(features_json, lon, lat) -> bytes — the tree mesher over a features document.

Buildings

A registered city's building bodies (Vienna publishes vienna-buildings.fgb, the Stadt Wien model) when the tile's query rectangle intersects that city, and Overture footprints otherwise. The two are different data, not different amounts of the same data — one Vienna tile is 815 city meshes against 312 Overture footprints — so the city model replaces Overture rather than adding to it. AreaBuildings.building_ids comes back empty, exactly as the retired route left it for every non-Mapbox source; AreaBuildings.buildings is keyed by the real building identifier. The route's parent/child grouping of courtyard polygons is not replicated. get_by_tiles reads the same way and shares the same one-read-per-area acquisition.

Acquisition, in one sentence. ground_materials.get_area() and buildings.get_area() read the Overture files ONCE for the whole site and the road layer once per roughly 2 x 2 km read chunk, then compose, merge and extrude the site in the SDK, two chunks at a time; the simulation tiling is applied later, when run_area() builds each tile's payload.

The five ground materials. A ground_materials layer key must be one of asphalt, concrete, soil, vegetation or water. Those are the only names the simulation resolves to a surface; anything else — a typo, a registry UUID, or the land-cover class building — reaches the model as an unknown material and produces a wrong thermal result, so the SDK refuses it before the job is submitted. Case and surrounding spaces do not matter. Layers from ground_materials.get_area() always pass.

Run and resume. run_area() re-anchors every metre layer from the site frame into each tile's own frame with the exact projection the server uses, so a building and its twin in the neighbouring tile line up. A saved schedule resumes with retry_from=; the buildings map you pass on the retry must be the one the run was submitted with (the SDK fingerprints it and refuses a mismatch). To keep accepted job ids when the process stops during submission, pass on_accepted=lambda job_id, tile_key: ... to run_area() or run_area_and_wait(): it is called once for each accepted job, as the submit loop records it (never for a failed or uncertain tile). It only observes: an exception from it is logged and ignored, and the calls come one at a time from the submit threads.

Area size. One run is recommended up to 20 km². The local metre frame fixes its east-west scale at the site origin's latitude, so shapes at the far corner of a 20 km² site are off by about 0.08 % (a 100 m building by under 8 cm) at 48 deg N. Above that the SDK logs a warning; the hard stop is the planned-bytes ceiling, which raises OvertureReadTooLargeError. For a larger or more precise area, split it into several run_area() calls — raster results are geo-referenced by bounds and stitch by lat/lon.

Long or narrow sites. A site is read as its bounding box, so a long, narrow or diagonal polygon has a box much bigger than the ground its simulation tiles cover. Three things follow, and the SDK does all three for you. The compose is given chunk ∩ union(tile rectangles) — the read chunk cut down to the pieces a simulation will actually read — so the merged ground document carries no surplus from the empty corners of the box; the parquet READ is unchanged by that, because a row group spans more ground than a piece. The Overture read is ONE scan for the whole site, so the box being larger than the tiles costs a few extra rows decoded and discarded, not a re-fetch per chunk. The read chunk is the same 2 km grid whatever the shape — it bounds the compose and the road reads. And the one-frame warning has a second rule: the flat-frame error grows with EXTENT, not with area, so a 10 km × 300 m strip is only 3 km² but carries the far-end error of a 10 km site (about 17 m at 48 deg N). Above 5 km on the longest side the SDK warns and names that error. A compact site is unaffected in every respect: its answer, byte for byte, is the one it gave before.

BuildingsConfig (output format, compression, optimizations) shaped the retired POST body. It is still accepted and is ignored.

Feeding the acquired site context into a run

The three reads above are inputs, not side effects: pass them into the run, or the analysis is billed for an empty scene.

buildings = sdk.buildings.get_area(polygon)
trees = sdk.vegetation.get_area(polygon)
ground = sdk.ground_materials.get_area(polygon)

result = sdk.run_area_and_wait(
    payload, polygon,
    buildings=buildings,          # the OBJECT: it records the frame it is in
    vegetation=trees.features,
    ground_materials=ground.layers,
)

Acquire with the same polygon you run — or hand over the object. The buildings result records origin, the site frame its meshes are stored in, and run_area(buildings=<that object>) re-anchors from it, so one acquisition over a large polygon can serve several sub-area runs. Passing the bare buildings.buildings MAP still works and is unchanged, but a map carries no frame: it is read as already being in the run polygon's frame, so acquiring for one polygon and running another moves the whole city by the distance between the two south-west corners.

Facade results as columns

A facade / surface area result keeps its per-surface dict (result.surfaces). The same numbers are also available as columns, one numpy array per field (read-only views of the SDK's buffers, no copy); the names match the TypeScript SDK's SurfaceColumns. The server sends surface results in surface schema 2 (per-surface columns), and the SDK joins all jobs of the run in one call.

Read the columns for speed and memory. result.columns builds no Python objects. result.surfaces builds one SurfaceSensorGrid per surface on its FIRST read, with the same values and fields as 0.5.3. A caller that reads only result.columns does not pay that cost: on the HK Mid-Levels fixture (130,581 surfaces) it holds about 370 MB less and the result step takes 0.1 s, not 1.0 s.

values keeps the server's type. cols.values is float16, float32, int16 (with cols.value_divisor and the cols.valid bitmap) or float64 (JSON results): 2 or 4 bytes per cell, not 8. Use cols.has_value(cell) and cols.physical_values(start, end) for physical numbers. See UPGRADING.md.

Row order is deterministic. The SDK joins the jobs in schedule entry-id order, not in the order the server accepted or finished them. Two identical runs give the same rows in the same order, so a row index is stable between runs.

result = sdk.run_area_and_wait(payload, polygon, buildings=buildings)
cols = result.columns                      # None for a result parsed from one response

row = cols.ids.index("building-7/2")       # surface id -> row
cells = cols.physical_values(cols.cell_offsets[row], cols.cell_offsets[row + 1])   # float64, NaN = no value
origin, u_axis, v_axis = cols.origin[row], cols.u_axis[row], cols.v_axis[row]
walls = abs(cols.u_axis[:, 0] * cols.v_axis[:, 1] - cols.u_axis[:, 1] * cols.v_axis[:, 0]) <= 0.5

buffers = cols.render_buffers()            # frames, outline, values, validity (see below)
tris = cols.triangles                      # None unless emit_cell_tris=True
for group, positions in enumerate(tris.positions):     # one job (tile or batch) each
    xyz = positions.reshape(-1, 3).astype("f8")        # float32, tile-local metres
    xyz[:, :2] += tris.anchors[group]                  # the tile's SW offset

Draw with render buffers. cols.render_buffers() gives the flat arrays a renderer needs: the frames, the outline triangles of each frame, one value for each cell (in the type the server sent) and one validity bit for each cell. No mesh for each cell exists, and the arrays own their memory. A facade or roof run of this client has the outline. cols.render_buffers(layout=layout) takes it from a saved FacadeLayout instead. The client keeps the capture of each facade or roof job until you merge the run: merge in the client that submitted it, and call client.forget_schedule(schedule) for a schedule you will not merge. The guide "Draw facade and roof results fast" on the SDK docs site shows the format and a drawing recipe. To serve many users, read "Serve many users".

emit_cell_tris is False by default; set it to True only if you need the per-cell triangles (cols.triangles).

cell_area_state says what the wire sent for each surface's cell-area (0 absent, 1 null, 2 an array); triangles.cell_offsets cuts the triangles into cells and triangles.drawn marks the cells without geometry.

Show facade results after a reload, offline (synthesize_facade_layout, #707). The outline and the triangles above come from the live run's capture; after the process restarts there is nothing to merge. infrared_sdk.facade_layout.synthesize_facade_layout(buildings, analysis_surfaces=..., surface_grid_size=..., ...) rebuilds the identical frames, cell order and outline straight from the buildings you already have, offline, no HTTP, no billing, with surfgrid_version pinned to the value the original result was built at (never a silent "latest"). layout.to_bytes() / FacadeLayout.from_bytes(data) save and reload a layout WITHOUT the geometry, for when the model may have changed since the run. The saved format (version 3) holds the frames and the outline of each frame; it holds no per-cell triangles unless you pass emit_cell_tris=True. A layout saved by an earlier version is refused: build it and save it again. Then columns.render_buffers(layout=layout) builds the render buffers. Store layout.layout_key alongside every result: attach_values(layout, columns, expected_layout_key=...) requires it and refuses a mismatch -- including a geometry change too small to move a frame's header, since layout_key hashes the geometry itself -- instead of draping values onto plausible-looking wrong cells. to_bytes() exports the layout you already have, never re-synthesizing (HK Mid-Levels, 817 buildings: 38.98 MB in 1.0 s); pass per-job active_ids (and, tiled, site_origin/tile_origin) so a large site's export stays job-sized. See UPGRADING.md § Show facade results after a reload.

Save and reload a facade result

Save the values of a facade or roof result as one blob. Save the layout of the RUN, as a second file. Both files together draw the result in a new process, with no geometry and no network.

Build the layout of the run with the schedule that the run returned. The schedule holds the job split and the tile frames of the run, so the layout has the same surfaces and cells as the result. A layout that you build from the buildings alone does not match a server result: the run analyses only the buildings of the polygon, and attach_values refuses the pair.

This example runs. It needs pip install "infrared-sdk[geodata]" for buildings.get_area. It bills one run (one job for each tile or batch).

# save.py
import os, time
from infrared_sdk import InfraredClient, SvfModelRequest
from infrared_sdk.analyses.types import AnalysesName
from infrared_sdk.facade_layout import synthesize_facade_layout

lon, lat, d = 16.371, 48.208, 0.0005          # a small site in Vienna
polygon = {"type": "Polygon", "coordinates": [[
    [lon - d, lat - d * 0.67], [lon + d, lat - d * 0.67], [lon + d, lat + d * 0.67],
    [lon - d, lat + d * 0.67], [lon - d, lat - d * 0.67]]]}
sdk = InfraredClient(api_key=os.environ["INFRARED_API_KEY"])
buildings = sdk.buildings.get_area(polygon)
payload = SvfModelRequest(analysis_type=AnalysesName.sky_view_factors,
                          analysis_surfaces="facades", surface_grid_size=2.0)

schedule = sdk.run_area(payload, polygon, buildings=buildings)
while sdk.check_area_state(schedule).status in ("pending", "running"):
    time.sleep(5)
columns = sdk.merge_area_jobs(schedule).columns

# The layout of THIS run: same buildings, same sensor settings, the run's schedule.
layout = synthesize_facade_layout(
    {key: mesh.model_dump() for key, mesh in buildings.buildings.items()},
    analysis_surfaces="facades", surface_grid_size=2.0, schedule=schedule)
open("layout.bin", "wb").write(layout.to_bytes())
open("values.bin", "wb").write(columns.to_bytes(layout_key=layout.layout_key))
# load.py -- a new process: no buildings, no client, no network.
from infrared_sdk.analyses.surface_columns import SurfaceColumns
from infrared_sdk.facade_layout import FacadeLayout, attach_values

layout = FacadeLayout.from_bytes(open("layout.bin", "rb").read())
columns = SurfaceColumns.from_bytes(open("values.bin", "rb").read())
attach_values(layout, columns, expected_layout_key=columns.layout_key)  # refuses a mismatch
buffers = columns.render_buffers(layout=layout)

Checked on 1.0.0rc1 against the production API (one small Vienna site, 9,117 surfaces, 387,167 cells): the values of load.py are bit-identical to the live values.

Both SDKs use the same blob format; a blob saved by one loads in the other. For the same schedule the layout bytes are identical. The TypeScript values blob also stores the legend and aggregates; the Python one leaves them empty.

  • pickle and model_dump do not keep the compact columns. The blob is the way to save a result.
  • The blob holds the values in the server's type, the surface ids, the cell placement and the per-surface numbers. It holds no outline: render buffers after a reload need the saved layout.
  • The Python blob has no legend and no aggregates. They are not Python columns.

Daylight-factor results as columns

client.analyses.run_and_wait returns a DaylightFactorResult for a daylight-factor request. It reads like the JSON result (result["floors"], built on the first key read) and gives every sensor as numpy columns (read-only views of one buffer, no copy). The parts come back as binary frames when the server offers the daylight-points family, else as JSON; the result is the same.

result = client.analyses.run_and_wait(payload)
cols = result.columns
rows = result.group_slice("0")                 # floor "0"
xyz = result.positions()[rows]                 # (n, 3) float64, a copy
df = cols.values[rows]                         # float32; to_json() has the exact numbers
room = [None if c == 0xFFFF else result.rooms[c]["id"] for c in cols.room[rows]]
plain = result.to_json()                       # the plain dict, as with result_format="json"

Bring your own geometry: what each input must be

The wire shapes are the DotbimMeshPayload, FlatMesh and VegetationInstances JSON schemas (the public/schemas/json/ folder of the infrared-core repository; this package does not contain it). This section gives the MEANING that a schema cannot state. The SDK refuses some malformed input before it sends a request (a mesh without indices, an unknown ground layer, a malformed terrain sheet). It does not check or repair the solid rules below: if a mesh breaks one of them, the run is billed and the result is wrong, often with no error.

Input Shape Frame and units
polygon One GeoJSON Polygon, one ring, no holes WGS84 [lon, lat]
buildings {building_id: {"coordinates": [x, y, z, ...], "indices": [i, j, k, ...]}} Local metres, Z up, origin at the SOUTH-WEST corner of the polygon's bounding box
vegetation {tree_id: GeoJSON Feature}, Point geometry WGS84 [lon, lat]; height and crownDiameter in metres
ground_materials {layer: FeatureCollection}, layer one of asphalt, concrete, soil, vegetation, water WGS84 [lon, lat]
ground_geometry (payload) {sheet_id: {"coordinates", "indices"}}, a triangle mesh Same local frame as buildings
context_geometry (payload) {mesh_id: {"coordinates", "indices"}}, distant occluders only, LOW-POLY Same local frame as buildings

Context geometry is for distant shading, and it must be low-poly. The SDK does not cut a context_geometry mesh. Each mesh goes WHOLE into every tile job whose context box (the 512 m tile plus a 128 m border) it touches. One mesh that covers the whole site therefore goes whole into every tile job. Every triangle costs upload bytes and SDK memory once PER TILE that carries it. On the Hong Kong Mid-Levels fixture, one 174,000-triangle context mesh went into all 4 tile jobs. As a guide, keep context_geometry below about 50,000 triangles for the whole site: use simple blocks for far-away buildings and hills. Put the buildings that you analyse, and their near neighbours, in buildings, because that layer is cut per tile. The SDK logs a warning when context_geometry holds more than 50,000 triangles.

One building is one closed solid. Send one mesh per building id. The mesh is a closed triangle shell that includes its BOTTOM face, with outward winding. These rules come from the workers:

  • The worker masks a ground sensor only when an upward ray from below the ground hits a face at ground level. A shell with no bottom face, or a solid that floats above the ground, does not mask the sensors inside it.
  • The advanced thermal-comfort wall model reads the normal from the raw winding. An inward-wound building gets wrong wall temperatures. Facade and roof sensors (analysis_surfaces) re-orient each shell themselves, so only that path accepts any winding.
  • The wind models read a top-down height map. An open panel (a canopy, a bridge) becomes a solid block down to the ground.
  • Loose wall panels and open surfaces are not a building: they do not mask the ground and they have no inside. Floor-by-floor stacks work, but they add interior faces and triangles. Merge each building into one solid before you send it.

Send roughly correct meshes. The SDK makes small, exact repairs. It does not fill holes, it does not weld vertices that are only NEAR each other, and it does not guess an inside that a mesh does not have. A broken mesh gives a wrong result.

Mesh input: the SDK cleans each building. Before it grids facades and roofs, the SDK cleans every building (every map entry) on its own:

  1. vertices at bit-identical positions share one index (-0.0 equals +0.0, no tolerance, so two separate objects that touch never fuse);
  2. degenerate triangles (a repeated corner, zero area) and exact duplicates are dropped;
  3. the winding is made consistent across shared edges, and the direction of the MAJORITY of the drawn area wins;
  4. a CLOSED building (every edge shared by exactly two triangles) is turned outward. An OPEN mesh keeps the direction it was drawn in: the SDK never guesses an inside it does not have.

So a Rhino or Grasshopper export with three vertices per triangle gives the same facade sensors, normals and surfaces as the same mesh welded, and a wall faces the way its author drew it wherever it sits in the site. Draw open walls facing outward. An open mesh drawn facing inward, with no closed or coplanar neighbour, stays inward.

Before this change, an open south- or west-facing wall pointed INTO its building. Facade results on south and west walls therefore differ from 0.5.3; the new values are correct.

Wall grids are level. A wall's sensor grid runs horizontal along the wall and vertical up the wall, whatever the shape of the wall piece. A roof's grid still follows the roof's minimum-area rectangle. Before, a wall piece cut along a sloped line (a triangulated LOD2 mesh, a wall on a hill) could get a tilted grid. Wall sensor positions and counts therefore differ from 0.5.3 (SURFGRID_VERSION 4).

To switch the cleaning off for a run, set mesh_cleaning="off" on the payload ("auto" is the default). Meshes are then used as drawn; a closed building is still turned outward. The setting is part of config_hash(), so a retry never mixes the two.

To see what the cleaning does, or to make an upload smaller, run the same core step yourself:

from infrared_sdk.geometry import clean_mesh

mesh = clean_mesh(coordinates, indices)     # numpy in and out, one mesh
print(mesh.report)                          # welded, removed, flipped, open/closed parts
buildings[bid] = {"coordinates": mesh.coordinates.tolist(), "indices": mesh.indices.tolist()}

A facade retry across a sensor-count change is refused. The mesh cleaning and the level wall grids change how many sensors a wall gets. This SDK plans facade batches under sensor-count contract 3. A facade retry_from of a schedule planned under an older contract (by 0.5.3 or by an earlier test build) raises ValueError, which names the change. The finished tiles of that schedule stay readable; a fresh run analyses (and bills) the whole area again.

Ids. A building id is a map key: unique in the run, and the same between a run and its retry_from, because the retry guard fingerprints the map. Facade results are keyed by these ids.

Heights. Without terrain, the ground is z = 0, and z is height above the ground. With terrain in ground_geometry, terrain_alignment selects how the solids meet it ("as-is", the default for supplied scenes; "auto-align"; "assume-aligned"). The wind models take no terrain; their terrain_alignment is "as-is" (default since 0.9.7) or "to-ground", see Supplied scenes on terrain. Terrain is a mesh only. The SDK has no DEM input: triangulate a DEM before you send it.

Trees. genus selects the crown archetype (an unknown genus is broadleaf deciduous). height and crownDiameter are optional; without them the tree gets a default size. See Leaf-off vegetation.

Coordinates. Keep every local coordinate below 100,000 m in magnitude. Do not send UTM or other absolute coordinates.

Your own sensor points. Instead of analysis_surfaces, a payload can carry sensor_points (and optionally sensor_normals). One job takes up to 300,000 sensors, the same budget as synthesized facade sensors, on both transports. A payload with 300,001 or more sensors is refused when you build it (a pydantic ValidationError, which is a ValueError), before any upload, so nothing is billed. The sensors travel in the uploaded geometry document, not in the request body, so the request size does not limit them.

On the binary transport the sensors are packed: positions as 16-bit steps of the job's sensor extent (error at most 3.9 mm on a 512 m tile, never above 1.5 cm) and normals in octahedral form (error below 0.01 degrees). The JSON transport keeps the exact values. The sensor order does not change. See

Limits (server). 5,000,000 triangles for one tile's scene (buildings, trees, terrain and context together); 500,000 terrain triangles; 64 MiB for one request. Above a limit the server refuses the job (HTTP 422 or 413).

buildings.get_area returns this shape, so it is the reference for your own input.

Overture release pinning

Ground materials and buildings report the Overture release they read, and accept a pin. The public index pointer moves daily, so the same polygon can otherwise return different layers on two days with no signal. The value is None when no Overture file was read — a buildings read inside a city overlay (Vienna) comes from the city FGB, and only the overlay's own version applies:

ground = sdk.ground_materials.get_area(polygon)
ground.overture_release          # e.g. "2026-08-19.0"
later = sdk.ground_materials.get_area(
    polygon, overture_release=ground.overture_release
)

Leaf-off vegetation

A deciduous tree loses its leaves for part of the year. A bare crown lets much more direct sun through than a leafy one, so the SDK gives each deciduous tree a leaf-off canopy transmissivity and applies it for the months when the tree is bare. The SDK does not ask for a season: it decides leaf-on or leaf-off per sun hour, from that sun hour's month and the site latitude.

When a tree is leaf-off. North of 23.5 deg N: November through March. South of 23.5 deg S: May through September. In the tropics (within 23.5 deg of the equator) a tree is never leaf-off — the model keeps it leaf-on all year, because the tropics have no reliable temperature-driven leaf-off season.

Which analyses this changes. daylight-availability, direct-sun-hours and solar-radiation apply the leaf-off transmissivity in a leaf-off month. sky-view-factors always uses the leaf-on value; it takes no month input and does not change with season.

Transmissivity values. A leaf-off deciduous tree (the broadleaf and columnar archetypes) lets 0.45 of the direct beam through. An evergreen tree (conifer, palm) keeps its leaf-on transmissivity all year (conifer 0.03, palm 0.30) and ignores the leaf-off season entirely.

Genus to archetype. The SDK resolves a tree's genus to one of four crown archetypes. A tree with an unrecognized genus is treated as broadleaf deciduous, the SDK's default archetype. These genera resolve to the evergreen conifer archetype and never go leaf-off: abies, buxus, cedrus, chamaecyparis, cryptomeria, cupressus, ilex, juniperus, picea, pinus, platycladus, pseudotsuga, sequoia, sequoiadendron, taxus, thuja, tsuga. The deciduous conifers larix (larch) and metasequoia (dawn redwood) drop their needles in winter: they resolve to the deciduous columnar archetype and go leaf-off. The palm archetype (also evergreen, never leaf-off) covers arecaceae, butia, chamaerops, cocos, phoenix, roystonea, sabal, syagrus, trachycarpus, washingtonia.

Per-tree override. Set transmissivity-leaf-off on one tree's GeoJSON properties, a number in [0, 1], to replace the archetype default for that tree only:

trees = sdk.vegetation.get_area(polygon)
for feature in trees.features:
    if feature["properties"].get("genus") == "tilia":
        feature["properties"]["transmissivity-leaf-off"] = 0.6

Weather

WeatherServiceClient reads weather stations from a public, pre-built catalog on geo.infrared.city; no API key is sent to it.

Product gap. No SDK payload declares a solar_model selector, so an interior energy-balance (irradiance) run cannot be requested from this SDK today.

stations = sdk.weather.get_weather_file_from_location(lat=48.21, lon=16.37)
uuid = stations[0]["uuid"]          # catalog rows are dicts: uuid, fileName, location_data
station = sdk.weather.get_weather_file_from_identifier(identifier=uuid)
rows = sdk.weather.filter_weather_data(identifier=uuid, time_period=period)

static_base_url selects the catalog host. Private and custom-EPW weather files are NOT in the public catalog and are no longer looked up by id: an identifier the catalog does not carry raises WeatherServiceError telling you to bring the EPW file itself (BYO weather, below). No other station is ever substituted. Catalog design and known differences (the distance constant, identifier forms): docs/plans/2026-09-06-weather-static-catalog.md and

Inputs beyond weather

Not every required field comes from a weather source. Two examples the SDK does not fill in for you:

pedestrian-wind-comfort needs a wind rose: criteria plus paired wind_speed and wind_direction lists, set directly on the payload, never through a weather file:

from infrared_sdk.analyses.types import AnalysesName, PwcCriteria, PwcModelRequest

payload = PwcModelRequest(
    analysis_type=AnalysesName.pedestrian_wind_comfort,
    criteria=PwcCriteria.lawson_2001,
    wind_speed=[5.0] * 12,
    wind_direction=[180.0] * 12,
)
schedule = sdk.run_area(payload, polygon)

direct-sun-hours and daylight-availability read no weather array, but each payload still needs a time_period and a location. Omit either and run_area refuses the payload before it submits anything:

from infrared_sdk.analyses.types import AnalysesName, SolarModelRequest
from infrared_sdk.models import TimePeriod

payload = SolarModelRequest(
    analysis_type=AnalysesName.direct_sun_hours,
    latitude=48.21,
    longitude=16.37,
    time_period=TimePeriod(start_month=6, start_day=21, start_hour=8,
                           end_month=6, end_day=21, end_hour=18),
)
schedule = sdk.run_area(payload, polygon)

Bring your own weather file

Any .epw file works, from anywhere. There is no registration, no upload and no private weather database: the file is read here, validated by the bundled code, and only the arrays the model reads are sent with the analysis.

from infrared_sdk import parse_epw
from infrared_sdk.analyses.types import AnalysesName, UtciModelBaseRequest, UtciModelRequest
from infrared_sdk.models import Location, TimePeriod

weather = parse_epw("vienna.epw")          # a path, or the file's text/bytes
payload = UtciModelRequest.from_weatherfile_payload(
    payload=UtciModelBaseRequest(analysis_type=AnalysesName.thermal_comfort_index),
    location=Location(latitude=48.21, longitude=16.37),
    time_period=TimePeriod(start_month=7, start_day=15, start_hour=12,
                           end_month=7, end_day=15, end_hour=16),
    weather_data=weather,
)
schedule = sdk.run_area(payload, polygon)

parse_epw raises EpwParseError for a file the SDK refuses — no LOCATION header, sub-hourly data, a row shorter than 22 columns, an hour outside 1-24 — and the message names the row or the field. from_weatherfile_payload raises WeatherModelInputsError when the file cannot serve the model for that window: a gap in a required column INSIDE the window, or a window shorter than a year for a model that needs one. Both happen before any request is submitted, so before anything is billed. A gap is never filled in: a substituted reading would change a billed result.

sdk.weather.parse_epw(path) is the same call on the client, and sdk.weather.filter_weather_data(weather=document, time_period=period) returns the file's hours the way a public station's are returned, with no network call at all.

Checking a file against a model, before planning a run. weather.model_inputs(analysis_type=..., time_period=...) returns the arrays that model reads and raises if the file cannot serve it.

solar_model belongs to analysis_type="energy-balance", the one analysis whose worker reads it; on any other analysis it raises rather than being ignored. solar_model="irradiance" checks the file against the interior-irradiance required set and its full-year rule. To RUN energy-balance with a file, use EnergyBalanceModelRequest.from_weather (see Interior energy balance).

Weather identity, and the retry. weather.identity is a sha256: digest of the validated readings, the location, the calendar columns and the hour basis — never the file name, the raw text or a station id, so two differently formatted files with the same readings share one identity. run_area records it on the AreaSchedule, and a resume must match it:

import time

schedule = sdk.run_area(payload, polygon)                    # records the identity
retry = sdk.run_area(payload, polygon, retry_from=schedule)  # same file: admitted
# `run_area` submits and returns; poll and merge yourself (`run_area_and_wait`
# has no `retry_from`):
while sdk.check_area_state(retry).status in ("pending", "running"):
    time.sleep(10)
result = sdk.merge_area_jobs(retry)                          # finished tiles carried, failed ones re-run

A resume built from a DIFFERENT weather file raises WeatherIdentityError before any request. The server's config_hash covers the window and not the readings, so without this a retry with changed weather would resubmit the failed tiles against one climate while carrying the succeeded tiles forward against another — one grid, two climates, no error. A schedule written by an SDK before 0.10, or one whose run used weather that could not be proved, is refused with the same error and told to start a fresh run; the SDK never assigns the current weather to old jobs and never starts a billed run for you.

The identity describes the values the run SUBMITS, plus the payload latitude, longitude and window. The lists inside a payload are mutable, and the thermal payloads accept whole-field reassignment too, so editing one after you build it — payload.dry_bulb_temperature[0] = -40.0, or payload.latitude = 40.0 — changes what is sent and what the sun does while config_hash stands still. run_area recomputes the identity before it submits and refuses the run before any request. Build the payload again with from_weatherfile_payload instead of editing it.

One run_area call with several payloads records one identity per payload: each returned schedule carries its own. A payload never inherits a sibling's.

Every weather source can be resumed. A run built from the public catalog's records, or from arrays you wrote yourself, carries an identity exactly as a bring-your-own-file run does, so retry_from works for all three. The identity names what the run submits, so it does not have to name a file.

payload_run_identity(payload) answers the identity run_area would record, without submitting anything. It is read-only, and it refuses a payload that carries no location, no window or no weather column.

WeatherDocument.identity still names the FILE and is still there as provenance — "which file is this?" — but it is not what a schedule records, and it is not the value payload.weather_identity returns. The two are computed over different preimages under different version tags; never compare one with the other.

Time windows. A TimePeriod is a date span with a daily hour range — the window the model counts. 1 March 08:00 → 30 September 18:00 selects hours 08-18 of every day from 1 March to 30 September, 31 March included. The hours are EPW local standard time: a window that crosses a DST change gains or loses no hour. In older versions a multi-month window was a product mask (days 1-30 of every month), which dropped days such as 31 October and made the model refuse the run. A single-hour window (start == end) is valid and selects one hour.

Malformed values are refused — a month outside 1-12, an hour outside 0-23, a day outside its month. A window may cross the year end (for example 1 December → 28 February): it selects December, January and February of the typical year, in calendar order of the year (January first). See

Interior daylight factor

daylight-factor calculates the daylight factor (DF, in percent) at sensor points inside a building, under a CIE standard overcast sky. You supply the walls and slabs (barriers), the windows (openings) and the sensors (sensor_points, sensor_surfaces, floors or buildings). Put neighbouring buildings in context_geometry: without them the rooms read too bright.

The model is not an area model: run_area() refuses it. Run it with client.analyses.run_and_wait(request), which returns the result dict. client.analyses.execute(request) still sends ONE job and returns its Job handle (it warns when run_and_wait would split the request).

Large requests: automatic floor parts

run_and_wait splits a large request on the floors tier into parts of WHOLE floors by itself. The parts run as separate jobs, in parallel, and the SDK joins their results into the result one request gives, byte for byte: the same keys, the same floor order, the same warnings in the same order. A floor above the part size is split inside the floor and joined exactly You call nothing special to get the speed.

  • When it splits. The SDK counts the exact sensors of each floor, as the worker makes them, and packs whole floors into parts of about 100 000 sensors. A request at or below that is ONE part and goes out as the same bytes execute() sends. sensor_points, sensor_surfaces, buildings and a single floor are always one part. Only the JSON transport splits.

  • Billing is per part. Each part is one job and is billed as one job, so a request in 3 parts costs 3 x the job price. preview_parts tells you before anything is sent:

    preview = client.analyses.preview_parts(request)
    print(preview.part_count, preview.estimated_cost_tokens, preview.total_sensors)
    

    max_parts=1 keeps one job (slower, one charge). max_parts=N is a spending limit: a plan of more than N parts is refused with a ValueError before anything is sent. Every option is checked before the first POST.

  • All or nothing. When a part fails, no result is returned: PartsRunError names each missing part (failures, failed_parts, download_failed_parts). Resend only the failed parts, keeping the finished ones:

    try:
        result = client.analyses.run_and_wait(request)
    except PartsRunError as exc:
        result = client.analyses.run_and_wait(request, retry_from=exc.schedule)
    

    A part whose submit outcome is unknown (the server may hold a job for it) is never resubmitted by the SDK. Any error after the submit (a timeout too) carries the run as exc.schedule: finish it with client.analyses.merge_parts(exc.schedule), never with a new, billed run. On a one-job run the error carries the accepted job as exc.job_id; fetch its result with client.jobs.

  • Without waiting. run_parts(request) submits the parts and returns a PartsSchedule (picklable); check_parts_state(schedule) polls it and merge_parts(schedule) joins it.

  • Warnings. The SDK refuses nothing for its content. The SDK's notes on the request (for example a grid-size of 0) and the result's warnings are shown as AnalysisPartsWarning; the result keeps its warnings key. A request the SDK cannot plan goes out as one job (with a logged warning), and the server answers it.

  • Transport. Every part carries the whole scene inline on the JSON route (about 2 MB per part for a 10-storey office). Uploading the scene once per request comes later, with the binary route.

Rooms: per-room output

Add spatial_volumes (wire key spatialVolumes) to get results per room. It takes the same "space" volumes as energy balance: one closed volume per room. With rooms, the server puts each sensor in its room and calculates the internal reflection of each closed room from its own walls and windows.

Field (wire key) What to put in it Default
spatial_volumes (spatialVolumes) One closed volume per room, category "space" none: one internal reflection per floor
glazing_transmittance (glazing-transmittance) Light transmittance of a window that has no openingFactor 0.63 (0.7 x maintenance 0.9)

A window with its own openingFactor keeps that value. The SDK sends both fields as you give them. The server clamps a glazing_transmittance outside [0, 1], ignores malformed volumes, and tells you in warnings. Read warnings on every rooms run.

Cookbook: daylight per room. One line adds the rooms; the rest is the usual request:

from infrared_sdk import AnalysesName, DaylightFactorModelRequest, interior_entities

request = DaylightFactorModelRequest(
    analysis_type=AnalysesName.daylight_factor,
    barriers=interior_entities(walls_and_slabs),
    openings=interior_entities(windows, category="window"),
    sensor_points=points,
    spatial_volumes=interior_entities(rooms, category="space"),  # per-room output
    glazing_transmittance=0.6,  # optional: windows with no openingFactor
)
result = client.analyses.run_and_wait(request)

With rooms, the result adds (the shape without rooms does not change):

  • per sensor: room (the spatialVolumes key, or null for a sensor in no room; no sensor is dropped);
  • per floor: rooms, a map {room id: {name, sensors, mean-df, median-df, min-df, max-df, area-pct-df-ge-2, window-area, floor-area, irc}}, plus mean-df (sensors in a room) and mean-df-all (all sensors);
  • warnings (a list of strings, only when it is not empty): rooms with no window or no closed mesh, sensors in no room, doors and voids, ignored volumes. It sits next to floors / surfaces / buildings, or next to output on a single floor or sensor_points run.

window_area, when you set it, overrides the window area of EVERY room. The full field map is lambda-models docs/rust-models-io-map.md (daylight-factor).

Interior energy balance

energy-balance calculates the monthly heating and cooling energy NEED of each zone in a building (ISO 13790 monthly method). You supply the zones, the walls and slabs, the windows and one year of weather. The model returns results per zone and per month, and an annual value per m² of floor area.

Read these three rules before you use the result:

  1. Send the whole building in ONE request. A wall or slab that touches two zones of the request is internal, and no heat flows through it. A wall or slab that touches one zone is external. If you send one storey alone, its floor and ceiling become external, and the zone loses heat through slabs that have heated rooms on the other side. The result then depends on how you split the building.
  2. The result is energy NEED, not delivered energy. EUI_heat and EUI_cool are in kWh/m²·yr (ISO Q_nd). The model applies no boiler, heat-pump or chiller efficiency. To get delivered or primary energy, apply your own system efficiencies.
  3. The model is screening grade. Use it to compare design variants of the same building, not to certify a building. The validation record against EN 15265 is in lambda-models docs/validation/energy-balance.

The model is execute-only. Submit it with client.analyses.execute(). run_area() refuses it, because it takes zones, not an area.

What goes in which field

Field (wire key) What to put in it Category
spatial_volumes (spatialVolumes) One closed volume per zone (room or space) "space"
barriers Walls, and ALL horizontal slabs (ground floor, intermediate floors, roof) "wall" or "floor"
openings Windows "window" (a "door" is accepted, but the model ignores doors)
context_geometry Neighbouring buildings that shade the windows any
ground_geometry Terrain (flat {coordinates, indices} meshes) —

Use to_interior_entity(mesh, category=...) to wrap each mesh. It sets the nested entity shape and an identity position/rotation. The server reads position and rotation strictly on zones and windows, and a missing one fails the (charged) job.

The worker uses only what it recognises. It skips a zone that is not "space", a barrier that is not "wall" or "floor" (use "floor" for every slab, the roof too: the U-value comes from the height of the slab in its zone), and an opening that is not a "window". It ignores vegetation. It clamps or defaults an out-of-range energy_settings value, and it echoes the settings it applied in result["settings"]. The SDK sends your input as you give it. Check your categories and the echoed settings.

The SDK refuses a request before it sends it only when the server would fail the job after it charges it:

  • no spatialVolumes entry with category "space" (422);
  • a "space" or "window" without geometry.position / geometry.rotation (500);
  • climate series shorter than 12 values or of different lengths, incomplete irradiance inputs, a series that is not one full year, a year that does not agree with the series length, a malformed operation (422);
  • a non-identity global_transform with solar_model="irradiance" (422).

Cookbook: an older building from an Archicad export

This example sends a two-storey building that you exported from Archicad (or any IFC tool). Each Archicad Zone becomes one spatialVolumes entry. Each wall and slab becomes one barriers entry. Each window becomes one openings entry. The weather comes from an EPW file.

from infrared_sdk import (
    AnalysesName,
    EnergyBalanceModelRequest,
    EnergySettings,
    InfraredClient,
    Operation,
    interior_entities,
    parse_epw,
    to_interior_entity,
)
from infrared_sdk.analyses.jobs import JobsServiceClient

# Meshes from your export: {id: {"coordinates": [...], "indices": [...]}},
# in metres, in one world frame. One map per element class.
zones, walls, slabs, windows = load_my_export("school-1965.ifc")

spatial_volumes = interior_entities(zones, category="space")
barriers = {
    **interior_entities(walls, category="wall"),
    **interior_entities(slabs, category="floor"),  # ground floor, floors, roof
}
openings = interior_entities(windows, category="window")

# An older building: solid brick walls, an uninsulated roof, old double
# glazing, heavy construction, draughty.
settings = EnergySettings(
    u_values={"ext_wall": 1.4, "flat_roof": 1.0, "ground_floor": 0.9},
    glazing={"u_value": 2.8, "shgc": 0.75, "frame_fraction": 0.3,
             "frame_u_value": 2.2},
    occupancy={"heating_setpoint": 20.0, "cooling_setpoint": 26.0},
    ach_infiltration=0.4,
    construction_class="heavy",
    ground_level_z=0.0,
)

weather = parse_epw("vienna.epw")
request = EnergyBalanceModelRequest.from_weather(
    weather,                    # latitude/longitude come from the EPW header
    year=2021,                  # a non-leap year for an 8760-hour file
    spatial_volumes=spatial_volumes,
    barriers=barriers,
    openings=openings,
    context_geometry=interior_entities(neighbours),  # optional: shading
    energy_settings=settings,
)

client = InfraredClient(api_key="...")
job = client.analyses.execute(payload=request)
client.jobs.wait_for_completion(job.job_id, timeout=900)
result = JobsServiceClient.decompress(
    client.jobs.download_results(job.job_id).content
)
for zone in result["output"]:
    print(zone["zone_name"], zone["annual"]["EUI_heat"], zone["annual"]["EUI_cool"])

Intermittent operation. The example above conditions the building 24 hours a day, 7 days a week. A school or an office runs its plant only in use hours. Add an operation (ISO 13790 Clause 13.2.2):

office_hours = EnergyBalanceModelRequest.from_weather(
    weather,
    year=2021,
    spatial_volumes=spatial_volumes,
    barriers=barriers,
    openings=openings,
    energy_settings=settings,
    operation=Operation.intermittent(hours=(7, 17), days="weekdays"),
)

hours is [start, end] in local legal time, with 0 <= start < end <= 24 (the notation of lambda-models docs/rust-models-io-map.md). The model uses only the number of hours, end - start: [7, 17] is 10 hours a day, the same as a half-open [7, 17) window. days is "weekdays" (5 of 7) or "all" (7 of 7). With days="all" there is no cooling reduction, because the cooling factor counts days, not hours. With an intermittent schedule, give the internal gains and ach_natural as continuous-mode averages: do not average them over the off-days, because the reduction factor already removes those days.

Solar model

solar_model="irradiance" (opt-in; the default is the server's "legacy-flat") calculates the irradiance per window orientation, with shading from barriers, context_geometry and ground_geometry. It needs latitude, longitude and one full hourly year (8760 or 8784 values) of four series: dry-bulb temperature, global horizontal, direct normal and diffuse horizontal radiation. from_weather(parse_epw(...)) supplies all of them. For an 8784-hour (leap year) file, set a leap year.

solar_model="legacy-flat" is the older flat orientation factor. It reads only the temperature and global horizontal series, and it accepts a monthly (12-value) series. Do not compare results across the two models: the response field solar-model tells you which model made the numbers.

What comes back

Per zone (result["output"], sorted by EUI_heat, highest first):

  • zone_name, zone_uuid (the spatialVolumes key), H_total_W_K (the heat loss coefficient) and the ground terms.
  • annual.EUI_heat, annual.EUI_cool — kWh/m²·yr of floor area.
  • monthly (12 entries): Q_heat_kWh, Q_cool_kWh, Q_sol_kWh, Q_sol_opaque_kWh, F_sh_ob (shading factor, 1.0 = nothing in the way) and F_sh_ob_iso. Q_sol_kWh is the solar gain on the WHOLE envelope (ISO 13790 Eq. 44). It can be negative in a dark month, because the envelope radiates heat to the sky. Q_sol_kWh − Q_sol_opaque_kWh is the glazing share (±0.1 kWh rounding).
  • in monthly, intermittent runs only: a_red_H, a_red_C (the reduction factors that the model applied).

At the top level: settings (the settings the model applied, with defaults filled in), solar-model, shading-sampling, T_monthly, G_monthly. The full field map is lambda-models docs/rust-models-io-map.md (energy-balance).

Grid images

gen_grid_image renders in-process, with no image round trip:

png = sdk.weather.gen_grid_image(
    grid=results_grid,                       # result.physical_grid(): numbers, wind classes, or None
    analysis_type="pedestrian-wind-comfort",
    criteria="lawson-lddc",
)

The colours are the same registry visualConfigurations the retired route used, fetched from the public mirror registry.infrared.city — no API key, cached in the process. The image is 1:1: one pixel per grid cell up to a 960 px long axis — twice the route's ~480 px long axis (four times the pixels). A local render is therefore 1:1 to 960 px and exact at any size, and max_long_axis_px=None gives every cell — so the "render locally for full-resolution overlays" workaround is a one-argument choice rather than a workaround.

Size. max_long_axis_px moves that cap (default 960; None or 0 renders every cell — a district grid is then 4187 x 2744 px). Above the cap the grid is sampled nearest-neighbour on the VALUES before it is coloured, so the aspect ratio holds and a no-data cell stays no-data — no blended value appears between two classes. Read what you got, and what it is per cell:

from infrared_sdk.layers.image_local import grid_image_size

size = grid_image_size(png, len(results_grid[0]), len(results_grid))
size.width, size.height, size.scale     # e.g. 960, 629, 0.229

grid_image_size and its return type GridImageSize, plus the default DEFAULT_MAX_LONG_AXIS_PX, are exported from infrared_sdk.layers.image_local.

Output pixel (x, y) reads grid cell (floor((x + 0.5) * grid_width / size.width), floor((y + 0.5) * grid_height / size.height)) — the nearest-neighbour rule — so an overlay stays aligned at any cap.

Orientation. The bitmap is south-up, as the retired route rendered it: image row 0 is the SOUTHERNMOST grid row (AreaResult.merged_grid row 0 is south, tiling/types.py). To place it as a north-up map overlay, flip the grid first — sdk.weather.gen_grid_image(grid=results_grid[::-1], ...) — and pair it with AreaResult.bounds. The TypeScript twin offers the same flip as the reverseRows option of renderGridPng; Python has no flag yet (follow-up).

Failures — an unreachable registry, a ragged grid, text in a numeric grid — raise WeatherServiceError rather than returning an image in fallback colours. Two things are NOT failures: an analysis_type the registry has no config for renders with magma_r, exactly as the route did; and a grid with no data at all renders fully transparent whenever a config resolves (only the magma_r fallback, which takes its range from the data, has no answer there).

Configuration

Every setting resolves the same way: constructor argument → environment variable → default.

Variable Sets Default
INFRARED_API_KEY Your API key (api_key=) — required
INFRARED_BASE_URL Gateway URL (base_url=) https://api.infrared.city/v2
INFRARED_APPLICATION Calling surface (application=) sdk
INFRARED_SDK Calling library + version (sdk_id=) infrared-sdk/<version>
INFRARED_QUIET Silences the one-time startup INFO log unset
INFRARED_SDK_DEBUG Verbose diagnostics unset
INFRARED_OVERTURE_TRANSPORT How Overture parquet is read: auto (S3, and HTTPS where pyarrow's S3 filesystem cannot load, as in Windows QGIS), s3 or https; read once per process, at the first Overture read auto

No .env file is loaded by the SDK itself — it is a library, imported into QGIS, Rhino, notebooks and Lambda, so it never reads files the host did not ask for. Export the variables, or pass the arguments.

Identifying your application

Two headers tell Infrared which client made a call: x-infrared-application (the surface) and x-infrared-sdk (the library and its version). Left alone they say sdk and infrared-sdk/<version>.

If you are embedding this SDK — a plugin, a connector, another SDK — set both, or your traffic is reported as generic SDK traffic:

from infrared_sdk import InfraredClient

client = InfraredClient(
    api_key=...,
    application="qgis",                    # the surface: a fixed literal
    sdk_id=f"infrared-qgis/{plugin_version}",  # your library and ITS version
)

application is any value the gateway expects for your surface (qgis, arcgis, sketchup, …); it is not validated client-side, so a new surface needs no SDK release. sdk_id is your own name/version, assembled from whatever your host already uses as its version source:

# a QGIS plugin — the same metadata.txt QGIS itself reads
import configparser
from pathlib import Path

cfg = configparser.ConfigParser()
cfg.read(Path(__file__).parent / "metadata.txt", encoding="utf-8")
SDK_ID = f"infrared-qgis/{cfg.get('general', 'version')}"

# a pip-installed connector
from importlib.metadata import version
SDK_ID = f"infrared-arcgis/{version('infrared-arcgis')}"

This SDK's own token is appended, not replaced, so the wire carries infrared-qgis/1.1.2 infrared-sdk/1.0.0 — your client is identified without losing which SDK version ran, which is the first thing support asks.

For hosts that build the client through glue code and cannot pass arguments, export INFRARED_APPLICATION / INFRARED_SDK before the client is constructed; a constructor argument always wins over the environment.

Metadata

Release files for infrared-sdk 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for infrared-sdk 1.0.0
File Size Uploaded
infrared_sdk-1.0.0.tar.gz 2.0 MB Details

Built distributions (wheels)

Table of built distributions (wheels) for infrared-sdk 1.0.0
File
infrared_sdk-1.0.0-cp39-abi3-win_amd64.whl CPython 3.9 abi3 Windows x86-64 Details
infrared_sdk-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl CPython 3.9 abi3 Linux musl 1.2+ x86-64 Details
infrared_sdk-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl CPython 3.9 abi3 Linux musl 1.2+ ARM64 Details
infrared_sdk-1.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.9 abi3 Linux glibc 2.17+ x86-64 Details
infrared_sdk-1.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.9 abi3 Linux glibc 2.17+ ARM64 Details
infrared_sdk-1.0.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl CPython 3.9 abi3 macOS 10.12+ x86-64, macOS 11.0+ ARM64, macOS 10.12+ universal2 (ARM64, x86-64) Details

Total release size: 33.7 MB

Release files / infrared_sdk-1.0.0.tar.gz

Download URL infrared_sdk-1.0.0.tar.gz
Size 2.0 MB
Tags Source
SHA-256 checksum
How to use checksums
a178b892b27a3a3c76c9b0b031fb3ad3d8ded86b2d53807e10dcd89858c1db12
BLAKE2b-256 checksum
How to use checksums
102fc5250106b2104143c0b7cdcba84827a66c8dd06533fbb0ede05bcf8a599a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / infrared_sdk-1.0.0-cp39-abi3-win_amd64.whl

Download URL infrared_sdk-1.0.0-cp39-abi3-win_amd64.whl
Size 4.6 MB
Tags CPython 3.9 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
fa1dbcf03c30d7ef56ca4d848ec7395d299675eaacf899a2b9f3be8de6a93d15
BLAKE2b-256 checksum
How to use checksums
b9a3c55e34638aa0394515c2cb9ac8a1244c0e2fc88c1665ea401269861a51dd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / infrared_sdk-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl

Download URL infrared_sdk-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl
Size 5.0 MB
Tags CPython 3.9 Linux musl 1.2+ x86-64 abi3
SHA-256 checksum
How to use checksums
45a2c3d060bdd17896527be46d0ae22094474860070eb8891ca8ae94773509cf
BLAKE2b-256 checksum
How to use checksums
94e15151af112d27d7ad052c0af7fbfa632e3be67d8530a0dce82fb2ba823126
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / infrared_sdk-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl

Download URL infrared_sdk-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl
Size 4.6 MB
Tags CPython 3.9 Linux musl 1.2+ ARM64 abi3
SHA-256 checksum
How to use checksums
512fe06162642b469ed3bd9f336164d820e296054f49b79ff28d427ec696c77f
BLAKE2b-256 checksum
How to use checksums
ba8384fecbd796dda8627d1deec40417f13325388a312f61f9bad4b1ca48735f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / infrared_sdk-1.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL infrared_sdk-1.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 4.8 MB
Tags CPython 3.9 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
02683c7460eafdd273f0f1cc6485df590b1e70ddaedf24aebc42f67ef1a69984
BLAKE2b-256 checksum
How to use checksums
31dad2c688f9ab401011def1b05d8338829c674354019d7debd013dedba6aec7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / infrared_sdk-1.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL infrared_sdk-1.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 4.4 MB
Tags CPython 3.9 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
6d13e687c216c370000f8e8bc37d8d3b6d612758a03dbb5e411927cafe482d1e
BLAKE2b-256 checksum
How to use checksums
88e095aad004ed2459e338b6601f4d158a4638018fa28000cb2c05bc1faf98dc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release files / infrared_sdk-1.0.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl

Download URL infrared_sdk-1.0.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl
Size 8.3 MB
Tags CPython 3.9 abi3 macOS 10.12+ universal2 (ARM64, x86-64) macOS 10.12+ x86-64 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
e42693ee2ea94d0836ac295652c45c0dcb59939ab2cd9febc99dd8660868ecf2
BLAKE2b-256 checksum
How to use checksums
daeb425f65776d0f6c18b37d341e015106689b16642e14a95a0f0400efaad71b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

7 release files

0.5.3

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.14

2 release files

0.4.13

2 release files

0.4.12

2 release files

0.4.11

2 release files

0.4.10

2 release files

0.4.9

2 release files

0.4.8

2 release files

0.4.7

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page