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.
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:
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.
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.
Four inputs, three external to the package.
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.
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%).
Currently defaults to all-true — every cell treated as maize. A known limitation and the single highest-value outstanding fix (see Limitations).
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.
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.
| hazard | signal(s) | head |
|---|---|---|
| drought | cuml_rain, dry_spell | maize_precip_deficit_…, maize_dry_spell_… |
| heat | edd, hot_days_min | maize_edd_…, maize_hot_nights_… |
| cold | frost (structural_absent) | maize_frost_… |
| water | precip_excess | maize_excess_moisture_… |
Drought is the two rainfall-supply drivers — cuml_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.
s = curve(dose) ∈ [0,1], by linear/PCHIP interpolation over the
curve breakpoints, NaN-preserving and clamped flat at both ends (total loss stays total loss).S = (Σ s_d³ / n)^(1/3). Max-leaning but not degenerate, and weight-free — legitimate
only because every severity is on the same ruler. Absent hazards (Kenya cold) are declared
structural_absent and excluded from n, not zero-filled.combine_min_coverage), so a reduction over zero valid inputs is NaN — which keeps off-crop
pixels off-crop. A dimension quietly dropping out is a hard error, not a silent coverage change.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.
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.
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.
--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 severities — E[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.
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).
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.
VALIDATION.md as a record.cold reads 0.000 across the equatorial belt, which is correct (declared
structural_absent) and confirms the plumbing.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).scope: global today; per-country (ADM0) anchoring is the productionisation step for TZ/ZM.OUTSTANDING.md.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.