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

283 statements  

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

1"""Concentration extraction from front-tracking solutions (V, θ coordinates). 

2 

3Every public function in this module takes θ (cumulative flow, m³). Callers 

4translate user-facing time t → θ at the API boundary via 

5``FrontTrackerState.theta_at_t``. 

6 

7Outlet-mass functions use the PDE conservation identity 

8``m_out(θ) = m_in(θ) − m_dom(θ)`` (Bear & Cheng 2010, Ch. 3: mass 

9conservation for transport with sorption). ``m_dom`` honors historical 

10wave activity via ``wave.was_active_at(theta)`` so retrospective queries 

11at θ before a collision event correctly attribute c at v_outlet. 

12 

13Available functions: 

14 

15- :func:`concentration_at_point` - Exact concentration at a single point ``(v, θ)``: the upstream state of the 

16 nearest wave face at or downstream of ``v`` among the waves active at ``θ``, the downstream state of the 

17 outermost face when nothing lies downstream, and ``0`` in a virgin domain. Exactly on a shock or fan face the 

18 two sides are averaged; a contact carries its upstream value at its own position. The ``sorption`` argument is 

19 unused — each wave carries its own sorption reference. 

20 

21- :func:`compute_breakthrough_curve` - Outlet concentration sampled at every θ of a θ-array, i.e. 

22 :func:`concentration_at_point` evaluated at ``v_outlet``. Returns one concentration [mass/volume] per θ; these 

23 are point samples of the exact trace, not bin averages. 

24 

25- :func:`compute_bin_averaged_concentration_exact` - Outlet concentration averaged over each output θ-bin (a 

26 flow-weighted average, since θ is cumulative flow), evaluated through the conservation identity 

27 ``c_avg = (Δm_in − Δm_dom) / Δθ`` rather than by integrating the outlet trace, so overlapping fans need no 

28 ownership dispatch. A residual inside the floating-point cancellation band is set to zero; a bin more negative 

29 than that band warns and is clamped to zero to preserve the ``cout >= 0`` contract. 

30 

31- :func:`identify_outlet_segments` - Partition of a θ-interval into the segments over which a single wave 

32 controls the outlet, as a list of dicts holding the segment θ-range, its type (``'constant'``, 

33 ``'rarefaction'`` or ``'decaying_fan'``), the controlling wave and the concentrations at both segment ends. 

34 Crossings extrapolated at or past a wave's deactivation are dropped. 

35 

36- :func:`compute_domain_mass` - Total mass ``∫₀^{v_outlet} C_total(v, θ) dv`` held in the domain at one θ [mass], 

37 dissolved plus sorbed. The domain is partitioned at the active wave faces; a constant region integrates as 

38 ``C_total·Δv`` and a fan region in closed form via :func:`integrate_fan_spatial_exact`. 

39 

40- :func:`compute_cumulative_inlet_mass` - Mass injected at the inlet from ``θ = 0`` up to ``theta``, 

41 ``∫₀^θ cin dτ`` [mass], exact for the piecewise-constant ``cin`` on ``theta_edges``. 

42 

43- :func:`compute_cumulative_outlet_mass` - Mass that has left through the outlet from ``θ = 0`` up to ``theta`` 

44 [mass], as ``m_in(θ) − m_dom(θ)``; ``0`` for ``θ <= 0``. 

45 

46- :func:`compute_total_outlet_mass` - Outlet mass over all θ, from the inlet record alone: the full injected mass 

47 ``Σ cin·Δθ`` when the record returns to ``cin[-1] = 0``, and ``+inf`` for a sustained ``cin[-1] > 0`` boundary, 

48 which keeps injecting forever. 

49 

50- :func:`integrate_fan_exact` - Exact ``∫ c(θ) dθ`` [mass] at a fixed position for any self-similar fan, given 

51 the fan apex ``(theta_origin, v_origin)``, via the universal integration-by-parts antiderivative 

52 ``F(θ) = c·(θ − θ_origin) − Δv·C_T(c)``. ``c_apex > 0`` clamps the fan at its tail θ and adds the constant 

53 plateau beyond it; ``theta_end`` may be ``+inf`` where the fan integral converges, and raises otherwise. 

54 

55- :func:`integrate_rarefaction_exact` - :func:`integrate_fan_exact` with the apex and ``c_apex = raref.c_tail`` 

56 read off a :class:`~gwtransport.fronttracking.waves.RarefactionWave`. 

57 

58- :func:`integrate_fan_spatial_exact` - Exact ``∫ C_total(v, θ) dv`` [mass] over a v-segment of a self-similar 

59 fan at fixed θ, via the spatial counterpart ``G(u) = C_T(c)·u − κ·c`` with ``κ = θ − θ_origin`` and 

60 ``u = v − v_origin``. ``c_apex > 0`` splits off the constant-``C_total(c_apex)`` region near the apex. 

61 

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

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

64""" 

65 

66import warnings 

67from collections.abc import Sequence 

68from itertools import pairwise 

69from operator import itemgetter 

70 

71import numpy as np 

72import numpy.typing as npt 

73 

74from gwtransport.fronttracking.events import find_outlet_crossing 

75from gwtransport.fronttracking.interactions import Face, iter_faces 

76from gwtransport.fronttracking.math import ( 

77 NonlinearSorption, 

78 SorptionModel, 

79) 

80from gwtransport.fronttracking.waves import ( 

81 CharacteristicWave, 

82 DecayingShockWave, 

83 Feeder, 

84 RarefactionWave, 

85 ShockWave, 

86 Wave, 

87) 

88 

89# Numerical tolerance constants 

90EPSILON_VELOCITY = 1e-15 # Tolerance for checking if velocity is effectively zero 

91EPSILON_VOLUME = 1e-15 # Tolerance for negligible spatial-segment widths Δv [m³] 

92EPSILON_POSITION = 1e-15 # Tolerance for shock-face proximity in position v [m³] 

93# Multiplier on the eps·max(|m_in|,|m_dom|)/Δθ cancellation scale below which a conservation-form 

94# residual is numerical zero. The m_dom fan-integral sum accumulates ~few·10² ULP over a 

95# multi-pulse record (measured max ratio ~460); 65536 clears that with wide margin while staying 

96# ~7 orders below any real breakthrough concentration (O(cin)). 

97FP_CANCELLATION_CLAMP = 65536.0 

98 

99 

100def _reader_faces(waves: Sequence[Wave], theta: float) -> list[tuple[float, Face]]: 

101 """``(position, face)`` for every active wave face at ``theta`` (reader sweep basis). 

102 

103 Fan boundary lines are included: they mark where a downstream-opening fan ends and its 

104 plateau begins (a ``decay_side='right'`` decaying/doubly-fed shock has its fan on the 

105 downstream side, so its head boundary must partition the domain — the feeder clamp alone 

106 only reaches the apex-side plateau). 

107 """ 

108 out: list[tuple[float, Face]] = [] 

109 for wave in waves: 

110 if not wave.was_active_at(theta): 

111 continue 

112 for face in iter_faces(wave, theta): 

113 pos = face.position(theta) 

114 if pos is not None: 

115 out.append((pos, face)) 

116 return out 

117 

118 

119def concentration_at_point( 

120 v: float, 

121 theta: float, 

122 waves: Sequence[Wave], 

123 sorption: SorptionModel, # noqa: ARG001 

124) -> float: 

