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

459 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-27 06:32 +0000

1""" 

2Concentration Extraction from Front Tracking Solutions. 

3 

4This module computes outlet concentrations from front tracking wave solutions 

5using exact analytical integration. All calculations maintain machine precision 

6with no numerical dispersion. 

7 

8Functions 

9--------- 

10concentration_at_point(v, t, waves, sorption) 

11 Compute concentration at any point (v, t) in domain 

12compute_breakthrough_curve(t_array, v_outlet, waves, sorption) 

13 Compute concentration at outlet over time array 

14compute_bin_averaged_concentration_exact(t_edges, v_outlet, waves, sorption) 

15 Compute bin-averaged concentrations using exact analytical integration 

16compute_domain_mass(t, v_outlet, waves, sorption) 

17 Compute total mass in domain [0, v_outlet] at time t using exact analytical integration 

18compute_cumulative_inlet_mass(t, cin, flow, tedges_days) 

19 Compute cumulative mass entering domain from t=0 to t 

20compute_cumulative_outlet_mass(t, v_outlet, waves, sorption, flow, tedges_days) 

21 Compute cumulative mass exiting domain from t=0 to t 

22find_last_rarefaction_start_time(v_outlet, waves) 

23 Find time when last rarefaction head reaches outlet 

24integrate_rarefaction_total_mass(raref, v_outlet, t_start, sorption, flow) 

25 Compute total mass exiting through rarefaction to infinity 

26compute_total_outlet_mass(v_outlet, waves, sorption, flow, tedges_days) 

27 Compute total integrated outlet mass until all mass has exited 

28 

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

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

31""" 

32 

33from collections.abc import Sequence 

34from operator import itemgetter 

35 

36import mpmath as mp 

37import numpy as np 

38import numpy.typing as npt 

39from scipy.special import beta as beta_func 

40from scipy.special import betainc 

41 

42from gwtransport.fronttracking.events import find_outlet_crossing 

43from gwtransport.fronttracking.math import ( 

44 ConstantRetardation, 

45 FreundlichSorption, 

46 LangmuirSorption, 

47 NonlinearSorption, 

48 SorptionModel, 

49) 

50from gwtransport.fronttracking.waves import CharacteristicWave, RarefactionWave, ShockWave, Wave 

51 

52# Numerical tolerance constants 

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

54EPSILON_BETA = 1e-15 # Tolerance for checking if beta is effectively zero (linear case) 

55EPSILON_TIME = 1e-15 # Tolerance for negligible time segments 

56EPSILON_TIME_MATCH = 1e-6 # Tolerance for matching arrival times (for rarefaction identification) 

57EPSILON_CONCENTRATION = 1e-10 # Tolerance for checking if concentration is effectively zero 

58 

59 

60def concentration_at_point( 

61 v: float, 

62 t: float, 

63 waves: Sequence[Wave], 

64 sorption: SorptionModel, # noqa: ARG001 

65) -> float: 

66 """ 

67 Compute concentration at point (v, t) with exact analytical value. 

68 

69 Searches through all waves to find which wave controls the concentration 

70 at the given point in space-time. Uses exact analytical solutions for 

71 characteristics, shocks, and rarefaction fans. 

72 

73 Parameters 

74 ---------- 

75 v : float 

76 Position [m³]. 

77 t : float 

78 Time [days]. 

79 waves : list of Wave 

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

81 sorption : SorptionModel 

82 Sorption model. 

83 

84 Returns 

85 ------- 

86 concentration : float 

87 Concentration at point (v, t) [mass/volume]. 

88 

89 Notes 

90 ----- 

91 **Wave Priority**: 

92 The algorithm checks waves in this order: 

93 1. Rarefaction waves (if point is inside rarefaction fan) 

94 2. Shocks (discontinuities) 

95 3. Rarefaction tails (if rarefaction tail has passed point) 

96 4. Characteristics (smooth regions) 

97 

98 If no active wave controls the point, returns 0.0 (initial condition). 

99 

100 **Rarefaction Tail Behavior**: 

101 After a rarefaction tail passes a query point, that point maintains the 

102 tail concentration as a plateau. This ensures proper concentration behavior 

103 after rarefaction waves pass through. 

104 

105 **Machine Precision**: 

106 All position and concentration calculations use exact analytical formulas. 

107 Numerical tolerance is only used for equality checks (v == v_shock). 

108 

109 Examples 

110 -------- 

111 :: 

112 

113 sorption = FreundlichSorption( 

114 k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3 

115 ) 

116 # After running simulation with waves... 

117 c = concentration_at_point(v=250.0, t=5.0, waves=all_waves, sorption=sorption) 

118 c >= 0.0 

119 """ 

120 # Check rarefactions first (they have spatial extent and override other waves) 

121 for wave in waves: 

122 if isinstance(wave, RarefactionWave) and wave.is_active: 

123 c = wave.concentration_at_point(v, t) 

124 if c is not None: 

125 return c 

126 

127 # Track the most recent wave to affect position v 

128 # We need to compare crossing times of shocks and rarefaction tails 

129 latest_wave_time = -np.inf 

130 latest_wave_c = None 

131 

132 # Check shocks - track which shocks control this position 

133 for wave in waves: 

134 if isinstance(wave, ShockWave) and wave.is_active: 

135 v_shock = wave.position_at_time(t) 

136 if v_shock is not None: 

137 # Tolerance for exact shock position 

138 tol = 1e-15 

139 

140 if abs(v - v_shock) < tol: 

141 # Exactly at shock position 

142 return 0.5 * (wave.c_left + wave.c_right) 

143 

144 # Determine if shock has crossed position v and when 

145 if wave.velocity is not None and abs(wave.velocity) > EPSILON_VELOCITY: 

146 t_cross = wave.t_start + (v - wave.v_start) / wave.velocity 

147 

148 if t_cross <= t: 

149 # Shock has crossed position v by time t 

150 # After crossing, point sees c_left (concentration behind shock) 

151 if t_cross > latest_wave_time: 

152 latest_wave_time = t_cross 

153 latest_wave_c = wave.c_left 

154 elif v > v_shock and wave.t_start > latest_wave_time: 

155 # Point is ahead of shock (shock hasn't reached it yet) 

156 # Check if this is the closest shock ahead of us 

157 # In this case, we see c_right unless overridden by another wave 

158 # We track this with a negative time (shock formation time) to indicate 

159 # it's a "passive" state (not actively changed) 

160 latest_wave_time = wave.t_start 

161 latest_wave_c = wave.c_right 

162 

163 # Check rarefaction tails - they can override previous waves 

164 for wave in waves: 

165 if isinstance(wave, RarefactionWave) and wave.is_active: 

166 v_tail = wave.tail_position_at_time(t) 

167 if v_tail is not None and v_tail > v + 1e-15: 

168 # Rarefaction tail has passed position v 

169 # Find when it passed: v_start + tail_velocity * (t_pass - t_start) = v 

170 tail_vel = wave.tail_velocity() 

171 if tail_vel > EPSILON_VELOCITY: 

172 t_pass = wave.t_start + (v - wave.v_start) / tail_vel 

173 if t_pass <= t and t_pass > latest_wave_time: 

174 latest_wave_time = t_pass 

175 latest_wave_c = wave.c_tail 

176 

177 if latest_wave_c is not None: 

178 return latest_wave_c 

179 

180 # Check characteristics 

181 # Find the most recent characteristic that has reached position v by time t 

182 latest_c = 0.0 

183 latest_time = -np.inf 

184 

185 for wave in waves: 

186 if isinstance(wave, CharacteristicWave) and wave.is_active: 

187 # Check if this characteristic has reached position v by time t 

188 v_char_at_t = wave.position_at_time(t) 

189 

190 if v_char_at_t is not None and v_char_at_t >= v - 1e-15: 

