Coverage for src/gwtransport/fronttracking/waves.py: 93%

411 statements  

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

1""" 

2Wave Representation for Front Tracking in (V, θ) coordinates. 

3 

4This module implements wave classes for representing characteristics, shocks, 

5and rarefaction waves in the front tracking algorithm. Each wave stores its 

6formation position in cumulative-flow coordinate ``θ = ∫flow(t') dt'`` and 

7knows how to compute its position at any later θ. 

8 

9The change from (V, t) to (V, θ) makes every wave velocity a property of the 

10sorption isotherm alone — flow no longer enters into wave dynamics. Time- 

11varying flow is absorbed entirely into the θ(t) mapping at the API boundary; 

12no wave needs recreation when the flow rate changes. 

13 

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

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

16""" 

17 

18from abc import ABC, abstractmethod 

19from dataclasses import dataclass, field 

20 

21import numpy as np 

22from scipy.interpolate import CubicSpline 

23from scipy.optimize import brentq 

24 

25from gwtransport.fronttracking.math import ( 

26 _C_MIN, 

27 BrooksCoreyConductivity, 

28 FreundlichSorption, 

29 LangmuirSorption, 

30 NonlinearSorption, 

31 SorptionModel, 

32 characteristic_speed, 

33) 

34 

35# Numerical tolerance constants 

36EPSILON_POSITION = 1e-15 # Tolerance for checking if two positions are equal 

37DECAYING_SHOCK_U_FLOOR = 1e-300 # Lower bracket bound for brentq on Freundlich u-invariant 

38DECAYING_SHOCK_BRENTQ_XTOL = ( 

39 1e-14 # brentq absolute tolerance for monotone θ inversions (exhaustion, outlet, numerical) 

40) 

41# Cached numerical decay profile (see ``_build_decay_profile``): c-grid resolution, the 

42# Gauss-Legendre panel order for the cumulative invariant integral, and the fraction of the 

43# c-gap the grid stops short of a secant-speed pole (where ``θ_local → ∞``). 

44DECAY_PROFILE_NODES = 6000 

45DECAY_PROFILE_GAUSS_ORDER = 10 

46DECAY_PROFILE_POLE_FLOOR = 1e-6 

47 

48 

49@dataclass 

50class Wave(ABC): 

51 """Abstract base class for all wave types in front tracking. 

52 

53 All waves share common attributes and must implement methods for 

54 computing position and concentration. Waves can be active or inactive 

55 (deactivated waves are preserved for history but don't participate in 

56 future interactions). 

57 

58 Parameters 

59 ---------- 

60 theta_start : float 

61 Cumulative flow at which the wave forms [m³]. 

62 v_start : float 

63 Position at which the wave forms [m³]. 

64 is_active : bool, optional 

65 Whether wave is currently active. Default True. 

66 """ 

67 

68 theta_start: float 

69 """Cumulative flow at which the wave forms [m³].""" 

70 v_start: float 

71 """Position at which the wave forms [m³].""" 

72 is_active: bool = field(default=True, kw_only=True) 

73 """Whether wave is currently active (in the solver's event-loop sense).""" 

74 theta_deactivation: float = field(default=float("inf"), kw_only=True) 

75 """Cumulative flow at which the wave was deactivated (default ``+∞``). 

76 

77 Historical record set by collision handlers when a wave is replaced 

78 (e.g., a parent rarefaction superseded by a ``DecayingShockWave``). 

79 ``is_active = False`` is the "current state" flag the solver uses for 

80 its event loop; ``theta_deactivation`` is the moment in θ-history when 

81 the wave stopped contributing. Retrospective queries (any θ in the 

82 past) must use ``was_active_at(theta)`` instead of ``is_active`` so 

83 that ``compute_domain_mass`` etc. correctly attribute c at v_outlet 

84 during the wave's lifetime even after later events have deactivated 

85 the wave. 

86 """ 

87 

88 def was_active_at(self, theta: float) -> bool: 

89 """Whether the wave was active at cumulative flow ``theta`` (geometric truth). 

90 

91 Use for retrospective queries — ``is_active`` reflects only the 

92 wave's *current* (post-simulation) state, which is wrong for 

93 ``compute_domain_mass`` and similar at θ before a deactivation event. 

94 

95 Parameters 

96 ---------- 

97 theta : float 

98 Cumulative flow at which to query historical activity [m³]. 

99 

100 Returns 

101 ------- 

102 bool 

103 ``True`` for ``theta_start <= theta < theta_deactivation``. 

104 A wave constructed with ``is_active=False`` and no recorded 

105 ``theta_deactivation`` (default ``+∞``) is treated as 

106 never-active — e.g., synthetic test fixtures that want the 

107 wave excluded from dispatch entirely. 

108 """ 

109 if not self.is_active and self.theta_deactivation == float("inf"): 

110 return False 

111 return self.theta_start <= theta < self.theta_deactivation 

112 

113 def deactivate(self, theta: float) -> None: 

114 """Mark the wave inactive at cumulative flow ``theta`` (collision handler API). 

115 

116 Sets both ``is_active = False`` (solver event-loop flag) and 

117 ``theta_deactivation = theta`` (historical record for retrospective 

118 ``was_active_at`` queries). 

119 

120 Parameters 

121 ---------- 

122 theta : float 

123 Cumulative flow at which the wave is deactivated [m³]. 

124 """ 

125 self.is_active = False 

126 self.theta_deactivation = theta 

127 

128 @abstractmethod 

129 def position_at_theta(self, theta: float) -> float | None: 

130 """Compute wave position at cumulative flow θ. 

131 

132 Parameters 

133 ---------- 

134 theta : float 

135 Cumulative flow [m³]. 

136 

137 Returns 

138 ------- 

139 position : float or None 

140 Position [m³], or None if θ < θ_start or θ >= theta_deactivation. 

141 (Past-θ queries respect the wave's historical lifetime; current-state 

142 queries before deactivation behave identically to the ``is_active`` 

143 check.) 

144 """ 

145 

146 @abstractmethod 

147 def concentration_left(self) -> float: 

148 """Concentration on the left (upstream) side of the wave.""" 

149 

150 @abstractmethod 

151 def concentration_right(self) -> float: 

152 """Concentration on the right (downstream) side of the wave.""" 

153 

154 @abstractmethod 

155 def concentration_at_point(self, v: float, theta: float) -> float | None: 

156 """Compute concentration at point (v, θ) if the wave controls it. 

157 

158 Returns 

159 ------- 

160 concentration : float or None 

161 Concentration [mass/volume] if the wave controls this point, None 

162 otherwise. 

163 """ 

164 

165 

166@dataclass 

167class CharacteristicWave(Wave): 

168 """Characteristic line along which concentration is constant. 

169 

170 In smooth regions, concentration travels at speed ``1/R(C)`` in (V, θ) 

171 coordinates. Along each characteristic line, the concentration value is 

172 constant. This is the fundamental solution element for hyperbolic 

173 conservation laws. 

174 

175 Parameters 

176 ---------- 

177 theta_start : float 

178 Formation cumulative flow [m³]. 

179 v_start : float 

180 Starting position [m³]. 

181 concentration : float 

182 Constant concentration carried [mass/volume]. 

183 sorption : SorptionModel 

184 Sorption model determining the speed. 

185 is_active : bool, optional 

186 Activity status. Default True. 

187 

188 Examples 

189 -------- 

190 >>> sorption = FreundlichSorption( 

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

192 ... ) 

193 >>> char = CharacteristicWave( 

194 ... theta_start=0.0, v_start=0.0, concentration=5.0, sorption=sorption 

195 ... ) 

196 >>> speed = char.speed() 

197 >>> bool(np.isclose(char.position_at_theta(1000.0), speed * 1000.0)) 

198 True 

199 """ 

200 

201 concentration: float 

202 """Constant concentration carried [mass/volume].""" 

203 sorption: SorptionModel 

204 """Sorption model determining the speed.""" 

205 _speed: float = field(init=False, repr=False, compare=False) 

206 """Cached characteristic speed (immutable inputs; set in ``__post_init__``).""" 