125 """Compute concentration at point (v, θ) with exact analytical value. 

126 

127 The function works entirely in (V, θ) coordinates: public callers must 

128 translate user-facing time t → θ at the API boundary (e.g., via 

129 ``FrontTrackerState.theta_at_t``). 

130 

131 Parameters 

132 ---------- 

133 v : float 

134 Position [m³]. 

135 theta : float 

136 Cumulative flow [m³]. 

137 waves : list of Wave 

138 All waves in the simulation (active and inactive). 

139 sorption : SorptionModel 

140 Sorption model (unused — kept for API symmetry; wave methods carry 

141 their own sorption reference). 

142 

143 Returns 

144 ------- 

145 concentration : float 

146 Concentration at point (v, θ) [mass/volume]. 

147 

148 Notes 

149 ----- 

150 **Nearest-downstream-face sweep.** ``C(v, θ)`` is the *left* (upstream) state of the 

151 nearest face strictly downstream of ``v`` among the waves active at ``θ``; if no face 

152 is downstream, the *right* state of the outermost face; ``0`` in a virgin domain. A 

153 face's feeder is a constant or a bounded self-similar fan (clamped to its extent, so the 

154 plateau beyond a fan reads correctly). Because every face crossing fires a solver event, 

155 the front list is interaction-consistent and each region has a single owner — no 

156 ownership heuristic is needed. This is exact for a single front (one owner everywhere) 

157 and for genuinely interacting multi-front inputs alike. 

158 """ 

159 faces = _reader_faces(waves, theta) 

160 # Nearest face at or downstream of v (within FP): its left (upstream) feeder gives the 

161 # state just behind it, which is the state at v. A face strictly upstream of v does not 

162 # describe v. If nothing is at/downstream, the outermost face's right (downstream) feeder 

163 # holds — the state ahead of every front. 

164 downstream = [(p, f) for (p, f) in faces if p >= v - EPSILON_POSITION] 

165 if downstream: 

166 pos, face = min(downstream, key=itemgetter(0)) 

167 # Exactly on a shock/fan face: return the average of the two sides (the infinitesimally 

168 # thin discontinuity convention). A contact (characteristic) carries its value on the 

169 # upstream side, so it returns that (the behind value) at its own position. 

170 if abs(pos - v) < EPSILON_POSITION and face.role != "contact": 

171 return 0.5 * (face.left.value(v, theta) + face.right.value(v, theta)) 

172 return face.left.value(v, theta) 

173 if faces: 

174 _p, face = max(faces, key=itemgetter(0)) 

175 return face.right.value(v, theta) 

176 return 0.0 

177 

178 

179def compute_breakthrough_curve( 

180 theta_array: npt.NDArray[np.floating], 

181 v_outlet: float, 

182 waves: Sequence[Wave], 

183 sorption: SorptionModel, 

184) -> npt.NDArray[np.floating]: 

185 """Concentration at the outlet evaluated over a θ-array (breakthrough curve). 

186 

187 Parameters 

188 ---------- 

189 theta_array : array-like 

190 Cumulative-flow points at which to query the outlet concentration [m³]. 

191 Must be sorted in ascending order. Callers translate from user-facing 

192 time via ``FrontTrackerState.theta_at_t`` before passing. 

193 v_outlet : float 

194 Outlet position [m³]. 

195 waves : list of Wave 

196 All waves in the simulation. 

197 sorption : SorptionModel 

198 Sorption model. 

199 

200 Returns 

201 ------- 

202 c_out : numpy.ndarray 

203 Concentration at ``v_outlet`` for each θ in ``theta_array`` [mass/volume]. 

204 

205 See Also 

206 -------- 

207 concentration_at_point : Point-wise concentration 

208 compute_bin_averaged_concentration_exact : Bin-averaged concentrations 

209 

210 Examples 

211 -------- 

212 .. disable_try_examples 

213 

214 :: 

215 

216 theta_array = np.linspace(0.0, tracker.state.theta_edges[-1], 1000) 

217 c_out = compute_breakthrough_curve( 

218 theta_array, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption 

219 ) 

220 """ 

221 theta_arr = np.asarray(theta_array, dtype=float) 

222 c_out = np.zeros(len(theta_arr)) 

223 for i, theta in enumerate(theta_arr): 

224 c_out[i] = concentration_at_point(v_outlet, float(theta), waves, sorption) 

225 return c_out 

226 

227 

228def identify_outlet_segments( 

229 theta_start: float, 

230 theta_end: float, 

231 v_outlet: float, 

232 waves: Sequence[Wave], 

233 sorption: SorptionModel, 

234) -> list[dict]: 

235 """Identify which waves control outlet concentration in θ-interval [theta_start, theta_end]. 

236 

237 Finds all wave crossing events at the outlet and constructs segments where 

238 concentration is constant or varying (rarefaction). All times are expressed 

239 as cumulative flow θ [m³]. 

240 

241 Parameters 

242 ---------- 

243 theta_start : float 

244 Start of cumulative-flow interval [m³]. 

245 theta_end : float 

246 End of cumulative-flow interval [m³]. 

247 v_outlet : float 

248 Outlet position [m³]. 

249 waves : list of Wave 

250 All waves in the simulation. 

251 sorption : SorptionModel 

252 Sorption model. 

253 

254 Returns 

255 ------- 

256 segments : list of dict 

257 List of segment dictionaries, each containing: 

258 

259 - 'theta_start' : float 

260 Segment start θ [m³] 

261 - 'theta_end' : float 

262 Segment end θ [m³] 

263 - 'type' : str 

264 ``'constant'``, ``'rarefaction'``, or ``'decaying_fan'``. 

265 ``'decaying_fan'`` is owned by a :class:`~gwtransport.fronttracking.waves.DecayingShockWave` after 

266 its head crosses ``v_outlet``; c at ``v_outlet`` then follows the 

267 wave's self-similar fan profile. 

268 - 'concentration' : float 

269 For constant segments 

270 - 'wave' : Wave 

271 For rarefaction and decaying_fan segments 

272 - 'c_start' : float 

273 Concentration at segment start 

274 - 'c_end' : float 

275 Concentration at segment end 

276 

277 Notes 

278 ----- 

279 Segments are constructed by: 

280 

281 1. Finding all wave crossing events at the outlet for θ in [theta_start, theta_end]. 

282 2. Sorting events by θ. 

283 3. Creating constant-concentration segments between events. 

284 4. Handling rarefaction and decaying-fan profiles with θ-varying concentration. 

285 

286 The segments completely partition the interval [theta_start, theta_end]. 

287 

288 Every crossing is clamped to ``theta_cross < theta_deactivation`` 

289 (matching ``was_active_at`` semantics): a crossing extrapolated past a 

290 wave's deactivation is an artifact — after a collision the front belongs 

291 to the successor wave, whose own crossing covers the outlet. 

292 """ 

293 # Find all waves that cross outlet in this θ-range 

294 outlet_events: list[dict] = [] 

295 

296 # Track rarefactions / decaying shocks that already contain the outlet at 

297 # theta_start (no crossing event in [theta_start, theta_end]). 

298 active_rarefactions_at_start: list[RarefactionWave | DecayingShockWave] = [] 

299 

300 for wave in waves: 

301 # Lifetime filter, matching ``Wave.was_active_at``: skip waves that were never 

302 # activated (``is_active=False`` with no recorded deactivation) or whose lifetime 

303 # ended before the window starts. ``is_active`` alone is the end-of-simulation 

