Catchment flood risk — stochastic rainfall driving a rainfall-runoff cascade

Methodology card. This is the primary human- and agent-legible description of the model. The runnable stub beside it (stub.go) is the type-checked generative demonstration; this card carries the structure, assumptions, and validity regime that the Go code does not spell out.

System

Catchment-scale flood dynamics under climate change, for the Upper Calder Valley (West Yorkshire) — a catchment with a real flooding history (Boxing Day 2015). A stochastic weather generator produces daily rainfall, which drives a lumped conceptual hydrological model that converts rainfall into river flow. The quantity of interest is the distribution of peak flow — the flood signal — and how it responds to climate perturbation.

The generative core is two coupled partitions:

Partition Iteration State Role
rainfall StochasticRainfallIteration [rainfall_mm] Two-state Markov (wet/dry) + Gamma wet-day amounts
runoff RainfallRunoffIteration [soil_moisture_mm, total_flow, fast_flow, slow_flow] PDM nonlinear runoff + parallel fast/slow stores

Rainfall. A first-order Markov chain switches between dry and wet days (p_wet_given_dry, p_wet_given_wet); wet-day depth is a Gamma draw (wet_day_shape, wet_day_scale). A rainfall_multiplier scales wet-day intensity — the UKCP18-style climate-change knob (1.2 ≈ +20% intensity).

Runoff (PDM-style). Net rainfall (after evapotranspiration) partitions into direct runoff and infiltration via a saturation-dependent runoff fraction 1 − (1 − S/S_max)^b; soil water above field capacity spills to runoff; slow drainage feeds a baseflow store. Direct runoff and drainage route through parallel fast/slow linear reservoirs (recession constants) and convert mm → m³/s by catchment area. The nonlinear saturation response is the hydrological heart: a wet antecedent catchment turns rainfall into flow far more efficiently than a dry one.

Partition wiring

The partition dependency graph, derived statically from the stub’s BuildStub wiring by pkg/graph. Solid arrows are within-step params_from_upstream wiring (which imposes a computation order); dashed arrows leaving a shaded past-copy node are lag reads of a partition’s committed state from an earlier step — drawn as separate source nodes so the graph stays a DAG.

flowchart TB
  n0["rainfall"]
  n1["runoff"]
  n0past["rainfall"]
  n0past -.->|upstream_partition| n1
  classDef pastcopy fill:#F5F5F5,stroke:#4a7ba6,color:#000;
  class n0past pastcopy;

Ingests (in the stub: nothing)

The stub is data-free — every input is a literal constant in stub.go, with the rainfall_multiplier exposed as the one swept driver. In the downstream application the rainfall parameters are fitted from gauge records and the runoff parameters are calibrated against observed flow; the model’s real-world ingests there are Environment Agency rainfall and river-flow series.

Assumptions

Validity regime

Failure modes

Question answered

Given a rainfall climate (occurrence, intensity, and a climate-change scaling factor), what distribution of river peak flows does the catchment produce — and in which direction, and roughly how much, does wetter forcing move the flood peak?

Generative behaviour under test

stub_test.go asserts, beyond “it runs”: 1. Harness — no NaNs, correct state widths, no params mutation, no statefulness residue across a repeated run (simulator.RunWithHarnesses). 2. Physical invariants — rainfall ≥ 0; flows ≥ 0; soil moisture stays inside the store [0, field_capacity]; total flow equals fast + slow every step. 3. Correct direction of parameter response — raising rainfall_multiplier raises the ensemble-mean peak flow (the observed rainfall sweep is the first row of the generated Observed behaviour table below). Averaged over an ensemble so the claim is about the distribution, not one noisy realisation.

The expected-behaviour suite (behaviour_test.go) adds named, plain-language response claims, with the observed number for each emitted by the test run into the Observed behaviour table below (never hand-typed). This model is purely structural — its decision layer (natural flood management) lives entirely downstream, so the stub has no actionable in-stub lever and the suite is instead comprehensive on the structural drivers of the flood peak: higher wet-day persistence raises peak flow; higher evapotranspiration lowers it; a larger catchment area raises it (the mm→m³/s scaling); and a greater soil-storage field_capacity lowers it — the structural basis for why “make room for water” catchment measures work, and the closest the stub comes to expressing the downstream NFM intervention.

Observed behaviour

Every row below is one bound object: a plain-language response claim, the test subtest that enforces it, and the number that test produced (ensemble values rounded to 2 dp). Nothing here is hand-written — the claims and their numbers are emitted by TestFloodRiskExpectedBehaviour (via go run ./cmd/model-graphs), so a claim cannot drift from its test or its result. If the model’s behaviour changes, either the binding test fails (a claim’s assertion broke) or TestCardsUpToDate fails (a number moved) — a broken claim cannot reach the card silently.

Response claim Enforced by Observed
Higher rainfall raises peak flow (headline driver) TestFloodRiskExpectedBehaviour/higher_rainfall_raises_peak_flow ensemble-mean peak flow (m³/s) — ×1.0 32.70 · ×1.15 39.78 · ×1.3 47.62
Higher wet-day persistence raises flow TestFloodRiskExpectedBehaviour/higher_wet_persistence_raises_flow ensemble-mean peak flow (m³/s) — base 32.70 · p_wet_given_wet=0.95 38.46
Higher evapotranspiration lowers flow TestFloodRiskExpectedBehaviour/higher_evapotranspiration_lowers_flow ensemble-mean peak flow (m³/s) — base 32.70 · et_rate=5.0 20.43
Larger catchment area raises flow (mm→m³/s scaling) TestFloodRiskExpectedBehaviour/larger_catchment_area_raises_flow ensemble-mean peak flow (m³/s) — base 32.70 · area ×2 65.40
Greater soil-storage capacity lowers peak flow (“room for water”) TestFloodRiskExpectedBehaviour/higher_field_capacity_lowers_peak_flow ensemble-mean peak flow (m³/s) — base 32.70 · field_capacity=700mm 25.43

Bespoke extensions (staged beside the stub)

StochasticRainfallIteration (rainfall.go) and RainfallRunoffIteration (runoff.go) are custom simulator.Iteration implementations lifted from the downstream repo. The parameter-fitting helpers that accompany them there (estimating Gamma / Markov parameters and calibrating the PDM model from observed series) are inference concerns and were left downstream. These iterations live here rather than in the engine core because the catalogue is the staging ground for the “should this be promoted into core?” question — a generic weather generator or a linear-reservoir routing primitive recurring across other models would be the signal to promote, but that waits for the recurrence.

Downstream

Data ingestion (EA rainfall / flow series), calibration and simulation-based inference, and the natural-flood-management intervention/decision layer live in the project repo:

https://github.com/umbralcalc/floodrisk