207 

208 def __post_init__(self) -> None: 

209 """Cache the (immutable) characteristic speed once.""" 

210 self._speed = characteristic_speed(self.concentration, self.sorption) 

211 

212 def speed(self) -> float: 

213 """Characteristic speed dV/dθ = 1/R(C) (``+∞`` at a saturated state, R = 0).""" 

214 return self._speed 

215 

216 def position_at_theta(self, theta: float) -> float | None: 

217 """Position at cumulative flow θ. 

218 

219 ``V(θ) = v_start + speed * (θ - θ_start)``. 

220 """ 

221 if not self.was_active_at(theta): 

222 return None 

223 return self.v_start + self.speed() * (theta - self.theta_start) 

224 

225 def concentration_left(self) -> float: 

226 """Concentration on the left (upstream) side; equals the carried value.""" 

227 return self.concentration 

228 

229 def concentration_right(self) -> float: 

230 """Concentration on the right (downstream) side; equals the carried value.""" 

231 return self.concentration 

232 

233 def concentration_at_point(self, v: float, theta: float) -> float | None: 

234 """Return the carried concentration if the characteristic has reached ``v`` by θ.""" 

235 v_at_theta = self.position_at_theta(theta) 

236 if v_at_theta is None: 

237 return None 

238 

239 if v_at_theta >= v: 

240 return self.concentration 

241 

242 return None 

243 

244 

245@dataclass 

246class ShockWave(Wave): 

247 """Shock wave (discontinuity) with jump in concentration. 

248 

249 Shocks form when faster water overtakes slower water, creating a sharp 

250 front. In (V, θ) the shock speed is given by the Rankine-Hugoniot 

251 condition and is independent of flow:: 

252 

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

254 

255 Parameters 

256 ---------- 

257 theta_start : float 

258 Formation cumulative flow [m³]. 

259 v_start : float 

260 Formation position [m³]. 

261 c_left : float 

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

263 c_right : float 

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

265 sorption : SorptionModel 

266 Sorption model. 

267 is_active : bool, optional 

268 Activity status. Default True. 

269 speed : float, optional 

270 Shock speed dV/dθ. Computed from Rankine-Hugoniot in ``__post_init__``. 

271 

272 Examples 

273 -------- 

274 >>> sorption = FreundlichSorption( 

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

276 ... ) 

277 >>> shock = ShockWave( 

278 ... theta_start=0.0, 

279 ... v_start=0.0, 

280 ... c_left=10.0, 

281 ... c_right=2.0, 

282 ... sorption=sorption, 

283 ... ) 

284 >>> shock.speed > 0 

285 True 

286 >>> shock.satisfies_entropy() 

287 True 

288 """ 

289 

290 c_left: float 

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

292 c_right: float 

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

294 sorption: SorptionModel 

295 """Sorption model.""" 

296 speed: float = field(init=False) 

297 """Shock speed dV/dθ; set in ``__post_init__``.""" 

298 

299 def __post_init__(self) -> None: 

300 """Compute shock speed from Rankine-Hugoniot in (V, θ).""" 

301 self.speed = self.sorption.shock_speed(self.c_left, self.c_right) 

302 

303 def position_at_theta(self, theta: float) -> float | None: 

304 """Position at cumulative flow θ. Shock propagates linearly in θ.""" 

305 if not self.was_active_at(theta): 

306 return None 

307 return self.v_start + self.speed * (theta - self.theta_start) 

308 

309 def concentration_left(self) -> float: 

310 """Upstream concentration of the shock.""" 

311 return self.c_left 

312 

313 def concentration_right(self) -> float: 

314 """Downstream concentration of the shock.""" 

315 return self.c_right 

316 

317 def concentration_at_point(self, v: float, theta: float) -> float | None: 

318 """Return c_left if upstream of the shock at θ, c_right if downstream. 

319 

320 At the exact shock position the average is returned (convention; the 

321 shock is infinitesimally thin in practice). 

322 """ 

323 v_shock = self.position_at_theta(theta) 

324 if v_shock is None: 

325 return None 

326 

327 # Position-scaled face width (~1 ULP at all positions), matching 

328 # DecayingShockWave.concentration_at_point; a fixed 1e-15 falls below 

329 # one ULP for any v_shock > ~1 m³ and degenerates to bit-equality. 

330 tol = 1e-15 * max(abs(v_shock), 1.0) 

331 

332 if v < v_shock - tol: 

333 return self.c_left 

334 if v > v_shock + tol: 

335 return self.c_right 

336 return 0.5 * (self.c_left + self.c_right) 

337 

338 def satisfies_entropy(self) -> bool: 

339 """Check Lax entropy condition in (V, θ): ``λ_θ(C_L) ≥ s ≥ λ_θ(C_R)``.""" 

340 return self.sorption.check_entropy_condition(self.c_left, self.c_right, self.speed) 

341 

342 

343@dataclass 

344class RarefactionWave(Wave): 

345 """Rarefaction (expansion fan) with smooth concentration gradient. 

346 

347 Rarefactions form when slower water follows faster water, creating an 

348 expanding region where concentration varies smoothly. In (V, θ) the 

349 solution is self-similar in ``(V - v_start)`` vs ``(θ - θ_start)``:: 

350 

351 R(C) = (θ - θ_start) / (V - v_start) 

352 

353 Head and tail propagate at flow-free speeds ``1/R(C_head)`` and 

354 ``1/R(C_tail)``. 

355 

356 Parameters 

357 ---------- 

358 theta_start : float 

359 Formation cumulative flow [m³]. 

360 v_start : float 

361 Formation position [m³]. 

362 c_head : float 

363 Concentration at leading edge (faster) [mass/volume]. 

364 c_tail : float 

365 Concentration at trailing edge (slower) [mass/volume]. 

366 sorption : SorptionModel 

367 Sorption model (must be concentration-dependent). 

368 is_active : bool, optional 

369 Activity status. Default True. 

370 

371 Raises 

372 ------ 

373 ValueError 

374 If head speed <= tail speed (would be a compression, not a rarefaction). 

375 

376 Examples 

377 -------- 

378 >>> sorption = FreundlichSorption( 

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

380 ... ) 

381 >>> raref = RarefactionWave( 

382 ... theta_start=0.0, 

383 ... v_start=0.0, 

384 ... c_head=10.0, 

385 ... c_tail=2.0, 

386 ... sorption=sorption, 

387 ... ) 

388 >>> raref.head_speed() > raref.tail_speed() 

389 True 

390 >>> raref.contains_point(v=150.0, theta=2000.0) 

391 True 

392 """ 

393 

394 c_head: float 

395 """Concentration at leading edge (faster) [mass/volume].""" 

396 c_tail: float 

397 """Concentration at trailing edge (slower) [mass/volume].""" 

398 sorption: SorptionModel 

399 """Sorption model (must be concentration-dependent).""" 

400 _head_speed: float = field(init=False, repr=False, compare=False) 

401 """Cached head celerity (immutable inputs; set in ``__post_init__``).""" 

402 _tail_speed: float = field(init=False, repr=False, compare=False) 

403 """Cached tail celerity (immutable inputs; set in ``__post_init__``).""" 

404 

405 def __post_init__(self): 

406 """Cache head/tail celerities and verify this is a rarefaction (head faster than tail).""" 

407 self._head_speed = characteristic_speed(self.c_head, self.sorption) 

408 self._tail_speed = characteristic_speed(self.c_tail, self.sorption) 

409 

410 if self._head_speed <= self._tail_speed: 

411 msg = ( 

412 f"Not a rarefaction: head_speed={self._head_speed:.6g} <= tail_speed={self._tail_speed:.6g}. " 

413 f"This would be a compression (shock) instead." 

414 ) 

415 raise ValueError(msg) 

416 

417 def head_speed(self) -> float: 

418 """Speed of rarefaction head dV/dθ = 1/R(C_head) (``+∞`` at a saturated state, R = 0).""" 

419 return self._head_speed 

420 

421 def tail_speed(self) -> float: 

