OPM · Hydrology Course
ChaptersChapter 3

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:

  1. Saturation-excess (Dunne) runoff — where the soil is already full, every drop runs off. Captured by the Variable Source Area (VSA) model (§3.3).
  2. 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).
  3. 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":

ModeRUNOFF_SOURCEWhat it does
None'none'All rain becomes runoff; stateless (the Chapter 1–2 assumption)
Coefficient'coefficient'Multiply rain by a static runoff-coefficient raster Cf[0,1]C_f\in[0,1]
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.

rainwater tablerim
0% bone dry100% saturated
drizzledownpour
Runoff = 0% of rain
Soil still has room — everything infiltrates. Runoff stays at 0% until saturation reaches 100%.

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.

rain ratesoil surfacecapacity
drizzledownpour
tight/clayeyopen/sandy
Runoff = 0% of rain
Rain rate (30 mm/hr) is at or below capacity (50 mm/hr) — the soil keeps up, everything infiltrates.

Infiltration-excess: the storm's intensity decides, not the soil's capacity.

OPM runs both at once: OPM runs both mechanisms simultaneously and adds their contributions — Chapter 3 §3.3 builds the Dunne side into the real Variable Source Area equations, and §3.4 builds the Horton side into Green-Ampt infiltration.

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 ln(a/tanβ)\ln(a/\tan\beta) — large where the upslope area aa is big (lots of water arrives) and the local slope tanβ\tan\beta 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 At(t)A_t(t) 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 QmaxQ_{\max} [m³/s] measured at the outlet, together with the catchment area AoutletA_\text{outlet} [m²]:

At(0)=Aoutlet1ln(Qmin/Qmax),Qmin=0.001 m3/s (a global floor)A_t^{(0)} = \frac{A_\text{outlet}}{1 - \ln(Q_{\min}/Q_{\max})}, \qquad Q_{\min} = 0.001\ \text{m}^3/\text{s (a global floor)}

Any cell whose upslope area exceeds At(0)A_t^{(0)} is already saturated when the storm begins. A larger QmaxQ_{\max} means a wetter catchment right now — a higher water table, a more extensive saturated zone — so At(0)A_t^{(0)} shrinks and more cells qualify as saturated from the start.

Equation 4 — the constant HaH_a. Computed once, ties the initial soil-moisture state to that initial threshold area, and never changes again:

Ha=At(0)At(0)A1ln ⁣(SDminSDmax(0))H_a = \frac{A_t^{(0)}}{A_t^{(0)} - A_1}\cdot\ln\!\left(\frac{SD_{\min}}{SD_{\max}^{(0)}}\right)

where A1=Δx2A_1 = \Delta x^2 is one grid cell's area, SDmax(0)SD_{\max}^{(0)} [m] is the initial root-zone soil-moisture deficit (how much water the soil column could still absorb), and SDmin=0.001SD_{\min} = 0.001 m is a floor. Since SDmin/SDmax(0)<1SD_{\min}/SD_{\max}^{(0)} < 1, HaH_a is always negative.

Equation 12 — the sandbox water balance. The sandbox tracks one state variable, zz [m] — the height of the water table above an impervious base at the divide — updated by forward Euler exactly like a routing cell:

qbn=KlatSdivznΔx,ΔV=(PdivA1qbn)Δt,zn+1=max ⁣(0, zn+ΔVA1ϕ)q_b^n = K_\text{lat}S_\text{div}z^n\Delta x, \qquad \Delta V = (P_\text{div}A_1 - q_b^n)\Delta t, \qquad z^{n+1} = \max\!\bigl(0,\ z^n + \tfrac{\Delta V}{A_1\phi}\bigr) SDmaxn+1=max ⁣(SDmin, SDmax(0)zn+1)SD_{\max}^{n+1} = \max\!\bigl(SD_{\min},\ SD_{\max}^{(0)} - z^{n+1}\bigr)

SDmaxSD_{\max} 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:

