Coverage for src/gwtransport/percolation.py: 100%

72 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +0000

1""" 

2Percolation through thick unsaturated zones via the Kinematic Wave method. 

3 

4This module provides one public function: 

5 

6- :func:`root_zone_to_water_table_kinematic_wave` — exact front-tracking 

7 solver for gravity-driven percolation between the bottom of the root 

8 zone and the water table, following the Kinematic-Wave method described 

9 in Olsthoorn (2026, *Stromingen* 32(1)). Supports Brooks-Corey and 

10 van Genuchten-Mualem constitutive curves and a time-varying 

11 multiplicative scaling of K(θ) (e.g. for temperature-corrected 

12 viscosity). 

13 

14**Forward-only.** Inverse mapping ``water_table_to_root_zone`` is not 

15provided. The KW unsaturated-zone problem is fundamentally one-way under 

16gravity: multiple ``q_root_zone(t)`` series produce indistinguishable 

17``q_water_table(t)`` after the column's intrinsic low-pass response, 

18making the inverse ill-posed. Users wanting an inverse should formulate 

19it as a regularised inverse problem outside this package. 

20 

21**Cumulative pore-volume coordinate.** The position axis is *cumulative 

22pore volume per unit cross-sectional area* (units of length), not 

23geometric depth. For a soil of constant porosity ``n_p ≡ θ_s`` and 

24water-table depth ``z_wt``, the conversion is ``V_out = θ_s · z_wt``. 

25The docstring of :func:`root_zone_to_water_table_kinematic_wave` 

26spells out the recovery rule and the layered-porosity generalisation. 

27 

28The full Kinematic-Wave derivation and the constitutive-curve references 

29are documented on :func:`root_zone_to_water_table_kinematic_wave`. 

30 

31This file is part of gwtransport which is released under AGPL-3.0 license. 

32See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details. 

33""" 

34 

35import numpy as np 

36import numpy.typing as npt 

37import pandas as pd 

38 

39from gwtransport._time import tedges_to_days 

40from gwtransport.advection import _flow_weighted_front_tracking_output 

41from gwtransport.fronttracking.math import ( 

42 BrooksCoreyConductivity, 

43 VanGenuchtenMualemConductivity, 

44) 

45from gwtransport.fronttracking.solver import FrontTracker 

46from gwtransport.fronttracking.waves import CharacteristicWave, RarefactionWave, ShockWave 

47 

48 

49def root_zone_to_water_table_kinematic_wave( 

50 *, 

51 q_root_zone: npt.ArrayLike, 

52 tedges: pd.DatetimeIndex, 

53 q_water_table_tedges: pd.DatetimeIndex, 

54 cumulative_pore_volumes_outlet: npt.ArrayLike, 

55 theta_r: float, 

56 theta_s: float, 

57 k_s: float, 

58 brooks_corey_lambda: float | None = None, 

59 van_genuchten_n: float | None = None, 

60 mualem_l: float = 0.5, 

61 k_scaling: npt.ArrayLike | None = None, 

62 max_iterations: int = 10000, 

63) -> tuple[npt.NDArray[np.floating], list[dict]]: 