422 """Speed of rarefaction tail dV/dθ = 1/R(C_tail) (``+∞`` at a saturated state, R = 0).""" 

423 return self._tail_speed 

424 

425 def head_position_at_theta(self, theta: float) -> float | None: 

426 """Position of rarefaction head at cumulative flow θ.""" 

427 if not self.was_active_at(theta): 

428 return None 

429 return self.v_start + self.head_speed() * (theta - self.theta_start) 

430 

431 def tail_position_at_theta(self, theta: float) -> float | None: 

432 """Position of rarefaction tail at cumulative flow θ.""" 

433 if not self.was_active_at(theta): 

434 return None 

435 return self.v_start + self.tail_speed() * (theta - self.theta_start) 

436 

437 def position_at_theta(self, theta: float) -> float | None: 

438 """Head position (leading edge of rarefaction). Implements abstract Wave method.""" 

439 return self.head_position_at_theta(theta) 

440 

441 def contains_point(self, v: float, theta: float) -> bool: 

442 """Return ``True`` if ``(v, θ)`` lies between the fan's tail and head.""" 

443 if theta <= self.theta_start or theta >= self.theta_deactivation: 

444 return False 

445 

446 v_head = self.head_position_at_theta(theta) 

447 v_tail = self.tail_position_at_theta(theta) 

448 

449 if v_head is None or v_tail is None: 

450 return False 

451 

452 return v_tail <= v <= v_head 

453 

454 def concentration_left(self) -> float: 

455 """Upstream concentration is the trailing-edge value c_tail.""" 

456 return self.c_tail 

457 

458 def concentration_right(self) -> float: 

459 """Downstream concentration is the leading-edge value c_head.""" 

460 return self.c_head 

461 

462 def concentration_at_point(self, v: float, theta: float) -> float | None: 

463 """Self-similar concentration inside the fan: ``R(C) = (θ - θ_start)/(v - v_start)``. 

464 

465 Outside the fan returns None. For ``ConstantRetardation``, rarefactions 

466 don't form (all concentrations travel at the same speed), so this also 

467 returns None. 

468 

469 Examples 

470 -------- 

471 >>> sorption = FreundlichSorption( 

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

473 ... ) 

474 >>> raref = RarefactionWave(0.0, 0.0, 10.0, 2.0, sorption) 

475 >>> c = raref.concentration_at_point(v=150.0, theta=2000.0) 

476 >>> c is not None 

477 True 

478 >>> 2.0 <= c <= 10.0 

479 True 

480 """ 

481 if abs(v - self.v_start) < EPSILON_POSITION and theta >= self.theta_start: 

482 return self.c_tail 

483 

484 if not self.contains_point(v, theta): 

485 return None 

486 

487 r_target = (theta - self.theta_start) / (v - self.v_start) 

488 

489 if r_target <= 1.0: 

490 return None # Unphysical 

491 

492 try: 

493 c = self.sorption.concentration_from_retardation(r_target) 

494 except NotImplementedError: 

495 # ConstantRetardation case — rarefactions don't form 

496 return None 

497 

498 # contains_point(v, theta) was True, so the point is geometrically inside 

499 # the fan. The inverted c may drift by a few ULPs past [c_tail, c_head] 

500 # — clamp rather than rejecting so callers at the head/tail boundaries 

501 # get the correct boundary concentration. 

502 c_lo = min(self.c_tail, self.c_head) 

503 c_hi = max(self.c_tail, self.c_head) 

504 return min(max(float(c), c_lo), c_hi) 

505 

506 

507@dataclass 

508class DecayingShockWave(Wave): 

509 r"""Merging shock with closed-form (or quadrature) trajectory in θ-space. 

510 

511 Formed when a rarefaction fan and a shock collide. The shock then has 

512 one side fed by the fan's self-similar profile (the "decay" side) and 

513 the other side at the original outer state (the "fixed" side). Valid for 

514 any :class:`~gwtransport.fronttracking.math.NonlinearSorption`. 

515 

516 Two collision regimes are supported via ``decay_side``: 

517 

518 - ``'left'`` (favorable head-collision): the rarefaction's head (faster) 

519 catches a leading shock. After collision, the shock's ``c_left`` decays 

520 from the rarefaction head value toward ``c_fan_tail`` (the unchanged 

521 downstream c_right is ``c_fixed``). 

522 - ``'right'`` (unfavorable tail-collision, n<1 mirrored): a trailing shock 

523 catches the rarefaction's tail. After collision, the shock's ``c_right`` 

524 decays from the rarefaction tail value toward ``c_fan_tail`` (the 

525 unchanged upstream c_left is ``c_fixed``). 

526 

527 The wave is valid only while ``c_decay ∈ (c_fan_tail, c_decay_initial]``; 

528 once ``c_decay`` reaches ``c_fan_tail`` the fan is exhausted (see the 

529 solver's ``DSW_FAN_EXHAUSTED`` event). 

530 

531 **Dispatch.** ``_c_decay_at_theta_local`` is the single dispatch site 

532 (position, fan-exhaustion and outlet-crossing all route through it): a 

533 closed form is used where one exists, otherwise the per-wave cached numerical 

534 profile (:func:`_build_decay_profile`). No combination raises — any 

535 :class:`~gwtransport.fronttracking.math.NonlinearSorption` is valid. With 

536 ``θ_local := θ − theta_origin`` measured from the rarefaction apex, 

537 ``α := ρ_b · k_f / n_por`` for Freundlich, and ``u_d := c_decay^(1/n)``: 

538 

539 - Freundlich, ``c_fixed = 0`` (general ``n > 0``, ``n ≠ 1``) — closed form: 

540 invariant ``θ_local · u_d^n = K · (n · u_d^(n-1) + α)``, 

541 position ``V_s(θ) = v_origin + n · K / u_d(θ)``. 

542 - Freundlich, ``c_fixed > 0``, ``n = 2`` and ``c_decay_initial > c_fixed`` 

543 — closed form: invariant ``(u_d - u_R)² · θ_local = K · (2 u_d + α)`` 

544 with ``u_R := c_fixed^(1/2)``, 

545 position ``V_s(θ) = v_origin + 2 K · u_d(θ) / (u_d - u_R)²``. 

546 (The ``c_decay_initial < c_fixed`` mirror falls through to numerical.) 

547 - Langmuir, ``c_fixed = 0`` — closed form: 

548 invariant ``θ_local · c_d² = K · ((K_L + c_d)² + a)`` with 

549 ``a := ρ_b · s_max · K_L / n_por``, 

550 position ``V_s(θ) = v_origin + K · (K_L + c_d)² / c_d²``. 

551 - Brooks-Corey, ``c_fixed = 0`` — closed form: 

552 invariant ``θ_local ∝ R(c_decay)^{a/(a−1)}`` (``R·S = 1/a`` constant), 

553 so ``R(c_d) = R(c0)·(θ_local/θ_local_coll)^{(a−1)/a}``. 

554 - Every other ``(isotherm, c_fixed)`` combination (Freundlich ``c_fixed>0, 

555 n≠2``, Langmuir/Brooks-Corey ``c_fixed>0``, any van Genuchten) — cached 

556 numerical profile (:func:`_build_decay_profile`): the decay-agnostic 

557 invariant ``θ_local(c_d) = θ_local_coll · exp(∫ R'/[(1 − R·S)·R] dc)`` with 

558 the symmetric secant speed ``S = (c − c_fixed)/(C_T(c) − C_T(c_fixed))``, 

559 built once by composite quadrature and inverted for ``c_d(θ)`` by monotone 

560 spline interpolation. 

561 

562 Every path shares the fan-continuity identity 

563 ``V_s = v_origin + θ_local / R(c_decay)``, which ``position_at_theta`` and 

564 ``outlet_crossing_theta`` use uniformly across all isotherms. 

565 

566 The invariant constant ``K`` (closed-form Freundlich/Langmuir only) is set 

567 in ``__post_init__`` from the collision IC ``(theta_start, c_decay_initial)``. 

568 

569 Parameters 

570 ---------- 

571 theta_start : float 

572 Cumulative flow at which the merged wave forms (collision θ) [m³]. 

573 v_start : float 

574 Position at which the merged wave forms [m³]. Should equal 

575 ``v_origin + (V_s) at θ=theta_start`` for a fan-consistent 

576 construction. 

577 c_decay_initial : float 

578 Concentration on the decaying side at θ=theta_start [mass/volume]. 

579 Must be non-negative; a fully-drained collision value of ``0`` is 

580 floored to the shared dry-soil singularity floor ``_C_MIN`` so the 

581 retardation and secant-speed evaluations stay finite (issue #222). 

582 c_fixed : float 

583 Concentration on the non-decaying side [mass/volume]. Constant in θ. 

584 Non-negative. 

585 c_fan_tail : float 

586 Concentration at the fan's far boundary [mass/volume]. The wave is 

587 valid only while ``c_decay ∈ (c_fan_tail, c_decay_initial]``; at 

588 ``c_fan_tail`` the fan is exhausted. Non-negative. 

589 decay_side : str 

590 ``'left'`` or ``'right'``. See class docstring. 

591 v_origin : float 

592 Position of the rarefaction apex [m³]. 

593 theta_origin : float 

594 Cumulative flow at the rarefaction apex [m³]. Must satisfy 

595 ``theta_origin < theta_start``. 

596 sorption : NonlinearSorption 

597 Sorption model (any concentration-dependent isotherm). 

598 is_active : bool, optional 

599 Activity flag. Default True. 

600 

601 See Also 

602 -------- 

603 ShockWave : Linear-θ shock (no decaying side). 

604 RarefactionWave : Self-similar expansion fan. 

605 """ 