At(t)=HaA1Haln(SDmin/SDmax(t)),clipped to [A1,Aoutlet]A_t(t) = \frac{H_a A_1}{H_a - \ln(SD_{\min}/SD_{\max}(t))}, \quad \text{clipped to } [A_1, A_\text{outlet}]

As SDmax(t)SD_{\max}(t) falls, At(t)A_t(t) 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:

cell iVSA    upslope_areai>At(t)\text{cell } i \in \text{VSA} \iff \text{upslope\_area}_i > A_t(t)

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

Catchment & storm inputs — adjust to explore beyond the worked example
1.00 m³/s
0.100 m
20 mm/hr
0.35
44 m/day
0.050 m/m
A_outlet = 100,000A₁ = 10,000 m² (Δx = 100 m)Q_min = 0.001 m³/s (fixed)SD_min = 0.001 m (fixed)Δt = 60 s

The Initial Threshold Area

Eq. 10

Before 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.

A_t⁽⁰⁾ = A_outlet / (1 − ln(Q_min / Q_max))
A_t⁽⁰⁾ = 100,000 / (1 − ln(0.001 / 1.00))
    = 100,000 / (1 − (-6.908))
    = 100,000 / 7.908
    = 12646
Physical intuition: a larger Q_max (wetter catchment right now) makes ln(Q_min/Q_max) more negative, which makes the denominator bigger, which makes A_t⁽⁰⁾ smaller — so more cells already qualify as saturated. Wet catchment → big initial VSA.

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

Terrain type — where upslope area concentrates
Storm & antecedent-state controls
20 mm/hr
0.100 m
0.50 m³/s

A drier antecedent state has more room to expand — to see the VSA visibly grow you need a drier start and a heavier storm.

↓ outletdivide ↑

Blue = saturated (in the VSA)  ·  gray-scale = relative upslope area (elevation-shaded, not saturation)

Timet = 0s (0 min)
A_t(t)194051
VSA fraction0.0% (0/140 cells)
H_a (fixed this run)-4.86
A_outlet1,400,000
A_t(t) and VSA fraction over the storm
t=0t=5400sA_tVSA %
A_t(t) [m²], fallingVSA fraction [%], rising
Soil under valley bottoms and channel confluences saturates first because upslope area concentrates there — not because the soil itself is special.

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 ψ\psi of the dry soil below the front sucking water into its empty pores. The maximum rate the soil can absorb [m/s] is:

fp=Kv(1+ψΔθ0F)f_p = K_v\left(1 + \frac{\psi\,\Delta\theta_0}{F}\right)

with KvK_v the vertical saturated conductivity [m/s], ψ\psi the wetting-front suction [m], Δθ0=ϕθinit\Delta\theta_0 = \phi - \theta_\text{init} the initial moisture deficit [-], and FF the cumulative infiltration so far [m]. When the soil is dry (F0F\to0) capacity is huge — all rain soaks in; as it wets (FF\to\infty) capacity sinks toward KvK_v.

Runoff begins the instant the soil can no longer keep up, fp=Pf_p = P. Solving for the cumulative infiltration at that moment:

Fc=KvψΔθ0PKv(only defined when P>Kv)F_c = \frac{K_v\,\psi\,\Delta\theta_0}{P - K_v} \qquad\text{(only defined when } P > K_v\text{)}

If PKvP \le K_v, the soil always wins and there is never any Horton runoff. A worked example: Kv=5K_v=5 mm/hr, ψ=0.20\psi=0.20 m, Δθ0=0.25\Delta\theta_0=0.25, P=20P=20 mm/hr gives Fc=16.7F_c = 16.7 mm, reached after about 50 minutes of steady rain — after which the runoff is PKv=15P-K_v=15 mm/hr, 75% of the rain.

Green-Ampt Infiltration Capacity

f_p = K_v(1 + ψ·Δθ₀/F) — capacity falls as the wetting front advances

