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

598 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 20:54 +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 

9In (V, θ) every wave velocity is a property of the sorption isotherm alone — 

10flow does not enter wave dynamics. Time-varying flow is absorbed entirely into 

11the θ(t) mapping at the API boundary, so no wave needs recreation when the flow 

12rate changes. Attributes named ``theta_*`` are therefore cumulative flow [m³], 

13never time; ``v_*`` are volumetric positions [m³] measured from the inlet face 

14``V = 0``. Every module in :mod:`gwtransport.fronttracking` follows this convention. 

15 

16Available functions: 

17 

18- :class:`Wave` - Abstract base of the hierarchy. Holds the formation point ``(v_start, theta_start)``, the 

19 ``is_active`` flag and the ``theta_deactivation`` history marker, and requires ``position_at_theta``, 

20 ``concentration_left``, ``concentration_right`` and ``concentration_at_point`` from every subclass. Use 

21 ``was_active_at(theta)`` for retrospective queries; ``is_active`` describes only the current state. 

22 

23- :class:`CharacteristicWave` - A single characteristic line carrying one constant concentration at the 

24 flow-free speed ``1/R(c)``. Physically a contact discontinuity in a smooth region: the concentration value 

25 travels unchanged, and ``c_ahead`` records the state it separates from downstream. 

26 

27- :class:`ShockWave` - A sharp front across which concentration jumps from ``c_left`` to ``c_right``, formed 

28 where faster water overtakes slower water. Its ``speed`` is the Rankine-Hugoniot secant 

29 ``(c_R − c_L)/(C_T(c_R) − C_T(c_L))``, constant in θ; ``satisfies_entropy`` tests the Lax condition. 

30 

31- :class:`RarefactionWave` - An expansion fan spreading between ``c_head`` and ``c_tail``, formed where 

32 slower water follows faster water. The interior is self-similar, ``R(c) = (θ − θ_start)/(V − v_start)``, so 

33 head and tail move at the characteristic speeds ``1/R(c_head)`` and ``1/R(c_tail)``; construction is 

34 rejected when the head is not faster than the tail (that geometry is a compression, hence a shock). 

35 

36- :class:`DecayingShockWave` - A shock with a fan on one side, formed when a rarefaction and a shock collide. 

37 The fan feeds the ``decay_side``, whose concentration relaxes from ``c_decay_initial`` toward the fan's far 

38 bound ``c_fan_tail`` while the other side stays at ``c_fixed``, so the front decelerates along a curved 

39 trajectory instead of a straight line. The trajectory uses a closed form where one exists (Freundlich with 

40 ``c_fixed = 0`` or ``n = 2``, Langmuir and Brooks-Corey with ``c_fixed = 0``) and a cached quadrature 

41 profile otherwise; ``theta_at_fan_exhaustion`` reports the θ at which the fan is spent and the wave hands 

42 off to a plain shock. 

43 

44- :class:`DoubleFanShockWave` - A shock fed by a self-similar fan on *both* sides, formed when a fan boundary 

45 catches a decaying shock whose surviving side is itself a fan. Both side concentrations then relax as the 

46 front advances. The trajectory is closed form for Freundlich ``n = 2`` with a shared apex position (the 

47 case every inlet-born fan produces) and a cached RK4 spline otherwise; a side ending is detected 

48 externally, as the shock face crossing its own fan boundary line. 

49 

50- :class:`Feeder` - The boundary state on one side of a front: either a constant concentration or a bounded 

51 self-similar fan with apex ``(v_apex, theta_apex)`` spanning ``[c_a, c_b]``, evaluated by inverting 

52 ``R = (θ − θ_apex)/(v − v_apex)`` and clamped in ``R``-space so a query past the fan edge reads the plateau 

53 value. Feeders are the common currency of the interaction calculus in 

54 :mod:`gwtransport.fronttracking.interactions`. 

55 

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

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

58""" 

59 

60from abc import ABC, abstractmethod 

61from dataclasses import dataclass, field 

62 

63import numpy as np 

64from scipy.interpolate import CubicSpline 

65from scipy.optimize import brentq 

66 

67from gwtransport.fronttracking.math import ( 

68 _C_MIN, 

69 BrooksCoreyConductivity, 

70 FreundlichSorption, 

71 LangmuirSorption, 

72 NonlinearSorption, 

73 SorptionModel, 

74 characteristic_speed, 

75) 

76 

77# Numerical tolerance constants 

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

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

80DECAYING_SHOCK_BRENTQ_XTOL = ( 

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

82) 

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

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

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

86DECAY_PROFILE_NODES = 6000 

87DECAY_PROFILE_GAUSS_ORDER = 10 

88DECAY_PROFILE_POLE_FLOOR = 1e-6 

89# DoubleFanShockWave numerical-trajectory RK4 substeps per unit of fan age (only used when 

90# no closed form applies — distinct fan apex positions or a non-n=2 isotherm). The 

91# self-similar fans vary on the scale of their age, so the step is age/this-many. 

92DFSW_RK_SUBSTEPS = 512 

93 

94 

95@dataclass(frozen=True) 

96class Feeder: 

97 """One side's boundary state feeding a front: a constant, or a bounded self-similar fan. 

98 

99 A ``const`` feeder ignores ``(v, θ)`` and returns its value everywhere. A ``fan`` 

100 feeder evaluates the self-similar retardation ``R = (θ − θ_apex)/(v − v_apex)`` and 

101 inverts it to a concentration, clamped to the fan's physical extent ``[c_a, c_b]``. 

102 The clamp is monotonicity-agnostic (it clamps in ``R``-space, so it is correct for 

103 both R-decreasing isotherms — Freundlich ``n>1``, Langmuir, Brooks-Corey, 

104 van Genuchten-Mualem — and the R-increasing Freundlich ``n<1`` mirror), and it is 

105 exactly what makes a fan feeder read the plateau concentration beyond the fan's edge. 

106 

107 Feeders are the uniform currency of the interaction calculus: every wave exposes its 

108 sides as feeders, event handlers form a successor from ``(rear.left, front.right)``, 

109 and the reader evaluates the left feeder of the nearest downstream face. 

