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

318 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +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 

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

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

20""" 

21 

22from abc import ABC, abstractmethod 

23from dataclasses import dataclass, field 

24 

25import numpy as np 

26import numpy.typing as npt 

27from scipy.optimize import brentq 

28 

29# Numerical tolerance constants 

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

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

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

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

34 

35 

36class NonlinearSorption(ABC): 

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

38 

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

40 `concentration_from_retardation`. Shock velocity and entropy checking 

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

42 

43 See Also 

44 -------- 

45 FreundlichSorption : Freundlich isotherm implementation. 

46 LangmuirSorption : Langmuir isotherm implementation. 

47 ConstantRetardation : Linear (constant R) retardation model. 

48 """ 

49 

50 @abstractmethod 

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

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

53 

54 @abstractmethod 

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

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

57 

58 @abstractmethod 

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

60 """Invert retardation factor to obtain concentration.""" 

61 

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

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

64 

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

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

67 Rankine-Hugoniot reduces to:: 

68 

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

70 

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

72 isotherm alone. 

73 

74 Parameters 

75 ---------- 

76 c_left : float 

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

78 c_right : float 

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

80 

81 Returns 

82 ------- 

83 shock_speed : float 

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

85 """ 

86 c_total_left = self.total_concentration(c_left) 

87 c_total_right = self.total_concentration(c_right) 

88 denom = c_total_right - c_total_left 

89 

90 if abs(denom) < EPSILON_DENOMINATOR: 

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

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

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

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

95 

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

97 

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

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

100 

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

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

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

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

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

106 integrators. 

107 """ 

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

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

110 return c, ct 

111 

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

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

114 

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

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

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

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

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

120 """ 

121 return True 

122 

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

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

125 

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

127 Lax condition for a physical shock is:: 

128 

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

130 

131 Parameters 

132 ---------- 

133 c_left : float 

134 Concentration upstream of shock [mass/volume]. 

135 c_right : float 

136 Concentration downstream of shock [mass/volume]. 

137 shock_speed : float 

138 Shock speed dV/dθ. 

139 

140 Returns 

141 ------- 

142 satisfies : bool 

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

144 """ 

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

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

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

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

149 

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

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

152 # Lax test itself is ill-posed. 

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

154 return False 

155 

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

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

158 

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

160 

161 

162@dataclass 

163class FreundlichSorption(NonlinearSorption): 

164 """ 

165 Freundlich sorption isotherm with exact analytical methods. 

166 

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

168 

169 where: 

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

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

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

173 - n is Freundlich exponent (dimensionless) 

174 

175 For n > 1: Higher C travels faster 

176 For n < 1: Higher C travels slower 

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

178 

179 Parameters 

180 ---------- 

181 k_f : float 

182 Freundlich coefficient [(m³/kg)^(1/n)]. Must be positive. 

183 n : float 

184 Freundlich exponent [-]. Must be positive and != 1. 

185 bulk_density : float 

186 Bulk density of porous medium [kg/m³]. Must be positive. 

187 porosity : float 

188 Porosity [-]. Must be in (0, 1). 

189 c_min : float, optional 

190 Minimum concentration threshold (the dry-soil singularity floor). For 

191 n>1, prevents infinite retardation as C→0. Default ``1e-12`` for all n. 

192 

193 Notes 

194 ----- 

195 The retardation factor is defined as: 

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

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

198 

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

200 

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

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

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

204 

205 Examples 

206 -------- 

207 >>> sorption = FreundlichSorption( 

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

209 ... ) 

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

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

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

213 True 

214 """ 

215 

216 k_f: float 

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

218 n: float 

219 """Freundlich exponent [-].""" 

220 bulk_density: float 

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

222 porosity: float 

223 """Porosity [-].""" 

224 c_min: float = 1e-12 

225 """Minimum concentration threshold to prevent infinite retardation.""" 

226 

227 def __post_init__(self): 

228 """Validate parameters after initialization. 

229 

230 Raises 

231 ------ 

232 ValueError 

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

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

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

236 """ 

237 if self.k_f <= 0: 

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

239 raise ValueError(msg) 

240 if self.n <= 0: 

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

242 raise ValueError(msg) 

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

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

245 raise ValueError(msg) 

246 if self.bulk_density <= 0: 

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

248 raise ValueError(msg) 

249 if not 0 < self.porosity < 1: 

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