304 # state and would also skip waves deactivated after an in-window crossing. 

305 never_active = not wave.is_active and wave.theta_deactivation == float("inf") 

306 if never_active or wave.theta_deactivation <= theta_start: 

307 continue 

308 

309 if isinstance(wave, DecayingShockWave): 

310 # The wave's outlet crossing arrival behaves like a rarefaction head 

311 # arrival: before arrival, v_outlet is downstream (c=c_fixed for 

312 # decay_side='left'); after arrival, v_outlet is inside the fan 

313 # whose c follows the self-similar profile and asymptotes to the 

314 # fan's tail concentration. 

315 theta_cross = wave.outlet_crossing_theta(v_outlet) 

316 if theta_cross is None or theta_cross >= wave.theta_deactivation: 

317 # Crossings at or after deactivation are spurious extrapolations. 

318 continue 

319 if theta_cross <= theta_start: 

320 # Outlet already inside the fan at theta_start. 

321 active_rarefactions_at_start.append(wave) 

322 elif theta_cross <= theta_end: 

323 # c_after is the fan c just past arrival (the decay-side c at 

324 # the arrival θ). theta_cross > wave.theta_start by construction 

325 # (outlet_crossing_theta enforces v_outlet > v_start), so 

326 # c_decay_at_theta does not return None. 

327 c_after = wave.c_decay_at_theta(theta_cross) 

328 outlet_events.append({ 

329 "theta": theta_cross, 

330 "wave": wave, 

331 "boundary": "head", 

332 "c_after": c_after, 

333 }) 

334 continue 

335 

336 # For rarefactions, detect both head and tail crossings 

337 if isinstance(wave, RarefactionWave): 

338 # Check if outlet is already inside this rarefaction at theta_start 

339 if wave.contains_point(v_outlet, theta_start): 

340 active_rarefactions_at_start.append(wave) 

341 # Detect when the tail crosses during [theta_start, theta_end] 

342 tail_speed = wave.tail_speed() 

343 if tail_speed > EPSILON_VELOCITY: 

344 theta_cross = wave.theta_start + (v_outlet - wave.v_start) / tail_speed 

345 if theta_start < theta_cross <= theta_end and theta_cross < wave.theta_deactivation: 

346 outlet_events.append({ 

347 "theta": theta_cross, 

348 "wave": wave, 

349 "boundary": "tail", 

350 "c_after": wave.c_tail, 

351 }) 

352 continue 

353 

354 # Head crossing 

355 head_speed = wave.head_speed() 

356 if head_speed > EPSILON_VELOCITY and wave.v_start < v_outlet: 

357 theta_cross = wave.theta_start + (v_outlet - wave.v_start) / head_speed 

358 if theta_start <= theta_cross <= theta_end and theta_cross < wave.theta_deactivation: 

359 outlet_events.append({ 

360 "theta": theta_cross, 

361 "wave": wave, 

362 "boundary": "head", 

363 "c_after": wave.c_head, 

364 }) 

365 

366 # Tail crossing 

367 tail_speed = wave.tail_speed() 

368 if tail_speed > EPSILON_VELOCITY and wave.v_start < v_outlet: 

369 theta_cross = wave.theta_start + (v_outlet - wave.v_start) / tail_speed 

370 if theta_start <= theta_cross <= theta_end and theta_cross < wave.theta_deactivation: 

371 outlet_events.append({ 

372 "theta": theta_cross, 

373 "wave": wave, 

374 "boundary": "tail", 

375 "c_after": wave.c_tail, 

376 }) 

377 else: 

378 # Characteristics and shocks 

379 theta_cross = find_outlet_crossing(wave, v_outlet, theta_start) 

380 

381 if theta_cross is not None and theta_start <= theta_cross <= theta_end: 

382 if isinstance(wave, CharacteristicWave): 

383 c_after = wave.concentration 

384 elif isinstance(wave, ShockWave): 

385 # After shock passes outlet, outlet sees left (upstream) state 

386 c_after = wave.c_left 

387 else: 

388 c_after = 0.0 

389 

390 outlet_events.append({"theta": theta_cross, "wave": wave, "boundary": None, "c_after": c_after}) 

391 

392 # Sort events by θ 

393 outlet_events.sort(key=itemgetter("theta")) 

394 

395 # Create segments between events 

396 segments: list[dict] = [] 

397 current_theta = theta_start 

398 current_c = concentration_at_point(v_outlet, theta_start, waves, sorption) 

399 

400 # Handle case where we start inside a rarefaction or decaying-shock fan. 

401 # Multi-fan overlap: pick the newest (largest ``theta_start``) — matches 

402 # ``concentration_at_point`` and ``compute_domain_mass`` dispatch. 

403 if active_rarefactions_at_start: 

404 raref = max(active_rarefactions_at_start, key=lambda w: w.theta_start) 

405 

406 if isinstance(raref, RarefactionWave): 

407 # Find when tail crosses (if it does) 

408 tail_cross_theta = None 

409 for event in outlet_events: 

410 if event["wave"] is raref and event["boundary"] == "tail" and event["theta"] > theta_start: 

411 tail_cross_theta = event["theta"] 

412 break 

413 

414 raref_end = min(tail_cross_theta or theta_end, theta_end) 

415 c_end = raref.c_tail if tail_cross_theta and tail_cross_theta <= theta_end else None 

416 

417 segments.append({ 

418 "theta_start": theta_start, 

419 "theta_end": raref_end, 

420 "type": "rarefaction", 

421 "wave": raref, 

422 "c_start": current_c, 

423 "c_end": c_end, 

424 }) 

425 else: 

426 # DecayingShockWave fan extends to θ=+∞ (or asymptotes to c_fixed 

427 # for n>1 with c_min); treat the whole [theta_start, theta_end] 

428 # as one decaying-fan segment. 

429 raref_end = theta_end 

430 c_end = concentration_at_point(v_outlet, theta_end, waves, sorption) 

431 

432 segments.append({ 

433 "theta_start": theta_start, 

434 "theta_end": raref_end, 

435 "type": "decaying_fan", 

436 "wave": raref, 

437 "c_start": current_c, 

438 "c_end": c_end, 

439 }) 

440 

441 current_theta = raref_end 

442 current_c = ( 

443 concentration_at_point(v_outlet, raref_end + 1e-10, waves, sorption) if raref_end < theta_end else current_c 

444 ) 

445 

446 for event in outlet_events: 

447 # Skip events that fall inside an already-emitted (typically rarefaction) 

448 # segment. ``concentration_at_point`` lets active rarefactions "win" 

449 # over a behind-shock c_left; the segment list must reflect the same 

450 # convention to avoid double-counting. 

451 if event["theta"] < current_theta: 

452 continue 

453 

454 if isinstance(event["wave"], RarefactionWave) and event["boundary"] == "head": 

455 if event["theta"] > current_theta: 

456 segments.append({ 

457 "theta_start": current_theta, 

458 "theta_end": event["theta"], 

459 "type": "constant", 

460 "concentration": current_c, 

461 "c_start": current_c, 

462 "c_end": current_c, 

463 }) 

464 

465 raref = event["wave"] 

466 tail_cross_theta = None 

467 for later_event in outlet_events: 

468 if ( 

469 later_event["wave"] is raref 

470 and later_event["boundary"] == "tail" 

471 and later_event["theta"] > event["theta"] 

472 ): 

473 tail_cross_theta = later_event["theta"] 

