title: "Chapter 5 — Diffusive-Wave Routing" chapter: 5
Chapter 5 — Diffusive-Wave Routing
Chapter 4 builds the kinematic wave from the Saint-Venant equations and shows it translating a flood pulse downstream without ever changing its shape. That assumption — friction balances gravity, full stop — is exactly right on a steep mountain reach and exactly wrong on a flat valley floor. This chapter is entirely about the gap between those two cases: where the kinematic wave actually gives the wrong answer, what extra physics fixes it, how that physics is written into OPM's production router, and what it costs to run all of it under one single, global time step.
5.1 — Where Kinematic Wave Fails
Drop a stone in a long, still canal and watch the ripple travel away. It does not stay a sharp spike — it spreads out and gets shorter the farther it goes. A flood wave does the same: the sharp crest that leaves the mountains arrives at the city lower and broader, smeared over a longer time. This flattening is called peak attenuation, and the kinematic wave cannot reproduce it — it translates the crest without ever lowering it, so it tends to over-predict the peak discharge at a gauge, exactly the number engineers care most about.
Now pour water steadily into one end of a bathtub with the drain open. Near the drain the water is shallow and quick; but if the drain is small, water piles up behind it and the level rises upstream. Rivers do this constantly — a pond, a flat reach, a narrow culvert, or a downstream lake raises the water level and slows the flow coming in from upstream. This is the backwater effect, and the kinematic wave is blind to it too: it uses only the bed slope, which always points downhill and knows nothing about how deep the water already is in the next cell. A kinematic cell keeps draining at full Manning's rate even when the cell below it is brim-full and physically cannot accept more.
Both pictures correspond to the kinematic-wave number from Chapter 2, , falling below the safe threshold of about 20:
- Flat slopes () — the bed slope no longer dominates; the water's own depth differences matter as much as gravity.
- Peak attenuation — the kinematic wave keeps the crest sharp; reality flattens it.
- Backwater and adverse gradients — a downstream pool can be deeper than the upstream cell, opposing the flow; the kinematic wave never sees it.
The widget below runs the same two-cell setup three ways — Normal, Backwater, and Adverse — holding the upstream cell's depth fixed and only changing how full the downstream cell is. Watch : it never moves. It cannot, because the kinematic formula never looks at .
Where Kinematic Wave Fails
A real, numerically concrete case: two cells, one fixed bed slope, three downstream depths
Select scenario — h_A is held fixed at 0.008 m in all three
B is relatively empty — water drains downhill as expected.
Side view (to scale on elevation)
Live readout — Normal scenario
| Quantity | Value |
|---|---|
| S_w | 0.00105 |
| S_eff | 0.00105 |
| h_higher | 0.008 m |
| Q_kin | 0.02024 m³/s |
| Q_diff | 0.02074 m³/s |
| % difference | +2.5% |
z_A = 5.00 z_B = 4.90 h_A = 0.008 m h_B = 0.003 m
WSE_A = 5.008 m WSE_B = 4.903 m
All three scenarios at a glance
| Scenario | h_B (m) | Q_kin (m³/s) | Q_diff (m³/s) | % diff |
|---|---|---|---|---|
| Normal | 0.003 | 0.02024 | 0.02074 | +2.5% |
| Backwater | 0.050 | 0.02024 | 0.01541 | -23.8% |
| Adverse | 0.110 | 0.02024 | 0.00000 | -100.0% |
Q_kinematic uses only S0 and h_i — blind to h_ds
Q_diffusive uses S_w = S0 + θ·(h_i − h_ds)/dist, clamped to S_eff = max(S_w, 0)
DIFFUSION_THETA = 1.0 in production (θ = 1 used throughout this example)
In the Adverse lane, the downstream water surface is higher than the upstream one — flow should stop. The diffusive scheme correctly clamps to . The kinematic scheme keeps draining at regardless, into an already-fuller cell — not a rounding error, a physically wrong answer.
5.2 — Follow the Water Surface, Not the Bed
The fix is one idea: drive flow with the slope of the water surface, not the slope of the bed. Define the water-surface elevation as bed plus depth, , and replace the bed slope with
Three cases fall out immediately:
- — the upstream cell is fuller than the bed slope alone would suggest. : a small boost.
- — both cells equally full. exactly: identical to kinematic.
- — the downstream water surface is higher. , and once exceeds , goes negative. OPM clamps it: — flow halts rather than running backward.
Drag and below and watch the water-surface line tilt, flatten, and reverse. With and , the exact halt threshold is — verify it on the slider.
Follow the Water Surface, Not the Bed
S_w = S₀ + (h_i − h_ds) / Δx, clamped at zero — the diffusive-wave fix
Halt threshold for this geometry
Flow halts once h_ds − h_i exceeds S₀·Δx = 100 mm.
Current h_ds − h_i = -5 mm (-5% of threshold)
Side View — depth exaggerated 220× for visibility
5.3 — Deriving the Diffusive-Wave Equation From Scratch
Chapter 4 showed the full Saint-Venant momentum equation has five terms, and that the kinematic wave keeps only friction and gravity:
Kinematic drops ①②③, leaving . The diffusive wave makes a smaller cut: it drops only the inertial terms ①② — local and convective acceleration, the two terms responsible for genuine flood-wave dynamics like surging and reflection — but it keeps the pressure-gradient term ③. What survives is
which is exactly the continuous-space version of the discrete from §5.2. Linearizing around a reference depth and velocity (small perturbation ) turns the diffusive-wave momentum balance into an advection–diffusion equation:
The celerity is identical to Chapter 4's kinematic celerity — the wave still travels at the flow velocity. The new term is , a genuine diffusivity: it spreads and lowers the pulse exactly the way heat spreads through a rod. Because sits in the denominator, flat slopes () blow up — flat reaches diffuse strongly, steep reaches barely diffuse at all:
| Setting | (m²/s) | Behavior | |
|---|---|---|---|
| Flat valley floor | 0.001 | 0.15 | strong attenuation — diffusive routing matters |
| Moderate slope | 0.01 | 0.015 | mild attenuation |
| Steep hillslope | 0.05 | 0.003 | negligible — kinematic is fine |
The widget below walks through the full derivation step by step, then lets you turn a slider and watch a pulse spread on a 1-D grid — the same equation, made visible.
Deriving the Diffusive-Wave Equation From Scratch
Same Saint-Venant starting point as Chapter 4 — one term survives, and that changes everything
Recall the Saint-Venant momentum equation from Chapter 2 with its five terms — the same ①②③④⑤ labels from §4.1.
Watch a Pulse Spread — Solving ∂h′/∂t + c·∂h′/∂x = D·∂²h′/∂x²
Upwind scheme for advection, central difference for diffusion. As D increases, the pulse spreads and flattens more over the same elapsed time; at D ≈ 0 it translates almost without spreading.
5.4 — The Real OPM Code: GSSHA-Style Conveyance Depth
alone is not quite enough. On a normal downhill bed the upstream cell's own depth is the right depth to carry the flow. But at a D8 saddle or a local DEM step, the downstream cell's ground can sit higher than the upstream cell's () even while its water surface is still lower — water is conveyed across that higher lip, and the relevant depth is the depth of water over that higher bed, not over the upstream one.
OPM's diffusive_wave_discharge() in routing_utils.py borrows the answer from the CASC2D family of models (Julien et al., 1995; later GSSHA, and the LISFLOOD-FP diffusion wave): use the depth above whichever of the two beds is higher,
A single blend weight ties the slope and the conveyance depth together as a coherent pair: reduces to kinematic exactly (own depth, bed slope); — OPM's production default — is the full diffusion wave (water-over-higher-bed, water-surface slope). The widget below renders the actual, unabridged function body from the production code, with six numbered callouts, plus the ds_safe trick that lets every cell — including the outlet, which has no downstream neighbor — run the identical branch-free formula on the GPU.
Conveyance Depth: GSSHA-Style “Depth Over the Higher Bed”
The real diffusive_wave_discharge() from OPM's production routing_utils.py
Part A — The D8-Saddle Scenario
z_ds > z_i: the downstream cell's ground is locally HIGHER, but the water surface still drains forward.
WSE_i = 5.500 m
WSE_ds = 5.400 m
Side view (to scale on elevation)
| Quantity | Value | Meaning |
|---|---|---|
| h_naive (= h_i) | 0.500 m | What a scheme using only the upstream cell's own depth would compute |
| h_higher | 0.200 m | CASC2D / GSSHA: depth of water over whichever bed is higher |
| Divergence | -60.0% | h_higher corrects for the higher downstream bed — h_naive would be wrong |
| Q (using h_naive) | 19.92110 m³/s | Same S_eff, conveyance depth swapped to h_naive |
| Q_diff (actual OPM) | 4.32593 m³/s | Production formula: h_flow built from h_higher |
Part B — The Real OPM Source Code
routing_utils.py → diffusive_wave_discharge() — every line of real production logic, unabridged. Numbered comments map one-to-one onto the callouts below.
def diffusive_wave_discharge(depth, dem, dist, slope_bnd, n, ds_safe, valid_ds,
theta, cell_size, xp, min_depth):
depth_ds = depth[ds_safe]
dem_ds = dem[ds_safe]
# ① water-surface slope = bed slope + theta * depth gradient
S_w = slope_bnd + theta * (depth - depth_ds) / dist
# ② outlet cells fall back to bed slope; clamp adverse gradient to 0
S_eff = xp.where(valid_ds, S_w, slope_bnd)
S_eff = xp.maximum(S_eff, 0.0)
# ③ water-surface elevations (this cell and its downstream neighbour)
wse = dem + depth
wse_ds = wse[ds_safe]
# ④ CASC2D / GSSHA conveyance depth = water over the higher bed
h_higher = xp.maximum(wse, wse_ds) - xp.maximum(dem, dem_ds)
# ⑤ blend own depth (kinematic) with h_higher (diffusive) by theta
h_flow = (1.0 - theta) * depth + theta * h_higher
h_flow = xp.where(valid_ds, h_flow, depth)
h_flow = xp.maximum(h_flow, min_depth)
# ⑥ Manning-style discharge on (h_flow, S_eff)
return (1.0 / n) * (h_flow ** (5.0 / 3.0)) * (S_eff ** 0.5) * cell_sizeslope_bnd, already floored at MIN_SLOPE with watershed-boundary handling) plus θ times the depth-gradient term (h_i − h_ds)/dist. At θ=0 this is pure bed slope (kinematic); at θ=1 it is the full water-surface slope.slope_bnd instead — free outflow identical to the kinematic scheme.WSE = z + h. Everything from here on follows the water surface, not the ground.max(WSE_i, WSE_ds) − max(z_i, z_ds). In the normal downhill case this reduces exactly to h_i; at a saddle it correctly uses the depth over the raised downstream lip.min_depth floor guards the wet/dry boundary.(1/n)·h_flow^(5/3)·S_eff^(1/2)·cell_size. Not yet flux-limited — the caller applies the CFL limiter afterward.A Small but Vital Coding Trick: ds_safe
To compute S_w and h_higher, every cell must look up its downstream neighbour's depth and elevation: h_ds = h[ds_idx], z_ds = z[ds_idx]. But the outlet (and any cell draining off the watershed edge) has ds_idx = -1 — there is no downstream cell.
Why −1 is dangerous: in Python, array[-1] silently returns the last element — wrong, but not a crash. On a GPU, CuPy treats the index as unsigned, so −1 becomes a gigantic address: garbage or a crash. Either way the outlet would read nonsense.
The fix, computed once before the time loop, is to replace every −1 with a harmless valid index (0), then discard the bogus result afterwards with a masked select:
valid_ds = ds_idx >= 0 # True for interior cells ds_safe = xp.where(valid_ds, ds_idx, 0) # outlet's -1 -> 0 (any valid index)
“Compute everywhere, mask the result.” This is the standard pattern for fast parallel code. Rather than branching (if outlet: do one thing else: another), which makes a GPU's threads diverge and stall, every cell runs the same instructions; the few cells whose result is meaningless (the outlet) simply have that result thrown away by xp.where. Outlet cells thus fall back to free outflow — the kinematic bed slope and their own depth — with no special case.
diffusive_wave_discharge(depth, dem, dist, slope_bnd, n, ds_safe, valid_ds, theta, cell_size, xp, min_depth)
DIFFUSION_THETA = 1.0 (full diffusive in production — θ = 1 used throughout this widget)
5.5 — One Global Δt, Many Slopes: The Hidden Cost
Every explicit scheme needs a stable , and OPM picks exactly one for the whole watershed. Look at the real self-check in kinematic_wave_router.py:
max_slope = float(slope_1d.max().item())
n_min = float(n.min().item()) if hasattr(n, 'min') else float(n)
V_at_1m_max_slope = (1.0 / n_min) * (1.0 ** (2.0 / 3.0)) * (max_slope ** 0.5)
C_indicator = V_at_1m_max_slope * dt / dx
safe_dt = dx / V_at_1m_max_slope
This runs once, on the static bed-slope grid, using the single steepest cell and the smallest roughness anywhere in the basin — and it runs identically whether ROUTING_SCHEME is 'kinematic' or 'diffusive'. Diffusive routing inherits the kinematic CFL's global ; it does not get its own.
That single choice is sized for the steepest cell, which means every gentler cell is running far below its own stability limit — and an under-sized Courant number is exactly what manufactures numerical diffusion (, from Chapter 4 §4.4; the global-vs-local trade-off is Chapter 4 §4.5). The widget below puts OPM's real and default into three illustrative zones spanning a real watershed's slope range:
One Global Δt, Many Slopes: The Hidden Cost
OPM sizes Δt once, off the steepest cell — every gentler cell pays for it
One Watershed, One Δt — Three Very Different Slopes
What this means: roughly 27.7% of the flat-zone diffusivity is numerical, not physical.
D_num and D_phys both feed the exact same diffusive-wave equation, so the model cannot tell them apart at run time. At the flat valley floor, the upwind scheme's own numerical smoothing (D_num ≈ 9.27 m²/s) is already 28% as large as the physical backwater diffusivity the diffusive-wave scheme was built to capture (D_phys ≈ 33.40 m²/s). That means a meaningful fraction of the attenuation and smoothing visible in a simulated valley-floor hydrograph could be a grid/timestep artifact rather than real backwater physics — and OPM currently has no way to report which fraction is which, because both effects enter the same equation and only their sum is ever computed.
The Real CFL Self-Check — kinematic_wave_router.py
max_slope = float(slope_1d.max().item()) n_min = float(n.min().item()) if hasattr(n, 'min') else float(n) V_at_1m_max_slope = (1.0 / n_min) * (1.0 ** (2.0 / 3.0)) * (max_slope ** 0.5) C_indicator = V_at_1m_max_slope * dt / dx safe_dt = dx / V_at_1m_max_slope
This check runs once, on the static bed-slope grid, using the single steepest cell (slope_1d.max()) and the smallest roughness (n.min()) anywhere in the watershed — and it runs identically regardless of ROUTING_SCHEME. Diffusive routing inherits the kinematic CFL's global Δt; it does not get its own.
At the default settings, the flat valley-floor cell's Courant number is 164× smaller than the steep headwater cell that set in the first place — and it is 131× smaller than Chapter 4's already-failing "Too Small Δt" demo lane. The consequence: at that flat cell, the scheme's own numerical smoothing is already ≈28% the size of the physical backwater diffusivity it was built to capture. and enter the exact same equation, so OPM has no way to report which fraction of a flat-valley hydrograph's attenuation is real physics and which is a grid artifact. (At the steep cell the ratio looks huge too, but both numbers are negligible there in absolute terms — consistent with §5.3's table that steep slopes barely diffuse regardless.)
5.6 — Should OPM Have Used Implicit?
An implicit scheme removes the CFL limit entirely — but it is not free:
| Issue | Implicit | Explicit (what OPM uses) |
|---|---|---|
| Stability | any | needs |
| Cost per step (2-D grid) | sparse matrix solve | one elementwise pass |
| Nonlinear | Newton iterations each step | direct formula |
| GPU parallelism | global communication | embarrassingly parallel |
Chapter 4 §4.6 already showed a real implicit scheme — the Preissmann θ-method solved by the Thomas algorithm — and noted it works because a single 1-D channel is a chain: each interior cell has exactly one upstream and one downstream neighbor, which collapses the linear system to a lower-bidiagonal matrix solvable in one forward sweep.
OPM's domain is not a chain. It is a watershed-wide D8 flow-direction graph, assembled and routed with scatter_add, where a confluence — two or more upstream cells feeding one downstream cell — is the normal case, not the exception.
One Channel vs. a Watershed Tree
Why a tridiagonal Thomas-algorithm solve doesn't carry over to OPM's D8 network
Single Channel (what Preissmann / Thomas assumes)
OPM's Real Domain: a D8 Flow-Direction Tree
A confluence breaks the tridiagonal/bidiagonal structure a Thomas algorithm needs: the downstream row in the matrix now has two or more nonzero upstream entries, not one. Solving that requires a general sparse solver, not a cheap single-sweep elimination — architecturally different, and far more expensive, at the –-cell scale OPM targets.
Verdict: explicit was the right call for OPM's actual architecture and scale — the per-step cost of a sparse implicit solve over a branching DAG would dwarf the savings, and the flux limiter already neutralizes the one thing implicit buys (robustness to large ). But that decision leaves the §5.5 cost sitting on the table: a watershed with both steep and flat cells pays needless numerical diffusion on every flat cell, every step. The fix that would address it without a full implicit rewrite is local/block time stepping, a TVD flux, or Muskingum–Cunge matched diffusion (Chapter 4 §4.5 lays out all four routes and the GPU trade-off) — none implemented in OPM today, and left here as clearly scoped future work rather than a flaw to patch immediately.
5.7 — Side-by-Side & Mass Balance
| Condition | Recommended | Reason | |
|---|---|---|---|
| Steep hillslopes () | Kinematic or diffusive | Nearly identical; diffusive auto-reduces to it | |
| Moderate (–) | 20–100 | Either | Small difference |
| Flat valley floors () | Diffusive | Backwater and attenuation matter | |
| Urban / ponding areas | varies | Diffusive | Stormwater pools |
This is why OPM defaults to ROUTING_SCHEME = 'diffusive': a basin with both steep hillslopes and flat valley floors is handled correctly by one scheme, because the diffusive formula automatically collapses to the kinematic answer wherever and .
Adding backwater and flow-halting does not threaten mass conservation. The volume update from Chapter 2 is completely unchanged —
— only the formula for differs between schemes, and both are capped by the identical flux limiter and tracked by the identical mass-balance accumulators. When , : no water leaves the cell, the limiter is trivially satisfied, and the volume is simply unchanged — stopping flow does not lose water.
Drag below to separate the kinematic, blended, and full-diffusive curves on the same hydrograph:
Kinematic vs. Diffusive Wave
θ_diff controls attenuation — kinematic translates, diffusive spreads
8-Reach Channel — t = 0 / 11
Outlet Hydrograph (reach i = 7)
Outlet Statistics
| Scheme | Peak Q (m³/s) | Peak at t= | Attenuation | Mass (m³) |
|---|---|---|---|---|
| Kinematic | 2.3 | 11 | 0% | 7293 |
| θ=0.00 | 2.3 | 11 | 0.0% | 7293 |
| Diffusive | 1936.9 | 11 | -85388.7% | 588248 |
Routing Scheme Limitations
| Limitation | Kinematic | Diffusive | Dynamic |
|---|---|---|---|
| No backwater effects | ✗ blind | partial | ✓ |
| Hydrograph attenuation | ✗ zero | ✓ some | ✓ full |
| Flat reaches (S→0) | ✗ fails | ~ marginal | ✓ |
| Computational cost | O(N) | O(N) | O(N log N) |
| Valid when | S₀ > 0.001 | S₀ > 0.0001 | always |
ROUTING_SCHEME = 'kinematic' → θ_diff = 0
ROUTING_SCHEME = 'diffusive' → uses DIFFUSION_THETA
DIFFUSION_THETA = 1.0 (full diffusion by default)
5.8 — Chapter Summary
| Concept | Formula |
|---|---|
| Water-surface elevation | |
| Water-surface slope | |
| Effective slope (-blend) | |
| Conveyance depth | |
| Flow depth | |
| Discharge | |
| Hydraulic diffusivity |
Key takeaways:
- The kinematic wave fails on flat slopes, cannot attenuate peaks, and cannot feel backwater — all because it uses the static bed slope.
- The diffusive wave's one idea: drive flow with the water-surface slope instead of .
- Three cases — (normal boost), (identical to kinematic), (clamped to 0, flow halts).
- The -blend makes exactly kinematic and fully diffusive; OPM uses .
- The CASC2D/GSSHA conveyance depth correctly handles adverse slopes and reduces to for normal downhill flow.
- The
ds_safetrick replaces the outlet's index with 0 and masks the result — a branch-free, GPU-friendly downstream lookup. - OPM's single global , sized for the steepest cell, leaves flat cells running at a tiny fraction of their own stability limit — manufacturing numerical diffusion that is indistinguishable, inside the model, from the real physical diffusivity it was built to capture.
- Explicit was the right architectural call for OPM's branching D8 domain — a true implicit solve would need a general sparse solver, not the cheap single-sweep Thomas algorithm a 1-D channel allows — but a per-zone or flow-adaptive remains the clean fix for the cost identified in §5.5, and is not yet implemented.