251 raise ValueError(msg) 

252 if self.c_min < 0: 

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

254 raise ValueError(msg) 

255 

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

257 """ 

258 Compute retardation factor R(C). 

259 

260 The retardation factor relates concentration speed to pore water speed in 

261 (V, θ) coordinates:: 

262 

263 dV/dθ = 1 / R(C) 

264 

265 For Freundlich sorption:: 

266 

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

268 

269 Parameters 

270 ---------- 

271 c : float or array-like 

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

273 

274 Returns 

275 ------- 

276 r : float or numpy.ndarray 

277 Retardation factor [-]. Always >= 1.0. 

278 

279 Notes 

280 ----- 

281 - For n > 1: R decreases with increasing C (higher C travels faster) 

282 - For n < 1: R increases with increasing C (higher C travels slower) 

283 - n<1 with c_min=0: R(0)=1 (no sorption at zero, physically correct) 

284 because clamping to ``c_min=0`` leaves ``C^((1/n)-1) = 0^positive = 0``. 

285 - Otherwise: ``c`` is clamped to ``c_min`` before evaluation. This pairs with 

286 :meth:`total_concentration`, which also clamps to ``c_min``. 

287 

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

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

290 fractional power on negative ``c``. 

291 """ 

292 is_array = isinstance(c, np.ndarray) 

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

294 exponent = (1.0 / self.n) - 1.0 

295 coefficient = (self.bulk_density * self.k_f) / (self.porosity * self.n) 

296 result = 1.0 + coefficient * (c_eff**exponent) 

297 return result if is_array else float(result) 

298 

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

300 """ 

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

302 

303 Total concentration includes both dissolved and sorbed mass: 

304 C_total = C + (rho_b/n_por) * s(C) 

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

306 

307 Parameters 

308 ---------- 

309 c : float or array-like 

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

311 

312 Returns 

313 ------- 

314 c_total : float or numpy.ndarray 

315 Total concentration [mass/volume]. Always >= c. 

316 

317 Notes 

318 ----- 

319 This is the conserved quantity in the transport equation: 

320 ∂C_total/∂t + ∂(flow*C)/∂v = 0 

321 

322 The flux term only includes dissolved concentration because sorbed mass 

323 is immobile. 

324 

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

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

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

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

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

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

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

332 """ 

333 is_array = isinstance(c, np.ndarray) 

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

335 sorbed = (self.bulk_density / self.porosity) * self.k_f * (c_arr ** (1.0 / self.n)) 

336 result = c_arr + sorbed 

337 return result if is_array else float(result) 

338 

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

340 """ 

341 Invert retardation factor to obtain concentration analytically. 

342 

343 Given R, solves R = retardation(C) for C. This is used in rarefaction waves 

344 where the self-similar solution gives R as a function of position and time. 

345 

346 Parameters 

347 ---------- 

348 r : float or array-like 

349 Retardation factor [-]. Must be >= 1.0. 

350 

351 Returns 

352 ------- 

353 c : float or numpy.ndarray 

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

355 

356 Notes 

357 ----- 

358 This inverts the relation: 

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

360 

361 The analytical solution is: 

362 C = [(R-1) * n_por*n / (rho_b*k_f)]^(n/(1-n)) 

363 

364 For n = 1 (linear sorption), the exponent n/(1-n) is undefined, which is 

365 why linear sorption must use ConstantRetardation class instead. 

366 

367 Examples 

368 -------- 

369 >>> sorption = FreundlichSorption( 

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

371 ... ) 

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

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

374 >>> bool(np.isclose(c, 5.0, rtol=1e-14)) 

375 True 

376 """ 

377 is_array = isinstance(r, np.ndarray) 

378 r_arr = np.asarray(r) 

379 

380 # FreundlichSorption.__post_init__ rejects |n-1| < EPSILON_FREUNDLICH_N, 

381 # so the previous n≈1 guard here was unreachable. 

382 exponent = (1.0 / self.n) - 1.0 

383 coefficient = (self.bulk_density * self.k_f) / (self.porosity * self.n) 

384 base = (r_arr - 1.0) / coefficient 

385 inversion_exponent = 1.0 / exponent 

386 

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

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

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

390 c = safe_base**inversion_exponent 

391 result = np.where(base > 0, np.maximum(c, self.c_min), self.c_min) 

392 

