Next steps — the modelling roadmap
Distinct from data_gaps.md, which lists data we cannot fetch. This lists work to do, in the order it has to be done, and says what each step unblocks. Written 2026-08-11.
Distinct from data_gaps.md, which lists data we cannot fetch. This
lists work to do, in the order it has to be done, and says what
each step unblocks. Written 2026-08-11.
The ordering is not a preference. Each step consumes the output of the one before it, so doing them out of order means fitting a model to inputs that are about to change.
1. Dynamic water modelling — rainfall and river level coupling
Do this first. Everything downstream needs a driver, and rainfall is the only true forcing in the system. Until the catchment responds to rain in time, every water balance we have is an annual average wearing a daily timestamp.
What exists already
build_rainfall.pybuilds per-catchment daily rainfall series for the nine gauged catchments plus the ungauged remainder- daily flow series for the gauges;
calibrate_runoff.pyalready fits a soil-moisture bucket against them analyse_recession.pyfor the falling limb
What is missing — the coupling itself: rainfall → soil moisture → runoff → river level, at a daily step, calibrated per catchment rather than regionally. The bucket exists; what it lacks is a routing and level relation so that “river level on day n” is a modelled quantity and not an observation we happen to hold.
Done looks like: a daily river level at each intake candidate, driven by rainfall, reproducing the observed hydrograph within a stated error over the calibration period — and, critically, over a validation period it was not fitted to.
Why it must be first: step 2 needs a driver and step 3 needs river level to decide what is divertible and what a returned flow does.
It is now a HARD prerequisite, not an ordering preference
Step 2 was built and it hit a wall that only step 1 can move. The
coupled model has no quick-runoff pathway - soil-bucket overflow all
becomes recharge - so it identifies recharge / Sy and neither term
alone. A three-arm sensitivity test confirmed the degeneracy: moving
the AET prior by +/-90 mm/yr moved recharge by 15% and fitted Sy by
13%, conserving the ratio to within 2.5%.
Step 1 partitions overflow between runoff and recharge. That is the same split the river model needs, so it closes the degeneracy as a BY-PRODUCT rather than as extra work.
Rainfall is already areal, and the routing already has a scar
rain_catchment_daily.csvis area-weighted per catchment throughrain_thiessen_weights.csv, over the catchments delineated bybuild_catchments.py. Nothing point-based needs fixing.- Water must be counted exactly once.
compare_04c_nesting.pyexists because run 04 summed nine gauges whose catchments NEST, so shared water was double-counted; 04c is the eight-gauge correction. That is the precedent - the routing test is not “does rain reach a river” but “does each parcel reach exactly one accounting point”.
Flood / high-flow data is the calibration lever
Mean flows are where the quick and slow pathways are LEAST distinguishable, and that is what we have been calibrating on. Flood peaks are where they separate maximally: a sharp peak with a muted groundwater response is direct evidence of the split; a muted peak with a long recession is evidence of the other.
fetch_river_levels.py pulls stage from the hydrology API (the
flood-monitoring service keeps only ~4 weeks). One dataset does two
jobs - it also turns h_base in step 2 from a constant into a series.
Carry this caution into calibration: flood peaks are often
SUB-DAILY. A daily model under-fits them systematically, and tuning
the runoff fraction to compensate absorbs a timestep error into a
physical parameter. Check period per measure first.
Also worth having: EA Recorded Flood Outlines. They constrain WHERE water goes once channels are exceeded, which matters here in a way it would not for a pure water-resources scheme - this project’s premise is that the floodplain is storage.
Sub-catchments at key points — worth testing, with one rule
The idea: subdivide strategically where flood data is good, so that river level at points we care about becomes a modelled output rather than an interpolation. This is semi-distributed modelling and it is sound. One rule keeps it honest:
Subdividing where an OBSERVATION exists adds information. Subdividing where a DECISION needs an answer only relocates the output. Both are legitimate and they are not the same thing. A model cut into twenty sub-catchments with six gauges has six constraints and fourteen extrapolations, and will look more precise while being no better informed - the classic way a semi-distributed model becomes confidently wrong.
So: split at gauges and good flood records FIRST and check the fit improves; only then push sub-catchment outlets out to candidate intake sites, and label those outputs as extrapolated. The nesting scar above is the same lesson from the other direction.
2. Dynamic water table
What exists
build_water_table.py— a STATIC surface,wt = 0.05 + 0.768 × ground, n=100, R² 0.83, residual SD 21.1 mbuild_wt_dynamics.py— the observed dynamics characterised but not modelled: Chalk swings a median 11.9 m/yr, most stations peak in Jan–Feb, Chalk headroom 52.8 m at the seasonal high
What is missing — a water table that MOVES, driven by step 1. The
current surface has no time dimension, so saturated thickness, and
therefore every volume in aquifer_volumes.md, is a single annual
figure for a quantity observed to swing by 12 m.
Method, when we get to it: cross-correlate each station against its catchment rainfall series and its nearest gauge; fit a lag and a gain per aquifer; drive the fitted surface with step 1’s output. The lag is the number that matters — the project’s surplus-to-deficit lag is 229 days, and an aquifer whose head peaks with the rivers offers no seasonal shift at all.
Reuse the rainfall→river pattern — with one change
The obvious move, and the right one: the water table is another STORE
with an input, a loss and a lag, which is exactly what
calibrate_runoff.py already fits for soil moisture. Same daily step,
same calibrate-then-validate-on-held-out-years discipline, same shape
of object. Do not invent a second framework for it.
Two things that do NOT carry over, and both matter:
1. The time constant is an order of magnitude longer. Runoff responds in days; the water table in months. Consequences: the calibration window must contain several full recharge cycles (the ~11-year record from 2010 is enough, but a 3-year station is not — see the coverage note above), and a fit that looks good on a single wet winter is meaningless.
2. THE COUPLING IS TWO-WAY, AND RAINFALL→RIVER IS NOT. Rain drives rivers and rivers do not drive rain. But the aquifer feeds the river as baseflow and a high river stage can recharge the aquifer through the bed. Modelling it one-way — river level as a driver of the water table — would silently double-count: the same water arrives as baseflow in the river AND is credited as storage in the aquifer.
This is the single place where copying the rainfall pattern wholesale would introduce an error rather than save work. The fix is to make exchange a function of the HEAD DIFFERENCE between aquifer and river, signed, so the direction falls out of the state rather than being assumed — which also makes step 3’s closed balance possible instead of retro-fitted.
Two things to carry forward, both already established
- Headroom, not storage, is the binding constraint on injection. Surplus water exists in winter; winter is when the aquifer is fullest. A dynamic water table is what turns that from an observation into a constraint the model enforces.
- Coverage is the weak point, and it is specific: only 47 of 115 stations have 3+ years, and the Otter Sandstone — the actual target — has one. Fix that before trusting a fitted response for HEY.
3. Injection and baseflow accountancy for leaky aquifers
Last, because it needs both of the above. This is the step that closes the loop: what happens to water we put in, and what comes back out where we did not ask for it.
What exists
- leakage per day, residence time and half-life tiers in
aquifer_registry.yml;derive_leakage.pyandestimate_aquifer_unknowns.pybehind them core/model/aquifer.pytreats the aquifer as a storage account with a dead fraction — a balance, not a groundwater simulation
What is missing — the accountancy proper:
- injection — how much can go in, at what rate, before head rise eats the headroom. Amplitude bounds it: an aquifer that naturally swings 2 m will not take a 20 m mound without pushing back. Nothing in the record contains an injection, so this cannot be validated against observation and must be presented as bounded, not predicted.
- baseflow return — leakage out of a leaky aquifer is not a loss if it surfaces in a river we are counting. Run 04 already credits 80% of leakage back as baseflow; that number is an assumption and should become a routed quantity once step 1 gives us river level.
- double-counting — the same water must not appear as aquifer storage AND as river baseflow in the same month. This is the specific failure mode this step exists to prevent.
Done looks like: a closed daily balance where injected volume, storage change, leakage and baseflow credit sum to zero within tolerance, with the recovery constrained by the compartment rule rather than by polygon area.
Deliberately parked
Not forgotten, and not being chased — the flows above are worth more than these:
| gap | governs | grade |
|---|---|---|
| Bracklesham + Barton — nothing read at all | 5,179 Mm³ | guess |
| Upper Greensand thickness | 1,623 Mm³ | guess |
| Great Oolite thickness/storage | 1,097 Mm³ | guess |
| White Chalk thickness (Sy now closed) | 486 Mm³ | guess |
| Wiveliscombe Sandstones — no Lexicon entry exists | — | inferred |
Why parking these is defensible: they widen the range on volumes
that are already labelled guess, and no decision currently turns on
their central values. Why it stops being defensible: the moment a
site is chosen and one of these bodies is near it. --site E,N on
inventory_aquifers.py is what tells you which.
Also parked: the per-aquifer water-table fit currently applies to the Chalk only (n=48). Inferior Oolite (7), Great Oolite (6) and Carboniferous Limestone (6) all sit just under the n≥8 threshold — a handful more long records each would promote them.