Taos Engine · Modules

Modules

Analysis (Dataflow Programs over Geo Data)

The analysis module answers questions about gridded Earth data — "is sea temperature correlated with wind?", "how fast is this region warming?", "what does an El Niño winter look like?" — by running small dataflow programs over the layers Earth Explorer streams (a standalone open source project built on this engine). One interpreter serves three front ends:

New to the module? The Analysis Tutorials are a guided path through the graph editor — start with Your First Graph. This page is the reference they build on. For the story of the explorer sample itself, see the book deep-dive Geo GIS Explorer.

Source lives in src/geo/analysis/. Everything runs in the browser: the data never leaves the machine — programs execute on CPU cells the explorer already holds.


The mental model#

A program is a flat list of nodes forming a DAG. Each node applies one operation; inputs reference the producing node by id. There is no control flow — no loops, no conditionals — which is what keeps every program safe, obviously terminating, and cheap enough to run on every edit.

JSONC
{ "nodes": [
  { "id": "sst",  "op": "layer",     "params": { "layer": "sst",  "start": "2023-01", "stepMonths": 2 } },
  { "id": "wind", "op": "layer",     "params": { "layer": "wind", "component": "speed", "start": "2023-01", "stepMonths": 2 } },
  { "id": "r",    "op": "correlate", "inputs": { "a": "sst", "b": "wind" }, "params": { "mode": "temporal" } },
  { "id": "map",  "op": "display",   "inputs": { "value": "r" } },
  { "id": "ans",  "op": "answer",    "inputs": { "value": "r" }, "params": { "label": "SST vs wind speed" } }
] }

Every program needs at least one sink (display, chart, or answer) — sinks are what produce results; anything not feeding a sink is dead code and never runs.

The five value types#