474 break 

475 

476 raref_end = min(tail_cross_theta or theta_end, theta_end) 

477 

478 segments.append({ 

479 "theta_start": event["theta"], 

480 "theta_end": raref_end, 

481 "type": "rarefaction", 

482 "wave": raref, 

483 "c_start": raref.c_head, 

484 "c_end": raref.c_tail if tail_cross_theta and tail_cross_theta <= theta_end else None, 

485 }) 

486 

487 current_theta = raref_end 

488 current_c = ( 

489 concentration_at_point(v_outlet, raref_end + 1e-10, waves, sorption) 

490 if raref_end < theta_end 

491 else current_c 

492 ) 

493 elif isinstance(event["wave"], DecayingShockWave) and event["boundary"] == "head": 

494 if event["theta"] > current_theta: 

495 segments.append({ 

496 "theta_start": current_theta, 

497 "theta_end": event["theta"], 

498 "type": "constant", 

499 "concentration": current_c, 

500 "c_start": current_c, 

501 "c_end": current_c, 

502 }) 

503 

504 decaying = event["wave"] 

505 # The decaying_fan segment ends at the next outlet-crossing event 

506 # (if any falls in the window) or at theta_end. Without this split, 

507 # multi-DSW pulses would have the first DSW's fan swallow every 

508 # later wave's arrival. 

509 seg_end = theta_end 

510 for later_event in outlet_events: 

511 if later_event["theta"] > event["theta"] and later_event["theta"] <= theta_end: 

512 seg_end = later_event["theta"] 

513 break 

514 c_end_val = concentration_at_point(v_outlet, seg_end, waves, sorption) 

515 

516 segments.append({ 

517 "theta_start": event["theta"], 

518 "theta_end": seg_end, 

519 "type": "decaying_fan", 

520 "wave": decaying, 

521 "c_start": event["c_after"], 

522 "c_end": c_end_val, 

523 }) 

524 

525 current_theta = seg_end 

526 current_c = c_end_val 

527 else: 

528 if event["theta"] > current_theta: 

529 segments.append({ 

530 "theta_start": current_theta, 

531 "theta_end": event["theta"], 

532 "type": "constant", 

533 "concentration": current_c, 

534 "c_start": current_c, 

535 "c_end": current_c, 

536 }) 

537 

538 current_theta = event["theta"] 

539 current_c = event["c_after"] 

540 

541 # Final segment 

542 if theta_end > current_theta: 

543 segments.append({ 

544 "theta_start": current_theta, 

545 "theta_end": theta_end, 

546 "type": "constant", 

547 "concentration": current_c, 

548 "c_start": current_c, 

549 "c_end": current_c, 

550 }) 

551 

552 return segments 

553 

554 

555def integrate_rarefaction_exact( 

556 raref: RarefactionWave, v_outlet: float, theta_start: float, theta_end: float, sorption: SorptionModel 

557) -> float: 

558 """Exact θ-integral ``∫ c(θ) dθ`` of a rarefaction at the outlet. 

559 

560 Convenience wrapper over :func:`integrate_fan_exact` that pulls the fan 

561 apex from ``raref.theta_start, raref.v_start``. Returns the mass-like 

562 quantity ``∫ c dθ`` (= ``∫ c·flow dt`` in time coordinates). 

563 

564 Parameters 

565 ---------- 

566 raref : RarefactionWave 

567 Rarefaction wave controlling the outlet. 

568 v_outlet : float 

569 Outlet position [m³]. 

570 theta_start, theta_end : float 

571 Integration range in cumulative flow [m³]. Either can be ``±np.inf``. 

572 sorption : SorptionModel 

573 Sorption model (any NonlinearSorption subclass). 

574 

575 Returns 

576 ------- 

577 integral : float 

578 ``∫ c(θ) dθ`` [mass — i.e. concentration × volume]. 

579 """ 

580 return integrate_fan_exact( 

581 raref.theta_start, raref.v_start, v_outlet, theta_start, theta_end, sorption, c_apex=raref.c_tail 

582 ) 

583 

584 

585def integrate_fan_exact( 

586 theta_origin: float, 

587 v_origin: float, 

588 v_outlet: float, 

589 theta_start: float, 

590 theta_end: float, 

591 sorption: SorptionModel, 

592 c_apex: float = 0.0, 

593) -> float: 

594 """Exact θ-integral ``∫ c(θ) dθ`` for any self-similar fan at the outlet. 

595 

596 Decoupled from the wave object so the same closed-form math applies to 

597 both :class:`~gwtransport.fronttracking.waves.RarefactionWave` (apex = ``theta_start, v_start``) and 

598 :class:`~gwtransport.fronttracking.waves.DecayingShockWave` (apex = ``theta_origin, v_origin``). 

599 

600 Parameters 

601 ---------- 

602 theta_origin, v_origin : float 

603 Cumulative flow and position at the fan's apex [m³]. 

604 v_outlet : float 

605 Outlet position [m³]. 

606 theta_start, theta_end : float 

607 Integration range in cumulative flow [m³]. ``theta_end`` may be 

608 ``+np.inf``; ``theta_start`` must be finite. 

609 sorption : SorptionModel 

610 Sorption model (any NonlinearSorption subclass). 

611 c_apex : float, optional 

612 Concentration on the constant side at the fan apex. For 

613 ``RarefactionWave`` this is ``raref.c_tail``; for 

614 ``DecayingShockWave`` (decay_side='left') this is ``wave.c_fan_tail`` 

615 (the plateau the outlet holds once the fan's far edge sweeps by). 

616 For ``c_apex > 0`` the fan formula extrapolates past the physical 

617 fan range; the integration is clamped at ``θ_tail`` (where 

618 ``c(θ_tail) = c_apex``) and the constant-c_apex region beyond 

619 contributes ``c_apex · (theta_end − θ_tail)``. Default 0.0 

620 preserves the c=0 apex behavior for canonical c_R=0 fans. 

621 

622 Returns 

623 ------- 

624 float 

625 Mass-like quantity ``∫ c(θ) dθ`` [mass — concentration × volume]. 

626 

627 Raises 

628 ------ 

629 TypeError 

630 If the sorption model does not support exact fan integration. 

631 """ 

632 # Every NonlinearSorption uses one universal IBP antiderivative, which evaluates the fan 

633 # kernel k = R·c − C_T at the segment endpoints via c_and_total_from_retardation. 

634 if isinstance(sorption, NonlinearSorption): 

635 return _integrate_fan_exact_universal( 

636 theta_origin, v_origin, v_outlet, theta_start, theta_end, sorption, c_apex 

637 ) 

638 

639 msg = f"Exact fan integration not supported for {type(sorption).__name__}" 

640 raise TypeError(msg) 

641 

642 

643def _integrate_fan_exact_universal( 

644 theta_origin: float, 

645 v_origin: float, 

646 v_outlet: float, 

647 theta_start: float, 

648 theta_end: float, 

649 sorption: NonlinearSorption, 

650 c_apex: float = 0.0, 

651) -> float: 