606 

607 c_decay_initial: float 

608 """Concentration on the decaying side at θ=theta_start [mass/volume].""" 

609 c_fixed: float 

610 """Concentration on the non-decaying side [mass/volume].""" 

611 c_fan_tail: float 

612 """Concentration at the fan's far boundary [mass/volume]; bounds the decay.""" 

613 decay_side: str 

614 """``'left'`` (favorable head-collision) or ``'right'`` (n<1 mirrored).""" 

615 v_origin: float 

616 """Position of the rarefaction apex [m³].""" 

617 theta_origin: float 

618 """Cumulative flow at the rarefaction apex [m³].""" 

619 sorption: NonlinearSorption 

620 """Sorption model (any concentration-dependent isotherm).""" 

621 K: float = field(init=False) 

622 """Invariant constant set in ``__post_init__`` (closed-form Freundlich ``c_fixed=0``/``n≈2`` and Langmuir 

623 ``c_fixed=0`` cases; ``nan`` for every numerical case).""" 

624 _freundlich_cf: bool = field(init=False, repr=False, compare=False) 

625 """Cached Freundlich-closed-form predicate (immutable inputs; set in ``__post_init__``).""" 

626 _langmuir_cf: bool = field(init=False, repr=False, compare=False) 

627 """Cached Langmuir-closed-form predicate.""" 

628 _brooks_corey_cf: bool = field(init=False, repr=False, compare=False) 

629 """Cached Brooks-Corey ``c_fixed=0`` closed-form predicate.""" 

630 _numerical: bool = field(init=False, repr=False, compare=False) 

631 """Cached predicate: no closed form applies, so the decay routes to the cached numerical profile.""" 

632 _decay_profile_cache: tuple | None = field(default=None, init=False, repr=False, compare=False) 

633 """Lazily-built monotone ``θ_local(c)`` map for the numerical decay path (see ``_decay_profile``).""" 

634 

635 def __post_init__(self) -> None: 

636 """Validate inputs and compute the closed-form invariant K when applicable.""" 

637 if self.decay_side not in {"left", "right"}: 

638 msg = f"decay_side must be 'left' or 'right', got {self.decay_side!r}" 

639 raise ValueError(msg) 

640 if self.c_decay_initial < 0.0: 

641 msg = f"c_decay_initial must be non-negative, got {self.c_decay_initial}" 

642 raise ValueError(msg) 

643 # Floor a fully-drained fan tail (c_decay_initial == 0, #222) to _C_MIN so the 

644 # retardation and secant-speed evaluations stay finite (package floor convention). 

645 self.c_decay_initial = max(self.c_decay_initial, _C_MIN) 

646 if self.c_fixed < 0.0: 

647 msg = f"c_fixed must be non-negative, got {self.c_fixed}" 

648 raise ValueError(msg) 

649 if self.c_fan_tail < 0.0: 

650 msg = f"c_fan_tail must be non-negative, got {self.c_fan_tail}" 

651 raise ValueError(msg) 

652 if self.theta_origin >= self.theta_start: 

653 msg = ( 

654 f"theta_origin ({self.theta_origin}) must be strictly less than " 

655 f"theta_start ({self.theta_start}); rarefaction apex precedes collision" 

656 ) 

657 raise ValueError(msg) 

658 

659 if not isinstance(self.sorption, NonlinearSorption): 

660 msg = f"DecayingShockWave requires a NonlinearSorption, got {type(self.sorption).__name__}" 

661 raise TypeError(msg) 

662 

663 # Classify the decay path once (immutable inputs). Closed forms exist for: 

664 # Freundlich c_fixed=0 (general n) or the n≈2 quadratic when the decaying side 

665 # starts above the fixed side (the +√ / (u_d−u_R)² branch was derived only for 

666 # c_decay_initial > c_fixed — the mirror routes to the numerical profile); 

667 # Langmuir c_fixed=0; Brooks-Corey c_fixed=0. Everything else is numerical. 

668 s = self.sorption 

669 self._freundlich_cf = isinstance(s, FreundlichSorption) and ( 

670 self.c_fixed == 0.0 or bool(np.isclose(s.n, 2.0, rtol=1e-12) and self.c_decay_initial > self.c_fixed) 

671 ) 

672 self._langmuir_cf = isinstance(s, LangmuirSorption) and self.c_fixed == 0.0 

673 self._brooks_corey_cf = isinstance(s, BrooksCoreyConductivity) and self.c_fixed == 0.0 

674 self._numerical = not (self._freundlich_cf or self._langmuir_cf or self._brooks_corey_cf) 

675 

676 # K is the closed-form invariant constant; set only for the Freundlich/Langmuir 

677 # closed forms and left NaN for every numerical (and Brooks-Corey) case. The 

678 # isinstance guards narrow ``s`` for the typed helpers (the cached predicate already 

679 # implies the type; the ``np.isclose`` cost is not re-incurred). 

680 self.K = float("nan") 

681 if self._freundlich_cf and isinstance(s, FreundlichSorption): 

682 self.K = _compute_k_freundlich( 

683 s, 

684 self.theta_start - self.theta_origin, 

685 self.c_decay_initial, 

686 self.c_fixed, 

687 ) 

688 elif self._langmuir_cf and isinstance(s, LangmuirSorption): 

689 self.K = _compute_k_langmuir( 

690 s, 

691 self.theta_start - self.theta_origin, 

692 self.c_decay_initial, 

693 ) 

694 

695 def _decay_profile(self) -> tuple: 

696 """Lazily build & cache the monotone ``θ_local(c)`` map for the numerical decay path. 

697 

698 Returns ``(c_of_i, i_max, c_limit_node)``: a ``CubicSpline`` mapping the 

699 cumulative invariant ``I = ln(θ_local/θ_local_coll)`` to ``c_decay``, the 

700 largest resolved ``I`` (endpoint of the reachable c-range), and the c at 

701 that endpoint. Built once per wave (see :func:`_build_decay_profile`). 

702 """ 

703 if self._decay_profile_cache is None: 

704 self._decay_profile_cache = _build_decay_profile( 

705 self.sorption, 

706 self.c_decay_initial, 

707 self.c_fixed, 

708 self.c_fan_tail, 

709 ) 

710 return self._decay_profile_cache 

711 

712 def c_decay_at_theta(self, theta: float) -> float | None: 

713 """Concentration on the decaying side at cumulative flow θ. 

714 

715 Returns ``None`` for ``θ < theta_start`` or when the wave is inactive; 

716 otherwise delegates to the single per-isotherm dispatch in 

717 ``_c_decay_at_theta_local``. 

718 """ 

