---
title: Infrared SDK guide
description: Guide to the Infrared SDK (Python and TypeScript), one Markdown file.
source: https://infrared.city/docs/sdk/1.0
---

# Infrared SDK
The Infrared SDK runs urban microclimate simulations on your own site model:
wind, sun, thermal comfort and daylight. You use it from Python or
TypeScript. A .NET SDK is coming. The SDK prepares the work on your machine.
The Infrared cloud runs only the simulation models.

<div class="side" markdown>
<figure markdown>
![A matrix of the ten analyses in four groups: outdoor wind, outdoor sun and light, outdoor thermal comfort and interior (beta). Daylight factor has two rows: rooms, and horizontal surfaces that you give, such as a roof. Dots show where each analysis gives values: ground, facade, roof or room. Only solar radiation, sky view factor, direct sun hours and daylight availability give values on facades and roofs. The last column shows what each analysis needs: a weather file, time and place, wind from the file, or nothing.](assets/diagrams/analyses-matrix.svg)
</figure>
<div markdown>

## What goes in, what comes out

**In:** an area (one polygon), your model, weather and a time period.
Your model has buildings, trees, ground materials, terrain and context
geometry. Context geometry is far objects that only give shade. See
[Your model](#your-inputs). You can also bring your own sensor points
(see [Bring your own sensors](#bring-your-own-sensors)).

**Out:** ground maps, values on facades and roofs, values in rooms, and one
value for each sensor point that you give. See
[Results](#what-comes-back) and the [ten analyses](#the-ten-analyses).

## How people use it

Pick the case that is closest to yours. Each line shows what to read next.

| You want to | Typical setup | Read next |
|---|---|---|
| Compare design variants | Python in a notebook, your own model | [How a run works](#how-a-run-works), [Your inputs](#your-inputs), [What comes back](#what-comes-back) |
| Show results in a web app | TypeScript in the browser, one SDK worker | [Where it runs](#where-it-runs), [Serve many users](#serve-many-users), [Facades and roofs](#facade-and-roof-runs) |
| Run many sites | Python or Node.js on a server, a script | [Tiling](#tiling), [Cost and failures](#cost-and-retry) |
| Work in Rhino or Grasshopper | Rhino 8 with the Python SDK (plug-in: work in progress) | [Coordinates](#coordinates), [Your inputs](#your-inputs) |

The chapters up to "The ten analyses" explain the concepts. The chapters after
them are deep dives: read them when you need them.

## The Rust core

One compiled core does the local work: tiles, geometry preparation and
merge. The Python wheel and the TypeScript package share this core. The .NET
SDK will share it too. All SDKs give the same results, and the local
preparation is fast. See [How a run works](#how-a-run-works).

</div>
</div>

## Measured numbers

Measured with the Python SDK 1.0.0 from PyPI on the production API, on a
32-core machine. The buildings were read before the timer. "Warm" is the
second run of the same request: the models are warm and the geometry is
already uploaded.

- **Total time**: the full SDK call. It plans the tiles, prepares the site,
  uploads, submits, waits, downloads all results and merges them.
- **Cloud part**: from the first job accepted to all jobs done (queue and
  simulation).

| Area | Analysis | Total time | Cloud part |
|---|---|---|---|
| **40 km²**, Hong Kong (24,444 buildings) | Sky view factor on the ground: 1 m grid, 169 tiles | **8.2 s** warm (14.8 s first run, with a 24 MB upload) | 7.3 s |
| **6 km²**, Hong Kong (3,440 buildings) | Solar radiation on facades, June 08–18 h: 2.8 million sensors, 27 jobs | **2.7 s** warm (3.9 s first run) | 2.0 s |
| **6 km²**, Hong Kong (3,440 buildings) | Sky view factor on facades: 2.8 million sensors, 27 jobs | **2.6 s** warm (4.0 s first run) | 2.0 s |
| **6 km²**, Hong Kong (3,440 buildings) | Sky view factor on the ground: 1 m grid, 25 tiles | **3.3 s** warm (8.5 s cold) | 3.0 s |
| **1 km²**, Vienna (4,212 buildings) | Sky view factor, wind speed and UTCI in one call | 8.7 s | first job after 2.9 s |

The largest run used less than 2 GB of memory on the machine.

Dates: Hong Kong 2026-10-08, Vienna 2026-10-07. Your times depend on your
network, your machine and your site.

## Install

=== "Python"

    ```bash
    pip install infrared-sdk
    pip install "infrared-sdk[geodata]"   # only to read Overture Maps
    ```

    Python 3.9 or later. The package is `infrared-sdk` on PyPI.

=== "TypeScript"

    ```bash
    npm install @infrared-city/infrared-sdk-ts
    npm install hyparquet hyparquet-compressors   # only to read Overture Maps
    ```

    Node 18 or later. The package is `@infrared-city/infrared-sdk-ts` on npm.

!!! warning "An old .npmrc keeps you on 0.12.12"
    If an `.npmrc` maps `@infrared-city` to GitHub Packages, `npm install`
    stays on 0.12.12. Version 1.0.0 is on npmjs.org, not on GitHub Packages.
    Remove that line from `.npmrc`. You need no token for npmjs.org.

## Quickstart

This run uses your own geometry: one box-shaped tower, in metres, with the
origin at the south-west corner of the area. `my_tower` is a placeholder.

=== "Python"

    ```python
    import os
    from infrared_sdk import InfraredClient, SvfModelRequest
    from infrared_sdk.analyses.types import AnalysesName

    lon, lat = 16.371, 48.208
    polygon = {"type": "Polygon", "coordinates": [[
        [lon, lat], [lon + 0.004, lat], [lon + 0.004, lat + 0.003],
        [lon, lat + 0.003], [lon, lat]]]}
    xy = [(100, 100), (120, 100), (120, 120), (100, 120)]
    coordinates = [c for z in (0, 30) for x, y in xy for c in (x, y, z)]
    indices = [0,2,1, 0,3,2, 4,5,6, 4,6,7, 0,1,5, 0,5,4,
               1,2,6, 1,6,5, 2,3,7, 2,7,6, 3,0,4, 3,4,7]
    my_tower = {"tower": {"coordinates": coordinates, "indices": indices}}

    client = InfraredClient(api_key=os.environ["INFRARED_API_KEY"])
    payload = SvfModelRequest(analysis_type=AnalysesName.sky_view_factors)
    result = client.run_area_and_wait(payload, polygon, buildings=my_tower)
    grid = result.physical_grid()   # sky view factor, 0 to 100 %
    ```

    The SDK writes INFO log lines, for example the default base URL. This is normal.

=== "TypeScript"

    Save this as `quickstart.ts` and run it with `npx tsx quickstart.ts`. It is
    an ES module. Set `"type": "module"` in `package.json` for top-level `await`.

    ```ts
    import { InfraredClient, initializeCore, areaGridValuesF32, type AreaResult } from "@infrared-city/infrared-sdk-ts";

    await initializeCore();
    const lon = 16.371, lat = 48.208;
    const polygon = { type: "Polygon", coordinates: [[
      [lon, lat], [lon + 0.004, lat], [lon + 0.004, lat + 0.003],
      [lon, lat + 0.003], [lon, lat]]] };
    const xy = [[100, 100], [120, 100], [120, 120], [100, 120]];
    const coordinates = [0, 30].flatMap((z) => xy.flatMap(([x, y]) => [x, y, z]));
    const indices = [0,2,1, 0,3,2, 4,5,6, 4,6,7, 0,1,5, 0,5,4,
                     1,2,6, 1,6,5, 2,3,7, 2,7,6, 3,0,4, 3,4,7];

    const client = new InfraredClient({ apiKey: process.env.INFRARED_API_KEY });
    const result = await client.runAreaAndWait(
      { analysisType: "sky-view-factors" }, polygon,
      { buildings: { tower: { coordinates, indices } } },
    );
    const grid = areaGridValuesF32(result as AreaResult);   // 0 to 100 %
    ```

No data yet? The SDK can read public buildings, trees and ground materials
for an area. See [Your model](#your-inputs).

## Versions

- Each SDK has its own SemVer version. Python and TypeScript both start at 1.0.0. Their versions can differ later.
- A major version can change the API. Read `UPGRADING.md` before you upgrade.
- Pin the version in your project.

## How a run works

You give an area, your geometry and the analyses. The SDK on your machine
cuts the area into tiles, sends one job for each tile to the Infrared cloud
and merges the tile results into one result. The cloud runs only the
simulation models.

<div class="side" markdown>
<figure markdown>
![The SDK cuts the area into tiles. Each tile goes on as soon as it is ready: the SDK prepares and uploads its geometry and sends its job while it prepares the next tiles. Results come in while other jobs still run. One tile fails and the SDK sends it again. Then the SDK merges all tiles into one result. A timeline shows one row for each tile.](assets/diagrams/run-flow.svg)
</figure>
<div markdown>

### The inputs

- **Area**: one GeoJSON polygon in WGS84 (`[lon, lat]`).
- **Your geometry**: buildings, trees, ground materials, terrain, and context
  geometry. Context geometry is far objects that only give shade. The SDK
  does not analyse it. Only the sun and light analyses and the interior models take
  it, and it reaches 128 m past a tile.
- **Weather**: a weather file or a public weather station, and the time
  period.
- **Analyses**: one analysis or a list of analyses for the same area.
- **Sensors** (optional): the ground grid is the default. You can ask for
  sensors on facades and roofs (`analysis_surfaces`). Or give your own
  sensor points. Own points run as one job, not as an area run. See
  [Bring your own sensors](#bring-your-own-sensors).

Use your own data. To start fast, you can also read public data for the area
with the SDK.

### The steps

1. **Plan the tiles.** The SDK cuts the area into tiles of 512 m. All
   analyses except wind read 128 m of geometry past each tile. Wind analyses
   move 256 m from tile to tile, so the tiles overlap by half. The SDK skips
   empty tiles. One run has a maximum of 100 tiles.
2. **Prepare the geometry.** The SDK prepares the site one time. Then it
   cuts your geometry for each tile.
3. **Upload the geometry.** The SDK uploads the geometry of each tile one
   time. All analyses on that tile use the same upload.
4. **Submit the jobs.** The SDK sends one job for each tile and analysis. Each
   job has an idempotency key. If a submit gets no answer, the SDK sends it
   again with the same key: the job does not run or bill two times. A job
   that failed on the server is sent again as a new job, with a new key.
5. **Wait.** When the SDK has sent all jobs, it asks for their status: first
   after 0.5 s, then every 1 s, and after 10 s every 2 s. One request asks for the status
   of up to 50 jobs, so 81 jobs need 2 requests for each check, not 81.
6. **Download the results.** The results come back in a binary format by
   default.
7. **Merge.** The SDK joins the tiles into one result on your machine. It
   merges each result when its download is complete. You get the result
   when all tiles are merged.

Steps 2 to 4 do not wait for all tiles. Each tile goes on as soon as it is
ready. By default, up to 8 tiles are prepared, uploaded or sent at the same
time (`max_workers`, `maxWorkers`). This is not a limit on the jobs that run
in the cloud: more jobs can run at the same time. While the SDK prepares a
tile, the jobs of the tiles before it already run. In Python, the SDK
downloads each result while the other jobs still run. In TypeScript, the
downloads start when all jobs are done.

### When a tile fails

A failed tile does not stop the other tiles. The SDK never gives you a
result with holes. `run_area_and_wait` (`runAreaAndWait`) sends the failed
tiles again one time, then raises an error that names them if a tile still
fails (Python: `AreaRunError`, with `failed_tiles`). For `run_area`
(`runArea`) and the retry keys, read [Cost and retry](#cost-and-retry).

</div>
</div>

### Example: one run

=== "Python"

    ```python
    import os

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

    client = InfraredClient(api_key=os.environ["INFRARED_API_KEY"])

    payload = UtciModelRequest.from_weatherfile_payload(
        payload=UtciModelBaseRequest(analysis_type=AnalysesName.thermal_comfort_index),
        location=Location(latitude=48.21, longitude=16.37),
        time_period=TimePeriod(start_month=7, start_day=15, start_hour=12,
                               end_month=7, end_day=15, end_hour=16),
        weather_data=parse_epw("vienna.epw"),
    )
    result = client.run_area_and_wait(
        payload, polygon,
        buildings=buildings,        # your meshes, in metres
        vegetation=trees,           # your trees
        ground_materials=ground,    # your ground layers
    )
    ```

=== "TypeScript"

    ```ts
    import { InfraredClient, initializeCore } from "@infrared-city/infrared-sdk-ts";

    await initializeCore();
    const client = new InfraredClient({ apiKey: process.env.INFRARED_API_KEY });

    const result = await client.runAreaAndWait(
      {
        analysisType: "thermal-comfort-index",
        latitude: 48.21, longitude: 16.37,
        dateFilters: { period: { start: { month: 7, day: 15, hour: 12 },
                                 end: { month: 7, day: 15, hour: 16 } } },
        weather: client.weather.parseEpw(epwText),
      },
      polygon,
      { buildings, vegetation: trees, groundMaterials: ground },
    );
    ```

Terrain goes in the analysis request as `ground_geometry` (`groundGeometry`).
Context geometry goes in the request of a ray-traced solar or interior
analysis as `context_geometry` (`contextGeometry`).

#### Reference

- Python: [the client](python/sdk.md), `run_area_and_wait` and `run_area`.
- TypeScript: [the SDK reference](api/typescript/index.md),
  `InfraredClient.runAreaAndWait` and `InfraredClient.runArea`.

## Coordinates

You give the area as a polygon in lon/lat. You give your geometry in metres.
The SDK moves every layer into the frame of each tile. Most wrong results
come from a wrong frame, so read this page once.

<div class="side right" markdown>
<figure markdown>
![Four panels. A lon/lat polygon with its dashed bounding box and the SW corner marked; the same corner as origin of a 3D model in metres with x, y and z axes; a tile grid where the SDK moves the site origin to a tile origin; a red panel with four silent traps (lat and lon swapped, caught only when the swapped latitude is above 90 in size, so silent for most of Europe and the Americas; Y-up model; centimetres; origin at polygon centre) that are not caught, are billed and give a wrong result.](assets/diagrams/coordinates.svg)
</figure>
<div markdown>

### The rules

1. **The area is a polygon in lon/lat** (WGS84, `[lon, lat]`). Use one ring,
   no holes, no self-crossing.
2. **The south-west corner of its bounding box is the origin** (0, 0, 0) of
   your model: the smallest lon and the smallest lat.
3. **Your model is in metres.** x is east, y is north, z is up. z is the
   height above the ground.
4. **Keep every coordinate below 100,000 m** in size (absolute value). Do
   not send UTM or other absolute coordinates. The SDK refuses them.
5. **Trees and ground materials are in lon/lat**, not in metres.

Buildings, terrain and context geometry share the metre frame.

### The SDK moves it for you

Each tile has its own frame. The SDK moves every metre layer from the site
frame into each tile frame. You do not do this.

A bare `buildings` map has no frame. The SDK reads it as "already in the
frame of the run polygon". An acquired buildings object keeps its origin.
So pass the object that you got:

- You get buildings for polygon A and run polygon B with the bare map: the
  whole city moves by the distance between the two south-west corners.
- You pass the acquired object: the SDK moves it from its own origin. One
  acquisition for a large polygon can serve several smaller runs.

### What the SDK refuses

The SDK stops with an error, before you pay, when:

- the polygon is wrong, or a coordinate is not finite;
- a mesh has no `indices`;
- a ground layer name is unknown, or a terrain sheet is wrong;
- a tile coordinate is 100,000 m or more;
- you give more than 300,000 own sensor points.

</div>
</div>

### Silent traps

The SDK does **not** catch these. The run is billed and the result is wrong.

| Trap | What happens |
|---|---|
| **Lat and lon swapped** | The SDK refuses a latitude above 90 in size. So it catches the swap in places like Tokyo or Sydney. It is silent for most of Europe and the Americas. |
| **Y-up model** | Many tools use y as height. The SDK needs z up. |
| **Centimetres** (or feet) | The SDK reads every number as metres. |
| **Origin at the polygon centre** | The origin must be the south-west corner of the bounding box. |
| **Open meshes** | A building needs a closed solid with a bottom face. A mesh with holes or without a bottom face gives wrong shade. |

Check your model in a viewer before you run. A building that stands at the
right place and has the right height is a good sign.

### Example: coordinates

=== "Python"

    ```python
    buildings = client.buildings.get_area(polygon)   # keeps its origin
    result = client.run_area_and_wait(
        payload, polygon,
        buildings=buildings,          # pass the object, not buildings.buildings
    )
    ```

=== "TypeScript"

    ```ts
    const buildings = await client.buildings.getBuildingsInArea(polygon);
    const result = await client.runAreaAndWait(
      request, polygon,
      { buildings },                  // pass the object, not its inner map
    );
    ```

#### Learn more

- [Your inputs](#your-inputs): all the layers and the weather.
- [How a run works](#how-a-run-works): the tiles and the steps.
- Python: [the client](python/sdk.md). TypeScript:
  [the SDK reference](api/typescript/index.md).

## Tiling

The SDK cuts your area into square tiles. It sends one job for each tile.
Tiles are 512 m wide. The step from tile to tile depends on the analysis.
This page shows how the grid looks and what it means for your results.

<div class="side right" markdown>
<figure markdown>
![Top view of one area, cut into tiles in two ways: the solar-family grid on the left and the wind grid on the right.](assets/diagrams/tiling-families.svg)
</figure>
<div markdown>

### Two tile families

- **Solar family**: all tiled analyses except wind. The step is 512 m, so
  tiles do not overlap. Each tile also reads geometry 128 m past its edge.
- **Wind family**: wind speed and pedestrian wind comfort. The step is 256 m,
  so neighbour tiles overlap by 50 % each way. Wind tiles read no extra
  margin.

Each family has its own grid. Analyses of one family share it. A run with
a solar analysis and a wind analysis uses both grids.

### Empty tiles and the tile limit

- A tile that does not touch your area is empty. The SDK skips it and sends
  no job.
- One run has a maximum of 100 non-empty tiles. A larger area needs an
  explicit yes: `max_tiles_override` (`maxTilesOverride`).

### How the tiles join

Wind tiles overlap, so the SDK must choose which values to keep.

- **Default**: the SDK keeps the centre 256 m of each tile. The centres fit
  together with no gap.
- **Directional blend**: an option for wind speed only. It needs the wind
  direction. In `merge_area_jobs`, use `strategy="directional_blend"` with
  `wind_direction_deg` (TypeScript: `strategy`, `windDirectionDeg`).

</div>
</div>

<div class="side" markdown>
<figure markdown>
![Side view of one solar-family tile of 512 m with a 128 m margin on each side, a near tower, a far tower and a low sun.](assets/diagrams/context-margin.svg)
</figure>
<div markdown>

### Far shading

A tile job only holds geometry up to 128 m past the tile edge. Wind tiles
have a margin of 0 m.

- A tower in the margin is part of the job. Its shadow falls on the tile and
  the result counts it.
- A tall building farther away is not part of the job. Its shadow is missing
  from the result. The run gives no error.
- This is also true for `context_geometry` (`contextGeometry`). Only the
  ray-traced solar models and the interior models take it. The SDK does not
  cut it. A mesh goes whole into each tile job whose margin it touches, and a
  mesh that touches no margin is dropped. No layer adds shade from beyond
  128 m in 1.0.

**Coming in 1.1:** a `context_reach_m` setting (default 600 m) and a ring
mode for far geometry.

**Advice:** give far geometry as sparse, low-poly shapes. Each mesh costs
upload size once for each tile that holds it.

</div>
</div>

### Example: preview tiles

Count the tiles of each family before you run. This sends no job.

=== "Python"

    ```python
    solar = client.preview_area(polygon, analysis_type="solar-radiation")
    wind = client.preview_area(polygon, analysis_type="wind-speed")
    print(solar.tile_count, wind.tile_count)  # wind: about 4 times more
    ```

=== "TypeScript"

    ```ts
    const solar = client.previewArea(polygon, { analysisType: "solar-radiation" });
    const wind = client.previewArea(polygon, { analysisType: "wind-speed" });
    console.log(solar.tileCount, wind.tileCount); // wind: about 4 times more
    ```

#### Learn more

- [How a run works](#how-a-run-works)
- [Cost and retry](#cost-and-retry): how tiles become jobs and cost.
- Python: [the client](python/sdk.md), `preview_area`.
- TypeScript: [the SDK reference](api/typescript/index.md), `InfraredClient.previewArea`.

## Your inputs

Your own model is the normal input. Give the layers that you have. Each layer
has a fixed shape. Weather and a time period come on top.

<div class="side" markdown>
<figure markdown>
![An exploded stack of five layers over one site builds up from the bottom: terrain (not for wind), ground materials, buildings, trees and context geometry (not analysed). Each layer has a label.](assets/diagrams/inputs.svg)
</figure>
<div markdown>

### Your model, layer by layer

- **Buildings**: closed meshes `{id: {coordinates, indices}}` in metres
  (x east, y north, z up). Each building is one closed solid with a bottom
  face. All outdoor analyses read
  them. Facade and roof sensors come from them.
- **Trees**: `{id: GeoJSON Point}` in lon/lat, with `genus`, `height` and
  `crownDiameter` (metres). The `genus` sets the crown shape. Height and
  diameter are optional: a tree with none gets 6 m height and 4 m crown, and
  gives no warning. An unknown `genus` is a broadleaf tree.
- **Ground materials**: one GeoJSON FeatureCollection in lon/lat for each
  layer: `asphalt`, `concrete`, `soil`, `vegetation`, `water`. An unknown
  layer name is an error. Only the thermal comfort analyses read them.
- **Terrain**: a triangle mesh in the frame of the buildings, in
  `ground_geometry` (`groundGeometry`). There is no input for a height map:
  make a mesh first. Maximum 500,000 triangles in one request. `terrain_alignment`
  sets how your buildings and trees meet it: `"as-is"` (default),
  `"auto-align"` or `"assume-aligned"`. Wind analyses refuse terrain.
- **Context geometry**: far objects that only give shade, in
  `context_geometry` (`contextGeometry`). The SDK does not analyse them.
  The four sun and light analyses and the interior models take them. Wind
  and thermal comfort do not. A request with context geometry also needs
  terrain, facade or roof sensors, or your own sensor points: on its own it
  is refused.

Meshes are in metres. See [Coordinates](#coordinates) for the frame.

### Context geometry: the 1.0 limit

In 1.0, a context mesh reaches up to 128 m past a tile. An
object that is farther away is not sent. It gives no shadow and **no
error**. Keep far geometry sparse and low-poly: simple blocks for far
buildings and hills.

**Coming in 1.1:** `context_reach_m` (default 600 m) and a ring mode.

</div>
</div>

### Bring your own sensors

The ground grid is the default. You can choose where the values are:

- **Facades and roofs**: `analysis_surfaces` (`analysisSurfaces`) is
  `"facades"`, `"roofs"` or `"all"`. `surface_grid_size` (`surfaceGridSize`)
  sets the cell size in metres (default 2.0, minimum 0.25). `surface_offset`
  (`surfaceOffset`) sets the distance from the surface (default 0.1 m). This
  is an area run. See [Facades and roofs](#facade-and-roof-runs).
- **Your own points**: `sensor_points` (`sensorPoints`) is a list of
  `[x, y, z]` points in metres, in the frame of your geometry. The list
  must not be empty and has at most 300,000 points for each job.
  `sensor_normals` (`sensorNormals`) is optional: one non-zero normal
  `[x, y, z]` for each point, in the same order. You cannot use
  `analysis_surfaces` and `sensor_points` in one request.

Both work on the four sun and light analyses: solar radiation, sky view
factor, direct sun hours and daylight availability. Thermal comfort and
wind take no own sensors. The interior daylight factor takes
`sensor_points` and `sensor_surfaces` too. See
[Interior](#interior-beta).

Own points are not an area run: `run_area` and `run_area_and_wait`
(`runArea`, `runAreaAndWait`) refuse them. The SDK does not tile them. Send
your geometry with them in one request, and call
`client.analyses.run_and_wait(request)` (TypeScript:
`client.runAndWait(request)`). The result is a flat list with one value for
each point, under the key `output`.

=== "Python"

    ```python
    request = SvfModelRequest(
        analysis_type=AnalysesName.sky_view_factors,
        geometries=my_tower,                   # your meshes, in metres
        sensor_points=[[110, 90, 1.5], [110, 130, 15]],
        sensor_normals=[[0, -1, 0], [0, 1, 0]],
    )
    result = client.analyses.run_and_wait(request)
    values = result["output"]                  # one entry for each point
    ```

=== "TypeScript"

    ```ts
    const result = await client.runAndWait({
      analysisType: "sky-view-factors",
      geometries: { tower: { coordinates, indices } },
      sensorPoints: [[110, 90, 1.5], [110, 130, 15]],
      sensorNormals: [[0, -1, 0], [0, 1, 0]],
    });
    ```

### Weather and time period

Thermal comfort, solar radiation and energy balance read weather.

<div class="side right" markdown>
<figure markdown>
![Two weather sources at the top: the public weather catalog and your own EPW file. Below them, a heatmap of one typical year (Vienna, 12 months by 24 hours of air temperature) with a window from 1 December to 28 February, 08 to 18 h.](assets/diagrams/weather.svg)
</figure>
<div markdown>

#### Two sources

1. **Your own EPW file.** Use any hourly `.epw` file with one year of data
   (8760 rows). The SDK refuses sub-hourly files. It reads and checks the
   file on your machine. **Nothing is uploaded as a file.**
2. **The public catalog.** It has 16,757 stations. Find the nearest one by
   location. A station holds one typical year (TMYx): each month is one
   real month from many years of records (2009 to 2023 for most stations).

The catalog has no forecast, no future climate and no single year that
you choose. For those, use your own EPW file.

#### Time period

A time period is a date span with a daily hour range: start and end month,
day and hour. 1 Jun to 31 Aug, 08 to 18 h, keeps 1,012 hours.

A winter window works too. 1 Dec to 28 Feb is **one window** of December,
January and February (990 hours at 08 to 18 h). The values are in file
order: January first.

</div>
</div>

<div class="side" markdown>
<figure markdown>
![Seven weather columns on the left connect to three analyses on the right: thermal comfort (7 columns), solar radiation (2 columns) and energy balance (2 columns).](assets/diagrams/weather-fields.svg)
</figure>
<div markdown>

#### Which column goes where

The SDK sends only the arrays that the analysis reads.

- **Thermal comfort** (UTCI and statistics): 7 columns. They are air
  temperature, humidity, wind speed, global, direct and diffuse radiation,
  and infrared from the sky.
- **Solar radiation**: direct and diffuse radiation.
- **Energy balance** (interior, Beta): air temperature and global radiation.
- **Direct sun hours, daylight availability**: no weather array. They need a
  time period and a location. Sky view factor and daylight factor need no
  weather input.
- **Wind analyses**: the wind comes from the weather file too, but you put
  it into the request yourself. Pedestrian wind comfort takes the hourly wind
  speeds and directions of your time window from the file. Wind speed takes
  one speed and one direction: pick them from the file or set them.

The SDK reads the other columns of the file but does not send them, for
example dew point, pressure, sky cover, illuminance, rain and snow.

A gap in the file inside your window is an error before you pay. The SDK
never fills a gap.

</div>
</div>

=== "Python"

    ```python
    from infrared_sdk import parse_epw

    stations = client.weather.get_weather_file_from_location(lat=48.21, lon=16.37)
    rows = client.weather.filter_weather_data(
        identifier=stations[0]["uuid"], time_period=winter,  # winter: a TimePeriod, 1 Dec to 28 Feb
    )

    # or your own EPW file, read on your machine
    weather = parse_epw("vienna.epw")
    ```

=== "TypeScript"

    ```ts
    const stations = await client.weather.getWeatherFileFromLocation(48.21, 16.37, 100);
    const hours = await client.weather.filterWeatherData(stations[0].uuid, {
      period: { start: { month: 12, day: 1, hour: 8 },
                end: { month: 2, day: 28, hour: 18 } },
    });

    // or your own EPW file (pass the text)
    const weather = client.weather.parseEpw(await file.text());
    ```

Put the weather into the analysis request. See the example in
[How a run works](#how-a-run-works).

### No data yet? Public context

The SDK can read public data for your area:

| Layer | Public source |
|---|---|
| Buildings (`buildings.get_area`, `getBuildingsInArea`) | City overlay of Infrared where it exists, else Overture footprints |
| Ground materials (`ground_materials.get_area`, `groundMaterials.getArea`) | Roads from Infrared, with Overture water, land cover and land use |
| Trees (`vegetation.get_area`, `vegetation.getArea`) | Public Infrared tree data (no extra needed) |

Buildings and ground materials need the `geodata` extra: Python
`pip install "infrared-sdk[geodata]"`, TypeScript
`npm install hyparquet hyparquet-compressors`. There is no public terrain.

Pass the buildings object itself to the run, not only its inner map. The
object keeps its origin. See [Coordinates](#coordinates).

#### Learn more

- [Coordinates](#coordinates): the frame of your model.
- [How a run works](#how-a-run-works): the steps of one run.
- Python: [the client](python/sdk.md). TypeScript:
  [the SDK reference](api/typescript/index.md).

## The ten analyses

SDK 1.0 has ten analyses. Eight are outdoor analyses. They run on an area,
and the SDK cuts the area into tiles. Two are interior analyses. They are
**Beta**. You can run several outdoor analyses in one call.

<div class="side right" markdown>
<figure markdown>
![A matrix of the ten analyses in four groups: outdoor wind, outdoor sun and light, outdoor thermal comfort and interior (beta). Daylight factor has two rows: rooms, and horizontal surfaces that you give, such as a roof. Dots show where each analysis gives values: ground, facade, roof or room. Only solar radiation, sky view factor, direct sun hours and daylight availability give values on facades and roofs. The last column shows what each analysis needs: a weather file, time and place, wind from the file, or nothing.](assets/diagrams/analyses-matrix.svg)
</figure>
<div markdown>

### Outdoor wind

- **Wind speed**: the wind speed at each point, for one speed and one
  direction. Pick them from the weather file or set them. It takes no
  terrain.
- **Pedestrian wind comfort**: a comfort class for a criterion that you
  select. You give the hourly wind speeds and directions from the weather
  file. It takes no terrain.

### Outdoor sun and light

- **Solar radiation**: the solar radiation over a time period.
- **Sky view factor**: how much open sky each point sees. It needs no
  weather and no time period.
- **Direct sun hours**: the hours of direct sun over a time period.
- **Daylight availability**: the share of daylight over a time period.

Only these four give values on facades and roofs. They also take your own
sensor points (`sensor_points`, up to 300,000 for each job) and give one value
for each point. See [Bring your own sensors](#bring-your-own-sensors).

### Outdoor thermal comfort

- **Thermal comfort index (UTCI)**: the perceived outdoor temperature over a
  time period.
- **Thermal comfort statistics**: the percent of time in comfort, heat
  stress or cold stress.

### Daylight factor and energy balance (Beta)

- **Daylight factor: rooms**: the daylight factor at sensor points in rooms,
  under a CIE overcast sky. A large floor runs in parts.
- **Daylight factor: your sensors**: the same overcast-sky method on
  sensors that you give: your own points (`sensor_points`), or your own
  horizontal surfaces, for example a roof (`sensor_surfaces`). It is not an
  area run.

Daylight availability (above) and the daylight factor are two different
methods. Daylight availability uses the sun and the sky over a time window.
The daylight factor uses a standard overcast sky and needs no time.
- **Energy balance**: the monthly heating and cooling energy need of each
  zone. Run one building in one request.

Interior analyses do not run as an area run.

</div>
</div>

### All analyses at a glance

| Analysis | Tells you | Values on | Needs | Tiling family | Unit |
|---|---|---|---|---|---|
| Wind speed (`wind-speed`) | Wind speed for one speed and direction | Ground | One wind speed and direction (from the file or set). No terrain. | Wind | m/s |
| Pedestrian wind comfort (`pedestrian-wind-comfort`) | Comfort class for a criterion | Ground | A criterion and the hourly wind speeds and directions from the weather file. No terrain. | Wind | Comfort class |
| Solar radiation (`solar-radiation`) | Radiation over a time period | Ground, facade, roof | Time period, diffuse horizontal and direct normal radiation | Solar | kWh/m² |
| Sky view factor (`sky-view-factors`) | Open sky at each point | Ground, facade, roof | No weather, no time period | Solar | % (0-100) |
| Direct sun hours (`direct-sun-hours`) | Hours of direct sun | Ground, facade, roof | Time period and location | Solar | h |
| Daylight availability (`daylight-availability`) | Daylight over a time period | Ground, facade, roof | Time period and location | Solar | % |
| Thermal comfort index (`thermal-comfort-index`) | UTCI over a time period | Ground | Time period and 7 weather columns | Solar | °C |
| Thermal comfort statistics (`thermal-comfort-statistics`) | Percent of time in comfort, heat or cold stress | Ground | Time period and the same 7 weather columns | Solar | % of time |
| Daylight factor: rooms (`daylight-factor`) | Daylight factor at sensor points (Beta) | Room | Walls, slabs, windows and sensors. No weather. | None (floor parts) | % |
| Daylight factor: your sensors (`daylight-factor`) | Daylight factor at points or on horizontal surfaces you give (Beta) | Your points, roof | Your points (`sensor_points`) or surfaces (`sensor_surfaces`), context geometry. No weather. | None | % |
| Energy balance (`energy-balance`) | Monthly heating and cooling need (Beta) | Room | 2 weather series (default model). One building in one request. | None (one request) | kWh/m²·yr |

The tiling family sets the tile step and the context around each tile. See
[Tiling and context](#tiling).

#### Learn more

- [Your model](#your-inputs): the inputs each analysis reads.
- [Weather and time period](#weather-and-time-period): the weather columns.
- [Results](#what-comes-back): read real values, for example UTCI.
- [Interior analyses](#interior-beta): daylight factor and energy balance.
- [Facades and roofs](#facade-and-roof-runs): runs with values on surfaces.
- API reference: [Python](python/index.md) and [TypeScript](api/typescript/index.md).

## Interior (Beta)

Two interior models run on one building and not on an area: **daylight
factor** and **energy balance**. Both are in Beta. You send the walls, slabs
and windows. `run_area` refuses them.

<div class="side right" markdown>
<figure markdown>
![An exploded two-storey building builds up one input at a time. A numbered key names the inputs: barriers, openings, rooms, floors, sensors and context. At the end, each sensor point shows an example daylight factor.](assets/diagrams/interior-daylight-inputs.svg)
</figure>
<div markdown>

### Daylight factor: the inputs

The model gives the daylight factor in % under a CIE overcast sky. It has no
date and no time.

1. `barriers`: the walls and slabs (category `"wall"` or `"floor"`).
2. `openings`: the windows (category `"window"`). A window can carry its own
   light transmittance from 0 to 1 (`opening_factor=` in
   `interior_entities`). Without it, `glazing_transmittance` applies
   (default 0.63).
3. `spatial_volumes` (optional): one closed volume for each room (category
   `"space"`), for results for each room.
4. `floors`: the storeys to calculate, by index or by UUID.
5. The sensors: a grid on each floor (`grid_size`, default 0.5 m;
   `analysis_height`, default 0.8 m), or your own sensors (see
   "Your own sensors" below).
6. `context_geometry`: neighbours that only give shade. Without them the rooms
   read too bright. `ground_geometry` holds the terrain.
7. `room_reflectances`: floor 0.2, walls 0.5, ceiling 0.7 by default.

The result is one value for each sensor point. With rooms, you also get the
mean, minimum, maximum and the share of area at 2 % or more for each room.

</div>
</div>

### Your own sensors

Bring your own sensors instead of the floor grid. This also works outside a
room: the same overcast-sky method, for example on a roof.

- `sensor_points` (`sensorPoints` in TypeScript): a list of `[x, y, z]`
  points in metres. The result has one value for each point.
- `sensor_surfaces` (`sensorSurfaces`): your own meshes by id. The model puts
  a sensor grid on them. The server refuses a surface that is not
  horizontal, so a facade does not work here. For facades, use an outdoor
  analysis with `analysis_surfaces`.
- If you send both, `sensor_points` wins and `sensor_surfaces` is ignored.
  The SDK refuses this. With either one, `floors` is ignored: the SDK
  refuses `floors`, `floor_index` and `floor_uuid` next to them.
- A request with your own sensors is one job. The SDK does not split it into
  parts.
- Add `context_geometry` for the neighbours that give shade. Barriers are
  optional when you bring `context_geometry`, `ground_geometry` or trees.

<div class="side" markdown>
<figure markdown>
![A building with a big hall on the ground floor and five upper floors. The hall has more than 300,000 points, so the SDK splits it inside the floor into parts of at most 300,000 points. Part 3 fails: the first call raises PartsRunError with no result, and a second call with retry_from sends only part 3.](assets/diagrams/interior-parts.svg)
</figure>
<div markdown>

### Parts of a large building

The SDK counts the sensors of each floor and packs whole floors into parts of
about 300,000 points. A floor that is bigger than one part is split inside the
floor. Each part is one job, and each job is billed. The SDK joins the parts
exactly.

All parts must finish. If one part fails, you get a `PartsRunError` and no
result. Call again with `retry_from=exc.schedule`: the SDK sends only the
failed parts. A part that is sent again is a new job, so it is billed.

When the server supports it, the SDK uploads the scene one time and all parts
use it. Else each part sends its own JSON.

To see the part count and the cost first, use
`client.analyses.preview_parts(request)`. Energy balance has no parts: it
sends one request for each building.

</div>
</div>

<div class="side right" markdown>
<figure markdown>
![An exploded two-storey building builds up one input at a time. A numbered key names the inputs: zones, barriers, openings, context, weather, solar model and settings. At the end, each zone shows an example energy need.](assets/diagrams/interior-energy-inputs.svg)
</figure>
<div markdown>

### Energy balance: the inputs

The model calculates the heating and cooling energy **need** of each zone
(monthly method).

1. `spatial_volumes`: one closed volume for each zone (category `"space"`).
   This field is required.
2. `barriers`: the walls (`"wall"`) and every slab, the roof too (`"floor"`).
3. `openings`: the windows.
4. `context_geometry` and `ground_geometry`: neighbours and terrain. They give
   shade only with `solar_model="irradiance"`.
5. Weather: one year of hourly data from an EPW file, through
   `from_weather(parse_epw(...))`. For a leap year (8784 hours), set `year`.
6. `solar_model`: `"legacy-flat"` is the default. It uses two series
   (`dry_bulb_temperature`, `global_horizontal_radiation`) and ignores shade.
   `"irradiance"` is opt-in. It uses the shade geometry and needs latitude,
   longitude and four hourly series.
7. `energy_settings=EnergySettings(...)`: the U-values, glazing, set-points,
   gains, infiltration and thermal mass. Every field is optional, and an
   unset field uses the server default. The defaults include `u_values`
   (`ext_wall` 0.13, `flat_roof` 0.25, `ground_floor` 0.30 W/m²K),
   `glazing` (`u_value` 1.1, `shgc` 0.60, `frame_fraction` 0.15),
   `occupancy` (`heating_setpoint` 21 °C, `cooling_setpoint` 26 °C,
   `people_gains` 1.6 W/m²), `ach_infiltration` 0.03 and
   `construction_class="medium"`. See
   [Material and building properties](#material-and-building-properties)
   for all names, units and defaults.
8. `operation`: when the plant runs. The default is continuous (24 hours,
   7 days). For an office, use
   `Operation.intermittent(hours=(8, 18), days="weekdays")`.

The ground reflectance for ground-reflected sun is `ground_reflectance`
(default 0.2). You cannot set a reflectance for each wall or window: the
properties apply to the whole request. For several buildings in one request,
each entry of `buildings` can override the request.

**Keep one building whole in one request.** A slab with heated rooms on both
sides is internal. If you send one storey alone, it loses heat through its
slabs. The result is the need in kWh/m²·yr (`EUI_heat`, `EUI_cool`) and 12
monthly values. It is not delivered energy: no boiler, heat pump or chiller
efficiency is applied.

</div>
</div>

### Example: interior run

=== "Python"

    ```python
    from infrared_sdk import PartsRunError, interior_entities, parse_epw
    from infrared_sdk.analyses.types import (
        AnalysesName, DaylightFactorModelRequest, EnergyBalanceModelRequest,
    )

    # my_barriers: interior_entities(...) of category "wall" and "floor"
    windows = interior_entities(my_windows, category="window")
    df = DaylightFactorModelRequest(
        analysis_type=AnalysesName.daylight_factor,
        barriers=my_barriers, openings=windows, floors=[0, 1],
        context_geometry=interior_entities(my_neighbours),
    )
    try:
        result = client.analyses.run_and_wait(df)
    except PartsRunError as exc:       # retry sends only the failed parts
        result = client.analyses.run_and_wait(df, retry_from=exc.schedule)

    eb = EnergyBalanceModelRequest.from_weather(
        parse_epw("vienna.epw"), year=2021,
        spatial_volumes=interior_entities(my_zones, category="space"),
        barriers=my_barriers, openings=windows,
    )
    energy = client.analyses.run_and_wait(eb)   # one job: the result dict
    ```

=== "TypeScript"

    The TypeScript SDK has no typed request classes for these two models. Send
    a plain object with the wire keys to `client.runAndWait(request)`. A
    daylight factor run gives a `DaylightFactorResult`, and
    `client.previewParts` shows the parts. The TypeScript SDK has no typed
    energy balance request, so use Python for the `"irradiance"` solar model.

    ```ts
    const request = { "analysis-type": "daylight-factor",
                      barriers: myBarriers, openings: myWindows, floors: [0, 1] };
    const result = await client.runAndWait(request) as DaylightFactorResult;
    ```

#### Learn more

- [What comes back](#what-comes-back): the shape of an interior result.
- Python: [the client](python/sdk.md).
- TypeScript: [the SDK reference](api/typescript/index.md).

## What comes back

An area run gives one merged result. A facade or roof run gives one value for
each surface cell. An interior run gives one value for each sensor point. In
all cases, read the values with the helper of the SDK. The helper gives you the
real values, whatever type the server used to send them.

<div class="side right" markdown>
<figure markdown>
![Three panels: a ground grid of 1 m cells over an area, with no value outside it; a building with one value for each facade and roof cell; a floor plan with sensor points in three rooms.](assets/diagrams/results-shapes.svg)
</figure>
<div markdown>

### Three shapes

1. **Ground grid (area runs).** The SDK merges the tiles and clips the result
   to your area. You get one map with cells of 1 m. A cell outside the area
   has no value.
2. **Facades and roofs (surface runs).** You get one value for each cell of
   each surface, in columns.
3. **Interior points (daylight factor).** You get one value for each sensor
   point. Each point has a room, or no room. See [Interior](#interior-beta).

| | Python | TypeScript |
|---|---|---|
| Ground grid | `result.merged_grid`, `result.physical_grid()` | `result.mergedGrid`, `areaGridValuesF32(result)` |
| Surface cells | `result.columns.values`, `columns.physical_values()` | `columns.values`, `surfaceValuesF32(columns)` |
| Interior points | `result.columns.values`, `result.columns.room` | `result.values`, `result.room` |

</div>
</div>

<div class="side" markdown>
<figure markdown>
![A flow from the raw array through the helper to real values. A red trap panel: in TypeScript an f16 grid holds half-float bits, so a UTCI of 23.4 °C reads as 19930; the helper gives 23.4.](assets/diagrams/values-dtype.svg)
</figure>
<div markdown>

### Wire type and real values

The server stores each result in the smallest type that is accurate enough.
The raw array keeps this wire type:

- **f16** (half float): solar radiation, sky view factor, thermal comfort
  statistics, wind speed, and UTCI.
- **f32**: direct sun hours, daylight availability, and the class codes of
  pedestrian wind comfort.

Do not read the raw array. Use the helper. It handles every type, and it
gives NaN to a cell with no value.

In TypeScript, an f16 grid is a `Uint16Array` of half-float **bits**, not of
numbers. `areaGridValuesF32` and `surfaceValuesF32` give you a `Float32Array`
of real values.

</div>
</div>

### The legend range

The result holds the range of its own values: `min_legend` and `max_legend`
(`minLegend` and `maxLegend` in TypeScript). The SDK measures them from the
finished result. Use them for your colour scale. Class codes have no legend
range.

### Example: read a result

=== "Python"

    ```python
    result = client.run_area_and_wait(payload, polygon, buildings=buildings)

    values = result.physical_grid()          # float64, NaN = no value
    print(result.min_legend, result.max_legend)

    # a facade or roof run
    cells = result.columns.physical_values()
    ```

=== "TypeScript"

    ```ts
    import { areaGridValuesF32, surfaceValuesF32 } from "@infrared-city/infrared-sdk-ts";

    const result = await client.runAreaAndWait(payload, polygon, { buildings });

    const values = areaGridValuesF32(result);   // Float32Array, NaN = no value
    console.log(result.minLegend, result.maxLegend);

    // a facade or roof run gives SurfaceColumns (see "Facade and roof runs")
    const cells = surfaceValuesF32(surfaceResult);
    ```

#### Learn more

- [Facade and roof runs](#facade-and-roof-runs): batches and render buffers.
- [Interior](#interior-beta): daylight factor and energy balance.
- Python: [the client](python/sdk.md).
- TypeScript: [the SDK reference](api/typescript/index.md).

## Facade and roof runs

A facade or roof run puts sensors on the walls and roofs of your buildings. It
gives one value for each sensor cell. Only solar radiation, sky view factor,
direct sun hours and daylight availability run on surfaces. Set
`analysis_surfaces` (`analysisSurfaces`) to `"facades"`, `"roofs"` or `"all"`.
The sensors replace the ground grid.

<div class="side" markdown>
<figure markdown>
![A tile with six target buildings and one neighbour building gets a grid of sensor cells on the target buildings. The buildings go into three batches. A second tile has no target building and sends no job.](assets/diagrams/facade-batches.svg)
</figure>
<div markdown>

### Batches

1. **Sensors.** The SDK puts a grid of sensor cells on the walls and roofs of
   the target buildings of each tile.
2. **Batches.** The SDK puts the buildings into batches by sensor count. A
   batch has at most 250,000 sensors, counted exactly. The SDK groups the
   buildings by building id, not by place.
3. **One job for each batch.** Each job is billed. A tile with no target
   building sends no job and costs nothing.
4. **The scene.** When the server supports it, the SDK uploads the scene of a
   tile one time. The jobs of the tile use that upload.
5. **Shade.** Neighbour buildings and the buildings of other batches only give
   shade. They get no result.
6. **Merge.** The SDK joins the batches into one result.

To see the number of jobs before you run, use `preview_area(..., payload=...)`
and read `would_bill_jobs`. Without `payload=`, the facade count is too low.

</div>
</div>

### Draw the result: render buffers

To draw the cells, you do not need one mesh for each cell. The **render
buffers** are a few flat arrays that any renderer can read: deck.gl, three.js
or raw WebGL. See [Render buffers in detail](#draw-facade-and-roof-results-fast).

<div class="side right" markdown>
<figure markdown>
![One wall frame of 6 by 4 cells. The slow way draws one mesh for each cell (48 triangles). The fast way draws only the outline (2 triangles here). The shader finds cell k and reads its value and its validity bit. A cell with no value holds 0 and a clear bit.](assets/diagrams/render-buffers.svg)
</figure>
<div markdown>

A **frame** is one flat region (a wall or a roof part) with a regular grid of
cells. The buffers hold:

- `outline`: the triangles of each frame, in cell units. They follow the exact
  border of the surface.
- `frames` and `dims`: the corner and steps of each frame, and its columns,
  rows and first cell (`cellStart`).
- `values`: one value for each cell, `f16` or `f32`.
- `validity`: one bit for each cell. A cell with no value has value `0` and a
  clear bit. Values are never NaN. Test the bit, not the value.
- `anchor`: the centre of the run, in 64-bit floats. Keep it in the model
  transform of your scene.
- `valueMin`, `valueMax`, `anyValid`: the range for your colour scale.

The shader finds cell `k = cellStart + i * nu + j` from the outline point
`(s, t)`: `j = floor(s)`, `i = floor(t)`. It reads `values[k]` and bit `k & 7`
of byte `k >> 3` of `validity`. Draw frames with both faces. Keep one run to
one site, so the corners stay accurate.

</div>
</div>

### Save, reload and free

The **layout** (frames and outline) depends only on the geometry. Save it one
time for each geometry. The **values** of each run are one blob with no
outline. It holds the `layout_key` of its layout. A live result needs no
layout: merge the run in the client that submitted it. Save the layout only to
draw later, or in another process. `attach_values` (`attachValues`) refuses a
layout that does not match the values. The Python SDK and the TypeScript SDK
can load the files that the other SDK saves.

To drop a schedule that you will not merge, call
`client.forget_schedule(schedule)` (`client.jobs.captures.forgetSchedule`).
Memory rules are in [Render buffers in detail](#draw-facade-and-roof-results-fast).

### Example: facade run

=== "Python"

    ```python
    from infrared_sdk.analyses.types import SvfModelRequest
    from infrared_sdk.analyses.surface_columns import SurfaceColumns
    from infrared_sdk.facade_layout import FacadeLayout, attach_values

    payload = SvfModelRequest(analysis_type="sky-view-factors",
                              analysis_surfaces="facades", surface_grid_size=3.0)
    result = client.run_area_and_wait(payload, polygon, buildings=my_buildings)
    buffers = result.columns.render_buffers()    # a live result

    # later, from saved bytes
    layout = FacadeLayout.from_bytes(saved_layout)
    columns = SurfaceColumns.from_bytes(saved_values)
    attach_values(layout, columns, expected_layout_key=columns.layout_key)
    buffers = columns.render_buffers(layout=layout)
    ```

=== "TypeScript"

    ```ts
    import { FacadeLayout, attachValues, surfaceRenderBuffers, type SurfaceColumns }
      from "@infrared-city/infrared-sdk-ts";

    const payload = { analysisType: "sky-view-factors",
                      analysisSurfaces: "facades", surfaceGridSize: 3 };
    const columns = (await client.runAreaAndWait(payload, polygon,
      { buildings: myBuildings })) as SurfaceColumns;
    const buffers = surfaceRenderBuffers(columns);   // a live result

    // later, from saved bytes
    const layout = FacadeLayout.fromBytes(savedLayout);
    attachValues(layout, columns, { expectedLayoutKey: savedLayoutKey });
    const saved = surfaceRenderBuffers(columns, { layout });
    layout.free();
    ```

#### Learn more

- [What comes back](#what-comes-back): wire types and the helpers.
- [Serve many users](#serve-many-users): a server that draws for many users.
- Python: [the client](python/sdk.md).
- TypeScript: [the SDK reference](api/typescript/index.md).

## Where it runs

The SDK runs on your machine. Only the simulation models run in the Infrared
cloud. This page shows where the SDK can run.

<div class="side right" markdown>
<figure markdown>
![Only the simulation models run in the Infrared cloud, and you pay for each job. The SDK on your machine does all other steps: plan the tiles, prepare the site, upload geometry, submit jobs, poll the status, download results, merge the tiles and, as an option, build render data.](assets/diagrams/where-it-runs.svg)
</figure>
<div markdown>

### One core

The SDK has one core, written one time in Rust. Python and TypeScript use the
same compiled core for the hard steps: tiling, geometry preparation, packing
and merge. You get the same result in Python and in the browser.

Your machine sets the speed of these steps. The network and the cloud models
set the rest. Measured on 2026-10-07 (1.0.0 packages, production API): one
square kilometre of Vienna (4,212 buildings, own data) with sky view factor,
wind and UTCI in one Python call took 8.7 s.

### Python

- By default the SDK submits 8 tiles, polls with 5 threads and merges with 8
  threads. The submit limit is 20.
- More than 8 threads do not help: 9.8 tiles/s with 8, 9.4 with 20.

### Node.js

- Use Node 18 or later. The defaults are the same as in Python: submit 8,
  merge 8. The core uses one thread, and a network call does not block it.
- Call `await initializeCore()` one time before a run call (`runArea` or
  `runAreaAndWait`). Without it, a run call throws `CoreNotReadyError`. Only
  the public site reads (`buildings.getBuildingsInArea`, `vegetation.getArea`,
  `groundMaterials.getArea`) load the core by themselves.
- For big merges, opt in to threads (Node 22 or later):

```ts
await initializeCore({ threads: 4 });
```

A 4 km facade merge took 5.5 s with 4 threads and 10.0 s without. Four is
the best value measured. Threads do not speed up network calls. A failure on
a thread ends the process, so use threads where a restart is acceptable.

**Module format.** The package is ESM. CommonJS `require()` works for the
root entry only. The types are in the package: use TypeScript 5.4 or later
(5.8 for CommonJS).

</div>
</div>

### TypeScript in the browser

<div class="side" markdown>
<figure markdown>
![A browser tab with the page and one SDK Web Worker, an optional geometryUrlStore in IndexedDB, your proxy on the same origin, and the Infrared API. The first visit uploads three tiles, sends a job and gets the result. After a reload, the new worker reads the upload URLs from the store and does not upload again.](assets/diagrams/browser-app.svg)
</figure>
<div markdown>

The browser runs the core on one thread and needs no special headers. Use a
Web Worker to keep the page free.

1. In the worker file, call `serveSdkWorker()`.
2. On the page, call `createWorkerClient`. Compile the WASM module one time
   and give it to the worker. Call `initializeCore` on the page and in each
   worker: each has its own core.
3. Put your API key in a proxy on your own origin. The browser never
   holds it.
4. Optional: pass a `geometryUrlStore` that you own. A new worker then does
   not upload the tiles again.


</div>
</div>

```ts
// sdk.worker.ts
import { serveSdkWorker } from "@infrared-city/infrared-sdk-ts/worker";
serveSdkWorker();

// page
import { createWorkerClient } from "@infrared-city/infrared-sdk-ts/worker";
const sdk = createWorkerClient({
  worker: new Worker(new URL("./sdk.worker.ts", import.meta.url), { type: "module" }),
  module,                                    // the compiled WASM module
  config: { baseUrl: `${location.origin}/infrared-api` }, // your proxy
  getToken: () => session.accessToken(),     // your session, not the API key
});
```

For the proxy and the save and reload steps, read
[Serve many users](#serve-many-users).

### A good worker pattern

<div class="side right" markdown>
<figure markdown>
![One SDK worker serves one signed-in session. The page sends a call to the worker. The worker prepares, uploads, submits and merges. Your proxy adds the API key, and up to 8 jobs are in flight. The page polls the status, gets the result by transfer and draws it. The page stays free all the time.](assets/diagrams/worker-pattern.svg)
</figure>
<div markdown>

**Who does what.** The worker prepares the site, uploads the geometry and
submits the jobs (`runArea`). It then downloads, decodes and merges the
results (`mergeAreaJobs`) and transfers the buffers to the page. When you use
`runArea` and `mergeAreaJobs` in a worker, poll the job status with a plain
`InfraredClient` on the page (same `config` and `getToken`). The worker client
has no `runAreaAndWait`. The page builds the render buffers.

**Do**

- Use one SDK worker for each signed-in session.
- Keep the heavy work in the worker and the API key in your proxy.
- Transfer the result buffers to the page. Do not clone big arrays.
- Use a `geometryUrlStore` to skip the upload after a reload. See
  [Reuse uploads across reloads and workers](#reuse-uploads-across-reloads-and-workers).
- Use one client for each process. In Python, keep `max_workers` near 8.
- Free what you keep: `forget_schedule` (`forgetSchedule`) and `close()`.

**Do not**

- Do not start one worker for each tile, or share one worker between two
  clients (a second `createWorkerClient` on the same worker throws).
- Do not send a run again after a worker failure. A paid job can exist.
- Do not use `threads` in the browser. It refuses them.
- Do not run many large merges in one process. A 4 km facade merge peaks at
  about 3 GB.

**The sweet spot.** One SDK worker, the default 8 jobs in flight, and 4
threads only in Node 22 or later for big merges. More workers use more
memory: each has its own core and its own copy of the geometry.

</div>
</div>

#### Learn more

- [How a run works](#how-a-run-works) and [Serve many users](#serve-many-users)
- [TypeScript reference](api/typescript/index.md), [Python client](python/sdk.md)

## Serve many users

Use this guide when many people use your app to run facade or roof analyses
and look at the results. You choose where the SDK runs. Both ways use the same
SDK calls and saved formats. For the runtimes, read [Where it runs](#where-it-runs).

<div class="side" markdown>
<figure markdown>
![A browser tab with the page and one SDK Web Worker, an optional geometryUrlStore in IndexedDB, your proxy on the same origin, and the Infrared API. The first visit uploads three tiles, sends a job and gets the result. After a reload, the new worker reads the upload URLs from the store and does not upload again.](assets/diagrams/browser-app.svg)
</figure>
<div markdown>

### Two ways

| | A. In the browser (default) | B. On your server |
|---|---|---|
| Who pays for the compute | Each user's device | Your server |
| Your server does | Hides the API key (a proxy), optional quotas | Holds the key, runs, merges and stores |
| Best for | Interactive apps | Batch jobs, no browser, shared results |

You can mix the two. A result that a server made can be drawn in a browser,
and the other way round.

**Never send the API key to a browser.** In both ways the key stays on your
server.

### A. In the browser

The SDK runs in one Web Worker on the user's device. Use one client for each
tab. It keeps the geometry once for all analyses of that user. For the worker
code, read [Where it runs](#where-it-runs).

The browser must call the API through a proxy on your own origin. The proxy:

1. Checks that the user is signed in (your own check).
2. Optionally counts the runs of the user against a quota.
3. Replaces the `Authorization` header with your key (`X-Api-Key`).
4. Forwards the request to `https://api.infrared.city/v2`.
5. Also relays the storage URLs for upload and download, because the
   browser blocks them. The code below does not show this. See "Calling the
   API from a browser page" in the TypeScript README.


</div>
</div>

```js
// A small proxy. Any server or edge function that can forward a request works.
// Add the relay for the storage URLs (step 5) yourself.
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (!url.pathname.startsWith("/infrared-api/")) return new Response("Not found", { status: 404 });
    const user = await verifySession(request.headers.get("Authorization"), env); // your check
    if (!user) return new Response("Forbidden", { status: 403 });
    const headers = new Headers(request.headers);
    headers.delete("Authorization");
    headers.set("X-Api-Key", env.INFRARED_API_KEY); // a secret on the server only
    const target = "https://api.infrared.city/v2/" + url.pathname.slice("/infrared-api/".length) + url.search;
    return fetch(target, { method: request.method, headers, body: request.body });
  },
};
```

The SDK has no per-user API key. To limit each user, count in the proxy.

### B. On your server

Use one client for each process. A second client holds the geometry again.

=== "Python"

    ```python
    import os
    from infrared_sdk import InfraredClient

    client = InfraredClient(api_key=os.environ["INFRARED_API_KEY"])
    ```

=== "TypeScript"

    ```ts
    import { InfraredClient, initializeCore } from "@infrared-city/infrared-sdk-ts";

    await initializeCore(); // { threads: 4 } on Node 22+ for big merges
    export const client = new InfraredClient({ apiKey: process.env.INFRARED_API_KEY });
    ```

- The client keeps the geometry of each facade or roof job in its own memory.
  Analyses on the same geometry share one copy.
- Submit and merge with the same client, in the same process. A schedule that
  another process merges gives a result with no outline. That process needs a
  saved layout.


### Save and reload

Save two things in your own storage:

- The layout, one time for each geometry: `layout.toBytes()` and
  `layout.layoutKey`.
- The values of each result: `surfaceColumnsToBytes(columns, { layoutKey })`
  (Python: `columns.to_bytes(layout_key=...)`).

To reload, load both (`FacadeLayout.fromBytes`, `surfaceColumnsFromBytes`),
check them with `attachValues`, and call
`surfaceRenderBuffers(columns, { layout })`. To rebuild the layout of an older
run, pass the `surfgridVersion` (`surfgrid_version`) of that run. Leave
`emitCellTris` off: it makes the layout about three times larger. For the
code and the drawing steps, read
[Render buffers in detail](#draw-facade-and-roof-results-fast).

### Reuse uploads across reloads and workers

The SDK keeps the URL of each uploaded tile geometry in memory. A new worker
or a page reload uploads all tiles again. A `geometryUrlStore` that you own
keeps the URLs, so the same geometry is not uploaded twice.

```ts
const geometryUrlStore = {
  async get(key: string) { return await idbGet(key); }, // { url, expiresAt } or undefined
  async set(key: string, url: string, expiresAt: number) {
    await idbPut(key, { url, expiresAt });
  },
};
serveSdkWorker({ geometryUrlStore /* , ...other options */ });
```

`idbGet` and `idbPut` stand for your own IndexedDB store.

- One store serves every worker of the session.
- The SDK makes the keys from a digest of the credentials. A renewed token
  gives a new key. Keep one store for each user.
- An entry expires after 23 hours. A refused URL is uploaded again by the SDK.
- An entry is a read link: delete the store at sign-out.
- Python keeps URLs in memory until they expire (the link lasts about 24
  hours) and has no store option.

### Clean up and limit

- Free the geometry of a schedule that you drop:
  `client.jobs.captures.forgetSchedule(schedule)` or
  `client.forget_schedule(schedule)`.
- `runAreaAndWait` frees the jobs of a failed or aborted run itself.
- At exit, call `client.jobs.captures.free()` (TypeScript) or `client.close()`
  (Python).
- A merge of a large site uses much memory for a short time. A 4 km city
  facade run (about 400,000 surfaces) peaked at about 3 GB. Run 1 or 2 merges
  at a time in one process, and measure your own sites.
- On Node, a failure in a threaded core ends the process.

### Checklist

- The API key is only on your server.
- Browser way: one worker client for each tab.
- Server way: one client for each process.
- The layout is saved one time for each geometry, with its `layoutKey`.
- Abandoned schedules go to `forgetSchedule` (`forget_schedule`).

## Cost and retry

One tile is one billed job. This page shows how to count the jobs before you
run, what happens when a tile fails, and how the SDK keeps a retry from
billing a job two times.

<div class="side" markdown>
<figure markdown>
![One site, run with one analysis and then with three. Each analysis has its own tile grid and its own jobs: 4 + 4 + 16 = 24 jobs. The two solar-family analyses share 4 uploads.](assets/diagrams/jobs-and-cost.svg)
</figure>
<div markdown>

### What is one job

- **Grid analyses**: one job for each tile.
- **Facades**: one job for each building batch.
- **Daylight factor**: one job for each part. Count the parts with
  `client.analyses.preview_parts(request)` (Python) or `client.previewParts(request)`.
- **Several analyses**: each family has its own grid, so each has its own
  jobs. Solar-family analyses on the same grid and layers share the uploads.
  An upload saves time. It does not change the job count.

There is no result cache. The same run again is billed again.

### Count before you run

Use `preview_area` (`previewArea`). It sends no job.

1. Always give the analysis: `analysis_type=` (`analysisType`) or
   `payload=`. Without it, the preview uses the wind grid. A solar count is
   then about 4 times too high, and you get a warning.
2. Python: price from `would_bill_jobs`, not from `tile_count`. For facades,
   give `payload=`.
3. TypeScript: `previewArea` has no job count. It returns `tileCount` and
   `estimatedCostTokens` at the default price per job. This is right for a
   grid analysis. For facades, and to get the job count, use
   `await previewAreaBatches(input, polygon, options)`. It reads
   `plannedJobCount` from the plan that `runArea` would send. Pass the same
   `input` and buildings as the run. For the live price, use
   `previewAreaWithPricing`.
4. One call prices one analysis. For three analyses, call it three times and
   add the results.

</div>
</div>

<div class="side right" markdown>
<figure markdown>
![An area run with 6 tiles. Tile 2 fails on the server. The answer for tile 5 is lost on the network. A retry sends only these two tiles. Each job has a small tag: its idempotency key.](assets/diagrams/retry-idempotency.svg)
</figure>
<div markdown>

### When a tile fails: retry keys

A failed tile does not stop the other tiles. The merge gives no partial map.
Python raises `AreaRunError`. TypeScript throws an `Error`.

- `run_area_and_wait` (`runAreaAndWait`) sends the failed tiles again one
  time (`retries=1`). It raises only if the retry fails too. `retries=0`
  turns this off.
- With `run_area` (`runArea`), you retry yourself:
  wait for the jobs first, then use `retry_from=schedule`
  (`retryFrom: schedule`). The SDK sends only the tiles that need it.

### Keys: no double bill

Each job has an idempotency key. One key gives at most one job.

- **No answer** (for example, the network drops it): the SDK sends the same
  key again, up to 6 sends in all. The server returns the job it already has.
- **Failed or refused**: the retry gets a new key and makes a new job.

### No credits (402)

On HTTP 402 the SDK stops sending the other tiles and does not retry. Tiles
sent before may still run and bill. Add credits, then retry.

</div>
</div>

### Example: count and retry

=== "Python"

    ```python
    preview = client.preview_area(polygon, analysis_type="solar-radiation")
    print(preview.would_bill_jobs)          # price from this number

    schedule = client.run_area(payload, polygon, buildings=my_buildings)
    # ... some tiles failed: send only those again
    schedule = client.run_area(payload, polygon, buildings=my_buildings,
                               retry_from=schedule)
    ```

=== "TypeScript"

    ```ts
    const preview = await client.previewAreaBatches(input, polygon, { buildings: myBuildings });
    console.log(preview.plannedJobCount);   // price from this number

    let schedule = await client.runArea(input, polygon, { buildings: myBuildings });
    // ... some tiles failed: send only those again
    schedule = await client.runArea(input, polygon, {
      buildings: myBuildings, retryFrom: schedule,
    });
    ```

#### Learn more

- [How a run works](#how-a-run-works)
- [Tiling](#tiling)
- Python: [the client](python/sdk.md), `preview_area` and `run_area_and_wait`.
- TypeScript: [the SDK reference](api/typescript/index.md), `previewArea` and `runAreaAndWait`.

## Draw facade and roof results fast

A facade or roof run gives one value for each cell of each surface. To draw
these cells, you do not need one mesh for each cell. Use the **render
buffers**. They are a few flat arrays that any renderer can read.

Use the render buffers when you draw a facade or roof result on screen, in
deck.gl, three.js or raw WebGL.

### What the render buffers are

A surface result has many **frames**. A frame is one flat region (a wall or a
roof part) with a regular grid of cells. The buffers hold:

| Buffer | Type | Meaning |
|---|---|---|
| `anchor` | f64 x 3 | Centre of the run. Add it back in 64-bit floats. |
| `frames` | f32 x 9 for each frame | `corner`, `uStep` and `vStep`, relative to `anchor`. |
| `dims` | u32 x 3 for each frame | Columns `nu`, rows `nv`, and `cellStart` (the first cell of the frame in `values`). |
| `outline` | f32 x 6 for each triangle | The triangles of each frame, as `s0 t0 s1 t1 s2 t2`. |
| `outlineOffsets` | u32 x (frames + 1) | Frame `f` owns the triangles `outlineOffsets[f]` to `outlineOffsets[f + 1]`. |
| `values` | f16 or f32 for each cell | The value of a cell. `0` when the cell has no value. |
| `validity` | u8, one bit for each cell | Cell `k` is bit `k & 7` of byte `k >> 3`. A set bit means the cell has a value. |
| `valueMin`, `valueMax`, `anyValid` | numbers | The range over the cells that have a value, for your colour scale. |

Important points:

- **The format does not depend on a renderer.** It is plain arrays.
- **One outline for each frame.** You draw the outline triangles of a frame.
  The outline gives the exact border of the surface. There is no mesh for each
  cell, so the buffers are small.
- **Outline coordinates are in cell units.** A point `(s, t)` of the outline is
  at `corner + s * uStep + t * vStep`. A cell `(i, j)` covers `s` from `j` to
  `j + 1` and `t` from `i` to `i + 1`.
- **Values keep their native type.** The server sends half floats (`f16`) for
  solar radiation and sky view factor. It sends 32-bit floats for sun hours
  and daylight availability. The buffers give
  `f16` for `f16` results and `f32` for all others. Nothing is widened to 64
  bits.
- **Validity is a separate bit.** A cell with no value (for example, a cell
  under terrain) has `values[k] = 0` and a clear validity bit. Values are never
  NaN or infinite. Test the bit, not the value.

### Get the buffers

#### TypeScript

```ts
import {
  InfraredClient, initializeCore, surfaceRenderBuffers, type SurfaceColumns,
} from "@infrared-city/infrared-sdk-ts";

await initializeCore();
const client = new InfraredClient({ apiKey: process.env.INFRARED_API_KEY });

// A facade run returns SurfaceColumns. `runAreaAndWait` is typed for every
// analysis, so narrow the result.
const columns = (await client.runAreaAndWait(
  { analysisType: "sky-view-factors", analysisSurfaces: "facades", surfaceGridSize: 3 },
  polygon,
  { buildings },
)) as SurfaceColumns;

const buffers = surfaceRenderBuffers(columns);
console.log(buffers.valueDtype, buffers.values.length, buffers.valueMin, buffers.valueMax);
```

`valueDtype` is `"f16"` or `"f32"`. For `"f16"`, `values` is a `Uint16Array` of
half-float bits. Node 18 to 22 has no `Float16Array`, so the SDK always gives
bits.

#### Python

```python
from infrared_sdk import InfraredClient
from infrared_sdk.analyses.types import SvfModelRequest

client = InfraredClient(api_key="...")
payload = SvfModelRequest(
    analysis_type="sky-view-factors",
    analysis_surfaces="facades",
    surface_grid_size=3.0,
)
result = client.run_area_and_wait(payload, polygon, buildings=buildings)

buffers = result.columns.render_buffers()
print(buffers.values.dtype, buffers.values.shape, buffers.value_min, buffers.value_max)
```

For `float16` input, `buffers.values` is a `float16` array. Python names are
snake case: `u_step`, `outline_offsets`, `value_min`, `any_valid`.

### Live result and saved layout

The outline comes from the **layout** of the surfaces: the frames and the
borders, which depend only on the geometry. There are two ways to get it.

- **Live result.** After a run in the same client, the result holds the
  outline. Call `surfaceRenderBuffers(columns)` or `columns.render_buffers()`.
  You must merge the run in the client that submitted it. A schedule that you
  merge in another process has no outline, and the call throws an error that
  names the missing outline.
- **Saved layout.** Save the layout once (`layout.toBytes()` or
  `layout.to_bytes()`). Later, load it (`FacadeLayout.fromBytes`,
  `FacadeLayout.from_bytes`) and pass it in. The outline of each surface comes
  from the layout, by surface id.

```ts
import { FacadeLayout, attachValues, surfaceRenderBuffers } from "@infrared-city/infrared-sdk-ts";

const layout = FacadeLayout.fromBytes(savedBytes);
attachValues(layout, columns, { expectedLayoutKey: savedLayoutKey }); // throws on a mismatch
const buffers = surfaceRenderBuffers(columns, { layout });
layout.free();
```

```python
from infrared_sdk.facade_layout import FacadeLayout, attach_values

layout = FacadeLayout.from_bytes(saved_bytes)
attach_values(layout, columns, expected_layout_key=saved_layout_key)  # raises on a mismatch
buffers = columns.render_buffers(layout=layout)
```

Save the values of each result as one blob, and link it to the layout by
`layoutKey`:

```ts
const blob = surfaceColumnsToBytes(columns, { layoutKey: layout.layoutKey });
// later:
const { columns, layoutKey } = surfaceColumnsFromBytes(blob);
attachValues(layout, columns, { expectedLayoutKey: layoutKey! });
```

```python
blob = columns.to_bytes(layout_key=layout.layout_key)
# later:
columns = SurfaceColumns.from_bytes(blob)
attach_values(layout, columns, expected_layout_key=columns.layout_key)
```

The blob holds the values in their native type, and no outline. A Python
blob has no legend and no aggregates (Python columns have none). A reader
refuses a blob of a version it does not know. `attachValues` refuses a layout
that does not match the result. The saved outline is
quantized. It is at most `nu / 131070` cells (`nv / 131070` for the other
axis) from the live outline.

A layout from `synthesizeFacadeLayout` or `FacadeLayout.toBytes()` does not
hold the per-cell triangles unless you set `emitCellTris: true`
(`emit_cell_tris=True`). Do not set it to draw with render buffers. It makes
the layout about three times larger.

A layout saved by an older SDK (format version 2) has no outline. Loading it
fails with `layout v2 has no outline; re-run the analysis`. Save the layout
again.

### Draw with deck.gl, three.js or WebGL

The renderer draws the outline triangles of each frame. The fragment shader
finds the cell and reads its value. This is a summary, not a full renderer.

1. **Positions.** For each outline vertex `(s, t)` of frame `f`, the position is
   `corner_f + s * uStep_f + t * vStep_f`. All three are in `frames`, relative
   to `anchor`. Do this once on the CPU in 32-bit floats. The error is about
   0.25 mm at 2 km from the anchor.
2. **Cell coordinates.** Give `(s, t)` to the shader as a varying. The
   `outline` array is already a ready attribute: two floats for each vertex.
3. **Frame data.** Give `nu`, `nv` and `cellStart` of the frame to each vertex
   as an unsigned integer attribute (flat varying).
4. **Cell lookup.** In the fragment shader:
   `j = clamp(floor(s), 0, nu - 1)`, `i = clamp(floor(t), 0, nv - 1)`,
   `k = cellStart + i * nu + j`.
5. **Value and validity.** Upload `values` as a texture (`R16F` with
   `HALF_FLOAT` for `f16`, `R32F` for `f32`). Upload `validity` as an `R8UI`
   texture. Read cell `k`, test bit `k & 7` of byte `k >> 3`, discard the
   fragment or draw a neutral colour when the bit is clear, and map the value
   with `valueMin` and `valueMax`.
6. **Double-sided.** Draw frames with both faces. A wall can face any way.
7. **Position in the scene.** Put `anchor` in the model transform in 64-bit
   floats (three.js: set the mesh position; deck.gl: use `METER_OFFSETS`
   coordinates with `anchor` as the origin). Keep one run to one site. Frames
   that are kilometres apart make the f32 corners less accurate.

This code builds the vertex arrays from the buffers (TypeScript). It runs
as it is:

```ts
import type { SurfaceRenderBuffers } from "@infrared-city/infrared-sdk-ts";

function vertexArrays(b: SurfaceRenderBuffers) {
  const frames = b.dims.length / 3;
  const vertices = b.outline.length / 2;
  const position = new Float32Array(vertices * 3); // relative to b.anchor
  const frameDims = new Uint32Array(vertices * 3); // nu, nv, cellStart
  for (let f = 0; f < frames; f += 1) {
    const [cx, cy, cz, ux, uy, uz, vx, vy, vz] = b.frames.subarray(9 * f, 9 * f + 9);
    for (let v = 3 * b.outlineOffsets[f]; v < 3 * b.outlineOffsets[f + 1]; v += 1) {
      const s = b.outline[2 * v];
      const t = b.outline[2 * v + 1];
      position.set([cx + s * ux + t * vx, cy + s * uy + t * vy, cz + s * uz + t * vz], 3 * v);
      frameDims.set(b.dims.subarray(3 * f, 3 * f + 3), 3 * v);
    }
  }
  return { position, cell: b.outline, frameDims }; // `cell` is (s, t) for each vertex
}
```

### Memory

- **Buffers are yours.** Each array owns its memory. In TypeScript you can
  transfer them to a worker (`postMessage(msg, [buffers.frames.buffer, ...])`).
  They stay valid when WebAssembly memory grows. In Python they are NumPy arrays.
- **The outline arrays are your own.** `buffers.outline` and
  `buffers.outlineOffsets` are the same objects as `columns.outline` and
  `columns.outlineOffsets`. The SDK does not copy them. If you transfer them,
  the columns lose them. Do not change them.
- **Values are not widened.** `f16` takes 2 bytes for each cell, `f32` takes 4.
- **Free what the client keeps.** The client keeps the capture of each facade
  or roof job until you merge the run. If you will not merge a schedule, call
  `client.jobs.captures.forgetSchedule(schedule)` (TypeScript) or
  `client.forget_schedule(schedule)` (Python). When you finish with the client
  in TypeScript, call `client.jobs.captures.free()`. In Python, `client.close()`
  (or leaving a `with` block) clears the kept captures. Call `layout.free()` for each
  `FacadeLayout` in TypeScript.

For servers that draw for many users, see
[Serve many users](#serve-many-users).

## Configuration and limits

### Environment variables

Each setting resolves in this order: constructor argument, environment
variable, default. The SDK does not read `.env` files.

| Variable | SDK | Sets | Default |
|---|---|---|---|
| `INFRARED_API_KEY` | both | Your API key | none (required) |
| `INFRARED_BASE_URL` | both | Gateway URL | `https://api.infrared.city/v2` |
| `INFRARED_GEOMETRY_REF_ENABLED` | both | `false` turns off geometry reuse between jobs | on |
| `INFRARED_APPLICATION` | Python | Calling surface (`application=`) | `sdk` |
| `INFRARED_SDK` | Python | Calling library and version (`sdk_id=`) | `infrared-sdk/<version>` |
| `INFRARED_QUIET` | Python | Hides the one-time start-up INFO log | unset |
| `INFRARED_SDK_DEBUG` | Python | More diagnostic output | unset |
| `INFRARED_OVERTURE_TRANSPORT` | Python | Overture read path: `auto`, `s3` or `https` | `auto` |

In TypeScript, pass `env` in the client config to give these values without
`process.env`. A value in `env` has priority.

### Identify your application

The SDK sends two headers with each call: `x-infrared-application` (the
surface that made the call) and `x-infrared-sdk` (the library and its
version). If you build a plugin or a connector, set them. Then your traffic is
not counted as generic SDK traffic. Your value is added in front of the SDK
token, so the SDK version stays visible.

=== "Python"

    ```python
    client = InfraredClient(api_key=key, application="qgis", sdk_id=f"infrared-qgis/{version}")
    ```

=== "TypeScript"

    The TypeScript client sets `x-infrared-sdk` itself. You choose only the
    surface, from a fixed list of names (default `script`).

    ```ts
    const client = new InfraredClient({ apiKey, surface: "qgis" });
    ```

### Limits

| Limit | Value | When it is broken |
|---|---|---|
| Non-empty tiles in one run | 100 | The run is refused before any job. Pass `max_tiles_override` (Python) or `maxTilesOverride` (TypeScript), and read the price first. |
| Own sensor points in one job | 300,000 | The payload is refused when you build it. |
| Facade sensors in one batch | about 250,000 (target) | The SDK plans more batches. |
| Terrain triangles | 500,000 | The server refuses the job. |
| Triangles in one tile scene | 5,000,000 (all layers) | The server refuses the job (HTTP 422). |
| Size of one request | 64 MiB | The server refuses the job (HTTP 413). |
| `context_geometry` | about 50,000 triangles for the whole site | The SDK logs a warning. Each tile job carries it. |
| Local coordinates | below 100,000 m | Use local metres, not UTM. |

### Material and building properties

These are the properties you can set. Python uses snake case. The wire and
TypeScript use the kebab-case or camelCase name (`ach_infiltration` is
`ach-infiltration`; `wall_albedo` is `wallAlbedo` in TypeScript). In
TypeScript, the energy balance and daylight factor have no typed classes:
send the wire keys.

**Energy balance** (`EnergySettings`, see [Interior](#interior-beta)). All
fields are optional. The settings apply to the whole request.

| Property | Default | Unit |
|---|---|---|
| `u_values.ext_wall`, `flat_roof`, `ground_floor` | 0.13, 0.25, 0.30 | W/m²K |
| `glazing.u_value` (centre of glass) | 1.1 | W/m²K |
| `glazing.shgc` | 0.60 | 0 to 1 |
| `glazing.frame_fraction` | 0.15 | 0 to 1 |
| `glazing.frame_u_value` | 1.4 | W/m²K |
| `glazing.edge_psi` | 0.06 | W/mK |
| `occupancy.heating_setpoint`, `cooling_setpoint` | 21, 26 | °C |
| `occupancy.people_gains`, `lighting_gains`, `equipment_gains` | 1.6, 1.5, 2.0 | W/m² floor |
| `occupancy.ach_natural` | 0.5 | air changes per hour |
| `ach_infiltration` | 0.03 | air changes per hour |
| `construction_class` (thermal mass) | `"medium"` | `very-light`, `light`, `medium`, `heavy`, `very-heavy` |
| `thermal_bridge_surcharge` | automatic | W/m²K |
| `ground_reflectance` (albedo of the ground) | 0.2 | 0 to 1 |
| `ground_level_z` | 0.0 | m, absolute z |
| `operation` (`Operation.continuous()` or `.intermittent(hours, days)`) | continuous | hours `[start, end]`, days `"weekdays"` or `"all"` |
| `solar_model` | `"legacy-flat"` | `"legacy-flat"` or `"irradiance"` |

The model picks the U-value key from the height of a slab or wall in its zone
(roof at the top, ground floor at the bottom, else wall). An unknown
`construction_class` counts as `medium`. With an intermittent `operation`,
give the gains and `ach_natural` as averages for the hours the plant runs.
You cannot set a value for one wall or one window. There is no emissivity
setting in this model.

**Daylight factor.**

| Property | Default | Note |
|---|---|---|
| `room_reflectances` (`floor`, `walls`, `ceiling`) | 0.2, 0.5, 0.7 | Set it for the whole request. |
| `glazing_transmittance` | 0.63 | For a window with no `opening_factor`. |
| `opening_factor` (in `interior_entities`) | none | Light transmittance of one window, 0 to 1. |
| `exterior_ground_reflectance` | none | The server reads it, but it has no effect on the result. |

**Outdoor thermal comfort** (`thermal-comfort-index` and
`thermal-comfort-statistics` only; the other models refuse these fields).
Leave a field unset to use the model default. Python names:

| Property | Range | Note |
|---|---|---|
| `wall_albedo` | 0 to 1 | All walls. |
| `wall_absorptivity` | 0 to 1 | All walls. Server default 0.75. A building can carry its own `absorptivity` on its entry in `buildings`. |
| `ground_albedo` | 0 to 1 | All ground, over the table of each material. |
| `ground_dt_max` | 0 to 50 K | Largest day lift of the ground surface temperature. |
| `canopy_transmissivity` | 0 to 1 | All trees. A tree can carry its own `transmissivity` in its GeoJSON `properties`. The built-in leaf-on value is 0.03 (palm 0.30). |

The first matching value wins: the entry of one building or tree, then the
request-wide field, then the built-in value.

**Ground materials are fixed on the server.** The SDK layers are `asphalt`,
`concrete`, `water`, `soil` and `vegetation`. Each name has a built-in albedo
and a temperature lift in the model, and the SDK cannot read them. An
`albedo` or `dt-max` in the properties of a ground feature is ignored: only
the geometry and the name count. To change the albedo, use `ground_albedo`
for the whole request. The SDK has no field for emissivity (the wall
emissivity is fixed on the server at 0.90).
Tree leaf-off values are in [Leaf-off vegetation](#leaf-off-vegetation).

### Leaf-off vegetation

A tree with no leaves lets more sun through. The SDK decides leaf-on or
leaf-off for each sun hour, from its month and the site latitude.

- North of 23.5 deg N: leaf-off from November to March. South of 23.5 deg S:
  from May to September. In the tropics, a tree is always leaf-on.
- It changes `daylight-availability`, `direct-sun-hours` and
  `solar-radiation`. `sky-view-factors` always uses leaf-on.
- A deciduous tree lets 0.45 of the direct beam through when bare. Evergreen
  trees (conifer, palm) keep their leaf-on value all year.
- An unknown genus counts as broadleaf deciduous.
- To change one tree, set `"transmissivity-leaf-off"` (0 to 1) in its GeoJSON
  `properties`:

```python
for f in trees.features:
    if f["properties"].get("genus") == "tilia":
        f["properties"]["transmissivity-leaf-off"] = 0.6
```

### Grid images

- Python: `sdk.weather.gen_grid_image(grid=..., analysis_type=..., criteria=...)`
  gives a PNG in the process.
- TypeScript: `await renderGridPng(grid, { analysisType, criteria })` gives a
  PNG, one pixel per cell, up to 960 px on the long side.

### TypeScript project setup

- Use TypeScript 5.8 or later. Types compile with `strict` and without
  `skipLibCheck`. ESM-only projects also work from 5.4.
- Set `module` and `moduleResolution` to `NodeNext` or `Bundler`. `Node10`
  does not work, because it cannot read the package `exports`.
- CommonJS `require()` works for the root entry only, and needs TypeScript 5.8.
- If you set `lib` by hand, keep `DOM` or add `@types/node`.
