← ag_risk documentation

ag_risk — extending: new regions, variables & dimensions

How to grow the model — add a region, a variable, a dimension or a commodity — without touching the engine · mirrors docs/EXTENDING.md

The engine never changes. A hazard is declared in config as fuse + signals, and the severity curve resolves automatically by key — the library key is (crop, peril, metric) = (commodity, hazard-name/peril, signal.name). Everything that produces a feature (a flood model, a disease detector) lives upstream; ag_risk consumes its output on a read contract and scores it. Growing the model is adding inputs, never editing the engine.

Where each journey plugs in

read → derive (dose) → WHEN (phenoweight) → accumulate (season-to-date)
     → severity → fuse (within hazard) → weight-free soft-max combine → band + store
StageOwned byYou touch it when…
reada source connector (io/read.py registry)you add a new upstream producer
doseSIGNAL_TO_HEAD (io/connectors/pipeline.py)a signal needs wiring to its head
WHEN + accumulateupstream only — the weather pipeline's *_onset_cumsum headsa new dimension needs a head built in the pipeline
severitytwx-risk-severity-library (keyed)you add / anchor a curve
fusefuse: in config (default max_severity)never in v1 (config value only)
combinethe engine (weight-free soft-max)never

Three keyed, reusable inputs feed the science: severity functions (twx-risk-severity-library, keyed (crop, peril, metric)), WHEN (phenoweight profiles per (crop, region), which live upstream in the weather pipeline), and the crop calendar (per-pixel planting DOY that anchors the kernel).

Adding a new dimension — the reducer requirement (read this first)

This is the one rule that is easy to miss and breaks the model silently. ag_risk scores compounding risk: a month's score reflects the whole season to date, not that month alone. That only holds if every dimension emits a monotone, season-to-date series — "the consequence so far" — produced by a temporal reducer.

The reducer runs exactly once and must be monotone-to-date. Because it's monotone-to-date, where it runs — at the dimension's own producer or provisionally inside ag_risk — is a deployment choice, not a change of meaning. That is what lets you add a reducer in ag_risk now and migrate it upstream later with no downstream re-validation.

Dimension typeReducerExample
flux (accumulating stress)cumsumheat/EDD, waterlogging surplus (the weather heads)
state (a level)running-min / running-maxsoil moisture — worst level reached to date
ratio (fraction of demand met)cumulative ratioΣ-numerator / Σ-denominator to date (no live KE example — no evaporation in the frozen variable set)
exposure (irreversible area)area-union / running-maxfire scar, flood footprint (no phenoweight)
The "down the line" dimensions (fire · flood · disease) are GEE-sourced and live on a separate demo branch. They are the boxed extensibility demo — observed fields consumed on the read contract, realised-only — and gate out of a weather-only or forecast run automatically. v1 is weather-only. When one is productionised it enters exactly like any dimension below: an upstream source emits the field, a reducer makes it season-to-date, and a severity entry (a fraction/return_period exposure measure, or a curve) is keyed in the library.

Journey A — add a region

A new region for an existing commodity is config only. Same engine, same commodity curves; only the region's calendar, timing and geography change. This is the Tanzania / Zambia path.

  1. New configconfigs/<region>_<commodity>.yaml. Copy the closest existing region, set region / bbox / feeds / calendar, and keep the hazards blocks (the commodity's curves already exist).
  2. A phenoweight profile for (commodity, region) — reuse an existing one if the agro-climate matches (Tanzania can reuse an East-Africa maize profile) or add a new one for a distinct regime (a Southern-Africa profile for Zambia). This is a weather-pipeline change, applied when the region's *_phenoweight_onset_cumsum heads are built; ag_risk only needs the heads to exist.
  3. A crop calendar — the per-pixel planting DOY the profile anchors to (calendar.parquet_uri).
  4. A crop mask (optional but recommended) — a real maize mask (WorldCereal / MapSPAM) via crop_mask:, else the all-true / cropland stopgap.

No new curve, no deriver, no engine change. Then:

ag-risk run score --config configs/<region>_<commodity>.yaml --dry-run   # coverage gate
ag-risk run score --config configs/<region>_<commodity>.yaml             # real run
Severity-side reminder. A head axis is region-specific, so each new region also needs its curves re-anchored / ADM0-scoped against that country's store (except dimensionless ratio-to-normal curves, which are portable). That step — running the calibrator per country — is on the severity-library explainer (§5c, Tanzania/Zambia).

Journey B — add a variable (a new driver signal)

Use this when an existing hazard should gain a new driver, or a signal needs a curve the library doesn't yet hold.

  1. Wire the dose — add one entry to SIGNAL_TO_HEAD (io/connectors/pipeline.py) mapping your signal name to the pipeline's onset-cumsum head. There is no in-model deriver path: if the head doesn't exist, that's a weather-pipeline change (build the phenoweighted onset-cumsum head there) — do not reintroduce a local dose computation, it would diverge from the pipeline's phenoweighting and break the shared 0→1 ruler.
  2. Add a curve to twx-risk-severity-library, keyed (crop, peril, signal.name), with its source / citation / verdict provenance.
  3. Reference it in config — add the signal to the hazard's signals list with its name (== the library metric), source, kind and stage hint. The curve resolves by key; you never name it.

Journey C — add a commodity

The heaviest journey — a new crop needs its own science, but still no engine change: a crop calendar, a phenoweight profile, crop-keyed curves (the crop half of the key changes, so maize curves don't transfer), a crop mask, and a config wiring the hazards to the new signals.

The config schema (current)

A hazard block declares its signals (and optionally fuse / peril). Nothing names a severity function — the curve resolves by key; nothing names the dose maths — every scored signal resolves by name to an upstream onset-cumsum head via SIGNAL_TO_HEAD.

region: kenya
commodity: maize          # the `crop` half of the library key

hazards:
  drought:                # a hazard = a dimension; its name is the `peril` half of the key
    fuse: max_severity    # optional — signals → ONE hazard severity (default: worst signal)
    signals:
      - name: dry_spell        # == the library metric; resolves (maize, drought, dry_spell)
        source: weather
        kind: curve            # curve | fraction | return_period
        stage: pre_flower      # hint only — WHEN comes from the phenoweight kernel
        source_kind: onset_cumsum_head

  cold:
    peril: cold_frost
    structural_absent: true    # reported as its own 0 layer, but EXCLUDED from `combined`
    signals:
      - {name: frost, source: weather, kind: curve, source_kind: onset_cumsum_head}

  water:
    peril: waterlogging        # maps the product dimension to the severity key
    signals:
      - {name: precip_excess, source: weather, kind: curve, stage: pre_flower}
ag-risk run score --config configs/kenya_maize.yaml --dry-run   # curve-coverage gate; no engine edit

What extension never does

Checklist — adding a hazard signal

Source of truth: docs/EXTENDING.md in the model repo (model-foundry → model_foundry/models/ag_risk). Severity-side steps (adding/anchoring a curve, new-country calibration) are on the severity-library explainer; the model overview is on the methods page.