393 return result if is_array else float(result) 

394 

395 def fan_converges_at_infinity(self) -> bool: 

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

397 return self.n > 1.0 

398 

399 

400@dataclass 

401class ConstantRetardation: 

402 """ 

403 Constant (linear) retardation model. 

404 

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

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

407 

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

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

410 

411 Parameters 

412 ---------- 

413 retardation_factor : float 

414 Constant retardation factor [-]. Must be >= 1.0. 

415 R = 1.0 means no retardation (conservative tracer). 

416 

417 Notes 

418 ----- 

419 With constant retardation: 

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

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

422 - Shocks occur only at concentration discontinuities at inlet 

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

424 

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

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

427 

428 Examples 

429 -------- 

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

431 >>> sorption.retardation(5.0) 

432 2.0 

433 >>> sorption.retardation(10.0) 

434 2.0 

435 """ 

436 

437 retardation_factor: float 

438 """Constant retardation factor [-]. Must be >= 1.0.""" 

439 

440 def __post_init__(self): 

441 """Validate parameters after initialization. 

442 

443 Raises 

444 ------ 

445 ValueError 

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

447 """ 

448 if self.retardation_factor < 1.0: 

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

450 raise ValueError(msg) 

451 

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

453 """ 

454 Return constant retardation factor (independent of concentration). 

455 

456 Parameters 

457 ---------- 

458 c : float 

459 Dissolved concentration (not used for constant retardation). 

460 

461 Returns 

462 ------- 

463 r : float 

464 Constant retardation factor. 

465 """ 

466 return self.retardation_factor 

467 

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

469 """ 

470 Compute total concentration for linear sorption. 

471 

472 For constant retardation: 

473 C_total = C * R 

474 

475 Parameters 

476 ---------- 

477 c : float 

478 Dissolved concentration [mass/volume]. 

479 

480 Returns 

481 ------- 

482 c_total : float 

483 Total concentration [mass/volume]. 

484 """ 

485 return c * self.retardation_factor 

486 

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

488 """ 

489 Not applicable for constant retardation. 

490 

491 With constant R, all concentrations have the same retardation, so 

492 inversion is not meaningful. This method raises an error. 

493 

494 Raises 

495 ------ 

496 NotImplementedError 

497 Always raised for constant retardation. 

498 """ 

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

500 raise NotImplementedError(msg) 

501 

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

503 """Compute shock speed dV/dθ for constant retardation. 

504 

505 With constant R, ``dV/dθ = 1 / R`` for any concentration pair — 

506 identical to every characteristic speed. 

507 

508 Parameters 

509 ---------- 

510 c_left, c_right : float 

511 Concentrations (unused — kept for ABC compatibility). 

512 

513 Returns 

514 ------- 

515 shock_speed : float 

516 Shock speed dV/dθ = 1/R. 

517 """ 

518 return 1.0 / self.retardation_factor 

519 

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

521 """Entropy condition for constant retardation: trivially satisfied. 

522 

523 With constant R every characteristic speed equals the shock speed in 

524 θ-space, so the Lax condition holds as an equality regardless of 

525 ``c_left``/``c_right``. 

526 

527 Returns 

528 ------- 

529 satisfies : bool 

530 Always True. 

531 """ 

532 del c_left, c_right, shock_speed 

533 return True 

534 

535 

536@dataclass 

537class LangmuirSorption(NonlinearSorption): 

538 """ 

539 Langmuir sorption isotherm with exact analytical methods. 

540 

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

542 

543 where: 

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

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

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

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

548 

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

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

551 is needed. 

552 

553 Parameters 

554 ---------- 

555 s_max : float 

556 Maximum sorption capacity [mass/mass of solid]. Must be positive. 

557 k_l : float 

558 Half-saturation constant [mass/volume]. Concentration at which 

559 s = s_max / 2. Must be positive. 

560 bulk_density : float 

561 Bulk density of porous medium [kg/m³]. Must be positive. 

562 porosity : float 

563 Porosity [-]. Must be in (0, 1). 

564 

565 See Also 

566 -------- 

567 FreundlichSorption : Freundlich isotherm (unbounded sorption). 

568 ConstantRetardation : Linear (constant R) retardation model. 

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

570 

571 Notes 

572 ----- 

573 The retardation factor is defined as: 

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

575 

576 Key properties: 

577 

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

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

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

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

582 

583 Examples 

584 -------- 

585 >>> sorption = LangmuirSorption( 

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

587 ... ) 

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

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

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

591 True 

592 """ 