110 """ 

111 

112 c_a: float 

113 """One physical boundary concentration of the fan (or the constant value).""" 

114 c_b: float 

115 """The other physical boundary concentration of the fan (unused for a constant).""" 

116 is_const: bool 

117 """Whether this is a constant state (``True``) or a self-similar fan (``False``).""" 

118 v_apex: float = 0.0 

119 """Fan apex position [m³] (ignored for a constant).""" 

120 theta_apex: float = 0.0 

121 """Fan apex cumulative flow [m³] (ignored for a constant).""" 

122 sorption: SorptionModel | None = None 

123 """Sorption model used to invert ``R`` (required for a fan; ``None`` for a constant).""" 

124 far_boundary_free: bool = True 

125 """Whether the fan's far edge is a free plateau boundary (a collision line) rather than 

126 already terminated by another shock. Propagated through merges so a wave born onto a 

127 fan whose far end another wave owns does not re-expose a phantom boundary line.""" 

128 

129 @classmethod 

130 def constant(cls, c: float) -> "Feeder": 

131 """Return a constant-concentration feeder.""" 

132 return cls(c_a=c, c_b=c, is_const=True) 

133 

134 @classmethod 

135 def fan( 

136 cls, 

137 v_apex: float, 

138 theta_apex: float, 

139 c_a: float, 

140 c_b: float, 

141 sorption: SorptionModel, 

142 *, 

143 far_boundary_free: bool = True, 

144 ) -> "Feeder": 

145 """Return a bounded self-similar fan feeder with apex ``(v_apex, theta_apex)`` spanning ``[c_a, c_b]``.""" 

146 return cls( 

147 c_a=c_a, 

148 c_b=c_b, 

149 is_const=False, 

150 v_apex=v_apex, 

151 theta_apex=theta_apex, 

152 sorption=sorption, 

153 far_boundary_free=far_boundary_free, 

154 ) 

155 

156 def value(self, v: float, theta: float) -> float: 

157 """Concentration this feeder supplies at ``(v, θ)`` (clamped to the fan extent).""" 

158 if self.is_const: 

159 return self.c_a 

160 sorption = self.sorption 

161 assert sorption is not None # noqa: S101 # a fan feeder always carries its sorption model 

162 r_a = float(sorption.retardation(self.c_a)) 

163 r_b = float(sorption.retardation(self.c_b)) 

164 # c at the smaller-R (faster, "head") and larger-R (slower, "tail"/apex) ends. 

165 if r_a <= r_b: 

166 r_lo, r_hi, c_at_lo, c_at_hi = r_a, r_b, self.c_a, self.c_b 

167 else: 

168 r_lo, r_hi, c_at_lo, c_at_hi = r_b, r_a, self.c_b, self.c_a 

169 if theta <= self.theta_apex or v <= self.v_apex: 

170 return c_at_hi # at/behind the apex: the largest-R (tail) end 

171 r = (theta - self.theta_apex) / (v - self.v_apex) 

172 if r <= r_lo: 

173 return c_at_lo 

174 if r >= r_hi: 

175 return c_at_hi 

176 return float(sorption.concentration_from_retardation(r)) 

177 

178 

179@dataclass 

180class Wave(ABC): 

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

182 

183 All waves share common attributes and must implement methods for 

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

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

186 future interactions). 

187 """ 

188 

189 theta_start: float 

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

191 v_start: float 

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

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

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

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

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

197 

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

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

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

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

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

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

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

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

206 the wave. 

207 """ 

208 

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

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

211 

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

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

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

215 

216 Parameters 

217 ---------- 

218 theta : float 

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

220 

221 Returns 

222 ------- 

223 bool 

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

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

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

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

228 wave excluded from dispatch entirely. 

229 """ 

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

231 return False 

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

233 

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

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

236 

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

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

239 ``was_active_at`` queries). 

240 

241 Parameters 

242 ---------- 

243 theta : float 

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

245 """ 

246 self.is_active = False 

247 self.theta_deactivation = theta 

248 

249 @abstractmethod 

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

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

252 

253 Parameters 

254 ---------- 

255 theta : float 

256 Cumulative flow [m³]. 

257 

258 Returns 

259 ------- 

260 position : float or None 

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

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

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

264 check.) 

265 """ 

266 

267 @abstractmethod 

268 def concentration_left(self) -> float: 

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

270 

271 @abstractmethod 

272 def concentration_right(self) -> float: 

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

274 

275 @abstractmethod 

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

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

278 

279 Returns 

280 ------- 

281 concentration : float or None 

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

283 otherwise. 

284 """ 

285 

286 

287@dataclass 

288class CharacteristicWave(Wave): 

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

290 

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

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

293 constant. This is the fundamental solution element for hyperbolic 

294 conservation laws. 

295 

296 Examples 

297 -------- 

298 >>> sorption = FreundlichSorption( 

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

300 ... ) 

301 >>> char = CharacteristicWave( 

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

303 ... ) 

304 >>> speed = char.speed() 

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

306 True 

307 """ 

308 

309 concentration: float 

310 """Constant concentration carried on the upstream (behind) side [mass/volume].""" 

311 sorption: SorptionModel 

312 """Sorption model determining the speed.""" 

313 c_ahead: float = field(default=0.0, kw_only=True) 

314 """Concentration on the downstream (ahead) side — the state the contact advances into. 

315 

316 A contact separates the carried ``concentration`` (behind, upstream) from ``c_ahead`` 

317 (ahead, downstream, the pre-existing state). The solver sets it to the previous inlet 

318 value; it defaults to ``0`` (the virgin initial condition) for a lone contact. The 

319 reader sweep uses it as the contact's downstream feeder.""" 

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

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

322 

323 def __post_init__(self) -> None: 

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

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

326 

327 def speed(self) -> float: 

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

329 return self._speed 

330 

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

332 """Position at cumulative flow θ. 

333 

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

335 """ 

336 if not self.was_active_at(theta): 

337 return None 

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

339 

340 def concentration_left(self) -> float: 

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

342 return self.concentration 

343 

344 def concentration_right(self) -> float: 

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

346 return self.concentration 

347 

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

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

350 v_at_theta = self.position_at_theta(theta) 