191 # This characteristic has passed through v 

192 # Find when it passed: v_start + vel*(t_pass - t_start) = v 

193 vel = wave.velocity() 

194 

195 if vel > EPSILON_VELOCITY: 

196 t_pass = wave.t_start + (v - wave.v_start) / vel 

197 

198 if t_pass <= t and t_pass > latest_time: 

199 latest_time = t_pass 

200 latest_c = wave.concentration 

201 

202 return latest_c 

203 

204 

205def compute_breakthrough_curve( 

206 t_array: npt.NDArray[np.floating], 

207 v_outlet: float, 

208 waves: Sequence[Wave], 

209 sorption: SorptionModel, 

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

211 """ 

212 Compute concentration at outlet over time array. 

213 

214 This is the breakthrough curve: C(v_outlet, t) for all t in t_array. 

215 

216 Parameters 

217 ---------- 

218 t_array : array-like 

219 Time points [days]. Must be sorted in ascending order. 

220 v_outlet : float 

221 Outlet position [m³]. 

222 waves : list of Wave 

223 All waves in the simulation. 

224 sorption : SorptionModel 

225 Sorption model. 

226 

227 Returns 

228 ------- 

229 c_out : numpy.ndarray 

230 Array of concentrations matching t_array [mass/volume]. 

231 

232 See Also 

233 -------- 

234 concentration_at_point : Point-wise concentration 

235 compute_bin_averaged_concentration_exact : Bin-averaged concentrations 

236 

237 Examples 

238 -------- 

239 :: 

240 

241 t_array = np.linspace(0, 100, 1000) 

242 c_out = compute_breakthrough_curve( 

243 t_array, v_outlet=500.0, waves=all_waves, sorption=sorption 

244 ) 

245 len(c_out) == len(t_array) 

246 """ 

247 t_array = np.asarray(t_array, dtype=float) 

248 c_out = np.zeros(len(t_array)) 

249 

250 for i, t in enumerate(t_array): 

251 c_out[i] = concentration_at_point(v_outlet, t, waves, sorption) 

252 

253 return c_out 

254 

255 

256def identify_outlet_segments( 

257 t_start: float, 

258 t_end: float, 

259 v_outlet: float, 

260 waves: Sequence[Wave], 

261 sorption: SorptionModel, 

262) -> list[dict]: 

263 """ 

264 Identify which waves control outlet concentration in time interval [t_start, t_end]. 

265 

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

267 concentration is constant or varying (rarefaction). 

268 

269 Parameters 

270 ---------- 

271 t_start : float 

272 Start of time interval [days]. 

273 t_end : float 

274 End of time interval [days]. 

275 v_outlet : float 

276 Outlet position [m³]. 

277 waves : list of Wave 

278 All waves in the simulation. 

279 sorption : SorptionModel 

280 Sorption model. 

281 

282 Returns 

283 ------- 

284 segments : list of dict 

285 List of segment dictionaries, each containing: 

286 

287 - 't_start' : float 

288 Segment start time 

289 - 't_end' : float 

290 Segment end time 

291 - 'type' : str 

292 'constant' or 'rarefaction' 

293 - 'concentration' : float 

294 For constant segments 

295 - 'wave' : Wave 

296 For rarefaction segments 

297 - 'c_start' : float 

298 Concentration at segment start 

299 - 'c_end' : float 

300 Concentration at segment end 

301 

302 Notes 

303 ----- 

304 Segments are constructed by: 

305 1. Finding all wave crossing events at outlet in [t_start, t_end] 

306 2. Sorting events chronologically 

307 3. Creating constant-concentration segments between events 

308 4. Handling rarefaction fans with time-varying concentration 

309 

310 The segments completely partition the time interval [t_start, t_end]. 

311 """ 

312 # Find all waves that cross outlet in this time range 

313 outlet_events = [] 

314 

315 # Track rarefactions that already contain the outlet at t_start 

316 # These need to be handled separately since they don't generate crossing events 

317 active_rarefactions_at_start = [] 

318 

319 for wave in waves: 

320 if not wave.is_active: 

321 continue 

322 

323 # For rarefactions, detect both head and tail crossings 

324 if isinstance(wave, RarefactionWave): 

325 # Check if outlet is already inside this rarefaction at t_start 

326 if wave.contains_point(v_outlet, t_start): 

327 active_rarefactions_at_start.append(wave) 

328 # Don't add crossing events for this wave since we're already inside it 

329 # But we still need to detect when the tail crosses during [t_start, t_end] 

330 v_tail = wave.tail_position_at_time(t_start) 

331 if v_tail is not None and v_tail < v_outlet: 

332 vel_tail = wave.tail_velocity() 

333 if vel_tail > 0: 

334 dt = (v_outlet - v_tail) / vel_tail 

335 t_cross = t_start + dt 

336 if t_start < t_cross <= t_end: 

337 outlet_events.append({ 

338 "time": t_cross, 

339 "wave": wave, 

340 "boundary": "tail", 

341 "c_after": wave.c_tail, 

342 }) 

343 continue 

344 

345 # Head crossing 

346 v_head = wave.head_position_at_time(t_start) 

347 if v_head is not None and v_head < v_outlet: 

348 vel_head = wave.head_velocity() 

349 if vel_head > 0: 

350 dt = (v_outlet - v_head) / vel_head 

351 t_cross = t_start + dt 

352 if t_start <= t_cross <= t_end: 

353 outlet_events.append({ 

354 "time": t_cross, 

355 "wave": wave, 

356 "boundary": "head", 

357 "c_after": wave.c_head, 

358 }) 

359 

360 # Tail crossing 

361 v_tail = wave.tail_position_at_time(t_start) 

362 if v_tail is not None and v_tail < v_outlet: 

363 vel_tail = wave.tail_velocity() 

364 if vel_tail > 0: 

365 dt = (v_outlet - v_tail) / vel_tail 

366 t_cross = t_start + dt 

367 if t_start <= t_cross <= t_end: 

368 outlet_events.append({ 

369 "time": t_cross, 

370 "wave": wave, 

371 "boundary": "tail", 

372 "c_after": wave.c_tail, 

373 }) 

374 else: 

375 # Characteristics and shocks 

376 t_cross = find_outlet_crossing(wave, v_outlet, t_start) 

377 

378 if t_cross is not None and t_start <= t_cross <= t_end: 

379 if isinstance(wave, CharacteristicWave): 

380 c_after = wave.concentration 

381 elif isinstance(wave, ShockWave): 

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

383 c_after = wave.c_left 

384 else: 

385 c_after = 0.0 

386 

387 outlet_events.append({"time": t_cross, "wave": wave, "boundary": None, "c_after": c_after}) 

388 

389 # Sort events by time 

390 outlet_events.sort(key=itemgetter("time")) 

391 

392 # Create segments between events 

393 segments = [] 

394 current_t = t_start 

395 current_c = concentration_at_point(v_outlet, t_start, waves, sorption) 

396 

397 # Handle case where we start inside a rarefaction 

398 if active_rarefactions_at_start: 

399 # Should only be one rarefaction containing the outlet at t_start 

400 raref = active_rarefactions_at_start[0] 

401 

402 # Find when tail crosses (if it does) 

403 tail_cross_time = None 

404 for event in outlet_events: 

405 if event["wave"] is raref and event["boundary"] == "tail" and event["time"] > t_start: 

406 tail_cross_time = event["time"] 

407 break 

408 

409 # Create rarefaction segment from t_start 

410 raref_end = min(tail_cross_time or t_end, t_end) 

411 

412 c_start = concentration_at_point(v_outlet, t_start, waves, sorption) 

413 c_end = None 

414 if tail_cross_time and tail_cross_time <= t_end: 

415 c_end = raref.c_tail 

416 

417 segments.append({ 

418 "t_start": t_start, 

419 "t_end": raref_end, 

420 "type": "rarefaction", 

421 "wave": raref, 

422 "c_start": c_start, 

423 "c_end": c_end, 

424 }) 

425 

426 current_t = raref_end 

427 current_c = ( 

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

429 ) 

430 

431 for event in outlet_events: 

432 # Check if we're entering a rarefaction fan 

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

434 # Before rarefaction head: constant segment 

435 if event["time"] > current_t: 

436 segments.append({ 

437 "t_start": current_t, 

438 "t_end": event["time"], 

439 "type": "constant", 

440 "concentration": current_c, 

441 "c_start": current_c, 

442 "c_end": current_c, 

443 }) 

444 

445 # Find when tail crosses (if it does) 

446 raref = event["wave"] 

447 tail_cross_time = None 

448 

449 for later_event in outlet_events: 

450 if ( 

451 later_event["wave"] is raref 

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

453 and later_event["time"] > event["time"] 

454 ): 

455 tail_cross_time = later_event["time"] 

456 break 

457 

458 # Rarefaction segment 

459 raref_end = min(tail_cross_time or t_end, t_end) 

460 

461 segments.append({ 

462 "t_start": event["time"], 

463 "t_end": raref_end, 

464 "type": "rarefaction", 

465 "wave": raref, 

466 "c_start": raref.c_head, 

467 "c_end": raref.c_tail if tail_cross_time and tail_cross_time <= t_end else None, 

468 }) 

469 

470 current_t = raref_end 

471 current_c = ( 

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

473 ) 

474 else: 

475 # Regular event (characteristic or shock crossing) 

476 # Segment before event 

477 if event["time"] > current_t: 

478 segments.append({ 

479 "t_start": current_t, 

480 "t_end": event["time"], 

481 "type": "constant", 

482 "concentration": current_c, 

483 "c_start": current_c, 

484 "c_end": current_c, 

485 }) 

486 

487 current_t = event["time"] 

488 current_c = event["c_after"] 

489 

490 # Final segment 

491 if t_end > current_t: 

492 segments.append({ 

493 "t_start": current_t, 

494 "t_end": t_end, 

495 "type": "constant", 

496 "concentration": current_c, 

497 "c_start": current_c, 

498 "c_end": current_c, 

499 }) 

500 

501 return segments 

502 

503 

504def integrate_rarefaction_exact( 

505 raref: RarefactionWave, v_outlet: float, t_start: float, t_end: float, sorption: SorptionModel 

506) -> float: 

507 """ 

508 Exact analytical integral of rarefaction concentration over time at fixed position. 

509 

510 Computes integral of C(t) dt from t_start to t_end where C(t) is the 

511 self-similar rarefaction solution at the outlet. Dispatches to 

512 isotherm-specific closed-form formulas. 

513 

514 Parameters 

515 ---------- 

516 raref : RarefactionWave 

517 Rarefaction wave controlling the outlet. 

518 v_outlet : float 

519 Outlet position [m3]. 

520 t_start : float 

521 Integration start time [days]. Can be -np.inf. 

522 t_end : float 

523 Integration end time [days]. Can be np.inf. 

524 sorption : SorptionModel 

525 Sorption model (FreundlichSorption or LangmuirSorption). 

526 

527 Returns 

528 ------- 

529 integral : float 

530 Exact integral value [concentration * time]. 

531 

532 Raises 

533 ------ 

534 TypeError 

535 If sorption model does not support exact rarefaction integration. 

536 """ 

537 if isinstance(sorption, FreundlichSorption): 

538 return _integrate_rarefaction_exact_freundlich(raref, v_outlet, t_start, t_end, sorption) 

539 if isinstance(sorption, LangmuirSorption): 

540 return _integrate_rarefaction_exact_langmuir(raref, v_outlet, t_start, t_end, sorption) 

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

542 raise TypeError(msg) 

543 

544 

545def _integrate_rarefaction_exact_freundlich( 

546 raref: RarefactionWave, v_outlet: float, t_start: float, t_end: float, sorption: FreundlichSorption 

547) -> float: 

548 """Exact temporal integral for Freundlich rarefaction. 

549 

550 See `integrate_rarefaction_exact` for parameters. 

551 

552 Returns 

553 ------- 

554 float 

555 Exact integral value [concentration * time]. 

556 

557 Raises 

558 ------ 

559 ValueError 

560 If sorption is linear (n = 1) or integral diverges. 

561 

562 Notes 

563 ----- 

564 For Freundlich: C(t) = [(kappa*t + mu - 1)/alpha]^(1/beta) where 

565 kappa = flow/(v_outlet - v_origin), mu = -flow*t_origin/(v_outlet - v_origin), 

566 alpha = rho_b*k_f/(n_por*n), beta = 1/n - 1. 

567 

568 Antiderivative: F(t) = coeff * (kappa*t + mu - 1)^(1/beta + 1) 

569 """ 

570 t_origin = raref.t_start 

571 v_origin = raref.v_start 

572 flow = raref.flow 

573 

574 kappa = flow / (v_outlet - v_origin) 

575 mu = -flow * t_origin / (v_outlet - v_origin) 

576 

577 alpha = sorption.bulk_density * sorption.k_f / (sorption.porosity * sorption.n) 

578 beta = 1.0 / sorption.n - 1.0 

579 

580 if abs(beta) < EPSILON_BETA: 

581 msg = "integrate_rarefaction_exact requires nonlinear sorption (n != 1)" 

582 raise ValueError(msg) 

583 

584 exponent = 1.0 / beta + 1.0 

585 coeff = 1.0 / (alpha ** (1.0 / beta) * kappa * exponent) 

586 

587 def antiderivative(t: float) -> float: 

588 if np.isinf(t): 

589 if t > 0: 

590 if exponent < 0: 

591 return 0.0 

592 msg = f"Integral diverges at t=+∞ with exponent={exponent} > 0" 

593 raise ValueError(msg) 

594 return 0.0 

595 

596 base = kappa * t + mu - 1.0 

597 if base <= 0: 

598 return 0.0 

599 return coeff * base**exponent 

600 

601 return antiderivative(t_end) - antiderivative(t_start) 

602 

603 

604def _integrate_rarefaction_exact_langmuir( 

605 raref: RarefactionWave, v_outlet: float, t_start: float, t_end: float, sorption: LangmuirSorption 

606) -> float: 

607 """Exact temporal integral for Langmuir rarefaction. 

608 

609 See `integrate_rarefaction_exact` for parameters. 

610 

611 Returns 

612 ------- 

613 float 

614 Exact integral value [concentration * time]. 

615 

616 Notes 

617 ----- 

618 For Langmuir: C(t) = sqrt(A / B(t)) - K_L where 

619 B(t) = kappa*t + mu - 1, 

620 kappa = flow/(v_outlet - v_origin), mu = -flow*t_origin/(v_outlet - v_origin), 

621 A = rho_b * s_max * K_L / n_por. 

622 

623 Antiderivative: F(t) = (2*sqrt(A)/kappa) * sqrt(B(t)) - K_L * t 

624 """ 

625 t_origin = raref.t_start 

626 v_origin = raref.v_start 

627 flow = raref.flow 

628 

629 kappa = flow / (v_outlet - v_origin) 

630 mu = -flow * t_origin / (v_outlet - v_origin) 

631 a_coeff = sorption.a_coeff 

632 k_l = sorption.k_l 

633 

634 coeff_sqrt = 2.0 * np.sqrt(a_coeff) / kappa 

635 

636 def antiderivative(t: float) -> float: 

637 if np.isinf(t): 

638 if t > 0: 

639 # For Langmuir, sqrt(B) → ∞ as t → ∞: integral diverges. 

640 # Physical Langmuir rarefactions always have finite tail velocity, 

641 # so t_end should always be finite. 

642 msg = "Langmuir rarefaction integral diverges at t=+∞" 

643 raise ValueError(msg) 

644 return 0.0 

645 

646 base = kappa * t + mu - 1.0 

647 if base <= 0: 

648 return 0.0 

649 return coeff_sqrt * np.sqrt(base) - k_l * t 

650 

651 return antiderivative(t_end) - antiderivative(t_start) 

652 

653 

654def compute_bin_averaged_concentration_exact( 

655 t_edges: npt.NDArray[np.floating], 

656 v_outlet: float, 

657 waves: Sequence[Wave], 

658 sorption: SorptionModel, 

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

660 """ 

661 Compute bin-averaged concentration using EXACT analytical integration. 

662 

663 For each time bin [t_i, t_{i+1}], computes: 

664 C_avg = (1/(t_{i+1} - t_i)) * ∫_{t_i}^{t_{i+1}} C(v_outlet, t) dt 

665 

666 This is the critical function for maintaining machine precision in output. 

667 All integrations use exact analytical formulas with no numerical quadrature. 

668 

669 Parameters 

670 ---------- 

671 t_edges : array-like 

672 Time bin edges [days]. Length N+1 for N bins. 

673 v_outlet : float 

674 Outlet position [m³]. 

675 waves : list of Wave 

676 All waves from front tracking simulation. 

677 sorption : SorptionModel 

678 Sorption model. 

679 

680 Returns 

681 ------- 

682 c_avg : numpy.ndarray 

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

684 

685 Raises 

686 ------ 

687 ValueError 

688 If any time bin has non-positive width (``t_edges[i] >= t_edges[i+1]``), 

689 or if an unknown segment type is encountered during integration. 

690 

691 See Also 

692 -------- 

693 concentration_at_point : Point-wise concentration 

694 compute_breakthrough_curve : Breakthrough curve 

695 integrate_rarefaction_exact : Exact rarefaction integration 

696 

697 Notes 

698 ----- 

699 **Algorithm**: 

700 

701 1. For each bin [t_i, t_{i+1}]: 

702 

703 a. Identify which wave segments control outlet during this period 

704 b. For each segment, compute: Constant C gives integral = C * Δt, 

705 Rarefaction C(t) uses exact analytical integral formula 

706 c. Sum segment integrals and divide by bin width 

707 

708 **Machine Precision**: 

709 

710 - Constant segments: exact to floating-point precision 

711 - Rarefaction segments: uses closed-form antiderivative 

712 - No numerical quadrature or interpolation 

713 - Maintains mass balance to ~1e-14 relative error 

714 

715 **Rarefaction Integration**: 

716 

717 For Freundlich sorption, rarefaction concentration at outlet varies as:: 

718 

719 C(t) = [(kappa*t + mu - 1)/alpha]^(1/beta) 

720 

721 The exact integral is:: 

722 

723 ∫ C dt = (1/(alpha^(1/beta)*kappa*exponent)) * (kappa*t + mu - 1)^exponent 

724 

725 where exponent = 1/beta + 1. 

726 

727 Examples 

728 -------- 

729 :: 

730 

731 # After running front tracking simulation 

732 t_edges = np.array([0.0, 10.0, 20.0, 30.0]) 

733 c_avg = compute_bin_averaged_concentration_exact( 

734 t_edges=t_edges, 

735 v_outlet=500.0, 

736 waves=tracker.state.waves, 

737 sorption=sorption, 

738 ) 

739 len(c_avg) == len(t_edges) - 1 

740 np.all(c_avg >= 0) 

741 """ 

742 t_edges = np.asarray(t_edges, dtype=float) 

743 n_bins = len(t_edges) - 1 

744 c_avg = np.zeros(n_bins) 

745 

746 for i in range(n_bins): 

747 t_start = t_edges[i] 

748 t_end = t_edges[i + 1] 

749 dt = t_end - t_start 

750 

751 if dt <= 0: 

752 msg = f"Invalid time bin: t_edges[{i}]={t_start} >= t_edges[{i + 1}]={t_end}" 

753 raise ValueError(msg) 

754 

755 # Identify wave segments controlling outlet in this time bin 

756 segments = identify_outlet_segments(t_start, t_end, v_outlet, waves, sorption) 

757 

758 # Integrate each segment 

759 total_integral = 0.0 

760 

761 for seg in segments: 

762 seg_t_start = max(seg["t_start"], t_start) 

763 seg_t_end = min(seg["t_end"], t_end) 

764 seg_dt = seg_t_end - seg_t_start 

765 

766 if seg_dt <= EPSILON_TIME: # Skip negligible segments 

767 continue 

768 

769 if seg["type"] == "constant": 

770 # C is constant over segment - exact integral 

771 integral = seg["concentration"] * seg_dt 

772 

773 elif seg["type"] == "rarefaction": 

774 # C(t) given by self-similar solution - use exact analytical integral 

775 if isinstance(sorption, NonlinearSorption): 

776 raref = seg["wave"] 

777 integral = integrate_rarefaction_exact(raref, v_outlet, seg_t_start, seg_t_end, sorption) 

778 else: 

779 # ConstantRetardation - rarefactions shouldn't form, use constant approximation 

780 c_mid = concentration_at_point(v_outlet, 0.5 * (seg_t_start + seg_t_end), waves, sorption) 

781 integral = c_mid * seg_dt 

782 else: 

783 msg = f"Unknown segment type: {seg['type']}" 

784 raise ValueError(msg) 

785 

786 total_integral += integral 

787 

788 c_avg[i] = total_integral / dt 

789 

790 return c_avg 

791 

792 

793def compute_domain_mass( 

794 t: float, 

795 v_outlet: float, 

796 waves: Sequence[Wave], 

797 sorption: SorptionModel, 

798) -> float: 

799 """ 

800 Compute total mass in domain [0, v_outlet] at time t using exact analytical integration. 

801 

802 Implements runtime mass balance verification as described in High Priority #3 

803 of FRONT_TRACKING_REBUILD_PLAN.md. Integrates concentration over space: 

804 

805 M(t) = ∫₀^v_outlet C(v, t) dv 

806 

807 using exact analytical formulas for each wave type. 

808 

809 Parameters 

810 ---------- 

811 t : float 

812 Time at which to compute domain mass [days]. 

813 v_outlet : float 

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

815 waves : list of Wave 

816 All waves in the simulation. 

817 sorption : SorptionModel 

818 Sorption model. 

819 

820 Returns 

821 ------- 

822 mass : float 

823 Total mass in domain [mass]. Computed to machine precision (~1e-14). 

824 

825 See Also 

826 -------- 

827 compute_cumulative_inlet_mass : Cumulative inlet mass 

828 compute_cumulative_outlet_mass : Cumulative outlet mass 

829 concentration_at_point : Point-wise concentration 

830 

831 Notes 

832 ----- 

833 **Algorithm**: 

834 

835 1. Partition spatial domain [0, v_outlet] into segments where concentration 

836 is controlled by a single wave or is constant. 

837 2. For each segment, compute mass analytically: 

838 - Constant C: mass = C * Δv 

839 - Rarefaction C(v): use exact spatial integration formula 

840 3. Sum all segment masses. 

841 

842 **Wave Priority** (same as concentration_at_point): 

843 

844 1. Rarefactions (if position is inside rarefaction fan) 

845 2. Shocks and rarefaction tails (most recent to pass) 

846 3. Characteristics (smooth regions) 

847 

848 **Rarefaction Spatial Integration**: 

849 

850 For a rarefaction fan at fixed time t, concentration varies with position v 

851 according to the self-similar solution: 

852 

853 R(C) = flow*(t - t_origin)/(v - v_origin) 

854 

855 The spatial integral ∫ C dv is computed analytically using the inverse 

856 retardation relation. 

857 

858 **Integration Precision**: 

859 

860 - Constant concentration regions: Exact analytical (C_total * dv) 

861 - Rarefaction regions: High-precision trapezoidal quadrature (10+ points) 

862 - Overall accuracy: ~1e-10 to 1e-12 relative error 

863 - Sufficient for runtime verification; primary outputs remain exact 

864 

865 Examples 

866 -------- 

867 :: 

868 

869 # After running simulation to time t=10.0 

870 mass = compute_domain_mass( 

871 t=10.0, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption 

872 ) 

873 mass >= 0.0 

874 """ 

875 # Partition spatial domain into segments based on wave structure 

876 # We'll evaluate concentration at many points and identify constant regions 

877 

878 # Collect all wave positions at time t 

879 wave_positions = [] 

880 

881 for wave in waves: 

882 if not wave.is_active: 

883 continue 

884 

885 if isinstance(wave, (CharacteristicWave, ShockWave)): 

886 v_pos = wave.position_at_time(t) 

887 if v_pos is not None and 0 <= v_pos <= v_outlet: 

888 wave_positions.append(v_pos) 

889 

890 elif isinstance(wave, RarefactionWave): 

891 v_head = wave.head_position_at_time(t) 

892 v_tail = wave.tail_position_at_time(t) 

893 

894 if v_head is not None and 0 <= v_head <= v_outlet: 

895 wave_positions.append(v_head) 

896 if v_tail is not None and 0 <= v_tail <= v_outlet: 

897 wave_positions.append(v_tail) 

898 

899 # Add domain boundaries 

900 wave_positions.extend([0.0, v_outlet]) 

901 

902 # Sort and remove duplicates 

903 wave_positions = sorted(set(wave_positions)) 

904 

905 # Remove positions outside domain 

906 wave_positions = [v for v in wave_positions if 0 <= v <= v_outlet] 

907 

908 # Compute mass in each segment using refined integration 

909 total_mass = 0.0 

910 

911 for i in range(len(wave_positions) - 1): 

912 v_start = wave_positions[i] 

913 v_end = wave_positions[i + 1] 

914 dv = v_end - v_start 

915 

916 if dv < EPSILON_VELOCITY: 

917 continue 

918 

919 # Check if this segment is inside a rarefaction fan 

920 # Sample at midpoint to check 

921 v_mid = 0.5 * (v_start + v_end) 

922 inside_rarefaction = False 

923 raref_wave = None 

924 

925 for wave in waves: 

926 if isinstance(wave, RarefactionWave) and wave.is_active and wave.contains_point(v_mid, t): 

927 inside_rarefaction = True 

928 raref_wave = wave 

929 break 

930 

931 if inside_rarefaction and raref_wave is not None: 

932 # Rarefaction: concentration varies with position 

933 # Use EXACT analytical spatial integration 

934 mass_segment = _integrate_rarefaction_spatial_exact(raref_wave, v_start, v_end, t, sorption) 

935 else: 

936 # Constant concentration region - exact integration 

937 v_mid = 0.5 * (v_start + v_end) 

938 c = concentration_at_point(v_mid, t, waves, sorption) 

939 c_total = sorption.total_concentration(c) 

940 mass_segment = c_total * dv 

941 

942 total_mass += mass_segment 

943 

944 return total_mass 

945 

946 

947def _integrate_rarefaction_spatial_exact( 

948 raref: RarefactionWave, 

949 v_start: float, 

950 v_end: float, 

951 t: float, 

952 sorption: SorptionModel, 

953) -> float: 

954 """ 

955 Exact analytical spatial integral of rarefaction total concentration at fixed time. 

956 

957 Computes integral of C_total(v) dv from v_start to v_end analytically using 

958 closed-form antiderivatives. This maintains machine precision for the mass 

959 balance diagnostic. 

960 

961 Parameters 

962 ---------- 

963 raref : RarefactionWave 

964 Rarefaction wave. 

965 v_start : float 

966 Integration start position [m3]. 

967 v_end : float 

968 Integration end position [m3]. 

969 t : float 

970 Time [days]. 

971 sorption : SorptionModel 

972 Sorption model. 

973 

974 Returns 

975 ------- 

976 mass : float 

977 Exact mass in segment to machine precision. 

978 

979 Raises 

980 ------ 

981 TypeError 

982 If sorption model does not support exact spatial integration. 

983 

984 Notes 

985 ----- 

986 For rarefaction at time t: R(C) = kappa/(v - v0) where kappa = flow*(t - t0). 

987 

988 For Freundlich: R = 1 + alpha*C^beta where alpha = rho_b*k_f/(n_por*n), 

989 beta = 1/n - 1. 

990 Total concentration: C_total = C + (rho_b/n_por)*k_f*C^(1/n). 

991 

992 Both integrals reduce to power-law forms u^p (kappa-u)^q du which can be 

993 expressed using the generalized incomplete beta function via mpmath.betainc(). 

994 

995 For Langmuir, the integral uses only sqrt operations. 

996 """ 

997 if isinstance(sorption, ConstantRetardation): 

998 # Constant retardation: no rarefactions 

999 v_mid = 0.5 * (v_start + v_end) 

1000 c = raref.concentration_at_point(v_mid, t) or 0.0 

1001 c_total = sorption.total_concentration(c) 

1002 return c_total * (v_end - v_start) 

1003 

1004 t_origin = raref.t_start 

1005 v_origin = raref.v_start 

1006 flow = raref.flow 

1007 

1008 if t <= t_origin: 

1009 return 0.0 

1010 

1011 kappa = flow * (t - t_origin) 

1012 u_start = v_start - v_origin 

1013 u_end = v_end - v_origin 

1014 

1015 if u_start <= 0 or u_end <= 0: 

1016 return 0.0 

1017 

1018 if isinstance(sorption, LangmuirSorption): 

1019 return _integrate_rarefaction_spatial_langmuir(sorption, kappa, u_start, u_end) 

1020 

1021 if isinstance(sorption, FreundlichSorption): 

1022 return _integrate_rarefaction_spatial_freundlich(sorption, kappa, u_start, u_end) 

1023 

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

1025 raise TypeError(msg) 

1026 

1027 

1028def _integrate_rarefaction_spatial_freundlich( 

1029 sorption: FreundlichSorption, kappa: float, u_start: float, u_end: float 

1030) -> float: 

1031 """Exact spatial integral for Freundlich rarefaction using beta functions. 

1032 

1033 Returns 

1034 ------- 

1035 float 

1036 Exact mass in segment. 

1037 """ 

1038 alpha = sorption.bulk_density * sorption.k_f / (sorption.porosity * sorption.n) 

1039 rho_b = sorption.bulk_density 

1040 n_por = sorption.porosity 

1041 k_f = sorption.k_f 

1042 n = sorption.n 

1043 

1044 beta = 1 / n - 1 

1045 t_start_norm = u_start / kappa 

1046 t_end_norm = u_end / kappa 

1047 

1048 # Dissolved: ∫ C(u) du via incomplete beta function 

1049 a_diss = 1 - 1 / beta 

1050 b_diss = 1 + 1 / beta 

1051 

1052 if a_diss > 0 and b_diss > 0: 

1053 beta_diss = betainc(a_diss, b_diss, t_end_norm) - betainc(a_diss, b_diss, t_start_norm) 

1054 beta_diss *= beta_func(a_diss, b_diss) 

1055 else: 

1056 beta_diss = float(mp.betainc(a_diss, b_diss, t_start_norm, t_end_norm, regularized=False)) 

1057 

1058 coeff_diss = (1 / alpha) ** (1 / beta) 

1059 mass_dissolved = coeff_diss * kappa * beta_diss 

1060 

1061 # Sorbed: ∫ (rho_b/n_por)*k_f*C^(1/n) du 

1062 exponent_sorb = 1 / (1 - n) 

1063 a_sorb = 1 - exponent_sorb 

1064 b_sorb = 1 + exponent_sorb 

1065 

1066 if a_sorb > 0 and b_sorb > 0: 

1067 beta_sorb = betainc(a_sorb, b_sorb, t_end_norm) - betainc(a_sorb, b_sorb, t_start_norm) 

1068 beta_sorb *= beta_func(a_sorb, b_sorb) 

1069 else: 

1070 beta_sorb = float(mp.betainc(a_sorb, b_sorb, t_start_norm, t_end_norm, regularized=False)) 

1071 

1072 coeff_sorb = (rho_b / n_por) * k_f / (alpha**exponent_sorb) 

1073 mass_sorbed = coeff_sorb * kappa * beta_sorb 

1074 

1075 return mass_dissolved + mass_sorbed 

1076 

1077 

1078def _integrate_rarefaction_spatial_langmuir( 

1079 sorption: LangmuirSorption, kappa: float, u_start: float, u_end: float 

1080) -> float: 

1081 """Exact spatial integral of C_total(v) for Langmuir rarefaction. 

1082 

1083 Returns 

1084 ------- 

1085 float 

1086 Exact mass in segment. 

1087 

1088 Notes 

1089 ----- 

1090 With u = v - v_origin, kappa = flow*(t - t_origin): 

1091 C(u) = sqrt(a_coeff*u/(kappa-u)) - K_L 

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

1093 

1094 Since K_L + C = sqrt(a_coeff*u/(kappa-u)): 

1095 C/(K_L+C) = 1 - K_L*sqrt((kappa-u)/(a_coeff*u)) 

1096 

1097 The integral simplifies to: 

1098 integral C_total du = -2*sqrt(a_coeff)*[sqrt(u*(kappa-u))]_start^end 

1099 + (rho_b*s_max/n_por - K_L)*(u_end - u_start) 

1100 """ 

1101 a_coeff = sorption.a_coeff 

1102 k_l = sorption.k_l 

1103 sorbed_max = sorption.bulk_density * sorption.s_max / sorption.porosity 

1104 

1105 term_sqrt_end = np.sqrt(u_end * (kappa - u_end)) 

1106 term_sqrt_start = np.sqrt(u_start * (kappa - u_start)) 

1107 

1108 return -2.0 * np.sqrt(a_coeff) * (term_sqrt_end - term_sqrt_start) + (sorbed_max - k_l) * (u_end - u_start) 

1109 

1110 

1111def compute_cumulative_inlet_mass( 

1112 t: float, 

1113 cin: npt.ArrayLike, 

1114 flow: npt.ArrayLike, 

1115 tedges_days: npt.ArrayLike, 

1116) -> float: 

1117 """ 

1118 Compute cumulative mass entering domain from t=0 to t. 

1119 

1120 Integrates inlet mass flux over time: 

1121 M_in(t) = ∫₀^t cin(τ) * flow(τ) dτ 

1122 

1123 using exact analytical integration of piecewise-constant functions. 

1124 

1125 Parameters 

1126 ---------- 

1127 t : float 

1128 Time up to which to integrate [days]. 

1129 cin : array-like 

1130 Inlet concentration time series [mass/volume]. 

1131 Piecewise constant within bins defined by tedges_days. 

1132 flow : array-like 

1133 Flow rate time series [m³/day]. 

1134 Piecewise constant within bins defined by tedges_days. 

1135 tedges_days : numpy.ndarray 

1136 Time bin edges [days]. Length len(cin) + 1. 

1137 

1138 Returns 

1139 ------- 

1140 mass_in : float 

1141 Cumulative mass entered [mass]. 

1142 

1143 Notes 

1144 ----- 

1145 For piecewise-constant cin and flow: 

1146 M_in = Σ cin[i] * flow[i] * dt[i] 

1147 

1148 where the sum is over all bins from tedges_days[0] to t. 

1149 Partial bins are handled exactly. 

1150 

1151 Examples 

1152 -------- 

1153 :: 

1154 

1155 mass_in = compute_cumulative_inlet_mass( 

1156 t=50.0, cin=cin, flow=flow, tedges_days=tedges_days 

1157 ) 

1158 mass_in >= 0.0 

1159 """ 

1160 tedges_arr = np.asarray(tedges_days, dtype=float) 

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

1162 flow_arr = np.asarray(flow, dtype=float) 

1163 

1164 # Find which bin t falls into 

1165 if t <= tedges_arr[0]: 

1166 return 0.0 

1167 

1168 if t >= tedges_arr[-1]: 

1169 # Integrate all bins 

1170 dt = np.diff(tedges_arr) 

1171 return float(np.sum(cin_arr * flow_arr * dt)) 

1172 

1173 # Find bin containing t 

1174 bin_idx = np.searchsorted(tedges_arr, t, side="right") - 1 

1175 

1176 # Mass flux across inlet boundary = Q * C_in (aqueous concentration) 

1177 # This is correct for sorbing solutes: only dissolved mass flows with water 

1178 # Integrate complete bins before t 

1179 if bin_idx > 0: 

1180 dt_complete = np.diff(tedges_arr[: bin_idx + 1]) 

1181 mass_complete = np.sum(cin_arr[:bin_idx] * flow_arr[:bin_idx] * dt_complete) 

1182 else: 

1183 mass_complete = 0.0 

1184 

1185 # Add partial bin 

1186 if bin_idx >= 0 and bin_idx < len(cin_arr): 

1187 dt_partial = t - tedges_arr[bin_idx] 

1188 mass_partial = cin_arr[bin_idx] * flow_arr[bin_idx] * dt_partial 

1189 else: 

1190 mass_partial = 0.0 

1191 

1192 return float(mass_complete + mass_partial) 

1193 

1194 

1195def find_last_rarefaction_start_time( 

1196 v_outlet: float, 

1197 waves: Sequence[Wave], 

1198) -> float: 

1199 """ 

1200 Find the time when the last rarefaction head reaches the outlet. 

1201 

1202 For rarefactions, we integrate analytically so we only need to know 

1203 when the rarefaction starts at the outlet (head arrival). 

1204 

1205 Parameters 

1206 ---------- 

1207 v_outlet : float 

1208 Outlet position [m³]. 

1209 waves : list of Wave 

1210 All waves in the simulation. 

1211 

1212 Returns 

1213 ------- 

1214 t_last : float 

1215 Time when last rarefaction head reaches outlet [days]. 

1216 For non-rarefaction waves, uses their arrival time. 

1217 Returns 0.0 if no waves reach the outlet. 

1218 

1219 Notes 

1220 ----- 

1221 This function finds when the last wave structure *starts* at the outlet. 

1222 For rarefactions, this is the head arrival time. The tail may arrive 

1223 much later (or at infinite time for rarefactions to C=0), but the 

1224 total mass in the rarefaction is computed analytically. 

1225 

1226 Examples 

1227 -------- 

1228 :: 

1229 

1230 t_last = find_last_rarefaction_start_time(v_outlet=500.0, waves=all_waves) 

1231 t_last >= 0.0 

1232 """ 

1233 t_last = 0.0 

1234 

1235 for wave in waves: 

1236 if not wave.is_active: 

1237 continue 

1238 

1239 if isinstance(wave, RarefactionWave): 

1240 # For rarefaction, use head arrival (when rarefaction starts) 

1241 head_vel = wave.head_velocity() 

1242 if head_vel > EPSILON_VELOCITY: 

1243 t_arrival = wave.t_start + (v_outlet - wave.v_start) / head_vel 

1244 t_last = max(t_last, t_arrival) 

1245 elif isinstance(wave, (CharacteristicWave, ShockWave)): 

1246 # For characteristics and shocks, compute arrival time 

1247 vel = wave.velocity if isinstance(wave, ShockWave) else wave.velocity() 

1248 if vel is not None and vel > EPSILON_VELOCITY: 

1249 t_arrival = wave.t_start + (v_outlet - wave.v_start) / vel 

1250 t_last = max(t_last, t_arrival) 

1251 

1252 return t_last 

1253 

1254 

1255def compute_cumulative_outlet_mass( 

1256 t: float, 

1257 v_outlet: float, 

1258 waves: Sequence[Wave], 

1259 sorption: SorptionModel, 

1260 flow: npt.ArrayLike, 

1261 tedges_days: npt.ArrayLike, 

1262) -> float: 

1263 """ 

1264 Compute cumulative mass exiting domain from t=0 to t. 

1265 

1266 Integrates outlet mass flux over time: 

1267 M_out(t) = ∫₀^t cout(τ) * flow(τ) dτ 

1268 

1269 using exact analytical integration. Outlet concentration cout(τ) is obtained 

1270 from the wave solution, and flow(τ) is piecewise constant. 

1271 

1272 Parameters 

1273 ---------- 

1274 t : float 

1275 Time up to which to integrate [days]. 

1276 v_outlet : float 

1277 Outlet position [m³]. 

1278 waves : list of Wave 

1279 All waves in the simulation. 

1280 sorption : SorptionModel 

1281 Sorption model. 

1282 flow : array-like 

1283 Flow rate time series [m³/day]. 

1284 Piecewise constant within bins defined by tedges_days. 

1285 tedges_days : numpy.ndarray 

1286 Time bin edges [days]. Length len(flow) + 1. 

1287 

1288 Returns 

1289 ------- 

1290 mass_out : float 

1291 Cumulative mass exited [mass]. 

1292 

1293 Notes 

1294 ----- 

1295 The outlet concentration is obtained from wave solution via 

1296 concentration_at_point(v_outlet, τ, waves, sorption). 

1297 

1298 For each flow bin [t_i, t_{i+1}], the mass flux integral is computed 

1299 exactly using identify_outlet_segments and analytical integration 

1300 (constant segments and rarefaction segments). 

1301 

1302 Examples 

1303 -------- 

1304 :: 

1305 

1306 mass_out = compute_cumulative_outlet_mass( 

1307 t=50.0, 

1308 v_outlet=500.0, 

1309 waves=tracker.state.waves, 

1310 sorption=sorption, 

1311 flow=flow, 

1312 tedges_days=tedges_days, 

1313 ) 

1314 mass_out >= 0.0 

1315 """ 

1316 tedges_arr = np.asarray(tedges_days, dtype=float) 

1317 flow_arr = np.asarray(flow, dtype=float) 

1318 

1319 if t <= tedges_arr[0]: 

1320 return 0.0 

1321 

1322 # Integrate bin by bin through all flow bins, then continue to t if needed 

1323 total_mass = 0.0 

1324 

1325 # Process bins within tedges range 

1326 n_flow_bins = len(flow_arr) 

1327 

1328 for i in range(n_flow_bins): 

1329 t_bin_start = tedges_arr[i] 

1330 t_bin_end = tedges_arr[i + 1] 

1331 

1332 # Skip bins entirely before t 

1333 if t_bin_end <= tedges_arr[0]: 

1334 continue 

1335 

1336 # Clip to [tedges[0], t] 

1337 t_bin_start = max(t_bin_start, tedges_arr[0]) 

1338 t_bin_end = min(t_bin_end, t) 

1339 

1340 flow_i = flow_arr[i] 

1341 dt_i = t_bin_end - t_bin_start 

1342 

1343 if dt_i <= 0: 

1344 continue 

1345 

1346 # Compute ∫_{t_bin_start}^{t_bin_end} cout(τ) dτ using exact integration 

1347 segments = identify_outlet_segments(t_bin_start, t_bin_end, v_outlet, waves, sorption) 

1348 

1349 integral_c_dt = 0.0 

1350 

1351 for seg in segments: 

1352 seg_t_start = max(seg["t_start"], t_bin_start) 

1353 seg_t_end = min(seg["t_end"], t_bin_end) 

1354 seg_dt = seg_t_end - seg_t_start 

1355 

1356 if seg_dt <= EPSILON_TIME: 

1357 continue 

1358 

1359 if seg["type"] == "constant": 

1360 integral_c_dt += seg["concentration"] * seg_dt 

1361 elif seg["type"] == "rarefaction": 

1362 if isinstance(sorption, NonlinearSorption): 

1363 raref = seg["wave"] 

1364 integral_c_dt += integrate_rarefaction_exact(raref, v_outlet, seg_t_start, seg_t_end, sorption) 

1365 else: 

1366 # ConstantRetardation - use midpoint 

1367 c_mid = concentration_at_point(v_outlet, 0.5 * (seg_t_start + seg_t_end), waves, sorption) 

1368 integral_c_dt += c_mid * seg_dt 

1369 

1370 # Mass for this bin = flow * ∫ cout dt 

1371 total_mass += flow_i * integral_c_dt 

1372 

1373 return float(total_mass) 

1374 

1375 

1376def integrate_rarefaction_total_mass( 

1377 raref: RarefactionWave, 

1378 v_outlet: float, 

1379 t_start: float, 

1380 sorption: SorptionModel, 

1381 flow: float, 

1382) -> float: 

1383 """ 

1384 Compute total mass exiting through a rarefaction. 

1385 

1386 For a rarefaction that starts at the outlet at time t_start, compute the 

1387 total mass that will exit through the rarefaction. Integration endpoint 

1388 depends on the rarefaction tail concentration: 

1389 

1390 - If c_tail ≈ 0: Integrate to infinity (tail extends infinitely) 

1391 - If c_tail > 0: Integrate to finite tail arrival time 

1392 

1393 Parameters 

1394 ---------- 

1395 raref : RarefactionWave 

1396 Rarefaction wave. 

1397 v_outlet : float 

1398 Outlet position [m³]. 

1399 t_start : float 

1400 Time when rarefaction head reaches outlet [days]. 

1401 sorption : SorptionModel 

1402 Sorption model. 

1403 flow : float 

1404 Flow rate [m³/day] (assumed constant). 

1405 

1406 Returns 

1407 ------- 

1408 total_mass : float 

1409 Total mass that exits through rarefaction [mass]. 

1410 

1411 Notes 

1412 ----- 

1413 Uses the exact analytical integral: 

1414 M_total = ∫_{t_start}^{t_end} Q * C(t) dt 

1415 

1416 where C(t) is the concentration at the outlet from the rarefaction wave. 

1417 

1418 For n > 1 (favorable sorption), rarefactions typically decrease to C=0, 

1419 so t_end = ∞ and the integral converges. 

1420 

1421 For n < 1 (unfavorable sorption), rarefactions typically increase from 

1422 low C to high C, so c_tail > 0 and the tail arrives at finite time 

1423 t_end = t_start + (v_outlet - v_start) / tail_velocity. 

1424 

1425 Examples 

1426 -------- 

1427 :: 

1428 

1429 mass = integrate_rarefaction_total_mass( 

1430 raref=raref_wave, 

1431 v_outlet=500.0, 

1432 t_start=40.0, 

1433 sorption=sorption, 

1434 flow=100.0, 

1435 ) 

1436 mass >= 0.0 

1437 """ 

1438 if isinstance(sorption, ConstantRetardation): 

1439 # No rarefactions with constant retardation 

1440 return 0.0 

1441 

1442 # Determine integration endpoint based on c_tail 

1443 # For rarefactions with c_tail ≈ 0, the tail extends to infinity 

1444 # For rarefactions with c_tail > 0, the tail arrives at finite time 

1445 if raref.c_tail < EPSILON_CONCENTRATION: 

1446 # Rarefaction tail goes to C≈0, extends to infinite time 

1447 # This is typical for n > 1 rarefactions (concentration decreases) 

1448 t_end = np.inf 

1449 else: 

1450 # Rarefaction tail has finite concentration, arrives at finite time 

1451 # This is typical for n < 1 rarefactions (concentration increases) 

1452 tail_velocity = raref.tail_velocity() 

1453 if tail_velocity < EPSILON_VELOCITY: 

1454 # Tail velocity is effectively zero, extends to infinity 

1455 t_end = np.inf 

1456 else: 

1457 # Compute finite tail arrival time 

1458 t_end = raref.t_start + (v_outlet - raref.v_start) / tail_velocity 

1459 

1460 # Integrate from t_start to t_end 

1461 integral_c_dt = integrate_rarefaction_exact(raref, v_outlet, t_start, t_end, sorption) 

1462 

1463 return flow * integral_c_dt 

1464 

1465 

1466def compute_total_outlet_mass( 

1467 v_outlet: float, 

1468 waves: Sequence[Wave], 

1469 sorption: SorptionModel, 

1470 flow: npt.ArrayLike, 

1471 tedges_days: npt.ArrayLike, 

1472) -> tuple[float, float]: 

1473 """ 

1474 Compute total integrated outlet mass until all mass has exited. 

1475 

1476 Automatically determines when the last wave passes the outlet and 

1477 integrates the outlet mass flux until that time, regardless of the 

1478 provided tedges extent. 

1479 

1480 Parameters 

1481 ---------- 

1482 v_outlet : float 

1483 Outlet position [m³]. 

1484 waves : list of Wave 

1485 All waves in the simulation. 

1486 sorption : SorptionModel 

1487 Sorption model. 

1488 flow : array-like 

1489 Flow rate time series [m³/day]. 

1490 Piecewise constant within bins defined by tedges_days. 

1491 tedges_days : numpy.ndarray 

1492 Time bin edges [days]. Length len(flow) + 1. 

1493 

1494 Returns 

1495 ------- 

1496 total_mass_out : float 

1497 Total mass that exits through outlet [mass]. 

1498 t_integration_end : float 

1499 Time until which integration was performed [days]. 

1500 This is the time when the last wave passes the outlet. 

1501 

1502 See Also 

1503 -------- 

1504 compute_cumulative_outlet_mass : Cumulative outlet mass up to time t 

1505 find_last_rarefaction_start_time : Find when last rarefaction starts 

1506 integrate_rarefaction_total_mass : Total mass in rarefaction to infinity 

1507 

1508 Notes 

1509 ----- 

1510 This function: 

1511 1. Finds when the last rarefaction *starts* at the outlet (head arrival) 

1512 2. Integrates outlet mass flux until that time 

1513 3. Adds analytical integral of rarefaction mass from start to infinity 

1514 

1515 For rarefactions to C=0, the tail has infinite arrival time but the 

1516 total mass is finite and computed analytically. 

1517 

1518 Examples 

1519 -------- 

1520 :: 

1521 

1522 total_mass, t_end = compute_total_outlet_mass( 

1523 v_outlet=500.0, 

1524 waves=tracker.state.waves, 

1525 sorption=sorption, 

1526 flow=flow, 

1527 tedges_days=tedges_days, 

1528 ) 

1529 total_mass >= 0.0 

1530 t_end >= tedges_days[0] 

1531 """ 

1532 # Find when the last rarefaction starts at the outlet 

1533 t_last_raref_start = find_last_rarefaction_start_time(v_outlet, waves) 

1534 

1535 tedges_arr = np.asarray(tedges_days, dtype=float) 

1536 flow_arr = np.asarray(flow, dtype=float) 

1537 

1538 # Integrate up to when last rarefaction starts 

1539 if t_last_raref_start <= tedges_arr[-1]: 

1540 # All waves start within provided time range 

1541 mass_up_to_raref_start = compute_cumulative_outlet_mass( 

1542 t_last_raref_start, v_outlet, waves, sorption, flow_arr, tedges_arr 

1543 ) 

1544 flow_at_raref_start = flow_arr[-1] # Use last flow value 

1545 else: 

1546 # Need to extend beyond tedges to reach rarefaction start 

1547 # First, compute mass up to tedges[-1] 

1548 mass_within_tedges = compute_cumulative_outlet_mass( 

1549 tedges_arr[-1], v_outlet, waves, sorption, flow_arr, tedges_arr 

1550 ) 

1551 

1552 # Then, integrate from tedges[-1] to t_last_raref_start 

1553 flow_extended = flow_arr[-1] 

1554 t_start_extended = tedges_arr[-1] 

1555 t_end_extended = t_last_raref_start 

1556 

1557 # Get outlet segments for extended period 

1558 segments = identify_outlet_segments(t_start_extended, t_end_extended, v_outlet, waves, sorption) 

1559 

1560 integral_c_dt = 0.0 

1561 

1562 for seg in segments: 

1563 seg_t_start = max(seg["t_start"], t_start_extended) 

1564 seg_t_end = min(seg["t_end"], t_end_extended) 

1565 seg_dt = seg_t_end - seg_t_start 

1566 

1567 if seg_dt <= EPSILON_TIME: 

1568 continue 

1569 

1570 if seg["type"] == "constant": 

1571 integral_c_dt += seg["concentration"] * seg_dt 

1572 elif seg["type"] == "rarefaction": 

1573 if isinstance(sorption, NonlinearSorption): 

1574 raref = seg["wave"] 

1575 integral_c_dt += integrate_rarefaction_exact(raref, v_outlet, seg_t_start, seg_t_end, sorption) 

1576 else: 

1577 # ConstantRetardation - use midpoint 

1578 c_mid = concentration_at_point(v_outlet, 0.5 * (seg_t_start + seg_t_end), waves, sorption) 

1579 integral_c_dt += c_mid * seg_dt 

1580 

1581 mass_up_to_raref_start = mass_within_tedges + flow_extended * integral_c_dt 

1582 flow_at_raref_start = flow_extended 

1583 

1584 # Find rarefactions that are active at the outlet after t_last_raref_start 

1585 # and add their total integrated mass 

1586 total_raref_mass = 0.0 

1587 

1588 for wave in waves: 

1589 if not wave.is_active: 

1590 continue 

1591 

1592 if isinstance(wave, RarefactionWave): 

1593 # Check if this rarefaction reaches the outlet 

1594 head_vel = wave.head_velocity() 

1595 if head_vel > EPSILON_VELOCITY and wave.v_start < v_outlet: 

1596 t_raref_start_at_outlet = wave.t_start + (v_outlet - wave.v_start) / head_vel 

1597 

1598 # If this rarefaction starts at or after t_last_raref_start, include its total mass 

1599 # (with small tolerance for numerical precision) 

1600 if abs(t_raref_start_at_outlet - t_last_raref_start) < EPSILON_TIME_MATCH: 

1601 # This is the last rarefaction - integrate to infinity 

1602 raref_mass = integrate_rarefaction_total_mass( 

1603 raref=wave, 

1604 v_outlet=v_outlet, 

1605 t_start=t_raref_start_at_outlet, 

1606 sorption=sorption, 

1607 flow=flow_at_raref_start, 

1608 ) 

1609 total_raref_mass += raref_mass 

1610 

1611 # For rarefactions with finite tails (c_tail > 0, typical for n < 1), 

1612 # all mass is already accounted for in the rarefaction integration 

1613 # from head to tail. No additional mass needs to be integrated after 

1614 # the tail - if there were more waves, they would be in the wave list. 

1615 total_mass = mass_up_to_raref_start + total_raref_mass 

1616 

1617 return float(total_mass), t_last_raref_start