5.0 mm/hr
0.200 m
0.25
20.0 mm/hr
60 s

Rawls (1983) soil-texture presets

Textureψ (m)K_v (mm/hr)
Sand0.050117
Sandy loam0.11023
Loam0.08913
Silt loam0.1677
Clay loam0.2092
Clay0.3160.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

P = 20.0 mm/hrK_v = 5.0 mm/hrF_c = 16.7 mm015304560010203040cumulative infiltration F (mm)rate (mm/hr)
At your current settings
F_c = K_v·ψ·Δθ₀ / (P − K_v) = 16.67 mm
Crossing reached at step 51 (t ≈ 51 min at Δt = 60 s)
After crossing, f_p settles toward K_v = 5.0 mm/hr, so runoff = P − K_v = 15.0 mm/hr
Post-threshold runoff fraction = (P − K_v)/P = 75% of the rain
Key relations
f_p = K_v · (1 + ψ·Δθ₀ / F)   (infiltration capacity)
f = min(P, f_p),   Fⁿ⁺¹ = Fⁿ + f·Δt   (forward Euler)
F_c = K_v·ψ·Δθ₀ / (P − K_v)   (crossing, only if P > K_v)
Implementation note: real code floors F at 1e-9 m so f_p doesn't divide by exactly zero when F = 0.

