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
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_gridisfloat16,float32,int16(UTCI, withvalue_divisorand avalidbitmap) orfloat64.SurfaceColumns.valueskeeps the type in the same way. Useresult.physical_grid()andcolumns.physical_values()for physical values; test a surface cell withcolumns.has_value(cell). UTCI read without the divisor is 10 times too large. - The SDK merges area grids. The merge keeps no
float64canvas of every tile. merged_gridis read-only.grid += xraisesValueError. Useresult.physical_grid()(a new array) ormerged_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_trisis off by default insynthesize_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()andSurfaceColumns.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_submissionswith 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
nrequests waits at leastn / 2s. - 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_completionwaits 900 s by default;timeoutaccepts fractional seconds and must be greater than 0. A 401 or 404 raisesJobPollErrorat once. A wait that does not recover from 429/5xx/network errors raisesJobTimeoutErrorat its deadline. The area wait (area_timeout) stays 3600 s.backoff_cap(aJobsServiceClientconstructor argument) caps the healthy interval of a single-job wait only. It never shortens an error backoff or aRetry-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 whatget_areauses.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.
pickleandmodel_dumpdo 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:
- vertices at bit-identical positions share one index (
-0.0equals+0.0, no tolerance, so two separate objects that touch never fuse); - degenerate triangles (a repeated corner, zero area) and exact duplicates are dropped;
- the winding is made consistent across shared edges, and the direction of the MAJORITY of the drawn area wins;
- 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,buildingsand 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_partstells you before anything is sent:preview = client.analyses.preview_parts(request) print(preview.part_count, preview.estimated_cost_tokens, preview.total_sensors)
max_parts=1keeps one job (slower, one charge).max_parts=Nis a spending limit: a plan of more thanNparts is refused with aValueErrorbefore anything is sent. Every option is checked before the first POST. -
All or nothing. When a part fails, no result is returned:
PartsRunErrornames 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 withclient.analyses.merge_parts(exc.schedule), never with a new, billed run. On a one-job run the error carries the accepted job asexc.job_id; fetch its result withclient.jobs. -
Without waiting.
run_parts(request)submits the parts and returns aPartsSchedule(picklable);check_parts_state(schedule)polls it andmerge_parts(schedule)joins it. -
Warnings. The SDK refuses nothing for its content. The SDK's notes on the request (for example a
grid-sizeof 0) and the result'swarningsare shown asAnalysisPartsWarning; the result keeps itswarningskey. 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(thespatialVolumeskey, ornullfor 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}}, plusmean-df(sensors in a room) andmean-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 tofloors/surfaces/buildings, or next tooutputon a single floor orsensor_pointsrun.
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:
- 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.
- The result is energy NEED, not delivered energy.
EUI_heatandEUI_coolare in kWh/m²·yr (ISOQ_nd). The model applies no boiler, heat-pump or chiller efficiency. To get delivered or primary energy, apply your own system efficiencies. - 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
spatialVolumesentry with category"space"(422); - a
"space"or"window"withoutgeometry.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
yearthat does not agree with the series length, a malformedoperation(422); - a non-identity
global_transformwithsolar_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(thespatialVolumeskey),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) andF_sh_ob_iso.Q_sol_kWhis 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_kWhis 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)
| File | Size | Uploaded | |
|---|---|---|---|
| infrared_sdk-1.0.0.tar.gz | 2.0 MB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| 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 logRelease 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 logRelease 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 logRelease 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 logRelease 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 logRelease 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 logRelease 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