351 if v_at_theta is None: 

352 return None 

353 

354 if v_at_theta >= v: 

355 return self.concentration 

356 

357 return None 

358 

359 

360@dataclass 

361class ShockWave(Wave): 

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

363 

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

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

366 condition and is independent of flow:: 

367 

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

369 

370 Examples 

371 -------- 

372 >>> sorption = FreundlichSorption( 

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

374 ... ) 

375 >>> shock = ShockWave( 

376 ... theta_start=0.0, 

377 ... v_start=0.0, 

378 ... c_left=10.0, 

379 ... c_right=2.0, 

380 ... sorption=sorption, 

381 ... ) 

382 >>> shock.speed > 0 

383 True 

384 >>> shock.satisfies_entropy() 

385 True 

386 """ 

387 

388 c_left: float 

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

390 c_right: float 

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

392 sorption: SorptionModel 

393 """Sorption model.""" 

394 speed: float = field(init=False) 

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

396 

397 def __post_init__(self) -> None: 

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

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

400 

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

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

403 if not self.was_active_at(theta): 

404 return None 

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

406 

407 def concentration_left(self) -> float: 

408 """Upstream concentration of the shock.""" 

409 return self.c_left 

410 

411 def concentration_right(self) -> float: 

412 """Downstream concentration of the shock.""" 

413 return self.c_right 

414 

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

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

417 

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

419 shock is infinitesimally thin in practice). 

420 """ 

421 v_shock = self.position_at_theta(theta) 

422 if v_shock is None: 

423 return None 

424 

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

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

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

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

429 

430 if v < v_shock - tol: 

431 return self.c_left 

432 if v > v_shock + tol: 

433 return self.c_right 

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

435 

436 def satisfies_entropy(self) -> bool: 

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

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

439 

440 

441@dataclass 

442class RarefactionWave(Wave): 

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

444 

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

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

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

448 

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

450 

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

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

453 

454 Raises 

455 ------ 

456 ValueError 

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

458 

459 Examples 

460 -------- 

461 >>> sorption = FreundlichSorption( 

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

463 ... ) 

464 >>> raref = RarefactionWave( 

465 ... theta_start=0.0, 

466 ... v_start=0.0, 

467 ... c_head=10.0, 

468 ... c_tail=2.0, 

469 ... sorption=sorption, 

470 ... ) 

471 >>> raref.head_speed() > raref.tail_speed() 

472 True 

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

474 True 

475 """ 

476 

477 c_head: float 

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

479 c_tail: float 

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

481 sorption: SorptionModel 

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

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

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

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

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

487 

488 def __post_init__(self): 

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

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

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

492 

493 if self._head_speed <= self._tail_speed: 

494 msg = ( 

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

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

497 ) 

498 raise ValueError(msg) 

499 

500 def head_speed(self) -> float: 

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

502 return self._head_speed 

503 

504 def tail_speed(self) -> float: 

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

506 return self._tail_speed 

507 

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

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

510 if not self.was_active_at(theta): 

511 return None 

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

513 

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

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

516 if not self.was_active_at(theta): 

517 return None 

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

519 

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

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

522 return self.head_position_at_theta(theta) 

523 

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

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

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

527 return False 

528 

529 v_head = self.head_position_at_theta(theta) 

530 v_tail = self.tail_position_at_theta(theta) 

531 

532 if v_head is None or v_tail is None: 

533 return False 

534 

535 return v_tail <= v <= v_head 

536 

537 def concentration_left(self) -> float: 

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

539 return self.c_tail 

540 

541 def concentration_right(self) -> float: 

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

543 return self.c_head 

544 

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

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

547 

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

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

550 returns None. 

551 

552 Examples 

553 -------- 

554 >>> sorption = FreundlichSorption( 

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

556 ... ) 

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

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

559 >>> c is not None 

560 True 

561 >>> 2.0 <= c <= 10.0 

562 True 

563 """ 

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

565 return self.c_tail 

566 

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

568 return None 

569 

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

571 

572 if r_target <= 1.0: 

573 return None # Unphysical 

574 

575 try: 

576 c = self.sorption.concentration_from_retardation(r_target) 

577 except NotImplementedError: 

578 # ConstantRetardation case — rarefactions don't form 

579 return None 

580 

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

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

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

584 # get the correct boundary concentration. 

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

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

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

588 

589 

590@dataclass 

591class DecayingShockWave(Wave): 

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

593 

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

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

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

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

598 

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

600 

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

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

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

604 downstream c_right is ``c_fixed``). 

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

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

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

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

609 

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

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

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

613 

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

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

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

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

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

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

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

621 

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

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

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

625 - Freundlich, ``c_fixed > 0``, ``n = 2`` (either decay orientation) — closed form: 

626 invariant ``(u_d - u_R)² · θ_local = K · (2 u_d + α)`` with ``u_R := c_fixed^(1/2)``, 

627 position ``V_s(θ) = v_origin + 2 K · u_d(θ) / (u_d - u_R)²``. The root of the 

628 quadratic in ``u_d`` is selected by the decay orientation (``u_d > u_R`` shrinking, 

629 ``u_d < u_R`` growing); both are exact (verified to ~1e-14 vs a DOP853 integration). 

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

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

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

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

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

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

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

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

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

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

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

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

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

643 spline interpolation. 

644 

645 Every path shares the fan-continuity identity 

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

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

648 

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

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

651 ``theta_start``/``v_start`` are the collision coordinates; a fan-consistent 

652 construction has ``v_start = v_origin + (theta_start − theta_origin)/R(c_decay_initial)``. 

653 

654 See Also 

655 -------- 

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

657 RarefactionWave : Self-similar expansion fan. 

658 """ 

659 

660 c_decay_initial: float 

661 """Concentration on the decaying side at θ=theta_start [mass/volume]. Non-negative. 

662 

663 A fully-drained collision value of ``0`` is floored to the shared dry-soil singularity 

664 floor ``_C_MIN`` so the retardation and secant-speed evaluations stay finite.""" 

665 c_fixed: float 

666 """Concentration on the non-decaying side [mass/volume]. Non-negative, constant in θ.""" 

667 c_fan_tail: float 

668 """Concentration at the fan's far boundary [mass/volume]; bounds the decay. Non-negative. 