The two conductivities you must not confuse. OPM uses two saturated conductivities for two different jobs: KvK_v (OPM_GA_KSAT_MMHR, or gridded via 'gee') is the vertical rate at which water enters the soil surface — typical 1–50 mm/hr. KlatK_\text{lat} (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 fpPf_p \gg 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.

With OPM_GA_SUCTION_SOURCE = 'texture', the suction ψ\psi is read per cell from soil texture (sand/clay%, from SoilGrids) through the USDA texture triangle and the Rawls (1983) table:

USDA textureψ\psi [m]typical KvK_v [mm/hr]
Sand0.0495117
Sandy loam0.11023
Loam0.08913
Silt loam0.1677
Clay loam0.2092
Clay0.3160.5

Because ψ\psi enters Green-Ampt only as the product ψΔθ0\psi\,\Delta\theta_0, 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 Imp[0,1]\text{Imp}\in[0,1]; 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' (Imp=0\text{Imp}=0).

All three mechanisms collapse into one per-cell effective-runoff rate [m/s]:

  reff=P[Imp+(1Imp)pervious_frac]  \boxed{\;r_\text{eff} = P\,\bigl[\text{Imp} + (1-\text{Imp})\cdot\text{pervious\_frac}\bigr]\;}

where the pervious part runs off fully if the cell is saturated, and otherwise only the infiltration-excess fraction runs off:

pervious_frac={1.0cellVSA (Dunne)excess_frac=max(Pfp,0)Potherwise (Horton)\text{pervious\_frac} = \begin{cases} 1.0 & \text{cell} \in \text{VSA (Dunne)} \\ \text{excess\_frac} = \dfrac{\max(P-f_p,\,0)}{P} & \text{otherwise (Horton)} \end{cases}
Cell stateImpIn VSA?excessreff/Pr_\text{eff}/P
Rural, saturated0Yes1.0 (full Dunne)
Urban, saturated0.3Yes0.3+0.7×1=1.00.3+0.7\times1=1.0 (still full)
Rural, dry, soil wins0No00 (all infiltrates)
Urban, dry, soil wins0.3No00.3 (only the paving sheds)
Rural, dry, Horton0No0.40.4 (infiltration-excess)
Urban, dry, Horton0.3No0.40.3+0.7×0.4=0.580.3+0.7\times0.4=0.58

Inside the VSA, impervious fraction is irrelevant. When pervious_frac = 1, the formula gives reff=P[Imp+(1Imp)]=Pr_\text{eff}=P[\text{Imp}+(1-\text{Imp})]=P 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

0.30
NO — Horton path
0.40

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)

PresetImpVSA?excessr_eff/P
Rural, saturated0.0Y1.00
Urban, saturated0.3Y1.00
Rural, dry, soil wins0.0N0.00.00
Urban, dry, soil wins0.3N0.00.30
Rural, dry, Horton0.0N0.40.40
Urban, dry, Horton0.3N0.40.58

r_eff / P split into its three additive mechanisms

r_eff / P = 0.580.00.51.0
r_imperv/P = 0.30 (urban shedding)r_Dunne/P = 0.00 (saturation-excess)r_Horton/P = 0.28 (infiltration-excess)
At your current settings
pervious_frac = excess_frac = 0.40 (off VSA)
r_eff/P = Imp + (1−Imp)·pervious_frac = 0.30 + 0.70×0.40 = 0.58
r_imperv/P = 0.30  +  r_Dunne/P = 0.00  +  r_Horton/P = 0.28  =  0.58 (sums exactly to r_eff/P — every drop is attributed to a mechanism)
Key relations
pervious_frac = 1.0 if in VSA, else excess_frac
r_eff = P · [Imp + (1−Imp)·pervious_frac]
r_imperv = P·Imp
r_Dunne = P·(1−Imp)·1[in VSA]
r_Horton = P·(1−Imp)·excess_frac·1[off VSA]
r_imperv + r_Dunne + r_Horton ≡ r_eff (exact, by construction)

The code also splits reffr_\text{eff} into three named streams so the mass-balance report can attribute every cubic metre of runoff to a mechanism:

rimperv=PImp,rDunne=P(1Imp)1[in VSA],rHorton=P(1Imp)excess_frac1[off VSA]r_\text{imperv} = P\cdot\text{Imp}, \qquad r_\text{Dunne} = P(1-\text{Imp})\cdot\mathbb{1}[\text{in VSA}], \qquad r_\text{Horton} = P(1-\text{Imp})\cdot\text{excess\_frac}\cdot\mathbb{1}[\text{off VSA}]

and by construction rimperv+rDunne+rHorton=reffr_\text{imperv}+r_\text{Dunne}+r_\text{Horton}=r_\text{eff}. 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 rimpervr_\text{imperv}, rDunner_\text{Dunne}, rHortonr_\text{Horton} 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 (zz, SDmaxSD_{\max}, FF, 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

state^nz, SD_max, F, VSA maskget_effective_1d()reads state^n → runoffupdate_state()writes state^{n+1}state^{n+1}ready for next step
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)

state^nz, SD_max, F, VSA maskupdate_state()writes state^{n+1} (too early)get_effective_1d()reads state^{n+1} → runoff (wrong)✗ tomorrow’s soilsheds today’s rain
✗ Uses tomorrow's soil to shed today's rain. A cell that becomes saturated during this step's rain would be treated as already-saturated for the entire step, overstating runoff using information that shouldn't exist yet.
The rule: use the current state to produce this step's runoff, then advance the state for the next step. In code (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, FF, and zz at step nn; update_state then writes zn+1z^{n+1}, SDmaxn+1SD_{\max}^{n+1}, Fn+1F^{n+1}, and rebuilds the mask for step n+1n{+}1. 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 zz, SDmaxSD_{\max}, and AtA_t, 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 SDmaxSD_{\max} 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.

A_outlet = 100,000A₁ = 10,000 m² (Δx = 100 m)Q_max = 1.00 m³/sSD_max⁽⁰⁾ = 0.10 mφ = 0.35K_lat = 5.09e-4 m/s (44 m/day)S_div = 0.05Δt = 60 sonly P varies per zone
Shared clock (advances all 3 zones together):t = 0s (step 0 / 60)
Zone A — Heavy tributary
Gauge sits under the storm core
35 mm/hr
z (water table)0 mSD_max(t)0.1000 mA_t(t)12646VSA%90%
t=0: A_t⁽⁰⁾ = 12646 m², H_a = -22.01 (identical across all zones)
A_t(t) trace
VSA% trace
%
t = 0s (step 0)
Zone B — Moderate tributary
Gauge sits on the storm fringe
20 mm/hr
z (water table)0 mSD_max(t)0.1000 mA_t(t)12646VSA%90%
t=0: A_t⁽⁰⁾ = 12646 m², H_a = -22.01 (identical across all zones)
A_t(t) trace
VSA% trace
%
t = 0s (step 0)
Zone C — Dry tributary
Gauge sits outside the storm
5 mm/hr
z (water table)0 mSD_max(t)0.1000 mA_t(t)12646VSA%90%
t=0: A_t⁽⁰⁾ = 12646 m², H_a = -22.01 (identical across all zones)
A_t(t) trace
VSA% trace
%
t = 0s (step 0)
Notice at t=0: all three zones start from the identical A_t⁽⁰⁾ = 12646 and H_a = -22.01 — because A_outlet, Q_max, and SD_max⁽⁰⁾ are shared. Nothing distinguishes the zones yet. Press Step or Play and watch the rain rate alone pull their trajectories apart.

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 SDmax(0)SD_{\max}^{(0)}, ϕ\phi, KvK_v, ψ\psi, Imp\text{Imp}, nn 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:

ET fraction=clamp(1.33NDVI0.049, 0, 1),θSERVES=ET fraction×(FCWP)+WP\text{ET fraction} = \text{clamp}(1.33\,\text{NDVI}-0.049,\ 0,\ 1), \qquad \theta_\text{SERVES} = \text{ET fraction}\times(FC-WP)+WP   SDdeficit=(porosityθSERVES)×Zr  \boxed{\;SD_\text{deficit} = (\text{porosity}-\theta_\text{SERVES})\times Z_r\;}

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

Why satellites instead of a calibrated parameter: Most rainfall-runoff models ask the user to guess or calibrate an antecedent-wetness parameter — a knob tuned to past storms that may not transfer to the next storm or the next basin. OPM instead reads the actual vegetation greenness, soil type, and land cover for the specific date and place from satellite imagery (Landsat by default), the same way you would check a weather map before a hike.
0.60
0.0 (bare soil)~0.9 (dense vegetation)

Live-computed chain

NDVI
0.60
satellite greenness index
ET fraction
0.749
clamp(1.33·NDVI − 0.049, 0, 1)
θ (soil moisture)
0.300
ET_fraction·(FC−WP) + WP
Deficit = SD_max
0.150m
(porosity − θ)·Z_r
Illustrative values (clean round numbers chosen for teaching, not a real basin measurement) — held fixed while you move the slider:
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.
Check at the default NDVI = 0.60: ET fraction = 1.33×0.60 − 0.049 = 0.749  →  θ = 0.749×(0.35−0.15) + 0.15 = 0.300  →  deficit = (0.45−0.300)×1.0 = 0.150 m. Move the slider — every box recomputes instantly.
The chain in one breath: Greener vegetation ⇒ higher NDVI ⇒ wetter soil (θ near FC) ⇒ smaller deficit ⇒ SD_max shrinks ⇒ a wetter antecedent state ⇒ (in the next section's VSA model) a larger initial saturated area and more runoff from the very first drops of a storm. Every link in this chain is physically measured, not guessed.
GEE datasetProvidesUsed for
ESA WorldCover v200 (10m)Land cover classRoot-zone depth ZrZ_r, Manning nn, impervious
WUDAPT LCZLocal Climate ZoneManning nn, impervious, ZrZ_r (urban detail)
Landsat 8/9 (Sentinel-2, MODIS)NDVI (greenness)SERVES soil moisture θ\theta
SoilGrids250m wv0033 / wv1500Field capacity, wilting pointθ\theta range; texture → suction ψ\psi
HiHydroSoil v2.0 wcsatSaturated water contentPorosity (for SDmaxSD_{\max}, ϕ\phi)
HiHydroSoil v2.0 KsatVertical conductivityGreen-Ampt KvK_v
NASA GPM IMERG V07Rainfall (0.1°, 30 min)Precipitation forcing

Graceful fallback. Every 'gee' source has a scalar fallback. If a cell has no texture data, ψ\psi 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

ConceptFormulaKey point
Initial threshold (Eq 10)At(0)=Aoutlet1ln(Qmin/Qmax)A_t^{(0)} = \dfrac{A_\text{outlet}}{1-\ln(Q_{\min}/Q_{\max})}One baseflow reading sets the starting VSA
Constant (Eq 4)Ha=At(0)At(0)A1lnSDminSDmax(0)H_a = \dfrac{A_t^{(0)}}{A_t^{(0)}-A_1}\ln\dfrac{SD_{\min}}{SD_{\max}^{(0)}}Computed once, ties soil state to At(0)A_t^{(0)}
Sandbox (Eq 12)Δz=(PdivA1KlatSzΔx)ΔtϕA1\Delta z = \dfrac{(P_\text{div}A_1-K_\text{lat}Sz\Delta x)\Delta t}{\phi A_1}Forward-Euler water table at the divide
Dynamic threshold (Eq 5)At(t)=HaA1Haln(SDmin/SDmax(t))A_t(t) = \dfrac{H_aA_1}{H_a-\ln(SD_{\min}/SD_{\max}(t))}Falls as the storm wets the soil
VSA mask (Eq 9)in VSA     \iff upslope_area >At(t)>A_t(t)Saturated set, rebuilt every step
Green-Amptfp=Kv(1+ψΔθ0/F)f_p=K_v(1+\psi\Delta\theta_0/F)Horton runoff once fpf_p drops to PP
SERVES deficitSDmax=(porosityθSERVES)ZrSD_{\max}=(\text{porosity}-\theta_\text{SERVES})Z_rSatellite greenness → soil parameter
Combinedreff=P[Imp+(1Imp)pervious_frac]r_\text{eff}=P[\text{Imp}+(1-\text{Imp})\,\text{pervious\_frac}]All three mechanisms in one number

Key takeaways:

  1. 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.
  2. The VSA model is a one-parameter scheme: a single baseflow QmaxQ_{\max} sets the initial saturated area, and a tiny sandbox at the divide moves the threshold AtA_t down as the storm wets the soil.
  3. Green-Ampt adds Horton runoff for unsaturated cells; it begins once the rain outpaces the soil. Always use the vertical KvK_v, never the lateral KlatK_\text{lat} — they differ by roughly 1000×.
  4. The combined formula sums the mechanisms; inside the VSA a cell always sheds 100% of rain, so impervious fraction only matters outside it.
  5. Per-polygon mode gives every gauge zone its own sandbox, so spatially variable rain drives spatially variable saturation.
  6. Almost every parameter is measured, not guessed: SERVES turns satellite greenness into SDmaxSD_{\max}; SoilGrids and HiHydroSoil give porosity, ϕ\phi, texture-based ψ\psi, and KvK_v; land cover gives roughness, root depth, and imperviousness — every 'gee' source has a scalar fallback.

The most relevant config.py knobs for this chapter:

KnobDefaultWhy
RUNOFF_SOURCE'vsa_opm'The full three-mechanism model
OPM_SD_MAX_INITIAL0.1 mRoot-zone deficit fallback; overridden by SERVES
OPM_Q_MAX100 m³/sObserved pre-storm baseflow — the one calibration number
OPM_PHI0.35Drainable porosity fallback; overridden by SoilGrids
OPM_K_SAT44 m/dayLateral sandbox drainage conductivity
OPM_PER_POLYGONTrueOne sandbox per gauge zone
OPM_INFILTRATION'green_ampt'Adds the Horton mechanism on top of VSA
OPM_GA_SUCTION_SOURCE'texture'Per-cell ψ\psi from SoilGrids texture
OPM_GA_KSAT_SOURCE'gee'Per-cell vertical KvK_v 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.