593 

594 s_max: float 

595 """Maximum sorption capacity [mass/mass of solid].""" 

596 k_l: float 

597 """Half-saturation constant [mass/volume].""" 

598 bulk_density: float 

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

600 porosity: float 

601 """Porosity [-].""" 

602 

603 def __post_init__(self): 

604 """Validate parameters after initialization. 

605 

606 Raises 

607 ------ 

608 ValueError 

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

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

611 outside (0, 1). 

612 """ 

613 if self.s_max <= 0: 

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

615 raise ValueError(msg) 

616 if self.k_l <= 0: 

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

618 raise ValueError(msg) 

619 if self.bulk_density <= 0: 

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

621 raise ValueError(msg) 

622 if not 0 < self.porosity < 1: 

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

624 raise ValueError(msg) 

625 

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

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

628 

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

630 """ 

631 Compute retardation factor R(C). 

632 

633 For Langmuir sorption: 

634 R(C) = 1 + A / (K_L + C)² 

635 

636 where A = rho_b * s_max * K_L / n_por. 

637 

638 Parameters 

639 ---------- 

640 c : float or array-like 

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

642 

643 Returns 

644 ------- 

645 r : float or numpy.ndarray 

646 Retardation factor [-]. Always >= 1.0. 

647 

648 Notes 

649 ----- 

650 - R(0) = 1 + rho_b * s_max / (n_por * K_L) — always finite 

651 - R decreases with increasing C (higher C travels faster) 

652 - R → 1 as C → ∞ (all sorption sites saturated) 

653 """ 

654 is_array = isinstance(c, np.ndarray) 

655 c_arr = np.asarray(c) 

656 c_eff = np.maximum(c_arr, 0.0) 

657 result = 1.0 + self.a_coeff / (self.k_l + c_eff) ** 2 

658 return result if is_array else float(result) 

659 

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

661 """ 

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

663 

664 For Langmuir sorption: 

665 C_total = C + (rho_b / n_por) * s_max * C / (K_L + C) 

666 

667 Parameters 

668 ---------- 

669 c : float or array-like 

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

671 

672 Returns 

673 ------- 

674 c_total : float or numpy.ndarray 

675 Total concentration [mass/volume]. Always >= c. 

676 """ 

677 is_array = isinstance(c, np.ndarray) 

678 c_arr = np.asarray(c) 

679 c_eff = np.maximum(c_arr, 0.0) 

680 sorbed = (self.bulk_density / self.porosity) * self.s_max * c_eff / (self.k_l + c_eff) 

681 result = c_arr + sorbed 

682 return result if is_array else float(result) 

683 

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

685 """ 

686 Invert retardation factor to obtain concentration analytically. 

687 

688 Given R, solves R = 1 + A / (K_L + C)² for C: 

689 C = sqrt(A / (R - 1)) - K_L 

690 

691 Parameters 

692 ---------- 

693 r : float or array-like 

694 Retardation factor [-]. Must be >= 1.0. 

695 

696 Returns 

697 ------- 

698 c : float or numpy.ndarray 

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

700 

701 Notes 

702 ----- 

703 For R <= 1, returns 0.0 (unphysical region). 

704 For R >= R(0) = 1 + A/K_L², returns 0.0 (at or below zero concentration). 

705 

706 Examples 

707 -------- 

708 >>> sorption = LangmuirSorption( 

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

710 ... ) 

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

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

713 >>> bool(np.isclose(c, 5.0, rtol=1e-14)) 

714 True 

715 """ 

716 is_array = isinstance(r, np.ndarray) 

717 r_arr = np.asarray(r) 

718 

719 r_minus_1 = r_arr - 1.0 

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

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

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

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

724 result = np.maximum(c, 0.0) 

725 

726 return result if is_array else float(result) 

727 

728 

729@dataclass 

730class BrooksCoreyConductivity(NonlinearSorption): 

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

732 

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

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

735 closed-form conductivity curve 

736 

737 .. math:: 

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

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

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

741 

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

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

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

745 and ``check_entropy_condition`` are inherited unchanged from 

746 :class:`NonlinearSorption`. 

747 

748 Parameters 

749 ---------- 

750 theta_r : float 