669 

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

671 ``c_fan_tail`` the fan is exhausted.""" 

672 decay_side: str 

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

674 v_origin: float 

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

676 theta_origin: float 

677 """Cumulative flow at the rarefaction apex [m³]; strictly less than ``theta_start``.""" 

678 sorption: NonlinearSorption 

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

680 K: float = field(init=False) 

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

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

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

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

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

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

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

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

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

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

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

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

693 fan_boundary_consumed: bool = field(default=False, kw_only=True) 

694 """Whether the fan's far-boundary line is owned from birth (a fan-entry successor rides a 

695 fan another wave already terminates downstream, so its boundary is never a free face).""" 

696 theta_fan_boundary_consumed: float = field(default=float("inf"), kw_only=True) 

697 """Cumulative flow at which a *free* boundary line was later consumed by a wave entering 

698 the fan. Retrospective reader/event queries treat the boundary as free only for 

699 ``θ < theta_fan_boundary_consumed`` (historical truth, mirroring ``was_active_at``).""" 

700 

701 def __post_init__(self) -> None: 

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

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

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

705 raise ValueError(msg) 

706 if self.c_decay_initial < 0.0: 

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

708 raise ValueError(msg) 

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

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

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

712 if self.c_fixed < 0.0: 

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

714 raise ValueError(msg) 

715 if self.c_fan_tail < 0.0: 

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

717 raise ValueError(msg) 

718 if self.theta_origin >= self.theta_start: 

719 msg = ( 

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

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

722 ) 

723 raise ValueError(msg) 

724 

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

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

727 raise TypeError(msg) 

728 

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

730 # Freundlich c_fixed=0 (general n) or the n≈2 quadratic for either decay orientation 

731 # (shrinking c_decay_initial > c_fixed picks the +√ root, growing c_decay_initial < 

732 # c_fixed the −√ root; both invert the same (u_d−u_R)² invariant, verified exact to 

733 # ~1e-14 vs a DOP853 integration of the fan-fed shock ODE); 

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

735 s = self.sorption 

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

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

738 ) 

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

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

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

742 

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

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

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

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

747 self.K = float("nan") 

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

749 self.K = _compute_k_freundlich( 

750 s, 

751 self.theta_start - self.theta_origin, 

752 self.c_decay_initial, 

753 self.c_fixed, 

754 ) 

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

756 self.K = _compute_k_langmuir( 

757 s, 

758 self.theta_start - self.theta_origin, 

759 self.c_decay_initial, 

760 ) 

761 

762 def _decay_profile(self) -> tuple: 

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

764 

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

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

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

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

769 """ 

770 if self._decay_profile_cache is None: 

771 self._decay_profile_cache = _build_decay_profile( 

772 self.sorption, 

773 self.c_decay_initial, 

774 self.c_fixed, 

775 self.c_fan_tail, 

776 ) 

777 return self._decay_profile_cache 

778 

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

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

781 

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

783 otherwise delegates to the single per-isotherm dispatch in 

784 ``_c_decay_at_theta_local``. 

785 """ 

786 if not self.was_active_at(theta): 

787 return None 

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

789 

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

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

792 

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

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

795 """ 

796 if not self.was_active_at(theta): 

797 return None 

798 

799 theta_local = theta - self.theta_origin 

800 c_d = self._c_decay_at_theta_local(theta_local) 

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

802 

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

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

805 

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

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

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

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

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

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

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

813 the fixed state and no finite exhaustion event occurs. 

814 

815 Returns 

816 ------- 

817 float or None 

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

819 """ 

820 # An interior exhaustion needs c_fan_tail strictly between c_fixed and 

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

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

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

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

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

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

827 return None 

828 

829 theta_local_collision = self.theta_start - self.theta_origin 

830 

831 if self._numerical: 

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

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

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

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

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

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

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

839 

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

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

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

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

844 

845 theta_local_exhaust = _invert_monotone_theta_local( 

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

847 ) 

848 if theta_local_exhaust is None: 

849 return None 

850 return self.theta_origin + theta_local_exhaust 

851 

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

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

854 

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

856 otherwise the cached numerical decay profile. The closed forms 

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

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

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

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

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

862 ``theta_at_fan_exhaustion`` and ``outlet_crossing_theta`` all route 

863 through here rather than repeating the dispatch. 

864 """ 

865 theta_local_collision = self.theta_start - self.theta_origin 

866 s = self.sorption 

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

868 return _c_decay_freundlich( 

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

870 ) 

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

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

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

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

875 

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

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

878 if theta_local <= theta_local_collision: 

879 return self.c_decay_initial 

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

881 i_target = np.log(theta_local / theta_local_collision) 

882 if i_target >= i_max: 

883 return float(c_limit_node) 

884 return float(c_of_i(i_target)) 

885 

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

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

888 

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

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

891 current activity flag is not consulted — callers asking 

892 retrospectively about a historical crossing need the answer regardless 

893 of subsequent deactivation. 

894 

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

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

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

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

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

900 """ 

901 if v_outlet <= self.v_start: 

902 return None 

903 

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

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

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

907 s = self.sorption 

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

909 return _outlet_crossing_freundlich( 

910 s, 

911 self.K, 

912 self.c_decay_initial, 

913 self.c_fixed, 

914 self.v_origin, 

915 self.theta_origin, 

916 v_outlet, 

917 ) 

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

919 return _outlet_crossing_langmuir( 

920 s, 

921 self.K, 

922 self.v_origin, 

923 self.theta_origin, 

924 v_outlet, 

925 ) 

926 return self._outlet_crossing_numerical(v_outlet) 

927 

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

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

930 

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

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

933 """ 

934 theta_local_collision = self.theta_start - self.theta_origin 

935 

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

937 c = self._c_decay_at_theta_local(theta_local) 

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

939 

940 f_lo = f(theta_local_collision) 

941 if f_lo >= 0.0: 

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

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

944 return self.theta_start 

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

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

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

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

949 if theta_local_cross is None: 

950 return None 

951 return self.theta_origin + theta_local_cross 