719 if not self.was_active_at(theta): 

720 return None 

721 return self._c_decay_at_theta_local(theta - self.theta_origin) 

722 

723 def position_at_theta(self, theta: float) -> float | None: 

724 """Shock position ``V_s(θ)`` via the fan-continuity identity. 

725 

726 ``V_s = v_origin + θ_local / R(c_decay)`` for every isotherm. Returns 

727 ``None`` for ``θ < theta_start`` or when inactive. 

728 """ 

729 if not self.was_active_at(theta): 

730 return None 

731 

732 theta_local = theta - self.theta_origin 

733 c_d = self._c_decay_at_theta_local(theta_local) 

734 return float(self.v_origin + theta_local / float(self.sorption.retardation(c_d))) 

735 

736 def theta_at_fan_exhaustion(self) -> float | None: 

737 """Cumulative flow θ at which ``c_decay`` reaches ``c_fan_tail``. 

738 

739 ``c_decay(θ)`` is strictly monotone from ``c_decay_initial`` toward 

740 ``c_fan_tail``, so the exhaustion θ is well-defined. The crossing test is 

741 orientation-agnostic: it holds for both the shrinking decay 

742 (``c_decay_initial > c_fan_tail``) and the growing decay 

743 (``c_decay_initial < c_fan_tail``). Returns ``None`` when ``c_fan_tail`` 

744 is not strictly between ``c_fixed`` and ``c_decay_initial`` — e.g. full drying 

745 (``c_fan_tail == c_fixed``), where the decay asymptotically merges with 

746 the fixed state and no finite exhaustion event occurs. 

747 

748 Returns 

749 ------- 

750 float or None 

751 Cumulative flow θ at exhaustion, or ``None`` if not reached. 

752 """ 

753 # An interior exhaustion needs c_fan_tail strictly between c_fixed and 

754 # c_decay_initial (orientation-agnostic via min/max). Full drying 

755 # (c_fan_tail == c_fixed) merges asymptotically with no finite crossing — 

756 # return None rather than grow the bracket forever (van Genuchten would hang). 

757 c_lo = min(self.c_fixed, self.c_decay_initial) 

758 c_hi = max(self.c_fixed, self.c_decay_initial) 

759 if not (c_lo < self.c_fan_tail < c_hi): 

760 return None 

761 

762 theta_local_collision = self.theta_start - self.theta_origin 

763 

764 if self._numerical: 

765 # The numerical forward map saturates AT c_fan_tail (it never crosses it), 

766 # so a forward-map bracket cannot see the crossing for either decay 

767 # orientation. Evaluate θ_local(c_fan_tail) from the un-clamped invariant 

768 # directly: the gate above guarantees c_fan_tail is the reachable limit, so 

769 # it is exactly the cached profile's endpoint ``i_max``. 

770 _c_of_i, i_max, _c_limit_node = self._decay_profile() 

771 return self.theta_origin + theta_local_collision * float(np.exp(i_max)) 

772 

773 # Closed forms cross c_fan_tail smoothly (always a shrinking decay); invert the 

774 # monotone forward map by bracketing (orientation-agnostic — no early return). 

775 def f(theta_local: float) -> float: 

776 return self._c_decay_at_theta_local(theta_local) - self.c_fan_tail 

777 

778 theta_local_exhaust = _invert_monotone_theta_local( 

779 f, theta_hi_seed=theta_local_collision, f_seed=f(theta_local_collision) 

780 ) 

781 if theta_local_exhaust is None: 

782 return None 

783 return self.theta_origin + theta_local_exhaust 

784 

785 def _c_decay_at_theta_local(self, theta_local: float) -> float: 

786 """Decaying concentration as a function of ``θ_local`` (apex-relative). 

787 

788 The SOLE isotherm dispatch site: closed where an exact form exists, 

789 otherwise the cached numerical decay profile. The closed forms 

790 (Freundlich ``c_fixed=0`` or ``n≈2``; Langmuir ``c_fixed=0``; 

791 Brooks-Corey ``c_fixed=0``) are selected by the ``__post_init__`` 

792 predicates; every other ``(isotherm, c_fixed)`` combination falls through 

793 to the per-wave cached invariant profile. Takes ``θ_local`` directly and 

794 skips the activity check. ``c_decay_at_theta``, ``position_at_theta``, 

795 ``theta_at_fan_exhaustion`` and ``outlet_crossing_theta`` all route 

796 through here rather than repeating the dispatch. 

797 """ 

798 theta_local_collision = self.theta_start - self.theta_origin 

799 s = self.sorption 

800 if self._freundlich_cf and isinstance(s, FreundlichSorption): 

801 return _c_decay_freundlich( 

802 s, self.K, self.c_decay_initial, self.c_fixed, theta_local_collision, theta_local 

803 ) 

804 if self._langmuir_cf and isinstance(s, LangmuirSorption): 

805 return _c_decay_langmuir(s, self.K, theta_local) 

806 if self._brooks_corey_cf and isinstance(s, BrooksCoreyConductivity): 

807 return _c_decay_brooks_corey(s, self.c_decay_initial, theta_local_collision, theta_local) 

808 

809 # Numerical path: invert the cached monotone θ_local(c) map. c_decay stays at 

810 # c_decay_initial up to the collision and clamps at the reachable c-limit past it. 

811 if theta_local <= theta_local_collision: 

812 return self.c_decay_initial 

813 c_of_i, i_max, c_limit_node = self._decay_profile() 

814 i_target = np.log(theta_local / theta_local_collision) 

815 if i_target >= i_max: 

816 return float(c_limit_node) 

817 return float(c_of_i(i_target)) 

818 

819 def outlet_crossing_theta(self, v_outlet: float) -> float | None: 

820 """Cumulative flow at which ``V_s = v_outlet``. 

821 

822 Returns ``None`` if the outlet is upstream of the wave's birth 

823 position or no crossing exists in ``(theta_start, +∞)``. The wave's 

824 current activity flag is not consulted — callers asking 

825 retrospectively about a historical crossing need the answer regardless 

826 of subsequent deactivation. 

827 

828 The closed-form Freundlich/Langmuir cases invert the fan-continuity 

829 identity ``V_s − v_origin = θ_local / R(c_decay)`` analytically (valid 

830 only when ``_c_decay_at_theta_local`` itself uses the closed form, so 

831 the same conditions are mirrored here); every other case inverts the 

832 monotone ``V_s(θ)`` via ``brentq``. 

833 """ 

834 if v_outlet <= self.v_start: 

835 return None 

836 

837 # V_s is monotonically increasing in θ (positive shock speed); invert 

838 # via the fan-continuity identity V_s - v_origin = θ_local / R(c_decay) 

839 # combined with the invariant to eliminate u, then solve for θ. 

840 s = self.sorption 

841 if self._freundlich_cf and isinstance(s, FreundlichSorption): 

842 return _outlet_crossing_freundlich( 

843 s, 

844 self.K, 

845 self.c_fixed, 

846 self.v_origin, 

847 self.theta_origin, 

848 v_outlet, 

849 ) 

850 if self._langmuir_cf and isinstance(s, LangmuirSorption): 

851 return _outlet_crossing_langmuir( 

852 s, 

853 self.K, 

854 self.v_origin, 

855 self.theta_origin, 

856 v_outlet, 

857 ) 

858 return self._outlet_crossing_numerical(v_outlet) 

859 

860 def _outlet_crossing_numerical(self, v_outlet: float) -> float | None: 

861 """θ at which ``V_s = v_outlet`` for every non-closed-form case. 

862 

863 ``V_s(θ) = v_origin + θ_local / R(c_decay(θ))`` is monotone increasing; 

864 invert by ``brentq`` on ``θ_local``. 

865 """ 

866 theta_local_collision = self.theta_start - self.theta_origin 

867 

868 def f(theta_local: float) -> float: 

869 c = self._c_decay_at_theta_local(theta_local) 

870 return self.v_origin + theta_local / float(self.sorption.retardation(c)) - v_outlet 

871 