751 Residual volumetric moisture content [-]. Must satisfy 

752 ``0 <= theta_r < theta_s``. 

753 theta_s : float 

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

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

756 k_s : float 

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

758 brooks_corey_lambda : float 

759 Pore-size distribution index ``λ`` [-]. Positive. The exponent 

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

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

762 a user wanting it can re-derive ``λ`` so the Burdine ``a`` matches the 

763 desired Mualem exponent. 

764 

765 See Also 

766 -------- 

767 VanGenuchtenMualemConductivity : Van Genuchten variant with brentq inversions. 

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

769 gwtransport.percolation.root_zone_to_water_table_kinematic_wave : The public wrapper. 

770 

771 Notes 

772 ----- 

773 The retardation factor and total-concentration relation are: 

774 

775 .. math:: 

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

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

778 

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

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

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

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

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

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

785 correct Rankine-Hugoniot velocity. 

786 

787 Examples 

788 -------- 

789 >>> sorption = BrooksCoreyConductivity( 

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

791 ... ) 

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

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

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

795 True 

796 """ 

797 

798 theta_r: float 

799 """Residual volumetric moisture content [-].""" 

800 theta_s: float 

801 """Saturated volumetric moisture content [-].""" 

802 k_s: float 

803 """Saturated hydraulic conductivity [length/time].""" 

804 brooks_corey_lambda: float 

805 """Pore-size distribution index λ [-].""" 

806 a: float = field(init=False) 

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

808 delta_theta: float = field(init=False) 

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

810 

811 def __post_init__(self) -> None: 

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

813 

814 Raises 

815 ------ 

816 ValueError 

817 If any parameter is outside its valid range. 

818 """ 

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

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

821 raise ValueError(msg) 

822 if not self.theta_s < 1.0: 

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

824 raise ValueError(msg) 

825 if self.k_s <= 0.0: 

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

827 raise ValueError(msg) 

828 if self.brooks_corey_lambda <= 0.0: 

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

830 raise ValueError(msg) 

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

832 self.delta_theta = self.theta_s - self.theta_r 

833 

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

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

836 is_array = isinstance(c, np.ndarray) 

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

838 result = self.delta_theta * (c_arr / self.k_s) ** (1.0 / self.a) 

839 return result if is_array else float(result) 

840 

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

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

843 is_array = isinstance(c, np.ndarray) 

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

845 result = (self.delta_theta / (self.a * self.k_s)) * (c_eff / self.k_s) ** (1.0 / self.a - 1.0) 

846 return result if is_array else float(result) 

847 

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

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

850 is_array = isinstance(r, np.ndarray) 

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

852 base = r_arr * self.a * self.k_s / self.delta_theta 

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

854 ratio = safe_base ** (-self.a / (self.a - 1.0)) 

855 c = self.k_s * ratio 

856 result = np.where(base > 0, np.maximum(c, _C_MIN), _C_MIN) 

857 return result if is_array else float(result) 

858 

859 

860@dataclass 

861class VanGenuchtenMualemConductivity(NonlinearSorption): 

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

863 

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

865 with the standard Mualem-van Genuchten conductivity curve 

866 

867 .. math:: 

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

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

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

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

872 

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

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

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

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

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

878 

879 Parameters 

880 ---------- 

881 theta_r : float 

882 Residual volumetric moisture content [-]. 

883 theta_s : float 

884 Saturated volumetric moisture content [-]. 

885 k_s : float 

886 Saturated hydraulic conductivity [length/time]. 

887 van_genuchten_n : float 

888 Shape parameter ``n_vG > 1``. ``m = 1 − 1/n_vG`` is derived. 

889 mualem_l : float, optional 

890 Pore-connectivity parameter ``L``. Default 0.5 (standard Mualem). 

891 Must satisfy ``L >= 0``. Setting ``L = 0`` (Burdine variant) gives 

892 a closed-form ``S_e(C)`` inverse; ``L != 0`` requires ``brentq``. 

893 

894 See Also 

895 -------- 

896 BrooksCoreyConductivity : Brooks-Corey closed-form variant. 

897 gwtransport.percolation.root_zone_to_water_table_kinematic_wave : The public wrapper. 

898 

899 Notes 

900 ----- 

901 The closed-form derivative is 

902 

903 .. math:: 

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

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

906 