952 

953 def concentration_left(self) -> float: 

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

955 

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

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

958 """ 

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

960 

961 def concentration_right(self) -> float: 

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

963 

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

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

966 """ 

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

968 

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

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

971 

972 Three regions: 

973 

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

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

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

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

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

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

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

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

982 

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

984 """ 

985 if not self.was_active_at(theta): 

986 return None 

987 

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

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

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

991 theta_local = theta - self.theta_origin 

992 c_d = self._c_decay_at_theta_local(theta_local) 

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

994 

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

996 

997 if abs(v - v_s) < tol: 

998 return 0.5 * (c_d + self.c_fixed) 

999 

1000 # Region selection depends on decay_side: 

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

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

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

1004 # c_fixed upstream (v < V_s). 

1005 if self.decay_side == "left": 

1006 v_fan_side = v < v_s - tol 

1007 v_fixed_side = v > v_s + tol 

1008 else: 

1009 v_fan_side = v > v_s + tol 

1010 v_fixed_side = v < v_s - tol 

1011 

1012 if v_fixed_side: 

1013 return self.c_fixed 

1014 

1015 if not v_fan_side: 

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

1017 

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

1019 if v == self.v_origin: 

1020 return None 

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

1022 if r_target <= 1.0: 

1023 return None 

1024 try: 

1025 c_fan = self.sorption.concentration_from_retardation(r_target) 

1026 except NotImplementedError: 

1027 return None 

1028 c_fan = float(c_fan) 

1029 

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

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

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

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

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

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

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

1037 return None 

1038 return c_fan 

1039 

1040 

1041@dataclass 

1042class DoubleFanShockWave(Wave): 

1043 r"""Shock fed by a self-similar fan on BOTH sides (a doubly-fed front). 

1044 

1045 Formed when a fan boundary (rarefaction head/tail, or another fan-fed shock) catches a 

1046 :class:`DecayingShockWave` whose surviving side is itself a fan — the merged front then 

1047 has a fan feeder on each side. The shock trajectory solves 

1048 

1049 .. math:: 

1050 \frac{dV}{d\theta} = S\bigl(c_L(V,\theta),\, c_R(V,\theta)\bigr), \qquad 

1051 R(c_i) = \frac{\theta - \theta_{{\rm apex},i}}{V - v_{{\rm apex},i}}, 

1052 

1053 with ``S`` the Rankine-Hugoniot secant and each ``c_i`` the self-similar value of its fan. 

1054 

1055 **Closed form (Freundlich ``n = 2``, shared apex position ``v_L = v_R = v_o``).** With 

1056 ``u_i = \sqrt{c_i} = A V'/(2(\tau_i - V'))`` (``V' = V - v_o``, ``\tau_i = \theta - 

1057 \theta_{{\rm apex},i}``, ``A = \rho_b k_f/n_{por}``), the product ``K = u_L u_R`` is a 

1058 first integral of the ODE, and the trajectory is the physical root of 

1059 

1060 .. math:: 

1061 (A^2 - 4K)\,V'^2 + 4K(\tau_L + \tau_R)\,V' - 4K\,\tau_L\,\tau_R = 0, 

1062 \qquad 0 < V' < \min(\tau_L, \tau_R). 

1063 

1064 Every fan born at the inlet shares ``v_o = 0``, so inlet-driven inputs use the closed 

1065 form. **General fallback** (distinct apex positions, or a non-``n=2`` isotherm): the ODE 

1066 is integrated once by fixed-step RK4 (``\Delta\theta = \text{fan age}/DFSW_RK_SUBSTEPS``, 

1067 speed-independent since the fans vary on the scale of their age) into a cached monotone 

1068 spline. Both paths answer position, side concentrations and outlet crossing uniformly 

1069 (side exhaustion is detected externally, as the shock face crossing its own fan boundary). 

1070 

1071 See Also 

1072 -------- 

1073 DecayingShockWave : One fan side; the doubly-fed front degrades to this on side exhaustion. 

1074 Feeder : The bounded-fan side descriptor this wave carries. 

1075 """ 

1076 

1077 left_feeder: Feeder 

1078 """Left (upstream) side feeder — a fan.""" 

1079 right_feeder: Feeder 

1080 """Right (downstream) side feeder — a fan.""" 

1081 sorption: NonlinearSorption 

1082 """Sorption model (concentration-dependent).""" 

1083 left_boundary_consumed: bool = field(default=False, kw_only=True) 

1084 """Whether the left fan's far-boundary line is owned from birth (not a free collision face).""" 

1085 right_boundary_consumed: bool = field(default=False, kw_only=True) 

1086 """Whether the right fan's far-boundary line is owned from birth (not a free collision face).""" 

1087 theta_left_boundary_consumed: float = field(default=float("inf"), kw_only=True) 

1088 """Cumulative flow at which a free left boundary was later consumed (historical, see DSW).""" 

1089 theta_right_boundary_consumed: float = field(default=float("inf"), kw_only=True) 

1090 """Cumulative flow at which a free right boundary was later consumed (historical, see DSW).""" 

1091 _closed_form: bool = field(init=False, repr=False, compare=False) 

1092 """Whether the n=2 shared-apex closed form applies.""" 

1093 _k: float = field(init=False, repr=False, compare=False) 

1094 """Closed-form first integral ``K = u_L·u_R`` (``nan`` for the numerical path).""" 

1095 _traj_cache: tuple | None = field(default=None, init=False, repr=False, compare=False) 

1096 """Lazily-built ``(theta_grid, CubicSpline)`` for the numerical trajectory.""" 

1097 

1098 def __post_init__(self) -> None: 

1099 """Classify closed-form vs numerical and set the first integral ``K``.""" 

1100 if self.left_feeder.is_const or self.right_feeder.is_const: 

1101 msg = "DoubleFanShockWave requires two fan feeders; use DecayingShockWave for a one-fan front" 

1102 raise ValueError(msg) 

1103 if self.theta_origin_left >= self.theta_start or self.theta_origin_right >= self.theta_start: 

1104 msg = "both fan apexes must precede the collision (theta_apex < theta_start)" 

1105 raise ValueError(msg) 

