OPM · Hydrology Course
ChaptersChapter 5

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, K=S0gL/V02K = S_0\,g\,L / V_0^2, falling below the safe threshold of about 20:

  1. Flat slopes (S0<103S_0 < 10^{-3}) — the bed slope no longer dominates; the water's own depth differences matter as much as gravity.
  2. Peak attenuation — the kinematic wave keeps the crest sharp; reality flattens it.
  3. 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 QkinQ_\text{kin}: it never moves. It cannot, because the kinematic formula never looks at hdsh_\text{ds}.

Where Kinematic Wave Fails

A real, numerically concrete case: two cells, one fixed bed slope, three downstream depths

Why this happens: The kinematic wave computes discharge from cell A's own depth and the fixed bed slope S₀ alone — it never looks at cell B. So it has no way to “see” a pool forming downstream, let alone a water surface that locally slopes back uphill. The diffusive wave fixes this by using the slope of the water surface (bed + depth) between the two cells instead of just the bed — so it can sense backwater and shut off flow when it should.

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)

z_A = 5.00z_B = 4.90WSE_A = 5.008WSE_B = 4.903Cell A (upstream)Cell B (downstream)Q_kin always flows →

Live readout — Normal scenario

QuantityValue
S_w0.00105
S_eff0.00105
h_higher0.008 m
Q_kin0.02024 m³/s
Q_diff0.02074 m³/s
% difference+2.5%
S0 = 0.001  n = 0.05  dist = 100 m
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

Scenarioh_B (m)Q_kin (m³/s)Q_diff (m³/s)% diff
Normal0.0030.020240.02074+2.5%
Backwater0.0500.020240.01541-23.8%
Adverse0.1100.020240.00000-100.0%
In OPM routing_utils.py:
  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 Qdiff=0Q_\text{diff} = 0. The kinematic scheme keeps draining at 0.02024 m3/s0.02024\ \text{m}^3/\text{s} 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, WSE=z+h\text{WSE} = z + h, and replace the bed slope S0S_0 with

Sw=S0+hihdsΔxS_w = S_0 + \frac{h_i - h_\text{ds}}{\Delta x}

Three cases fall out immediately:

Drag hih_i and hdsh_\text{ds} below and watch the water-surface line tilt, flatten, and reverse. With S0=0.001S_0 = 0.001 and Δx=100m\Delta x = 100\,\text{m}, the exact halt threshold is hdshi=0.10mh_\text{ds} - h_i = 0.10\,\text{m} — 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

8 mm
3 mm
S₀ = (z_i − z_ds) / Δx = 0.00100
S_w = S₀ + (h_i − h_ds)/Δx = 0.00105
S_eff = max(S_w, 0) = 0.00105

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)

Picture two ponds connected by a pipe. Water in the pipe does not care how the ground tilts beneath it — it only cares about the slope of its own surface, pond to pond. If the downstream pond happens to be fuller than gravity on the bed alone would suggest, that surface slope shrinks, flow slows, and — if the downstream pond is full enough to out-stand the upstream one — flow stops entirely rather than running backward.

Side View — depth exaggerated 220× for visibility

cell i (z=5.00)cell ds (z=4.90)flow →WSE tilts downhill →
Normal — downstream relatively emptier, S_w slightly exceeds S₀

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:

Qt+(Q2/A)x+gAhx+gASfgAS0=0\underbrace{\frac{\partial Q}{\partial t}}_{①} + \underbrace{\frac{\partial (Q^2/A)}{\partial x}}_{②} + \underbrace{gA\frac{\partial h}{\partial x}}_{③} + \underbrace{gAS_f}_{④} - \underbrace{gAS_0}_{⑤} = 0

Kinematic drops ①②③, leaving Sf=S0S_f = S_0. 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

Sf=S0hxS_f = S_0 - \frac{\partial h}{\partial x}