907 with ``T = 1 - S_e^{1/m}`` and ``U = 1 - T^m``. Used for 

908 ``retardation(C)`` (after solving ``S_e(C)``) and for the brentq 

909 objective in ``concentration_from_retardation(R)``. The formula is 

910 inlined at both call sites, not exposed as a separate method. 

911 

912 The class checks monotonicity of ``dK_M/dS_e`` at a single pair of 

913 sample points in ``__post_init__`` (cheap directional check). Truly 

914 pathological parameter combinations that yield a non-monotone curve 

915 surface as a ``brentq`` ValueError at the first inversion call. 

916 

917 Examples 

918 -------- 

919 >>> sorption = VanGenuchtenMualemConductivity( 

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

921 ... ) 

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

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

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

925 True 

926 """ 

927 

928 theta_r: float 

929 """Residual volumetric moisture content [-].""" 

930 theta_s: float 

931 """Saturated volumetric moisture content [-].""" 

932 k_s: float 

933 """Saturated hydraulic conductivity [length/time].""" 

934 van_genuchten_n: float 

935 """vG shape parameter ``n_vG > 1``.""" 

936 mualem_l: float = 0.5 

937 """Mualem pore-connectivity ``L``. Default 0.5.""" 

938 m: float = field(init=False) 

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

940 delta_theta: float = field(init=False) 

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

942 

943 def __post_init__(self) -> None: 

944 """Validate parameters and run a single-sample monotonicity check. 

945 

946 Raises 

947 ------ 

948 ValueError 

949 If parameters are outside their valid range, or if the cheap 

950 monotonicity sample at ``S_e = 0.5`` vs ``0.99`` indicates 

951 ``dK_M/dS_e`` is non-monotone (pathological). 

952 """ 

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

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

955 raise ValueError(msg) 

956 if not self.theta_s < 1.0: 

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

958 raise ValueError(msg) 

959 if self.k_s <= 0.0: 

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

961 raise ValueError(msg) 

962 if self.van_genuchten_n <= 1.0: 

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

964 raise ValueError(msg) 

965 if self.mualem_l < 0.0: 

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

967 raise ValueError(msg) 

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

969 self.delta_theta = self.theta_s - self.theta_r 

970 # Cheap monotonicity sanity check on dK_M/dS_e. 

971 s_low, s_high = 0.5, 0.99 

972 if self._dk_dse(s_low) >= self._dk_dse(s_high): 

973 msg = ( 

974 f"Non-monotone dK_M/dS_e detected at the sanity-check samples for " 

975 f"van_genuchten_n={self.van_genuchten_n}, mualem_l={self.mualem_l}: " 

976 f"dK_M/dS_e({s_low})={self._dk_dse(s_low):.6g} should be < " 

977 f"dK_M/dS_e({s_high})={self._dk_dse(s_high):.6g}. " 

978 f"Brentq inversions in this class assume monotone-increasing dK_M/dS_e." 

979 ) 

980 raise ValueError(msg) 

981 

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

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

984 if s <= 0.0: 

985 return 0.0 

986 if s >= 1.0: 

987 return self.k_s 

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

989 u = 1.0 - t**self.m 

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

991 

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

993 """Closed-form ``dK_M/dS_e`` at scalar ``S_e``. Inlined at call sites. 

994 

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

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

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

998 """ 

999 if s <= 0.0: 

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

1001 return 0.0 

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

1003 t = 1.0 - s_pow_inv_m 

1004 if t <= 0.0: 

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

1006 return float("inf") 

1007 u = 1.0 - t**self.m 

1008 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)) 

1009 

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

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

1012 

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

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

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

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

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

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

1019 """ 

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

1021 if c_eff >= self.k_s: 

1022 return 1.0 

1023 if self.mualem_l == 0.0: 

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

1025 one_minus_u = 1.0 - u 

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

1027 q = 1.0 - one_minus_q_to_inv_m 

1028 return float(q**self.m) 

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

1030 

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

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

1033 is_array = isinstance(c, np.ndarray) 

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

1035 flat = c_arr.ravel() 

1036 se = np.fromiter( 

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

1038 ).reshape(c_arr.shape) 

1039 result = self.delta_theta * se 

1040 return result if is_array else float(result) 

1041 

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

1043 """``R = Δθ / (dK_M/dS_e)|_{S_e(C)}``. Uses inlined derivative; clamps C at ``_C_MIN``.""" 

