@infrared-city/infrared-sdk
v1.0.1
Published
WASM-first Infrared City TypeScript SDK
Readme
Infrared City TypeScript SDK
This package contains the WASM-first Infrared City SDK implementation. It provides direct job submission, durable area schedules, area polling and merge operations, result routing, pricing helpers, and the buildings, vegetation, ground-materials, and weather clients used by the platform. The site-context and weather capabilities read the public data hosts and the SDK directly; the utilities service is not called at all.
What changed in 1.0.0
- Values keep the type the server sent. A surface result (
SurfaceColumns.values,valueDtype,valueDivisor,valid) and an area result (AreaResult.mergedGrid,valueDtype,valueDivisor,valid) hold"f16"(half-float bits in aUint16Array),"f32","i16"(with a divisor and a validity bitmap) or"f64". Read physical values withsurfaceValuesF32/areaGridValuesF32and test a cell withsurfaceHasValue/areaGridHasValue. UTCI read without the divisor is 10 times too large. - The SDK merges area grids. The merge keeps no canvas of every tile.
- Render buffers.
surfaceRenderBuffers(columns)gives the arrays a renderer needs, with no mesh for each cell. emitCellTrisis off by default insynthesizeFacadeLayout. A layout saved before 1.0.0 cannot be loaded (format version 3): build and save it again.- Save a result.
surfaceColumnsToBytesandsurfaceColumnsFromBytes(see Save and reload a facade result). - Year-wrap windows. A weather window from December to February is one window.
Details and the steps to change your code: UPGRADING.md.
Install
Upgrading from 0.12.12? Read UPGRADING.md first.
Registries. Stable versions (1.0.1 and later) go to the public npm
registry (npmjs.org). A plain npm install gets them; you need no token and no
.npmrc. Prereleases (for example 1.0.1-next.0) go to GitHub Packages with
the next tag. To install a prerelease, see
Release channels.
Two installs, because only ONE capability needs the parquet decoder.
npm install @infrared-city/infrared-sdk
# and, only to read Overture Maps:
npm install hyparquet hyparquet-compressors| | base | + parquet stack |
|---|---|---|
| Extra packages | — | hyparquet, hyparquet-compressors (+ fzstd, hysnappy) |
| node_modules (npm 10, Node 20) | 8.0 MB | 8.8 MB |
| Payloads, direct jobs, area schedules, polling, merge | ✅ | ✅ |
| Results (JSON and strict IRBF), pricing, images | ✅ | ✅ |
| Static weather catalog + EPW (geo.infrared.city) | ✅ | ✅ |
| Trees and city overlays (FlatGeobuf, read by the SDK) | ✅ | ✅ |
| Overture buildings, land use, land cover and water (parquet) | ❌ | ✅ |
Without them an Overture read rejects with one GeodataDependencyError naming
the exact command; it never resolves to an empty feature list.
import { GeodataDependencyError } from "@infrared-city/infrared-sdk";
try {
await getArea(polygon);
} catch (error) {
if (error instanceof GeodataDependencyError) {
console.error(GeodataDependencyError.installCommand);
// npm install hyparquet hyparquet-compressors
}
}The root entry does not load either package at import time. /geodata loads
them with a dynamic import() at the moment of the read
(tests/geodata/optional-parquet.test.ts asserts both halves).
The package contains the matching wasm-pack --target web JavaScript glue,
declarations, and WASM binary. A browser must give initializeCore a URL,
byte buffer, or compiled WebAssembly.Module. A Worker can inject its compiled
module. Node loads the packaged WASM file when no source is supplied.
Each Web Worker has its own core: call await initializeCore() in the main thread
and again in every Web Worker. A sync function that runs first throws
CoreNotReadyError with realm and method, and a text that says what to do.
import { initializeCore, packMesh } from "@infrared-city/infrared-sdk";
await initializeCore({ bytes });
const result = packMesh(coordinates, indices);JobsService submits direct jobs, polls job IDs, and downloads completed results. It resolves
gateway authentication for every control request and downloads signed result
URLs without gateway credentials. InfraredClient.analyses.execute accepts an
already wire-keyed payload. The run, runAndWait, and area facades also
accept declared legacy camel-case top-level fields. Opaque entity, mesh, and
metadata maps keep their keys. Binary is the default transport: with no
transport, every submission and area run uses the strict binary route. Pass
transport: "json" for JSON. energy-balance has no binary route and uses
JSON. A one-part daylight-factor request is one JSON job; a request of two
or more parts uses the binary route (see "Daylight factor: large requests
run as floor parts" below). Binary results carry the server's stored precision
(F16 for SVF, solar radiation, thermal comfort statistics and wind; 0.1 steps
for the thermal comfort index), so a value can differ from JSON by one step,
and a binary area grid is a Float32Array. If the binary route refuses a
request, the SDK raises that error; it does not resubmit the job as JSON. The
production API serves the binary route. The old binaryResults option is
rejected. On the binary route the two wind models carry a vegetation group
like every other outdoor model: trees are never boxed client-side. The
wind worker boxes a tree server-side when its model needs to. A malformed
ground-geometry sheet refuses the submission by name before any HTTP; an
omitted field is defaulted. Relevant outdoor requests with supplied terrain and solids
default to terrain-alignment: "as-is". Callers can select another supported
alignment mode. The two wind models spell the same option with
their own two values — "as-is" (the default since 1.0.0) and "to-ground" — because they
take no terrain and the worker reads a building's HEIGHT straight off the raw z
of its mesh. runArea applies it once per plan: "to-ground" drops every
supplied mesh so its own lowest point sits at z = 0, and a scene already at grade
is submitted byte-for-byte unchanged. runArea is the one place this happens; a
direct execute submission is sent as given. The option never reaches the wire,
and ground-geometry is still refused on both models.
import { buildAuthResolver, JobsService } from "@infrared-city/infrared-sdk";
const jobs = new JobsService({
baseUrl: "https://api.example.com",
auth: buildAuthResolver({ apiKey: "your-api-key" }),
});
const completed = await jobs.waitForCompletion(jobId);
const download = await jobs.downloadResults(jobId, { job: completed });
const result = jobs.decompress(download.content);JobsService.decompress returns a route-aware ParsedResult. Use
decompressResultValue(jobs, bytes) when an older caller intentionally needs
only the decoded payload. This adapter does not change the foundation method.
JobsService.decompress uses the shared result router. Binary grid and surface
results use owned typed buffers and do not retain the compressed archive.
Archive expansion defaults to 64 MiB compressed and 512 MiB expanded. Set
archive.maxCompressedBytes or archive.maxExpandedBytes per call when a
smaller positive safe-integer limit is required. These limits apply after the
HTTP download has produced its byte buffer and before JSON parsing; they do not
limit download buffering or the later JavaScript object graph.
If an ordinary inline submit outcome is unknown, do not automatically repeat the execute or submit call. Keep any accepted job IDs and poll those jobs. A geometry-reference error is different: its result is invalid and must use the separate handling described below. The server creates a new ID for each POST and has no client idempotency-key contract, so a manual resubmission can create another charge. This SDK does not claim exactly-once submission.
Waiting for jobs
All waits use one poll engine: waitForCompletion, runAndWait,
checkAreaState loops and runAreaAndWait. The SDK holds the numbers,
and the Python SDK uses the same ones.
- The first status check is 0.5 s after the wait starts, not at once.
- Then the SDK checks every 1 s for the first 10 s of the wait, and every 2 s after that.
- A single job uses
GET /async/jobs/{id}. An area run asks for up to 50 jobs in oneGET /async/jobs?ids=where the gateway has that route, and otherwise one GET for each job. A sweep ofnrequests waits at leastn / 2s. - HTTP 429, any 5xx or a network error does not end the wait. The engine
backs off (at most 30 s, never shorter than
Retry-After) and tries again. The first good answer resets the backoff. A 401 or 404 ends the wait at once. - The default wait timeout is 900 s. At the deadline the wait rejects with
JobTimeoutError. The area timeout default (3600 s) does not change.
pollIntervalMs (a fixed healthy interval) and backoffCapSeconds still
work.
Daylight factor: large requests run as floor parts
runAndWait splits a daylight-factor request into parts of whole floors,
sends the parts as separate jobs in parallel, and joins the part results
into the result the single request gives, byte for byte. The SDK
does all of it: it counts each floor's sensors exactly as the worker grids
them, packs whole floors into parts of at most about 300 000 sensors, writes each
part body (the request with a shorter floors list) and joins the results.
A floor above the part size is split inside the floor and joined exactly. Only the floors tier splits; sensor-points, sensor-surfaces,
buildings and a single floor-index are one job.
A request of one part is one job, and its POST carries the same bytes as
analyses.execute. maxParts: 1 turns splitting off. A larger maxParts
is a limit: a plan with more parts is refused (RangeError) before any
request; a maxParts that is not a positive integer is a TypeError, as for
maxWorkers.
Each part is one job and is billed as one job. previewParts tells what a
run submits and bills, without a request:
const request = {
"analysis-type": "daylight-factor",
latitude: 48.2, longitude: 16.37,
floors: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
"grid-size": 0.5,
barriers, openings, // wire-keyed interior entity maps
spatialVolumes: rooms, // the rooms: per-room statistics and warnings
"glazing-transmittance": 0.7, // a window with no openingFactor
};
const preview = client.previewParts(request);
console.log(preview.partCount, preview.totalSensors, preview.estimatedCostTokens);
const result = await client.runAndWait(request) as DaylightFactorResult;
for (const group of result.groups) { // one group per floor
const df = result.values.subarray(group.start, group.end); // Float32Array view
console.log(group.key, group.meanDf, df.length);
}
const json = result.toJson(); // the JSON result, on demandThe result is binary by default. runAndWait and mergeParts
return a DaylightFactorResult (kind: "daylight-points"): x, y, z
(Float64Array), values (the daylight factor, Float32Array), validity
and room (Uint16Array, an index into rooms; NO_ROOM = no room) are
views over ONE IRBF frame, with no object per sensor, as values /
validity of a grid or facade result. groups, rooms, buildings,
warnings are small arrays; minLegend / maxLegend as for the other
results. values holds the f32 of the worker's 4-digit value; toJson()
(or toJsonBytes()) gives the worker's JSON result, byte for byte. The parts
ask for binary results when the server's capability lists daylight-points
for daylight-factor; otherwise they come back as JSON and the SDK turns
them into the same frame. Pass resultFormat: "json" to get the JSON value
as before. The default is DEFAULT_DAYLIGHT_RESULT_FORMAT.
The request stays wire-keyed (this SDK has no daylight-factor type). Every
key goes to every part unchanged, so spatialVolumes and
glazing-transmittance reach the worker as sent.
- A part that fails stops the join:
runAndWaitrejects withAnalysisPartsError, whosefailedPartsnames the parts and whosescheduleholds the accepted jobs.runAndWait(request, { retryFrom: error.schedule })sends only the failed parts again and joins. A part whose POST outcome is unknown is never sent again automatically. A retry needs a SHA-256 digest of the request (WebCrypto or the Node entry); without one the run works, but itsretryFromis refused before any request. - Once the parts are submitted, every error of
runAndWaitcarries theschedule(an abort, a timeout, a part still running): it is anAnalysisPartsErrororPartsTimeoutError, with the original error ascausewhere there is one. Merge the schedule again, or pass it asretryFrom; nothing is paid twice. - A part result that does not download also rejects with
AnalysisPartsError, withdownloadFailedPartsset andfailedPartsempty.mergeParts(error.schedule)downloads again; nothing is resent. - The wait uses
timeout(default 900 s, as for one job). At the deadline a multi-part run rejects withPartsTimeoutError, which carries theschedule, notJobTimeoutError.onPollreports one job, so a multi-part run does not call it; useonProgress. runParts,checkPartsStateandmergePartsare the same steps one by one, asrunArea,checkAreaStateand the merges are for an area.- The SDK's notes on the request (for example a
grid-sizeof 0) and the result'swarningsgo to the client logger aswarnevents. The SDK does not refuse a request for its content: a request the SDK cannot plan is sent as one job, and the server answers it. - A daylight-factor run of two or more parts uses the binary route when the
server supports it: the scene is uploaded once and each part sends only its
control. Otherwise, and for a one-part request, every part carries
the whole scene inline on the JSON route; the result is still a
DaylightFactorResult.
Uploads
Each request that sends a body follows one rule. The request stops if
no body byte moves for 10 s (the stall guard), or if the body is not sent in
min(1800 s, max(60 s, bytes / 64 KiB/s)). A slow upload that still moves is
not stopped before that budget. When the last byte is sent, both limits end,
and timeoutMs applies only to the answer.
- In real Node, the SDK sends the body as a stream in 64 KiB blocks, with an
explicit
Content-Length. Bun, Deno, Electron and Workers send the body as bytes. - In a browser or web worker with the default
fetch, a cross-origin presigned S3 PUT usesXMLHttpRequest, which reports upload progress. Gateway requests usefetch. A proxy rewrite must therefore be in thefetchoption of the client, not only in awindow.fetchpatch (see Calling the API from a browser page). - A custom
fetch(the clientfetchoption) shows no progress. It gets one window for body and answer: the budget plustimeoutMs, with no stall guard.
The SDK does not send a paid POST again after the guard stops it. The outcome is uncertain; poll the job IDs that you know.
Area preparation validates polygons, generates deterministic tiles, reads the core-owned tiling preset, and composes supported geometry groups:
InfraredClient.runArea returns a durable AreaSchedule.
checkAreaState updates that schedule from job status responses.
mergeAreaJobs downloads and merges grid results, and
mergeSurfaceAreaJobs downloads and unions surface results. Both THROW when a
tile did not contribute rather than returning a grid with holes in it:
a result that could not be fetched is still on the server, so calling the merge
again with the same schedule completes the run. Failed tiles are in
schedule.failedSubmissions for runArea({ retryFrom }).
A tile that fails does not stop the run: runArea sends every tile. The tiles
that went out before the failure may be billed. A retry with retryFrom sends
only the failed tiles. runArea has up to maxWorkers requests in flight at
once. When the API answers with HTTP 429, the SDK sends that tile again. A paid retry needs
the same immutable geometry and layer inputs, plus a saved siteIdentity.
An input map changed in place is new content: the next run sends it, and a
paid retry of the old schedule is refused. Older
schedules without this identity can be polled and merged, but failed tiles
cannot be resubmitted.
runAreaAndWait performs these steps and selects the merge operation from the
saved schedule intent. previewArea and generateTiles do not submit jobs.
Schedules can be saved and restored with areaScheduleToJSON and
areaScheduleFromJSON from the root package or the /tiling entry point.
Keep uploaded geometry for a new worker or a reload
The SDK uploads the geometry of each tile once and keeps its signed URL in memory. A second run in the same JavaScript realm sends no geometry again. A new worker or a page reload has empty memory, so it uploads all tiles again. On a slow line the upload is most of the run time.
Give the client a geometryUrlStore to keep the URLs longer:
const client = new InfraredClient({
apiKey,
geometryUrlStore: {
get: (key) => JSON.parse(sessionStorage.getItem(`ir-geom:${key}`) ?? "null") ?? undefined,
set: (key, url, expiresAt) =>
sessionStorage.setItem(`ir-geom:${key}`, JSON.stringify({ url, expiresAt })),
},
});sessionStorage does not exist in a Worker. There, wrap an IndexedDB object
store (workers of the same origin share it) with the same get and set,
and pass it to serveSdkWorker({ geometryUrlStore }).
Rules:
- An entry is a read link to your geometry for its lifetime; treat the store like a cache of secrets.
- The SDK makes the keys. A key holds the gateway URLs, a SHA-256 digest of the credentials (never the credentials) and the digest of the geometry.
- Before an upload the SDK looks in its memory, then calls
getonce per geometry (runs that wait for the same upload do not call it again). After an upload it callssetand does not wait for it. expiresAtis 23 hours after the upload (the server keeps geometry for 24 hours), or the end of the signed URL when that is earlier. The SDK ignores an expired or malformed entry. You can delete entries at any time.- When the server refuses a stored URL (gone, expired, unreadable), the SDK uploads again, overwrites the entry and sends that job once more.
- A
getthat throws or does not answer in 2 s, or asetthat fails, gives one warning; the SDK then uploads. A store never fails a run. - Two clients that miss the same key at the same time may both upload.
- Without
geometryUrlStore, nothing changes: the URLs stay in memory only.
Robust area retry
Every tile submit of an area run carries an Idempotency-Key header, so a
retried submit is never billed twice. runArea({ retryFrom }) asks the
core for the retry plan instead of resubmitting every failedSubmissions
key: it also resends a compute-failed job (one whose status ended Failed)
and an uncertain submission with its SAME key, up to one core
constant's worth of attempts per key (3). A key past the cap is held back,
never sent again.
runAreaAndWait({ retries }) retries automatically: after the run completes,
if the SDK plan finds anything left to fix, it calls the retry path and
waits again, up to retries rounds (default 1; 0 turns this off). It
stops early once a round's plan resubmits nothing, and never retries after a
402 (schedule.submissionAbortStatus).
A schedule saved by an older SDK has no run id: it still reads and still retries, but its uncertain keys are held (never resent) because its first submit carried no key to repeat.
import {
composeTilePayloads,
generateTilesForPolygon,
getTilingConfig,
validatePolygon,
} from "@infrared-city/infrared-sdk";
const analysisType = "sky-view-factors";
const checked = validatePolygon(polygon);
const grid = generateTilesForPolygon(checked, { analysisType });
const nonEmptyTiles = grid.flat()
.filter((tile) => !tile.empty)
.map((tile) => ({ row: tile.row, col: tile.col, tileId: tile.tileId }));
const config = getTilingConfig(analysisType);
// The terrain arm `runArea` submits: the caller's extra reach, floored at the
// preset's own context margin. Terrain always reaches the solids the tile
// carries, whatever this says.
const marginM = Math.max((config.contextSizeM - config.inferenceSizeM) / 2, 128);
const payloads = composeTilePayloads(groups, nonEmptyTiles, checked, {
analysisType,
terrainContext: { margin_m: marginM },
});Every group comes back finished. composeTilePayloads assigns the
buildings and occluders, decides facade ownership, slices the terrain, trees
and materials, and re-anchors every group into each tile's own frame in ONE
core pass over the site. The bodies it
returns are the bodies runArea submits, in the canonical form the wire
carries: mesh keys sorted, numbers as JavaScript writes them. There is no
finishing step.
How big a site can be
A run is capped at 100 non-empty tiles. The cap is a cost guard, not a platform limit: one tile is one separately billed job. Over it, nothing is submitted and the refusal prices the run you asked for.
Polygon produces 196 non-empty tiles on the wind grid (256 m step), over the
limit of 100. Each tile is one billed job at about 10 tokens, so this run would
cost about 1,960 tokens. To run it, pass maxTilesOverride: 196; to spend less,
shrink the polygon or size it first with previewArea().The grid decides the count, not the polygon alone. wind-speed and
pedestrian-wind-comfort step at 256 m with 50 % overlap; solar, daylight and
thermal-comfort types step at 512 m with none, so the same 3.5 km square is 49
tiles on solar and 196 on wind. The number the refusal names is the number to
pass — maxTilesOverride: 196 runs this polygon, and so does anything larger.
previewArea submits nothing, takes the same override, and returns the same
numbers the refusal quotes. previewAreaWithPricing replaces the SDK's default
of 10 tokens a job with the gateway's live per-analysis price.
const preview = client.previewArea(polygon, {
analysisType: "wind-speed",
maxTilesOverride: 400,
});
console.log(preview.tileCount, preview.estimatedCostTokens); // 196 1960
const schedule = await client.runArea(input, polygon, { maxTilesOverride: 400 });The same refusal text reaches the Python SDK, which spells the knob
max_tiles_override.
A site is prepared once per tiling family and reused by every runArea in
the process. The site stage of a plan — assigning the buildings to tiles,
slicing the terrain, re-anchoring every tile's occluders — depends only on the
geometry and the tiling preset, not on the analysis, so six analyses of one
site prepare it twice (the solar family and the wind family), not six times.
The prepared site is keyed by the content of the inputs as you pass them and
by the polygon, so a changed building is a new site and nothing stale is ever
served; concurrent runs of one family wait for the one build instead of
repeating it; the process holds a few sites and evicts the least recently used.
A long-lived process that has finished with a site can release the memory
early with freePreparedSites() (root package or the /tiling entry point);
the next runArea on that site prepares it again.
A facade run bills per building batch, not per tile — use
previewAreaBatches to price it. previewArea above returns
tileCount * 10 tokens, one job per tile — right for a grid analysis, wrong
for a facade (analysisSurfaces) one: the server splits a dense tile's
target buildings into several separately billed sub-batches, and a tile with
no target building submits no job at all. previewAreaBatches runs the SAME
offline plan runArea submits — no HTTP — and reports the real planned job
count and the total synthesized sensor count:
const preview = await client.previewAreaBatches(input, polygon);
console.log(preview.tileCount, preview.plannedJobCount, preview.sensorCount);Pass the SAME input (and options.buildings, if the geometry is not
embedded on it) a runArea call would; plannedJobCount is what that call
would actually submit.
options.maxSensorsPerJob makes facade jobs smaller: it is the most retained
sensors one job may carry, a whole number from 1 to 250 000 (the default
target). Each batch is still an exact, verified count, so a preview with the
same cap reports the same jobs and sensors as the run. A value out of range
throws a RangeError before any request; a building above the cap throws
while planning. A retry replays the saved batches: it may omit the cap or
repeat the saved value, and any other cap is refused before a request. The
Python SDK's max_sensors_per_job has the same meaning. Running the identical facade request twice bills
every job again, in full — there is no result cache. Geometry-reference
reuse (INFRARED_GEOMETRY_REF_ENABLED, above) saves an upload — the same
geometry bytes are not sent twice — never the simulation result; see
docs/sdk-data-flow.md for why, and why that is a platform feature to ask
for, not an SDK one.
Mesh cleaning on a facade run (meshCleaning). Each mesh is cleaned before it is gridded ("auto", the default); pass meshCleaning: "off" to use meshes as drawn. See Bring your own geometry.
Draw facades and roofs with render buffers (surfaceRenderBuffers).
surfaceRenderBuffers(columns) gives the flat arrays a renderer needs: the
frames, the outline triangles of each frame, one value for each cell (in the
type the server sent) and one validity bit for each cell. No mesh for each cell
exists, so the buffers are small and every array can be transferred to a worker.
A facade or roof run of this client has the outline. surfaceRenderBuffers(columns, { layout })
takes it from a saved FacadeLayout instead. The guide "Draw facade and roof
results fast" on the SDK docs site shows the format and a drawing recipe. To
serve many users, read "Serve many users".
const columns = await client.runAreaAndWait(
{ ...input, analysisSurfaces: "facades" }, polygon, { buildings });
const buffers = surfaceRenderBuffers(columns); // frames, outline, values, validityThe client keeps the capture of each facade or roof job until you merge the run.
Merge in the client that submitted it. Call
client.jobs.captures.forgetSchedule(schedule) for a schedule you will not
merge, and client.jobs.captures.free() when you finish with the client.
Per-cell triangles are optional (emitCellTris: true). cell-tris is the
exact clipped per-cell render geometry. It is 12.6x the rest of a facade
response body (+214 MB gzip on a 66-job city scene), so this SDK never asks the
server for it. emitCellTris is false by default. Pass emitCellTris: true
only if you need the old per-cell mesh. Then mergeSurfaceAreaJobs
synthesizes the identical shapes with the SDK it already bundles, from the
geometry this client submitted, and hands them back in result.triangles:
const schedule = await client.runArea(
{ ...input, analysisSurfaces: "facades", emitCellTris: true }, polygon, { buildings });
const result = await client.mergeSurfaceAreaJobs(schedule);
// One mesh per job (tile or batch): tile-local f32 positions plus an f64 anchor.
const table = result.triangles!;
for (let group = 0; group < table.positions.length; group += 1) {
const positions = table.positions[group]; // 9 floats per triangle
const values = vertexValues(result, group); // one value per vertex
const [x, y] = [table.anchors[2 * group], table.anchors[2 * group + 1]];
// e.g. three.js: new BufferGeometry() + position/value attributes, mesh.position.set(x, y, 0)
}Show facade results after a reload, offline (synthesizeFacadeLayout,
#707). The outline and the triangles above come from the live client's
in-memory capture; after a page reload there is nothing to merge. synthesizeFacadeLayout({
buildings, analysisSurfaces, surfaceGridSize, ... }) rebuilds the identical
frames, cell order and outline straight from the buildings you already
have, offline, no HTTP, no billing, with surfgridVersion pinned to the
value the original result was built at (never a silent "latest").
layout.toBytes() / FacadeLayout.fromBytes(bytes) save and reload a layout
WITHOUT the geometry, for when the model may have changed since the run. The
saved format (version 3) holds the frames and the outline of each frame; it
holds no per-cell triangles unless you pass emitCellTris: true. A layout
saved by an earlier version is refused: build it and save it again. Then
surfaceRenderBuffers(columns, { layout }) builds the render buffers. Store layout.layoutKey alongside every result:
attachValues(layout, columns, { expectedLayoutKey }) requires it and
refuses a mismatch -- including a geometry change too small to move a
frame's header, since layoutKey hashes the geometry itself -- instead of
draping values onto plausible-looking wrong cells. toBytes() exports the
layout you already have, never re-synthesizing (a large dense-city fixture, 817
buildings: 38.98 MB in 1.9 s); pass per-job activeIds (and, tiled,
siteOrigin/tileOrigin) so a large site's export stays job-sized. See
UPGRADING.md § Show facade results after a reload.
The merged surface result is columns (SurfaceColumns, since 1.0.0). One
typed array per surface field (origin, uAxis, vAxis, gridSize, nu,
nv, area, mean, peak), cellOffsets cutting the per-cell values
and cellArea, the surface ids in one string cut by
idOffsets (surfaceId, surfaceIndex), and the aggregates and legends as
before. There is no object per surface and no array per cell, and every array
owns its buffer, so the whole result can be transferred to a worker. The SDK
joins the run in one call: the 4 km Vienna facade case (397 k
surfaces, 39.3 M triangles) merges in about 10 s single-threaded, down from 32 s,
at 3 GB peak, down from 7.3 GB. The triangles cost zero extra bytes.
values keeps the server's type (since 1.0.0). It is a
Uint16Array of half-float bits (valueDtype "f16"), a Float32Array, an
Int16Array with valueDivisor and the valid bitmap ("i16"), or a
Float64Array (JSON results): 2 or 4 bytes per cell, not 8. Test a cell with
surfaceHasValue(result, cell) and get physical numbers with
surfaceValuesF32(result, first, last). See
UPGRADING.md.
The row order is fixed. The SDK sorts the jobs by their schedule entry id before it joins them, so two identical runs give the same rows in the same order, whatever order the server accepted the jobs in. To join two results, use the surface id and the cell index, not the row number of an earlier SDK version. The SDK reads a binary surface result in schema 1 and in schema 2 (per-surface columns, 2026-09-29); the merged result is the same for both.
Wall grids are level (SURFGRID_VERSION 4). A wall grid has u
horizontal along the wall and v up the wall; a roof keeps the grid of its
minimum-area rectangle. Together with mesh cleaning, this
changes facade values from earlier versions and test builds. The largest change is
on south- and west-facing walls: before the cleaning, many of those walls
pointed into their own building. Compare new runs with new runs only.
Every attach is checked against the
response's sensor-layout-hash; if the local layout does not match, or the
geometry is no longer held (a schedule resumed in another process), nothing is
attached, one warn line is logged, the result carries
cellTrisFallback (the sorted fallback reasons), and the result is the blocky
one. On terrain the SDK first seats the buildings the way the server does
for terrainAlignment: "auto-align".
Area composition currently accepts uncompressed JSON mesh coordinates. It rejects packed or mixed binary mesh arms because the current tiling core shifts the JSON coordinate arm. Compact vegetation instances also have no area assignment policy yet and fail explicitly.
Synchronous operations throw CoreNotReadyError until initialization finishes.
Malformed geometry throws. Valid geometry that cannot use the portable binary
format returns { not_representable: reason }; callers can retain its JSON arm.
The wrapper checks primitive types before it makes typed buffers. It does not
change or detach caller arrays.
The browser build does not initialize WASM as an import side effect. Call
initializeCore({url}), initializeCore({bytes}), or
initializeCore({module}) before geometry, tiling, cleaning, merge,
prepareAreaPayload with column-form weatherData (the SDK filters it),
or the geodata reads (groundMaterials.getArea, groundMaterials.getRaw,
vegetation.getArea, buildings.getBuildingsInArea). A Web Worker has a
separate module realm and must initialize its own SDK instance. Node can
load packaged WASM when initializeCore() has no argument, and the four
geodata reads above, runArea and runAreaAndWait do this for you in Node — call initializeCore() first
only to pick a non-default source or to pay the load cost earlier. A second
initializeCore call in the same realm is safe: after the core is loaded, it
resolves for a URL or byte source without loading it again, and for a
WebAssembly.Module that exports every function this SDK calls (a module
without them is refused). A worker that gets a new posted
WebAssembly.Module for each request can call it every time.
Vite applications can resolve the public WASM asset at application boot:
import coreUrl from "@infrared-city/infrared-sdk/core.wasm?url";
await initializeCore({ url: new URL(coreUrl, location.href) });Repeat this initialization inside each existing Worker realm before it calls an SDK operation. This follows Vite's explicit URL import and WebAssembly asset pattern; the SDK does not load WASM as an import side effect.
Threads in Node (opt-in)
In Node, initializeCore({ threads: 4 }) loads the threaded core that the
package carries in generated-threads/ and starts a pool of four worker
threads. Nothing loads it unless threads is above 1. The SDK then runs its parallel steps on the pool;
the results are byte-identical to the serial core. A 4 km facade merge with
triangles takes 5.5 s instead of 10.0 s, and about 10 % more memory.
Four threads is the best value measured; eight is slower.
- Call it once, before any other SDK call. A realm holds one core, so a later call with another thread count is refused.
- It needs Node 22 or later; on Node 20 the pool does not start, so the call is refused there.
- It takes no
url,bytesormodule. The browser and worker entries refusethreadsabove 1. - A failure on a pool thread ends the process. The threaded build aborts
on a core panic, and the main thread is blocked in the SDK call where
no error can reach it. So the worker writes one JSON line to stderr
(
"event":"infrared_core_worker_failed") and ends the process (SIGKILL, which no handler can catch) instead of hanging. That includes running out of WebAssembly memory near the 4 GiB limit, on a pool thread or on the main thread in a core call. On the serial core the same failure is a thrown error. Run a threaded merge where a process restart is acceptable, and keep inputs well below the limit. - A pool that fails to start does not block the serial core: a later
initializeCore()loads it.
Calling the API from a browser page
A browser app must call the Infrared API through a same-origin proxy. Today
the API does not send CORS headers on the 202 that accepts an analysis job,
so the browser blocks that response when the page calls the API directly. A
server fix is in progress; until it is deployed, set baseUrl to a path on
your own origin and forward that path to the API:
const client = new InfraredClient({
baseUrl: `${location.origin}/infrared-api`, // proxied to https://api.infrared.city/v2
});The proxy forwards the method, body and Authorization header unchanged. For
a Cloudflare Pages app, add a _worker.js that relays the API calls and the
result downloads described below. Node and Cloudflare Workers do not apply CORS
and can call the API directly.
The proxy must also relay S3 traffic. A run's presigned upload and
download URLs point at S3, a different origin, so the browser blocks them the
same way. The async-results bucket sends no CORS headers, so a result
download must go through the proxy too — not only the /infrared-api calls
above.
A worker can relay this at /s3-proxy/<host>/<key>: it allowlists the
results-bucket host, strips the Authorization and cookie headers, then
forwards the request to S3. Rewrite a presigned URL to that path before you
fetch it:
const S3_HOST = /^(staging-)?infrared-async-inference-jobs-outputs\.s3\.[a-z0-9-]+\.amazonaws\.com$/;
const toProxyUrl = (url: string, origin: string) => {
const u = new URL(url);
return S3_HOST.test(u.host) ? `${origin}/s3-proxy/${u.host}${u.pathname}${u.search}` : url;
};Apply this rewrite inside a custom fetch and pass that fetch to
InfraredClient (on the page and in a Worker realm). Do not only patch
window.fetch: with the default fetch, the SDK sends a presigned S3 PUT
through XMLHttpRequest (see Uploads), so a window.fetch patch
does not see that URL and the browser blocks the upload. A page fetch patch
also does not reach a Worker. Rewrite the presigned URL to the relay path
yourself, as shown above, and have the Worker allowlist the results-bucket host.
One SDK worker
@infrared-city/infrared-sdk/worker (browser, ESM only) runs one SDK
client in one module Worker, so site preparation, encode, decode and merge
run off the page:
// sdk.worker.ts — the whole worker file
import { serveSdkWorker } from "@infrared-city/infrared-sdk/worker";
serveSdkWorker(); // or serveSdkWorker({ fetch }); a custom fetch must forward init.signal
// page
import { createWorkerClient } from "@infrared-city/infrared-sdk/worker";
const sdk = createWorkerClient({
worker: new Worker(new URL("./sdk.worker.ts", import.meta.url), { type: "module" }),
module, // WebAssembly.Module, compiled once on the page
config: { baseUrl }, // cloneable InfraredClientConfig fields only
getToken, // runs on the page
});
const schedule = await sdk.runArea(input, polygon, {
signal, onProgress, onAccepted: (jobId, tileKey) => saveJobId(jobId, tileKey),
});
const result = await sdk.mergeAreaJobs(schedule, { signal }); // grid buffer transferred
sdk.dispose();Store each job id when onAccepted reports it (an observer: the run does not
wait for it, so keep the ids in memory until the store succeeds), and store
the returned schedule with areaScheduleToJSON. One worker serves one client;
a second createWorkerClient on the same worker throws. A worker failure rejects open
calls with WorkerLostError: reason: "load" (never ready, nothing sent) or
"lost" (acceptedJobIds are accepted, every other tile is uncertain; do
not send the run again). After lost in runArea, the helper cannot merge the accepted jobs: there is no public API that builds an AreaSchedule
from job ids. Only a schedule you saved earlier can be merged.
getToken is called per request with no session binding: use one worker per
signed-in session and dispose the client on sign-out. Errors cross as a plain Error: check name, not
instanceof. Poll with a plain InfraredClient on the page. The recipe, with
the no-Worker fallback, is
examples/dedicated-worker.
Release channels
Builds require Rust 1.94.0 and wasm-pack 0.13.1. Normal CI only builds and
tests the package, through typescript-sdk.yml. Each publish channel has its
own dispatch-only file. CI does not call these files, and their publish
input defaults to none (build only).
- Prereleases (
typescript-sdk-internal.yml): prerelease versions fromstaging, to restricted GitHub Packages with thenexttag. - Stable releases (
typescript-sdk-release.yml): stable versions frommain, to npmjs.org, authenticated by OIDC (no stored token, after a one-time manual first publish). The upload becomeslatestat once. This is the stable channel from 1.0.0 (owner decision, 2026-09-29).
typescript-sdk-stable-packages.yml (stable versions to GitHub Packages)
stays in the repository, but it is not the stable channel. Each publish needs
the exact version typed as confirmation. No channel rewrites the checked-in
version, and no channel can publish the SDK packages. The full procedure,
the settings each channel needs, and what you cannot undo are in
docs/RELEASING.md §"TypeScript SDK release
channels".
Installing a prerelease from GitHub Packages needs an .npmrc that maps the
scope, plus a token with read:packages and read access on the package:
@infrared-city:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Then install with the tag: npm install @infrared-city/infrared-sdk@next.
Remove this .npmrc mapping to install stable versions from npmjs.org again.
CI builds the locked WASM package, runs unit and real-WASM tests, and installs
the packed tarball outside the source tree. Installed checks cover root ESM and
CommonJS and the ESM-only /tiling, /utilities, and /billing entry points.
tests/installed-dual-entry.mjs loads the root with require and the subpaths
with import in one process and checks that one initializeCore() prepares all
of them (infrared-core#683).
Browser and Worker acceptance uses the installed package, initializes each
realm explicitly, and runs the /worker helper in a real module Worker. Release, publishing, and production cutover are separate.
Supported tsconfig
The package ships .d.ts files that compile under a plain tsc --strict
project with no skipLibCheck.
TypeScript 5.8 or newer. That floor is not a guess: npm ci pins it as an
alias devDependency and CI compiles a consumer with it on every run, next to
the TypeScript this package builds with. ESM-only consumers in fact compile
from 5.4 (measured); 5.8 is where CommonJS joins them, because that is the
release where TypeScript accepts require() of an ES module — see the CommonJS
note below.
The minimum config is:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true
}
}| Setting | Supported | Note |
|---|---|---|
| target: ES2022, lib unset (the default library) | ✅ | Checked in CI |
| lib: ["ESNext", ...] | ✅ | Checked in CI; also what using needs |
| skipLibCheck: false | ✅ | Never required to make our types compile |
| module/moduleResolution: NodeNext or Bundler | ✅ | The exports map needs one of these |
| module/moduleResolution: Node10 ("node") | ❌ | Cannot read exports, and TypeScript 6 deprecates it |
| CommonJS (require, .cts), TypeScript ≥ 5.8 | ✅ | Checked in CI; root entry only |
| CommonJS, TypeScript 5.4 – 5.7 | ❌ | TS1479 — see below |
lib: ["ES2022"] written out explicitly drops DOM, and with it URL,
Response and AbortSignal — which this SDK's own types name. Either keep the
default library, or add "DOM" (or @types/node with "lib": ["ES2022"] plus
"types": ["node"]) yourself.
CommonJS below TypeScript 5.8. At RUNTIME a require() caller is fine on
any supported Node: the node+require condition resolves to dist/index.cjs,
real CommonJS. It is the TYPES that stop early — the root entry's "types" key
sits above the node/require branch, so a CommonJS consumer's compiler reads
the ESM dist/index.d.ts and, before 5.8, refuses it with
TS1479: ... cannot be imported with 'require'. TypeScript 5.8 accepts
require() of an ES module and the error goes away. Consumers below 5.8 can use
a dynamic import(), or upgrade. Shipping a dist/index.d.cts would lift the
floor back to 5.4 for them; that is a change to the published export map and is
tracked separately, not folded into this one.
Four of these rows are a CI gate, not a claim: tests/consumer-typecheck.mjs
compiles a consumer against the packed tarball on every run — three shapes (ESM
default library, ESM esnext, CommonJS) against two compilers (the supported
floor and the one this package builds with), six clean compilations
(tests/README.md). The gate exists because the
[Symbol.dispose] members wasm-bindgen writes into
generated/infrared-core.d.ts used to demand lib: esnext from every
consumer, including ones that never write using (infrared-core#262).
Conversion and data acquisition in the SDK
The site-context capabilities no longer call the utilities service at all.
Trees, ground materials and buildings are read from the public data hosts in
this process and computed in the SDK, and there is no option that
selects a service path. Callers upgrading from an earlier version: see
MIGRATION.md.
vegetation.toMeshes(featureCollection) converts tree Point features to
dotbim meshes in the SDK, the same computation as the Python SDK's
convert_to_mesh(converter="local"). The collection carries
referencePoint: [lon, lat] and features. converter accepts only
"local": this package has never had a remote convert route, so a remote
value is a typed VegetationMeshError rather than a silent local run.
const meshes = client.vegetation.toMeshes(collection);There is nothing to select:
// Node: loads the packaged core if no earlier call already did.
await initializeCore();
// Browser: name the core explicitly instead (see "Core initialization" above),
// e.g. with Vite: await initializeCore({ url: new URL(coreUrl, location.href) });
const client = new InfraredClient({ apiKey });
const trees = await client.vegetation.getArea(polygon); // { signal } cancels the read
const ground = await client.groundMaterials.getArea(polygon);
const buildings = await client.buildings.getBuildingsInArea(polygon);In Node, each of these three calls (plus groundMaterials.getRaw) loads the
core itself when nothing initialized it yet, so the explicit call above is
optional there — call it first only to pick a non-default source or to pay
the load cost earlier. A browser or Worker realm has no packaged core to load,
so there this call stays required; calling one of these methods first throws
a CoreInitializationError naming the method.
Read only the layers the analysis uses. runArea does not read a layer by
itself; the caller reads it. Ground materials are the slowest read, and only
thermal-comfort-index and thermal-comfort-statistics use them. The other
models ignore them; the binary transport refuses them. Trees are used by those
two and by sky-view-factors, solar-radiation, daylight-availability and
direct-sun-hours; the wind models use buildings only. On a 2 km
solar-radiation area, not reading ground materials removed about 7 s of a
15 s run and about 330 MB of peak memory.
An area is acquired at AREA level: one Overture read per data type for the whole polygon, the types read together, and the features then assigned to the tiles whose query rectangle their bbox meets — the same features each tile's own read returned, so the documents are identical and only the number of reads changes. Roads and trees stay per tile.
The read margin follows the analysis. All three take an optional
analysisType, and it sizes the read: a solar, daylight or thermal model needs
the buildings and trees that cast a shadow into a tile, and they stand further
away than the obstacles a wind model needs. The half extent is the SDK
preset's contextSizeM / 2 — 256 m for wind-speed and
pedestrian-wind-comfort, 384 m for every other analysis — and ground
materials and trees read the tile half diagonal of that, 363 m and 544 m.
Pass nothing and the read uses the WIDEST margin. That is correct for every analysis and costs a wind caller extra reading; the reverse default would drop the outer band of context with no error and no other symptom.
await client.buildings.getBuildingsInArea(polygon, { analysisType: "wind-speed" }); // 256 m
await client.buildings.getBuildingsInArea(polygon); // 384 mEach result records readMarginM and analysisType. runArea compares them
with what its own analysis needs and throws ReadMarginError, naming both
margins, rather than running a scene with a band of context missing. Equal or
wider passes, so one default acquisition serves every analysis. Pass the whole
object — runArea(input, polygon, { buildings, vegetation, groundMaterials })
— for that check to happen; a bare map records nothing and is never refused.
cleaner left unset cleans in the SDK, on data that never leaves
the process. It still accepts a caller's own GroundMaterialCleaner object,
which is an extension seam rather than a service selector; the old string
values "remote" and "local" raise a TypeError naming the replacement.
The five ground materials. A ground-materials layer key must be one of
asphalt, concrete, soil, vegetation or water. Those are the only names
the simulation resolves to a surface; anything else — a typo, a registry UUID,
or the land-cover class building — reaches the model as an unknown material
and produces a wrong thermal result, so the SDK throws before the job is
submitted. Case and surrounding spaces do not matter. Layers from
groundMaterials.getArea() always pass.
Direct buildings follow the prod-BUILDINGS route's precedence: a city
buildings overlay from sources.json when the query rectangle intersects
that city, otherwise Overture with a 0.002 degree candidate buffer. A missing
height extrudes 9 m from z = 0 and min_height is carried but unused. The
whole site is extruded ONCE, in one frame, and AreaBuildings.origin names
that frame — the polygon's south-west corner. buildingIds is ALWAYS
empty: the deleted POST /buildings route returned Mapbox ids only, and the
public sources read here carry none.
Pass the acquired object to runArea, not just its map.
const buildings = await client.buildings.getBuildingsInArea(polygon);
const schedule = await client.runArea(input, polygon, { buildings });runArea reads buildings.origin and re-anchors the whole map into the run
polygon's frame, so buildings acquired once for a large area can be run as
several sub-areas. Passing the bare buildings.buildings map still works and
is unchanged, but a bare map carries no frame: acquire with the same polygon
you run, or the city is submitted translated by the distance between the two
south-west corners.
Overlay-first matters for the counts. On one Vienna tile the deleted
POST /buildings, served from the Stadt Wien overlay, returned 815 meshes,
where the public Overture buildings index holds 312 footprints for the same
box — an Overture-only read under-counts Vienna by roughly 60 %, which is why
the overlay is read first. Separately, that route grouped a courtyard parent
with its children into ONE building and this SDK does not, so a per-building
count can differ from the route's on the same data.
Footprints the SDK extruder cannot represent are dropped inside the
core and are simply absent from the map; skippedFootprints therefore
stays empty on this path.
Results carry overtureRelease, the Overture release the base themes came
from. The index pointer moves daily, so a result without it is not
reproducible; pass it back as the overtureRelease option to pin the read to
that release's immutable manifest.
These paths reach exactly three hosts, always over https and never with an
API key: geo.infrared.city (the world FlatGeobufs and the city-overlay
registry), registry.infrared.city, and
overturemaps-us-west-2.s3.us-west-2.amazonaws.com (anonymous Overture
parquet). Every URL is checked against that allow-list, including the ones a
fetched manifest names. The lower-level readers are exported from
@infrared-city/infrared-sdk/geodata.
A site is acquired as a SITE, not as a pile of simulation tiles: the Overture
themes and the building footprints are read ONCE for the whole site, the road
FlatGeobuf once per read chunk, and the SDK composes, merges and cleans the
ground in one call and normalises and extrudes every footprint in one frame.
Read chunking is deliberately decoupled from simulation tiling — a 5 km2 site
is 81 overlapping 512 m simulation tiles but ONE parquet read — and the chunk
grid (about 2 x 2 km above 4 km2) bounds the COMPOSE, not the read: a row group
is the smallest unit the parquet reader can fetch and it spans far more ground
than a chunk, so chunking the read only re-fetches the same bytes. runArea then re-anchors each tile's building
bodies into that tile's own frame with the exact affine the server projects it
at.
Save and reload a facade result
Save the values of a facade or roof result as one blob. Save the layout of the RUN, as a second file. Both files together draw the result in a new process, with no geometry and no network.
Build the layout of the run with the schedule that the run returned. The
schedule holds the job split and the tile frames of the run, so the layout has
the same surfaces and cells as the result. A layout that you build from the
buildings alone does not match a server result: the run analyses only the
buildings of the polygon, and attachValues refuses the pair.
This example runs (Node 20 or later, ES modules). It bills one run (one job for each tile or batch).
// save.mjs
import { writeFileSync } from "node:fs";
import {
InfraredClient, initializeCore, surfaceColumnsToBytes, synthesizeFacadeLayout,
} from "@infrared-city/infrared-sdk";
const lon = 16.371, lat = 48.208, d = 0.0005; // a small site in Vienna
const polygon = { type: "Polygon", coordinates: [[
[lon - d, lat - d * 0.67], [lon + d, lat - d * 0.67], [lon + d, lat + d * 0.67],
[lon - d, lat + d * 0.67], [lon - d, lat - d * 0.67]]] };
await initializeCore();
const client = new InfraredClient({ apiKey: process.env.INFRARED_API_KEY });
const buildings = await client.buildings.getBuildingsInArea(polygon);
const input = { analysisType: "sky-view-factors", analysisSurfaces: "facades", surfaceGridSize: 2 };
const schedule = await client.runArea(input, polygon, { buildings });
while (!(await client.checkAreaState(schedule)).isComplete) {
await new Promise((resolve) => setTimeout(resolve, 5000));
}
const columns = await client.mergeSurfaceAreaJobs(schedule);
// The layout of THIS run: same buildings, same sensor settings, the run's schedule.
const layout = synthesizeFacadeLayout({
buildings: buildings.buildings, analysisSurfaces: "facades", surfaceGridSize: 2, schedule,
});
writeFileSync("layout.bin", layout.toBytes());
writeFileSync("values.bin", surfaceColumnsToBytes(columns, { layoutKey: layout.layoutKey }));
layout.free();
client.jobs.captures.free();// load.mjs -- a new process: no buildings, no client, no network.
import { readFileSync } from "node:fs";
import {
FacadeLayout, attachValues, initializeCore, surfaceColumnsFromBytes, surfaceRenderBuffers,
} from "@infrared-city/infrared-sdk";
await initializeCore();
const layout = FacadeLayout.fromBytes(readFileSync("layout.bin"));
const { columns, layoutKey } = surfaceColumnsFromBytes(readFileSync("values.bin"));
attachValues(layout, columns, { expectedLayoutKey: layoutKey }); // throws on a mismatch
const buffers = surfaceRenderBuffers(columns, { layout });
layout.free();Install the parquet stack for getBuildingsInArea (see Install). Checked on
1.0.0-next.0 against the production API (one small Vienna site, 9,117 surfaces,
387,167 cells): the values of load.mjs are bit-identical to the live values.
Both SDKs use the same blob format; a blob saved by one loads in the other. For the same schedule the layout bytes are identical. The TypeScript values blob also stores the legend and aggregates; the Python one leaves them empty.
JSON.stringify,structuredCloneand a copy of the object do not keep the compact columns in a reliable way. The blob is the way to save a result.- The blob holds the values in the server's type, the surface ids, the cell placement and the legend and aggregates. It holds no outline: render buffers after a reload need the saved layout.
Grid images
renderGridPng(grid, options) colours a result grid in the SDK,
with the registry visualConfigurations the retired image route used. The
image is 1:1 — one pixel per grid cell up to a 960 px long axis —
twice the route's ~480 px long axis (four times the pixels).
// `renderGridPng` takes rows of numbers. An `AreaResult` keeps its values in one
// flat array in the server's type, so take the physical values first.
const [rows, cols] = result.gridShape;
const flat = areaGridValuesF32(result); // Float32Array, NaN = no value
const grid = Array.from({ length: rows }, (_, r) =>
Array.from(flat.subarray(r * cols, (r + 1) * cols)));
const png = await renderGridPng(grid, {
analysisType: "pedestrian-wind-comfort",
criteria: "lawson-lddc",
maxLongAxisPx: 960, // the default; 0 renders every cell
});
const size = gridImageSize(png, cols, rows);Above the cap the grid is sampled nearest-neighbour on the VALUES before it is
coloured, so the aspect ratio holds and a no-data cell stays no-data — no
blended value appears between two classes. gridImageSize reads the size back
out of the PNG and gives the scale factor, for aligning an overlay: output
pixel (x, y) is grid cell (floor((x + 0.5) * gridWidth / size.width),
floor((y + 0.5) * gridHeight / size.height)). It returns a GridImageSize,
and the default is DEFAULT_MAX_LONG_AXIS_PX; both are on the package root. 0 is the opt-out rather than
undefined, because JavaScript cannot tell an omitted option from an explicit
undefined.
Legend range
A grid AreaResult carries minLegend and maxLegend: the exact min and max
of the finite cells of the merged, clipped grid, measured in the SDK
(#390). Both are absent when no cell is finite, and for a categorical result,
which carries its class list in legend. The backend's own estimate is not
used. For another scale, pick a mode at display time:
legendRange(result, "trimmed"); // 2nd..98th percentile, robust to outliers
sharedLegendRange([before, after]); // one scale for several results
const fixed = await registryFixedRange("wind-speed");
if (fixed) legendRange(result, "fixed", { fixed }); // the registry's full scaleregistryFixedRange is undefined when the registry publishes no numeric
range for the analysis.
Long or narrow sites. A site is read as its bounding box, so a long, narrow
or diagonal polygon has a box much bigger than the ground its simulation tiles
cover. Three things follow, and the SDK does all three for you. The COMPOSE is
given chunk ∩ union(tile rectangles) — the read chunk cut down to the pieces a
simulation will actually read — so the merged ground document carries no surplus
from the empty corners of the box; the READ itself is unchanged, for the row
group reason above, and the read chunk stays the same 2 km grid whatever the
shape. And the
one-frame warning has a second rule: the flat-frame error grows with EXTENT, not
with area, so a 10 km x 300 m strip is only 3 km2 but carries the far-end error
of a 10 km site (about 17 m at 48 deg N); above 5 km on the longest side
siteChunks warns through your logger and names that error. A compact site is
unaffected in every respect: its answer, byte for byte, is the one it gave
before.
What a public read is allowed to do
Every read of a public host obeys four rules. They also hold for the colour
registry, and the Python SDK holds the same ones — the shared statement of
them is scripts/ci/transport_contract_cases.json, which both test suites
walk.
One deadline covers the answer AND its body. A timer that stops when the
headers arrive is no timer: a server can answer and then stall. Each read
takes timeoutMs, or DEFAULT_PUBLIC_TIMEOUT_MS (180 000 ms, the same
default the authenticated transport uses) when the caller names none. The
deadline also carries the caller's own signal, and it removes the listener
it puts on that signal when the read ends.
Every body is read under a cap, while it arrives. The reader counts the
bytes and stops the download the moment they pass the cap; a header is never
the only limit. The caps are exported: MAX_HTTP_200_BYTES (8 MiB, for a
server that ignores a Range request and sends the whole object),
MAX_RANGE_BYTES (64 MiB for one range answer), and MAX_JSON_BYTES
(32 MiB for an index document). A body that disagrees with its own
Content-Length is an error.
A range answer must prove its window. One Content-Range only, the
requested start, and the requested end or the last byte of the object when
the window runs past the end of the file. multipart/byteranges is refused.
Range reads ask for Accept-Encoding: identity. Lengths are checked on
decoded bytes, so an encoded range answer cannot be checked against its own
Content-Range. Node sends that header; a browser is not allowed to set it,
so a 206 that comes back under a content coding other than identity is a
typed error. Index documents are not affected — they may arrive compressed,
and only the length comparison is skipped for them.
fetchVisualConfigurations() takes the same timeoutMs and signal
options. It had no timeout before; its default is 30 000 ms.
The parquet reader dependency
hyparquet and hyparquet-compressors are OPTIONAL PEER dependencies. npm
does not install an optional peer, so a caller who brings their own data gets
the smaller install and no warning. They are loaded with a dynamic import()
in one module (src/geodata/overture-parquet.ts), only when ground materials
or buildings are read from Overture, and kept external in the CommonJS bundle
so the root entry never carries them at require() time. They are dropped
when a core parquet reader or an Overture FlatGeobuf republish lands,
whichever comes first.
Memory of an Overture read: the reader decodes one row group at a time, keeps
the geometry as WKB bytes until a row is kept, and does not decode the bbox
column twice. For the WKB step it also imports hyparquet/src/convert.js and
hyparquet/src/wkb.js (the package exports ./src/*.js); where they cannot
be imported, the read decodes GeoJSON as before. With hyparquet 1.31.1 or
later the peak is lower again, because that version assembles nested columns
(sources, names) only for the rows asked. 1.30.0 stays supported.
flatgeobuf is gone. It backed a second FlatGeobuf reader until the SDK
two-phase reader shipped in both bindings; the SDK reader is now the only
one, so selectFgbBackend, libraryFgbAvailable, setFgbLibraryModule and
the FgbBackend type are removed with it.
Why parquet is read in the host: a core parquet reader has not been tried in this repo — the Rust stack's zstd codec is C, so it needs a C toolchain in the wasm build job, and the size cost is unmeasured. That is a build question to settle with numbers, not a proven impossibility. Everything AFTER the columnar read (the bbox clip, the zoom-band drop) is the SDK's, whichever reader wins.
Bundle cost, measured with esbuild (minified, browser, ESM, WASM and the
parquet readers external): the ./geodata subpath is 17.9 KiB / 7.0 KiB
gzipped on its own, and adds 13.9 KiB / 5.0 KiB gzipped to the package root.
Bundling hyparquet with it instead of leaving it external took the subpath
to 193.3 KiB / 100.1 KiB gzipped, which is why it stays a dynamic import.
readFgbBbox has one seam and one backend. The host does only fetch with
Range headers: it asks the SDK for the file layout from a small prefix,
then loops — hand the SDK the index nodes fetched so far, fetch the ranges
it asks for next — and finally reads the feature ranges it plans. The packed
index of a world file is over a gigabyte, so it is never downloaded whole.
The reader's three exports are on the core's required-export list, so a build
without them fails at initializeCore() rather than falling back.
Three rules protect a multi-request read of a public object, and each one is
a typed GeodataRangeError:
- the object size comes fr
