← ag_risk documentation

ag_risk — methods

A modular, gridded crop-risk engine · v1: Kenya maize · weather hazards (drought · heat · cold · water) · ERA5 realised + SEAS5 forecast · curves grounded in published dose–response science

ag_risk produces a gridded crop-risk score in [0, 1] = the expected fraction of attributable crop loss implied by the season's weather to date — per hazard and as one combined headline, on a 0.25° grid, per month. It is an engine, not a single model: hazards are config entries, data sources are connectors, a region is one YAML file. This page is the framework overview and the v1 Kenya-maize methods; it mirrors docs/METHODS.md in the model repo.

Framework overview

One fixed engine, a few swappable inputs. The pipeline provides the data and the WHEN (phenology); the severity library provides the HOW BAD (dose→loss); two per-crop scaffolds — the crop calendar and the crop mask — localise it. Change the inputs, not the engine, to score a new crop, region or peril.

Three properties define the model, and every design decision follows from one of them:

What it is not. The score is a hazard term only — the expected fractional loss of the crop at that pixel, with no exposure or vulnerability weighting. In a risk = hazard × exposure framing this is squarely the hazard term; exposure would enter at aggregation (area-weighting by cropped fraction) and is not implemented. Unweighted spatial means therefore over-count marginal pixels.

How it links to the weather pipeline

WHEN and the accumulation happen upstream. By the time ag_risk reads anything, the dose is already phenoweighted and already cumulative. The model curves, fuses, combines and writes — it does not time, weight or accumulate.

ag_risk and the weather pipeline weather pipeline — upstream (tf-data-ml-utils · treefera-data-pipelines) ARCO-ERA5 hourly 0.25° · realised CDS SEAS5 51-member ~0.85° · forecast stitch obs ⊕ forecast one calendar axis, 0.25° daily indices edd·frost·PET·excess… × phenoweight per-pixel onset onset-cumsum HEAD season-to-date ag_risk — this package sample head at month-end .last() — no re-sum damage curve → severity 0–1 fuse in hazard element-wise max combine hazards power mean p=3 weight-free collapse members forecast only risk_score hz×t×lat×lon severity library shape fp + head axis xp
The seam: the pipeline emits a season-to-date head per hazard; ag_risk samples it at month-end and turns dose into a 0–1 severity via a library curve, fuses within each hazard, combines across hazards, and (forecast only) collapses ensemble members last.

Data sources

Four inputs, three external to the package.

1 · Weather indices — the heads

A gridded indices zarr built by the weather pipeline (tf-data-ml-utils/weather, recipe in treefera-data-pipelines). ARCO-ERA5 hourly 0.25° → daily, standardised to CF variables (tas, tasmax, tasmin, pr, d2m), then daily per-signal indices (precip, dry_day, hot_day_max, hot_night_min, frost, edd, vpd). The water balance is temperature and precipitation only — a deliberate constraint, because it is what lets the identical recipe run on a seasonal forecast: PET by Hargreaves–Samani (no radiation/wind/humidity), and the two tails of PET − precip (water_deficit, precip_excess). No soil moisture and no actual evapotranspiration.

For each hazard the pipeline emits a maize_<peril>_phenoweight_onset_cumsum head: a Gaussian phenology kernel (WHEN, from twx-phenoweight) anchored per pixel to the GGCMI planting_doy, then cumulatively summed within the season (resetting at onset). Because the kernel is normalised, a head is a mean rate, not a physical sum — the EDD head tops ~5–8 vs the literature curve's 0–120 °C·days (~15× compression), which is why curves must be re-anchored.

Season window — documented vs observed. The recipe describes the Kenya season as 03-01…08-31, but measured on both stores (2026-08-05) head validity spans 30 Jan → 31 Aug consistently. The two stores agree, so it is a documentation/recipe discrepancy to reconcile before quoting any window-derived figure.

2 · Crop calendar

GGCMI gridded calendar (maize/rainfed, 0.5° nearest-regridded). The pipeline uses planting_doy as the kernel onset anchor; ag_risk uses modal planting DOY + season length to size its read window. A no-calendar pixel gets NaN daily values, but the pipeline's cumsum(skipna=True) collapses it to 0.0 — indistinguishable from a calm pixel; only a real crop mask removes these (51 of 1312 KE pixels, 3.89%).

3 · Crop mask

Currently defaults to all-true — every cell treated as maize. A known limitation and the single highest-value outstanding fix (see Limitations).

4 · The severity curve library

twx-risk-severity-library, treated as a data input: the model reads finished curves and never fits one; a run pins the library version so a rerun reproduces. Full detail on the severity-library explainer page.

Methodology

Reading the dose

Each scored signal reads its head and samples at month-end: dose = head(signal, pixel, last day of month). .last(), never .sum() — the head is already cumulative — and never re-weighted. Accumulation happens exactly once, upstream.

The Kenya v1 hazard set

hazardsignal(s)head
droughtcuml_rain, dry_spellmaize_precip_deficit_…, maize_dry_spell_…
heatedd, hot_days_minmaize_edd_…, maize_hot_nights_…
coldfrost (structural_absent)maize_frost_…
waterprecip_excessmaize_excess_moisture_…