64 r"""Percolation flux at the water table by exact Kinematic-Wave front tracking. 

65 

66 Solves the nonlinear scalar conservation law 

67 

68 .. math:: 

69 \\frac{\\partial \\theta_m}{\\partial t} + 

70 \\frac{\\partial K(\\theta_m)}{\\partial z} = 0 

71 

72 exactly via :class:`gwtransport.fronttracking.solver.FrontTracker`, 

73 using either a Brooks-Corey or a van Genuchten-Mualem constitutive 

74 curve. Implements the Kinematic-Wave method (see [3]_ for the general 

75 theory) described in Olsthoorn (2026) [1]_. The capillary term 

76 ``∂ψ/∂z`` is dropped (gravity drainage only); real fronts are slightly 

77 smoothed by capillarity, so if smoothing matters use the Munsflow-style 

78 approach in :mod:`gwtransport.diffusion` instead. 

79 

80 Parameters 

81 ---------- 

82 q_root_zone : array-like 

83 Root-zone leakage entering the unsaturated zone at the top 

84 boundary [length/time, e.g. m/day]. Piecewise constant over each 

85 ``[tedges[i], tedges[i+1])`` bin. Non-negative. 

86 Length = ``len(tedges) - 1``. At any bin, ``q_root_zone <= f·K_s`` 

87 must hold (with ``f = k_scaling`` or 1) for the inlet inversion 

88 to be well-defined; the validator raises ``ValueError`` otherwise. 

89 tedges : pandas.DatetimeIndex 

90 Time bin edges of the input series. Length ``n + 1`` for ``n`` bins. 

91 q_water_table_tedges : pandas.DatetimeIndex 

92 Output time bin edges. Free monotone index when ``k_scaling`` is 

93 None; **must equal** ``tedges`` when ``k_scaling`` is set (the 

94 back-transform ``q_wt = f · cout`` is exact only on the input grid). 

95 Must lie within the input window ``[tedges[0], tedges[-1]]`` (the flow 

96 series defines the system only there); querying beyond it raises. 

97 cumulative_pore_volumes_outlet : array-like 

98 Cumulative pore volume per unit cross-sectional area at the water 

99 table [length]. For a soil of constant porosity (``n_p ≡ θ_s``) 

100 and water-table depth ``z_wt``, this is ``θ_s · z_wt``. For 

101 layered porosity, ``∫₀^{z_wt} n_p(z') dz'``. The geometric depth 

102 is recovered as ``z_wt = V_out / θ_s`` (uniform case). Array-like 

103 to support a distribution of column lengths in parallel 

104 (analogous to :func:`gwtransport.advection.gamma_infiltration_to_extraction`); each 

105 entry must be positive. 

106 theta_r : float 

107 Residual volumetric moisture content [-]. Must satisfy 

108 ``0 <= theta_r < theta_s``. 

109 theta_s : float 

110 Saturated volumetric moisture content [-]. Equal to the porosity 

111 for typical soils. Must satisfy ``theta_r < theta_s < 1``. 

112 k_s : float 

113 Saturated hydraulic conductivity [length/time]. Positive. 

114 brooks_corey_lambda : float or None, optional 

115 Brooks-Corey pore-size distribution index [-]. Set to use the 

116 Brooks-Corey branch. Mutually exclusive with ``van_genuchten_n``. 

117 Tabulated soil values are available in the Staringreeks [2]_. 

118 van_genuchten_n : float or None, optional 

119 Van Genuchten shape parameter ``n_vG > 1``. Set to use the 

120 van Genuchten-Mualem branch (numerical inversion via brentq). 

121 Mutually exclusive with ``brooks_corey_lambda``. 

122 mualem_l : float, optional 

123 Mualem pore-connectivity parameter ``L``. Default 0.5 

124 (standard Mualem). Honored only when ``van_genuchten_n`` is set. 

125 k_scaling : array-like or None, optional 

126 Dimensionless time-only multiplicative factor ``f(t)`` applied 

127 to the entire ``K(θ)`` curve: 

128 ``K(θ, t) = f(t) · K_reference(θ)``. Length ``n``. Default None 

129 means ``f ≡ 1``. All entries must be strictly positive. 

130 

131 The cumulative-flow trick in the underlying front-tracking solver 

132 absorbs ``f(t)`` exactly: wave dynamics in cumulative effective 

133 time remain flow-free. Typical usage is a temperature-corrected 

134 viscosity ``f(t) = μ_ref / μ(T(t))``; ``μ`` varies ~60% between 

135 5 °C and 25 °C, so seasonal swings of 30-50% in effective ``K_s`` 

136 are realistic for shallow soils. 

137 max_iterations : int, optional 

138 Maximum number of solver events. Default 10000. 

139 

140 Returns 

141 ------- 

142 q_water_table : ndarray 

143 Bin-averaged percolation flux at the water table [same units as 

144 ``q_root_zone``], length ``len(q_water_table_tedges) - 1``, 

145 averaged across the columns in ``cumulative_pore_volumes_outlet``. 

146 structures : list of dict 

147 Per-column simulation structures (same schema as 

148 :func:`gwtransport.advection.infiltration_to_extraction_nonlinear_sorption`, 

149 with ``aquifer_pore_volume`` renamed to 

150 ``cumulative_pore_volume_outlet``): 

151 

152 - ``waves`` — all wave objects. 

153 - ``events`` — event history; each record has ``"theta"`` (cumulative 

154 effective time) and ``"type"`` keys. Translate ``theta`` to wall-clock 

155 time via ``tracker_state.t_at_theta(event["theta"])``. 

156 - ``theta_first_arrival`` — cumulative effective time at which 

157 the first nonzero arrival reaches the outlet. 

158 - ``n_events``, ``n_shocks``, ``n_rarefactions``, 

159 ``n_characteristics`` — counts. 

160 - ``theta_current`` — final cumulative effective time. 

161 - ``sorption`` — the sorption object. 

162 - ``tracker_state`` — complete :class:`~gwtransport.fronttracking.solver.FrontTrackerState` for the 

163 column (use ``state.t_at_theta`` to translate ``θ → t``). 

164 - ``cumulative_pore_volume_outlet`` — the V_out for this column. 

165 

166 Raises 

167 ------ 

168 ValueError 

169 If inputs are inconsistent (wrong lengths, NaN, negative ``q_root_zone`` 

170 or ``k_scaling``, non-finite or non-positive 

171 ``cumulative_pore_volumes_outlet`` or ``k_s``), if neither or both 

172 sorption-parameter groups are supplied, 

173 if ``q_root_zone > f(t) * k_s`` at any bin (saturation/ponding limit), 

174 or if ``q_water_table_tedges`` does not equal ``tedges`` while 

175 ``k_scaling`` is provided. 

176 

177 Warns 

178 ----- 

179 UserWarning 

180 If output θ-bins extend beyond the inlet θ-window (i.e. the drying tail 

181 of ``q_root_zone`` reaches zero and the column has not yet equilibrated 

182 by the last output bin). Bin averages in that region are clamped to zero. 

183 

184 See Also 

185 -------- 

186 gwtransport.advection.infiltration_to_extraction_nonlinear_sorption : 

187 Solute transport with nonlinear sorption (analogous front-tracking 

188 algorithm in the saturated-zone domain). 

189 gwtransport.diffusion : 

190 Munsflow-style linearised advection-diffusion (complementary; 

191 smoothed fronts). 

192 gwtransport.fronttracking.math.BrooksCoreyConductivity : 

193 Brooks-Corey constitutive class. 

194 gwtransport.fronttracking.math.VanGenuchtenMualemConductivity : 

195 van Genuchten-Mualem constitutive class. 

196 :ref:`concept-kinematic-wave` : Background on the Kinematic-Wave method for 

197 unsaturated-zone percolation. 

198 

199 Notes 

200 ----- 

201 **Cumulative pore-volume coordinate.** The internal V axis is 

202 ``V(z) = int_0^z n_p(z') dz'`` (units of length). For a uniform soil 

203 with ``n_p = theta_s``, ``V = theta_s * z``; depth is recovered as 

204 ``z = V / theta_s``. The solver-side identification 

205 ``flow = theta_s * f(t)`` (with ``f`` the optional K-scaling) follows 

206 from the chain rule ``d/dz = theta_s * d/dV``. 

207 

208 **Inlet boundary inversion.** The solver works in a reference frame 

209 where ``K = K_ref(theta_m)``; the time-varying scaling is moved to the 

210 boundary as ``cin_solver(t) = q_root_zone(t) / f(t)`` and recovered 

211 at the outlet as ``q_water_table(t) = f(t) * cout(t)``. The 

212 requirement ``cin_solver <= k_s`` (i.e. ``q_root_zone <= f * k_s``) 

213 is the saturation/ponding admissibility check enforced by the 

214 validator. 

215 

216 **The KW approximation.** Capillary stresses are neglected; flow 

217 is gravity-only. Wetting fronts are sharp shocks satisfying 

218 Rankine-Hugoniot ``V_f = (K_1 - K_2)/(theta_1 - theta_2)``. Drying tails are 

219 self-similar rarefaction fans. Real fronts are slightly capillary- 

220 smoothed; if that smoothing matters, use Munsflow-style 

221 advection-diffusion (the article's Munsflow method, mapped to 

222 :mod:`gwtransport.diffusion` in this package). 

223 

224 **Initial condition.** The column starts at ``theta_m = theta_r`` (i.e. 

225 ``K = 0``) everywhere. To start from field capacity or a long-term 

226 equilibrium, prepend a constant-q spin-up to the input series. 

227 

228 **Exact mass conservation.** Both Brooks-Corey and van Genuchten-Mualem 

229 fan integrals use a closed-form integration-by-parts antiderivative 

230 derived from the universal identity ``R = dC_T/dC``: for the spatial 

231 fan integral ``G(u) = C_T(c) * u - kappa * c``, and for the temporal 

232 fan integral ``F(theta) = c * (theta - theta_origin) - Delta_v * C_T(c)``. 

233 For Brooks-Corey both ``c`` and ``C_T`` at the endpoints are closed 

234 form; for van Genuchten-Mualem they require a single ``brentq`` call 

235 per endpoint (transcendental ``K(theta)``). The Burdine variant 

236 (``mualem_l = 0``) admits a closed-form inverse and is fully 

237 free of root-finding. 

238 

239 References 

240 ---------- 

241 .. [1] Olsthoorn, T.N. (2026). Percolation through thick unsaturated 

242 zones — Munsflow vs. the Kinematic Wave. *Stromingen* 32(1). 

243 .. [2] Heinen, M., Bakker, G., Wösten, J.M.H. (2020). *Waterretentie 

244 en Doorlatendheidskarakteristieken van boven- en ondergronden in 

245 Nederland: de Staringreeks. Update 2018.* Wageningen Environmental 

246 Research, Report 2978. 

247 .. [3] Charbeneau, R.J. (2000). *Groundwater Hydraulics and Pollutant 

248 Transport.* Prentice Hall. 

249 

250 Examples 

251 -------- 

252 .. disable_try_examples 

253 

254 Reproduce a 10-year step-response for the article's soil O05 

255 (coarse sand, Brooks-Corey):: 

256 

257 import numpy as np 

258 import pandas as pd 

259 from gwtransport.percolation import ( 

260 root_zone_to_water_table_kinematic_wave, 

261 ) 

262 

263 tedges = pd.date_range("1995-01-01", "2005-01-01", freq="D") 

264 q_root = np.full(len(tedges) - 1, 1e-3) # 1 mm/day 

265 

266 q_wt, structures = root_zone_to_water_table_kinematic_wave( 

267 q_root_zone=q_root, 

268 tedges=tedges, 

269 q_water_table_tedges=tedges, 

270 cumulative_pore_volumes_outlet=np.array([0.337 * 20.0]), 

271 theta_r=0.01, 

272 theta_s=0.337, 

273 k_s=0.174, 

274 brooks_corey_lambda=0.25, 

275 ) 

276 

277 With time-varying water viscosity:: 

278 

279 days = ((tedges[:-1] - tedges[0]) / pd.Timedelta(days=1)).values 

280 T = 10.0 + 5.0 * np.sin(2 * np.pi * days / 365.25) # °C 

281 mu_ref, dmu_dT = 1.31, -0.027 # mPa·s, linear around 10 °C 

282 mu = mu_ref + dmu_dT * (T - 10.0) 

283 k_scaling = mu_ref / mu 

284 

285 q_wt_visc, _ = root_zone_to_water_table_kinematic_wave( 

286 q_root_zone=q_root, 

287 tedges=tedges, 

288 q_water_table_tedges=tedges, 

289 cumulative_pore_volumes_outlet=np.array([0.337 * 20.0]), 

290 theta_r=0.01, 

291 theta_s=0.337, 

292 k_s=0.174, 

293 brooks_corey_lambda=0.25, 

294 k_scaling=k_scaling, 

295 ) 

296 """ 