652 r"""Exact θ-integral ``∫ c(θ) dθ`` via the universal IBP antiderivative. 

653 

654 For any ``NonlinearSorption`` (with ``R = dC_T/dC``), integration by 

655 parts on the self-similar fan ``R(c(θ)) = (θ − θ_origin)/Δv`` gives the 

656 closed-form antiderivative 

657 

658 .. math:: 

659 F(\\theta) = c(\\theta)\\,(\\theta - \\theta_{\\rm origin}) 

660 - \\Delta v \\cdot C_T(c(\\theta)). 

661 

662 The derivation uses ``∫ c\\,d\\theta = c·(\\theta-\\theta_0) − ∫(\\theta-\\theta_0)·dc 

663 = c·(\\theta-\\theta_0) − \\Delta v · ∫ R(c)\\,dc = c·(\\theta-\\theta_0) − \\Delta v · C_T(c)``, 

664 where the last equality is the definition of ``C_T`` as the antiderivative 

665 of ``R`` (``R = dC_T/dC``). 

666 

667 This formula is exact for any sorption (Brooks-Corey, van Genuchten-Mualem, 

668 Freundlich, Langmuir). The only sorption-specific call is 

669 ``sorption.concentration_from_retardation`` at the two endpoints — for 

670 Brooks-Corey this is closed form; for van Genuchten-Mualem it is one 

671 ``brentq`` call per endpoint. No quadrature, no integration loop. 

672 

673 Convergence at θ → ∞ for ``c_apex = 0``: for any monotone sorption with 

674 ``R(0) = ∞`` (BC, vG, Freundlich n > 1), ``c(∞) = 0`` and ``c·θ → 0`` 

675 faster than ``Δv·C_T → 0`` (verified termwise from the closed-form 

676 asymptotic ``c ~ R^{-α}`` for some ``α > 1``), so ``F(∞) = 0``. 

677 

678 For ``c_apex > 0`` the fan formula extrapolates to ``c < c_apex`` past 

679 ``θ_tail = θ_origin + Δv·R(c_apex)``; clamp the fan portion at 

680 ``θ_tail`` and add ``c_apex·(θ_end − θ_tail)`` for any ``θ_end > θ_tail``. 

681 """ 

682 delta_v = v_outlet - v_origin 

683 if delta_v <= 0 or theta_end <= theta_start: 

684 return 0.0 

685 

686 if c_apex > 0.0: 

687 theta_tail = theta_origin + delta_v * float(sorption.retardation(c_apex)) 

688 theta_end_fan = min(theta_end, theta_tail) 

689 else: 

690 theta_tail = float("inf") 

691 theta_end_fan = theta_end 

692 

693 # For a c_apex=0 fan the upper bound may be +∞. The integral converges only when 

694 # c → 0 as R → ∞ (so base·c → 0). Freundlich n<1 (c → ∞ as R → ∞) diverges — reject it 

695 # explicitly via the fan_converges_at_infinity guard. 

696 if theta_end_fan == float("inf") and not sorption.fan_converges_at_infinity(): 

697 msg = "Fan integral diverges at θ=+∞ for this sorption (e.g. Freundlich n<1); pass a finite theta_end" 

698 raise ValueError(msg) 

699 

700 def antiderivative(theta: float) -> float: 

701 if theta == float("inf"): 

702 # F(∞) = 0 for any sorption with c → 0 as R → ∞ (guarded above for divergent cases). 

703 return 0.0 

704 base = (theta - theta_origin) / delta_v 

705 if base <= 0.0: 

706 return 0.0 

707 c, ct = sorption.c_and_total_from_retardation(base) 

708 return c * (theta - theta_origin) - delta_v * ct 

709 

710 fan_integral = antiderivative(theta_end_fan) - antiderivative(theta_start) 

711 constant_contrib = c_apex * max(theta_end - theta_tail, 0.0) if c_apex > 0.0 else 0.0 

712 return fan_integral + constant_contrib 

713 

714 

715def compute_bin_averaged_concentration_exact( 

716 theta_bin_edges: npt.NDArray[np.floating], 

717 v_outlet: float, 

718 waves: Sequence[Wave], 

719 sorption: SorptionModel, 

720 *, 

721 cin: npt.ArrayLike, 

722 theta_edges_inlet: npt.NDArray[np.floating], 

723) -> npt.NDArray[np.floating]: 

724 """θ-bin-averaged outlet concentration. 

725 

726 For each θ-bin ``[θ_i, θ_{i+1}]``:: 

727 

728 C_avg = (1 / Δθ) · ∫_{θ_i}^{θ_{i+1}} C(v_outlet, θ) dθ 

729 

730 evaluated through the conservation-law identity 

731 ``C_avg = (Δm_in − Δm_dom) / Δθ`` per bin — analytical and explicit, no 

732 outlet-side fan dispatch, so multi-DSW and n<1 mirror geometries are 

733 handled by the same single path. 

734 

735 Parameters 

736 ---------- 

737 theta_bin_edges : array-like 

738 Cumulative-flow OUTPUT bin edges [m³] (where C_avg is reported). 

739 Length N+1 for N bins. Callers translate t-bin edges with 

740 ``state.theta_at_t``. 

741 v_outlet : float 

742 Outlet position [m³]. 

743 waves : list of Wave 

744 All waves from front tracking simulation. 

745 sorption : SorptionModel 

746 Sorption model. 

747 cin : array-like (kw-only) 

748 Inlet concentration per inlet θ-bin. 

749 theta_edges_inlet : ndarray (kw-only) 

750 θ bin edges of the INLET (``state.theta_edges``), length 

751 ``len(cin) + 1``. 

752 

753 Returns 

754 ------- 

755 c_avg : numpy.ndarray 

756 Bin-averaged outlet concentrations [mass/volume]. Length N. 

757 

758 Raises 

759 ------ 

760 ValueError 

761 If any output θ-bin has non-positive width. 

762 

763 See Also 

764 -------- 

765 concentration_at_point : Point-wise concentration 

766 compute_breakthrough_curve : Breakthrough curve 

767 compute_cumulative_outlet_mass : Cumulative outlet mass via conservation 

768 """ 

769 theta_edges_out = np.asarray(theta_bin_edges, dtype=float) 

770 dtheta_out = np.diff(theta_edges_out) 

771 

772 if np.any(dtheta_out <= 0): 

773 bad = int(np.argmin(dtheta_out)) 

774 msg = ( 

775 f"Invalid θ-bin: theta_bin_edges[{bad}]={theta_edges_out[bad]} >= " 

776 f"theta_bin_edges[{bad + 1}]={theta_edges_out[bad + 1]}" 

777 ) 

778 raise ValueError(msg) 

779 

780 # Conservation form: c_avg = Δm_out/Δθ where m_out = m_in − m_dom. 

781 # m_in(θ) = ∫₀^θ cin dτ is the piecewise-LINEAR (in θ) integral of the piecewise-constant 

782 # cin, so evaluate it at every output edge in O(N+M) from the inlet-bin cumulative sums 

783 # plus the partial bin containing θ — instead of the dense N_out×M_in clip-and-matmul 

784 # (≈245× slower, a 128 MB temporary at N=M=4000). Edges below te_in[0] contribute 0 

785 # (nothing is injected before the record starts, even if it starts mid-window); edges at 

786 # or past te_in[-1] saturate at the total. This mirrors ``compute_cumulative_inlet_mass``'s 

787 # clip exactly. m_dom stays a per-θ spatial geometry loop. 

788 te_in = np.asarray(theta_edges_inlet, dtype=float) 

789 cin_arr = np.asarray(cin, dtype=float) 

790 cum_in = np.concatenate([[0.0], np.cumsum(cin_arr * np.diff(te_in))]) 

791 idx = np.clip(np.searchsorted(te_in, theta_edges_out, side="right") - 1, 0, len(cin_arr) - 1) 

