title: "Chapter 3 — Rainfall-Runoff Generation" chapter: 3
Chapter 3 — Rainfall-Runoff Generation
Chapters 1–2 answered how does water move across the terrain once it is on the surface? We pretended every drop of rain instantly became surface runoff. That is almost never true.
Stand in a forest during a shower and the ground stays firm — the rain soaks in. Stand on a city street and the same rain sheets straight into the gutter. Stand in a marshy field next to a stream and your boots fill — the soil is already full, so the rain has nowhere to go but to run off. This chapter fills that gap: how much of the rainfall actually becomes runoff, where, and when?
This is also the densest part of OPM. Three physical mechanisms run simultaneously, a "sandbox" model evolves soil state every timestep, and almost every parameter is measured from satellites rather than guessed. Take it one section at a time — by the end, every line of the real runoff_input.py will be explained, not simplified away.
3.1 — Rainfall Is Not Runoff: Three Mechanisms, Five Modes
OPM answers the question with three physical mechanisms working together:
- Saturation-excess (Dunne) runoff — where the soil is already full, every drop runs off. Captured by the Variable Source Area (VSA) model (§3.3).
- Infiltration-excess (Hortonian) runoff — where rain falls faster than the soil can absorb it, the excess runs off. Captured by the Green-Ampt infiltration model (§3.4).
- Impervious shedding — roofs and roads cannot absorb water at all, so a fixed fraction of urban cells sheds everything (§3.5).
The RunoffEngine class (runoff_input.py) actually supports five modes, selected by config.RUNOFF_SOURCE, forming a ladder from "trivial" to "fully physical":
| Mode | RUNOFF_SOURCE | What it does |
|---|---|---|
| None | 'none' | All rain becomes runoff; stateless (the Chapter 1–2 assumption) |
| Coefficient | 'coefficient' | Multiply rain by a static runoff-coefficient raster |
| Raster | 'raster' | Read a pre-computed runoff time series from disk |
| SCS-CN | 'scs_cn' | SCS Curve Number method on cumulative rainfall |
| VSA-OPM | 'vsa_opm' | Variable Source Area + Green-Ampt + impervious — the full model |
The simpler modes are useful for teaching and quick what-if runs, but they all hide the physics behind a number you must guess (a coefficient, a curve number). vsa_opm instead computes runoff from quantities we can actually measure — soil moisture, soil texture, land cover — so it needs no per-storm tuning. The rest of this chapter is about vsa_opm.
3.2 — Two Ways to Get Wet: Dunne vs. Horton
Hydrologists recognise two fundamentally different ways that rain turns into surface runoff. They look the same from a helicopter — water flowing overland — but they start for opposite reasons.
Saturation-excess (Dunne): the soil is full from below. Imagine a sponge sitting in a shallow tray of water. The bottom of the sponge is already soaked; the water table has risen into it. Pour more water on top and it runs straight off, because there is no empty pore space left. This happens in valley bottoms and near streams, where groundwater collects. The rate of rainfall does not matter — a full sponge rejects everything.
Infiltration-excess (Horton): the rain is too fast for the surface. Now take a dry sponge and blast it with a fire hose. Most of the water splashes off, not because the sponge is full, but because water cannot soak through the surface as fast as it arrives. This happens on crusted, compacted, or baked soils during intense storms. Here the rainfall rate is everything.
Two Ways Rain Becomes Runoff
Saturation-excess (Dunne) vs. infiltration-excess (Horton) — capacity vs. intensity
Dunne — Saturation-Excess
“The soil is full from below.” Sponge sitting in a shallow tray of water.
Saturation-excess: the soil's capacity decides, not the storm's intensity.
Horton — Infiltration-Excess
“The rain is too fast for the surface.” Dry sponge blasted with a fire hose.
Infiltration-excess: the storm's intensity decides, not the soil's capacity.
The two mechanisms are not rivals — a real catchment has both at once. Valley bottoms shed by saturation (Dunne); steep dry slopes under an intense burst shed by infiltration-excess (Horton); cities shed because they are paved. OPM runs all three simultaneously and adds their contributions (§3.5).
The VSA idea traces back to TOPMODEL (Beven & Kirkby, 1979), which ranks how likely each point is to saturate using the topographic wetness index — large where the upslope area is big (lots of water arrives) and the local slope is small (water lingers). OPM's VSA model (next section) simplifies this to a single, time-varying threshold on upslope area alone, which is what makes it a one-parameter model: calibrate it from a single discharge measurement and it tells you the whole saturated pattern.
3.3 — The Variable Source Area: a One-Parameter Watershed
OPM keeps a tiny "mental model" of the groundwater at the catchment divide — a sandbox — and uses it to move a single threshold area up and down. Five equations (numbered as in Pradhan & Ogden, 2010, matching the code's own comments) do all the work.
Equation 10 — the initial threshold area. The whole model is calibrated from one number: the pre-storm baseflow [m³/s] measured at the outlet, together with the catchment area [m²]:
Any cell whose upslope area exceeds is already saturated when the storm begins. A larger means a wetter catchment right now — a higher water table, a more extensive saturated zone — so shrinks and more cells qualify as saturated from the start.
Equation 4 — the constant . Computed once, ties the initial soil-moisture state to that initial threshold area, and never changes again:
where is one grid cell's area, [m] is the initial root-zone soil-moisture deficit (how much water the soil column could still absorb), and m is a floor. Since , is always negative.
Equation 12 — the sandbox water balance. The sandbox tracks one state variable, [m] — the height of the water table above an impervious base at the divide — updated by forward Euler exactly like a routing cell:
is how much more water the root zone can still take before it saturates. Every millimetre the water table climbs is a millimetre less room left.
Equation 5 — the dynamic threshold area. Recomputed every step as the soil wets:
As falls, decreases — a smaller threshold means more cells clear the bar. The VSA expands as the storm soaks in.
Equation 9 — the VSA mask. Finally, the saturated set is rebuilt every step from the current threshold:
Cells in the VSA shed 100% of rainfall as Dunne runoff; cells outside shed nothing unless Green-Ampt or impervious fractions are active.
Step through the algebra below with a verified 10-cell worked example — every number is reproduced exactly from the project's own hand-checked documentation:
Building the VSA Equations, One at a Time
OPM's one-parameter saturation scheme — five numbered equations (Pradhan & Ogden, 2010), revealed in the order OPM's code computes them
The Initial Threshold Area
Eq. 10Before the storm starts, OPM calibrates a single number — the threshold upslope area A_t⁽⁰⁾ — from one pre-storm measurement: the recent baseflow peak Q_max. Any cell whose upslope area already exceeds this threshold is treated as saturated before a drop of rain falls.
Now watch the same equations play out spatially. On a real DEM, upslope area isn't uniform — it concentrates in valley bottoms and channel confluences, so that's where saturation appears first:
Watching the VSA Grow Across a Watershed
One scalar threshold A_t(t), compared against every cell's upslope area — the spatial companion to the VSA equation builder
A drier antecedent state has more room to expand — to see the VSA visibly grow you need a drier start and a heavier storm.
Blue = saturated (in the VSA) · gray-scale = relative upslope area (elevation-shaded, not saturation)
3.4 — Green-Ampt: When the Soil Can't Keep Up
The VSA model handles the saturated valley bottoms. Green-Ampt (OPM_INFILTRATION = 'green_ampt') handles everywhere else: how fast can unsaturated soil drink the rain, and what is the leftover that runs off?
Green & Ampt (1911) idealised infiltration as a sharp wetting front descending into the soil like a piston: saturated above, dry below. Two forces pull water down — gravity, and the capillary suction of the dry soil below the front sucking water into its empty pores. The maximum rate the soil can absorb [m/s] is:
with the vertical saturated conductivity [m/s], the wetting-front suction [m], the initial moisture deficit [-], and the cumulative infiltration so far [m]. When the soil is dry () capacity is huge — all rain soaks in; as it wets () capacity sinks toward .
Runoff begins the instant the soil can no longer keep up, . Solving for the cumulative infiltration at that moment:
If , the soil always wins and there is never any Horton runoff. A worked example: mm/hr, m, , mm/hr gives mm, reached after about 50 minutes of steady rain — after which the runoff is mm/hr, 75% of the rain.
Green-Ampt Infiltration Capacity
f_p = K_v(1 + ψ·Δθ₀/F) — capacity falls as the wetting front advances
Rawls (1983) soil-texture presets
| Texture | ψ (m) | K_v (mm/hr) |
|---|---|---|
| Sand | 0.050 | 117 |
| Sandy loam | 0.110 | 23 |
| Loam | 0.089 | 13 |
| Silt loam | 0.167 | 7 |
| Clay loam | 0.209 | 2 |
| Clay | 0.316 | 0.5 |
Click a row to load ψ and K_v together — coarser soils have small ψ & large K_v, fine soils the reverse.
⚠ Don't confuse the two K_sats
This widget's K_v is the vertical surface-infiltration rate (typically 1–50 mm/hr) — the soil's ability to absorb rain straight down. OPM also has a completely separate lateral sandbox-drainage transmissivity, OPM_K_SAT, 44 m/day ≈ 1830 mm/hr — about 1000× larger.
Using the lateral value here would make f_p ≫ P everywhere, so the soil would never be overwhelmed and Horton runoff would never appear — physically wrong. The names look similar; the roles are opposite.
Infiltration capacity f_p vs. cumulative infiltration F — shaded region = Horton runoff excess
The two conductivities you must not confuse. OPM uses two saturated conductivities for two different jobs: (OPM_GA_KSAT_MMHR, or gridded via 'gee') is the vertical rate at which water enters the soil surface — typical 1–50 mm/hr. (OPM_K_SAT, 44 m/day ≈ 1830 mm/hr) is the lateral transmissivity that drains the sandbox sideways down the hillslope — roughly a thousand times larger. Using the lateral value for Green-Ampt would make everywhere, so the soil would never be overwhelmed and Horton runoff would never appear — physically wrong. The names look similar; the roles are opposite.
With OPM_GA_SUCTION_SOURCE = 'texture', the suction is read per cell from soil texture (sand/clay%, from SoilGrids) through the USDA texture triangle and the Rawls (1983) table:
| USDA texture | [m] | typical [mm/hr] |
|---|---|---|
| Sand | 0.0495 | 117 |
| Sandy loam | 0.110 | 23 |
| Loam | 0.089 | 13 |
| Silt loam | 0.167 | 7 |
| Clay loam | 0.209 | 2 |
| Clay | 0.316 | 0.5 |
Because enters Green-Ampt only as the product , making it spatial is cheap and consistent with the spatial deficit.
3.5 — Impervious Surfaces & the Combined Formula
Roofs, roads, and car parks cannot infiltrate at all. Each cell carries an impervious fraction ; that fraction of the cell sheds 100% of its rain no matter what the soil or VSA is doing. The source is set by IMPERVIOUS_SOURCE: 'lcz' (Local Climate Zones — designed precisely to describe urban form), 'lulc' (ESA WorldCover), 'raster' (a continuous GeoTIFF), or 'none' ().
All three mechanisms collapse into one per-cell effective-runoff rate [m/s]:
where the pervious part runs off fully if the cell is saturated, and otherwise only the infiltration-excess fraction runs off:
| Cell state | Imp | In VSA? | excess | |
|---|---|---|---|---|
| Rural, saturated | 0 | Yes | – | 1.0 (full Dunne) |
| Urban, saturated | 0.3 | Yes | – | (still full) |
| Rural, dry, soil wins | 0 | No | 0 | 0 (all infiltrates) |
| Urban, dry, soil wins | 0.3 | No | 0 | 0.3 (only the paving sheds) |
| Rural, dry, Horton | 0 | No | 0.4 | 0.4 (infiltration-excess) |
| Urban, dry, Horton | 0.3 | No | 0.4 |
Inside the VSA, impervious fraction is irrelevant. When pervious_frac = 1, the formula gives regardless of Imp — a saturated cell already sheds everything, so paving it changes nothing. Impervious fraction only matters outside the VSA.
Runoff Decomposition
r_eff = P·[Imp + (1−Imp)·pervious_frac] — three mechanisms, one formula
Inside the VSA, impervious fraction is irrelevant
When pervious_frac = 1 (cell saturated), r_eff = P[Imp + (1−Imp)] = P regardless of Imp — a saturated cell already sheds everything, so paving it changes nothing. Imp only matters outside the VSA.
Reference presets (click to load)
| Preset | Imp | VSA? | excess | r_eff/P |
|---|---|---|---|---|
| Rural, saturated | 0.0 | Y | – | 1.00 |
| Urban, saturated | 0.3 | Y | – | 1.00 |
| Rural, dry, soil wins | 0.0 | N | 0.0 | 0.00 |
| Urban, dry, soil wins | 0.3 | N | 0.0 | 0.30 |
| Rural, dry, Horton | 0.0 | N | 0.4 | 0.40 |
| Urban, dry, Horton | 0.3 | N | 0.4 | 0.58 |
r_eff / P split into its three additive mechanisms
The code also splits into three named streams so the mass-balance report can attribute every cubic metre of runoff to a mechanism:
and by construction . This is the real, unabridged function from runoff_input.py:
def _opm_effective_runoff(self, rain_1d):
xp = self._xp
if self._infiltration == 'green_ampt':
f_p = self._ga_ksat * (1.0 + self._ga_psi * self._ga_dtheta0
/ xp.maximum(self._ga_F, self._GA_F_FLOOR))
excess = xp.maximum(rain_1d - f_p, 0.0)
excess_frac = xp.where(rain_1d > 0.0,
excess / xp.maximum(rain_1d, 1e-30), 0.0)
else:
excess_frac = 0.0 # 'none': all rain infiltrates
pervious_frac = xp.where(self._vsa_mask, 1.0, excess_frac)
imp = self._imperv_1d
perv = 1.0 - imp
self._last_imperv_rate = rain_1d * imp
self._last_dunne_rate = rain_1d * perv * xp.where(self._vsa_mask, 1.0, 0.0)
self._last_horton_rate = rain_1d * perv * xp.where(self._vsa_mask, 0.0, excess_frac)
return rain_1d * (imp + perv * pervious_frac)
The three _last_*_rate arrays are exactly the , , above, stashed so the router can integrate them into mb_dunne, mb_horton, mb_imperv for the mass-balance CSV — every cubic metre of runoff is attributed to a mechanism, every run.
3.6 — State Sequencing: the Forward-Euler Call Order
The runoff engine carries state (, , , the VSA mask) that must advance in lock-step with the router. The rule, identical in spirit to the explicit routing of earlier chapters: use the current state to produce this step's runoff, then advance the state for the next step.
Call Order Matters: Read State, Then Advance It
Every timestep the router calls get_effective_1d before update_state — never the other way around
✓ Correct: read, then advance
get_effective_1d reads the VSA mask, F, and z exactly as they stand at step n to decide how much of this step's rain becomes runoff. Only after that runoff number is locked in does update_state write z^{n+1}, SD_max^{n+1}, F^{n+1}, and rebuild the VSA mask for the next step.✗ Reversed: advance, then read (wrong)
kinematic_wave_router.py):source_1d = runoff_engine.get_effective_1d(t_seconds, rain_1d) # uses state^n
runoff_engine.update_state(rain_1d, dt) # state^n -> state^{n+1}This is the real call order from kinematic_wave_router.py's time loop:
if runoff_engine is not None:
source_1d = runoff_engine.get_effective_1d(t_seconds, rain_1d) # [m/s]
if _partition:
mb_dunne += runoff_engine._last_dunne_rate.sum() * (cell_area * dt)
mb_horton += runoff_engine._last_horton_rate.sum() * (cell_area * dt)
mb_imperv += runoff_engine._last_imperv_rate.sum() * (cell_area * dt)
runoff_engine.update_state(rain_1d, dt)
else:
source_1d = rain_1d
rain_vol = source_1d * cell_area * dt
get_effective_1d reads the VSA mask, , and at step ; update_state then writes , , , and rebuilds the mask for step . Reversing the two would use tomorrow's soil to shed today's rain — a cell that saturates partway through this step's storm would incorrectly be treated as already-saturated for this entire step.
3.7 — Spatial Heterogeneity: One Sandbox Per Rain Gauge
A single sandbox assumes the whole basin wets up together. For a large catchment with many rain gauges that is unrealistic — one tributary may be in a downpour while another is dry. With OPM_PER_POLYGON = True (the default), each Thiessen rainfall zone gets its own sandbox: its own divide cell, its own , , and , driven by its own local rainfall.
Each zone's divide is the cell with the minimum flow accumulation in that zone (the most headwater point), with ties broken by highest elevation. When the SERVES deficit raster is available, each zone's is reduced (mean or max, per OPM_SD_REDUCER) over only that zone's watershed cells, so the soil-moisture partition matches the rainfall partition.
One Sandbox Per Rainfall Zone
OPM_PER_POLYGON = True (the default) — each tributary gets its own independent VSA sandbox, driven by its own gauge
A single shared VSA sandbox (see the equation-builder widget above) assumes the whole basin wets up together — one z, one SD_max(t), one A_t(t) for the entire catchment. Real catchments are split into rainfall zones (Thiessen/IDW polygons around each gauge), and OPM runs the exact same equations — Eq.10, Eq.4, Eq.12, Eq.5, Eq.9 — independently inside each zone. Below, all three zones share identical soil and slope parameters; only their rain rate differs. Adjust each slider independently, then press Step or Play to watch all three sandboxes evolve on the same shared clock.
Key insight
One tributary can be saturating fast while another stays nearly dry — a single shared sandbox would average these away and get both wrong. Averaging Zone A's 35 mm/hr downpour with Zone C's 5 mm/hr drizzle into one basin-wide rain rate would under-predict how saturated the heavy tributary really gets, and over-predict how saturated the dry one gets — exactly backwards from what either sub-catchment is actually doing.
One more thing this widget doesn't visualize: the real model also picks each zone's divide cell independently — the cell with the minimum flow accumulation in that zone (the most headwater point), tie-broken by highest elevation.
The whole per-polygon sandbox advances with no Python loop over zones — fully vectorised:
def _update_opm_sandbox_per_polygon(self, rain_1d, dt):
xp = self._xp
f_div = self._divide_infiltration(rain_1d) # (n_polygons,)
q_b = (self._ksat_ms * self._polygon_slope_divide
* self._opm_z * self._cell_size)
dV = (f_div * self._cell_area - q_b) * dt
dz = dV / (self._cell_area * self._phi)
self._opm_z = xp.maximum(0.0, self._opm_z + dz)
self._opm_SD_max = xp.maximum(self._sd_min,
self._SD_max_initial - self._opm_z)
Rf_t = self._sd_min / self._opm_SD_max
denom = self._opm_H_a - xp.log(Rf_t)
# Guard the near-zero denominator before dividing (xp.where evaluates
# both branches, so the divisor must be finite even where unused).
denom_safe = xp.where(xp.abs(denom) < 1e-12, 1.0, denom)
new_A_t = xp.where(xp.abs(denom) < 1e-12, self._opm_A_t_init,
self._opm_H_a * self._opm_A_1 / denom_safe)
self._opm_A_t = xp.clip(new_A_t, self._opm_A_1, self._opm_A_outlet)
# Vectorised VSA mask rebuild: each cell uses its polygon's A_t
A_t_per_cell = self._opm_A_t[self._cell_polygon]
self._vsa_mask = self._upslope_area > A_t_per_cell
The denom_safe guard is the same GPU-safe-indexing family as the ds_safe trick from Chapter 5's routing code: compute everywhere, mask the result. xp.where evaluates both branches before selecting, so the divisor passed into the unused branch must still be finite — even though it's discarded — or the GPU kernel would produce a NaN that xp.where can't un-produce after the fact.
3.8 — From Satellites to Parameters: the SERVES/GEE Chain
So far we have written parameters like , , , , , as if someone simply typed them in. For the full configuration, almost all of them are instead measured from satellites through Google Earth Engine (GEE) — the single biggest reason the model needs essentially no manual calibration.
Why measure instead of guess? The traditional way to set these numbers is to calibrate: run the model, compare to a gauge, nudge the parameters, repeat. That needs a long observed record and produces numbers that may not transfer to the next storm or the next basin. OPM instead reads the actual antecedent wetness, soil type, and land cover for the specific date and place — the same way you'd check a weather map before a hike. Same model, new event ⟹ just change the date.
The heart of it is SERVES, which estimates how wet the soil was before the storm from how green the vegetation is — greener plants imply more available soil water:
Greener vegetation ⟹ higher NDVI ⟹ wetter soil (θ near field capacity) ⟹ smaller deficit ⟹ a wetter antecedent state ⟹ a larger initial VSA (§3.3's Eq. 4–5) ⟹ more runoff from the first drops. Every link is physical, and every input is observed.
From Satellite Greenness to SD_max: The SERVES Chain
serves_gee.py — turning a satellite NDVI pixel into OPM's antecedent soil-moisture deficit, with no calibration knob
Live-computed chain
FC (field capacity) = 0.35 · WP (wilting point) = 0.15 · porosity = 0.45 · Z_r (root-zone depth) = 1.0 m
FC and WP come from SoilGrids; porosity from HiHydroSoil v2.0; Z_r from a land-cover lookup table.
| GEE dataset | Provides | Used for |
|---|---|---|
| ESA WorldCover v200 (10m) | Land cover class | Root-zone depth , Manning , impervious |
| WUDAPT LCZ | Local Climate Zone | Manning , impervious, (urban detail) |
| Landsat 8/9 (Sentinel-2, MODIS) | NDVI (greenness) | SERVES soil moisture |
| SoilGrids250m wv0033 / wv1500 | Field capacity, wilting point | range; texture → suction |
| HiHydroSoil v2.0 wcsat | Saturated water content | Porosity (for , ) |
| HiHydroSoil v2.0 Ksat | Vertical conductivity | Green-Ampt |
| NASA GPM IMERG V07 | Rainfall (0.1°, 30 min) | Precipitation forcing |
Graceful fallback. Every 'gee' source has a scalar fallback. If a cell has no texture data, reverts to OPM_GA_SUCTION_M; if Ksat is missing, to OPM_GA_KSAT_MMHR; if the whole SERVES query fails, the model uses OPM_SD_MAX_INITIAL and OPM_PHI. The satellite data improves the run; it is never a single point of failure.
3.9 — Chapter Summary
| Concept | Formula | Key point |
|---|---|---|
| Initial threshold (Eq 10) | One baseflow reading sets the starting VSA | |
| Constant (Eq 4) | Computed once, ties soil state to | |
| Sandbox (Eq 12) | Forward-Euler water table at the divide | |
| Dynamic threshold (Eq 5) | Falls as the storm wets the soil | |
| VSA mask (Eq 9) | in VSA upslope_area | Saturated set, rebuilt every step |
| Green-Ampt | Horton runoff once drops to | |
| SERVES deficit | Satellite greenness → soil parameter | |
| Combined | All three mechanisms in one number |
Key takeaways:
- Rainfall is not runoff. OPM converts it with three mechanisms running at once: saturation-excess (VSA/Dunne), infiltration-excess (Green-Ampt/Horton), and impervious urban shedding.
- The VSA model is a one-parameter scheme: a single baseflow sets the initial saturated area, and a tiny sandbox at the divide moves the threshold down as the storm wets the soil.
- Green-Ampt adds Horton runoff for unsaturated cells; it begins once the rain outpaces the soil. Always use the vertical , never the lateral — they differ by roughly 1000×.
- The combined formula sums the mechanisms; inside the VSA a cell always sheds 100% of rain, so impervious fraction only matters outside it.
- Per-polygon mode gives every gauge zone its own sandbox, so spatially variable rain drives spatially variable saturation.
- Almost every parameter is measured, not guessed: SERVES turns satellite greenness into ; SoilGrids and HiHydroSoil give porosity, , texture-based , and ; land cover gives roughness, root depth, and imperviousness — every
'gee'source has a scalar fallback.
The most relevant config.py knobs for this chapter:
| Knob | Default | Why |
|---|---|---|
RUNOFF_SOURCE | 'vsa_opm' | The full three-mechanism model |
OPM_SD_MAX_INITIAL | 0.1 m | Root-zone deficit fallback; overridden by SERVES |
OPM_Q_MAX | 100 m³/s | Observed pre-storm baseflow — the one calibration number |
OPM_PHI | 0.35 | Drainable porosity fallback; overridden by SoilGrids |
OPM_K_SAT | 44 m/day | Lateral sandbox drainage conductivity |
OPM_PER_POLYGON | True | One sandbox per gauge zone |
OPM_INFILTRATION | 'green_ampt' | Adds the Horton mechanism on top of VSA |
OPM_GA_SUCTION_SOURCE | 'texture' | Per-cell from SoilGrids texture |
OPM_GA_KSAT_SOURCE | 'gee' | Per-cell vertical from HiHydroSoil v2.0 |
IMPERVIOUS_SOURCE | 'lcz' | Urban impervious fraction from Local Climate Zones |
Notice the single human input is OPM_Q_MAX — one baseflow reading calibrates the initial VSA; everything else is satellite-derived or a physical constant. With the soil's behaviour now fully specified, the next two chapters route the resulting runoff downstream: Chapter 4 builds the kinematic wave from scratch, and Chapter 5 covers where it fails and how the diffusive wave fixes it.