Coverage for src/gwtransport/fronttracking/math.py: 0%

352 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 20:54 +0000

1""" 

2Mathematical Foundation for Front Tracking with Nonlinear Sorption. 

3 

4This module provides exact analytical computations for: 

5 

6- Freundlich, Langmuir, and constant retardation models 

7- Brooks-Corey and van Genuchten-Mualem unsaturated conductivity models 

8 (for Kinematic-Wave percolation, see :mod:`gwtransport.percolation`) 

9- Shock velocities via Rankine-Hugoniot condition 

10- Characteristic velocities and positions 

11- First arrival time calculations 

12- Entropy condition verification 

13 

14All sorption-class computations are exact analytical formulas; the 

15van Genuchten-Mualem class uses ``scipy.optimize.brentq`` for the two 

16inversions that have no closed form. 

17 

18Available functions: 

19 

20- :class:`NonlinearSorption` - Abstract base for concentration-dependent isotherms. Subclasses supply 

21 ``retardation(c)``, ``total_concentration(c)`` and ``concentration_from_retardation(r)``; the base derives 

22 the Rankine-Hugoniot ``shock_speed(c_left, c_right)``, the Lax ``check_entropy_condition``, the paired 

23 ``c_and_total_from_retardation`` and ``fan_converges_at_infinity`` from them. 

24 

25- :class:`FreundlichSorption` - Power-law isotherm ``s(C) = k_f · C^(1/n)`` with closed-form 

26 ``R(C) = 1 + (ρ_b · k_f)/(n_por · n) · C^(1/n − 1)``. ``n > 1`` makes higher concentrations travel faster, 

27 ``n < 1`` mirrors that, and ``n = 1`` is rejected (use :class:`ConstantRetardation`). Because ``R → ∞`` as 

28 ``C → 0`` for ``n > 1``, ``retardation`` and ``concentration_from_retardation`` clamp ``C`` at ``c_min``. 

29 

30- :class:`ConstantRetardation` - Linear sorption ``s(C) = K_d · C`` reduced to one ``retardation_factor``: 

31 every concentration moves at ``dV/dθ = 1/R``, so no rarefaction ever forms and the solution is a pure 

32 θ-shift with shocks only at inlet discontinuities. 

33 

34- :class:`LangmuirSorption` - Favorable isotherm ``s(C) = s_max · C/(K_L + C)`` with 

35 ``R(C) = 1 + (ρ_b · s_max · K_L)/(n_por · (K_L + C)²)``, which is finite at ``C = 0``, so no concentration 

36 floor is needed; ``R`` decreases with ``C``, giving shocks on concentration rises and fans on falls. 

37 

38- :class:`BrooksCoreyConductivity` - Brooks-Corey unsaturated conductivity ``K(θ) = K_s · Θ^a`` recast into 

39 the ``(C, C_T)`` variables of the sorption interface by identifying ``C ≡ K`` (flux) and ``C_T ≡ θ − θ_r`` 

40 (storage), which lets :mod:`gwtransport.percolation` run Kinematic-Wave percolation on this solver. All 

41 three curve methods are closed form; ``C`` is clamped at a dry-soil floor in ``retardation`` and 

42 ``concentration_from_retardation`` but not in ``total_concentration``, keeping the ``c_R = 0`` 

43 wetting-front shock speed exact. 

44 

45- :class:`VanGenuchtenMualemConductivity` - Mualem conductivity for the van Genuchten retention curve, recast 

46 the same way. ``total_concentration`` and the flux derivative are closed form; the two inversions ``S_e(C)`` 

47 and ``S_e(R)`` use ``scipy.optimize.brentq``. The retention parameter ``α_vG`` is not needed because the 

48 Kinematic-Wave approximation drops capillary suction. 

49 

50- :func:`characteristic_speed` - Characteristic speed ``dV/dθ = 1/R(c)`` in (V, θ) coordinates, a property of 

51 the isotherm alone. Its ``sorption`` argument is any of the classes above (the ``SorptionModel`` alias is 

52 their union). 

53 

54- :func:`characteristic_position` - Position of a characteristic at cumulative flow ``theta``, evaluated as 

55 ``v_start + (θ − θ_start)/R(c)``, or ``None`` when ``theta`` lies before ``theta_start``. 

56 

57- :func:`compute_first_front_arrival_theta` - Cumulative flow at which the first injected concentration level 

58 is fully present at the outlet: the θ-edge of the first bin that both carries water (positive θ-width) and 

59 has ``cin > 0``, plus ``V · R(c_first)`` for Freundlich ``n < 1`` and ``V · C_T(c_first)/c_first`` 

60 otherwise. These are *tail*-arrival semantics — a rarefaction head can reach the outlet much earlier — and 

61 the result is ``inf`` when no water-carrying bin injects concentration. 

62 

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

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

65""" 

66 

67import math 

68from abc import ABC, abstractmethod 

69from dataclasses import dataclass, field 

70 

71import numpy as np 

72import numpy.typing as npt 

73from scipy.optimize import brentq 

74 

75# Numerical tolerance constants 

76EPSILON_FREUNDLICH_N = 1e-10 # Tolerance for checking if n ≈ 1.0 (Freundlich constructor rejects this) 

77EPSILON_DENOMINATOR = 1e-15 # Tolerance for near-zero denominators in shock velocity 

78_C_MIN = 1e-12 # Shared dry-soil singularity floor for Freundlich n>1, Brooks-Corey, vG-Mualem. 

79BRENTQ_XTOL = 1e-14 # brentq absolute tolerance for vG-Mualem inversions; matches _invert_freundlich_cr_zero. 

80 

81 

82class NonlinearSorption(ABC): 

83 """Abstract base for concentration-dependent sorption models. 

84 

85 Subclasses must implement `retardation`, `total_concentration`, and 

86 `concentration_from_retardation`. Shock velocity and entropy checking 

87 are provided generically via the Rankine-Hugoniot and Lax conditions. 

88 

89 See Also 

90 -------- 

91 FreundlichSorption : Freundlich isotherm implementation. 

92 LangmuirSorption : Langmuir isotherm implementation. 

93 ConstantRetardation : Linear (constant R) retardation model. 

94 """ 

95 

96 @abstractmethod 