Edges carry one of five types. Ops declare which types each port accepts, and the validator (and the graph editor's port colors) enforce them:

TypeWhat it isTypical producer
stacktime × lat/lon grid — a layer over a date rangelayer
fieldone lat/lon grid — a single moment or a reduction over timetimeReduce, trend, correlate(temporal)
seriesone value per time stepareaMean(stack), enso
scalara single numberareaMean(field), correlate(spatial), correlateSeries
regiona lon/lat box, preset, or drawn polygonregion

Fields cross the analysis boundary as decoded physical floats (°C, m/s, hPa…) with NaN marking land/no-data, resampled onto a fixed 1° grid (360 × 180). Values are never clamped to a display range — an anomaly or a correlation can be any number; only the display sink maps values to colors.

Statistical conventions, encoded once#

Every op follows the same rules, so you don't have to remember to apply them:


Node reference#

Twenty-five ops in five groups (OP_NAMES in ast.ts is the authority — that table generates the validator, the editor's palette and the LLM schema). Port notation: name: type (a ? marks an optional port or param).

Sources#

Sources are materialized by the host (the explorer's FieldStore); in a program you just name what you want.

layer → stack#

One data layer as a stack of dated 1° fields.

ParamTypeNotes
layerstring, requiredA catalog key — see the layer table below.
start? / end?YYYY-MMInclusive; default = the layer's full coverage.
stepMonths?numberFrame cadence: 4, 2, or 1 months (default 4). Finer = more statistical power, slower loads for live feeds.
component?speed | u | vRequired for vector layers (wind, currents): which scalar to extract.

Layers in the explorer's catalog:

KeyUnitCoverageNotes
sst°C1981-09 → nowSea-surface temperature (NOAA OISST)
anom°C (relative)1981-09 → nowSST anomaly vs the NOAA 1971–2000 climatology
ice%1981-09 → nowSea-ice concentration
chlmg/m³2016-01 → bake dateChlorophyll-a; log-distributed values
dhw°C-weeks2016-01 → nowCoral Reef Watch degree heating weeks (rolling 12 weeks); ocean only
baa—2016-01 → nowBleaching alert level 0–4; ordinal, not a continuous quantity
mhw—2024-07 → nowMarine-heatwave category 0–5; ordinal, not a continuous quantity
wavesm2017-02 → nowSignificant wave height (total); no coverage poleward of ±77°
swellm2017-02 → nowSwell component of the sea state; same ±77° limit
windseam2017-02 → nowWind-sea component of the sea state; same ±77° limit
periods2017-02 → nowPeak wave period; same ±77° limit
rainmm/h2022-12 → nowGFS rainfall rate, 12:00Z snapshots
precipmm/day2016-01 → nowPERSIANN-CDR satellite daily totals; ±60° only (record starts 1983)
precipmonmm/day1979-01 → bake dateGPCP v2.3 monthly-mean precipitation, land + ocean, on its native 2.5° grid (reads blocky at 1°). The long rainfall record — use it for anything spanning decades or ENSO events
landanom°C (relative)1979-01 → bake dateGHCN-CAMS monthly 2 m land air-temperature anomaly vs its own 1991–2020 per-month climatology (not the SST anomaly's 1971–2000)
airtemp°C2022-12 → nowGFS 2 m air temperature (covers land)
skintemp°C2022-12 → nowGFS surface (ground/sea) temperature — not the 2 m air temperature
humidity%2022-12 → nowGFS 2 m relative humidity
pressurehPa2022-12 → nowGFS mean sea-level pressure
solarW/m²2026-01 → nowDownward shortwave flux — strongly diurnal, 12:00Z only
swupW/m²2026-01 → nowUpwelling (reflected) shortwave flux at the surface
lwupW/m²2026-01 → nowSurface upwelling longwave (≈σT⁴ of the skin) — not top-of-atmosphere OLR
lwdownW/m²2026-01 → nowDownward longwave flux — the greenhouse effect as a surface flux
windm/s (vector)2022-12 → nowGFS 10 m wind — set component
currentsm/s (vector)2020-01 → bake dateBaked surface currents — set component

The four radiation fluxes (solar, swup, lwup, lwdown) sit on the same GFS aggregation as the other atmosphere layers, but PacIOOS only populates them in its recent segment — dates before ~2026-01 come back as all-null grids, so their coverage floor is not the dataset's advertised 2022-12. baa and mhw are ordinal category scales: mean/trend over them are arithmetic on category numbers, not on a physical quantity, so read them as "how often, how severe", not as rates.

region → region#

A named preset, an explicit lon/lat box, or an arbitrary polygon. Every spatial reduction (areaMean, mask, histogram, hovmoller) restricts through the same point-in-region test, so a drawn shape behaves exactly like a typed box.

ParamNotes
preset?Named region (nino34, tropics, gulf-stream, …).
lonMin? / latMin? / lonMax? / latMax?Explicit box; lonMin > lonMax wraps the antimeridian.
points?Polygon ring as "lon,lat lon,lat …" (≥3 vertices, closes automatically). Takes precedence over the box params.

Drawing an area in the explorer produces exactly this string — the readout panel's region button copies it, ready to paste into a points param. Rings that cross ±180° are handled: vertices are unwrapped so the shape stays contiguous, and the inside test is evaluated against the ±360° copies. For a polygon, the region's bounding box is an envelope used only as a per-row latitude reject — the exact test is always the ring itself.

enso → series#

The monthly Oceanic Niño Index (ONI — the 3-month running mean of the Niño 3.4 SST anomaly). The standard ENSO index: ≥ +0.5 °C leans El Niño, ≤ −0.5 °C leans La Niña.

forecast → stack#

An on-device ML outlook: a small convolutional network (~190 KB, trained offline on the repo's baked OISST anomaly stack, run in the browser with LiteRT.js — WebGPU-accelerated, WASM fallback) rolls a layer forward month by month from the latest observations. The output stack has one predicted frame per future month; treat it as an outlook, not data. Downstream it composes like any stack — display it, areaMean it, difference it against the observed layer with math.

ParamTypeNotes
layeranom, requiredOnly the SST anomaly has a trained model.
months?numberMonths ahead to predict, 1–6 (default 3). Predictions feed back in, so skill decays with distance.
from?YYYY-MMLast observed month to roll forward from (default: latest available). Useful for hindcasts — forecast from a past month, then compare against what happened.

The model predicts a per-cell delta from persistence (next month = this month + learned change) from the last 4 observed months. On the 12 held-out validation months it reaches ~0.43 °C mean absolute error vs ~0.48 °C for pure persistence. The host registers the model via FieldStore's forecaster option; without one, forecast nodes fail with a structured error.

region → region#

A lon/lat box, by preset or explicit bounds (give one or the other):

ParamNotes
preset?nino34, tropics, arctic, southern-ocean, gulf-stream, north-atlantic, north-pacific
lonMin? latMin? lonMax? latMax?Degrees; lonMin > lonMax wraps across the antimeridian (e.g. the North Pacific preset).

Transforms (shape-preserving)#

mask — (value: stack|field, region: region) → same type#

Keeps only cells inside the region; everything else becomes no-data. Use it to focus a map on a region (for a regional number or series, areaMean takes a region directly).

anomaly — (value: stack) → stack#

ParamNotes
baselineStart? / baselineEnd?YYYY-MM reference period (e.g. 1991-01 → 2020-12); must lie inside the loaded range. Default: every loaded frame.
climatology?window (default) = one flat mean; monthly = each calendar month against its own baseline mean, removing the seasonal cycle.
as?difference (default) = value − climatology; percent = percent of normal, (value − climatology) / climatology × 100. Cells with a climatology ≤ 0 become no-data.

Subtracts a climatology from each frame. With no baseline the reference is whatever window you loaded, so the same cell changes value when the range changes; state a baseline for anything you will quote. The baseline used is recorded on the output's note. Use as: percent for rainfall — a 1 mm/day excess is a flood in a desert and noise in a monsoon, and raw differences let the wettest places dominate every map. Output is marked relative.

lag — (value: stack|series) → same type#

ParamNotes
monthsrequired; whole months to shift dates forward (negative allowed).

The lead/lag tool: correlate(a, lag(b, 3)) pairs a(t) with b(t−3 months), i.e. it tests whether b leads a by 3 months. (For series, correlateSeries has a built-in lagMonths that does the same thing more conveniently.)

selectFrames — (value: stack, oni?: series) → stack#

Keeps only frames matching a month-of-year list and/or an ENSO phase.

ParamNotes
months?Comma-separated months to keep, e.g. "12,1,2" for December–February.
phase?elnino | lanina | neutral. Requires the oni input (connect an enso node).

Phase is classified by the instantaneous ONI ±0.5 threshold at the nearest month — a simplification of the CPC rule (which requires five consecutive qualifying seasons). Good enough for composites; don't cite it as an official event classification.

filter — (value: field|stack|series)#

Range filter — ODV's sample filter. Values outside the range become no-data, so every downstream reduction sees only the part you asked about: "area mean where heat stress ≥ 4 °C-weeks". Shape-preserving, and units are untouched — this selects values, it does not transform them.

ParamNotes
min? / max?Inclusive bounds; at least one is required.
abs?Compare `

derive — (value: field|stack|series|scalar)#

Applies one function element-wise — ODV-style derived variables.

ParamNotes
fnlog10, ln, abs, sqrt, square, negate, inverse (required).

log10/ln are the ones that matter: log-distributed quantities (chlorophyll, rainfall) correlate and average meaningfully in log space, whereas a Pearson r on raw values is decided by a handful of extremes. Non-positive inputs to a log, negatives to sqrt and zero to inverse all become no-data rather than infinities.

Units are reported honestly. abs/negate keep the unit (abs drops relative, since a magnitude has no sign left to convert); everything else returns dimensionless, because a log or a power changes the dimension and this language does not track compound units — a retained unit label would be a lie.

isoline — (value: field|stack, region?: region)#

The 2-D counterpart of ODV's isosurfaces: instead of colouring a depth surface, it measures the region a contour encloses. Over a stack the output is a series; over a field, a scalar.

ParamNotes
levelContour value in the layer's physical units (required).
measure?area (default) — extent in million km²; latitude / longitude — the area-weighted centroid of the enclosed region, which tracks a boundary.
below?Measure value ≤ level instead of ≥.

Area uses exact spherical cell areas (R²·Δλ·|sin φ₂ − sin φ₁|), and positions are area-weighted so the answer is a centroid rather than a mean over grid rows — an unweighted mean would over-count the shrinking polar cells. This is what turns a threshold into a measurement: sea-ice extent at the conventional 15 % edge, ocean area above the 4 °C-week bleaching threshold, or the latitude of the ice edge as it retreats.

One caveat with teeth: cells are counted whole on the 1° analysis grid, so an extent runs a few percent above an operational figure computed at the source resolution. Read the shape of the series, not headline agreement with a published number.

Reductions#

areaMean — (value: stack|field, region?: region) → series | scalar#

The cos(lat)-weighted mean over valid cells, optionally inside a region. This is the bridge from maps to time series: a stack becomes a series (one value per frame), a field becomes a single number.

timeReduce — (value: stack) → field | stack#

ParamNotes
statrequired; mean | min | max | range (= max − min; needs ≥ 2 valid frames per cell).
per?all (default) → one field; run → a stack with one frame per run of consecutive frames.

Per-cell reduction across frames. range is the seasonal-amplitude map; mean is the time-average (also what correlate(spatial) uses internally).

per: run splits the stack wherever the gap between frames exceeds 1.5× its median step, and reduces each run separately. After selectFrames(months: "12,1,2") that is one Dec–Feb mean per winter, dated at the run's middle frame (January), so it pairs with the ONI season centered on the same month. Runs shorter than the longest are dropped as incomplete, so a season missing a month doesn't pass for a full one.

trend — (value: stack) → field#

Per-cell least-squares slope across the frames, reported per decade. Needs ≥ 8 valid frames per cell. The "how fast is it changing" map.

Combinators#

math — (a, b) → same type#

ParamNotes
fnrequired; add | sub | mul | div (division by zero → NaN).

Element-wise arithmetic. Operands must be the same type — field∘field, stack∘stack (frames pair by nearest date), series∘series — or one operand may be a scalar (broadcast). sub marks the result relative; mul/div drop the unit. Validity intersects: a cell must be valid in both operands.

correlate — (a, b) → field | scalar#

ParamNotes
moderequired; temporal | spatial.

Pearson correlation, two very different questions:

correlateSeries — (a: series, b: series) → scalar#

ParamNotes
lagMonths?Shift b forward N months before pairing — tests whether b leads a by N months.

Pearson r between two time series, paired by nearest time. Returns NaN with fewer than 8 pairs; the answer payload carries n.

regress — (value: stack, predictor: series, at?: scalar|series) → field#

ParamNotes
output?slope (default) | predict | skill.
at?Predictor value for predict when no at input is connected (e.g. 1.5).
atFrom?How an at series collapses: last, last3 (default — mean of the final three samples, the season a forecast ends on), mean, or peak (largest magnitude).
minSkill?slope/predict only: blank cells whose cross-validated skill (%) is below this. 0 keeps cells that beat climatology at all.

Per-cell least-squares regression, value(t) ≈ a + b·predictor(t), with each frame paired to the nearest predictor sample. This is the statistical core of a teleconnection outlook: how much a cell responds per unit of an index (usually the ONI), how reliably, and what that implies at a forecast index value.

slope and predict carry a standard error and p-value on the autocorrelation-adjusted sample size (the same Bretherton et al. correction as correlate), so display(stipple) works on them. For predict the test is "is the predicted departure distinguishable from zero".

Fit one season, not all months: regressing every calendar month together mixes seasons whose response differs. Fit seasonal means where you can, too (timeReduce(per: run)), because single months carry weather noise that a season averages out. The standard shape is:

layer(precipmon) → selectFrames(12,1,2) → timeReduce(mean, per: run)
  → anomaly(monthly, 1991–2020, as: percent) → regress(predictor: enso)

Sinks#

display — (value: field|stack, over?: field|stack)#

Shows the result on the explorer map, exactly like a built-in view: the legend gets the physical range, and a stack stays scrubbable on the timeline. Reverting is just picking any layer or view.

Wiring the optional over input draws a second quantity on top of the first. Area colour is already spent on the fill, so the overlay uses the channels that are left — iso-lines tinted by its own colormap, plus diagonal hatching above overHatchAt. It is the same compositing path the explorer's overlay picker uses, so a program can express any two-layer view the UI can, and ?prog= links carry it.

ParamNotes
title?Legend title.
colormap?thermal, balance, viridis, ice, chl, turbo, grayscale — overrides the automatic pick.
min? / max?Legend range in physical units — overrides the automatic range.
overTitle?Legend title for the over input.
overBands?Iso-line count for the overlay (default 8). Set it to a class count for ordinal quantities so every line lands on a real boundary.
overHatchAt?Hatch the overlay above this physical value; omit for lines only.

Automatic legend: correlation (r) → balance fixed at ±1; relative values → balance symmetric around 0 (range from the 2nd/98th percentiles); everything else → viridis over the 2nd–98th percentile. The over input gets the same treatment independently, so its line tint matches its own legend strip.

JSONC
// "Where was 2019 hot, and where did coral heat stress peak?" — one map, two quantities.
{ "nodes": [
  { "id": "s",  "op": "layer",      "params": { "layer": "sst", "start": "2019-01", "end": "2019-12" } },
  { "id": "d",  "op": "layer",      "params": { "layer": "dhw", "start": "2019-01", "end": "2019-12" } },
  { "id": "sm", "op": "timeReduce", "inputs": { "value": "s" }, "params": { "stat": "mean" } },
  { "id": "dm", "op": "timeReduce", "inputs": { "value": "d" }, "params": { "stat": "max" } },
  { "id": "m",  "op": "display",    "inputs": { "value": "sm", "over": "dm" },
    "params": { "title": "SST mean 2019", "overTitle": "Peak heat stress", "overBands": 4, "overHatchAt": 4 } }
] }

scatter — (a, b, c?)#

Adding the optional c input maps a third variable to dot colour, which is what makes a property-property plot readable — the structure inside the cloud (which water mass, which latitude, which season) is invisible when every dot is the same colour. The colour ramp is diverging for Δ-quantities and sequential otherwise, with its range labelled beside the key. c must be the same kind as a and b (all series, or all gridded).

chart — (a: series, b?: series, c?: series)#

A line chart in the panel, up to three series (each normalized to its own range, with a per-series colored min…max legend).

ParamNotes
title?Chart title (defaults to the series labels).

answer — (value: scalar|series|field)#

A structured numeric result rendered as text (and returned to the Ask tab's model as its tool result). Field answers report the area-weighted mean, the 5th/95th percentiles, and coverage; series answers report mean/min/max and the span; scalars report the value and n.

ParamNotes
labelrequired; what the number answers.

scatter — (a, b) · two series or two gridded inputs#

A scatter plot with a least-squares line and Pearson r — shows the relationship a correlation number summarizes (and the outliers/nonlinearity it hides). Two series pair by nearest date, one dot per time step. Two fields/stacks pair by grid cell (stacks are time-mean reduced first); the fit uses every valid cell, cos(lat)-weighted, and the plot draws a capped subsample.

ParamNotes
title?Plot title.
maxPoints?Plotted-point cap in cell mode (default 3000; the fit always uses all pairs).

histogram — (value: field|stack)#

The value distribution over valid cells, cos(lat)-weighted (a stack pools all its frames). Bars are fractions of the total weight; an amber line marks the weighted mean.

ParamNotes
bins?5–120 (default 40).
title?Plot title.

hovmoller — (value: stack, region?: region)#

A Hovmöller diagram: longitude (or latitude) × time heatmap. Each frame is averaged along the other axis — optionally restricted to a region (an equatorial band is the classic choice) — and stacked as one row per date, oldest at the top. The plot for seeing propagation: ENSO warm pools drift visibly east along the equator. Colormap follows the display rules (balance symmetric for relative values, else viridis), scaled to the diagram's own 2nd–98th percentiles.

ParamNotes
axis?lon (default) or lat.
title?Plot title.

annotate — (value: field)#

Pins the field's extrema on the map: screen-space markers (▲ maxima, ▼ minima) with the value, tracking pan/zoom in every flat projection (hidden on the globe). Markers are kept ≥ 12° apart so one blob doesn't soak up the whole budget. The marker list — values and coordinates — also goes into the run summary, so the Ask tab's model can narrate where things are.

ParamNotes
stat?max (default), min, or both.
count?Markers per kind, 1–8 (default 3).
label?What the markers show (used in the summary).

displayVectors — (u, v) · each field or stack#

Draws a vector field as arrows on the map from two scalar components (u = eastward, v = northward; stacks are time-mean reduced). Arrows are rasterized into the same equirect overlay space as the flow trails, so they composite correctly in every projection and on the globe. Length scales with magnitude (normalized to the field's 98th percentile); combine with a display of the speed for a filled background.

ParamNotes
title?Used in the run summary.
strideDeg?Degrees between arrows, 2–15 (default 5).
scale?Arrow-length multiplier (default 1).

Running programs from code#

The engine is headless and browser-agnostic apart from the loaders. The core API:

TypeScript
import { validate } from 'taos/geo/analysis/validate.js';
import { interpret, InterpreterCache, AnalysisError } from 'taos/geo/analysis/interpret.js';
import { AnalysisRunner } from 'taos/geo/analysis/run.js';           // Worker + inline fallback
import { FieldStore } from 'taos/geo/analysis/field_store.js';
import type { AnalysisProgram } from 'taos/geo/analysis/ast.js';
TypeScript
const store = new FieldStore(providers, { loadOni });
const { sources, sourceKeys } = await store.resolveSources(program);
const result = await new AnalysisRunner().run(program, { sources, sourceKeys });
for (const sink of result.sinks) { /* display / chart / answer results */ }

Ops are defined once in the OPS metadata table (ports, params, output-type resolution). The validator, the graph editor's widgets, and the LLM tool schema all derive from it — adding an op is one entry there plus one implementation in ops.ts.

The LLM surface#

schema.ts generates the Ask tab's tool:

Providers#

The Ask tab talks to either Claude or Gemini, chosen from a picker in the tab's key row. Each vendor keeps its own API key in localStorage (taos_anthropic_api_key / taos_gemini_api_key), pasted by the user at runtime; the selection itself lives in taos_analysis_provider. Calls go browser-direct with that key — no proxy, and no key ships with the sample.

Everything that is the same either way — the transcript, the key row, the run_analysis tool, and the execute pipeline behind it — lives in analysis_chat.ts. The API round-trip sits behind the ChatProvider seam in analysis_chat_provider.ts, with one module per vendor, imported lazily so only the chosen vendor's SDK is ever fetched:

ClaudeGemini
Default modelclaude-opus-4-8gemini-2.5-pro
Tool loopSDK beta tool runnerhand-written in the provider
Schema fieldinput_schema (JSON Schema)parametersJsonSchema

Two vendor differences are worth knowing. Gemini has no tool-runner equivalent, so its provider writes the send → collect functionCall → run → append functionResponse cycle out by hand; and its schema validator accepts JSON Schema via parametersJsonSchema (so anyOf and additionalProperties survive) but rejects const, which toGeminiJsonSchema rewrites to a single-value enum — our only use of const is the per-op discriminator in the node union, so the rewrite is lossless.

Conversation history is provider-native — Anthropic messages[] and Gemini contents[] carry tool calls in incompatible shapes — so switching vendors mid-session starts a fresh conversation rather than translating tool state.

Built-in graphs#

presets.ts ships curated example programs (surfaced in the graph editor's dropdown), each demonstrating a concept: temporal correlation (SST × wind; waves × wind), phase composites (El Niño − La Niña), series lag (correlateSeries), region series + seasonal range (Arctic ice), spatial correlation (chlorophyll × SST), and the El Niño winter outlooks — Dec–Feb rainfall and land temperature predicted from the on-device Niño 3.4 forecast via regress(predict), plus the cross-validated skill map that says where to trust them. Each is validated by the test suite, so they always reflect the current language.


Costs and limits#