792 m_in_at_edges = cum_in[idx] + cin_arr[idx] * (theta_edges_out - te_in[idx]) 

793 m_in_at_edges = np.where(theta_edges_out < te_in[0], 0.0, m_in_at_edges) 

794 m_in_at_edges = np.where(theta_edges_out >= te_in[-1], cum_in[-1], m_in_at_edges) 

795 m_dom_at_edges = np.array([ 

796 compute_domain_mass(theta=float(theta_e), v_outlet=v_outlet, waves=waves, sorption=sorption) 

797 for theta_e in theta_edges_out 

798 ]) 

799 # ``compute_cumulative_outlet_mass`` short-circuits to 0 for θ ≤ 0; 

800 # replicate that clamp so non-positive output edges contribute no mass. 

801 m_out_at_edges = np.where(theta_edges_out <= 0.0, 0.0, m_in_at_edges - m_dom_at_edges) 

802 result = np.diff(m_out_at_edges) / dtheta_out 

803 # FP-noise clamp scaled to the m_in − m_dom CANCELLATION magnitude: each cumulative-mass 

804 # edge carries ~eps·max(|m_in|,|m_dom|) rounding (the m_dom fan-integral sum accumulates 

805 # several hundred ULP over a multi-pulse record), amplified by 1/Δθ. Keying the band off 

806 # the OUTPUT concentration instead would collapse it to ~0 before breakthrough, where 

807 # ~1e-11 cancellation dust is normal. Residuals within this band are numerical zero: 

808 # clamp and stay silent. 

809 mass_scale = np.maximum(np.abs(m_in_at_edges), np.abs(m_dom_at_edges)) 

810 # Band = the larger of the FP-cancellation floor and a 1e-6 relative transient floor. 

811 # The latter absorbs the benign ``c_min``-floor artifact at a fan-entry (a decaying 

812 # side born at ``c ≈ 1e-12`` rather than exactly 0 perturbs the near-apex fan integral 

813 # for one θ-sample); a genuine over-count is O(a pulse's mass), far above the band. 

814 eps_band = ( 

815 max(FP_CANCELLATION_CLAMP * np.finfo(float).eps, 1e-6) 

816 * np.maximum(np.maximum(mass_scale[:-1], mass_scale[1:]), 1.0) 

817 / dtheta_out 

818 ) 

819 result = np.where(np.abs(result) < eps_band, 0.0, result) 

820 # A residual MORE negative than the FP band is a genuine conservation-form violation with two 

821 # possible drivers, and BOTH can be present at once. Attribute per offending bin, not off the 

822 # single last edge: a bin whose right edge passes ``theta_edges_inlet[-1]`` sees m_in saturate 

823 # while the wave list keeps evolving (out-of-window); a fully in-window offending bin is a real 

824 # wave-model over-count (e.g. overlapping non-interacting waves from an oscillating inlet, see 

825 # ``compute_domain_mass`` Notes). Report every cause that actually produced a negative bin. 

826 # Clamp to 0 to preserve the ``cout >= 0`` API contract either way. 

827 neg_mask = result < -eps_band 

828 if np.any(neg_mask): 

829 worst = float(np.min(result)) 

830 te_in_last = float(te_in[-1]) 

831 out_of_window = neg_mask & (theta_edges_out[1:] > te_in_last) 

832 causes = [] 

833 if np.any(out_of_window): 

834 causes.append( 

835 f"output θ-bin edges exceeding theta_edges_inlet[-1]={te_in_last:.3f} (m_in " 

836 "saturates at the last injected mass while the wave list keeps evolving — extend " 

837 "cin with trailing zeros to cover the output range, or restrict output bins to the " 

838 "inlet window)" 

839 ) 

840 if np.any(neg_mask & ~out_of_window): 

841 causes.append( 

842 "the domain-mass integral growing faster than the inlet-mass integral within the " 

843 "inlet window (an m_dom over-count — e.g. overlapping non-interacting waves from a " 

844 "continuous/oscillating inlet — or a wave-list / cin inconsistency)" 

845 ) 

846 warnings.warn( 

847 f"compute_bin_averaged_concentration_exact produced a concentration as negative as " 

848 f"{worst:.3e}, beyond the FP-cancellation band; likely cause(s): {'; '.join(causes)}.", 

849 UserWarning, 

850 stacklevel=2, 

851 ) 

852 return np.maximum(result, 0.0) 

853 

854 

855def compute_domain_mass( 

856 theta: float, 

857 v_outlet: float, 

858 waves: Sequence[Wave], 

859 sorption: SorptionModel, 

860) -> float: 

861 """ 

862 Compute total mass in domain [0, v_outlet] at cumulative flow θ. 

863 

864 Integrates concentration over space:: 

865 

866 M(θ) = ∫₀^v_outlet C_total(v, θ) dv 

867 

868 Exact analytical formulas for every wave type: constant regions 

869 (``C_total · Δv``), RarefactionWave fan interiors and DecayingShockWave fan 

870 interiors (closed-form via :func:`integrate_fan_spatial_exact`). 

871 

872 Parameters 

873 ---------- 

874 theta : float 

875 Cumulative flow at which to compute domain mass [m³]. 

876 v_outlet : float 

877 Outlet position (domain extent) [m³]. 

878 waves : list of Wave 

879 All waves in the simulation. 

880 sorption : SorptionModel 

881 Sorption model. 

882 

883 Returns 

884 ------- 

885 mass : float 

886 Total mass in domain [mass]. Closed-form analytical to machine precision. 

887 

888 See Also 

889 -------- 

890 compute_cumulative_inlet_mass : Cumulative inlet mass 

891 compute_cumulative_outlet_mass : Cumulative outlet mass 

892 concentration_at_point : Point-wise concentration 

893 integrate_fan_spatial_exact : Closed-form fan spatial integral 

894 

895 Examples 

896 -------- 

897 .. disable_try_examples 

898 

899 :: 

900 

901 mass = compute_domain_mass( 

902 theta=2500.0, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption 

903 ) 

904 mass >= 0.0 

905 """ 

906 # Partition [0, v_outlet] at the active faces; each segment has a single owner (the 

907 # left feeder of its nearest downstream face). A constant owner integrates as 

908 # C_total·Δv; a fan owner integrates in closed form via integrate_fan_spatial_exact, 

909 # clamped at the fan's near-apex (larger-retardation) bound. Because the wave list is 

910 # interaction-consistent (every face crossing fires a solver event), this single-owner 

911 # sweep is exact for single-front and genuinely interacting multi-front layouts alike. 

912 faces = _reader_faces(waves, theta) 

913 boundaries = {0.0, v_outlet} 

914 boundaries.update(p for (p, _f) in faces if 0.0 < p < v_outlet) 

915 positions = sorted(boundaries) 

916 

917 total_mass = 0.0 

918 for v_start, v_end in pairwise(positions): 

919 dv = v_end - v_start 

920 if dv < EPSILON_VOLUME: 

921 continue 

922 v_mid = 0.5 * (v_start + v_end) 

923 feeder = _owner_feeder(faces, v_mid) 

924 

925 if feeder is None or feeder.is_const: 

926 c = feeder.value(v_mid, theta) if feeder is not None else 0.0 

927 total_mass += float(sorption.total_concentration(c)) * dv 

928 else: 