297 q_root_zone_arr = np.asarray(q_root_zone, dtype=float) 

298 # Promote a scalar / 0-d outlet pore volume to a single-column 1-d array so the 

299 # downstream per-column iteration and len() are well-defined (a lone scalar is a 

300 # valid single column, consistent with the "array-like" contract in the docstring). 

301 cumulative_pore_volumes_outlet_arr = np.atleast_1d(np.asarray(cumulative_pore_volumes_outlet, dtype=float)) 

302 tedges = pd.DatetimeIndex(tedges) 

303 q_water_table_tedges = pd.DatetimeIndex(q_water_table_tedges) 

304 

305 n_bins = len(q_root_zone_arr) 

306 if len(tedges) != n_bins + 1: 

307 msg = f"tedges must have length len(q_root_zone) + 1, got {len(tedges)} vs {n_bins + 1}" 

308 raise ValueError(msg) 

309 if q_water_table_tedges[0] < tedges[0] or q_water_table_tedges[-1] > tedges[-1]: 

310 msg = ( 

311 f"q_water_table_tedges must lie within the input window [{tedges[0]}, {tedges[-1]}], got " 

312 f"[{q_water_table_tedges[0]}, {q_water_table_tedges[-1]}]. The flow series defines the " 

313 "system only over the input window; querying beyond it is ill-posed (extend q_root_zone instead)." 

314 ) 