which is exactly the continuous-space version of the discrete SwS_w from §5.2. Linearizing around a reference depth h0h_0 and velocity V0V_0 (small perturbation h=hh0h' = h - h_0) turns the diffusive-wave momentum balance into an advection–diffusion equation:

ht+ckhx=D2hx2\frac{\partial h'}{\partial t} + c_k\,\frac{\partial h'}{\partial x} = D\,\frac{\partial^2 h'}{\partial x^2} ck=53V0D=V0h02S0c_k = \tfrac{5}{3}\,V_0 \qquad\qquad D = \frac{V_0\,h_0}{2\,S_0}

The celerity ckc_k is identical to Chapter 4's kinematic celerity — the wave still travels at 53\tfrac{5}{3} the flow velocity. The new term is DD, a genuine diffusivity: it spreads and lowers the pulse exactly the way heat spreads through a rod. Because S0S_0 sits in the denominator, flat slopes (S00S_0\to 0) blow DD up — flat reaches diffuse strongly, steep reaches barely diffuse at all:

SettingS0S_0DD\approx (m²/s)Behavior
Flat valley floor0.0010.15strong attenuation — diffusive routing matters
Moderate slope0.010.015mild attenuation
Steep hillslope0.050.003negligible — kinematic is fine

The widget below walks through the full derivation step by step, then lets you turn a DD 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

You saw in §4.1 that dropping ①② (but keeping ) gives the diffusive wave — here's where that leads.
Step 0 — The full Saint-Venant momentum equation (Chapter 2 / 4)
∂U/∂t(local accel.)+U·∂U/∂x(convective accel.)+g·∂h/∂x(pressure gradient)=g·S₀(gravity / bed slope)g·S_f(friction slope)

Recall the Saint-Venant momentum equation from Chapter 2 with its five terms — the same ①②③④⑤ labels from §4.1.

Step 1 — Drop ①② only (NOT ③)
∂U/∂t(local accel.)+U·∂U/∂x(convective accel.)+g·∂h/∂x(pressure gradient)=g·S₀(gravity / bed slope)g·S_f(friction slope)
Kinematic drops ①②③; diffusive drops only ①②, keeping the pressure-gradient term — that's the ONE difference that lets it see backwater.

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.

0.00.51.0position x (cell index)h't = 0 (initial)current
t = 0
D ≈ 0 (pure translation)D large (strong spreading)
Peak height: 1.000 (started at 1.000) — attenuation so far: 0.0%. Larger D flattens the peak faster — this is the same peak-flattening effect from §5.1, now traced to a single term in the PDE.

5.4 — The Real OPM Code: GSSHA-Style Conveyance Depth

SwS_w alone is not quite enough. On a normal downhill bed the upstream cell's own depth hih_i 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 (zds>ziz_\text{ds} > z_i) 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,

hhigher=max(WSEi,WSEds)max(zi,zds)h_\text{higher} = \max(\text{WSE}_i, \text{WSE}_\text{ds}) - \max(z_i, z_\text{ds})

A single blend weight θ\theta ties the slope and the conveyance depth together as a coherent pair: θ=0\theta=0 reduces to kinematic exactly (own depth, bed slope); θ=1\theta=1 — 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

Why the upstream depth alone is not always right: On a normal downhill bed, the upstream cell's own depth h_i is the correct conveyance depth — water flows off the higher bed into the lower. But at a D8 saddle, DEM step, or flow reversal, the downstream cell's ground can actually be higher (z_ds > z_i) even while its water surface is still lower than upstream — water is conveyed across that higher downstream lip, and the relevant depth is the depth of water over that higher bed, not over the upstream one. A naive scheme that always uses h_i either misjudges how much cross-sectional flow area is actually available, or — in some adverse-bed schemes — could drain a cell when it shouldn't be able to.

z_ds > z_i: the downstream cell's ground is locally HIGHER, but the water surface still drains forward.

n = 0.05  dist = 100 m  θ = 1.0
WSE_i = 5.500 m
WSE_ds = 5.400 m

Side view (to scale on elevation)

h_higherz_i = 5.00z_ds = 5.30WSE_i = 5.50WSE_ds = 5.40Cell i (upstream)Cell ds (downstream)z_ds > z_i — adverse / D8-saddle bed
QuantityValueMeaning
h_naive (= h_i)0.500 mWhat a scheme using only the upstream cell's own depth would compute
h_higher0.200 mCASC2D / 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³/sSame S_eff, conveyance depth swapped to h_naive
Q_diff (actual OPM)4.32593 m³/sProduction formula: h_flow built from h_higher
This is the saddle case: WSE_i (5.50 m) is still above WSE_ds (5.40 m), so water correctly drains forward (S_eff = 0.00100 > 0). But the downstream bed (z_ds = 5.30 m) sits 0.30 m above the upstream bed (z_i = 5.00 m) — a D8 saddle that can occur on a real DEM. h_naive would just report h_i = 0.500 m, ignoring that higher lip entirely; h_higher correctly reports 0.200 m — the depth of water actually standing over the higher of the two beds.

Part B — The Real OPM Source Code

routing_utils.pydiffusive_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_size
① S_w — the water-surface slope: the existing bed slope (slope_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.
② S_eff = max(S_w, 0) — backwater / adverse-gradient protection. Cells with no valid downstream neighbour (the outlet, or any cell draining off-mask) keep slope_bnd instead — free outflow identical to the kinematic scheme.
③ wse / wse_ds — stack depth on bed elevation for this cell and its downstream neighbour: WSE = z + h. Everything from here on follows the water surface, not the ground.
④ h_higher — the GSSHA convention: depth above whichever bed is higher, 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.
⑤ h_flow blend — the same θ that blends the slope also blends the conveyance depth, so the two terms stay a coherent pair: θ=0 → own depth + bed slope (kinematic exactly); θ=1 → h_higher + water-surface slope (full diffusion wave). Outlet cells fall back to their own depth; a min_depth floor guards the wet/dry boundary.
⑥ Q_diff — Manning's equation with the two replacements installed: (1/n)·h_flow^(5/3)·S_eff^(1/2)·cell_size. Not yet flux-limited — the caller applies the CFL limiter afterward.
The CASC2D / GSSHA convention: 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. When the bed goes downhill (z_i > z_ds) and the water surface too (WSE_i > WSE_ds): WSE_higher = z_i + h_i and z_higher = z_i, so h_higher = h_i — exactly the kinematic depth. The formula only differs when the downstream water surface is higher.

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.

In OPM routing_utils.py:
  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 Δt\Delta t, 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 Δt\Delta t; 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 (DnumcΔx2(1C)D_\text{num} \approx \tfrac{c\,\Delta x}{2}(1-C), from Chapter 4 §4.4; the global-vs-local trade-off is Chapter 4 §4.5). The widget below puts OPM's real Δx100m\Delta x \approx 100\,\text{m} and default Δt=0.7s\Delta t = 0.7\,\text{s} 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

Steep headwaterS₀ = 0.30Moderate midslopeS₀ = 0.01Flat valley floorS₀ = 0.0005← one global Δt, computed from the steepest cell, applied everywhere →
0.1 sOPM default: 0.7 s3.0 s
Steep headwater (sets the global Δt)
n0.03h1 mS₀0.3V18.26 m/sc30.43 m/sD_num1197.4 m²/sD_phys30.43 m²/s
C = 0.2130 (sub-optimal)
D_num / D_phys = 3935.0%
Moderate midslope
n0.05h0.5 mS₀0.01V1.26 m/sc2.10 m/sD_num103.5 m²/sD_phys31.50 m²/s
C = 0.0147 (danger zone)
D_num / D_phys = 328.4%
Flat Terai valley floor
n0.09h0.3 mS₀0.0005V0.111 m/sc0.186 m/sD_num9.27 m²/sD_phys33.40 m²/s
C = 0.0013 (danger zone)
D_num / D_phys = 27.7%
At Δt = 0.70 s, the Courant number at the flat valley-floor zone (C = 0.0013) is 164× smaller than at the steep headwater zone (C = 0.2130) — the very zone that determined this Δt in the first place.
Chapter 4's animated Δt-sensitivity demo showed a “Too Small Δt” lane at C = 0.17 already producing severe numerical damping of a flood peak. Here, the real flat-valley cell's Courant number (C = 0.0013) is 131× smaller than that already-failing case — OPM's real valley-floor cells run far deeper into the numerical-diffusion danger zone than even that demo's worst case.

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 Δt\Delta t 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. DnumD_\text{num} and DphysD_\text{phys} 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:

IssueImplicitExplicit (what OPM uses)
Stabilityany Δt\Delta tneeds C1C \lesssim 1
Cost per step (2-D grid)sparse matrix solveone elementwise pass
Nonlinear Q(h)Q(h)Newton iterations each stepdirect formula
GPU parallelismglobal communicationembarrassingly 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)

12345each cell: exactly 1 upstream, 1 downstream neighbor
A straight chain of unknowns → the implicit system is a clean tridiagonal matrix, solved in one forward sweep + one back-substitution sweep (the Thomas algorithm from Ch. 4 §4.4).

OPM's Real Domain: a D8 Flow-Direction Tree

1a1b234cell 2 is a confluence: 2 upstream neighbors, 1 downstream
A watershed-wide directed acyclic graph (DAG) built from D8 steepest-descent directions. Confluences — two or more upstream cells draining into one downstream cell — are everywhere, not the exception.
Why this breaks the tridiagonal trick: a tridiagonal solve assumes every unknown has exactly one neighbor on each side — a single line of cells, as in the left panel. The confluence at cell “2” in the right panel violates that: it has two upstream neighbors feeding it, not one, so its row in the system matrix has an extra nonzero entry off the main diagonal. Stack that across a whole watershed and the matrix is no longer tridiagonal — it's a general sparse matrix shaped like the river network's branching structure. That system is still solvable exactly (e.g. with a general sparse linear solver), but doing so is more expensive and architecturally different from the cheap, single-sweep forward substitution that makes the Preissmann/Thomas approach attractive on one channel.

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 10410^410610^6-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 CC). 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

ConditionKKRecommendedReason
Steep hillslopes (S0>0.01S_0 > 0.01)20\gg 20Kinematic or diffusiveNearly identical; diffusive auto-reduces to it
Moderate (S00.005S_0 \sim 0.0050.010.01)20–100EitherSmall difference
Flat valley floors (S0<0.005S_0 < 0.005)<20< 20DiffusiveBackwater and attenuation matter
Urban / ponding areasvariesDiffusiveStormwater 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 hhigherhih_\text{higher} \approx h_i and SwS0S_w \approx S_0.

Adding backwater and flow-halting does not threaten mass conservation. The volume update from Chapter 2 is completely unchanged —

Vin+1=Vin+reffΔx2Δt+QinΔtQoutlimΔtV_i^{n+1} = V_i^n + r_\text{eff}\,\Delta x^2\,\Delta t + Q_\text{in}\,\Delta t - Q_\text{out}^\text{lim}\,\Delta t

— only the formula for QoutQ_\text{out} differs between schemes, and both are capped by the identical flux limiter and tracked by the identical mass-balance accumulators. When Seff=0S_\text{eff} = 0, Qdiff=0Q_\text{diff} = 0: no water leaves the cell, the limiter is trivially satisfied, and the volume is simply unchanged — stopping flow does not lose water.

Drag θdiff\theta_\text{diff} 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

i=0
2.0
i=1
2.0
i=2
2.0
i=3
2.0
i=4
2.0
i=5
2.0
i=6
2.0
i=7
2.0
Kinematicθ=0.0Diffusive
FastSlow
0 = kinematic0.5 = blend1 = diffusive
Input hydrograph: peak = 12 m³/s at t = 3

Outlet Hydrograph (reach i = 7)

04812036911Q (m³/s)time step2.32.31936.9t=0Kinematicθ=0.0Diffusive

Outlet Statistics

SchemePeak Q (m³/s)Peak at t=AttenuationMass (m³)
Kinematic2.3110%7293
θ=0.002.3110.0%7293
Diffusive1936.911-85388.7%588248

Routing Scheme Limitations

LimitationKinematicDiffusiveDynamic
No backwater effects✗ blindpartial
Hydrograph attenuation✗ zero✓ some✓ full
Flat reaches (S→0)✗ fails~ marginal
Computational costO(N)O(N)O(N log N)
Valid whenS₀ > 0.001S₀ > 0.0001always
In OPM config.py:
  ROUTING_SCHEME = 'kinematic'  →  θ_diff = 0
  ROUTING_SCHEME = 'diffusive'  →  uses DIFFUSION_THETA
  DIFFUSION_THETA = 1.0  (full diffusion by default)

5.8 — Chapter Summary

ConceptFormula
Water-surface elevationWSE=z+h\text{WSE} = z + h
Water-surface slopeSw=S0+(hihds)/ΔxS_w = S_0 + (h_i - h_\text{ds})/\Delta x
Effective slope (θ\theta-blend)Seff=max ⁣(S0+θ(hihds)/Δx, 0)S_\text{eff} = \max\!\big(S_0 + \theta(h_i-h_\text{ds})/\Delta x,\ 0\big)
Conveyance depthhhigher=max(WSEi,WSEds)max(zi,zds)h_\text{higher} = \max(\text{WSE}_i,\text{WSE}_\text{ds}) - \max(z_i,z_\text{ds})
Flow depthhflow=(1θ)hi+θhhigherh_\text{flow} = (1-\theta)h_i + \theta\,h_\text{higher}
DischargeQdiff=1nhflow5/3Seff1/2ΔxQ_\text{diff} = \tfrac{1}{n}\,h_\text{flow}^{5/3}\,S_\text{eff}^{1/2}\,\Delta x
Hydraulic diffusivityD=V0h0/(2S0)D = V_0 h_0 / (2 S_0)

Key takeaways:

  1. The kinematic wave fails on flat slopes, cannot attenuate peaks, and cannot feel backwater — all because it uses the static bed slope.
  2. The diffusive wave's one idea: drive flow with the water-surface slope SwS_w instead of S0S_0.
  3. Three cases — Sw>S0S_w > S_0 (normal boost), Sw=S0S_w = S_0 (identical to kinematic), Sw<0S_w < 0 (clamped to 0, flow halts).
  4. The θ\theta-blend makes θ=0\theta=0 exactly kinematic and θ=1\theta=1 fully diffusive; OPM uses θ=1\theta=1.
  5. The CASC2D/GSSHA conveyance depth hhigherh_\text{higher} correctly handles adverse slopes and reduces to hih_i for normal downhill flow.
  6. The ds_safe trick replaces the outlet's 1-1 index with 0 and masks the result — a branch-free, GPU-friendly downstream lookup.
  7. OPM's single global Δt\Delta t, 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.
  8. 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 Δt\Delta t remains the clean fix for the cost identified in §5.5, and is not yet implemented.