Metadata-Version: 2.4
Name: infrared-sdk
Version: 1.0.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering
Requires-Dist: requests>=2.0
Requires-Dist: requests<2.33 ; python_full_version < '3.10'
Requires-Dist: pydantic>=2.0
Requires-Dist: validators
Requires-Dist: numpy>=1.24
Requires-Dist: numpy<2.1 ; python_full_version < '3.10'
Requires-Dist: infrared-sdk[geodata] ; extra == 'direct'
Requires-Dist: orjson>=3.9,<4 ; extra == 'fast'
Requires-Dist: pyarrow>=14 ; extra == 'geodata'
Requires-Dist: shapely>=2.0 ; extra == 'geodata'
Provides-Extra: direct
Provides-Extra: fast
Provides-Extra: geodata
License-File: LICENSE
License-File: NOTICE
Summary: Python SDK for the Infrared City API
Author-email: "infrared.city" <support@infrared.city>
License-Expression: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://infrared.city/docs/sdk/
Project-URL: Homepage, https://infrared.city
Project-URL: Knowledge base, https://infrared.city/knowledge-base/
Project-URL: Notebooks & agent skills, https://github.com/Infrared-city/infrared-skills

## <img src="https://raw.githubusercontent.com/Infrared-city/infrared-skills/main/docs/assets/logo-teal.svg" width="200">

# Infrared Python SDK

Python SDK for the [Infrared City](https://infrared.city) simulation platform. Run urban microclimate analyses — wind, solar, thermal comfort — from a few lines of code.

**📚 [SDK Documentation](https://infrared.city/docs/sdk/)** &nbsp;·&nbsp; **🧪 [Notebooks + agent skills](https://github.com/Infrared-city/infrared-skills)** &nbsp;·&nbsp; **🌐 [infrared.city](https://infrared.city)** &nbsp;·&nbsp; **📊 [Knowledge base](https://infrared.city/knowledge-base/)**

[![PyPI version](https://img.shields.io/pypi/v/infrared-sdk)](https://pypi.org/project/infrared-sdk/)
[![Python](https://img.shields.io/pypi/pyversions/infrared-sdk)](https://pypi.org/project/infrared-sdk/)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-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](#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.

```bash
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:

```python
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:

```python
# 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:

```bash
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:

```python
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`):

```bash
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.

```text
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:

```python
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:

```python
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.

```python
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](#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:

```python
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](#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.

```python
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.

```python
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).

```python
# 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))
```

```python
# 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.

```python
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:

```python
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](#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](#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:

```python
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:

```python
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.

```python
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:

```python
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:

```python
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.

```python
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](#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:

```python
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:

  ```python
  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:

  ```python
  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:

```python
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`](https://github.com/Infrared-city/lambda-models/tree/staging/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.

```python
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):

```python
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:

```python
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:

```python
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:

```python
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:

```python
# 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.

