# Infrared City β€” full docs for LLMs > Plain-markdown bundle of Infrared City's developer documentation. The Python SDK reference is the recommended entry point for running urban microclimate analyses (wind, solar, thermal comfort, daylight, sky view factors) on the Infrared platform. Source of truth: https://infrared.city/docs/sdk/ Cookbook + agent skills: https://github.com/Infrared-city/infrared-skills ================================================================================ # Python SDK Reference ================================================================================ **πŸ§ͺ [Notebooks + agent skills](https://github.com/Infrared-city/infrared-skills)**  Β·  **πŸ“Š [Knowledge base](https://infrared.city/knowledge-base/)** Notebooks for all 9 analyses, agent skills (Claude Code / Cursor / Codex / Copilot / Windsurf), and runnable Python recipes live at [Infrared-city/infrared-skills](https://github.com/Infrared-city/infrared-skills). **Features:** - 9 analysis types: wind speed, pedestrian wind comfort, daylight availability, direct sun hours, sky view factors, solar radiation, thermal comfort (UTCI), thermal comfort statistics, and **interior daylight factor** - Interior daylight on explicit room geometry β€” walls, openings and floor slabs, per building / floor / surface / sensor point (see [Interior Daylight Factor](#interior-daylight-factor)) - Facade, roof & BYO-sensor analysis on building surfaces (solar-family models), plus terrain draping (solar-family + thermal comfort) - Area API for multi-tile polygon analysis with automatic tiling, merging, and clipping - Buildings API for 3D building data retrieval - Vegetation API for tree data retrieval - Ground Materials API for surface material layers - Weather data integration - Async job submission with webhook notifications and persistable schedules - Fully typed (PEP 561) ## Installation ```bash pip install infrared-sdk ``` Or with [uv](https://docs.astral.sh/uv/): ```bash uv add infrared-sdk ``` **Optional β€” faster tile decode.** Install the `[fast]` extra to enable `orjson`-backed JSON parsing (~2.2Γ— faster for large tile payloads): ```bash pip install infrared-sdk[fast] # or: uv add "infrared-sdk[fast]" ``` **Requirements:** Python 3.9+ (tested on 3.9 – 3.13; the 3.9 floor supports embedded environments like Rhino 8 / Grasshopper, Houdini 19, Maya 2024, QGIS LTS). Dependencies (`requests`, `pydantic`, `validators`, `numpy`) are installed automatically. `orjson` is an optional dependency installed only with `[fast]`. After installation the `infrared` console script is available on your PATH. Running it (or `python -m infrared_sdk`) prints a version banner β€” useful for confirming the install and active version: ```bash infrared # or python -m infrared_sdk ``` In non-TTY environments (CI, piped output) and when `NO_COLOR=1` or `INFRARED_QUIET=1` is set, the banner drops its ANSI colour codes but still prints. `INFRARED_QUIET=1` silences the runtime `[INFO]`/`[WARN]` diagnostics, not this banner β€” see Configuration. ## Quick Start ```python from infrared_sdk import InfraredClient from infrared_sdk.analyses.types import WindModelRequest, AnalysesName polygon = { "type": "Polygon", "coordinates": [[ [11.570, 48.195], [11.580, 48.195], [11.580, 48.201], [11.570, 48.201], [11.570, 48.195], ]], } # api_key and base_url fall back to INFRARED_API_KEY / INFRARED_BASE_URL env vars with InfraredClient() as client: # 1. Fetch buildings for the area area = client.buildings.get_area(polygon) # 2. Run a wind analysis over the polygon result = client.run_area_and_wait( WindModelRequest( analysis_type=AnalysesName.wind_speed, wind_speed=15, wind_direction=180, ), polygon, buildings=area.buildings, ) # 3. Result contains a merged grid covering the polygon print(f"Grid shape: {result.grid_shape}") ``` {% callout type="note" title="API key" %} To use the SDK you need an Infrared API key. Visit [infrared.city](https://infrared.city) to sign up or contact the Infrared team. {% /callout %} ## Before You Write Code Everything in this list returns a **plausible number with a `200`**, not an error. Nothing here raises, several are billed, and a reviewer reading your output cannot tell. | Assumption that is wrong | What you get instead of an error | | --- | --- | | *My polygon's CRS gets checked* | It does not. A projected or `[lat, lon]` polygon that still lands inside `[-180,180] Γ— [-90,90]` runs, on the wrong patch of the planet. β†’ [Coordinate Systems](#coordinate-systems) | | *Metres are metres* | There are two metre frames. `buildings` / `context_geometry` / `ground_geometry` passed to `run_area*` are **polygon-bbox-SW**; per-tile payloads and `sensor_points` are **tile-local**. Mixing them offsets the scene by a tile, silently. β†’ [Coordinate Systems](#coordinate-systems) | | *`min_legend` / `max_legend` give me a colour scale* | They are `None` on **every** area run, so the familiar `... if not None else np.nanmin(grid)` guard takes the fallback every time and auto-scales each plot to its own data. Populated on surface results only. β†’ [AreaResult](#arearesult) | | *A missing cell reads as 0* | Masked cells are `None` β€” map to `NaN` before any mean. Separately, a surface at exactly `0.0` is real data: party walls and light wells can be **32 %** of facades on a dense block. Two different things. β†’ [Facade, Roof & Terrain Analysis](#facade-roof--terrain-analysis) | | *Omitting terrain gives me flat ground on purpose* | It gives you a flat plane at `z = 0` and a result that looks entirely normal. There is no terrain client β€” `ground_geometry` is bring-your-own, every time. β†’ [Terrain draping](#building-the-terrain-mesh) | | *I can check `terrain_alignment` by comparing means* | The scene mean barely moves while individual surfaces are rewritten: **+0.03 kWh/mΒ²** mean while **44 %** of facades moved by more than 1, individual surfaces spanning **βˆ’48.8 to +85.6**. Compare distributions. β†’ [`terrain_alignment`](#terrain_alignment--how-your-geometry-meets-the-ground) | | *Distant hills still shade my site* | Not since 0.5.1 β€” terrain is sliced per tile. Pass relief you care about as `context_geometry`. β†’ [Facade, Roof & Terrain Analysis](#facade-roof--terrain-analysis) | | *One mesh shape for the whole payload* | Interior entities are **nested**; `ground_geometry` and `vegetation` are **flat**. The wrong shape is not a server error β€” the entity is skipped and you get a plausible field over an empty occluder. β†’ [Interior Daylight Factor](#interior-daylight-factor) | | *Extra interior selectors are ignored harmlessly* | The server dispatches on the **first key present** and drops the rest: a one-storey request billed as every storey of every building. β†’ [Interior Daylight Factor](#interior-daylight-factor) | | *`opening_factor` is the same as `openingFactor`* | Only the exact camelCase key is read, with no alias. The near-miss is ignored and the window silently becomes clear glass. β†’ [Interior Daylight Factor](#interior-daylight-factor) | | *A sensor below 2 % DF is under-lit* | Every sensor carries a constant ~2 % internally-reflected floor β€” the same number as the "daylit" convention, so a per-sensor test against 2 % does not discriminate. Read changes *above* the floor. β†’ [Interior Daylight Factor](#interior-daylight-factor) | | *`preview_area(polygon)` prices my run* | Without `analysis_type` it prices the **wind** grid: ~4Γ— the tiles for a solar or thermal run (36 vs 9). It warns; the number is still wrong. β†’ [Cost preview](#cost-preview) | | *A big facade request is one job* | Over the 262,144-sensor cap it is split transparently into sub-jobs, and **each sub-job is billed separately**. β†’ [Batching & billing](#facade-roof--terrain-analysis) | ## Recommended Reading Path If you are new to the SDK, read in this order: 1. **[Examples](#examples)** β€” runnable demos in the public [Infrared-city/infrared-skills](https://github.com/Infrared-city/infrared-skills) repo. 2. **[Output Reference](#output-reference)** β€” what each analysis produces, in what units, and how to read the numbers. 3. **[Coordinate Systems](#coordinate-systems)** β€” the four frames, and what a mix-up looks like when nothing errors. 4. **[Analysis Types](#analysis-types)** β€” pick the analysis you need and copy the snippet. 5. **[Area API β†’ How tiling works](#how-tiling-works)** β€” only if your polygon is larger than ~512 m on a side; otherwise tiling is automatic and you can skip it. ## Examples ### Jupyter notebooks Fourteen notebooks under [`cookbook/notebooks/`](https://github.com/Infrared-city/infrared-skills/tree/main/cookbook/notebooks) run end-to-end against the live API. Every one is executed against each SDK release before it ships. | | | | --- | --- | | [`00_quickstart`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/00_quickstart.ipynb) | first analysis, start here | | [`01_buildings`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/01_buildings.ipynb) | fetching and supplying building geometry | | [`02_vegetation_and_ground`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/02_vegetation_and_ground.ipynb) | trees and ground materials as layers | | [`03_weather_and_time_periods`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/03_weather_and_time_periods.ipynb) | weather files, `TimePeriod`, multi-month and annual windows | | [`04_tiling_and_area_api`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/04_tiling_and_area_api.ipynb) | how tiling, merging and clipping actually work | | [`05_analysis_types_tour`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/05_analysis_types_tour.ipynb) | one polygon, every area analysis, side by side | | [`06_image_rendering`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/06_image_rendering.ipynb) | server-rendered PNGs with the canonical palette | | [`07_async_and_webhooks`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/07_async_and_webhooks.ipynb) | submit now, collect later, webhook delivery | | [`08_wind_merge_strategies`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/08_wind_merge_strategies.ipynb) | wind tile seams and `directional_blend` | | [`09_error_handling_and_tuning`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/09_error_handling_and_tuning.ipynb) | typed errors, retries, resuming a schedule | | [`10_real_world_map_overlay`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/10_real_world_map_overlay.ipynb) | georeferenced overlay on a basemap | | [`11_facade_and_terrain`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/11_facade_and_terrain.ipynb) | **facade and roof sensors, terrain draping** | | [`12_surface_results_rendering`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/12_surface_results_rendering.ipynb) | **rendering surface results onto geometry** | | [`13_interior_daylight_factor`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/13_interior_daylight_factor.ipynb) | **interior daylight on explicit room geometry** | If a result looks wrong rather than reads wrong, check the scale, masking and colormap before debugging the model β€” see the [rendering recipe](https://github.com/Infrared-city/infrared-skills/blob/main/plugins/infrared/skills/use-infrared/references/recipes/rendering-results-well.md). ### Scripts Runnable script demos live in the public [Infrared-city/infrared-skills](https://github.com/Infrared-city/infrared-skills) repo under [`cookbook/scripts/`](https://github.com/Infrared-city/infrared-skills/tree/main/cookbook/scripts), ordered by learning path: 1. [`demo_wind_analysis.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_wind_analysis.py) β€” quickstart: single wind analysis with Plotly heatmap 2. [`demo_vienna.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_vienna.py) β€” the eight area analyses over one polygon, multi-panel visualization 3. [`demo_utci_analysis.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_utci_analysis.py) β€” end-to-end UTCI thermal comfort with buildings, weather, vegetation, and ground materials 4. [`demo_vegetation_ground.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_vegetation_ground.py) β€” fetch-once-reuse pattern across multiple analysis runs 5. [`demo_fetch_layers.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_fetch_layers.py) β€” fetch buildings, vegetation, and ground materials and plot the layers (no analysis) 6. [`demo_tiling.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_tiling.py) β€” educational walkthrough of the tiling internals 7. [`demo_advanced_usage.py`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/scripts/demo_advanced_usage.py) β€” low-level primitives, custom polling, BYO weather data, persist/resume schedules 8. [`areas_demo_async/`](https://github.com/Infrared-city/infrared-skills/tree/main/cookbook/scripts/areas_demo_async) β€” async area analysis with webhook notifications ## Output Reference Every analysis run over a polygon returns an `AreaResult` with a 2-D `merged_grid` (numpy array, ~1 m per cell) covering it. Cells outside the polygon are `NaN`. The table below lists what each cell value means. The exception is `daylight-factor`, which is not an area model: it takes explicit room geometry and returns per-sensor values, with no polygon and no grid. | Analysis | Cell unit | Typical range | Physical meaning | | -------- | --------- | ------------- | ---------------- | | Wind Speed | m/s | 0–20 | Steady-state wind magnitude near pedestrian level for one (speed, direction) pair | | Pedestrian Wind Comfort | comfort class (int 0–4) | Lawson criteria | Categorical comfort/safety class per chosen criterion (e.g. Lawson LDDC: A/B/C/D/E from `sit-long` to `unsafe`) | | Daylight Availability | hours | 0–100 | Hours of usable daylight at the cell over the chosen `TimePeriod` | | Direct Sun Hours | hours | 0–(period length) | Cumulative hours of direct sun over the chosen `TimePeriod` | | Sky View Factors | fraction | 0–100 | Portion of the sky hemisphere visible from the cell (1 = fully open, 0 = fully obstructed) | | Solar Radiation | kWh/mΒ² | 0–~hundreds | Cumulative solar irradiance on the ground over the `TimePeriod` | | Thermal Comfort (UTCI) | Β°C (UTCI equivalent) | Range based on weather data provided | Felt temperature combining air temperature, mean radiant temperature, humidity, and wind | | Thermal Comfort Statistics | % time | 0–(period length) | Time spent in the chosen band: `thermal_comfort`, `heat_stress`, or `cold_stress` | | Interior Daylight Factor | % of outdoor illuminance | legend 0–100 | **Per sensor, not per cell** β€” indoor illuminance as a share of the unobstructed outdoor value under a CIE overcast sky. See [Interior Daylight Factor](#interior-daylight-factor) | {% callout type="tip" title="Pick the colour scale before you plot" %} For Direct Sun Hours and Daylight Availability most of the grid sits near the maximum, so a data-derived colour range produces washed-out plots and a different scale on every run. Surface results carry usable `min_legend` / `max_legend`; **area results return `None` for both**. Fix the domain per analysis yourself β€” see [Analysis Types](#analysis-types) for the domains and [AreaResult](#arearesult) for the fields. {% /callout %} ## Configuration | Environment Variable | Description | Default | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | `INFRARED_API_KEY` | Your Infrared API key | β€” | | `INFRARED_BASE_URL` | API base URL | `https://api.infrared.city/v2` | | `INFRARED_BIG_PAYLOADS_ENABLED` | Kill switch for the auto-switching `$ref` envelope path. Set to `false` to force every POST to take the inline path. | `true` | | `INFRARED_BIG_PAYLOADS_THRESHOLD_BYTES` | Strict greater-than threshold (raw `json.dumps` bytes) above which large POST bodies are zipped, uploaded to S3, and replaced with a `{"$ref": ...}` envelope. | `5242880` (5 MiB) | | `INFRARED_SDK_DEBUG` | Set to `1` to add verbose `[DEBUG] [SDK:…]` diagnostics on top of the always-on `[INFO]`/`[WARN]` progress lines. | unset | | `INFRARED_QUIET` | Set to `1` to silence the runtime `[INFO]`/`[WARN]` diagnostic lines (as of 0.4.12) and the one-time client-instantiation hint; errors still print. Does not silence the `python -m infrared_sdk` CLI banner. | unset | The API key and base URL can also be passed directly to the constructor (the big-payload env vars don't have constructor equivalents β€” they're tuning knobs read at call time): ```python # Explicit β€” pass credentials directly client = InfraredClient(api_key="your-key", base_url="https://api.infrared.city/v2") # Env vars β€” set INFRARED_API_KEY (and optionally INFRARED_BASE_URL), then: client = InfraredClient() ``` `InfraredClient` supports the context manager protocol (`with` statement) for automatic cleanup of HTTP sessions. You can also call `client.close()` manually. {% callout type="note" title="Progress diagnostics on stdout" %} During buildings / area / submit / polling / merge operations the SDK prints structured `[INFO] [SDK:] key=value …` progress lines to **stdout** (and `[ERROR]` to stderr). These are informational, not failures. Set `INFRARED_SDK_DEBUG=1` for extra `[DEBUG]` detail, or `INFRARED_QUIET=1` to silence the `[INFO]`/`[WARN]` lines entirely (errors still print). {% /callout %} ## Geometry Format All analysis payloads accept a `geometries` parameter β€” a dict mapping building identifiers (strings) to DotBim mesh objects: ```python geometries = { "building-001": { "mesh_id": 0, "coordinates": [x1, y1, z1, x2, y2, z2, ...], # flat [x, y, z, ...] array in meters "indices": [0, 1, 2, 3, 4, 5, ...] # triangle index array (REQUIRED) }, "building-002": { "mesh_id": 1, "coordinates": [x1, y1, z1, x2, y2, z2, ...], "indices": [0, 1, 2, ...] }, } ``` Each mesh entry follows the special [DotBim](https://dotbim.net/) format: | Field | Type | Description | | ------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mesh_id` | `int` | Numeric mesh identifier | | `coordinates` | `list[float]` | Flat `[x, y, z, ...]` vertex array. Coordinates are in meters, relative to the **polygon bounding-box south-west corner** (see [DotBim coordinate system](#dotbim-coordinate-system)) | | `indices` | `list[int]` or `None` | Triangle index array (3 indices per face). Optional | You can load geometries from a file or pass the buildings dict returned by the Buildings API: ```python # Option A: Load from a file import json with open("scene.json") as f: geometries = json.load(f) # Option B: Use buildings from the area API area = client.buildings.get_area(polygon) geometries = area.buildings # dict already in the right format ``` When using `run_area_and_wait()`, pass buildings separately via the `buildings` parameter rather than setting `geometries` on the payload. The SDK handles per-tile coordinate transforms and building assignment automatically. Optional `vegetation` and `ground_materials` can also be fetched and passed to `run_area_and_wait()`. See [Vegetation & Ground Materials](#vegetation--ground-materials) for details. ## Buildings ### DotBim coordinate system Building coordinates use a **local meter-space** system: x-axis points east, y-axis points north, z is height. `get_area(polygon)` fetches buildings from multiple tiles, deduplicates them, and transforms all coordinates so the origin is the **polygon bounding-box SW corner** β€” all buildings share one frame regardless of which tile they came from. When you pass buildings to `run_area_and_wait()`, the SDK automatically transforms them from the polygon-bbox-SW frame to each tile's local frame. [Coordinate Systems](#coordinate-systems) below maps every frame the SDK uses and which call converts between them; [Building coordinate transforms](#building-coordinate-transforms) in the Area API section has the per-tile mechanics. Check [dotbimpy](https://github.com/paireks/dotbimpy) for more information on the dotbim file format. ### Building retrieval Fetch 3D building data for a polygon with automatic deduplication across tiles: ```python area = client.buildings.get_area(polygon) print(area.total_buildings) print(area.buildings) # dict[str, DotBimMesh] ``` ## Coordinate Systems Four frames: degrees in, two metre frames in the middle, a UV frame out. **Nothing in the API errors when you supply the wrong one** β€” the geometry lands somewhere else and the run succeeds. | Frame | Units and origin | What lives in it | | --- | --- | --- | | **WGS84 lon/lat** | degrees, `[lon, lat]` (RFC 7946) | the `polygon` argument; `vegetation` and `ground_materials` features | | **Polygon-bbox-SW metres** | SW corner of the *polygon's* bounding box = `(0, 0)`; `+x` east, `+y` north, `z` up | `buildings`, `context_geometry`, `ground_geometry` as passed to `run_area()` / `run_area_and_wait()`; what `get_area()` returns | | **Tile-local metres** | SW corner of that tile's **inference** square = `(0, 0)`; same axes | `payload.geometries` on a single-tile `analyses.execute()`; `sensor_points` / `sensor_normals`; interior-model geometry | | **Surface UV** | per-surface `origin` + `u_axis` / `v_axis`, in tile metres | `SurfaceAnalysisResult.surfaces` β€” output only, never an input | ### Input CRS β€” getting to WGS84 The SDK takes WGS84 lon/lat and **does not negotiate CRS, reproject, or warn on plausibility**. `validate_polygon()` checks structure and that coordinates fall inside `[-180, 180] Γ— [-90, 90]`; it cannot check the CRS. A UTM easting of `4_500_000` is rejected on range, but an easting of `400_000` is read as a longitude in West Africa and runs. One line prevents it: ```python import geopandas as gpd from shapely.geometry import mapping gdf = gpd.read_file("aoi.gpkg", layer="study_area") gdf_4326 = gdf.to_crs("EPSG:4326") # <- this one polygon = mapping(gdf_4326.geometry.iloc[0]) ``` When you go through `pyproj` directly, always pass `always_xy=True` β€” without it `Transformer` returns `(lat, lon)` for EPSG:4326 and a handful of others. The full recipe set β€” GeoPandas / shapely, bbox and extent, GeoTIFF bounds via `rasterio`, BIM and IFC site anchoring, UTM auto-select for your own metric work, and a ten-line preflight that catches lat/lon swaps and out-of-envelope polygons β€” is in [`geospatial-crs.md`](https://github.com/Infrared-city/infrared-skills/blob/main/plugins/infrared/skills/use-infrared/references/geospatial-crs.md) in the skills repo. {% callout type="warning" title="`vegetation` and `ground_materials` stay in degrees" %} One payload, two units. `buildings` is in metres while `vegetation` and `ground_materials` alongside it are WGS84 lon/lat β€” a tree comes back as `{"geometry": {"type": "Point", "coordinates": [11.575942, 48.199694]}}`. Metre vertices passed to `vegetation` are read as degrees, putting every tree in the ocean, and no error is raised. {% /callout %} ### The two metre frames β€” who converts, and when - **`client.buildings.get_area(polygon)`** fetches per tile, deduplicates, and returns everything in **polygon-bbox-SW** β€” one frame for the whole area, whichever tile a building came from. - **`run_area()` / `run_area_and_wait()`** do the **polygon-bbox-SW β†’ tile-local** step for you, per tile, on `buildings`, `context_geometry` and `ground_geometry`. You never write this transform. The mechanics are in [Building coordinate transforms](#building-coordinate-transforms). - **`client.analyses.execute()` does not.** It is a single-tile primitive, so `payload.geometries` and `sensor_points` are read as already tile-local. The split: **through `run_area*` you speak polygon-bbox-SW; through the job primitives you speak tile-local.** Mixing them β€” building a payload by hand from `area.buildings` and posting it through `analyses.execute()` β€” offsets the entire scene by that tile's position within the polygon. Request and result both look normal. {% callout type="warning" title="Negative coordinates are correct β€” do not filter them" %} In **both** metre frames, negative x/y is normal and load-bearing. Out of `get_area`, buildings are collected from tiles covering a margin around the polygon, so ones south or west of the bbox corner have negative coordinates β€” on a 200 m polygon, expect a spread like `x ∈ [-133.1, 394.1]`, `y ∈ [-174.3, 390.0]`. Per tile, a building pulled in by the 128 m solar context margin sits outside the 0–512 m inference range by construction. A tidy-up pass that drops negatives deletes exactly the neighbours that were there to cast shadow into your site. The result stays plausible and gets brighter. {% /callout %} ### Vertical datum `z` is metres up, and it is **relative** β€” the SDK asserts no geoid, no ellipsoid, no vertical EPSG. Only the internal agreement between your terrain and your buildings matters. `client.buildings.get_area()` returns every building **based at exactly `z = 0`**. They carry height, not elevation, so pairing them with a DEM in orthometric or ellipsoidal heights leaves the two disagreeing by the site elevation β€” a few hundred metres across most of Europe. [`terrain_alignment`](#terrain_alignment--how-your-geometry-meets-the-ground) decides the outcome: `"auto-align"` (the default) re-bases each solid onto the terrain beneath it and absorbs the mismatch silently, which is why fetched buildings plus an absolute DEM appear to "just work"; `"assume-aligned"` moves nothing and makes any base outside a Β±1 m band a 422 for the whole job. With no `ground_geometry` the setting is inert and you get a flat plane at `z = 0` β€” not an error, and a result that looks entirely normal. ### Surface UV frames Each `SurfaceSensorGrid` carries `origin`, `u_axis` and `v_axis` in tile metres, and the frame is **right-handed: the outward normal is `u_axis Γ— v_axis`, in that order.** The server builds it as `u = αΊ‘ Γ— n`, `v = n Γ— u`, so outward-wound shells β€” everything `client.buildings` returns β€” give outward-pointing normals. Because the frame is `+x` east and `+y` north, the compass bearing follows directly; the code is under [Which way does a surface face?](#facade-roof--terrain-analysis), and cell-centre maths and `cell_tris` are in notebook `12_surface_results_rendering`. ### "My geometry is in the wrong place" Nothing below raises. Work down the table. | Symptom | Likely frame error | | --- | --- | | Result is over open water, farmland, or another country | Polygon is not WGS84, or is `[lat, lon]` | | Everything mirrored about the diagonal | `[lat, lon]` swap β€” or `pyproj` without `always_xy=True` | | Buildings offset by a whole multiple of 512 m (256 m on wind) | Polygon-bbox-SW geometry posted straight to `analyses.execute()`, which expects tile-local | | Only the SW tile looks right; the others are bare | Same cause, seen across a multi-tile run | | Trees or ground materials nowhere near the site | Metre vertices passed where lon/lat was expected | | Site unexpectedly bright; distant blocks cast no shadow | Negative-coordinate buildings filtered out, or an occluder simply beyond the 128 m tile context | | Buildings float above or sink into the terrain | `ground_geometry` on an absolute vertical datum against `z = 0` buildings | | Terrain shading vanished after upgrading to 0.5.1 | Terrain is now sliced per tile β€” pass distant relief as `context_geometry` | | Heatmap overlay squashed toward the SW | Placed with `polygon.bounds` instead of [`result.bounds`](#arearesult), which is NE-padded to the grid | | Exported GeoTIFF upside down | SDK row 0 is south, GeoTIFF row 0 is north β€” `np.flipud` | ## Time Period Solar, thermal, and wind-comfort analyses require a `TimePeriod` to define the time window for the simulation. The time period also determines which weather data points are included when filtering from a weather file. ```python from infrared_sdk.models import TimePeriod tp = TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=8, end_day=31, end_hour=17, ) ``` | Field | Type | Range | Description | | ------------- | ----- | ----- | ----------- | | `start_month` | `int` | 1-12 | Start month | | `start_day` | `int` | 1-31 | Start day | | `start_hour` | `int` | 0-23 | Start hour | | `end_month` | `int` | 1-12 | End month | | `end_day` | `int` | 1-31 | End day | | `end_hour` | `int` | 0-23 | End hour | All 6 fields are required. `day` values are validated against the calendar month: `April 31`, `February 30`, `June 31`, `September 31`, and `November 31` raise `ValidationError`. `February 29` is accepted β€” `TimePeriod` carries no year context. The window must move forward (`end > start`); year-wrap windows such as Novβ†’Feb are not supported, so split them into two periods. ### How TimePeriod affects weather data `TimePeriod` defines a recurring time window applied across every year in your weather file. It works as a **three-level cascade filter**: 1. **Months** β€” only data from `start_month` through `end_month` is considered. 2. **Days** β€” within each of those months, only days from `start_day` through `end_day` are kept. 3. **Hours** β€” within each of those days, only hours from `start_hour` through `end_hour` are kept. Every hourly data point that does not fall inside all three windows is discarded. The diagram below illustrates how `TimePeriod(start_month=6, start_day=1, start_hour=9, end_month=8, end_day=20, end_hour=17)` filters the data. **Result:** ~3 months Γ— 20 days Γ— 9 hours = **540 hourly data points** per year in the weather file. ![Filtering weather data with time period](https://cloudflare-cdn.infrared.workers.dev/time_period_filter_standalone.svg) ### Which analyses need a TimePeriod | Analysis | TimePeriod | Weather Data | | -------------------------- | --------------------------- | -------------------------------------------- | | Wind Speed | No | No | | Sky View Factors | No | No | | Daylight Availability | Yes | No | | Direct Sun Hours | Yes | No | | Solar Radiation | Yes | Yes (radiation arrays) | | Thermal Comfort (UTCI) | Yes | Yes (temperature, radiation, humidity, wind) | | Thermal Comfort Statistics | Yes | Yes (same as UTCI) | | Pedestrian Wind Comfort | Yes (for weather filtering) | Yes (wind speed/direction arrays) | ## Weather Data Search for nearby weather stations and filter data by time range: ```python from infrared_sdk.models import TimePeriod # Find weather stations near a location (radius in km) locations = client.weather.get_weather_file_from_location( lat=48.1983, lon=11.575, radius=50 ) # Returns a list of station dicts: # [ # { # "uuid": "eb91892c-fbe3-4743-ade5-c22cfb5913e1", # "fileName": "DEU_BY_Munich-Theresienwiese.108650_TMYx", # "location_data": { # "city": "Munich-Theresienwiese", "state": "BY", "country": "DEU", # "latitude": 48.1632, "longitude": 11.5429, "elevation": 520.0, # "time_zone": 1.0, "station_id": "108650", "source": "SRC-TMYx", # "type": "Location", # }, # }, # ... # ] # Use the station's uuid to filter weather data by time range. The # `identifier` parameter on filter_weather_data is the station uuid. weather_data = client.weather.filter_weather_data( identifier=locations[0]["uuid"], time_period=TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ), ) # Returns a list[WeatherDataPoint], one per matching hour: # [ # WeatherDataPoint(dryBulbTemperature=22.3, windSpeed=3.2, windDirection=180.0, # diffuseHorizontalRadiation=120.0, directNormalRadiation=450.0, ...), # WeatherDataPoint(dryBulbTemperature=23.1, windSpeed=4.1, windDirection=195.0, ...), # ... # ] ``` ### Extracting fields for analysis payloads Use `extract_weather_fields` to convert `WeatherDataPoint` lists into the flat arrays that analysis payloads expect. Field names are passed in camelCase (matching `WeatherDataPoint` attributes); the returned dict uses snake_case keys: ```python from infrared_sdk.models import extract_weather_fields wind_fields = extract_weather_fields(weather_data, ["windSpeed", "windDirection"]) # Returns: {"wind_speed": [3.2, 4.1, ...], "wind_direction": [180, 195, ...]} ``` {% callout type="important" %} Pass the same `TimePeriod` to both `filter_weather_data()` and the analysis payload. This guarantees the weather arrays are perfectly aligned with the simulation time window. {% /callout %} Analyses that require weather data (Solar Radiation, UTCI, TCS, PWC) use the `from_weatherfile_payload()` class method, which extracts the required weather arrays from the data points and constructs the full request automatically. ## Analysis Types All analysis types follow the same pattern: construct a request, call `client.run_area_and_wait()` with a polygon and buildings, get an `AreaResult`. The `AreaResult` contains a `merged_grid` (numpy array), `min_legend` / `max_legend` for color scale bounds, and metadata about succeeded/failed tiles. See [AreaResult](#arearesult) for the full schema. {% callout type="warning" title="Never scale a heatmap to its own grid β€” and on area runs, pick the scale yourself" %} Auto-scaling each render to `merged_grid.min()` / `.max()` gives every run a different colour scale: a baseline and a variant stop being comparable, and a flat grid is stretched until noise looks like structure. Where the fixed scale comes from depends on the result type: - **Surface results** (`SurfaceAnalysisResult`, from `analysis_surfaces`) populate `min_legend` / `max_legend`. Use them. - **Area results** (`AreaResult`, the grid path) return **`None` for both**. Fix a per-analysis domain and hold it across every run you compare. | Analysis | Fixed domain | Unit | | --- | --- | --- | | `sky-view-factors` | `[0, 100]` | % | | `daylight-availability` | `[0, 100]` | % | | `direct-sun-hours` | `[0, 12]` | hours | | `solar-radiation` | `[0, 1000]` | kWh/mΒ² | | `wind-speed` | `[0, 15]`, top bin open (`> 15`) | m/s | | `thermal-comfort-index` | `[-40, 46]` | Β°C | These are the domains the Infrared platform renders with. Clamp out-of-domain values to the end colours rather than leaving them uncoloured, and label a clipped top bound as clipped. {% /callout %} ### Wind Speed Simulates the steady-state wind field around buildings for a single inflow condition. Output cells are wind magnitude in m/s near pedestrian height. | Parameter | Type | Range | Description | | ---------------- | ----- | ----- | -------------------------------------------------------------------------- | | `wind_speed` | `float` | 0-100 | Inflow wind speed (m/s). Fractional values are simulated as given, so do not round an EPW-derived mean such as `3.9`. `0` is a valid calm-wind baseline. | | `wind_direction` | `int` | 0-360 | Inflow direction (degrees, meteorological convention: 0 = wind from north). Whole degrees only: the model truncates a fractional bearing, so the SDK rejects one rather than shifting your input. | ```python from infrared_sdk.analyses.types import WindModelRequest, AnalysesName payload = WindModelRequest( analysis_type=AnalysesName.wind_speed, wind_speed=15, wind_direction=180, ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Pedestrian Wind Comfort (PWC) Wind comfort classification using standard criteria. Requires wind speed and direction arrays from weather data. | Parameter | Type | Description | | ---------------- | ------------- | -------------------------------------------- | | `criteria` | `PwcCriteria` | Classification standard (see below) | | `wind_speed` | `list[float]` | Wind speed time series from weather data | | `wind_direction` | `list[float]` | Wind direction time series from weather data | **Available criteria:** `vdi_3787` (also exposed as `vdi_387`, an alias kept for older code), `lawson_1970`, `lawson_2001`, `lawson_lddc`, `davenport`, `nen_8100_comfort`, `nen_8100_safety` {% callout type="note" title="Multi-month windows" %} This example uses a single-month window for brevity. Pedestrian wind comfort supports multi-month and annual windows in a single request; a full-year wind rose is the standard basis for pedestrian comfort. {% /callout %} ```python from infrared_sdk.analyses.types import PwcModelRequest, PwcCriteria, AnalysesName from infrared_sdk.models import TimePeriod, extract_weather_fields weather_data = client.weather.filter_weather_data( identifier="your-weather-file-id", time_period=TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ), ) wind_fields = extract_weather_fields(weather_data, ["windSpeed", "windDirection"]) payload = PwcModelRequest( analysis_type=AnalysesName.pedestrian_wind_comfort, criteria=PwcCriteria.lawson_2001, **wind_fields, ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Daylight Availability Simulates daylight availability at a location over a time period. | Parameter | Type | Range | Description | | ------------- | ------------ | ----------- | -------------------- | | `latitude` | `float` | -90 to 90 | Location latitude | | `longitude` | `float` | -180 to 180 | Location longitude | | `time_period` | `TimePeriod` | β€” | Analysis time window | ```python from infrared_sdk.analyses.types import SolarModelRequest, AnalysesName from infrared_sdk.models import TimePeriod payload = SolarModelRequest( analysis_type=AnalysesName.daylight_availability, latitude=48.1983, longitude=11.575, time_period=TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ), ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Direct Sun Hours Simulates direct sun hours. Same parameters as Daylight Availability. ```python payload = SolarModelRequest( analysis_type=AnalysesName.direct_sun_hours, latitude=48.1983, longitude=11.575, time_period=TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ), ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Sky View Factors (SVF) Calculates sky view factors. Geometry-only β€” no time period or weather data needed. | Parameter | Type | Range | Description | | ----------- | ------- | ----------- | ---------------------------------------------------------------------------------------------------------- | | `latitude` | `float` | -90 to 90 | Optional. Tile-centroid latitude used by the vegetation validator. SVF inference itself does not read it. | | `longitude` | `float` | -180 to 180 | Optional. Tile-centroid longitude used by the vegetation validator. SVF inference itself does not read it. | ```python from infrared_sdk.analyses.types import SvfModelRequest, AnalysesName payload = SvfModelRequest( analysis_type=AnalysesName.sky_view_factors, latitude=48.1983, # optional β€” only needed if you inject vegetation longitude=11.575, # optional β€” only needed if you inject vegetation ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Solar Radiation Simulates solar radiation. Requires weather data arrays for diffuse horizontal and direct normal radiation. {% callout type="important" %} Pass the same `TimePeriod` to both `filter_weather_data()` and the analysis payload, so the weather arrays align with the simulation window β€” the model cannot process misaligned arrays. If you bring your own weather data, its field lengths must match what `filter_weather_data` produces. {% /callout %} {% callout type="note" title="Multi-month windows" %} This example uses a single-month window for brevity. Multi-month and annual windows are supported in a single request. {% /callout %} ```python from infrared_sdk.analyses.types import ( SolarRadiationModelRequest, BaseAnalysisPayload, AnalysesName, ) from infrared_sdk.models import TimePeriod, Location tp = TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ) weather_data = client.weather.filter_weather_data( identifier="your-weather-file-id", time_period=tp, ) payload = SolarRadiationModelRequest.from_weatherfile_payload( payload=BaseAnalysisPayload( analysis_type=AnalysesName.solar_radiation, ), location=Location(latitude=48.1983, longitude=11.575), time_period=tp, weather_data=weather_data, ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` #### Solar radiation on facades, over terrain The factory does **not** carry the surface/terrain fields β€” `BaseAnalysisPayload` rejects them (`extra_forbidden`), so they cannot be passed the way the snippet above passes `analysis_type`. Build the weather payload first, then add the geometry fields with `model_copy`. `terrain_mesh` below is **yours to supply** β€” the SDK has no terrain client and will not fetch a DEM. See [Building the terrain mesh](#building-the-terrain-mesh) for `terrain_mesh_from_grid`, which produces it from an elevation array: ```python terrain_mesh = terrain_mesh_from_grid(elevation, step=10.0) # your DEM payload = SolarRadiationModelRequest.from_weatherfile_payload( payload=BaseAnalysisPayload(analysis_type=AnalysesName.solar_radiation), location=Location(latitude=48.1983, longitude=11.575), time_period=tp, weather_data=weather_data, ).model_copy(update={ "analysis_surfaces": "facades", # or "roofs" / "all" "surface_grid_size": 1.0, "ground_geometry": {"terrain": terrain_mesh}, "terrain_alignment": "auto-align", }) result = client.run_area_and_wait( payload, polygon, buildings=area.buildings, terrain_context_margin_m=512.0, # only if distant relief shades the site ) # analysis_surfaces set -> SurfaceAnalysisResult, not a grid ``` Constructing `SolarRadiationModelRequest(...)` directly works too, but then you supply `latitude`, `longitude`, `time_period`, `diffuse_horizontal_radiation` and `direct_normal_radiation` yourself. The factory derives those from a weather file β€” prefer it, and layer the geometry on top. ### Thermal Comfort Index (UTCI) Calculates the Universal Thermal Climate Index. Requires filtered weather data. {% callout type="important" %} Pass the same `TimePeriod` to both `filter_weather_data()` and the analysis payload, so the weather arrays align with the simulation window β€” the model cannot process misaligned arrays. If you bring your own weather data, its field lengths must match what `filter_weather_data` produces. {% /callout %} {% callout type="note" title="Multi-month windows" %} This example uses a single-month window for brevity. Multi-month and annual windows are supported in a single request. {% /callout %} ```python from infrared_sdk.analyses.types import UtciModelRequest, UtciModelBaseRequest, AnalysesName from infrared_sdk.models import TimePeriod, Location tp = TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ) weather_data = client.weather.filter_weather_data( identifier="your-weather-file-id", time_period=tp, ) payload = UtciModelRequest.from_weatherfile_payload( payload=UtciModelBaseRequest( analysis_type=AnalysesName.thermal_comfort_index, ), location=Location(latitude=48.1983, longitude=11.575), time_period=tp, weather_data=weather_data, ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Thermal Comfort Statistics (TCS) Aggregated thermal comfort over a time period. Three subtypes: `thermal_comfort`, `heat_stress`, `cold_stress`. {% callout type="important" %} Pass the same `TimePeriod` to both `filter_weather_data()` and the analysis payload, so the weather arrays align with the simulation window β€” the model cannot process misaligned arrays. If you bring your own weather data, its field lengths must match what `filter_weather_data` produces. {% /callout %} {% callout type="note" title="Multi-month windows" %} This example uses a single-month window for brevity. Multi-month and annual windows are supported in a single request. {% /callout %} ```python from infrared_sdk.analyses.types import TcsModelBaseRequest, TcsModelRequest, TcsSubtype, AnalysesName from infrared_sdk.models import TimePeriod, Location tp = TimePeriod( start_month=6, start_day=1, start_hour=9, end_month=6, end_day=30, end_hour=17, ) weather_data = client.weather.filter_weather_data( identifier="your-weather-file-id", time_period=tp, ) payload = TcsModelRequest.from_weatherfile_payload( payload=TcsModelBaseRequest( analysis_type=AnalysesName.thermal_comfort_statistics, subtype=TcsSubtype.heat_stress, ), location=Location(latitude=48.1983, longitude=11.575), time_period=tp, weather_data=weather_data, ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` ### Facade, Roof & Terrain Analysis Analyse **building surfaces** (facades, roofs) or **arbitrary sensor points** instead of the default 512Γ—512 m ground grid, and drape results over **terrain geometry**. Requires `infrared-sdk >= 0.4.12`. {% callout type="warning" title="Multi-tile needs >= 0.4.13" %} Any facade/surface analysis over an area larger than one 512Γ—512 m tile needs `infrared-sdk >= 0.4.13`. On 0.4.12, multi-tile surface results collapse onto the SW-corner tile β€” per-tile aggregates still look right, so the collapse only shows in the 3D geometry β€” and `context_geometry` / `ground_geometry` land misplaced on non-SW tiles. Single-tile runs are correct on 0.4.12. {% /callout %} Facade / roof / BYO-sensor fields work on the four raytraced solar-family models **only**: `sky-view-factors`, `solar-radiation`, `direct-sun-hours`, `daylight-availability`. Terrain fields additionally work on `thermal-comfort-index` and `thermal-comfort-statistics`. **Synthesised surface sensors** β€” pass `analysis_surfaces` to put a sensor grid on every facade and/or roof. This flips the result type from a grid `AreaResult` to a `SurfaceAnalysisResult`: ```python from infrared_sdk import InfraredClient, SurfaceAnalysisResult from infrared_sdk.analyses.types import SvfModelRequest, AnalysesName payload = SvfModelRequest( analysis_type=AnalysesName.sky_view_factors, analysis_surfaces="facades", # "facades" | "roofs" | "all" surface_grid_size=1.0, # sensor spacing on each surface, metres (>= 0.25) surface_offset=0.0, # optional: push sensors off the surface, metres (>= 0) ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) assert isinstance(result, SurfaceAnalysisResult) ``` **Terrain draping** β€” supply `ground_geometry` (a `{id: mesh}` map of DotBim-style meshes) so results follow real elevation instead of a flat plane. Works on all six raytraced models, including UTCI / TCS. {% callout type="important" title="The SDK does not fetch terrain β€” you bring it" %} `client.buildings`, `client.vegetation` and `client.ground_materials` fetch geometry for you. **There is no terrain client and no DEM service.** No argument to `run_area_and_wait()` produces elevation, and omitting `ground_geometry` is not an error β€” you get a flat plane at z = 0 and a result that looks normal. `ground_geometry` is always bring-your-own. {% /callout %} #### Building the terrain mesh One flat mesh per entry, the same shape as `buildings` β€” **not** the nested interior-entity shape: ```python {"terrain": {"coordinates": [x0, y0, z0, x1, y1, z1, ...], # flat triples "indices": [i0, i1, i2, ...]}} # flat triangles ``` Coordinates are **metres in the polygon-bbox-SW frame**: the same frame `area.buildings` comes back in, origin at the south-west corner of the polygon's bounding box, `+x` east, `+y` north, `z` elevation on the same datum as the building meshes. A wrong frame does not fail the run β€” the terrain sits somewhere else, and `auto-align` seats every building onto whatever happens to be under it. `terrain_mesh_from_grid` below turns any `(ny, nx)` elevation array into that shape. Triangles are wound counter-clockwise seen from above: ```python import numpy as np def terrain_mesh_from_grid(elevation, x0=0.0, y0=0.0, step=10.0): """Triangulate a (ny, nx) elevation array into one flat SDK mesh. elevation[j, i] is the height in metres at (x0 + i*step, y0 + j*step), in the polygon-bbox-SW frame that `area.buildings` uses. """ elevation = np.asarray(elevation, dtype=float) ny, nx = elevation.shape gx, gy = np.meshgrid(x0 + step * np.arange(nx), y0 + step * np.arange(ny)) coords = np.stack([gx, gy, elevation], axis=-1).reshape(-1, 3) i, j = np.meshgrid(np.arange(nx - 1), np.arange(ny - 1)) a = (j * nx + i).ravel() b, c, d = a + 1, a + nx, a + nx + 1 tris = np.concatenate([np.stack([a, b, d], 1), np.stack([a, d, c], 1)]) return {"coordinates": coords.ravel().tolist(), "indices": tris.ravel().tolist()} # Stand in for your DEM: a 400 x 400 m patch at 10 m, with an 18 m rise. # Replace `elevation` with your own array β€” nothing else changes. nx = ny = 41 step = 10.0 gx, gy = np.meshgrid(np.arange(nx) * step, np.arange(ny) * step) cx = cy = 0.5 * (nx - 1) * step elevation = 18.0 * np.exp(-(((gx - cx) ** 2 + (gy - cy) ** 2) / (2 * 90.0 ** 2))) payload = SvfModelRequest( analysis_type=AnalysesName.sky_view_factors, analysis_surfaces="facades", surface_grid_size=2.0, ground_geometry={"terrain": terrain_mesh_from_grid(elevation, step=step)}, terrain_alignment="auto-align", # see below ) result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) ``` **Getting the array.** A GeoTIFF DEM read with `rasterio` is the usual source: `rasterio.open(path).read(1)` gives you the array, and `rasterio.warp.reproject` resamples it onto the metre grid above. `rasterio` is **not an SDK dependency** β€” install it yourself, and check the reprojection, since that is where the frame is set. Whatever the source, the array must be in metres on the polygon-bbox-SW frame and must **cover the whole polygon**. Objects beyond the terrain's extent are clamped to the edge height rather than refused. #### `terrain_alignment` β€” how your geometry meets the ground Buildings, `context_geometry` and `vegetation` arrive with their own z values; terrain arrives with its own surface. `terrain_alignment` decides what happens when the two disagree. | Mode | What the server does | | ---- | -------------------- | | `"auto-align"` (default) | **Seats the scene.** Every solid in `geometries`, `context_geometry` and `vegetation` is re-based to local grade before inference: each base vertex is dropped to the terrain height beneath it, with a 0.5 m skirt so the footprint stays sealed on a slope. The seated geometry feeds the grid drape, the under-building mask, the occluder union and facade synthesis. | | `"assume-aligned"` | **Validates only, moves nothing.** Any object whose base sits outside a Β±1 m band around the terrain is a **422 for the whole job**, naming the first five offenders with their residuals. Use it when your geometry is already prepped against this DEM and you want a mismatch to be loud. | With no `ground_geometry` the setting is inert and the payload is untouched. {% callout type="warning" title="Compare distributions, not means" %} Switching modes moves buildings vertically, so facades gain and lose exposure in roughly equal measure and the **scene mean barely moves**: a mean delta of **+0.03 kWh/mΒ²** can sit on top of **44 % of facades moving by more than 1 kWh/mΒ²**. A before/after check on averages passes cleanly through a change that rewrote nearly half the surfaces. Compare per-surface distributions, not aggregates, whenever alignment changes. {% /callout %} {% callout type="warning" title="Behaviour change in 0.5.1 β€” terrain is sliced per tile" %} Before 0.5.1 the whole terrain mesh was copied into every tile. It is now **sliced**: each tile receives only the terrain it needs to seat its own buildings and trees and ground its own sensors. A 6 kmΒ² run drops from ~140 MB of repeated terrain to ~12 MB, and high-resolution terrain becomes usable β€” the server caps terrain at 500,000 triangles *per job*, which a 2 m mesh over a few kmΒ² exceeds as a single blob but fits per tile. **Results can change.** Terrain is an occluder, so slicing removes long-range terrain shading; terrain *inside* a tile's envelope shades exactly as before. On a flat site the difference is nil, but a 150 m escarpment 350 m outside the polygon can cost up to **1.0 h** of direct sun (mean 0.35 h) and is invisible at the default reach. **If distant relief matters to your result, pass it as `context_geometry`** β€” the general-purpose occluder input β€” rather than relying on whatever DEM extent your file happened to contain. {% /callout %} **`terrain_context_margin_m`** (on `run_area` / `run_area_and_wait`) widens how far `ground_geometry` is sliced beyond each tile, in metres: ```python result = client.run_area_and_wait( payload, polygon, buildings=area.buildings, terrain_context_margin_m=512.0, # default: seat-only reach ) ``` Values **below the tile config's own context margin are floored to it** (128 m on the solar family, 0 m on wind), so a building or tree can never be stranded on absent ground: `run_area(..., terrain_context_margin_m=0)` on a solar grid reads back `128.0`. Payload grows roughly with the square of the reach. `AreaSchedule.terrain_context_margin_m` records the reach actually used, and a `retry_from` that resolves to a different reach is **refused** β€” mixing them would merge two terrain extents into one result. Schedules written before 0.5.1 record nothing and never trip the guard. **Occluders** β€” `context_geometry` (same `{id: mesh}` shape) adds shading geometry that is *not* itself analysed. **`accuracy`** (`"standard"` / `"precision"`) selects finer raytracing on `direct-sun-hours` / `daylight-availability` only. Supply `context_geometry` and `ground_geometry` in the same polygon-bbox-SW frame as `buildings`; on multi-tile runs the SDK transforms them into each tile's local frame automatically (see the version note above). **Bring your own sensors** β€” `sensor_points` (mutually exclusive with `analysis_surfaces`) computes at exact points instead of synthesising a surface grid. This is **single-tile only**: submit through the job primitives, not `run_area_and_wait` (which raises `ValueError` because the flat per-sensor response can't be tile-merged): ```python payload = SvfModelRequest( analysis_type=AnalysesName.sky_view_factors, geometries=area.buildings, sensor_points=[[105.0, 99.9, 1.5], [105.0, 99.9, 4.5]], # tile-local metres sensor_normals=[[0.0, -1.0, 0.0], [0.0, -1.0, 0.0]], # optional, non-zero, same length ) job = client.analyses.execute(payload=payload) completed = client.jobs.wait_for_completion(job.job_id, timeout=120) raw = client.jobs.decompress(client.jobs.download_results(completed.job_id).content) raw["output"] # flat per-sensor list, one value per sensor in input order ``` An `analysis_surfaces` request returns a **`SurfaceAnalysisResult`** (there is no `merged_grid`): | Field | Type | Description | | ---------------- | ------ | --------------------------------------------------------------------------- | | `surfaces` | `dict` | `{"/": SurfaceSensorGrid}`. Each grid has `origin` / `u_axis` / `v_axis` (UV frame in tile metres), `nu` Γ— `nv`, `values`, `mean`, `peak`, `area`, `cell_area`, `cell_tris`. Per-cell lists carry `None` for masked cells outside the surface footprint β€” map to `NaN` before numeric work, never `0`. Helpers: `.grid()` (NaN-filled `(nv, nu)` array), `.triangles()` (exact clipped cell geometry). | | `aggregates` | `dict` | `{"buildings": {building_id: BuildingAggregate}}` with `area` / `mean` / `peak` β€” ready for element-level colouring. | | `sensor_count` | `int` | Total synthesised sensors. | | `min_legend` / `max_legend` | `float` | Value bounds across all surfaces. **Populated on surface results** β€” unlike area results, where they are `None`. | {% callout type="note" title="Rendering surfaces" %} Colour roofs and facades on **one shared** `[min_legend, max_legend]` scale so surfaces stay comparable (roofs sit high, facades lower β€” that contrast is the reading). A facade-only scale is a labelled exception, not the default. See notebook `12_surface_results_rendering` in the cookbook. {% /callout %} {% callout type="warning" title="A surface at exactly 0.0 is data, not a gap" %} Party walls, light wells and fully occluded elevations return `mean = peak = 0.0` with every cell present and finite. That is the true answer β€” no sun reaches them β€” and it is **distinct from the `None` masked cells**, which mean "outside the surface footprint, no sensor here". Nothing in the response marks a surface as degenerate. They are not rare: on a dense city block, **32 % of facades** can come back at exactly zero, which is enough to move a June direct-sun-hours scene mean from 5.33 h to 3.62 h. Decide which population you are averaging, and say which: ```python vals = [s.mean for s in result.surfaces.values()] lit = [v for v in vals if v > 0.0] # "mean over all analysed facades" -> statistics.mean(vals) # "mean over facades receiving any sun" -> statistics.mean(lit) ``` Neither is wrong. Reporting one as "the facade average" without saying which is. {% /callout %} {% callout type="note" title="Which way does a surface face?" %} `origin` / `u_axis` / `v_axis` describe a **right-handed** frame: the outward normal is `u_axis Γ— v_axis`, in that order. The server builds the frame from the surface's own triangle winding (`u = αΊ‘ Γ— n`, `v = n Γ— u`), so outward-wound building shells β€” everything `client.buildings` returns β€” give outward-pointing normals. The convention holds for the large majority of surfaces but not all of them: on a typical dense block, around **1 % of vertical facades** carry an inward-pointing normal, inherited from inverted winding in the source geometry. That is enough to misfile a facade into the opposite compass sector. If you are grouping surfaces by orientation, sanity-check the normal against the building's own centroid β€” a facade normal should point away from it β€” and drop or flip the ones that fail rather than trusting the cross product alone. Because the local frame is `+x` east and `+y` north, the compass bearing follows directly: ```python import math import numpy as np def outward_normal(s): n = np.cross(s.u_axis, s.v_axis) return n / np.linalg.norm(n) def bearing(n): """Degrees clockwise from north: 0 = N, 90 = E, 180 = S, 270 = W.""" return math.degrees(math.atan2(n[0], n[1])) % 360.0 south_facing = {k: s for k, s in result.surfaces.items() if 135 <= bearing(outward_normal(s)) < 225} ``` `s.is_vertical` uses the same cross product for the server's own facade test (`|n_z| ≀ 0.5`), so you never need to reimplement that one. {% /callout %} {% callout type="warning" title="Batching & billing" %} A large facade request whose estimated sensor count exceeds the server's 262,144-sensor synthesis cap is transparently split into multiple sub-jobs (each seeing every other building as occluder context) and merged back into one result. **Each sub-job is billed separately.** `run_area(..., max_sensors_per_job=...)` lowers the per-job budget that sizes those batches, and may only make them **smaller** β€” the ceiling is 90 % of the server cap, since batch sizing is an estimate. Leave it at the default unless you have a reason not to: smaller batches mean more jobs, and per-request overhead then dominates. Halving the budget on a 6 kmΒ² facade run takes it from 39 jobs to 67 and makes it **21 % slower**; a cap of 2 000 produces 1 000 jobs, where a 0.3 % download-failure rate is enough to abort the whole merge. As with the terrain reach, the resolved cap is recorded on the schedule and a mismatched `retry_from` is refused: batch keys are positional (`{tile}#batch0`, `#batch1`, …), so a different cap **refills** them with different buildings. {% /callout %} **`emit_cell_tris`** β€” set `False` on the payload to drop `cell_tris` / `cell_area` from the response when you are texture-mapping and do not need the clipped per-cell geometry. On a large facade run those triangle arrays dominate the download, which dominates wall-clock. Both keys are then **absent** (not empty), so check for the key rather than assuming it is there. Other rules: `analysis_surfaces` and `sensor_points` are mutually exclusive (raises `ValidationError` client-side); `sensor_points` cap is 100,000 entries; `sensor_normals` must match its length with non-zero entries; `surface_grid_size >= 0.25`, `surface_offset >= 0`. A **terrain-only** request (no facade / sensor fields) still returns a normal grid `AreaResult`. ### Interior Daylight Factor `daylight-factor` measures light **inside** a room: the illuminance at each sensor as a percentage of the unobstructed outdoor horizontal illuminance under a CIE standard overcast sky (10,000 lux). It is time-independent β€” an overcast sky carries no sun position β€” so it takes no `TimePeriod` and no weather data. Requires `infrared-sdk >= 0.5.1`. {% callout type="warning" title="Not an area model β€” it does not go through run_area" %} You supply the room geometry, so there is no polygon, no tiling and no merge. `run_area()` / `run_area_and_wait()` **reject** it (`ValueError: Analysis type 'daylight-factor' is not supported for tiling`). Submit through the job primitives instead β€” `client.analyses.execute()` β†’ `client.jobs.wait_for_completion()` β†’ `download_results()` β†’ `decompress()`, the same path the `sensor_points` example above uses. The result is a per-sensor list, not an `AreaResult`, and there is no `merged_grid`. {% /callout %} **Entity shape.** The outdoor models take **flat** meshes; interior entities are **nested**: ```jsonc {"geometry": {"payload": {"coordinates": [...], "indices": [...]}}} // interior entity {"coordinates": [...], "indices": [...]} // outdoor mesh ``` Passing the flat shape is **not** a server error. The entity reads as having no geometry and is skipped, so the analysis runs against an empty occluder and returns a near-uniform, physically meaningless field with a 200 β€” on a job you were billed for. `to_interior_entity()` (one mesh) and `interior_entities()` (a whole map) do the conversion; both accept a flat dict, a `DotBimMesh`, or an already-nested entity, so they are safe to call on anything: ```python from infrared_sdk import interior_entities, to_interior_entity wall = to_interior_entity(flat_mesh) # -> nested entity slab = to_interior_entity(flat_mesh, category="floor") # + identity position/rotation glass = to_interior_entity(flat_mesh, opening_factor=0.7) # -> emits "openingFactor" area = client.buildings.get_area(polygon) context = interior_entities(area) # neighbours as occluders; get_area returns FLAT meshes ``` {% callout type="important" title="Two fields take the flat shape β€” `ground_geometry` and `vegetation`" %} One payload, two conventions: `barriers`, `openings`, `context_geometry` and everything under `buildings` are read as **nested entities**, while `ground_geometry` and `vegetation` are read as the **flat** `{coordinates, indices}` shape. Running `to_interior_entity()` over terrain or trees is therefore the error there. The SDK rejects both mistakes locally, naming the field and the shape it wants. {% /callout %} **The four tiers.** How you say *where to measure* selects a tier. The server dispatches on the **first key present**, in this order: `sensor_points` β†’ `sensor_surfaces` β†’ `buildings` β†’ `floors` | Tier | You supply | Sensors are | Result shape | | ---- | ---------- | ----------- | ------------ | | `sensor_points` | a list of `[x, y, z]` in metres | exactly your points | `{"output": [...], "min-legend": 0, "max-legend": 100}` | | `sensor_surfaces` | `{id: entity}` of horizontal work planes | gridded onto each surface, `analysis_height` above it | `{"surfaces": {id: {...}}}` | | `buildings` | `{id: {"barriers": ..., "openings": ..., "floors": [...]}}` | per building, per storey | `{"buildings": {id: {"floors": {...}}}}` | | `floors` / `floor_index` / `floor_uuid` | a storey selector over top-level `barriers` | on the selected slab | `{"floors": {...}}`, or the bare floor object for a single legacy selector | {% callout type="warning" title="The losing selectors are ignored, not rejected β€” server-side" %} Only the first key present is read; the rest are **silently dropped**. The expensive case is `buildings` alongside a top-level `floors`: floor selection lives *inside* each `buildings` entry, so the top-level list is never seen and a request for one storey is billed as **every** storey of every building β€” a correct-looking result at many times the price. The SDK refuses these combinations locally, naming the tier that would have won. `buildings` with `sensor_points` is supported: there `buildings` is the occluder, not a competing tier. {% /callout %} **Glazing is per opening.** `openingFactor` is the visible-light transmittance of one aperture, in `[0, 1]` β€” not a room-wide setting. Two identical windows on opposite walls at 0.9 and 0.1 give half-field means of 8.20 % and 3.51 %. Absent, it defaults to `1.0`, a fully clear pane. The worker reads the exact camelCase key `openingFactor`, with **no fallback and no alias**: `opening_factor` and other spellings are ignored, and the window silently becomes clear glass. The value multiplies the ray weight with no clamp, so `openingFactor=7.5` returns a daylight factor above 100 % rather than an error. Set it via `to_interior_entity(mesh, opening_factor=...)`, which emits the one spelling that is read; the SDK rejects near-misses and out-of-range values before submission. `interior_entities(meshes, opening_factor=...)` will not apply a bulk value over entities that already carry their own. **Caps.** The SDK checks these client-side because **the charge lands before the worker runs**: every cap is a 422 raised inside the worker, so an over-cap request is billed and then refused, with nothing in the failure path reversing it. | Limit | Value | Counted over | | ----- | ----- | ------------ | | Floors per request | 15 | `floors`, `sensor_surfaces`, or summed across **all** `buildings` entries | | `sensor_points` | 100,000 | one request | | Occluder triangles | 2,000,000 | `barriers` + `openings` + `context_geometry` + `ground_geometry` + `vegetation` + everything under `buildings`. `sensor_surfaces` are **virtual** and do not count β€” they are gridded into measurement points and never join the occluder. | {% callout type="note" title="The floor cap is a lower bound" %} A `buildings` entry that omits `floors` contributes **all** of that building's storeys server-side, clustered from its slabs β€” not zero. The SDK cannot know that count without reimplementing the clustering, so its check is deliberately loose and can pass a request the server still refuses for being over 15 floors. Count your own storeys when you omit `floors`. {% /callout %} **A complete run** β€” a two-storey building with a window per storey, from geometry to numbers: ```python import os from infrared_sdk import ( AnalysesName, DaylightFactorModelRequest, InfraredClient, to_interior_entity, ) client = InfraredClient(api_key=os.environ["INFRARED_API_KEY"]) def quad(p0, p1, p2, p3): """One flat quad as two triangles, in the SDK's usual flat mesh shape.""" return { "coordinates": [c for p in (p0, p1, p2, p3) for c in p], "indices": [0, 1, 2, 0, 2, 3], } W = D = 8.0 # room footprint, metres H = 3.2 # storey height STOREYS = [0.0, 3.2] # slab elevations -> storey index 0 and 1 barriers = {} openings = {} for i, z in enumerate(STOREYS): # A slab MUST carry category="floor": storeys are clustered from # categorised slabs by elevation, and the storey index is that ranking. barriers[f"slab-{i}"] = to_interior_entity( quad((0, 0, z), (W, 0, z), (W, D, z), (0, D, z)), category="floor" ) top = z + H barriers[f"ceiling-{i}"] = to_interior_entity( quad((0, 0, top), (W, 0, top), (W, D, top), (0, D, top)) ) barriers[f"wall-s-{i}"] = to_interior_entity( quad((0, 0, z), (W, 0, z), (W, 0, top), (0, 0, top)) ) barriers[f"wall-n-{i}"] = to_interior_entity( quad((0, D, z), (W, D, z), (W, D, top), (0, D, top)) ) barriers[f"wall-w-{i}"] = to_interior_entity( quad((0, 0, z), (0, D, z), (0, D, top), (0, 0, top)) ) barriers[f"wall-e-{i}"] = to_interior_entity( quad((W, 0, z), (W, D, z), (W, D, top), (W, 0, top)) ) # A 4 x 1.6 m window in the south wall, sill 0.9 m above the slab, nudged # just inside the wall plane so it is unambiguously an aperture in it. eps, sill = 0.01, z + 0.9 openings[f"window-s-{i}"] = to_interior_entity( quad((2, eps, sill), (6, eps, sill), (6, eps, sill + 1.6), (2, eps, sill + 1.6)), opening_factor=0.7, ) request = DaylightFactorModelRequest( analysis_type=AnalysesName.daylight_factor, barriers=barriers, openings=openings, floors=[0, 1], # both storeys; the lowest slab is index 0 grid_size=0.5, # sensor pitch on the working plane, metres analysis_height=0.8, # sensors this far above the slab ) job = client.analyses.execute(payload=request) client.jobs.wait_for_completion(job.job_id, timeout=600) raw = client.jobs.decompress(client.jobs.download_results(job.job_id).content) for key, floor in raw["floors"].items(): df = [p["df"] for p in floor["output"]] print( f"storey {key}: {len(df)} sensors " f"min {min(df):.2f}% mean {sum(df) / len(df):.2f}% max {max(df):.2f}%" ) ``` **Reading the output.** Each sensor is `{"x", "y", "z", "df"}` β€” position in the same metre frame you sent, and `df` as a **percentage** of outdoor illuminance. The fixed legend is `0`–`100`. Values fall off steeply with distance from the aperture: on a 10 m Γ— 10 m floor plate with one 3 m Γ— 2 m window per storey, sensors run from **27.9 % directly at the window to 2.2 % at the back wall**, mean 4.6 %. Expect a wider spread with more or larger glazing, and a much narrower one in a deep plan. {% callout type="note" title="Values sit on a ~2 % floor β€” compare above it" %} Every sensor carries a constant internally-reflected component of about **2 %**. A sealed box with no openings returns `2.00 %` throughout, and the minimum stays within 2.00–2.07 % across transmittances of 0.1–1.0 and window areas of 2–16 mΒ². That floor sits at the same number as the common "daylit" planning convention (average DF β‰ˆ 2 %, the BS 8206-2 / BREEAM lineage), so a per-sensor comparison against 2 % will not discriminate. Read glazing changes from the values **above** the floor, and prefer an area-weighted room mean to a per-sensor threshold. The convention is an external design rule in any case β€” the model neither asserts nor enforces it, and your jurisdiction may set it elsewhere. The interior models are new and under active development; this floor is on the list. {% /callout %} A **flat field at the floor** signals a stripped occluder rather than a real answer: a fully opaque envelope β€” windows discarded, or never read β€” returns `2.00 %` everywhere, which reads as a dim room rather than as an error. `len(set(values)) > 1` is **not** a sufficient guard, because back-of-room values sit at 2.0–2.2 % whether or not the glazing was read. Check instead that the maximum clears the floor β€” `max(values) > 2.5`. A room whose *best* sensor is at the floor has no working aperture. Neighbouring buildings shade the room through `context_geometry`. Omitting it does not error; the room simply reads brighter than it is, by a scene-dependent margin. **Model defaults**, all overridable on the payload: | Field | Default | Meaning | | ----- | ------- | ------- | | `grid_size` | `0.5` | Sensor pitch, metres | | `analysis_height` | `0.8` | Working-plane height above the slab, metres | | `window_area` | `2.0` mΒ² | Total glazed area, scalar or a list that is summed. Feeds the internally reflected component **only** β€” see below | | `room_reflectances` | `{"floor": 0.2, "walls": 0.5, "ceiling": 0.7}` | One set for the whole request, unlike `openingFactor` | | `exterior_ground_reflectance` | `0.2` | CIE overcast ground bounce | | `use_obb` | `true` | Grid the floor over its oriented bounding box rather than an axis-aligned one | {% callout type="important" title="`window_area` does not control how much light gets in" %} Light enters the room through the **geometry** β€” the `openings` meshes and their `openingFactor` β€” which is what the raytracer sees. `window_area` feeds one term of the split-flux approximation, the internally reflected component, added as a **single scalar to every sensor**: ``` DF = (SC + ERC + IRC) / 10,000 lux Γ— 100 IRC = mean(SC) Γ— window_area Γ— ρ_avg / (floor_area Γ— (1 βˆ’ ρ_avg)) ``` Its effect scales with **`window_area / floor_area`**, so it disappears on a large floor plate. Taking `window_area` from 2 to 16 mΒ² (8Γ—), everything else fixed: | Room | Mean DF at 2 mΒ² | at 16 mΒ² | Ξ” | | --- | --- | --- | --- | | 8 Γ— 8 m (64 mΒ²) | 5.65 % | 6.33 % | **+0.68** | | 16 Γ— 16 m (256 mΒ²) | 2.97 % | 3.01 % | **+0.05** | A near-flat response on a large room is the formula behaving as specified. To model more glazing, enlarge the `openings` mesh or raise `openingFactor`. Set `window_area` to your actual glazed area so the reflected term is right, and expect it not to move the headline number on a deep plan. `room_reflectances` scales the same term (through `ρ_avg`) and dilutes the same way. {% /callout %} **What the SDK refuses before submission.** Each of these is something the server accepts, bills, and then either fails on or answers wrongly: | You do this | What the server does | What the SDK does | | ----------- | -------------------- | ----------------- | | flat mesh in `barriers` / `openings` / `context_geometry` | skips the entity β†’ empty occluder, 200, billed | rejects, naming the entity | | nested entity in `ground_geometry` / `vegetation` | 500-class failure, billed | rejects, names the flat shape | | room geometry in `geometries` | never read β†’ uniform field | rejects, points at `barriers` | | `buildings` + top-level `openings` | drops the windows β†’ fully opaque envelope, a plausible flat field near 2 % | rejects, explains the takeover | | `buildings` + top-level `barriers` | drops your barriers silently | rejects, explains the takeover | | a selector the winning tier ignores | runs a different tier, silently | rejects, naming the tier that wins | | `floors` beside legacy `floor_index` / `floor_uuid` | `floors` wins, the legacy key is never read | rejects, tells you to fold or drop it | | non-horizontal `sensor_surfaces` | 422 **after** the charge | rejects locally | | an empty selector (`sensor_points=[]`, `buildings={}`) | 422 from inside the dispatched tier, after the charge | rejects with the server's own message | | `opening_factor` (snake) instead of `openingFactor` | never read β†’ glazing silently becomes 1.0 | rejects, gives the spelling | | `openingFactor` outside `[0, 1]` | no clamp β†’ daylight factor above 100 % | rejects, bounds it | | over any request-size cap | 422 **after** billing | rejects locally | | non-finite, empty, or out-of-range mesh arrays | opaque 500, silent drop, or billed 500 | rejects locally | {% callout type="note" title="Horizontality is measured in world space" %} `sensor_surfaces` must be work planes: at least 50 % of each surface's face area within ~20Β° of horizontal. That test runs on the **transformed** mesh β€” after the entity's own `position` / `rotation` and after `global_transform` β€” so a wall-local mesh whose rotation lays it flat is a valid work surface, and a nominally flat mesh that a transform stands upright is not. For arbitrary orientations use `sensor_points`, which is not gridded and carries no such constraint. {% /callout %} A full walkthrough, including each failure mode above, is in `cookbook/notebooks/13_interior_daylight_factor.ipynb` in the [`infrared-skills`](https://github.com/Infrared-city/infrared-skills) repo. ### Analysis Names Reference | Enum Value | API Name | | ----------------------------------------- | ---------------------------- | | `AnalysesName.wind_speed` | `wind-speed` | | `AnalysesName.pedestrian_wind_comfort` | `pedestrian-wind-comfort` | | `AnalysesName.daylight_availability` | `daylight-availability` | | `AnalysesName.direct_sun_hours` | `direct-sun-hours` | | `AnalysesName.sky_view_factors` | `sky-view-factors` | | `AnalysesName.solar_radiation` | `solar-radiation` | | `AnalysesName.thermal_comfort_index` | `thermal-comfort-index` | | `AnalysesName.thermal_comfort_statistics` | `thermal-comfort-statistics` | | `AnalysesName.daylight_factor` | `daylight-factor` | ## Vegetation & Ground Materials The SDK can fetch vegetation (trees) and ground material layers (asphalt, grass, water, etc.) for a polygon. Fetch them explicitly and pass to `run_area_and_wait()`: ```python # Fetch vegetation (trees from OSM) area_veg = client.vegetation.get_area(polygon) print(f"{area_veg.total_trees} trees found") # Fetch ground materials (Overture + road-surface FlatGeobuf) area_gm = client.ground_materials.get_area(polygon) print(f"{area_gm.total_features} features found") # Pass to run_area_and_wait result = client.run_area_and_wait( payload, polygon, buildings=area.buildings, vegetation=area_veg.features, ground_materials=area_gm.layers, ) ``` Fetch once and reuse across multiple analysis runs over the same polygon to avoid redundant API calls. ### Layer parameter behaviour The `buildings`, `vegetation`, and `ground_materials` parameters on `run_area()` / `run_area_and_wait()` are **opt-in**: nothing is auto-fetched. | Value | Behavior | | ------------------------ | ----------------------------------------------------------- | | `None` (default) or `{}` | Skip β€” no data of this type is injected into the simulation | | `{...}` (non-empty) | Use the provided data | If you need vegetation or ground materials in a simulation, fetch them with the dedicated sub-clients (`client.vegetation.get_area()`, `client.ground_materials.get_area()`) and pass the result. Wind / SVF analyses generally don't need them; thermal and solar analyses produce more realistic results when they are included. ### Format | Field | Format | Coordinate frame | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `buildings` | DotBim meshes (`coordinates` flat XYZ list, `indices` face triplets) | polygon-bbox-SW meters; SDK transforms to tile-SW per tile | | `vegetation` | GeoJSON `Feature` dict keyed by OSM id; each `Feature` has `geometry.coordinates = [lon, lat]` and OSM tree `properties` | lon/lat β€” the inference layer handles projection and any geometry conversion | | `ground_materials` | Dict of GeoJSON `FeatureCollection` keyed by **material name** (`asphalt`, `concrete`, `vegetation`, `water`, `soil`) β€” **not UUIDs** | lon/lat β€” projected server-side | {% callout type="important" title="Ground materials keys must be material names, not UUIDs" %} UUID-shaped keys (e.g. `{"d7a9f2d3-...": FC}`) raise `ValueError` in SDK β‰₯ 0.4.7. Unrecognised names (typos, wrong case) emit `UserWarning` β€” they reach the server as-is and produce emissivity 0.97 (wrong UTCI) if the server does not recognise them. Always use `area_gm.layers` directly; its keys are already correct. {% /callout %} {% callout type="note" title="Vegetation format change" %} `AreaVegetation.features` and the `vegetation` payload field carry GeoJSON Point Features. Mesh conversion and the polygon-bbox-SW transform happen in the inference layer; SDK versions before 2026-04 did that conversion client-side via `convert_geojson_to_mesh()`. {% /callout %} {% callout type="tip" title="Large ground material sets" %} With the default `INFRARED_BIG_PAYLOADS_ENABLED=true` the SDK transparently switches large POST bodies (raw JSON > 5 MiB) onto an S3 `$ref` envelope, so dense ground material layers do not hit gateway request-size limits. You do not need to pass `ground_materials={}` or pre-filter above `area_gm.total_features > 5000`. Pre-filtering still helps when you want to skip injection entirely or trim features your simulation doesn't need β€” the envelope path is automatic but adds one presign plus one S3 PUT round-trip per call. {% /callout %} ## Area API For multi-tile analyses over large polygons, the area API handles tiling, building assignment, and result merging automatically. ### Cost preview Before running an area analysis, preview how many tiles it will require. **Always pass `analysis_type`** so the preview uses the correct tile grid for the analysis you intend to run: ```python # Solar / daylight / thermal-comfort analyses (512 m grid, edge-to-edge) preview = client.preview_area(polygon, analysis_type="solar-radiation") # Wind analyses (256 m grid, 50% overlap) preview = client.preview_area(polygon, analysis_type="wind-speed") print(f"Tiles: {preview.tile_count}") print(f"Estimated time: {preview.estimated_time_s}s") print(f"Estimated cost: {preview.estimated_cost_tokens} tokens") ``` {% callout type="important" %} The default `analysis_type=None` returns the wind-grid (256 m) tile count for backwards compatibility. Solar / daylight / thermal-comfort analyses run on a 512 m grid (~4Γ— fewer tiles per area), so omitting `analysis_type` for those workflows over-counts tiles by ~4Γ— and under-estimates cost. **Always pass the analysis you intend to run.** {% /callout %} The returned `estimated_time_s` and `estimated_cost_tokens` are **per analysis** at the chosen grid. For a multi-analysis workflow on the same grid family, multiply by the number of analyses. | Field | Type | Description | | ----------------------- | ------- | ----------------------------------------------- | | `tile_count` | `int` | Non-empty tiles for `analysis_type`'s grid | | `estimated_time_s` | `float` | Per-analysis wall-clock time (`tile_count*10`) | | `estimated_cost_tokens` | `int` | Per-analysis token cost (`tile_count*10`) | Wire-format analysis names accepted (kebab-case): `"wind-speed"`, `"pedestrian-wind-comfort"`, `"solar-radiation"`, `"direct-sun-hours"`, `"daylight-availability"`, `"sky-view-factors"`, `"thermal-comfort-index"`, `"thermal-comfort-statistics"`. ### Basic usage ```python from infrared_sdk import InfraredClient from infrared_sdk.analyses.types import WindModelRequest, AnalysesName polygon = { "type": "Polygon", "coordinates": [[ [13.4050, 52.5200], [13.4110, 52.5200], [13.4110, 52.5254], [13.4050, 52.5254], [13.4050, 52.5200], ]], } with InfraredClient() as client: # Fetch buildings once area = client.buildings.get_area(polygon) # Run analysis β€” buildings are reused wind_result = client.run_area_and_wait( WindModelRequest( analysis_type=AnalysesName.wind_speed, wind_speed=10, wind_direction=180, ), polygon, buildings=area.buildings, ) print(wind_result.grid_shape) # e.g. (768, 1024) print(wind_result.succeeded_jobs) # number of tiles that completed ``` ### Multi-analysis runs Run several analysis types over the same polygon in a single parallel batch by passing a list of payloads. All tile submissions across all analysis types are pooled into one shared thread pool, so tiles from different analysis types can be in flight simultaneously: ```python results = client.run_area_and_wait( [wind_payload, svf_payload, solar_payload], polygon, buildings=area.buildings, ) # Results are returned as a list in the same order as the input payloads wind_result = results[0] svf_result = results[1] solar_result = results[2] ``` The same applies to **parameter sweeps** of one analysis type β€” passing a list of payloads with different config (e.g. 8 wind directions) submits all `8 Γ— tile_count` jobs through a single shared 20-worker pool rather than running 8 sequential per-direction batches. #### Concurrency at scale - **Per-call cap:** the SDK caps in-flight submissions at `max_workers` (default 20) regardless of how many payloads Γ— tiles you pass. So `run_area_and_wait([8 payloads], polygon)` with 24 tiles per payload still uses 20 concurrent submissions, not 192. The `max_workers` argument tunes this per call. - **Multi-user / multi-process:** each `InfraredClient` instance has its own pool. To go above 20 simultaneous submissions, instantiate multiple clients in separate threads or processes β€” the API is designed to handle parallel callers. - **Cold start:** the first request in a session typically takes 2–5Γ— longer than subsequent ones (Lambda cold start). Benchmark numbers from warm runs are not representative of first-call latency. - **Backend limits:** the API enforces an account-level concurrency ceiling on simulation execution. Contact support if you regularly need to exceed ~100 simultaneous tile jobs. #### Webhooks with multi-payload batches When `webhook_url` is set on a multi-payload run, your endpoint will receive up to `payloads Γ— tiles` events in a tight time window β€” much denser than per-payload sequential submission. Make sure your endpoint can handle the burst (queue ingestion / batch DB writes recommended). ### Polygon requirements - GeoJSON Polygon format: `{"type": "Polygon", "coordinates": [[[lon, lat], ...]]}` - Coordinate order: `[longitude, latitude]` (GeoJSON standard) - Single ring, closed, at least 3 unique vertices, no self-intersections - Max ~100 non-empty tiles (override with `max_tiles_override`) ### How tiling works {% callout type="note" title="TL;DR β€” most users can skip this section" %} Polygons that fit inside one ~512 m tile run in a single API call with no tiling logic involved. The SDK auto-tiles bigger polygons; the rest of this section explains the internals if you need to debug a tiled run, tune `max_workers`, or bring your own buildings. {% /callout %} The Infrared API simulates a fixed 512Γ—512 m tile at a time. To analyse a polygon larger than one tile, the SDK splits it into a grid of overlapping tiles, runs each one in parallel, then crops and stitches the results into a single merged grid. #### Tile geometry Every tile has three key dimensions: | Parameter | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Inference size** (512 m) | The area actually simulated by the API. Always 512Γ—512 m, producing a 512Γ—512 cell grid (1 m per cell). | | **Context size** | The area used to select which buildings are sent with the tile. May be larger than the inference size so buildings outside the tile that cast shadows or affect wind can be included. | | **Step size** | The distance between adjacent tile centres. Controls how much tiles overlap. | These parameters differ between wind and solar model groups: | Config | Inference | Context | Step | Overlap | Crop | | -------------------------------------------------- | --------- | ------- | ---- | ------------------- | -------------------- | | **Wind** (`wind-speed`, `pedestrian-wind-comfort`) | 512 m | 512 m | 256 m | 50% (256 m) | Centre 256Γ—256 cells | | **Solar** (all other types) | 512 m | 768 m | 512 m | None (edge-to-edge) | Full 512Γ—512 cells | **Why the difference?** Wind effects propagate laterally β€” a building's wind shadow extends far downwind. Dense 50% overlap with centre-cropping ensures each point in the merged grid comes from the most accurate central region of a tile. Solar/daylight analyses need long shadows from distant buildings (hence the wider 768 m context, adding 128 m on each side) but the output itself doesn't benefit from overlap, so tiles are placed edge-to-edge. ![wind tiling diagram](https://cloudflare-cdn.infrared.workers.dev/wind_tiling_standalone.svg) ![solar tiling diagram](https://cloudflare-cdn.infrared.workers.dev/solar_tiling_standalone.svg) #### Merging After all tiles complete, the SDK extracts a centre crop from each tile's 512Γ—512 result: - **Wind:** crops the inner 256Γ—256 cells (discards the 128-cell border on each side), then places each crop at its grid position. Adjacent crops meet exactly β€” no blending needed because each point was computed from the tile where it's most central. - **Solar:** uses the full 512Γ—512 result (no crop), placed edge-to-edge. Cells outside the input polygon are set to NaN via cell-level point-in-polygon clipping. ##### Optional: directional merge strategies (wind-speed only) By default `merge_area_jobs` uses the plain centre-crop merge described above (`strategy="default"`). For wind-speed analyses two directional strategies are available: | Strategy | Description | |---|---| | `"default"` | Plain centre-crop (no direction needed). | | `"directional"` | Directional argmax without blending. Each cell is won by the tile with the most upwind geometry context. Useful to inspect the winner map or when blending is undesirable. Requires `wind_direction_deg`. | | `"directional_blend"` | **S12a full smart-blend** β€” argmax + upstream-biased Gaussian. Produces visually smoother results across tile seams. Requires `wind_direction_deg`. | ```python # Argmax only β€” winner map, no smoothing result = client.merge_area_jobs( schedule, strategy="directional", wind_direction_deg=270.0, # meteorological: wind FROM west ) # Full smart-blend β€” argmax + upstream-biased Gaussian result = client.merge_area_jobs( schedule, strategy="directional_blend", wind_direction_deg=270.0, ) # Reduce memory footprint for large polygons (strategy="default" only) import numpy as np result = client.merge_area_jobs(schedule, dtype=np.float32) ``` {% callout type="warning" title="Wind-speed only" %} Do not use `"directional"` or `"directional_blend"` for `pedestrian-wind-comfort` or any multi-direction analysis β€” both algorithms assume a single wind vector. Omitting `wind_direction_deg` with either strategy raises `ValueError`. If you call `merge_tiles` directly (rather than `merge_area_jobs`), you must also pass `config` explicitly β€” `merge_area_jobs` handles this automatically. {% /callout %} In the overlap between two tiles, the tile that was *upwind* during simulation saw that area with full context behind it, so its numbers are more trustworthy. Both strategies pick the upwind tile's values there β€” `"directional"` with a hard switch, `"directional_blend"` with a weight that fades gradually β€” which shrinks seam artefacts to near zero without post-processing. This matters most when running extra tiles for higher coverage, custom non-uniform grids, or targeted re-sampling of a specific zone. ![S12a directional merge β€” concept: reliability mask shifts toward upwind tile in the overlap zone](https://raw.githubusercontent.com/Infrared-city/infrared-skills/main/docs/assets/blending_concept.jpg) ![S12a directional merge β€” example output showing seam elimination across tile boundaries](https://raw.githubusercontent.com/Infrared-city/infrared-skills/main/docs/assets/blending_example.jpg) #### Building coordinate transforms This is the most important piece to understand when working with the area API: 1. **`client.buildings.get_area(polygon)`** fetches buildings from multiple tiles, deduplicates them, and transforms all coordinates into the **polygon bounding-box SW frame** β€” the south-west corner of the polygon's bounding box is origin `(0, 0)`, x points east, y points north, values in meters. ![geometries/buildings area coordinate](https://cloudflare-cdn.infrared.workers.dev/polygon_bbox_sw_frame_standalone.svg) 2. **When you pass `buildings` to `run_area_and_wait()`**, the SDK must assign each building to the tile(s) it overlaps. For each tile, the SDK: - Computes the tile's **inference SW offset** relative to the polygon bbox SW (based on the tile's row/col and the step size) - Expands the tile's bounding box by the **context margin** (0 m for wind, 128 m for solar) β€” this expanded area is only used to **select** which buildings to include - Tests each building's bounding box against this expanded context area - **Deep-copies** the building and **subtracts the inference tile's SW offset** from its coordinates, converting from polygon-bbox-SW frame to tile-SW frame ![tile buildings coordinates](https://cloudflare-cdn.infrared.workers.dev/per_tile_transform_standalone.svg) {% callout type="important" %} Building coordinates are always relative to the **inference square** (512Γ—512 m), not the context square. The context box is only used for selection β€” to decide which buildings are close enough to affect the simulation. The coordinate transform itself uses the inference tile's SW corner as the origin. This means buildings caught by the solar context margin (the extra 128 m) will have **negative coordinates** (they sit outside the 0–512 m inference range), which is correct β€” the API needs to know where they are relative to the simulation tile to compute their shadow or wind effect. {% /callout %} This means the same building can appear in multiple adjacent tiles (with different coordinates in each), which is correct β€” the API expects buildings in the tile's local coordinate frame. **If you provide your own buildings** to `run_area_and_wait()`, they must be in the polygon-bbox-SW frame. The SDK handles the per-tile transform automatically. Buildings returned by `client.buildings.get_area()` are already in this frame. #### Other details - **Parallelism:** Up to 20 concurrent API calls per run (configurable via `max_workers`) - **Retry:** 2 retries with exponential backoff + jitter for HTTP 429/5xx - **Single-tile bypass:** If the polygon fits in one tile, tiling overhead is skipped - **Projection:** Local tangent plane approximation, accurate for city-scale polygons (<50 km span) ### AreaResult | Field | Type | Description | | ---------------- | ----------------- | ------------------------------------------------ | | `merged_grid` | `numpy.ndarray` | Merged, clipped grid (NaN outside polygon) | | `polygon` | `dict` | The source GeoJSON polygon | | `analysis_type` | `str` | Which analysis type was run | | `grid_shape` | `tuple[int, int]` | (rows, cols) of merged grid | | `failed_jobs` | `list[str]` | Job IDs that failed | | `skipped_jobs` | `list[str]` | Job IDs that were skipped (download error, etc.) | | `total_jobs` | `int` | Total number of jobs submitted | | `succeeded_jobs` | `int` | Number of jobs that succeeded | | `failed_tiles` | `list[TileFailure]` | Per-tile failure records (`tile_id`, `row`, `col`, `error`, `phase`), classified by `TileFailurePhase` (`submit` / `compute` / `download` / `skipped`) β€” the structured answer to "which tiles produced no usable output". Empty when every tile succeeded. | | `min_legend` | `float` or `None` | Minimum legend value across all tile results. **`None` on area runs** β€” declared, but not populated by the grid path. Use a fixed per-analysis domain instead. | | `max_legend` | `float` or `None` | Maximum legend value across all tile results. **`None` on area runs**, as above. Populated only on `SurfaceAnalysisResult`. | | `bounds` | `tuple[float, float, float, float]` or `None` | Geographic extent of `merged_grid` as `(min_lng, min_lat, max_lng, max_lat)`. Padded NE past `polygon.bounds` to the next `step_m` boundary when the polygon side is not an integer multiple of `step_m`. Use this (not `polygon.bounds`) to place the bitmap in a map viewer β€” otherwise you get an SW-anchored squash. `None` when no grid was produced (empty schedule). | {% callout type="warning" title="Area runs return `None` β€” carry your own domain" %} `min_legend` / `max_legend` are `None` on every area run, so a `result.min_legend if ... is not None else np.nanmin(...)` guard takes the fallback branch **every time** β€” you are auto-scaling to the grid, and two runs stop being comparable. Keep a fixed domain per analysis type instead (see [Analysis Types](#analysis-types) for the values), and use the result's own bounds only where they are populated β€” surface results. ```python import plotly.graph_objects as go DOMAIN = { "sky-view-factors": (0, 100), "daylight-availability": (0, 100), "direct-sun-hours": (0, 12), "solar-radiation": (0, 1000), "wind-speed": (0, 15), "thermal-comfort-index": (-40, 46), } zmin, zmax = DOMAIN[result.analysis_type] go.Heatmap( z=result.merged_grid, zmin=zmin, zmax=zmax, ) ``` Difference plots are the exception: deltas go negative, so they need their own symmetric scale centred on zero. {% /callout %} Serialize for JSON: `result.to_dict()` (converts the numpy grid to nested lists with NaN replaced by `None`). ## Image Generation Generate a PNG image from analysis results: ```python result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) grid = result.merged_grid.tolist() img_bytes = client.weather.gen_grid_image( grid=grid, analysis_type="wind-speed", # optional: improves color mapping ) with open("output.png", "wb") as f: f.write(img_bytes) ``` `gen_grid_image` also accepts optional `criteria` and `subtype` parameters for PWC and TCS analyses. ## Async Jobs & Webhooks The SDK supports two execution styles for analyses: synchronous polling β€” `run_area_and_wait()` blocks until results are ready β€” and asynchronous submission β€” `run_area()` returns immediately with an `AreaSchedule`, the API processes jobs in the background, and your service is notified through webhooks (or by manual polling). Pick the style that matches how your code waits for the result. **Prefer async + webhooks when:** - Long-running, large-area runs where blocking a process for minutes is impractical. - Headless / serverless / batch jobs where there is no caller to keep open. - Multi-analysis or parameter-sweep batches that submit many tiles at once. - Multi-user or fan-out backends where many polygons are scheduled concurrently and a single webhook stream consolidates completions. **Prefer synchronous polling (`run_area_and_wait`) when:** - Notebooks or interactive scripts where the result is consumed inline. - Small polygons or single-tile runs that complete in seconds. - Local development and debugging β€” no public webhook endpoint required. - Environments without a routable webhook URL (corporate networks, ad-hoc machines). ### Single-tile primitives For direct control over a single job β€” custom polling, replaying jobs from your own queue, or wiring webhooks at the analysis level β€” use the low-level primitives `client.analyses.execute()` and `client.jobs.*`. Most users should reach for `run_area()` / `run_area_and_wait()` instead; these primitives exist for advanced workflows. ```python from infrared_sdk import InfraredClient, WEBHOOK_EVENT_SUCCEEDED, WEBHOOK_EVENT_FAILED from infrared_sdk.analyses.jobs import JobStatus with InfraredClient() as client: # 1. Submit (returns immediately) job = client.analyses.execute( payload=payload, webhook_url="https://your-server.com/webhooks", webhook_events=[WEBHOOK_EVENT_SUCCEEDED, WEBHOOK_EVENT_FAILED], ) print(job.job_id, job.status) # e.g. "abc-123", JobStatus.pending # 2a. Either poll manually... snapshot = client.jobs.get_status(job.job_id) if snapshot.status == JobStatus.succeeded: download = client.jobs.download_results(job.job_id) # 2b. ...or block on a convenience wrapper (returns when terminal) completed = client.jobs.wait_for_completion(job.job_id, timeout=300) # 2c. ...or skip polling entirely and react to the webhook delivery instead. # 3. Download results once the job has Succeeded download = client.jobs.download_results(completed.job_id) ``` `JobStatus` is a string enum returned by `client.jobs.get_status()` and exposed on the `Job` dataclass. The five values are: | Field | Type | Description | | ----------- | ----- | ---------------------------------------------------------------------------------- | | `pending` | `str` | Job has been accepted by the API and is queued for execution. | | `running` | `str` | Job is currently being processed by the inference backend. | | `succeeded` | `str` | Terminal β€” results are ready to download via `client.jobs.download_results()`. | | `failed` | `str` | Terminal β€” the job did not produce a result; inspect `job.error` for details. | | `unknown` | `str` | Status string was not recognised (forward-compat fallback). Treat as non-terminal. | ### Async area runs with `run_area` `client.run_area()` is the async counterpart to `run_area_and_wait()`. It performs the same tiling, building assignment, and submission, but returns an `AreaSchedule` describing the in-flight jobs without blocking on completion. ```python from infrared_sdk import InfraredClient, WEBHOOK_EVENT_SUCCEEDED, WEBHOOK_EVENT_FAILED with InfraredClient() as client: area = client.buildings.get_area(polygon) schedule = client.run_area( payload, polygon, buildings=area.buildings, webhook_url="https://your-server.com/webhooks", webhook_events=[WEBHOOK_EVENT_SUCCEEDED, WEBHOOK_EVENT_FAILED], ) print(f"Submitted {len(schedule.jobs)} tile jobs ({len(schedule.failed_submissions)} submission errors)") # ... your webhook receiver records each job.succeeded / job.failed event ... # Once all jobs are terminal, download and merge into a single AreaResult result = client.merge_area_jobs(schedule) ``` `AreaSchedule` schema: | Field | Type | Description | | -------------------- | --------------------------- | -------------------------------------------------------------------- | | `jobs` | `dict[str, str]` | Mapping of `tile_id` to the submitted `job_id`. | | `polygon` | `dict` | The source GeoJSON polygon (used by `merge_area_jobs` to clip). | | `analysis_type` | `str` | Which analysis type was submitted. | | `failed_submissions` | `tuple[str, ...]` | Tile IDs whose submission HTTP call failed; passed to `retry_from=`. | | `submission_abort_status` | `int` or `None` | HTTP status code that caused early abort during submission (e.g. `402` for insufficient credits). `None` when all submissions completed or failed only transiently. Check before retrying β€” a `402` means the account hit its credit limit; resubmitting immediately reproduces the abort. | | `webhook_url` | `str` or `None` | Webhook URL the schedule was submitted with (preserved for retries). | | `webhook_events` | `tuple[str, ...]` or `None` | Webhook events the schedule subscribed to. | `run_area` accepts a list of payloads (multi-analysis or parameter sweeps) and returns `list[AreaSchedule]` β€” one per payload β€” sharing a single thread pool. See [Webhooks with multi-payload batches](#webhooks-with-multi-payload-batches) above for the burst-rate caveat that applies when these schedules deliver events to the same endpoint. **Persistence and retry.** `AreaSchedule.to_dict()` and `AreaSchedule.from_dict()` round-trip JSON-safely, so a schedule can be persisted (database, file, queue message) between submission and merge. To retry only the tiles/batches that failed, pass the original schedule back via `retry_from=`: `client.run_area(payload, polygon, retry_from=prior_schedule)` resubmits just the failed keys and, from 0.4.13, **carries the previously-succeeded jobs forward into the returned schedule**. Merge that schedule directly β€” no manual `prior_schedule.merge(retry_schedule)` step. The retry payload must match the original run, or `run_area` raises `ValueError` before submitting: (a) a different payload config (`config_hash` mismatch), (b) a different buildings/geometries map than the one that produced the schedule (`buildings_hash` guard), or (c) a multi-payload list β€” `retry_from` describes one payload's prior run, so retry each payload separately (`run_area(B, polygon, retry_from=schedule_B)`). **Manual polling.** When a webhook endpoint is not available, `client.check_area_state(schedule)` queries every job status in parallel and returns an `AreaState` (counts of pending/running/succeeded/failed and an `is_complete` flag) suitable for a polling loop. **Submission retries.** Tile submissions retry HTTP 429 / 5xx with exponential backoff and jitter (`max_retries=2`); see the note under [Area API β†’ Other details](#other-details). **`run_area_and_wait` also accepts `webhook_url=`.** Passing `webhook_url=` (and optionally `webhook_events=`) to `run_area_and_wait()` does not change its blocking behaviour β€” the call still returns the merged `AreaResult` locally β€” but it also asks the API to deliver per-job lifecycle events to your endpoint. Use this when you want the convenience of a synchronous result inside a script while still streaming job-level signals into a backend (queue, database, monitoring). ### Webhooks Webhook endpoints are the back-channel the API uses to signal job lifecycle changes. Endpoints are registered once per environment via `client.webhooks.*`; per-job subscriptions are attached at submit time using `webhook_url=` / `webhook_events=`. ```python from infrared_sdk import InfraredClient from infrared_sdk import WEBHOOK_EVENT_SUCCEEDED, WEBHOOK_EVENT_FAILED with InfraredClient() as client: # Register an endpoint endpoint = client.webhooks.register( url="https://your-server.com/webhooks", type="production", ) print(f"Endpoint ID: {endpoint.id}") # List all registered endpoints endpoints = client.webhooks.list() # Delete an endpoint when no longer needed client.webhooks.delete(endpoint.id) ``` The `type` argument selects the **server-side environment** (and signing-secret pair) that an endpoint is bound to: `"production"` for production traffic, `"development"` for development / staging traffic. The SDK forwards `type` to the API verbatim; it does not change client behaviour. The signing secret for a registered endpoint is available in your account dashboard at [platform.infrared.city](https://platform.infrared.city) after registration. Treat the secret like an API key: store it server-side and pass it directly to `verify_signature()`. **Webhook events.** When you submit a job, pass the events you want delivered as the raw event-name strings on the wire: - `job.running` β€” job has started processing. - `job.succeeded` β€” job completed successfully (results available via `client.jobs.download_results`). - `job.failed` β€” job failed (the event payload includes the error reason). In Python code, prefer the SDK constants `WEBHOOK_EVENT_RUNNING`, `WEBHOOK_EVENT_SUCCEEDED`, `WEBHOOK_EVENT_FAILED` (re-exported from `infrared_sdk`) instead of typing the strings. {% callout type="important" title="Delivery is best-effort" %} The API may retry deliveries on transient failures, so the same event can arrive more than once. Webhook consumers must therefore be **idempotent** β€” apply forward-only state transitions (e.g. `pending β†’ running β†’ succeeded/failed`) and ignore events that would move a job backwards. The async demo's SQLite handler shows the pattern. {% /callout %} **Signature verification.** Every delivery is signed with the Standard Webhooks v1 HMAC-SHA256 scheme. The `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers carry the message id, signing timestamp, and HMAC respectively. `tolerance` (default 300 s) bounds how old a timestamp may be before the call is rejected as a replay. **Always verify against the raw request body bytes** β€” verifying against parsed JSON or a re-encoded string changes byte-level whitespace and breaks the HMAC. This is the most common cause of webhook verification failures. `verify_signature()` accepts secrets with the `whsec_` prefix as stored in the dashboard; the prefix is stripped internally before HMAC computation. ```python from infrared_sdk import WebhooksServiceClient is_valid = WebhooksServiceClient.verify_signature( payload_body=request_body, # raw bytes from the HTTP request body headers=request_headers, secret="whsec_...", # signing secret from the dashboard tolerance=300, # seconds β€” replay-attack window ) ``` ## Pre-flight Diagnostics At low sun angles, building shadows can extend beyond the per-tile geometry buffer and silently lose context near tile edges. The pre-flight check (`estimate_sun_context_loss`) flags those configurations before you run. ## Error Handling **Payload validation:** The SDK validates all payloads at construction time using Pydantic. Invalid inputs raise `ValidationError` immediately: ```python from pydantic import ValidationError try: payload = WindModelRequest( analysis_type=AnalysesName.wind_speed, wind_speed=200, # exceeds max of 100 wind_direction=180, ) except ValidationError as e: print(e) # field validation errors ``` **HTTP errors:** The SDK automatically retries HTTP 429 (rate-limited) and 5xx (server error) responses with exponential backoff and jitter. Non-retryable errors (401, 403) raise immediately. **Job-level errors:** All job exceptions inherit from `InfraredJobError`: | Exception | When | | ---------------------- | -------------------------------- | | `JobSubmitError` | Job submission failed | | `JobPollError` | Error while polling status | | `JobFailedError` | Job completed with failed status | | `JobTimeoutError` | Polling timed out | | `JobNotCompletedError` | Results requested for a job that hasn't completed | | `ResultsDownloadError` | Failed to download results | **Area-level errors:** raised by the area orchestration path (`run_area_and_wait`, `merge_area_jobs`, `buildings.get_area`). They do not inherit from `InfraredJobError` β€” catch them separately: | Exception | When | | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `AreaRunError` | Every job in the run failed (server error, download permanently failed, or per-tile grid rejected). Carries `failed_jobs`, `skipped_jobs`, `total_jobs`. | | `AreaTimeoutError` | `run_area_and_wait` exceeded its `area_timeout`. Carries the live `area_state` snapshot so callers can decide whether to keep polling. | | `TiledRunError` | `client.buildings.get_area(...)` (or other tiled fetchers) had every tile fail after retries. Carries `failed_tiles`. **Partial** failures don't raise β€” inspect `area.failed_tiles` on the returned `AreaBuildings` instead. | | `PolygonValidationError` | The GeoJSON polygon is malformed β€” wrong coordinate order, unclosed ring, self-intersection, or too few vertices. Raised client-side by `run_area` / `run_area_and_wait` before any request. Subclasses `ValueError`. | ```python from infrared_sdk import AreaRunError # importable from the top-level package try: result = client.run_area_and_wait(payload, polygon, buildings=area.buildings) except AreaRunError as exc: # Every job failed β€” log per-job state and either retry or fail loudly. print(f"All {exc.total_jobs} jobs failed: {exc.failed_jobs}, skipped: {exc.skipped_jobs}") raise ``` **Big-payload errors:** when a POST body exceeds `INFRARED_BIG_PAYLOADS_THRESHOLD_BYTES` (default 5 MiB), the SDK transparently zips the payload, uploads it to S3 via a presigned URL, and POSTs a `{"$ref": ...}` envelope at the original endpoint. Failures in any of those steps surface as the typed exceptions below β€” distinct from the per-service `*ServiceError` classes because they signal infrastructure failures (presign, S3 PUT, ref fetch) rather than domain-level API errors: | Exception | When | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BigPayloadError` | Common base. Catch this to handle every big-payload infrastructure error in one block. | | `BigPayloadPresignError` | Gateway refused the presign call (network failure, timeout, non-2xx, body not JSON, missing `upload-url` / `get-url`). Carries `status_code` + `response_body`. | | `BigPayloadUploadError` | S3 PUT failed (`SignatureDoesNotMatch` from a byte-count / Content-Type drift, transient 5xx exhausted, or transport error after retries). Carries `status_code` + `response_body`. | | `BigPayloadFetchError` | Final envelope POST returned a structured `REF_*` error other than `REF_EXPIRED` (`REF_INVALID_ENVELOPE`, `REF_NOT_FOUND`, `REF_TOO_LARGE`, `REF_HOST_NOT_ALLOWED`, `REF_CONTENT_TYPE_REJECTED`, `REF_FETCH_TIMEOUT`, `REF_DECODE_FAILED`). Dispatch on `.code` β€” any server-supplied `REF_*` code passes through, so treat the set as open. | | `RefExpiredRetryExhausted` | `BigPayloadFetchError` subclass. Raised after the bounded `REF_EXPIRED` retry budget (2 retries) has been consumed without success. Always carries `code == "REF_EXPIRED"`. | ```python from infrared_sdk import BigPayloadError, BigPayloadFetchError, RefExpiredRetryExhausted try: area_gm = client.ground_materials.get_area(large_polygon) except RefExpiredRetryExhausted: # Presigned GET URL expired before the consumer could fetch it, # twice in a row. Retry the call. ... except BigPayloadFetchError as exc: # Dispatch on the structured REF_* code rather than parsing the message. if exc.code == "REF_TOO_LARGE": # Pre-filter or aggregate features before retrying. ... else: raise except BigPayloadError: # Presign or S3 PUT failed β€” infrastructure issue. raise ``` {% callout type="note" title="Privacy" %} Presigned URLs are bearer credentials. The SDK redacts them from log lines and never includes them in exception messages β€” see `_redact_presigned_url` in `src/infrared_sdk/_internal/big_payloads/core.py`. If you wrap the SDK in custom logging, do not stringify presigned URLs. {% /callout %} ## Cookbook and examples Notebooks, agent skills (Claude Code / Cursor / Codex / Copilot / Windsurf), and runnable Python recipes live in [Infrared-city/infrared-skills](https://github.com/Infrared-city/infrared-skills) β€” start at [`cookbook/notebooks/`](https://github.com/Infrared-city/infrared-skills/tree/main/cookbook/notebooks). Each notebook is self-contained and ordered as a learning path: | Notebook | Topic | | -------- | ----- | | [`00_quickstart.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/00_quickstart.ipynb) | Install, env, instantiate the client, run one analysis end-to-end | | [`01_buildings.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/01_buildings.ipynb) | `client.buildings.get_area`, DotBim mesh format, building heights | | [`02_vegetation_and_ground.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/02_vegetation_and_ground.ipynb) | `client.vegetation`, `client.ground_materials`, layer formats | | [`03_weather_and_time_periods.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/03_weather_and_time_periods.ipynb) | Weather file lookup, `filter_weather_data`, `TimePeriod` semantics | | [`04_tiling_and_area_api.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/04_tiling_and_area_api.ipynb) | `preview_area`, rectangular vs. irregular polygons, tile geometry, `AreaResult` | | [`05_analysis_types_tour.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/05_analysis_types_tour.ipynb) | All 8 analysis types with payload patterns and outputs | | [`06_image_rendering.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/06_image_rendering.ipynb) | `gen_grid_image`, orientation, colormap caveats | | [`07_async_and_webhooks.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/07_async_and_webhooks.ipynb) | `run_area`, `check_area_state`, `merge_area_jobs`, webhooks | | [`08_wind_merge_strategies.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/08_wind_merge_strategies.ipynb) | Wind-speed merge strategies (`default` / `directional` / `directional_blend`), seam-zone inspection | | [`09_error_handling_and_tuning.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/09_error_handling_and_tuning.ipynb) | `estimate_sun_context_loss` pre-flight, big-payload `$ref` envelope errors, `Retry-After`, tuning | | [`10_real_world_map_overlay.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/10_real_world_map_overlay.ipynb) | Overlay a result on an interactive OSM basemap using `AreaResult.bounds` | | [`11_facade_and_terrain.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/11_facade_and_terrain.ipynb) | `analysis_surfaces` (facades / roofs), `ground_geometry` terrain, `SurfaceAnalysisResult` | | [`12_surface_results_rendering.ipynb`](https://github.com/Infrared-city/infrared-skills/blob/main/cookbook/notebooks/12_surface_results_rendering.ipynb) | Rendering surface grids: texture route, exact `cell_tris` mesh, whole-tile on one shared scale | The [`cookbook/notebooks/advanced-api/`](https://github.com/Infrared-city/infrared-skills/tree/main/cookbook/notebooks/advanced-api) folder goes deeper per analysis (advanced solar / daylight / DSH / SVF / UTCI) and covers terrain, context-geometry occluders, and a realistic combined urban scenario. ## License Apache-2.0. Full text: https://www.apache.org/licenses/LICENSE-2.0.