872 f_lo = f(theta_local_collision) 

873 if f_lo >= 0.0: 

874 # Already at/past the outlet at collision; the linear-shock guards 

875 # in the solver handle the duplicate-crossing suppression. 

876 return self.theta_start 

877 # Seed at the collision (f_lo < 0 established above) and let the helper grow the 

878 # bracket upward — no dimensional floor, so crossings within θ_local < 1 of the 

879 # apex are found (mirrors theta_at_fan_exhaustion's closed-form bracket). 

880 theta_local_cross = _invert_monotone_theta_local(f, theta_hi_seed=theta_local_collision, f_seed=f_lo) 

881 if theta_local_cross is None: 

882 return None 

883 return self.theta_origin + theta_local_cross 

884 

885 def concentration_left(self) -> float: 

886 """Concentration on the left (upstream) side at θ=theta_start. 

887 

888 For ``decay_side='left'`` returns the decaying c at the collision 

889 moment; for ``decay_side='right'`` returns the fixed side. 

890 """ 

891 return self.c_decay_initial if self.decay_side == "left" else self.c_fixed 

892 

893 def concentration_right(self) -> float: 

894 """Concentration on the right (downstream) side at θ=theta_start. 

895 

896 For ``decay_side='right'`` returns the decaying c at the collision 

897 moment; for ``decay_side='left'`` returns the fixed side. 

898 """ 

899 return self.c_decay_initial if self.decay_side == "right" else self.c_fixed 

900 

901 def concentration_at_point(self, v: float, theta: float) -> float | None: 

902 """Concentration at ``(v, θ)`` if controlled by this decaying shock. 

903 

904 Three regions: 

905 

906 1. ``v == V_s(θ)`` (within FP): average of decay-side and fixed-side c. 

907 2. ``v > V_s(θ)`` (downstream): fixed-side c if ``decay_side='left'``; 

908 decay-side c at θ if ``decay_side='right'``. 

909 3. ``v < V_s(θ)`` (upstream, inside the fan): the fan's self-similar 

910 concentration ``R(c) = (θ − theta_origin)/(v − v_origin)``. Outside 

911 the fan — i.e. the decay-side characteristic from the apex hasn't 

912 reached v yet, OR the point lies beyond the ``c_fan_tail`` boundary 

913 (the fan's far edge) — returns ``None``. 

914 

915 Returns ``None`` for ``θ < theta_start`` or inactive waves. 

916 """ 

917 if not self.was_active_at(theta): 

918 return None 

919 

920 # Compute the decaying-side concentration once and derive V_s from it 

921 # (inlining position_at_theta's body) so the shock-face branch can reuse 

922 # it instead of re-running the numerical-isotherm root-find. 

923 theta_local = theta - self.theta_origin 

924 c_d = self._c_decay_at_theta_local(theta_local) 

925 v_s = float(self.v_origin + theta_local / float(self.sorption.retardation(c_d))) 

926 

927 tol = 1e-15 * max(abs(v_s), 1.0) 

928 

929 if abs(v - v_s) < tol: 

930 return 0.5 * (c_d + self.c_fixed) 

931 

932 # Region selection depends on decay_side: 

933 # 'left' (favorable n>1, Langmuir): fan extends upstream of V_s 

934 # (v < V_s), c_fixed downstream (v > V_s). 

935 # 'right' (n<1 mirror): fan extends downstream of V_s (v > V_s), 

936 # c_fixed upstream (v < V_s). 

937 if self.decay_side == "left": 

938 v_fan_side = v < v_s - tol 

939 v_fixed_side = v > v_s + tol 

940 else: 

941 v_fan_side = v > v_s + tol 

942 v_fixed_side = v < v_s - tol 

943 

944 if v_fixed_side: 

945 return self.c_fixed 

946 

947 if not v_fan_side: 

948 return None # within tol of shock face — handled above 

949 

950 # Fan-interior: self-similar profile with apex at (v_origin, theta_origin). 

951 if v == self.v_origin: 

952 return None 

953 r_target = (theta - self.theta_origin) / (v - self.v_origin) 

954 if r_target <= 1.0: 

955 return None 

956 try: 

957 c_fan = self.sorption.concentration_from_retardation(r_target) 

958 except NotImplementedError: 

959 return None 

960 c_fan = float(c_fan) 

961 

962 # The fan the DSW controls spans concentrations between the shock face 

963 # (c_decay, ≤ c_decay_initial) and the fan's far boundary c_fan_tail. 

964 # A point past c_fan_tail belongs to whatever lies beyond the fan, not 

965 # to this wave — reject so the fan is not extended past its extent. 

966 c_lo = min(self.c_fan_tail, self.c_decay_initial) 

967 c_hi = max(self.c_fan_tail, self.c_decay_initial) 

968 if c_fan < c_lo - EPSILON_POSITION or c_fan > c_hi + EPSILON_POSITION: 

969 return None 

970 return c_fan 

971 

972 

973def _invert_monotone_theta_local(f, *, theta_hi_seed: float, f_seed: float | None = None) -> float | None: 

974 """Bracket-then-brentq a monotone ``f(θ_local)`` with a sign change above the seed. 

975 

976 Shared by the closed-form branch of ``theta_at_fan_exhaustion`` and by 

977 ``_outlet_crossing_numerical``: both invert a monotone function of 

978 ``θ_local`` whose sign at the collision is already known to differ from its 

979 sign at large ``θ_local``. Geometrically grows ``θ_hi`` (``×2``, ≤200 iters) 

980 from ``theta_hi_seed`` until ``f`` flips sign, then inverts with ``brentq``. 

981 Returns ``None`` if no sign change is bracketed within the iteration budget. 

982 

983 Parameters 

984 ---------- 

985 f : callable 

986 Monotone residual ``f(θ_local)``; ``f(theta_hi_seed)`` and the far-field 

987 value must straddle zero. Caller-specific early sentinels 

988 (already-past-outlet) are handled by the caller. 

989 theta_hi_seed : float 

990 ``θ_local`` lower bracket; the search grows ``θ_hi`` from here. 

991 f_seed : float, optional 

992 Pre-evaluated ``f(theta_hi_seed)``; callers that already computed it pass 

993 it to avoid a redundant evaluation. ``None`` recomputes it here. 

994 

995 Returns 

996 ------- 

997 float or None 

998 Root ``θ_local`` of ``f``, or ``None`` if not bracketed. 

999 """ 

1000 if f_seed is None: 

1001 f_seed = f(theta_hi_seed) 

1002 theta_hi = theta_hi_seed 

1003 for _ in range(200): 

1004 theta_hi *= 2.0 

1005 if f(theta_hi) * f_seed < 0.0: 

1006 return float(brentq(f, theta_hi_seed, theta_hi, xtol=DECAYING_SHOCK_BRENTQ_XTOL)) 

1007 return None 

1008 

1009 

1010def _build_decay_profile( 

1011 sorption: NonlinearSorption, 

1012 c_decay_initial: float, 

1013 c_fixed: float, 

1014 c_fan_tail: float, 

1015) -> tuple: 