929 total_mass += integrate_fan_spatial_exact( 

930 feeder.theta_apex, 

931 feeder.v_apex, 

932 v_start, 

933 v_end, 

934 theta, 

935 sorption, 

936 c_apex=_near_apex_bound(feeder, sorption), 

937 ) 

938 

939 return float(total_mass) 

940 

941 

942def _owner_feeder(faces: list[tuple[float, Face]], v: float) -> Feeder | None: 

943 """Return the feeder controlling the state at ``v``: nearest-downstream face's left, else outermost right.""" 

944 downstream = [(p, f) for (p, f) in faces if p > v + EPSILON_POSITION] 

945 if downstream: 

946 _p, face = min(downstream, key=itemgetter(0)) 

947 return face.left 

948 if faces: 

949 _p, face = max(faces, key=itemgetter(0)) 

950 return face.right 

951 return None 

952 

953 

954def _near_apex_bound(feeder: Feeder, sorption: SorptionModel) -> float: 

955 """Return the fan boundary concentration with the larger retardation (the near-apex / plateau side). 

956 

957 This is the ``c_apex`` clamp for ``integrate_fan_spatial_exact``: the value the fan holds 

958 at (and beyond) its apex. Monotonicity-agnostic — the low-c bound for R-decreasing 

959 isotherms, the high-c bound for the Freundlich ``n<1`` mirror where R increases with c. 

960 """ 

961 r_a = float(sorption.retardation(feeder.c_a)) 

962 r_b = float(sorption.retardation(feeder.c_b)) 

963 return feeder.c_a if r_a >= r_b else feeder.c_b 

964 

965 

966def integrate_fan_spatial_exact( 

967 theta_origin: float, 

968 v_origin: float, 

969 v_start: float, 

970 v_end: float, 

971 theta: float, 

972 sorption: SorptionModel, 

973 c_apex: float = 0.0, 

974) -> float: 

975 """Exact spatial integral ``∫ C_total(v, θ) dv`` for any self-similar fan. 

976 

977 Decoupled from the wave object so the same closed-form math applies to 

978 :class:`~gwtransport.fronttracking.waves.RarefactionWave` (apex = ``theta_start, v_start``) and 

979 :class:`~gwtransport.fronttracking.waves.DecayingShockWave` (apex = ``theta_origin, v_origin``). 

980 

981 In (V, θ) the self-similar fan satisfies ``R(C) = (θ - θ_origin)/(v - v_origin)``; 

982 define ``kappa = θ - θ_origin`` and ``u = v - v_origin``. The dissolved and 

983 sorbed contributions reduce to power-law forms in ``u`` that admit closed 

984 forms via incomplete beta functions (Freundlich) or elementary sqrt 

985 operations (Langmuir). 

986 

987 Parameters 

988 ---------- 

989 theta_origin, v_origin : float 

990 Cumulative flow and position at the fan's apex [m³]. 

991 v_start, v_end : float 

992 Integration range in v [m³]. 

993 theta : float 

994 Cumulative flow at which to evaluate [m³]. 

995 sorption : SorptionModel 

996 Sorption model (any NonlinearSorption subclass). 

997 c_apex : float, optional 

998 Concentration on the constant side at the fan apex (typically the 

999 parent rarefaction's ``c_tail`` or the DSW's ``c_fixed`` for 

1000 ``decay_side='left'``). For ``c_apex > 0`` the fan formula is 

1001 unphysical for ``u < u_tail = kappa / R(c_apex)``; the integration 

1002 is split into a constant-C_total(c_apex) region for 

1003 ``u ∈ [u_start, u_tail]`` plus the fan integral for 

1004 ``u ∈ [u_tail, u_end]``. Default 0.0 preserves the c=0 apex 

1005 behavior for canonical c_R=0 rarefactions. 

1006 

1007 Returns 

1008 ------- 

1009 float 

1010 Mass in the segment ``[v_start, v_end]``. 

1011 

1012 Raises 

1013 ------ 

1014 TypeError 

1015 If the sorption model does not support exact spatial integration. 

1016 """ 

1017 if theta <= theta_origin: 

1018 return 0.0 

1019 

1020 kappa = theta - theta_origin 

1021 u_start = v_start - v_origin 

1022 u_end = v_end - v_origin 

1023 

1024 # The fan only exists for v > v_origin; clip u_start at 0 (the apex 

1025 # contributes nothing to the integral since c(v_origin)=0 for n>1 and 

1026 # the Beta-function form handles the lower-bound singularity for n<1). 

1027 # If the whole segment is upstream of the apex, return 0. 

1028 if u_end <= 0: 

1029 return 0.0 

1030 if u_start < 0: 

1031 u_start = 0.0 

1032 

1033 # Split off the constant-c_apex region near the apex for c_apex > 0. 

1034 # The fan formula is only valid for u ≥ u_tail = kappa / R(c_apex); 

1035 # below u_tail, c is clamped to c_apex (the parent's tail / DSW's fixed 

1036 # concentration). Spatial counterpart of the temporal θ_tail clamp in 

1037 # _integrate_fan_exact_universal. 

1038 constant_contrib = 0.0 

1039 if c_apex > 0.0: 

1040 u_tail = kappa / float(sorption.retardation(c_apex)) 

1041 if u_start < u_tail: 

1042 c_total_apex = float(sorption.total_concentration(c_apex)) 

1043 constant_contrib = c_total_apex * (min(u_end, u_tail) - u_start) 

1044 u_start = u_tail 

1045 if u_end <= u_start: 

1046 return constant_contrib 

1047 

1048 # One universal IBP antiderivative for every NonlinearSorption (see the rationale in 

1049 # ``integrate_fan_exact``, the temporal counterpart). 

1050 if isinstance(sorption, NonlinearSorption): 

1051 return constant_contrib + _integrate_rarefaction_spatial_universal(sorption, kappa, u_start, u_end) 

1052 

1053 msg = f"Exact spatial fan integration not supported for {type(sorption).__name__}" 

1054 raise TypeError(msg) 

1055 

1056 

1057def _integrate_rarefaction_spatial_universal( 

1058 sorption: NonlinearSorption, 

1059 kappa: float, 

1060 u_start: float, 

1061 u_end: float, 

1062) -> float: 

1063 r"""Exact spatial integral ``∫ C_T(c(u)) du`` via the universal IBP antiderivative. 

1064 

1065 For any ``NonlinearSorption`` with ``R = dC_T/dC``, integration by parts 

1066 on the self-similar fan ``R(c(u)) = κ/u`` gives the closed-form 

1067 antiderivative 

1068 

1069 .. math:: 

1070 G(u) = C_T(c(u))\\cdot u - \\kappa\\cdot c(u). 

1071 

1072 Derivation: ``∫ C_T\\,du = C_T·u − ∫ u\\,dC_T = C_T·u − ∫ (κ/R)·R\\,dc = 

1073 C_T·u − κ·c`` (the second equality uses ``u = κ/R`` and the third uses 

1074 ``dC_T = R\\,dc``). 

1075 

1076 Sorption-specific calls limited to ``concentration_from_retardation`` and 

1077 ``total_concentration`` at the two endpoints. For Brooks-Corey both are 

1078 closed form; for van Genuchten-Mualem ``concentration_from_retardation`` is 

1079 one ``brentq`` per endpoint and ``total_concentration`` is also one 

1080 ``brentq`` per endpoint (chained internally). No quadrature. 

1081 

1082 At the apex (``u → 0``, ``R → ∞``) ``c → 0`` and ``C_T → 0`` so ``G(0) = 0``; 

1083 a segment whose lower bound is at (or below, for a bounded-``R`` sorption like 

1084 Langmuir where ``c = 0`` for ``u`` below the fan tail) the apex contributes 

1085 ``G(u_end) − 0``. 

1086 """ 