Drought is the two rainfall-supply driverscuml_rain (cumulative-rainfall shortfall relative to the pixel's own normal, on the climatology-relative axis) and dry_spell (longest dry-day streak to date, i.e. temporal clustering). The fuse takes the worse of the two, so overlapping dryness isn't double-counted. water_deficit (the PET−precip flux) is deliberately dropped from KE drought: on the KE store its head is ~0.69 correlated with the edd head (PET is temperature-driven, and the variable set has no soil moisture / actual ET), so it echoed heat rather than measuring water-supply failure. Its head and curve stay available for regions where that coupling doesn't hold — dropping it is a per-region config choice, not a deletion.

Dose → severity → hazard → headline

Corollary — hazard sets must match to compare headlines. Because n is the denominator, adding a near-zero hazard lowers the headline. Two stores computed over different hazard sets are not directly comparable; combined_hazards travels as a store attribute to keep this traceable.

Severity curves & calibration (in brief)

Every curve carries three independent axes: verdict (Directional / Exposure), status (literature-anchored → Validated), scope (global → ADM0 → ADM2 → point). A curve is a shape (fp, from literature by default) plus a dose axis (xp). Because heads are normalised mean-rates, literature curves are re-anchored onto the region's head axis (onset_cumsum_reanchor) — moving the axis, never the shape, so the published dose–response is preserved (a re-anchor, not a re-fit). The heat and water curves anchor at P99.9 and dry_spell at P99; cuml_rain uses the dimensionless climatology-relative axis (season-end head ÷ the pixel's own normal). The full method — including that ratio-to-normal axis and the step-by-step for adding/calibrating a curve — is on the severity-library explainer page.

The axis guardrail. Head and physical dose axes must never be mixed — it is silently wrong. Head curves carry calibration.method: onset_cumsum_reanchor, and check_coverage asserts before any I/O that a head signal resolves to a re-anchored curve and a non-head signal does not. --dry-run exercises exactly this.

Two run modes

--role realised (ERA5) and --role forecast (SEAS5) — same engine, curves, fuse, combine and bands. A config declares a feed per role; the hazards: block is role-agnostic, so the modes cannot drift apart. Realised appends one month on valid_time; forecast appends a vintage on init_time over horizon.

The one methodological difference — member collapse. The forecast feed's 51-member ensemble rides untouched through read → dose → curve → fuse → combine and is reduced only afterwards: S_final = mean over members of S. The ordering is the substance: the score is an expected loss, so the mean belongs over member severitiesE[loss(dose)], never loss(E[dose]). Collapsing doses first would score one fictional average member and destroy every hot-and-dry co-occurrence.

Reach: forecast_horizon_months: 7. Scores exist only where the forecast window intersects the season window, and because the head is season-to-date over a stitched obs⊕forecast series, a late-season vintage is dominated by weather that already happened (ensemble spread at season end on the 2026-07-01 init: only 0.8–3.4% of head level). Forecast information is greatest early in a season.

Outputs

One zarr per role, a single risk_score variable (float32, NaN off-crop) on a hazard axis: drought · heat · cold · water · combined. combined is an entry on that axis, so a new hazard lengthens a coordinate rather than migrating the schema. Bands travel as attributes (band_threshold_medium 0.33, band_threshold_high 0.67), applied app-side. NaN means "no statement", never "no risk" — off-crop, outside the season window, or beyond a vintage's reach. Full schema in OUTPUT_CONTRACT.md (model repo).

Validation approach & status

The curves are anchored in published agronomic dose–response science (Schlenker & Roberts EDD, Ren waterlogging, Bayer thresholds) — a legitimate, production-usable basis, not a placeholder. Because the score is anchored to literature rather than fitted to local yield, the validation route is a ladder of independent proxies — does the model light up in documented bad years, in the right places and months, and does it co-move with outcome proxies — with consequence-calibration against yield as the planned upgrade that promotes a curve to Validated.

Status: validation is in progress.

Limitations (v1)

  1. Scores are strong relative indicators — best read season vs season, hazard vs hazard, place vs place within a region; absolute levels firm up once curves are consequence-calibrated.
  2. Anchors are Kenya-specific, curves are scope: global — a KE-anchored curve mis-scores TZ/ZM; ADM0-scoped curves are the productionisation step, with no global fallback for head metrics (fail loud rather than score wrong).
  3. Crop mask defaults to all-true — a real maize mask sharpens regional means; highest-value single input to add.
  4. No exposure weighting yet — spatial means are unweighted (area-weighting by cropped fraction is the planned aggregation step).
  5. Frost is uncalibrated — harmless in KE (nil dose), and needs a real anchor before a frost-bearing region relies on it.
  6. Scope: curves are literature-anchored and scope: global today; per-country (ADM0) anchoring is the productionisation step for TZ/ZM.
  7. Plus one canonical phenology profile (per-pixel in onset only) and PET temperature-only (chosen for ERA5/SEAS5 parity). Known open items are collected in the model's OUTSTANDING.md.

Adding a region, variable or dimension

ag_risk is an engine: a new region is one YAML config + ADM0-scoped curves; a new variable is a pipeline index + a head + a curve; a new dimension is a hazard config entry. The near-term goal is three regions (Kenya, Tanzania, Zambia) on ADM0-scoped curves. The full step-by-step is on the extending page (add a region / variable / dimension / commodity, and the reducer requirement), with the severity-side steps on the severity-library explainer page.

Source of truth is the model repo (model-foundry → model_foundry/models/ag_risk): docs/METHODS.md (this page mirrors it), MODEL_CARD.md, docs/{FORECAST,OUTPUT_CONTRACT,VALIDATION,EXTENDING,OUTSTANDING}.md.