1016 r"""Build the per-wave monotone ``θ_local(c)`` map for collisions with no closed form. 

1017 

1018 Decay-agnostic: the fan-continuity + Rankine-Hugoniot relations do not 

1019 depend on which side decays. The secant speed 

1020 ``S(c) = (c − c_fixed)/(C_T(c) − C_T(c_fixed))`` is symmetric in 

1021 ``(c_decay, c_fixed)``, so the same invariant 

1022 ``θ_local(c) = θ_local_coll · exp(I(c))``, ``I(c) = ∫_{c0}^{c} R'/[(1 − R·S)·R] dc`` 

1023 (``R'`` by central finite difference) holds for Freundlich ``c_fixed>0, n≠2``, 

1024 Langmuir ``c_fixed>0``, Brooks-Corey ``c_fixed>0`` and any van Genuchten case 

1025 alike. ``I(c)`` is built ONCE by a single vectorised composite Gauss-Legendre 

1026 cumulative quadrature over a c-grid from ``c_decay_initial`` to the reachable 

1027 limit, then inverted by monotone-spline interpolation — replacing the former 

1028 quad-inside-brentq-inside-brentq scalar solve (~1000× fewer integrand evals 

1029 across a record). The reachable limit is the fan tail ``c_fan_tail`` UNLESS 

1030 ``c_fixed`` lies strictly between ``c_decay_initial`` and ``c_fan_tail`` — then 

1031 the secant speed has a pole at ``c_fixed`` (``R·S → 1``, ``θ_local → ∞``): the 

1032 shock asymptotes to the fixed state, so the grid stops a hair short of it and 

1033 ``c_decay`` clamps there. 

1034 

1035 Parameters 

1036 ---------- 

1037 sorption : NonlinearSorption 

1038 Sorption model. 

1039 c_decay_initial : float 

1040 Decaying-side concentration at the collision (``c0``) [mass/volume]. 

1041 c_fixed : float 

1042 Non-decaying-side concentration [mass/volume]. 

1043 c_fan_tail : float 

1044 Concentration at the fan's far boundary [mass/volume]; bounds the decay. 

1045 

1046 Returns 

1047 ------- 

1048 tuple 

1049 ``(c_of_i, i_max, c_limit_node)``: a ``CubicSpline`` mapping the cumulative 

1050 invariant ``I = ln(θ_local/θ_local_coll)`` to ``c_decay``, the endpoint 

1051 ``I`` of the reachable c-range, and the ``c`` at that endpoint. ``I`` is 

1052 collision-independent (``θ_local_coll`` enters only at query time). 

1053 """ 

1054 ct_fixed = float(sorption.total_concentration(c_fixed)) 

1055 

1056 def integrand(c): 

1057 c = np.asarray(c, dtype=float) 

1058 h = np.maximum(1e-9, 1e-7 * np.abs(c)) 

1059 r_prime = (np.asarray(sorption.retardation(c + h)) - np.asarray(sorption.retardation(c - h))) / (2.0 * h) 

1060 r = np.asarray(sorption.retardation(c)) 

1061 ct = np.asarray(sorption.total_concentration(c)) 

1062 secant = (c - c_fixed) / (ct - ct_fixed) 

1063 return r_prime / ((1.0 - r * secant) * r) 

1064 

1065 pole = (c_decay_initial - c_fixed) * (c_fan_tail - c_fixed) < 0.0 

1066 c_limit = c_fixed if pole else c_fan_tail 

1067 

1068 # c-grid from c0 to the reachable limit. Toward a pole (θ_local → ∞) the grid is 

1069 # geometric, stopping a fraction DECAY_PROFILE_POLE_FLOOR of the gap short; the 

1070 # non-pole grid reaches c_fan_tail exactly (so i_max is the exhaustion integral). 

1071 frac = np.linspace(0.0, 1.0, DECAY_PROFILE_NODES) 

1072 gap0 = abs(c_decay_initial - c_limit) 

1073 gaps = gap0 * DECAY_PROFILE_POLE_FLOOR**frac if pole else gap0 * (1.0 - frac) 

1074 c_nodes = c_limit + np.sign(c_decay_initial - c_limit) * gaps 

1075 

1076 # Cumulative composite Gauss-Legendre integral of the invariant integrand. 

1077 x_gl, w_gl = np.polynomial.legendre.leggauss(DECAY_PROFILE_GAUSS_ORDER) 

1078 lo = c_nodes[:-1] 

1079 hi = c_nodes[1:] 

1080 mid = 0.5 * (lo + hi) 

1081 half = 0.5 * (hi - lo) 

1082 points = mid[:, None] + half[:, None] * x_gl[None, :] 

1083 panel = (integrand(points.ravel()).reshape(points.shape) * w_gl[None, :]).sum(axis=1) * half 

1084 i_nodes = np.concatenate([[0.0], np.cumsum(panel)]) 

1085 

1086 # Inverse-interpolation precondition: keep the strictly-increasing prefix (the 

1087 # near-pole tail can lose monotonicity as the singular integrand outruns the grid). 

1088 non_increasing = np.nonzero(np.diff(i_nodes) <= 0.0)[0] 

1089 if non_increasing.size: 

1090 cut = non_increasing[0] + 1 

1091 i_nodes = i_nodes[:cut] 

1092 c_nodes = c_nodes[:cut] 

1093 c_of_i = CubicSpline(i_nodes, c_nodes) 

1094 return c_of_i, float(i_nodes[-1]), float(c_nodes[-1]) 

1095 

1096 

1097def _c_decay_brooks_corey( 

1098 sorption: BrooksCoreyConductivity, 

1099 c_decay_initial: float, 

1100 theta_local_collision: float, 

1101 theta_local: float, 

1102) -> float: 

1103 r"""Brooks-Corey ``c_fixed = 0`` closed form for the decaying-side concentration. 

1104 

1105 For Brooks-Corey with ``c_fixed = 0`` the product ``R·S = 1/a`` is constant 

1106 (``a = sorption.a``), so the universal invariant integrates to 

1107 ``θ_local ∝ R(c_decay)^{a/(a−1)}``. Inverting, 

1108 ``R(c_d) = R(c0)·(θ_local/θ_local_coll)^{(a−1)/a}`` and 

1109 ``c_d = concentration_from_retardation(R)``. 

1110 

1111 Parameters 

1112 ---------- 

1113 sorption : BrooksCoreyConductivity 

1114 Sorption model. 

1115 c_decay_initial : float 

1116 Decaying-side concentration at the collision [mass/volume]. 

1117 theta_local_collision : float 

1118 ``θ_local`` at the collision [m³]. 

1119 theta_local : float 

1120 ``θ_local`` at which to evaluate the decaying concentration [m³]. 

1121 

1122 Returns 

1123 ------- 

1124 float 

1125 Decaying-side concentration ``c`` at ``theta_local``. 

1126 """ 

1127 if theta_local <= theta_local_collision: 

1128 return c_decay_initial 

1129 a = sorption.a 

1130 r0 = float(sorption.retardation(c_decay_initial)) 

1131 r_target = r0 * (theta_local / theta_local_collision) ** ((a - 1.0) / a) 

1132 return float(sorption.concentration_from_retardation(r_target)) 

1133 

1134 

1135def _compute_k_freundlich( 

1136 sorption: FreundlichSorption, 

1137 theta_local: float, 

1138 c_decay_initial: float, 

1139 c_fixed: float, 

1140) -> float: 

1141 """Closed-form invariant K for Freundlich DecayingShockWave. 

1142 

1143 Derivation: see plan §"Closed-form derivations". For c_fixed=0, 

1144 K = θ_local · u_c^n / (n · u_c^(n-1) + α); for c_fixed>0 (n=2 only), 

1145 K = θ_local · (u_c - u_r)^2 / (2 · u_c + α). Here α = ρ_b · k_f / n_por. 

1146 

1147 Parameters 

1148 ---------- 

1149 sorption : FreundlichSorption 

1150 Sorption model. 

1151 theta_local : float 

1152 Cumulative flow from rarefaction apex to collision [m³]. 

1153 c_decay_initial : float 

1154 Decaying-side concentration at the collision [mass/volume]. 

1155 c_fixed : float 

1156 Non-decaying-side concentration [mass/volume]. 

1157 

1158 Returns 

1159 ------- 

1160 float 

1161 Invariant constant K. 

1162 """ 

1163 n = sorption.n 

1164 alpha = sorption.bulk_density * sorption.k_f / sorption.porosity 

1165 u_d = c_decay_initial ** (1.0 / n) 

1166 

1167 if c_fixed == 0.0: 

1168 return float(theta_local * u_d**n / (n * u_d ** (n - 1.0) + alpha)) 

1169 

1170 # c_fixed > 0, n=2 

1171 u_r = c_fixed**0.5 

1172 return float(theta_local * (u_d - u_r) ** 2 / (2.0 * u_d + alpha)) 

1173 

1174 

1175def _compute_k_langmuir( 

1176 sorption: LangmuirSorption, 

1177 theta_local: float, 

1178 c_decay_initial: float, 

1179) -> float: 

