Skip to content

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.

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.

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. You can also bring your own sensor points (see 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 and 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, Your inputs, What comes back
Show results in a web app TypeScript in the browser, one SDK worker Where it runs, Serve many users, Facades and roofs
Run many sites Python or Node.js on a server, a script Tiling, Cost and failures
Work in Rhino or Grasshopper Rhino 8 with the Python SDK (plug-in: work in progress) Coordinates, 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.

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

Example: one run

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
)
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, run_area_and_wait and run_area.
  • TypeScript: the SDK reference, 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.

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.

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.

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

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
)
const buildings = await client.buildings.getBuildingsInArea(polygon);
const result = await client.runAreaAndWait(
  request, polygon,
  { buildings },                  // pass the object, not its inner map
);

Learn more

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.

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.

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

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.

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.

Example: preview tiles

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

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
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

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.

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.

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

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.
  • 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.

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.

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

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.

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.

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

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.

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")
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.

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.

Learn more

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.

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.

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.

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.

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.

Learn more

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.

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.

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.

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.

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.

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.

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.

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

Example: interior run

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

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.

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

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.

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.

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

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.

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.

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

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()
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

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.

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.

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.

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.

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.

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.

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.

Example: facade run

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)
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

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.

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.

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):
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).

TypeScript in the browser

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.

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.
// 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.

A good worker pattern

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.

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.
  • 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.

Learn more

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.

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.

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.

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.
// 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.

import os
from infrared_sdk import InfraredClient

client = InfraredClient(api_key=os.environ["INFRARED_API_KEY"])
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.

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.

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.

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.

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.

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.

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.

Example: count and retry

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)
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

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

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

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.
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();
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:

const blob = surfaceColumnsToBytes(columns, { layoutKey: layout.layoutKey });
// later:
const { columns, layoutKey } = surfaceColumnsFromBytes(blob);
attachValues(layout, columns, { expectedLayoutKey: layoutKey! });
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:

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.

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.

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

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

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

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:
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.