315 raise ValueError(msg) 

316 if np.any(q_root_zone_arr < 0): 

317 msg = "q_root_zone must be non-negative" 

318 raise ValueError(msg) 

319 if np.any(np.isnan(q_root_zone_arr)): 

320 msg = "q_root_zone must not contain NaN" 

321 raise ValueError(msg) 

322 if cumulative_pore_volumes_outlet_arr.size == 0 or not np.all( 

323 np.isfinite(cumulative_pore_volumes_outlet_arr) & (cumulative_pore_volumes_outlet_arr > 0) 

324 ): 

325 msg = "cumulative_pore_volumes_outlet must be non-empty with all entries positive and finite" 

326 raise ValueError(msg) 

327 if not (0.0 <= theta_r < theta_s < 1.0): 

328 msg = f"theta_r, theta_s must satisfy 0 <= theta_r < theta_s < 1, got theta_r={theta_r}, theta_s={theta_s}" 

329 raise ValueError(msg) 

330 if not (np.isfinite(k_s) and k_s > 0): 

331 msg = f"k_s must be positive and finite, got {k_s}" 

332 raise ValueError(msg) 

333 if (brooks_corey_lambda is None) == (van_genuchten_n is None): 

334 msg = "Exactly one of brooks_corey_lambda or van_genuchten_n must be provided" 