1087 if u_end <= 0.0 or u_end <= u_start or kappa <= 0.0: 

1088 return 0.0 

1089 if u_start <= 0.0: 

1090 g_start = 0.0 # G(0) = C_T(0)·0 − κ·0 = 0 (apex) 

1091 else: 

1092 c_start, ct_start = sorption.c_and_total_from_retardation(kappa / u_start) 

1093 g_start = ct_start * u_start - kappa * c_start 

1094 c_end, ct_end = sorption.c_and_total_from_retardation(kappa / u_end) 

1095 g_end = ct_end * u_end - kappa * c_end 

1096 return g_end - g_start 

1097 

1098 

1099def compute_cumulative_inlet_mass( 

1100 theta: float, 

1101 cin: npt.ArrayLike, 

1102 theta_edges: npt.ArrayLike, 

1103) -> float: 

1104 """Cumulative inlet mass entering the domain from θ=0 to ``theta``. 

1105 

1106 In cumulative-flow coordinates ``M_in(θ) = ∫₀^θ cin(τ) dτ``; for 

1107 piecewise-constant ``cin`` this is exact under summation over θ-bin 

1108 widths. 

1109 

1110 Parameters 

1111 ---------- 

1112 theta : float 

1113 Cumulative flow up to which to integrate [m³]. 

1114 cin : array-like 

1115 Inlet concentration per θ-bin [mass/volume]. 

1116 theta_edges : array-like 

1117 θ bin edges [m³], length ``len(cin) + 1``. 

1118 

1119 Returns 

1120 ------- 

1121 mass_in : float 

1122 Cumulative inlet mass [mass]. 

1123 

1124 Examples 

1125 -------- 

1126 .. disable_try_examples 

1127 

1128 :: 

1129 

1130 mass_in = compute_cumulative_inlet_mass( 

1131 theta=5000.0, cin=cin, theta_edges=theta_edges 

1132 ) 

1133 mass_in >= 0.0 

1134 """ 

1135 te = np.asarray(theta_edges, dtype=float) 

1136 widths = np.clip(theta - te[:-1], 0.0, np.diff(te)) 

1137 return float(np.sum(np.asarray(cin, dtype=float) * widths)) 

1138 

1139 

1140def compute_cumulative_outlet_mass( 

1141 theta: float, 

1142 v_outlet: float, 

1143 waves: Sequence[Wave], 

1144 sorption: SorptionModel, 

1145 *, 

1146 cin: npt.ArrayLike, 

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

1148) -> float: 

1149 """Cumulative mass exiting through the outlet from θ=0 to ``theta``. 

1150 

1151 Computed analytically via the conservation-law identity:: 

1152 

1153 m_out(θ) = m_in(θ) − m_dom(θ) 

1154 

1155 derived from integrating the PDE ``∂_θ C_T + ∂_V c = 0`` over the spatial 

1156 domain ``[0, v_outlet]`` (Bear & Cheng 2010, Ch. 3: mass conservation 

1157 for advection with sorption). This sidesteps the multi-fan dispatch problem 

1158 that the outlet-segment integration faces when several DSWs cover 

1159 v_outlet simultaneously — every term on the right is purely spatial or a 

1160 closed-form inlet sum, no ownership priority needed. 

1161 

1162 Parameters 

1163 ---------- 

1164 theta : float 

1165 Cumulative flow up to which to integrate [m³]. 

1166 v_outlet : float 

1167 Outlet position [m³]. 

1168 waves : list of Wave 

1169 All waves in the simulation. 

1170 sorption : SorptionModel 

1171 Sorption model. 

1172 cin : array-like (kw-only) 

1173 Inlet concentration per θ-bin [mass/volume]. 

1174 theta_edges : ndarray (kw-only) 

1175 θ bin edges [m³], length ``len(cin) + 1``. 

1176 

1177 Returns 

1178 ------- 

1179 mass_out : float 

1180 Cumulative outlet mass [mass]. 

1181 

1182 Examples 

1183 -------- 

1184 .. disable_try_examples 

1185 

1186 :: 

1187 

1188 mass_out = compute_cumulative_outlet_mass( 

1189 theta=5000.0, 

1190 v_outlet=500.0, 

1191 waves=tracker.state.waves, 

1192 sorption=sorption, 

1193 cin=cin, 

1194 theta_edges=tracker.state.theta_edges, 

1195 ) 

1196 mass_out >= 0.0 

1197 """ 

1198 if theta <= 0.0: 

1199 return 0.0 

1200 m_in = compute_cumulative_inlet_mass(theta=theta, cin=cin, theta_edges=theta_edges) 

1201 m_dom = compute_domain_mass(theta=theta, v_outlet=v_outlet, waves=waves, sorption=sorption) 

1202 return m_in - m_dom 

1203 

1204 

1205def compute_total_outlet_mass( 

1206 *, 

1207 cin: npt.ArrayLike, 

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

1209) -> float: 

1210 """Total outlet mass over θ → ∞ (finite only for a returning-to-zero pulse). 

1211 

1212 The final inlet value ``c_∞ = cin[-1]`` is the sustained boundary state as θ → ∞: 

1213 

1214 - For ``c_∞ = 0`` (canonical c_R=0 pulse): injection ceases, the domain empties, and 

1215 every injected mass unit eventually exits — ``m_out_total = m_in_total`` (the finite 

1216 record integral ``Σ cin·Δθ``). The wave list is not needed. 

1217 - For ``c_∞ > 0`` (sustained ambient): the inlet keeps injecting ``c_∞`` forever, so the 

1218 cumulative outlet mass grows without bound — return ``+inf``. Pairing the FINITE record 

1219 integral with the infinite-time steady-state fill ``C_T(c_∞)·v_outlet`` is not an option: 

1220 that difference goes negative whenever ``m_in_total < C_T(c_∞)·v_outlet``. 

1221 

1222 Parameters 

1223 ---------- 

1224 cin : array-like (kw-only) 

1225 Inlet concentration per θ-bin [mass/volume]. 

1226 theta_edges : ndarray (kw-only) 

1227 θ bin edges [m³], length ``len(cin) + 1``. 

1228 

1229 Returns 

1230 ------- 

1231 float 

1232 ``m_in_total`` for ``cin[-1] = 0``; ``+inf`` for ``cin[-1] > 0``. 

1233 

1234 See Also 

1235 -------- 

1236 compute_cumulative_outlet_mass : Cumulative outlet mass up to a finite θ (use this for 

1237 a sustained ``c_∞ > 0`` boundary, where the θ → ∞ total is unbounded). 

1238 compute_domain_mass : Spatial integral of C_total in the aquifer 

1239 """ 

1240 cin_arr = np.asarray(cin, dtype=float) 

1241 # An empty cin is a malformed (no-bin) input by the cin/theta_edges contract; 

1242 # let cin_arr[-1] raise IndexError rather than masking it as c_inf=0. 

1243 if float(cin_arr[-1]) > 0.0: 

1244 return float("inf") 

1245 te = np.asarray(theta_edges, dtype=float) 

1246 return float(np.sum(cin_arr * np.diff(te)))