1180 """Closed-form invariant K for Langmuir DecayingShockWave (c_fixed=0). 

1181 

1182 K = θ_local · c_d^2 / ((K_L + c_d)^2 + a) with a = ρ_b · s_max · K_L / n_por. 

1183 

1184 Parameters 

1185 ---------- 

1186 sorption : LangmuirSorption 

1187 Sorption model. 

1188 theta_local : float 

1189 Cumulative flow from rarefaction apex to collision [m³]. 

1190 c_decay_initial : float 

1191 Decaying-side concentration at the collision [mass/volume]. 

1192 

1193 Returns 

1194 ------- 

1195 float 

1196 Invariant constant K. 

1197 """ 

1198 return float(theta_local * c_decay_initial**2 / ((sorption.k_l + c_decay_initial) ** 2 + sorption.a_coeff)) 

1199 

1200 

1201def _c_decay_freundlich( 

1202 sorption: FreundlichSorption, 

1203 k_invariant: float, 

1204 c_decay_initial: float, 

1205 c_fixed: float, 

1206 theta_local_collision: float, 

1207 theta_local: float, 

1208) -> float: 

1209 """Invert the Freundlich invariant to get c on the decaying side at θ_local. 

1210 

1211 For n=2 c_fixed=0 (quadratic in u): closed form 

1212 ``u = (K + sqrt(K^2 + K·θ_local·α)) / θ_local``. For general n with 

1213 c_fixed=0 (transcendental): brentq on the monotone bracket 

1214 ``(tiny, c_decay_initial^(1/n)]``. For n=2 c_fixed>0 (quadratic in u with u_r): 

1215 closed form with positive sign chosen to give u > u_r as θ_local → ∞. 

1216 

1217 Returns 

1218 ------- 

1219 float 

1220 Decaying-side concentration c at θ_local. 

1221 """ 

1222 n = sorption.n 

1223 alpha = sorption.bulk_density * sorption.k_f / sorption.porosity 

1224 

1225 if c_fixed == 0.0: 

1226 if np.isclose(n, 2.0, rtol=1e-12): 

1227 disc = k_invariant * k_invariant + theta_local * k_invariant * alpha 

1228 u = (k_invariant + np.sqrt(disc)) / theta_local 

1229 return float(u * u) 

1230 u_root = _invert_freundlich_cr_zero(k_invariant, c_decay_initial, n, alpha, theta_local_collision, theta_local) 

1231 return float(u_root**n) 

1232 

1233 # n=2, c_fixed > 0 

1234 u_r = c_fixed**0.5 

1235 disc = k_invariant * (theta_local * (2.0 * u_r + alpha) + k_invariant) 

1236 u = (u_r * theta_local + k_invariant + np.sqrt(disc)) / theta_local 

1237 return float(u * u) 

1238 

1239 

1240def _invert_freundlich_cr_zero( 

1241 k_invariant: float, 

1242 c_decay_initial: float, 

1243 n: float, 

1244 alpha: float, 

1245 theta_local_collision: float, 

1246 theta_local: float, 

1247) -> float: 

1248 """Invert ``θ_local · u^n = K · (n·u^(n-1) + α)`` for u via brentq. 

1249 

1250 Returns 

1251 ------- 

1252 float 

1253 Root u of the invariant at θ_local. 

1254 """ 

1255 u_collision = c_decay_initial ** (1.0 / n) 

1256 

1257 def f(u: float) -> float: 

1258 return theta_local * u**n - k_invariant * (n * u ** (n - 1.0) + alpha) 

1259 

1260 if theta_local <= theta_local_collision: 

1261 # Earlier than (or at) collision: c_decay equals c_decay_initial. 

1262 return u_collision 

1263 

1264 u_root = brentq(f, DECAYING_SHOCK_U_FLOOR, u_collision, xtol=1e-15) 

1265 return float(u_root) # type: ignore[arg-type] 

1266 

1267 

1268def _c_decay_langmuir(sorption: LangmuirSorption, k_invariant: float, theta_local: float) -> float: 

1269 """Invert the Langmuir invariant ``θ_local · c^2 = K · ((K_L+c)^2 + a)`` for c. 

1270 

1271 Expanded: ``(θ_local - K)·c^2 - 2·K·K_L·c - K·(K_L^2 + a) = 0`` (quadratic 

1272 in c). Positive root chosen for c > 0. 

1273 

1274 Returns 

1275 ------- 

1276 float 

1277 Decaying-side concentration c at θ_local. 

1278 """ 

1279 k_l = sorption.k_l 

1280 a_coeff = sorption.a_coeff 

1281 denom = theta_local - k_invariant 

1282 disc = k_invariant * (k_invariant * k_l * k_l + denom * (k_l * k_l + a_coeff)) 

1283 return float((k_invariant * k_l + np.sqrt(disc)) / denom) 

1284 

1285 

1286def _outlet_crossing_freundlich( 

1287 sorption: FreundlichSorption, 

1288 k_invariant: float, 

1289 c_fixed: float, 

1290 v_origin: float, 

1291 theta_origin: float, 

1292 v_outlet: float, 

1293) -> float | None: 

1294 """θ at which a Freundlich DecayingShockWave reaches v_outlet. 

1295 

1296 Returns 

1297 ------- 

1298 float or None 

1299 Cumulative flow at crossing, or None if no crossing. 

1300 """ 

1301 n = sorption.n 

1302 alpha = sorption.bulk_density * sorption.k_f / sorption.porosity 

1303 delta_v = v_outlet - v_origin 

1304 

1305 if c_fixed == 0.0: 

1306 u_target = n * k_invariant / delta_v 

1307 if u_target <= 0.0: 

1308 return None 

1309 theta_local = k_invariant * (n * u_target ** (n - 1.0) + alpha) / u_target**n 

1310 return float(theta_origin + theta_local) 

1311 

1312 # n=2, c_fixed > 0: V_s - v_origin = 2·K·u / (u - u_r)^2 ⇒ quadratic in u. 

1313 # The plus-sqrt root always satisfies u > u_r for K, alpha, delta_v > 0: 

1314 # the quadratic's roots multiply to u_r² and sum to 2u_r + 2K/delta_v > 2u_r, 

1315 # so exactly one root exceeds u_r and it is the plus-sqrt branch. The 

1316 # minus-sqrt root is the unphysical companion. 

1317 u_r = c_fixed**0.5 

1318 b_coef = -(2.0 * delta_v * u_r + 2.0 * k_invariant) 

1319 c_coef = delta_v * u_r * u_r 

1320 disc = b_coef * b_coef - 4.0 * delta_v * c_coef 

1321 if disc < 0: 

1322 return None 

1323 u_target = (-b_coef + np.sqrt(disc)) / (2.0 * delta_v) 

1324 theta_local = k_invariant * (2.0 * u_target + alpha) / (u_target - u_r) ** 2 

1325 return float(theta_origin + theta_local) 

1326 

1327 

1328def _outlet_crossing_langmuir( 

1329 sorption: LangmuirSorption, 

1330 k_invariant: float, 

1331 v_origin: float, 

1332 theta_origin: float, 

1333 v_outlet: float, 

1334) -> float | None: 

1335 """θ at which a Langmuir DecayingShockWave reaches v_outlet. 

1336 

1337 From V_s - v_origin = K·(K_L + c)^2 / c^2 ⇒ (K_L + c)/c = sqrt(Δv/K) =: ratio, 

1338 so c = K_L/(ratio - 1). Substitute into the invariant for θ_local. 

1339 

1340 Returns 

1341 ------- 

1342 float or None 

1343 Cumulative flow at crossing, or None if no crossing exists. 

1344 """ 

1345 delta_v = v_outlet - v_origin 

1346 ratio = np.sqrt(delta_v / k_invariant) 

1347 if ratio <= 1.0: 

1348 return None 

1349 c_target = sorption.k_l / (ratio - 1.0) 

1350 theta_local = k_invariant * ((sorption.k_l + c_target) ** 2 + sorption.a_coeff) / (c_target * c_target) 

1351 return float(theta_origin + theta_local)