1106 s = self.sorption 

1107 shared_apex = abs(self.left_feeder.v_apex - self.right_feeder.v_apex) < EPSILON_POSITION 

1108 self._closed_form = isinstance(s, FreundlichSorption) and bool(np.isclose(s.n, 2.0, rtol=1e-12)) and shared_apex 

1109 self._k = float("nan") 

1110 if self._closed_form: 

1111 v_prime = self.v_start - self.left_feeder.v_apex 

1112 tau_l = self.theta_start - self.theta_origin_left 

1113 tau_r = self.theta_start - self.theta_origin_right 

1114 a = self._alpha() 

1115 u_l = a * v_prime / (2.0 * (tau_l - v_prime)) 

1116 u_r = a * v_prime / (2.0 * (tau_r - v_prime)) 

1117 self._k = u_l * u_r 

1118 

1119 @property 

1120 def theta_origin_left(self) -> float: 

1121 """Cumulative flow at the left fan's apex [m³].""" 

1122 return self.left_feeder.theta_apex 

1123 

1124 @property 

1125 def theta_origin_right(self) -> float: 

1126 """Cumulative flow at the right fan's apex [m³].""" 

1127 return self.right_feeder.theta_apex 

1128 

1129 def _alpha(self) -> float: 

1130 """Freundlich lumped coefficient ``A = ρ_b·k_f/n_por`` (closed-form path only).""" 

1131 s = self.sorption 

1132 assert isinstance(s, FreundlichSorption) # noqa: S101 

1133 return s.bulk_density * s.k_f / s.porosity 

1134 

1135 def _v_closed(self, theta: float) -> float: 

1136 """Closed-form shock position at ``theta`` (n=2 shared apex).""" 

1137 v_o = self.left_feeder.v_apex 

1138 tau_l = theta - self.theta_origin_left 

1139 tau_r = theta - self.theta_origin_right 

1140 a2 = self._alpha() ** 2 - 4.0 * self._k 

1141 a1 = 4.0 * self._k * (tau_l + tau_r) 

1142 a0 = -4.0 * self._k * tau_l * tau_r 

1143 if abs(a2) < EPSILON_POSITION: 

1144 # A² = 4K: the quadratic degenerates to linear a1·V' + a0 = 0. 

1145 v_prime = -a0 / a1 

1146 return v_o + v_prime 

1147 disc = a1 * a1 - 4.0 * a2 * a0 

1148 sqrt_disc = np.sqrt(max(disc, 0.0)) 

1149 upper = min(tau_l, tau_r) 

1150 for sign in (-1.0, 1.0): 

1151 v_prime = (-a1 + sign * sqrt_disc) / (2.0 * a2) 

1152 if 0.0 < v_prime < upper: 

1153 return v_o + v_prime 

1154 # Numerical fallback: pick the root closest to the valid open interval. 

1155 candidates = [(-a1 + sign * sqrt_disc) / (2.0 * a2) for sign in (-1.0, 1.0)] 

1156 v_prime = min(candidates, key=lambda x: abs(x - 0.5 * upper)) 

1157 return v_o + float(np.clip(v_prime, 0.0, upper)) 

1158 

1159 def _rhs(self, v: float, theta: float) -> float: 

1160 """Shock-speed ODE right side ``S(c_L, c_R)`` at ``(v, θ)`` (numerical path).""" 

1161 c_l = self.left_feeder.value(v, theta) 

1162 c_r = self.right_feeder.value(v, theta) 

1163 return float(self.sorption.shock_speed(c_l, c_r)) 

1164 

1165 def _ensure_numerical(self, theta: float) -> tuple: 

1166 """Build/extend the cached RK4 trajectory spline out to at least ``theta``.""" 

1167 age0 = max(min(self.theta_start - self.theta_origin_left, self.theta_start - self.theta_origin_right), 1.0) 

1168 step0 = age0 / DFSW_RK_SUBSTEPS 

1169 if self._traj_cache is None: 

1170 thetas = [self.theta_start] 

1171 vs = [self.v_start] 

1172 else: 

1173 thetas, vs, _ = self._traj_cache 

1174 thetas = list(thetas) 

1175 vs = list(vs) 

1176 # March past ``theta`` and always keep at least two nodes so CubicSpline is well-posed 

1177 # (a query at ``theta_start`` alone would otherwise leave a single node). 

1178 target = max(theta, self.theta_start + step0) 

1179 while thetas[-1] < target: 

1180 t0 = thetas[-1] 

1181 v0 = vs[-1] 

1182 age = min(t0 - self.theta_origin_left, t0 - self.theta_origin_right, age0 + (t0 - self.theta_start)) 

1183 h = max(age, age0) / DFSW_RK_SUBSTEPS 

1184 k1 = self._rhs(v0, t0) 

1185 k2 = self._rhs(v0 + 0.5 * h * k1, t0 + 0.5 * h) 

1186 k3 = self._rhs(v0 + 0.5 * h * k2, t0 + 0.5 * h) 

1187 k4 = self._rhs(v0 + h * k3, t0 + h) 

1188 vs.append(v0 + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)) 

1189 thetas.append(t0 + h) 

1190 theta_arr = np.asarray(thetas) 

1191 v_arr = np.asarray(vs) 

1192 spline = CubicSpline(theta_arr, v_arr) 

1193 self._traj_cache = (theta_arr, v_arr, spline) 

1194 return self._traj_cache 

1195 

1196 def _v_at(self, theta: float) -> float: 

1197 """Shock position at ``theta`` (closed form or cached numerical spline).""" 

1198 if self._closed_form: 

1199 return self._v_closed(theta) 

1200 _theta_arr, _v_arr, spline = self._ensure_numerical(theta) 

1201 return float(spline(theta)) 

1202 

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

1204 """Shock position ``V_s(θ)``; ``None`` for ``θ < theta_start`` or when inactive.""" 

1205 if not self.was_active_at(theta): 

1206 return None 

1207 return self._v_at(theta) 

1208 

1209 def concentration_left(self) -> float: 

1210 """Left-side concentration at the collision moment.""" 

1211 return self.left_feeder.value(self.v_start, self.theta_start) 