335 raise ValueError(msg) 

336 

337 if k_scaling is None: 

338 f = np.ones(n_bins, dtype=float) 

339 else: 

340 f = np.asarray(k_scaling, dtype=float) 

341 if f.shape != (n_bins,): 

342 msg = f"k_scaling must have shape ({n_bins},), got {f.shape}" 

343 raise ValueError(msg) 

344 if np.any(np.isnan(f)) or np.any(f <= 0): 

345 msg = "k_scaling must be strictly positive and contain no NaN" 

346 raise ValueError(msg) 

347 if len(q_water_table_tedges) != len(tedges) or not (q_water_table_tedges == tedges).all(): 

348 msg = ( 

349 "q_water_table_tedges must equal tedges when k_scaling is provided " 

350 "(the back-transform q_wt = f * cout is exact only on the input grid)" 

351 ) 

352 raise ValueError(msg) 

353 

354 # Saturation/ponding admissibility: K_ref(θ_m at inlet) = q_root/f must be <= k_s. 

355 cin_solver = q_root_zone_arr / f 

356 if float(cin_solver.max()) > k_s: 

357 bin_idx = int(cin_solver.argmax()) 

358 msg = ( 

359 f"Inlet saturation/ponding limit exceeded at bin {bin_idx}: " 

360 f"q_root_zone/k_scaling = {cin_solver[bin_idx]:.6g} > k_s = {k_s:.6g}. " 

361 "Reduce q_root_zone or increase k_scaling (warmer water → lower viscosity → higher k_s effective)." 

362 ) 