1044 is_array = isinstance(c, np.ndarray) 

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

1046 flat = c_arr.ravel() 

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

1048 for i, ci in enumerate(flat): 

1049 s = self._se_from_c(ci) 

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

1051 result = out.reshape(c_arr.shape) 

1052 return result if is_array else float(result) 

1053 

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

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

1056 is_array = isinstance(r, np.ndarray) 

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

1058 flat = r_arr.ravel() 

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

1060 for i, ri in enumerate(flat): 

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

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

1063 result = out.reshape(r_arr.shape) 

1064 return result if is_array else float(result) 

1065 

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

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

1068 

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

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

1071 """ 

1072 if r <= 0.0: 

1073 return _C_MIN 

1074 target = self.delta_theta / r 

1075 try: 

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

1077 except ValueError: 

1078 return _C_MIN 

1079 

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

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

1082 

1083 Overrides the default base-class implementation (which calls 

1084 ``concentration_from_retardation`` and ``total_concentration`` 

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

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

1087 integrators. 

1088 """ 

1089 s = self._se_from_retardation(r) 

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

1091 ct = self.delta_theta * s 

1092 return c, ct 

1093 

1094 

1095SorptionModel = NonlinearSorption | ConstantRetardation 

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

1097 

1098 

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

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

1101 

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

1103 speed determined solely by the local concentration and the sorption 

1104 isotherm. 

1105 

1106 Parameters 

1107 ---------- 

1108 c : float 

1109 Dissolved concentration [mass/volume]. 

1110 sorption : SorptionModel 

1111 Sorption model. 

1112 

1113 Returns 

1114 ------- 

1115 speed : float 

1116 Characteristic speed dV/dθ. 

1117 

1118 Examples 

1119 -------- 

1120 >>> sorption = FreundlichSorption( 

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

1122 ... ) 

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

1124 >>> s > 0 

1125 True 

1126 """ 

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

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

1129 

1130 

1131def characteristic_position( 

1132 c: float, 

1133 sorption: SorptionModel, 

1134 theta_start: float, 

1135 v_start: float, 

1136 theta: float, 

1137) -> float | None: 

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

1139 

1140 Characteristics propagate linearly in θ:: 

1141 

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

1143 

1144 Parameters 

1145 ---------- 

1146 c : float 

1147 Concentration carried by characteristic [mass/volume]. 

1148 sorption : SorptionModel 

1149 Sorption model. 

1150 theta_start : float 

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

1152 v_start : float 

1153 Starting position [m³]. 

1154 theta : float 

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

1156 

1157 Returns 

1158 ------- 

1159 position : float or None 

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

1161 

1162 Examples 

1163 -------- 

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

1165 >>> v = characteristic_position( 

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

1167 ... ) 

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

1169 True 

1170 """ 

1171 if theta < theta_start: 

1172 return None 

1173 

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

1175 

1176 

1177def compute_first_front_arrival_theta( 

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

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

1180 aquifer_pore_volume: float, 

1181 sorption: SorptionModel, 

1182) -> float: 

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

1184 

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

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

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

1188 

1189 .. warning:: 

1190 

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

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

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

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

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

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

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

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

1199 

1200 Parameters 

1201 ---------- 

1202 cin : numpy.ndarray 

1203 Inlet concentration [mass/volume]. 

1204 theta_edges : numpy.ndarray 

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

1206 aquifer_pore_volume : float 

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

1208 sorption : SorptionModel 

1209 Sorption model. 

1210 

1211 Returns 

1212 ------- 

1213 theta_first_arrival : float 

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

1215 [m³]. Returns ``np.inf`` only if ``cin`` is identically zero. 

1216 

1217 Examples 

1218 -------- 

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

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

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

1222 >>> theta_first = compute_first_front_arrival_theta( 

1223 ... cin, theta_edges, 500.0, sorption 

1224 ... ) 

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

1226 True 

1227 """ 

1228 nonzero_indices = np.where(cin > 0)[0] 

1229 if len(nonzero_indices) == 0: 

1230 return float(np.inf) 

1231 

1232 idx_first = int(nonzero_indices[0]) 

1233 c_first = float(cin[idx_first]) 

1234 

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

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

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

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

1239 else: 

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

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

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

1243 

1244 return float(theta_edges[idx_first]) + target_volume