1212 

1213 def concentration_right(self) -> float: 

1214 """Right-side concentration at the collision moment.""" 

1215 return self.right_feeder.value(self.v_start, self.theta_start) 

1216 

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

1218 """Cumulative flow at which ``V_s = v_outlet`` (monotone, inverted by brentq). 

1219 

1220 ``θ_local`` is measured from the older of the two fan apexes, so the shared 

1221 bracket-then-brentq inversion sees a positive seed. 

1222 """ 

1223 if v_outlet <= self.v_start: 

1224 return None 

1225 theta_origin = min(self.theta_origin_left, self.theta_origin_right) 

1226 theta_local_cross = _invert_monotone_theta_local( 

1227 lambda theta_local: self._v_at(theta_origin + theta_local) - v_outlet, 

1228 theta_hi_seed=self.theta_start - theta_origin, 

1229 f_seed=self.v_start - v_outlet, 

1230 ) 

1231 if theta_local_cross is None: 

1232 return None 

1233 return theta_origin + theta_local_cross 

1234 

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

1236 """Concentration at ``(v, θ)`` if controlled by this doubly-fed shock. 

1237 

1238 Left of the shock face the left fan controls; right of it the right fan; at the face 

1239 the average. Outside both fans' physical extents returns ``None`` (another wave owns 

1240 the point). 

1241 """ 

1242 if not self.was_active_at(theta): 

1243 return None 

1244 v_s = self._v_at(theta) 

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

1246 if abs(v - v_s) < tol: 

1247 return 0.5 * (self.left_feeder.value(v_s, theta) + self.right_feeder.value(v_s, theta)) 

1248 feeder = self.left_feeder if v < v_s else self.right_feeder 

1249 # Only claim the point if it lies within the fan's live self-similar range. 

1250 if theta <= feeder.theta_apex or v <= feeder.v_apex: 

1251 return None 

1252 r = (theta - feeder.theta_apex) / (v - feeder.v_apex) 

1253 r_a = float(self.sorption.retardation(feeder.c_a)) 

1254 r_b = float(self.sorption.retardation(feeder.c_b)) 

1255 if r < min(r_a, r_b) - EPSILON_POSITION or r > max(r_a, r_b) + EPSILON_POSITION: 

1256 return None 

1257 return feeder.value(v, theta) 

1258 

1259 

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

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

1262 

1263 Shared by the closed-form branch of ``theta_at_fan_exhaustion``, by 

1264 ``DecayingShockWave._outlet_crossing_numerical`` and by 

1265 ``DoubleFanShockWave.outlet_crossing_theta``: each inverts a monotone function of 

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

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

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

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

1270 

1271 Parameters 

1272 ---------- 

1273 f : callable 

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

1275 value must straddle zero. Caller-specific early sentinels 

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

1277 theta_hi_seed : float 

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

1279 f_seed : float, optional 

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

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

1282 

1283 Returns 

1284 ------- 

1285 float or None 

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

1287 """ 

1288 if f_seed is None: 

1289 f_seed = f(theta_hi_seed) 

1290 theta_hi = theta_hi_seed 

1291 for _ in range(200): 

1292 theta_hi *= 2.0 

1293 if f(theta_hi) * f_seed < 0.0: 

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

1295 return None 

1296 

1297 

1298def _build_decay_profile( 

1299 sorption: NonlinearSorption, 

1300 c_decay_initial: float, 

1301 c_fixed: float, 

1302 c_fan_tail: float, 

1303) -> tuple: 

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

1305 

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

1307 depend on which side decays. The secant speed 

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

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

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

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

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

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

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

1315 limit, then inverted by monotone-spline interpolation. The reachable limit is the 

1316 fan tail ``c_fan_tail`` UNLESS ``c_fixed`` lies strictly between 

1317 ``c_decay_initial`` and ``c_fan_tail`` — then 

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

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

1320 ``c_decay`` clamps there. 

1321 

1322 Parameters 

1323 ---------- 

1324 sorption : NonlinearSorption 

1325 Sorption model. 

1326 c_decay_initial : float 

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

1328 c_fixed : float 

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

1330 c_fan_tail : float 

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

1332 

1333 Returns 

1334 ------- 

1335 tuple 

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

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

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

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

1340 """ 

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

1342 

1343 def integrand(c): 

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

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

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

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

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

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

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

1351 

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

1353 c_limit = c_fixed if pole else c_fan_tail 

1354 

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

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

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

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

1359 gap0 = abs(c_decay_initial - c_limit) 

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

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

1362 

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

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

1365 lo = c_nodes[:-1] 

1366 hi = c_nodes[1:] 

1367 mid = 0.5 * (lo + hi) 

1368 half = 0.5 * (hi - lo) 

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

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

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

1372 

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

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

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

1376 if non_increasing.size: 

1377 cut = non_increasing[0] + 1 

1378 i_nodes = i_nodes[:cut] 

1379 c_nodes = c_nodes[:cut] 

1380 c_of_i = CubicSpline(i_nodes, c_nodes) 

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

1382 

1383 

1384def _c_decay_brooks_corey( 

1385 sorption: BrooksCoreyConductivity, 

1386 c_decay_initial: float, 

1387 theta_local_collision: float, 

1388 theta_local: float, 

1389) -> float: 

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

1391 

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

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

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

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

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

1397 

1398 Parameters 

1399 ---------- 

1400 sorption : BrooksCoreyConductivity 

1401 Sorption model. 

1402 c_decay_initial : float 

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

1404 theta_local_collision : float 

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

1406 theta_local : float 

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

1408 

1409 Returns 

1410 ------- 

1411 float 

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

1413 """ 

1414 if theta_local <= theta_local_collision: 

1415 return c_decay_initial 

1416 a = sorption.a 

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

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

1419 return float(sorption.concentration_from_retardation(r_target)) 

1420 

1421 

1422def _compute_k_freundlich( 

1423 sorption: FreundlichSorption, 

1424 theta_local: float, 

1425 c_decay_initial: float, 

1426 c_fixed: float, 

1427) -> float: 

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

1429 

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

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

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

1433 

1434 Parameters 

1435 ---------- 

1436 sorption : FreundlichSorption 

1437 Sorption model. 

1438 theta_local : float 

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

1440 c_decay_initial : float 

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

1442 c_fixed : float 

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

1444 

1445 Returns 

1446 ------- 

1447 float 

1448 Invariant constant K. 

1449 """ 