363 raise ValueError(msg) 

364 

365 if brooks_corey_lambda is not None: 

366 sorption: BrooksCoreyConductivity | VanGenuchtenMualemConductivity = BrooksCoreyConductivity( 

367 theta_r=theta_r, theta_s=theta_s, k_s=k_s, brooks_corey_lambda=brooks_corey_lambda 

368 ) 

369 else: 

370 assert van_genuchten_n is not None # noqa: S101 # narrowed by validation above 

371 sorption = VanGenuchtenMualemConductivity( 

372 theta_r=theta_r, theta_s=theta_s, k_s=k_s, van_genuchten_n=van_genuchten_n, mualem_l=mualem_l 

373 ) 

374 

375 # Solver-frame arrays: flow_solver = θ_s · f(t) (porosity ≡ θ_s for unsaturated KW); 

376 # cin_solver = q_root/f was already formed for the admissibility check above. 

377 flow_solver = theta_s * f 

378 

379 flow_tedges_days = tedges_to_days(tedges) 

380 cout_tedges_days = tedges_to_days(q_water_table_tedges, ref=tedges[0]) 

381 n_out = len(q_water_table_tedges) - 1 

382 

383 q_wt_all = np.zeros((len(cumulative_pore_volumes_outlet_arr), n_out)) 

384 structures: list[dict] = [] 

385 

386 for i, v_out in enumerate(cumulative_pore_volumes_outlet_arr): 

387 tracker = FrontTracker( 

388 cin=cin_solver, 

389 flow=flow_solver, 

390 tedges=tedges, 

391 aquifer_pore_volume=float(v_out), 

392 sorption=sorption, 

393 ) 

394 tracker.run(max_iterations=max_iterations) 

395 

396 cout_ref = _flow_weighted_front_tracking_output( 

397 cout_tedges_days=cout_tedges_days, 

398 flow_tedges_days=flow_tedges_days, 

399 flow=flow_solver, 

400 v_outlet=float(v_out), 

401 waves=tracker.state.waves, 

402 sorption=sorption, 

403 theta_edges=tracker.state.theta_edges, 

404 cin=cin_solver, 

405 ) 

406 

407 # Back-transform to physical flux: q_wt = f · cout_ref. When k_scaling is set the 

408 # validator requires q_water_table_tedges == tedges, so f aligns with cout_ref. When 

409 # k_scaling is None, f ≡ 1 and the output grid may be coarser than the input, so 

410 # cout_ref is used directly (multiplying by the input-length f would mis-broadcast). 

411 q_wt_all[i, :] = cout_ref if k_scaling is None else f * cout_ref 

412 

413 structures.append({ 

414 "waves": tracker.state.waves, 

415 "events": tracker.state.events, 

416 "theta_first_arrival": tracker.theta_first_arrival, 

417 "n_events": len(tracker.state.events), 

418 "n_shocks": sum(1 for w in tracker.state.waves if isinstance(w, ShockWave)), 

419 "n_rarefactions": sum(1 for w in tracker.state.waves if isinstance(w, RarefactionWave)), 

420 "n_characteristics": sum(1 for w in tracker.state.waves if isinstance(w, CharacteristicWave)), 

421 "theta_current": tracker.state.theta_current, 

422 "sorption": sorption, 

423 "tracker_state": tracker.state, 

424 "cumulative_pore_volume_outlet": float(v_out), 

425 }) 

426 

427 return np.mean(q_wt_all, axis=0), structures