97 def retardation(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

98 """Compute retardation factor R(C). 

99 

100 R fixes the characteristic celerity in (V, θ) coordinates: ``dV/dθ = 1/R(C)``. 

101 

102 Parameters 

103 ---------- 

104 c : float or numpy.ndarray 

105 Dissolved concentration [mass/volume]. Non-negative. 

106 

107 Returns 

108 ------- 

109 float or numpy.ndarray 

110 Retardation factor [-]. 

111 """ 

112 

113 @abstractmethod 

114 def total_concentration(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

115 """Compute total concentration (dissolved + sorbed per unit pore volume). 

116 

117 ``C_T`` is the conserved quantity of ``∂C_T/∂θ + ∂C/∂V = 0``; the flux carries 

118 only the dissolved part because sorbed mass is immobile. 

119 

120 Parameters 

121 ---------- 

122 c : float or numpy.ndarray 

123 Dissolved concentration [mass/volume]. Non-negative. 

124 

125 Returns 

126 ------- 

127 float or numpy.ndarray 

128 Total concentration [mass/volume]. 

129 """ 

130 

131 @abstractmethod 

132 def concentration_from_retardation(self, r: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

133 """Invert retardation factor to obtain concentration. 

134 

135 Used by rarefaction fans, where the self-similar solution gives R as a 

136 function of position and cumulative flow. 

137 

138 Parameters 

139 ---------- 

140 r : float or numpy.ndarray 

141 Retardation factor [-]. 

142 

143 Returns 

144 ------- 

145 float or numpy.ndarray 

146 Dissolved concentration [mass/volume]. Non-negative. 

147 """ 

148 

149 def shock_speed(self, c_left: float, c_right: float) -> float: 

150 """Compute shock speed dV/dθ via Rankine-Hugoniot in (V, θ) coordinates. 

151 

152 With cumulative-flow coordinate θ = ∫flow(t') dt', the PDE 

153 ``∂C_T/∂t + flow·∂C/∂V = 0`` becomes ``∂C_T/∂θ + ∂C/∂V = 0``, and 

154 Rankine-Hugoniot reduces to:: 

155 

156 dV_s/dθ = (C_R - C_L) / (C_T(C_R) - C_T(C_L)) 

157 

158 Flow drops out entirely; the result is a property of the sorption 

159 isotherm alone. 

160 

161 Parameters 

162 ---------- 

163 c_left : float 

164 Concentration upstream (behind) shock [mass/volume]. 

165 c_right : float 

166 Concentration downstream (ahead of) shock [mass/volume]. 

167 

168 Returns 

169 ------- 

170 shock_speed : float 

171 Shock speed dV/dθ [m³ / m³ flow = dimensionless]. 

172 """ 

173 c_total_left = self.total_concentration(c_left) 

174 c_total_right = self.total_concentration(c_right) 

175 denom = c_total_right - c_total_left 

176 

177 if abs(denom) < EPSILON_DENOMINATOR: 

178 avg_retardation = 0.5 * float(self.retardation(c_left) + self.retardation(c_right)) 

179 # Degenerate (zero-strength) shock: its speed is the characteristic speed 1/R. A pair of 

180 # saturated states (R = 0, e.g. Mualem-vG at S_e = 1) gives +∞, matching characteristic_speed. 

181 return float("inf") if avg_retardation == 0.0 else 1.0 / avg_retardation 

182 

183 return float((c_right - c_left) / denom) 

184 

185 def c_and_total_from_retardation(self, r: float) -> tuple[float, float]: 

186 """Return ``(c, C_T(c))`` at a given retardation ``r``. 

187 

188 Default implementation calls ``concentration_from_retardation(r)`` then 

189 ``total_concentration(c)`` — two independent root-finds for sorptions 

190 where both routes back-solve the same equation (e.g. vG-Mualem with 

191 ``L ≠ 0``). Subclasses for which both can be computed from a single 

192 root-find should override this for ~2× speedup of the IBP fan 

193 integrators. 

194 """ 

195 c = float(self.concentration_from_retardation(r)) 

196 ct = float(self.total_concentration(c)) 

197 return c, ct 

198 

199 def fan_converges_at_infinity(self) -> bool: # noqa: PLR6301 

200 """Whether a ``c_apex=0`` fan's ``∫ c dθ`` converges as ``θ → +∞``. 

201 

202 True when ``c → 0`` as ``R → ∞`` (so ``base·c → 0`` faster than ``base → ∞``): 

203 Brooks-Corey, van Genuchten-Mualem, Langmuir, and Freundlich ``n > 1``. The 

204 only divergent case is Freundlich ``n < 1`` (``c → ∞`` as ``R → ∞``), which 

205 overrides this to ``False``. Used by the universal temporal fan integrator to 

206 reject a ``+∞`` upper bound when the integral diverges. 

207 """ 

208 return True 

209 

210 def check_entropy_condition(self, c_left: float, c_right: float, shock_speed: float) -> bool: 

211 """Verify Lax entropy condition in (V, θ) coordinates. 

212 

213 In θ-space, characteristic speeds are ``λ_θ(C) = 1 / R(C)``, and the 

214 Lax condition for a physical shock is:: 

215 

216 λ_θ(C_L) ≥ dV_s/dθ ≥ λ_θ(C_R) 

217 

218 Parameters 

219 ---------- 

220 c_left : float 

221 Concentration upstream of shock [mass/volume]. 

222 c_right : float 

223 Concentration downstream of shock [mass/volume]. 

224 shock_speed : float 

225 Shock speed dV/dθ. 

226 

227 Returns 

228 ------- 

229 satisfies : bool 

230 True if shock satisfies entropy condition (is physical). 

231 """ 

232 r_left = float(self.retardation(c_left)) 

233 r_right = float(self.retardation(c_right)) 

234 lambda_left = float("inf") if r_left == 0.0 else 1.0 / r_left 

235 lambda_right = float("inf") if r_right == 0.0 else 1.0 / r_right 

236 

237 # A saturated upstream state (λ_left = +∞, e.g. a Mualem-vG wetting front at S_e = 1) is 

238 # physical; reject only a non-finite shock speed or downstream characteristic, where the 

239 # Lax test itself is ill-posed. 

240 if not np.isfinite(shock_speed) or not np.isfinite(lambda_right): 

241 return False 

242 

243 finite_left = abs(lambda_left) if np.isfinite(lambda_left) else 0.0 

244 tolerance = 1e-14 * max(finite_left, abs(lambda_right), abs(shock_speed)) 

245 

246 return bool((lambda_left > shock_speed - tolerance) and (shock_speed > lambda_right - tolerance)) 

247 

248 

249@dataclass 

250class FreundlichSorption(NonlinearSorption): 

251 """ 

252 Freundlich sorption isotherm with exact analytical methods. 

253 

254 The Freundlich isotherm is: s(C) = k_f * C^(1/n) 

255 

256 where: 

257 - s is sorbed concentration [mass/mass of solid] 

258 - C is dissolved concentration [mass/volume of water] 

259 - k_f is Freundlich coefficient [(volume/mass)^(1/n)] 

260 - n is Freundlich exponent (dimensionless) 

261 

262 For n > 1: Higher C travels faster 

263 For n < 1: Higher C travels slower 

264 For n = 1: linear (not supported, use ConstantRetardation instead) 

265 

266 Notes 

267 ----- 

268 The retardation factor is defined as: 

269 R(C) = 1 + (rho_b/n_por) * ds/dC 

270 = 1 + (rho_b*k_f)/(n_por*n) * C^((1/n)-1) 

271 

272 For Freundlich sorption, R depends on C, which creates nonlinear wave behavior. 

273 

274 For n>1 (higher C travels faster), R(C)→∞ as C→0, which can cause extremely slow 

275 wave propagation. The c_min parameter prevents this by enforcing a minimum 

276 concentration, making R(C) finite for all C≥0. 

277 

278 Examples 

279 -------- 

280 >>> sorption = FreundlichSorption( 

281 ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 

282 ... ) 

283 >>> r = sorption.retardation(5.0) 

284 >>> c_back = sorption.concentration_from_retardation(r) 

285 >>> bool(np.isclose(c_back, 5.0)) 

286 True 

287 """ 

288 

289 k_f: float 

290 """Freundlich coefficient [(m³/kg)^(1/n)]. Positive.""" 

291 n: float 

292 """Freundlich exponent [-]. Positive and != 1.""" 

293 bulk_density: float 

294 """Bulk density of porous medium [kg/m³]. Positive.""" 

295 porosity: float 

296 """Porosity [-]. In (0, 1).""" 

297 c_min: float = 1e-12 

298 """Dry-soil singularity floor [mass/volume]; keeps ``R(C)`` finite as ``C → 0`` for n>1.""" 

299 _ret_coefficient: float = field(init=False, repr=False, compare=False) 

300 """Cached ``(rho_b*k_f)/(n_por*n)`` — shared by the scalar and array paths.""" 

301 _ret_exponent: float = field(init=False, repr=False, compare=False) 

302 """Cached ``(1/n) - 1`` retardation exponent.""" 

303 _ct_coefficient: float = field(init=False, repr=False, compare=False) 

304 """Cached ``(rho_b/n_por)*k_f`` sorbed-mass coefficient.""" 

305 _cfr_inv_exponent: float = field(init=False, repr=False, compare=False) 

306 """Cached ``1/((1/n) - 1)`` inversion exponent for concentration_from_retardation.""" 

307 

308 def __post_init__(self): 

309 """Validate parameters after initialization. 

310 

311 Raises 

312 ------ 

313 ValueError 

314 If any parameter is outside its valid range: ``k_f`` <= 0, 

315 ``n`` <= 0, ``n`` == 1, ``bulk_density`` <= 0, ``porosity`` 

316 outside (0, 1), or ``c_min`` < 0. 

317 """ 

318 if self.k_f <= 0: 

319 msg = f"k_f must be positive, got {self.k_f}" 

320 raise ValueError(msg) 

321 if self.n <= 0: 

322 msg = f"n must be positive, got {self.n}" 

323 raise ValueError(msg) 

324 if abs(self.n - 1.0) < EPSILON_FREUNDLICH_N: 

325 msg = "n = 1 (linear case) not supported, use ConstantRetardation instead" 

326 raise ValueError(msg) 

327 if self.bulk_density <= 0: 

328 msg = f"bulk_density must be positive, got {self.bulk_density}" 

329 raise ValueError(msg) 

330 if not 0 < self.porosity < 1: 

331 msg = f"porosity must be in (0, 1), got {self.porosity}" 

332 raise ValueError(msg) 

333 if self.c_min < 0: 

334 msg = f"c_min must be non-negative, got {self.c_min}" 

335 raise ValueError(msg) 

336 

337 self._ret_exponent = (1.0 / self.n) - 1.0 

338 self._ret_coefficient = (self.bulk_density * self.k_f) / (self.porosity * self.n) 

339 self._ct_coefficient = (self.bulk_density / self.porosity) * self.k_f 

340 self._cfr_inv_exponent = 1.0 / self._ret_exponent 

341 

342 def retardation(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

343 """``R(C) = 1 + (rho_b*k_f)/(n_por*n) * C^((1/n)-1)``. See :meth:`NonlinearSorption.retardation`. 

344 

345 Notes 

346 ----- 

347 R decreases with C for n > 1 (higher C travels faster) and increases for n < 1. 

348 With n<1 and ``c_min=0``, ``R(0) = 1`` (no sorption at zero) because clamping to 

349 ``c_min=0`` leaves ``C^((1/n)-1) = 0^positive = 0``; otherwise ``c`` is clamped to 

350 ``c_min`` before evaluation. 

351 

352 Clamping with ``np.maximum`` before the power keeps a single general path 

353 for every ``(n, c_min)`` combination and avoids raising the base to a 

354 fractional power on negative ``c``. 

355 

356 A pure-float fast path handles the (dominant) scalar case, bit-identical to 

357 the array path; it falls through to the array expression only when the 

358 clamped ``c`` is ``0`` (``c_min == 0`` and ``c <= 0``), where numpy's 

359 ``0.0**exponent`` yields the documented ``inf`` (n>1) / ``0`` (n<1) that a 

360 pure ``0.0**neg`` would instead raise on. 

361 """ 

362 if not isinstance(c, np.ndarray): 

363 cf = float(c) 

364 c_eff = max(self.c_min, cf) 

365 if c_eff > 0.0: 

366 return 1.0 + self._ret_coefficient * (c_eff**self._ret_exponent) 

367 c_eff = np.maximum(np.asarray(c), self.c_min) 

368 result = 1.0 + self._ret_coefficient * (c_eff**self._ret_exponent) 

369 return result if isinstance(c, np.ndarray) else float(result) 

370 

371 def total_concentration(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

372 """``C_T = C + (rho_b/n_por)·k_f·C^(1/n)``. See :meth:`NonlinearSorption.total_concentration`. 

373 

374 Notes 

375 ----- 

376 For ``c = 0``, ``c^(1/n) = 0`` exactly (no singularity for any 

377 ``n > 0``), so ``C_T(0) = 0`` is physically correct and no ``c_min`` 

378 clamp is needed here. ``c_min`` is only required to keep 

379 :meth:`retardation` finite as ``c -> 0`` for ``n > 1``; clamping 

380 ``total_concentration`` to ``c_min`` would bias Rankine-Hugoniot 

381 shock speeds when ``c_R = 0`` (e.g. the canonical 0->c->0 pulse). 

382 Negative ``c`` is clamped to ``0`` defensively. 

383 """ 

384 if not isinstance(c, np.ndarray): 

385 cf = float(c) 

386 c_eff = max(0.0, cf) 

387 return c_eff + self._ct_coefficient * (c_eff ** (1.0 / self.n)) 

388 c_arr = np.maximum(np.asarray(c), 0.0) 

389 return c_arr + self._ct_coefficient * (c_arr ** (1.0 / self.n)) 

390 

391 def concentration_from_retardation(self, r: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

392 """``C = [(R-1)·n_por·n/(rho_b·k_f)]^(n/(1-n))``. See :meth:`NonlinearSorption.concentration_from_retardation`. 

393 

394 Notes 

395 ----- 

396 The exponent ``n/(1-n)`` is undefined at n = 1, which is why linear sorption 

397 must use :class:`ConstantRetardation` instead. Results below ``c_min`` are 

398 clamped to it. 

399 """ 

400 # __post_init__ rejects |n-1| < EPSILON_FREUNDLICH_N, so _cfr_inv_exponent is finite. 

401 if not isinstance(r, np.ndarray): 

402 base = (float(r) - 1.0) / self._ret_coefficient 

403 if base > 0.0: 

404 return max(base**self._cfr_inv_exponent, self.c_min) 

405 return self.c_min 

406 

407 base = (np.asarray(r) - 1.0) / self._ret_coefficient 

408 # Mask base to a safe placeholder before exponentiation; NumPy emits 

409 # RuntimeWarning otherwise for base <= 0 with a fractional exponent. 

410 safe_base = np.where(base > 0, base, 1.0) 

411 c = safe_base**self._cfr_inv_exponent 

412 return np.where(base > 0, np.maximum(c, self.c_min), self.c_min) 

413 

414 def fan_converges_at_infinity(self) -> bool: 

415 """Freundlich ``n > 1``: ``c → 0`` as ``R → ∞`` (converges). ``n < 1``: ``c → ∞`` (diverges).""" 

416 return self.n > 1.0 

417 

418 

419@dataclass 

420class ConstantRetardation: 

421 """ 

422 Constant (linear) retardation model. 

423 

424 For linear sorption: s(C) = K_d * C 

425 This gives constant retardation: R(C) = 1 + (rho_b/n_por) * K_d = constant 

426 

427 This is a special case where concentration-dependent behavior disappears. 

428 Used for conservative tracers or as approximation for weak sorption. 

429 

430 Notes 

431 ----- 

432 With constant retardation: 

433 - All concentrations travel at same speed in (V, θ): dV/dθ = 1/R 

434 - No rarefaction waves form (all concentrations travel together) 

435 - Shocks occur only at concentration discontinuities at inlet 

436 - Solution reduces to simple θ-shifting (and then t-shifting via the θ↔t map) 

437 

438 This is equivalent to a single-pore-volume advective time-shift (the deterministic limit of 

439 :func:`gwtransport.advection.infiltration_to_extraction`) in the gwtransport package. 

440 

441 Examples 

442 -------- 

443 >>> sorption = ConstantRetardation(retardation_factor=2.0) 

444 >>> sorption.retardation(5.0) 

445 2.0 

446 >>> sorption.retardation(10.0) 

447 2.0 

448 """ 

449 

450 retardation_factor: float 

451 """Constant retardation factor [-]. At least 1.0; ``R = 1`` is a conservative tracer.""" 

452 

453 def __post_init__(self): 

454 """Validate parameters after initialization. 

455 

456 Raises 

457 ------ 

458 ValueError 

459 If ``retardation_factor`` is less than 1.0. 

460 """ 

461 if self.retardation_factor < 1.0: 

462 msg = f"retardation_factor must be >= 1.0, got {self.retardation_factor}" 

463 raise ValueError(msg) 

464 

465 def retardation(self, c: float) -> float: # noqa: ARG002 

466 """Constant retardation factor, independent of ``c``.""" 

467 return self.retardation_factor 

468 

469 def total_concentration(self, c: float) -> float: 

470 """``C_T = C·R`` for linear sorption.""" 

471 return c * self.retardation_factor 

472 

473 def concentration_from_retardation(self, r: float) -> float: 

474 """Not applicable: with constant R the inversion is not meaningful. 

475 

476 Raises 

477 ------ 

478 NotImplementedError 

479 Always. 

480 """ 

481 msg = "concentration_from_retardation not applicable for ConstantRetardation (R is independent of C)" 

482 raise NotImplementedError(msg) 

483 

484 def shock_speed(self, c_left: float, c_right: float) -> float: # noqa: ARG002 

485 """``dV/dθ = 1/R`` for any concentration pair — identical to every characteristic speed.""" 

486 return 1.0 / self.retardation_factor 

487 

488 def check_entropy_condition(self, c_left: float, c_right: float, shock_speed: float) -> bool: # noqa: PLR6301 

489 """Check the Lax entropy condition; always satisfied, since with constant R it holds as an equality. 

490 

491 Returns 

492 ------- 

493 satisfies : bool 

494 Always True. 

495 """ 

496 del c_left, c_right, shock_speed 

497 return True 

498 

499 

500@dataclass 

501class LangmuirSorption(NonlinearSorption): 

502 """ 

503 Langmuir sorption isotherm with exact analytical methods. 

504 

505 The Langmuir isotherm is: s(C) = s_max * C / (K_L + C) 

506 

507 where: 

508 - s is sorbed concentration [mass/mass of solid] 

509 - C is dissolved concentration [mass/volume of water] 

510 - s_max is maximum sorption capacity [mass/mass of solid] 

511 - K_L is half-saturation constant [mass/volume] 

512 

513 Retardation always decreases with C (favorable isotherm), and R(0) is 

514 finite — unlike Freundlich with n > 1, no minimum concentration threshold 

515 is needed. 

516 

517 See Also 

518 -------- 

519 FreundlichSorption : Freundlich isotherm (unbounded sorption). 

520 ConstantRetardation : Linear (constant R) retardation model. 

521 :ref:`concept-nonlinear-sorption` : Background on nonlinear sorption. 

522 

523 Notes 

524 ----- 

525 The retardation factor is defined as: 

526 R(C) = 1 + (rho_b * s_max * K_L) / (n_por * (K_L + C)^2) 

527 

528 Key properties: 

529 

530 - R(0) = 1 + rho_b * s_max / (n_por * K_L) -- finite for all parameters 

531 - R -> 1 as C -> infinity (all sorption sites saturated) 

532 - R always decreases with increasing C (higher C travels faster) 

533 - Shocks form on concentration increases, rarefaction fans on decreases 

534 

535 Examples 

536 -------- 

537 >>> sorption = LangmuirSorption( 

538 ... s_max=0.1, k_l=5.0, bulk_density=1500.0, porosity=0.3 

539 ... ) 

540 >>> r = sorption.retardation(5.0) 

541 >>> c_back = sorption.concentration_from_retardation(r) 

542 >>> bool(np.isclose(c_back, 5.0)) 

543 True 

544 """ 

545 

546 s_max: float 

547 """Maximum sorption capacity [mass/mass of solid]. Positive.""" 

548 k_l: float 

549 """Half-saturation constant [mass/volume] — the C at which ``s = s_max/2``. Positive.""" 

550 bulk_density: float 

551 """Bulk density of porous medium [kg/m³]. Positive.""" 

552 porosity: float 

553 """Porosity [-]. In (0, 1).""" 

554 

555 def __post_init__(self): 

556 """Validate parameters after initialization. 

557 

558 Raises 

559 ------ 

560 ValueError 

561 If any parameter is outside its valid range: ``s_max`` <= 0, 

562 ``k_l`` <= 0, ``bulk_density`` <= 0, or ``porosity`` 

563 outside (0, 1). 

564 """ 

565 if self.s_max <= 0: 

566 msg = f"s_max must be positive, got {self.s_max}" 

567 raise ValueError(msg) 

568 if self.k_l <= 0: 

569 msg = f"k_l must be positive, got {self.k_l}" 

570 raise ValueError(msg) 

571 if self.bulk_density <= 0: 

572 msg = f"bulk_density must be positive, got {self.bulk_density}" 

573 raise ValueError(msg) 

574 if not 0 < self.porosity < 1: 

575 msg = f"porosity must be in (0, 1), got {self.porosity}" 

576 raise ValueError(msg) 

577 

578 self.a_coeff: float = self.bulk_density * self.s_max * self.k_l / self.porosity 

579 """Lumped retardation constant rho_b * s_max * K_L / n_por.""" 

580 self._ct_coefficient: float = (self.bulk_density / self.porosity) * self.s_max 

581 """Cached ``(rho_b/n_por)*s_max`` sorbed-mass coefficient (scalar + array paths).""" 

582 

583 def retardation(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

584 """``R(C) = 1 + A/(K_L + C)²`` with ``A = rho_b·s_max·K_L/n_por``. 

585 

586 See :meth:`NonlinearSorption.retardation`. ``R(0)`` is finite, R decreases with 

587 C, and ``R → 1`` as ``C → ∞`` (all sorption sites saturated). 

588 """ 

589 if not isinstance(c, np.ndarray): 

590 cf = float(c) 

591 c_eff = max(0.0, cf) 

592 return 1.0 + self.a_coeff / (self.k_l + c_eff) ** 2 

593 c_eff = np.maximum(np.asarray(c), 0.0) 

594 return 1.0 + self.a_coeff / (self.k_l + c_eff) ** 2 

595 

596 def total_concentration(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

597 """``C_T = C + (rho_b/n_por)·s_max·C/(K_L + C)``. See :meth:`NonlinearSorption.total_concentration`.""" 

598 if not isinstance(c, np.ndarray): 

599 cf = float(c) 

600 c_eff = max(0.0, cf) 

601 return cf + self._ct_coefficient * c_eff / (self.k_l + c_eff) 

602 c_arr = np.asarray(c) 

603 c_eff = np.maximum(c_arr, 0.0) 

604 return c_arr + self._ct_coefficient * c_eff / (self.k_l + c_eff) 

605 

606 def concentration_from_retardation(self, r: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

607 """``C = sqrt(A/(R - 1)) - K_L``. See :meth:`NonlinearSorption.concentration_from_retardation`. 

608 

609 Notes 

610 ----- 

611 Returns ``0.0`` for ``R <= 1`` (unphysical) and for ``R >= R(0) = 1 + A/K_L²`` 

612 (at or below zero concentration). 

613 """ 

614 if not isinstance(r, np.ndarray): 

615 r_minus_1 = float(r) - 1.0 

616 if r_minus_1 > 0.0: 

617 return max(math.sqrt(self.a_coeff / r_minus_1) - self.k_l, 0.0) 

618 return 0.0 

619 

620 r_minus_1 = np.asarray(r) - 1.0 

621 # Mask r_minus_1 to a safe placeholder before division to avoid the 

622 # RuntimeWarning emitted by np.where's eager evaluation when r == 1. 

623 safe_r_minus_1 = np.where(r_minus_1 > 0, r_minus_1, 1.0) 

624 c = np.where(r_minus_1 > 0, np.sqrt(self.a_coeff / safe_r_minus_1) - self.k_l, 0.0) 

625 return np.maximum(c, 0.0) 

626 

627 

628@dataclass 

629class BrooksCoreyConductivity(NonlinearSorption): 

630 r"""Brooks-Corey unsaturated conductivity recast as a NonlinearSorption. 

631 

632 Used by :mod:`gwtransport.percolation` to model gravity-driven percolation 

633 through a thick unsaturated zone via the Kinematic-Wave method. The 

634 closed-form conductivity curve 

635 

636 .. math:: 

637 K(\\theta) = K_s \\cdot \\Theta^a, \\qquad 

638 \\Theta = (\\theta - \\theta_r)/(\\theta_s - \\theta_r), \\qquad 

639 a = 3 + 2/\\lambda \\;(\\text{Burdine}) 

640 

641 is recast in the framework's ``(C, C_T)`` variables by identifying 

642 ``C ≡ K`` (the flux variable) and ``C_T ≡ θ - θ_r`` (the conserved 

643 storage). All three abstract methods have closed forms; ``shock_speed`` 

644 and ``check_entropy_condition`` are inherited unchanged from 

645 :class:`NonlinearSorption`. 

646 

647 See Also 

648 -------- 

649 VanGenuchtenMualemConductivity : Van Genuchten variant with brentq inversions. 

650 FreundlichSorption : Power-law sorption isotherm (closed form, analogous shape). 

651 gwtransport.percolation.root_zone_to_water_table_kinematic_wave : The public wrapper. 

652 

653 Notes 

654 ----- 

655 The retardation factor and total-concentration relation are: 

656 

657 .. math:: 

658 C_T(C) = \\Delta\\theta \\cdot (C/K_s)^{1/a}, \\qquad 

659 R(C) = (\\Delta\\theta / (a K_s)) \\cdot (C/K_s)^{1/a - 1}, 

660 

661 with ``Δθ = θ_s − θ_r``. Since ``1/a − 1 < 0`` always (``a > 3``), 

662 ``R(C) → ∞`` as ``C → 0`` (dry-soil singularity). The class clamps ``C`` 

663 to a small floor in ``retardation`` and ``concentration_from_retardation`` 

664 (the same pattern as :class:`FreundlichSorption` with ``n > 1``); 

665 ``total_concentration`` and the inherited ``shock_speed`` do **not** 

666 clamp, so the canonical wetting-front shock ``c_R = 0`` produces the 

667 correct Rankine-Hugoniot velocity. 

668 

669 Examples 

670 -------- 

671 >>> sorption = BrooksCoreyConductivity( 

672 ... theta_r=0.01, theta_s=0.337, k_s=0.174, brooks_corey_lambda=0.25 

673 ... ) 

674 >>> r = sorption.retardation(0.05) 

675 >>> c = sorption.concentration_from_retardation(r) 

676 >>> bool(np.isclose(c, 0.05, rtol=1e-13)) 

677 True 

678 """ 

679 

680 theta_r: float 

681 """Residual volumetric moisture content [-]; ``0 <= theta_r < theta_s``.""" 

682 theta_s: float 

683 """Saturated volumetric moisture content [-]; ``theta_r < theta_s < 1``. The porosity for typical soils.""" 

684 k_s: float 

685 """Saturated hydraulic conductivity [length/time]. Positive.""" 

686 brooks_corey_lambda: float 

687 """Pore-size distribution index ``λ`` [-]. Positive. 

688 

689 The exponent ``a = 3 + 2/λ`` is the Burdine pore-connectivity result. The Mualem 

690 variant (``L = 0.5``) gives ``a = 2.5 + 2/λ`` and is not implemented; a user wanting 

691 it can re-derive ``λ`` so the Burdine ``a`` matches the desired Mualem exponent.""" 

692 a: float = field(init=False) 

693 """Exponent ``a = 3 + 2/λ`` (Burdine); set in ``__post_init__``.""" 

694 delta_theta: float = field(init=False) 

695 """``θ_s − θ_r``; set in ``__post_init__``.""" 

696 _inv_a: float = field(init=False, repr=False, compare=False) 

697 """Cached ``1/a`` total-concentration exponent (scalar + array paths).""" 

698 _ret_coefficient: float = field(init=False, repr=False, compare=False) 

699 """Cached ``Δθ/(a·K_s)`` retardation coefficient.""" 

700 _ret_exponent: float = field(init=False, repr=False, compare=False) 

701 """Cached ``1/a − 1`` retardation exponent.""" 

702 _cfr_exponent: float = field(init=False, repr=False, compare=False) 

703 """Cached ``−a/(a−1)`` inversion exponent for concentration_from_retardation.""" 

704 

705 def __post_init__(self) -> None: 

706 """Validate parameters and derive ``a``, ``delta_theta``. 

707 

708 Raises 

709 ------ 

710 ValueError 

711 If any parameter is outside its valid range. 

712 """ 

713 if not 0.0 <= self.theta_r < self.theta_s: 

714 msg = f"theta_r must satisfy 0 <= theta_r < theta_s, got theta_r={self.theta_r}, theta_s={self.theta_s}" 

715 raise ValueError(msg) 

716 if not self.theta_s < 1.0: 

717 msg = f"theta_s must be < 1, got {self.theta_s}" 

718 raise ValueError(msg) 

719 if self.k_s <= 0.0: 

720 msg = f"k_s must be positive, got {self.k_s}" 

721 raise ValueError(msg) 

722 if self.brooks_corey_lambda <= 0.0: 

723 msg = f"brooks_corey_lambda must be positive, got {self.brooks_corey_lambda}" 

724 raise ValueError(msg) 

725 self.a = 3.0 + 2.0 / self.brooks_corey_lambda 

726 self.delta_theta = self.theta_s - self.theta_r 

727 self._inv_a = 1.0 / self.a 

728 self._ret_coefficient = self.delta_theta / (self.a * self.k_s) 

729 self._ret_exponent = 1.0 / self.a - 1.0 

730 self._cfr_exponent = -self.a / (self.a - 1.0) 

731 

732 def total_concentration(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

733 """``C_T(C) = Δθ · (C/K_s)^(1/a)``. Returns 0 at C=0 (no clamp).""" 

734 if not isinstance(c, np.ndarray): 

735 cf = float(c) 

736 c_eff = max(0.0, cf) 

737 return self.delta_theta * (c_eff / self.k_s) ** self._inv_a 

738 c_arr = np.maximum(np.asarray(c, dtype=float), 0.0) 

739 return self.delta_theta * (c_arr / self.k_s) ** self._inv_a 

740 

741 def retardation(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

742 """``R(C) = (Δθ / (a·K_s)) · (C/K_s)^(1/a − 1)``. Clamped at ``_C_MIN``.""" 

743 if not isinstance(c, np.ndarray): 

744 cf = float(c) 

745 c_eff = max(_C_MIN, cf) 

746 return self._ret_coefficient * (c_eff / self.k_s) ** self._ret_exponent 

747 c_eff = np.maximum(np.asarray(c, dtype=float), _C_MIN) 

748 return self._ret_coefficient * (c_eff / self.k_s) ** self._ret_exponent 

749 

750 def concentration_from_retardation(self, r: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

751 """``C = K_s · (R · a · K_s / Δθ)^{−a/(a−1)}``. Result clamped at ``_C_MIN``.""" 

752 if not isinstance(r, np.ndarray): 

753 base = float(r) * self.a * self.k_s / self.delta_theta 

754 if base > 0.0: 

755 return max(self.k_s * base**self._cfr_exponent, _C_MIN) 

756 return _C_MIN 

757 base = np.asarray(r, dtype=float) * self.a * self.k_s / self.delta_theta 

758 safe_base = np.where(base > 0, base, 1.0) 

759 ratio = safe_base**self._cfr_exponent 

760 return np.where(base > 0, np.maximum(self.k_s * ratio, _C_MIN), _C_MIN) 

761 

762 

763@dataclass 

764class VanGenuchtenMualemConductivity(NonlinearSorption): 

765 r"""Mualem prediction for the van Genuchten retention curve, recast as NonlinearSorption. 

766 

767 Used by :mod:`gwtransport.percolation` for Kinematic-Wave percolation 

768 with the standard Mualem-van Genuchten conductivity curve 

769 

770 .. math:: 

771 K(\\theta) = K_s \\cdot S_e^L \\cdot 

772 \\left[1 - \\left(1 - S_e^{1/m}\\right)^m\\right]^2, \\qquad 

773 S_e = (\\theta - \\theta_r)/(\\theta_s - \\theta_r), \\qquad 

774 m = 1 - 1/n_\\text{vG}. 

775 

776 The retention parameter ``α_vG`` is *not* needed for ``K(θ)`` — the 

777 Kinematic-Wave approximation drops capillary suction, so only the 

778 ``K(S_e)`` curve matters. The two inversions ``S_e(C)`` and 

779 ``S_e(R)`` have no closed form; both use ``scipy.optimize.brentq`` 

780 with ``xtol = BRENTQ_XTOL = 1e-14``. 

781 

782 See Also 

783 -------- 

784 BrooksCoreyConductivity : Brooks-Corey closed-form variant. 

785 gwtransport.percolation.root_zone_to_water_table_kinematic_wave : The public wrapper. 

786 

787 Notes 

788 ----- 

789 The closed-form derivative is 

790 

791 .. math:: 

792 \\frac{dK_M}{dS_e} = K_s \\cdot S_e^{L-1} \\cdot U \\cdot 

793 \\left[L \\cdot U + 2 \\cdot S_e^{1/m} \\cdot T^{m-1}\\right], 

794 

795 with ``T = 1 - S_e^{1/m}`` and ``U = 1 - T^m``. It is evaluated by 

796 ``_dk_dse`` and used for ``retardation(C)`` (after solving ``S_e(C)``) 

797 and as the brentq objective in ``_se_from_retardation``. 

798 

799 ``dK_M/dS_e`` is strictly increasing for every ``n_vG > 1`` and 

800 ``L ≥ 0`` (the conductivity flux is convex, ``d²K/dS_e² > 0``; proved at 

801 200-digit precision in ``docs/theory/front_tracking_interactions.md`` §2), 

802 so the brentq inversions are always well-posed — no monotonicity guard is 

803 needed. A convex flux also means the transport admits only shocks and 

804 rarefactions, never compound waves. 

805 

806 Examples 

807 -------- 

808 >>> sorption = VanGenuchtenMualemConductivity( 

809 ... theta_r=0.01, theta_s=0.337, k_s=0.174, van_genuchten_n=2.28 

810 ... ) 

811 >>> r = sorption.retardation(0.05) 

812 >>> c = sorption.concentration_from_retardation(r) 

813 >>> bool(np.isclose(c, 0.05, rtol=1e-12)) 

814 True 

815 """ 

816 

817 theta_r: float 

818 """Residual volumetric moisture content [-]; ``0 <= theta_r < theta_s``.""" 

819 theta_s: float 

820 """Saturated volumetric moisture content [-]; ``theta_r < theta_s < 1``.""" 

821 k_s: float 

822 """Saturated hydraulic conductivity [length/time]. Positive.""" 

823 van_genuchten_n: float 

824 """vG shape parameter ``n_vG > 1``; ``m = 1 − 1/n_vG`` is derived from it.""" 

825 mualem_l: float = 0.5 

826 """Mualem pore-connectivity ``L >= 0``. Default 0.5 (standard Mualem). 

827 

828 ``L = 0`` (Burdine variant) gives a closed-form ``S_e(C)`` inverse; ``L != 0`` 

829 requires ``brentq``.""" 

830 m: float = field(init=False) 

831 """Derived ``m = 1 − 1/n_vG``; set in ``__post_init__``.""" 

832 delta_theta: float = field(init=False) 

833 """``θ_s − θ_r``; set in ``__post_init__``.""" 

834 

835 def __post_init__(self) -> None: 

836 """Validate parameters and derive ``m``, ``delta_theta``. 

837 

838 No convexity/monotonicity guard is needed: ``dK_M/dS_e`` is strictly 

839 increasing (the flux ``K(S_e)`` is convex, ``d²K/dS_e² > 0``) for every 

840 ``n_vG > 1`` and ``L ≥ 0`` — proved at 200-digit precision in 

841 ``docs/theory/front_tracking_interactions.md`` §2 — so the brentq 

842 inversions are always well-posed. 

843 

844 Raises 

845 ------ 

846 ValueError 

847 If any parameter is outside its valid range. 

848 """ 

849 if not 0.0 <= self.theta_r < self.theta_s: 

850 msg = f"theta_r must satisfy 0 <= theta_r < theta_s, got theta_r={self.theta_r}, theta_s={self.theta_s}" 

851 raise ValueError(msg) 

852 if not self.theta_s < 1.0: 

853 msg = f"theta_s must be < 1, got {self.theta_s}" 

854 raise ValueError(msg) 

855 if self.k_s <= 0.0: 

856 msg = f"k_s must be positive, got {self.k_s}" 

857 raise ValueError(msg) 

858 if self.van_genuchten_n <= 1.0: 

859 msg = f"van_genuchten_n must be > 1, got {self.van_genuchten_n}" 

860 raise ValueError(msg) 

861 if self.mualem_l < 0.0: 

862 msg = f"mualem_l must be >= 0, got {self.mualem_l}" 

863 raise ValueError(msg) 

864 self.m = 1.0 - 1.0 / self.van_genuchten_n 

865 self.delta_theta = self.theta_s - self.theta_r 

866 

867 def _k_se(self, s: float) -> float: 

868 """``K_M(S_e)`` evaluated at a scalar ``S_e``. Returns 0 at ``S_e = 0``.""" 

869 if s <= 0.0: 

870 return 0.0 

871 if s >= 1.0: 

872 return self.k_s 

873 t = 1.0 - s ** (1.0 / self.m) 

874 u = 1.0 - t**self.m 

875 return self.k_s * s**self.mualem_l * u * u 

876 

877 def _dk_dse(self, s: float) -> float: 

878 """Closed-form ``dK_M/dS_e`` at scalar ``S_e``. 

879 

880 At ``s → 1`` (saturation), ``dK/dS_e`` diverges because ``t^(m-1) → ∞`` 

881 for ``m < 1``. The function returns ``+∞`` at and above ``s = 1`` so that 

882 ``brentq`` can use ``s = 1`` as a closed upper bracket endpoint. 

883 """ 

884 if s <= 0.0: 

885 # Limit form: K vanishes as S^(L + 2/m), so derivative is 0 at S=0. 

886 return 0.0 

887 s_pow_inv_m = s ** (1.0 / self.m) 

888 t = 1.0 - s_pow_inv_m 

889 if t <= 0.0: 

890 # Numerical underflow or s ≥ 1 — the dK/dS_e singularity at saturation. 

891 return float("inf") 

892 u = 1.0 - t**self.m 

893 return self.k_s * s ** (self.mualem_l - 1.0) * u * (self.mualem_l * u + 2.0 * s_pow_inv_m * t ** (self.m - 1.0)) 

894 

895 def _se_from_c(self, c: float) -> float: 

896 """Invert ``K_M(S_e) = c`` for ``S_e``. Closed form for ``mualem_l = 0``; brentq otherwise. 

897 

898 For the Burdine variant (``L = 0``), ``K_M(S_e) = K_s · [1 − (1 − S_e^{1/m})^m]^2`` 

899 is invertible as ``S_e = (1 − (1 − √(K/K_s))^{1/m})^m`` — completely closed 

900 form. For ``L ≠ 0`` (default Mualem ``L = 0.5``), no closed-form inverse 

901 exists; ``scipy.optimize.brentq`` with ``xtol = BRENTQ_XTOL = 1e-14`` is 

902 used. The brentq call is unavoidable in the Mualem case because the 

903 ``K_M(S_e)`` function is transcendental. 

904 """ 

905 c_eff = max(float(c), _C_MIN) 

906 if c_eff >= self.k_s: 

907 return 1.0 

908 if self.mualem_l == 0.0: 

909 u = (c_eff / self.k_s) ** 0.5 # U = 1 − (1−S_e^{1/m})^m 

910 one_minus_u = 1.0 - u 

911 one_minus_q_to_inv_m = one_minus_u ** (1.0 / self.m) 

912 q = 1.0 - one_minus_q_to_inv_m 

913 return float(q**self.m) 

914 return float(brentq(lambda s: self._k_se(s) - c_eff, _C_MIN, 1.0, xtol=BRENTQ_XTOL)) # type: ignore[arg-type] 

915 

916 def total_concentration(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

917 """``C_T = Δθ · S_e(C)``. Returns 0 at C=0 (no clamp).""" 

918 is_array = isinstance(c, np.ndarray) 

919 c_arr = np.maximum(np.asarray(c, dtype=float), 0.0) 

920 flat = c_arr.ravel() 

921 se = np.fromiter( 

922 (self._se_from_c(ci) if ci > 0.0 else 0.0 for ci in flat), dtype=float, count=flat.size 

923 ).reshape(c_arr.shape) 

924 result = self.delta_theta * se 

925 return result if is_array else float(result) 

926 

927 def retardation(self, c: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

928 """``R = Δθ / (dK_M/dS_e)|_{S_e(C)}`` via ``_dk_dse``; clamps C at ``_C_MIN``.""" 

929 is_array = isinstance(c, np.ndarray) 

930 c_arr = np.maximum(np.asarray(c, dtype=float), _C_MIN) 

931 flat = c_arr.ravel() 

932 out = np.empty(flat.size, dtype=float) 

933 for i, ci in enumerate(flat): 

934 s = self._se_from_c(ci) 

935 out[i] = self.delta_theta / self._dk_dse(s) 

936 result = out.reshape(c_arr.shape) 

937 return result if is_array else float(result) 

938 

939 def concentration_from_retardation(self, r: float | npt.NDArray[np.float64]) -> float | npt.NDArray[np.float64]: 

940 """Invert ``R(C) = r``. Solve ``dK_M/dS_e(S_e) = Δθ/r`` via brentq, then ``C = K_M(S_e)``.""" 

941 is_array = isinstance(r, np.ndarray) 

942 r_arr = np.asarray(r, dtype=float) 

943 flat = r_arr.ravel() 

944 out = np.empty(flat.size, dtype=float) 

945 for i, ri in enumerate(flat): 

946 s = self._se_from_retardation(float(ri)) 

947 out[i] = max(self._k_se(s), _C_MIN) 

948 result = out.reshape(r_arr.shape) 

949 return result if is_array else float(result) 

950 

951 def _se_from_retardation(self, r: float) -> float: 

952 """Invert ``dK_M/dS_e(S_e) = Δθ/r`` for ``S_e`` via brentq. 

953 

954 Single root-find for vG-Mualem; shared by ``concentration_from_retardation`` 

955 and ``c_and_total_from_retardation`` to avoid duplicate brentq calls. 

956 """ 

957 if r <= 0.0: 

958 return _C_MIN 

959 target = self.delta_theta / r 

960 try: 

961 return float(brentq(lambda s, tgt=target: self._dk_dse(s) - tgt, _C_MIN, 1.0, xtol=BRENTQ_XTOL)) # type: ignore[arg-type] 

962 except ValueError: 

963 return _C_MIN 

964 

965 def c_and_total_from_retardation(self, r: float) -> tuple[float, float]: 

966 """Return ``(c, C_T)`` at retardation ``r`` from a SINGLE brentq call. 

967 

968 Overrides the default base-class implementation (which calls 

969 ``concentration_from_retardation`` and ``total_concentration`` 

970 separately and ends up doing two independent brentq solves on the same 

971 underlying equation). Halves the iterative-solver cost in the IBP fan 

972 integrators. 

973 """ 

974 s = self._se_from_retardation(r) 

975 c = max(self._k_se(s), _C_MIN) 

976 ct = self.delta_theta * s 

977 return c, ct 

978 

979 

980SorptionModel = NonlinearSorption | ConstantRetardation 

981"""Type alias for all sorption models accepted by the front-tracking solver.""" 

982 

983 

984def characteristic_speed(c: float, sorption: SorptionModel) -> float: 

985 """Compute characteristic speed dV/dθ = 1/R(C). 

986 

987 In (V, θ) coordinates, every characteristic propagates at a flow-free 

988 speed determined solely by the local concentration and the sorption 

989 isotherm. 

990 

991 Parameters 

992 ---------- 

993 c : float 

994 Dissolved concentration [mass/volume]. 

995 sorption : SorptionModel 

996 Sorption model. 

997 

998 Returns 

999 ------- 

1000 speed : float 

1001 Characteristic speed dV/dθ. 

1002 

1003 Examples 

1004 -------- 

1005 >>> sorption = FreundlichSorption( 

1006 ... k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 

1007 ... ) 

1008 >>> s = characteristic_speed(c=5.0, sorption=sorption) 

1009 >>> s > 0 

1010 True 

1011 """ 

1012 r = float(sorption.retardation(c)) 

1013 return float("inf") if r == 0.0 else 1.0 / r 

1014 

1015 

1016def characteristic_position( 

1017 c: float, 

1018 sorption: SorptionModel, 

1019 theta_start: float, 

1020 v_start: float, 

1021 theta: float, 

1022) -> float | None: 

1023 """Compute position of a characteristic at cumulative flow θ. 

1024 

1025 Characteristics propagate linearly in θ:: 

1026 

1027 V(θ) = v_start + characteristic_speed(C) * (θ - θ_start) 

1028 

1029 Parameters 

1030 ---------- 

1031 c : float 

1032 Concentration carried by characteristic [mass/volume]. 

1033 sorption : SorptionModel 

1034 Sorption model. 

1035 theta_start : float 

1036 Cumulative flow at which the characteristic starts [m³]. 

1037 v_start : float 

1038 Starting position [m³]. 

1039 theta : float 

1040 Cumulative flow at which to evaluate position [m³]. 

1041 

1042 Returns 

1043 ------- 

1044 position : float or None 

1045 Position at θ [m³], or None if θ < θ_start. 

1046 

1047 Examples 

1048 -------- 

1049 >>> sorption = ConstantRetardation(retardation_factor=2.0) 

1050 >>> v = characteristic_position( 

1051 ... c=5.0, sorption=sorption, theta_start=0.0, v_start=0.0, theta=1000.0 

1052 ... ) 

1053 >>> bool(np.isclose(v, 500.0)) # v = (1/2) * 1000 = 500 

1054 True 

1055 """ 

1056 if theta < theta_start: 

1057 return None 

1058 

1059 return v_start + characteristic_speed(c, sorption) * (theta - theta_start) 

1060 

1061 

1062def compute_first_front_arrival_theta( 

1063 cin: npt.NDArray[np.floating], 

1064 theta_edges: npt.NDArray[np.floating], 

1065 aquifer_pore_volume: float, 

1066 sorption: SorptionModel, 

1067) -> float: 

1068 """Cumulative-flow θ at which ``c_first`` arrives at the outlet (end of spin-up). 

1069 

1070 "Arrival" means the θ at which the ``c_first`` *level* is fully present at 

1071 the outlet, ``θ_emit + V·R(c_first)`` for ``n<1`` and 

1072 ``θ_emit + V·C_T(c_first)/c_first`` for ``n>1``/constant retardation. 

1073 

1074 .. warning:: 

1075 

1076 For ``n<1`` with ``c_min > 0`` (default ``c_min = 1e-12`` in 

1077 :class:`FreundlichSorption`), the actual wave emitted is a 

1078 :class:`~gwtransport.fronttracking.waves.RarefactionWave` whose head (``c = c_min ≈ 0``) reaches the 

1079 outlet at θ ≈ ``V·R(c_min) ≈ V`` — *much* earlier than the value this 

1080 function returns (which is the *tail* arrival ``V·R(c_first)``). 

1081 The function returns "tail arrival" semantics: the returned θ is a 

1082 conservative end-of-spin-up where c ≤ c_first everywhere before it. 

1083 Consult the solver event log for the true rarefaction head crossing. 

1084 

1085 Parameters 

1086 ---------- 

1087 cin : numpy.ndarray 

1088 Inlet concentration [mass/volume]. 

1089 theta_edges : numpy.ndarray 

1090 Cumulative-flow edges; length ``len(cin) + 1``. 

1091 aquifer_pore_volume : float 

1092 Total pore volume [m³]. Must be positive. 

1093 sorption : SorptionModel 

1094 Sorption model. 

1095 

1096 Returns 

1097 ------- 

1098 theta_first_arrival : float 

1099 Cumulative-flow θ at which ``c_first`` is fully present at the outlet 

1100 [m³]. Returns ``np.inf`` only if no water-carrying (positive θ-width) 

1101 bin has nonzero ``cin``. 

1102 

1103 Examples 

1104 -------- 

1105 >>> cin = np.array([0.0, 10.0] + [10.0] * 10) 

1106 >>> theta_edges = np.arange(0.0, 1300.0, 100.0) # constant flow=100, dt=1 

1107 >>> sorption = ConstantRetardation(retardation_factor=2.0) 

1108 >>> theta_first = compute_first_front_arrival_theta( 

1109 ... cin, theta_edges, 500.0, sorption 

1110 ... ) 

1111 >>> bool(np.isclose(theta_first, 100.0 + 500.0 * 2.0)) # θ_emit + V·R 

1112 True 

1113 """ 

1114 # Zero-flow (pump-off) bins have zero θ-width and emit no wave (the solver 

1115 # skips them), so they cannot be the first arrival either. 

1116 nonzero_indices = np.where((cin > 0) & (np.diff(theta_edges) > 0))[0] 

1117 if len(nonzero_indices) == 0: 

1118 return float(np.inf) 

1119 

1120 idx_first = int(nonzero_indices[0]) 

1121 c_first = float(cin[idx_first]) 

1122 

1123 if isinstance(sorption, FreundlichSorption) and sorption.n < 1.0: 

1124 # n<1: the 0→c_first step emits a rarefaction; its tail (c=c_first) 

1125 # reaches the outlet after V·R(c_first) units of cumulative flow. 

1126 target_volume = aquifer_pore_volume * float(sorption.retardation(c_first)) 

1127 else: 

1128 # n>1 or constant: R-H shock with speed = c / (C_T(c) - C_T(0)); 

1129 # target volume = V · C_T(c_first) / c_first. 

1130 target_volume = aquifer_pore_volume * float(sorption.total_concentration(c_first)) / c_first 

1131 

1132 return float(theta_edges[idx_first]) + target_volume