1450 n = sorption.n 

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

1452 u_d = c_decay_initial ** (1.0 / n) 

1453 

1454 if c_fixed == 0.0: 

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

1456 

1457 # c_fixed > 0, n=2 

1458 u_r = c_fixed**0.5 

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

1460 

1461 

1462def _compute_k_langmuir( 

1463 sorption: LangmuirSorption, 

1464 theta_local: float, 

1465 c_decay_initial: float, 

1466) -> float: 

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

1468 

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

1470 

1471 Parameters 

1472 ---------- 

1473 sorption : LangmuirSorption 

1474 Sorption model. 

1475 theta_local : float 

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

1477 c_decay_initial : float 

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

1479 

1480 Returns 

1481 ------- 

1482 float 

1483 Invariant constant K. 

1484 """ 

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

1486 

1487 

1488def _c_decay_freundlich( 

1489 sorption: FreundlichSorption, 

1490 k_invariant: float, 

1491 c_decay_initial: float, 

1492 c_fixed: float, 

1493 theta_local_collision: float, 

1494 theta_local: float, 

1495) -> float: 

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

1497 

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

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

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

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

1502 closed form; the root is selected by the decay orientation — the ``+√`` root 

1503 (``u > u_r``) for a shrinking decay (``c_decay_initial > c_fixed``), the ``−√`` 

1504 root (``u < u_r``) for a growing decay (``c_decay_initial < c_fixed``). Both 

1505 approach ``u_r`` as ``θ_local → ∞``; the initial side of ``u_r`` fixes the branch. 

1506 

1507 Returns 

1508 ------- 

1509 float 

1510 Decaying-side concentration c at θ_local. 

1511 """ 

1512 n = sorption.n 

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

1514 

1515 if c_fixed == 0.0: 

1516 # atol=0.0: the default atol=1e-8 would silently route any n within 1e-8 of 2 

1517 # to the n=2 closed form instead of the general-n inversion. 

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

1519 disc = k_invariant * k_invariant + theta_local * k_invariant * alpha 

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

1521 return float(u * u) 

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

1523 return float(u_root**n) 

1524 

1525 # n=2, c_fixed > 0. Growing decay (c_decay_initial < c_fixed) starts below u_r and 

1526 # takes the −√ root; shrinking decay starts above u_r and takes the +√ root. 

1527 u_r = c_fixed**0.5 

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

1529 sqrt_disc = np.sqrt(disc) 

1530 if c_decay_initial < c_fixed: 

1531 u = (u_r * theta_local + k_invariant - sqrt_disc) / theta_local 

1532 else: 

1533 u = (u_r * theta_local + k_invariant + sqrt_disc) / theta_local 

1534 return float(u * u) 

1535 

1536 

1537def _invert_freundlich_cr_zero( 

1538 k_invariant: float, 

1539 c_decay_initial: float, 

1540 n: float, 

1541 alpha: float, 

1542 theta_local_collision: float, 

1543 theta_local: float, 

1544) -> float: 

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

1546 

1547 Returns 

1548 ------- 

1549 float 

1550 Root u of the invariant at θ_local. 

1551 """ 

1552 u_collision = c_decay_initial ** (1.0 / n) 

1553 

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

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

1556 

1557 if theta_local <= theta_local_collision: 

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

1559 return u_collision 

1560 

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

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

1563 

1564 

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

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

1567 

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

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

1570 

1571 Returns 

1572 ------- 

1573 float 

1574 Decaying-side concentration c at θ_local. 

1575 """ 

1576 k_l = sorption.k_l 

1577 a_coeff = sorption.a_coeff 

1578 denom = theta_local - k_invariant 

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

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

1581 

1582 

1583def _outlet_crossing_freundlich( 

1584 sorption: FreundlichSorption, 

1585 k_invariant: float, 

1586 c_decay_initial: float, 

1587 c_fixed: float, 

1588 v_origin: float, 

1589 theta_origin: float, 

1590 v_outlet: float, 

1591) -> float | None: 

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

1593 

1594 Returns 

1595 ------- 

1596 float or None 

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

1598 """ 

1599 n = sorption.n 

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

1601 delta_v = v_outlet - v_origin 

1602 

1603 if c_fixed == 0.0: 

1604 u_target = n * k_invariant / delta_v 

1605 if u_target <= 0.0: 

1606 return None 

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

1608 return float(theta_origin + theta_local) 

1609 

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

1611 # The two roots multiply to u_r² and sum to 2u_r + 2K/delta_v > 2u_r, so exactly one 

1612 # exceeds u_r (the +√ root, shrinking decay) and one lies below (the −√ root, growing 

1613 # decay). Select by the decay orientation to match _c_decay_freundlich's branch. 

1614 u_r = c_fixed**0.5 

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

1616 c_coef = delta_v * u_r * u_r 

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

1618 if disc < 0: 

1619 return None 

1620 sqrt_disc = np.sqrt(disc) 

1621 if c_decay_initial < c_fixed: 

1622 u_target = (-b_coef - sqrt_disc) / (2.0 * delta_v) 

1623 else: 

1624 u_target = (-b_coef + sqrt_disc) / (2.0 * delta_v) 

1625 if u_target <= 0.0: 

1626 return None 

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

1628 return float(theta_origin + theta_local) 

1629 

1630 

1631def _outlet_crossing_langmuir( 

1632 sorption: LangmuirSorption, 

1633 k_invariant: float, 

1634 v_origin: float, 

1635 theta_origin: float, 

1636 v_outlet: float, 

1637) -> float | None: 

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

1639 

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

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

1642 

1643 Returns 

1644 ------- 

1645 float or None 

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

1647 """ 

1648 delta_v = v_outlet - v_origin 

1649 ratio = np.sqrt(delta_v / k_invariant) 

1650 if ratio <= 1.0: 

1651 return None 

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

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

1654 return float(theta_origin + theta_local)