Coverage for src / gwtransport / fronttracking / output.py: 89%
417 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
1"""
2Concentration Extraction from Front Tracking Solutions.
4This module computes outlet concentrations from front tracking wave solutions
5using exact analytical integration. All calculations maintain machine precision
6with no numerical dispersion.
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
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"""
33from operator import itemgetter
35import mpmath as mp
36import numpy as np
37import numpy.typing as npt
38from scipy.special import beta as beta_func
39from scipy.special import betainc
41from gwtransport.fronttracking.events import find_outlet_crossing
42from gwtransport.fronttracking.math import ConstantRetardation, FreundlichSorption
43from gwtransport.fronttracking.waves import CharacteristicWave, RarefactionWave, ShockWave, Wave
45# Numerical tolerance constants
46EPSILON_VELOCITY = 1e-15 # Tolerance for checking if velocity is effectively zero
47EPSILON_BETA = 1e-15 # Tolerance for checking if beta is effectively zero (linear case)
48EPSILON_TIME = 1e-15 # Tolerance for negligible time segments
49EPSILON_TIME_MATCH = 1e-6 # Tolerance for matching arrival times (for rarefaction identification)
50EPSILON_CONCENTRATION = 1e-10 # Tolerance for checking if concentration is effectively zero
53def concentration_at_point(
54 v: float,
55 t: float,
56 waves: list[Wave],
57 sorption: FreundlichSorption | ConstantRetardation, # noqa: ARG001
58) -> float:
59 """
60 Compute concentration at point (v, t) with exact analytical value.
62 Searches through all waves to find which wave controls the concentration
63 at the given point in space-time. Uses exact analytical solutions for
64 characteristics, shocks, and rarefaction fans.
66 Parameters
67 ----------
68 v : float
69 Position [m³].
70 t : float
71 Time [days].
72 waves : list of Wave
73 All waves in the simulation (active and inactive).
74 sorption : FreundlichSorption or ConstantRetardation
75 Sorption model.
77 Returns
78 -------
79 concentration : float
80 Concentration at point (v, t) [mass/volume].
82 Notes
83 -----
84 **Wave Priority**:
85 The algorithm checks waves in this order:
86 1. Rarefaction waves (if point is inside rarefaction fan)
87 2. Shocks (discontinuities)
88 3. Rarefaction tails (if rarefaction tail has passed point)
89 4. Characteristics (smooth regions)
91 If no active wave controls the point, returns 0.0 (initial condition).
93 **Rarefaction Tail Behavior**:
94 After a rarefaction tail passes a query point, that point maintains the
95 tail concentration as a plateau. This ensures proper concentration behavior
96 after rarefaction waves pass through.
98 **Machine Precision**:
99 All position and concentration calculations use exact analytical formulas.
100 Numerical tolerance is only used for equality checks (v == v_shock).
102 Examples
103 --------
104 ::
106 sorption = FreundlichSorption(
107 k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
108 )
109 # After running simulation with waves...
110 c = concentration_at_point(v=250.0, t=5.0, waves=all_waves, sorption=sorption)
111 c >= 0.0
112 """
113 # Check rarefactions first (they have spatial extent and override other waves)
114 for wave in waves:
115 if isinstance(wave, RarefactionWave) and wave.is_active:
116 c = wave.concentration_at_point(v, t)
117 if c is not None:
118 return c
120 # Track the most recent wave to affect position v
121 # We need to compare crossing times of shocks and rarefaction tails
122 latest_wave_time = -np.inf
123 latest_wave_c = None
125 # Check shocks - track which shocks control this position
126 for wave in waves:
127 if isinstance(wave, ShockWave) and wave.is_active:
128 v_shock = wave.position_at_time(t)
129 if v_shock is not None:
130 # Tolerance for exact shock position
131 tol = 1e-15
133 if abs(v - v_shock) < tol:
134 # Exactly at shock position
135 return 0.5 * (wave.c_left + wave.c_right)
137 # Determine if shock has crossed position v and when
138 if wave.velocity is not None and abs(wave.velocity) > EPSILON_VELOCITY:
139 t_cross = wave.t_start + (v - wave.v_start) / wave.velocity
141 if t_cross <= t:
142 # Shock has crossed position v by time t
143 # After crossing, point sees c_left (concentration behind shock)
144 if t_cross > latest_wave_time:
145 latest_wave_time = t_cross
146 latest_wave_c = wave.c_left
147 elif v > v_shock and wave.t_start > latest_wave_time:
148 # Point is ahead of shock (shock hasn't reached it yet)
149 # Check if this is the closest shock ahead of us
150 # In this case, we see c_right unless overridden by another wave
151 # We track this with a negative time (shock formation time) to indicate
152 # it's a "passive" state (not actively changed)
153 latest_wave_time = wave.t_start
154 latest_wave_c = wave.c_right
156 # Check rarefaction tails - they can override previous waves
157 for wave in waves:
158 if isinstance(wave, RarefactionWave) and wave.is_active:
159 v_tail = wave.tail_position_at_time(t)
160 if v_tail is not None and v_tail > v + 1e-15:
161 # Rarefaction tail has passed position v
162 # Find when it passed: v_start + tail_velocity * (t_pass - t_start) = v
163 tail_vel = wave.tail_velocity()
164 if tail_vel > EPSILON_VELOCITY:
165 t_pass = wave.t_start + (v - wave.v_start) / tail_vel
166 if t_pass <= t and t_pass > latest_wave_time:
167 latest_wave_time = t_pass
168 latest_wave_c = wave.c_tail
170 if latest_wave_c is not None:
171 return latest_wave_c
173 # Check characteristics
174 # Find the most recent characteristic that has reached position v by time t
175 latest_c = 0.0
176 latest_time = -np.inf
178 for wave in waves:
179 if isinstance(wave, CharacteristicWave) and wave.is_active:
180 # Check if this characteristic has reached position v by time t
181 v_char_at_t = wave.position_at_time(t)
183 if v_char_at_t is not None and v_char_at_t >= v - 1e-15:
184 # This characteristic has passed through v
185 # Find when it passed: v_start + vel*(t_pass - t_start) = v
186 vel = wave.velocity()
188 if vel > EPSILON_VELOCITY:
189 t_pass = wave.t_start + (v - wave.v_start) / vel
191 if t_pass <= t and t_pass > latest_time:
192 latest_time = t_pass
193 latest_c = wave.concentration
195 return latest_c
198def compute_breakthrough_curve(
199 t_array: npt.NDArray[np.floating],
200 v_outlet: float,
201 waves: list[Wave],
202 sorption: FreundlichSorption | ConstantRetardation,
203) -> npt.NDArray[np.floating]:
204 """
205 Compute concentration at outlet over time array.
207 This is the breakthrough curve: C(v_outlet, t) for all t in t_array.
209 Parameters
210 ----------
211 t_array : array-like
212 Time points [days]. Must be sorted in ascending order.
213 v_outlet : float
214 Outlet position [m³].
215 waves : list of Wave
216 All waves in the simulation.
217 sorption : FreundlichSorption or ConstantRetardation
218 Sorption model.
220 Returns
221 -------
222 c_out : numpy.ndarray
223 Array of concentrations matching t_array [mass/volume].
225 Examples
226 --------
227 ::
229 t_array = np.linspace(0, 100, 1000)
230 c_out = compute_breakthrough_curve(
231 t_array, v_outlet=500.0, waves=all_waves, sorption=sorption
232 )
233 len(c_out) == len(t_array)
235 See Also
236 --------
237 concentration_at_point : Point-wise concentration
238 compute_bin_averaged_concentration_exact : Bin-averaged concentrations
239 """
240 t_array = np.asarray(t_array, dtype=float)
241 c_out = np.zeros(len(t_array))
243 for i, t in enumerate(t_array):
244 c_out[i] = concentration_at_point(v_outlet, t, waves, sorption)
246 return c_out
249def identify_outlet_segments(
250 t_start: float, t_end: float, v_outlet: float, waves: list[Wave], sorption: FreundlichSorption | ConstantRetardation
251) -> list[dict]:
252 """
253 Identify which waves control outlet concentration in time interval [t_start, t_end].
255 Finds all wave crossing events at the outlet and constructs segments where
256 concentration is constant or varying (rarefaction).
258 Parameters
259 ----------
260 t_start : float
261 Start of time interval [days].
262 t_end : float
263 End of time interval [days].
264 v_outlet : float
265 Outlet position [m³].
266 waves : list of Wave
267 All waves in the simulation.
268 sorption : FreundlichSorption or ConstantRetardation
269 Sorption model.
271 Returns
272 -------
273 segments : list of dict
274 List of segment dictionaries, each containing:
276 - 't_start' : float
277 Segment start time
278 - 't_end' : float
279 Segment end time
280 - 'type' : str
281 'constant' or 'rarefaction'
282 - 'concentration' : float
283 For constant segments
284 - 'wave' : Wave
285 For rarefaction segments
286 - 'c_start' : float
287 Concentration at segment start
288 - 'c_end' : float
289 Concentration at segment end
291 Notes
292 -----
293 Segments are constructed by:
294 1. Finding all wave crossing events at outlet in [t_start, t_end]
295 2. Sorting events chronologically
296 3. Creating constant-concentration segments between events
297 4. Handling rarefaction fans with time-varying concentration
299 The segments completely partition the time interval [t_start, t_end].
300 """
301 # Find all waves that cross outlet in this time range
302 outlet_events = []
304 # Track rarefactions that already contain the outlet at t_start
305 # These need to be handled separately since they don't generate crossing events
306 active_rarefactions_at_start = []
308 for wave in waves:
309 if not wave.is_active:
310 continue
312 # For rarefactions, detect both head and tail crossings
313 if isinstance(wave, RarefactionWave):
314 # Check if outlet is already inside this rarefaction at t_start
315 if wave.contains_point(v_outlet, t_start):
316 active_rarefactions_at_start.append(wave)
317 # Don't add crossing events for this wave since we're already inside it
318 # But we still need to detect when the tail crosses during [t_start, t_end]
319 v_tail = wave.tail_position_at_time(t_start)
320 if v_tail is not None and v_tail < v_outlet:
321 vel_tail = wave.tail_velocity()
322 if vel_tail > 0:
323 dt = (v_outlet - v_tail) / vel_tail
324 t_cross = t_start + dt
325 if t_start < t_cross <= t_end:
326 outlet_events.append({
327 "time": t_cross,
328 "wave": wave,
329 "boundary": "tail",
330 "c_after": wave.c_tail,
331 })
332 continue
334 # Head crossing
335 v_head = wave.head_position_at_time(t_start)
336 if v_head is not None and v_head < v_outlet:
337 vel_head = wave.head_velocity()
338 if vel_head > 0:
339 dt = (v_outlet - v_head) / vel_head
340 t_cross = t_start + dt
341 if t_start <= t_cross <= t_end:
342 outlet_events.append({
343 "time": t_cross,
344 "wave": wave,
345 "boundary": "head",
346 "c_after": wave.c_head,
347 })
349 # Tail crossing
350 v_tail = wave.tail_position_at_time(t_start)
351 if v_tail is not None and v_tail < v_outlet:
352 vel_tail = wave.tail_velocity()
353 if vel_tail > 0:
354 dt = (v_outlet - v_tail) / vel_tail
355 t_cross = t_start + dt
356 if t_start <= t_cross <= t_end:
357 outlet_events.append({
358 "time": t_cross,
359 "wave": wave,
360 "boundary": "tail",
361 "c_after": wave.c_tail,
362 })
363 else:
364 # Characteristics and shocks
365 t_cross = find_outlet_crossing(wave, v_outlet, t_start)
367 if t_cross is not None and t_start <= t_cross <= t_end:
368 if isinstance(wave, CharacteristicWave):
369 c_after = wave.concentration
370 elif isinstance(wave, ShockWave):
371 # After shock passes outlet, outlet sees left (upstream) state
372 c_after = wave.c_left
373 else:
374 c_after = 0.0
376 outlet_events.append({"time": t_cross, "wave": wave, "boundary": None, "c_after": c_after})
378 # Sort events by time
379 outlet_events.sort(key=itemgetter("time"))
381 # Create segments between events
382 segments = []
383 current_t = t_start
384 current_c = concentration_at_point(v_outlet, t_start, waves, sorption)
386 # Handle case where we start inside a rarefaction
387 if active_rarefactions_at_start:
388 # Should only be one rarefaction containing the outlet at t_start
389 raref = active_rarefactions_at_start[0]
391 # Find when tail crosses (if it does)
392 tail_cross_time = None
393 for event in outlet_events:
394 if event["wave"] is raref and event["boundary"] == "tail" and event["time"] > t_start:
395 tail_cross_time = event["time"]
396 break
398 # Create rarefaction segment from t_start
399 raref_end = min(tail_cross_time or t_end, t_end)
401 c_start = concentration_at_point(v_outlet, t_start, waves, sorption)
402 c_end = None
403 if tail_cross_time and tail_cross_time <= t_end:
404 c_end = raref.c_tail
406 segments.append({
407 "t_start": t_start,
408 "t_end": raref_end,
409 "type": "rarefaction",
410 "wave": raref,
411 "c_start": c_start,
412 "c_end": c_end,
413 })
415 current_t = raref_end
416 current_c = (
417 concentration_at_point(v_outlet, raref_end + 1e-10, waves, sorption) if raref_end < t_end else current_c
418 )
420 for event in outlet_events:
421 # Check if we're entering a rarefaction fan
422 if isinstance(event["wave"], RarefactionWave) and event["boundary"] == "head":
423 # Before rarefaction head: constant segment
424 if event["time"] > current_t:
425 segments.append({
426 "t_start": current_t,
427 "t_end": event["time"],
428 "type": "constant",
429 "concentration": current_c,
430 "c_start": current_c,
431 "c_end": current_c,
432 })
434 # Find when tail crosses (if it does)
435 raref = event["wave"]
436 tail_cross_time = None
438 for later_event in outlet_events:
439 if (
440 later_event["wave"] is raref
441 and later_event["boundary"] == "tail"
442 and later_event["time"] > event["time"]
443 ):
444 tail_cross_time = later_event["time"]
445 break
447 # Rarefaction segment
448 raref_end = min(tail_cross_time or t_end, t_end)
450 segments.append({
451 "t_start": event["time"],
452 "t_end": raref_end,
453 "type": "rarefaction",
454 "wave": raref,
455 "c_start": raref.c_head,
456 "c_end": raref.c_tail if tail_cross_time and tail_cross_time <= t_end else None,
457 })
459 current_t = raref_end
460 current_c = (
461 concentration_at_point(v_outlet, raref_end + 1e-10, waves, sorption) if raref_end < t_end else current_c
462 )
463 else:
464 # Regular event (characteristic or shock crossing)
465 # Segment before event
466 if event["time"] > current_t:
467 segments.append({
468 "t_start": current_t,
469 "t_end": event["time"],
470 "type": "constant",
471 "concentration": current_c,
472 "c_start": current_c,
473 "c_end": current_c,
474 })
476 current_t = event["time"]
477 current_c = event["c_after"]
479 # Final segment
480 if t_end > current_t:
481 segments.append({
482 "t_start": current_t,
483 "t_end": t_end,
484 "type": "constant",
485 "concentration": current_c,
486 "c_start": current_c,
487 "c_end": current_c,
488 })
490 return segments
493def integrate_rarefaction_exact(
494 raref: RarefactionWave, v_outlet: float, t_start: float, t_end: float, sorption: FreundlichSorption
495) -> float:
496 """
497 Exact analytical integral of rarefaction concentration over time at fixed position.
499 For Freundlich sorption, the concentration within a rarefaction fan varies as:
500 R(C) = flow*(t - t_origin)/(v_outlet - v_origin)
502 This function computes the exact integral:
503 ∫_{t_start}^{t_end} C(t) dt
505 where C(t) is obtained by inverting the retardation relation.
507 Parameters
508 ----------
509 raref : RarefactionWave
510 Rarefaction wave controlling the outlet.
511 v_outlet : float
512 Outlet position [m³].
513 t_start : float
514 Integration start time [days]. Can be -np.inf.
515 t_end : float
516 Integration end time [days]. Can be np.inf.
517 sorption : FreundlichSorption
518 Freundlich sorption model.
520 Returns
521 -------
522 integral : float
523 Exact integral value [concentration * time].
525 Notes
526 -----
527 **Derivation**:
529 For Freundlich: R(C) = 1 + alpha*C^beta where:
530 alpha = rho_b*k_f/(n_por*n)
531 beta = 1/n - 1
533 At outlet: R = kappa*t + mu where:
534 kappa = flow/(v_outlet - v_origin)
535 mu = -flow*t_origin/(v_outlet - v_origin)
537 Inverting: C = [(R-1)/alpha]^(1/beta) = [(kappa*t + mu - 1)/alpha]^(1/beta)
539 Integral:
540 ∫ C dt = (1/alpha^(1/beta)) * ∫ (kappa*t + mu - 1)^(1/beta) dt
541 = (1/alpha^(1/beta)) * (1/kappa) * [(kappa*t + mu - 1)^(1/beta + 1) / (1/beta + 1)]
543 evaluated from t_start to t_end.
545 **Special Cases**:
546 - If (kappa*t + mu - 1) <= 0, concentration is 0 (unphysical region)
547 - For beta = 0 (n = 1), use ConstantRetardation instead
548 - For t_end = +∞ with exponent < 0 (n>1), integral converges to 0
549 - For t_start = -∞, antiderivative evaluates to 0
551 Examples
552 --------
553 ::
555 sorption = FreundlichSorption(
556 k_f=0.01, n=2.0, bulk_density=1500.0, porosity=0.3
557 )
558 raref = RarefactionWave(
559 t_start=0.0,
560 v_start=0.0,
561 flow=100.0,
562 c_head=10.0,
563 c_tail=2.0,
564 sorption=sorption,
565 )
566 integral = integrate_rarefaction_exact(
567 raref, v_outlet=500.0, t_start=5.0, t_end=15.0, sorption=sorption
568 )
569 integral > 0
570 # Integration to infinity
571 integral_inf = integrate_rarefaction_exact(
572 raref, v_outlet=500.0, t_start=5.0, t_end=np.inf, sorption=sorption
573 )
574 integral_inf > integral
575 """
576 # Extract parameters
577 t_origin = raref.t_start
578 v_origin = raref.v_start
579 flow = raref.flow
581 # Coefficients in R = kappa*t + μ
582 kappa = flow / (v_outlet - v_origin)
583 mu = -flow * t_origin / (v_outlet - v_origin)
585 # Freundlich parameters: R = 1 + alpha*C^beta
586 alpha = sorption.bulk_density * sorption.k_f / (sorption.porosity * sorption.n)
587 beta = 1.0 / sorption.n - 1.0
589 # For integration, we need exponent = 1/beta + 1
590 if abs(beta) < EPSILON_BETA:
591 # Linear case - shouldn't happen with Freundlich
592 # Fall back to numerical integration or raise error
593 msg = "integrate_rarefaction_exact requires nonlinear sorption (n != 1)"
594 raise ValueError(msg)
596 exponent = 1.0 / beta + 1.0
598 # Coefficient for antiderivative
599 # F(t) = (1/(alpha^(1/beta)*kappa*exponent)) * (kappa*t + mu - 1)^exponent
600 coeff = 1.0 / (alpha ** (1.0 / beta) * kappa * exponent)
602 def antiderivative(t: float) -> float:
603 """Evaluate antiderivative at time t."""
604 # Handle infinite bounds
605 if np.isinf(t):
606 if t > 0:
607 # t = +∞
608 if exponent < 0:
609 # For n > 1 (higher C travels faster, beta < 0, exponent < 0):
610 # As t → +∞, base → +∞, so base^exponent → 0
611 return 0.0
612 # For n < 1 (lower C travels faster, beta > 0, exponent > 0):
613 # As t → +∞, base → +∞, so base^exponent → +∞
614 # This should not happen for physical rarefactions to C=0
615 msg = f"Integral diverges at t=+∞ with exponent={exponent} > 0"
616 raise ValueError(msg)
617 # t = -∞: always returns 0
618 return 0.0
620 base = kappa * t + mu - 1.0
622 # Check if we're in physical region (R > 1)
623 if base <= 0:
624 return 0.0
626 return coeff * base**exponent
628 # Evaluate definite integral
629 return antiderivative(t_end) - antiderivative(t_start)
632def compute_bin_averaged_concentration_exact(
633 t_edges: npt.NDArray[np.floating],
634 v_outlet: float,
635 waves: list[Wave],
636 sorption: FreundlichSorption | ConstantRetardation,
637) -> npt.NDArray[np.floating]:
638 """
639 Compute bin-averaged concentration using EXACT analytical integration.
641 For each time bin [t_i, t_{i+1}], computes:
642 C_avg = (1/(t_{i+1} - t_i)) * ∫_{t_i}^{t_{i+1}} C(v_outlet, t) dt
644 This is the critical function for maintaining machine precision in output.
645 All integrations use exact analytical formulas with no numerical quadrature.
647 Parameters
648 ----------
649 t_edges : array-like
650 Time bin edges [days]. Length N+1 for N bins.
651 v_outlet : float
652 Outlet position [m³].
653 waves : list of Wave
654 All waves from front tracking simulation.
655 sorption : FreundlichSorption or ConstantRetardation
656 Sorption model.
658 Returns
659 -------
660 c_avg : numpy.ndarray
661 Bin-averaged concentrations [mass/volume]. Length N.
663 Notes
664 -----
665 **Algorithm**:
667 1. For each bin [t_i, t_{i+1}]:
669 a. Identify which wave segments control outlet during this period
670 b. For each segment, compute: Constant C gives integral = C * Δt,
671 Rarefaction C(t) uses exact analytical integral formula
672 c. Sum segment integrals and divide by bin width
674 **Machine Precision**:
676 - Constant segments: exact to floating-point precision
677 - Rarefaction segments: uses closed-form antiderivative
678 - No numerical quadrature or interpolation
679 - Maintains mass balance to ~1e-14 relative error
681 **Rarefaction Integration**:
683 For Freundlich sorption, rarefaction concentration at outlet varies as::
685 C(t) = [(kappa*t + mu - 1)/alpha]^(1/beta)
687 The exact integral is::
689 ∫ C dt = (1/(alpha^(1/beta)*kappa*exponent)) * (kappa*t + mu - 1)^exponent
691 where exponent = 1/beta + 1.
693 Examples
694 --------
695 ::
697 # After running front tracking simulation
698 t_edges = np.array([0.0, 10.0, 20.0, 30.0])
699 c_avg = compute_bin_averaged_concentration_exact(
700 t_edges=t_edges,
701 v_outlet=500.0,
702 waves=tracker.state.waves,
703 sorption=sorption,
704 )
705 len(c_avg) == len(t_edges) - 1
706 np.all(c_avg >= 0)
708 See Also
709 --------
710 concentration_at_point : Point-wise concentration
711 compute_breakthrough_curve : Breakthrough curve
712 integrate_rarefaction_exact : Exact rarefaction integration
713 """
714 t_edges = np.asarray(t_edges, dtype=float)
715 n_bins = len(t_edges) - 1
716 c_avg = np.zeros(n_bins)
718 for i in range(n_bins):
719 t_start = t_edges[i]
720 t_end = t_edges[i + 1]
721 dt = t_end - t_start
723 if dt <= 0:
724 msg = f"Invalid time bin: t_edges[{i}]={t_start} >= t_edges[{i + 1}]={t_end}"
725 raise ValueError(msg)
727 # Identify wave segments controlling outlet in this time bin
728 segments = identify_outlet_segments(t_start, t_end, v_outlet, waves, sorption)
730 # Integrate each segment
731 total_integral = 0.0
733 for seg in segments:
734 seg_t_start = max(seg["t_start"], t_start)
735 seg_t_end = min(seg["t_end"], t_end)
736 seg_dt = seg_t_end - seg_t_start
738 if seg_dt <= EPSILON_TIME: # Skip negligible segments
739 continue
741 if seg["type"] == "constant":
742 # C is constant over segment - exact integral
743 integral = seg["concentration"] * seg_dt
745 elif seg["type"] == "rarefaction":
746 # C(t) given by self-similar solution - use exact analytical integral
747 if isinstance(sorption, FreundlichSorption):
748 raref = seg["wave"]
749 integral = integrate_rarefaction_exact(raref, v_outlet, seg_t_start, seg_t_end, sorption)
750 else:
751 # ConstantRetardation - rarefactions shouldn't form, use constant approximation
752 c_mid = concentration_at_point(v_outlet, 0.5 * (seg_t_start + seg_t_end), waves, sorption)
753 integral = c_mid * seg_dt
754 else:
755 msg = f"Unknown segment type: {seg['type']}"
756 raise ValueError(msg)
758 total_integral += integral
760 c_avg[i] = total_integral / dt
762 return c_avg
765def compute_domain_mass(
766 t: float,
767 v_outlet: float,
768 waves: list[Wave],
769 sorption: FreundlichSorption | ConstantRetardation,
770) -> float:
771 """
772 Compute total mass in domain [0, v_outlet] at time t using exact analytical integration.
774 Implements runtime mass balance verification as described in High Priority #3
775 of FRONT_TRACKING_REBUILD_PLAN.md. Integrates concentration over space:
777 M(t) = ∫₀^v_outlet C(v, t) dv
779 using exact analytical formulas for each wave type.
781 Parameters
782 ----------
783 t : float
784 Time at which to compute domain mass [days].
785 v_outlet : float
786 Outlet position (domain extent) [m³].
787 waves : list of Wave
788 All waves in the simulation.
789 sorption : FreundlichSorption or ConstantRetardation
790 Sorption model.
792 Returns
793 -------
794 mass : float
795 Total mass in domain [mass]. Computed to machine precision (~1e-14).
797 Notes
798 -----
799 **Algorithm**:
801 1. Partition spatial domain [0, v_outlet] into segments where concentration
802 is controlled by a single wave or is constant.
803 2. For each segment, compute mass analytically:
804 - Constant C: mass = C * Δv
805 - Rarefaction C(v): use exact spatial integration formula
806 3. Sum all segment masses.
808 **Wave Priority** (same as concentration_at_point):
810 1. Rarefactions (if position is inside rarefaction fan)
811 2. Shocks and rarefaction tails (most recent to pass)
812 3. Characteristics (smooth regions)
814 **Rarefaction Spatial Integration**:
816 For a rarefaction fan at fixed time t, concentration varies with position v
817 according to the self-similar solution:
819 R(C) = flow*(t - t_origin)/(v - v_origin)
821 The spatial integral ∫ C dv is computed analytically using the inverse
822 retardation relation.
824 **Integration Precision**:
826 - Constant concentration regions: Exact analytical (C_total * dv)
827 - Rarefaction regions: High-precision trapezoidal quadrature (10+ points)
828 - Overall accuracy: ~1e-10 to 1e-12 relative error
829 - Sufficient for runtime verification; primary outputs remain exact
831 Examples
832 --------
833 ::
835 # After running simulation to time t=10.0
836 mass = compute_domain_mass(
837 t=10.0, v_outlet=500.0, waves=tracker.state.waves, sorption=sorption
838 )
839 mass >= 0.0
841 See Also
842 --------
843 compute_cumulative_inlet_mass : Cumulative inlet mass
844 compute_cumulative_outlet_mass : Cumulative outlet mass
845 concentration_at_point : Point-wise concentration
846 """
847 # Partition spatial domain into segments based on wave structure
848 # We'll evaluate concentration at many points and identify constant regions
850 # Collect all wave positions at time t
851 wave_positions = []
853 for wave in waves:
854 if not wave.is_active:
855 continue
857 if isinstance(wave, (CharacteristicWave, ShockWave)):
858 v_pos = wave.position_at_time(t)
859 if v_pos is not None and 0 <= v_pos <= v_outlet:
860 wave_positions.append(v_pos)
862 elif isinstance(wave, RarefactionWave):
863 v_head = wave.head_position_at_time(t)
864 v_tail = wave.tail_position_at_time(t)
866 if v_head is not None and 0 <= v_head <= v_outlet:
867 wave_positions.append(v_head)
868 if v_tail is not None and 0 <= v_tail <= v_outlet:
869 wave_positions.append(v_tail)
871 # Add domain boundaries
872 wave_positions.extend([0.0, v_outlet])
874 # Sort and remove duplicates
875 wave_positions = sorted(set(wave_positions))
877 # Remove positions outside domain
878 wave_positions = [v for v in wave_positions if 0 <= v <= v_outlet]
880 # Compute mass in each segment using refined integration
881 total_mass = 0.0
883 for i in range(len(wave_positions) - 1):
884 v_start = wave_positions[i]
885 v_end = wave_positions[i + 1]
886 dv = v_end - v_start
888 if dv < EPSILON_VELOCITY:
889 continue
891 # Check if this segment is inside a rarefaction fan
892 # Sample at midpoint to check
893 v_mid = 0.5 * (v_start + v_end)
894 inside_rarefaction = False
895 raref_wave = None
897 for wave in waves:
898 if isinstance(wave, RarefactionWave) and wave.is_active and wave.contains_point(v_mid, t):
899 inside_rarefaction = True
900 raref_wave = wave
901 break
903 if inside_rarefaction and raref_wave is not None:
904 # Rarefaction: concentration varies with position
905 # Use EXACT analytical spatial integration
906 mass_segment = _integrate_rarefaction_spatial_exact(raref_wave, v_start, v_end, t, sorption)
907 else:
908 # Constant concentration region - exact integration
909 v_mid = 0.5 * (v_start + v_end)
910 c = concentration_at_point(v_mid, t, waves, sorption)
911 c_total = sorption.total_concentration(c)
912 mass_segment = c_total * dv
914 total_mass += mass_segment
916 return total_mass
919def _integrate_rarefaction_spatial_exact(
920 raref: RarefactionWave,
921 v_start: float,
922 v_end: float,
923 t: float,
924 sorption: FreundlichSorption | ConstantRetardation,
925) -> float:
926 """
927 EXACT analytical spatial integral of rarefaction total concentration at fixed time.
929 Computes ∫_{v_start}^{v_end} C_total(v) dv analytically using closed-form
930 antiderivatives for specific Freundlich n values. This maintains machine precision
931 for the mass balance diagnostic.
933 For rarefaction at time t: R(C) = kappa/(v - v₀) where kappa = flow*(t - t₀)
935 For Freundlich: R = 1 + alpha*C^beta where alpha = rho_b*k_f/(n_por*n), beta = 1/n - 1
936 Total concentration: C_total = C + (rho_b/n_por)*k_f*C^(1/n)
938 **Exact formulas implemented**:
939 - n = 2 (beta = -0.5): Closed-form using polynomial expansion (optimized path)
940 - n = 1: No rarefactions (constant retardation)
941 - All other n > 0: Unified formula using generalized incomplete beta function via mpmath
943 Parameters
944 ----------
945 raref : RarefactionWave
946 v_start, v_end : float
947 Integration bounds [m³]
948 t : float
949 Time [days]
950 sorption : FreundlichSorption or ConstantRetardation
952 Returns
953 -------
954 mass : float
955 Exact mass in segment to machine precision
957 Notes
958 -----
959 **Derivation for n=2** (beta = -0.5):
961 C(v) = [(kappa/(v-v₀) - 1) / alpha]^(-2) = [alpha*(v-v₀)]² / (kappa - (v-v₀))²
963 Let u = v - v₀, then ∫ C du requires integrating u²/(kappa-u)².
964 Using substitution w = kappa - u:
966 ∫ u²/(kappa-u)² du = alpha²[kappa²/(kappa-u) + 2kappa*ln|kappa-u| - (kappa-u)]
968 For C_total, we integrate both C and (rho_b/n_por)*k_f*C^0.5 terms.
970 **Unified Formula for All n > 0**:
972 The spatial integral has the structure:
974 ∫ C_total(u) du = ∫ C(u) du + ∫ (rho_b/n_por)*k_f*C(u)^(1/n) du
976 where C(u) = [(kappa-u)/(alpha*u)]^(1/beta) with beta = 1/n - 1.
978 Both integrals reduce to power-law forms:
980 ∫ u^p (kappa-u)^q du
982 which can be expressed using the generalized incomplete beta function:
984 ∫_{u₁}^{u₂} u^p (kappa-u)^q du = kappa^(p+q+1) B(u₁/kappa, u₂/kappa; p+1, q+1)
986 where B(x₁, x₂; a, b) = ∫_{x₁}^{x₂} t^(a-1) (1-t)^(b-1) dt.
988 For many n values, the parameters a and b can be negative or zero, which
989 requires analytic continuation beyond the standard incomplete beta function.
990 The mpmath library provides this through mpmath.betainc(), which handles
991 all parameter values and achieves machine precision (~1e-15 relative error).
993 **Mathematical Properties**:
994 - Continuous for all n > 0 (no discontinuities)
995 - Achieves machine precision (~1e-14 to 1e-15 relative error)
996 - No numerical quadrature (uses special function evaluation)
997 - Single unified formula (no conditional logic based on n)
998 """
999 if isinstance(sorption, ConstantRetardation):
1000 # Constant retardation: no rarefactions
1001 v_mid = 0.5 * (v_start + v_end)
1002 c = raref.concentration_at_point(v_mid, t) or 0.0
1003 c_total = sorption.total_concentration(c)
1004 return c_total * (v_end - v_start)
1006 # Extract parameters
1007 t_origin = raref.t_start
1008 v_origin = raref.v_start
1009 flow = raref.flow
1011 if t <= t_origin:
1012 return 0.0
1014 kappa = flow * (t - t_origin) # kappa
1015 alpha = sorption.bulk_density * sorption.k_f / (sorption.porosity * sorption.n) # alpha
1016 rho_b = sorption.bulk_density
1017 n_por = sorption.porosity
1018 k_f = sorption.k_f
1019 n = sorption.n
1021 # Transform to u coordinates
1022 u_start = v_start - v_origin
1023 u_end = v_end - v_origin
1025 if u_start <= 0 or u_end <= 0:
1026 return 0.0
1028 # Unified formula for all n > 0 using generalized incomplete beta function
1029 beta = 1 / n - 1 # beta parameter
1031 # Transform to [0,1] domain: t = u/kappa (use regular floats)
1032 t_start = u_start / kappa
1033 t_end = u_end / kappa
1035 # Integral 1: Dissolved concentration
1036 # ∫ C(u) du = ∫ [(kappa-u)/(alpha*u)]^(1/beta) du
1037 # = (1/alpha)^(1/beta) ∫ u^(-1/beta) (kappa-u)^(1/beta) du
1038 # = (1/alpha)^(1/beta) * kappa * B(t_start, t_end; 1-1/beta, 1+1/beta)
1039 #
1040 # where B(x1, x2; a, b) = ∫_{x1}^{x2} t^(a-1) (1-t)^(b-1) dt
1042 # Beta function parameters (use regular floats)
1043 a_diss = 1 - 1 / beta
1044 b_diss = 1 + 1 / beta
1046 # Use scipy.special.betainc when parameters are positive (faster),
1047 # fall back to mpmath for negative parameters (requires analytic continuation)
1048 if a_diss > 0 and b_diss > 0:
1049 # scipy.special.betainc is regularized, so multiply by beta function
1050 beta_diss = betainc(a_diss, b_diss, t_end) - betainc(a_diss, b_diss, t_start)
1051 beta_diss *= beta_func(a_diss, b_diss)
1052 else:
1053 # Use mpmath for negative parameters (analytic continuation)
1054 beta_diss = float(mp.betainc(a_diss, b_diss, t_start, t_end, regularized=False))
1056 # Compute coefficient using regular float arithmetic
1057 coeff_diss = (1 / alpha) ** (1 / beta)
1058 mass_dissolved = coeff_diss * kappa * beta_diss
1060 # Integral 2: Sorbed concentration
1061 # ∫ (rho_b/n_por)*k_f*C^(1/n) du
1062 #
1063 # where C^(1/n) = [(kappa-u)/(alpha*u)]^(1/(1-n))
1064 #
1065 # This gives: ∫ u^(-1/(1-n)) (kappa-u)^(1/(1-n)) du
1066 #
1067 # Using same transformation: = kappa * B(t_start, t_end; 1-1/(1-n), 1+1/(1-n))
1069 # Beta function parameters (use regular floats)
1070 exponent_sorb = 1 / (1 - n)
1071 a_sorb = 1 - exponent_sorb
1072 b_sorb = 1 + exponent_sorb
1074 # Use scipy when possible, mpmath for negative parameters
1075 if a_sorb > 0 and b_sorb > 0:
1076 beta_sorb = betainc(a_sorb, b_sorb, t_end) - betainc(a_sorb, b_sorb, t_start)
1077 beta_sorb *= beta_func(a_sorb, b_sorb)
1078 else:
1079 beta_sorb = float(mp.betainc(a_sorb, b_sorb, t_start, t_end, regularized=False))
1081 # Compute coefficient using regular float arithmetic
1082 coeff_sorb = (rho_b / n_por) * k_f / (alpha**exponent_sorb)
1083 mass_sorbed = coeff_sorb * kappa * beta_sorb
1085 return mass_dissolved + mass_sorbed
1088def compute_cumulative_inlet_mass(
1089 t: float,
1090 cin: npt.NDArray[np.floating],
1091 flow: npt.NDArray[np.floating],
1092 tedges_days: npt.NDArray[np.floating],
1093) -> float:
1094 """
1095 Compute cumulative mass entering domain from t=0 to t.
1097 Integrates inlet mass flux over time:
1098 M_in(t) = ∫₀^t cin(τ) * flow(τ) dτ
1100 using exact analytical integration of piecewise-constant functions.
1102 Parameters
1103 ----------
1104 t : float
1105 Time up to which to integrate [days].
1106 cin : array-like
1107 Inlet concentration time series [mass/volume].
1108 Piecewise constant within bins defined by tedges_days.
1109 flow : array-like
1110 Flow rate time series [m³/day].
1111 Piecewise constant within bins defined by tedges_days.
1112 tedges_days : numpy.ndarray
1113 Time bin edges [days]. Length len(cin) + 1.
1115 Returns
1116 -------
1117 mass_in : float
1118 Cumulative mass entered [mass].
1120 Notes
1121 -----
1122 For piecewise-constant cin and flow:
1123 M_in = Σ cin[i] * flow[i] * dt[i]
1125 where the sum is over all bins from tedges_days[0] to t.
1126 Partial bins are handled exactly.
1128 Examples
1129 --------
1130 ::
1132 mass_in = compute_cumulative_inlet_mass(
1133 t=50.0, cin=cin, flow=flow, tedges_days=tedges_days
1134 )
1135 mass_in >= 0.0
1136 """
1137 tedges_arr = np.asarray(tedges_days, dtype=float)
1138 cin_arr = np.asarray(cin, dtype=float)
1139 flow_arr = np.asarray(flow, dtype=float)
1141 # Find which bin t falls into
1142 if t <= tedges_arr[0]:
1143 return 0.0
1145 if t >= tedges_arr[-1]:
1146 # Integrate all bins
1147 dt = np.diff(tedges_arr)
1148 return float(np.sum(cin_arr * flow_arr * dt))
1150 # Find bin containing t
1151 bin_idx = np.searchsorted(tedges_arr, t, side="right") - 1
1153 # Mass flux across inlet boundary = Q * C_in (aqueous concentration)
1154 # This is correct for sorbing solutes: only dissolved mass flows with water
1155 # Integrate complete bins before t
1156 if bin_idx > 0:
1157 dt_complete = np.diff(tedges_arr[: bin_idx + 1])
1158 mass_complete = np.sum(cin_arr[:bin_idx] * flow_arr[:bin_idx] * dt_complete)
1159 else:
1160 mass_complete = 0.0
1162 # Add partial bin
1163 if bin_idx >= 0 and bin_idx < len(cin_arr):
1164 dt_partial = t - tedges_arr[bin_idx]
1165 mass_partial = cin_arr[bin_idx] * flow_arr[bin_idx] * dt_partial
1166 else:
1167 mass_partial = 0.0
1169 return float(mass_complete + mass_partial)
1172def find_last_rarefaction_start_time(
1173 v_outlet: float,
1174 waves: list[Wave],
1175) -> float:
1176 """
1177 Find the time when the last rarefaction head reaches the outlet.
1179 For rarefactions, we integrate analytically so we only need to know
1180 when the rarefaction starts at the outlet (head arrival).
1182 Parameters
1183 ----------
1184 v_outlet : float
1185 Outlet position [m³].
1186 waves : list of Wave
1187 All waves in the simulation.
1189 Returns
1190 -------
1191 t_last : float
1192 Time when last rarefaction head reaches outlet [days].
1193 For non-rarefaction waves, uses their arrival time.
1194 Returns 0.0 if no waves reach the outlet.
1196 Notes
1197 -----
1198 This function finds when the last wave structure *starts* at the outlet.
1199 For rarefactions, this is the head arrival time. The tail may arrive
1200 much later (or at infinite time for rarefactions to C=0), but the
1201 total mass in the rarefaction is computed analytically.
1203 Examples
1204 --------
1205 ::
1207 t_last = find_last_rarefaction_start_time(v_outlet=500.0, waves=all_waves)
1208 t_last >= 0.0
1209 """
1210 t_last = 0.0
1212 for wave in waves:
1213 if not wave.is_active:
1214 continue
1216 if isinstance(wave, RarefactionWave):
1217 # For rarefaction, use head arrival (when rarefaction starts)
1218 head_vel = wave.head_velocity()
1219 if head_vel > EPSILON_VELOCITY:
1220 t_arrival = wave.t_start + (v_outlet - wave.v_start) / head_vel
1221 t_last = max(t_last, t_arrival)
1222 elif isinstance(wave, (CharacteristicWave, ShockWave)):
1223 # For characteristics and shocks, compute arrival time
1224 vel = wave.velocity if isinstance(wave, ShockWave) else wave.velocity()
1225 if vel is not None and vel > EPSILON_VELOCITY:
1226 t_arrival = wave.t_start + (v_outlet - wave.v_start) / vel
1227 t_last = max(t_last, t_arrival)
1229 return t_last
1232def compute_cumulative_outlet_mass(
1233 t: float,
1234 v_outlet: float,
1235 waves: list[Wave],
1236 sorption: FreundlichSorption | ConstantRetardation,
1237 flow: npt.NDArray[np.floating],
1238 tedges_days: npt.NDArray[np.floating],
1239) -> float:
1240 """
1241 Compute cumulative mass exiting domain from t=0 to t.
1243 Integrates outlet mass flux over time:
1244 M_out(t) = ∫₀^t cout(τ) * flow(τ) dτ
1246 using exact analytical integration. Outlet concentration cout(τ) is obtained
1247 from the wave solution, and flow(τ) is piecewise constant.
1249 Parameters
1250 ----------
1251 t : float
1252 Time up to which to integrate [days].
1253 v_outlet : float
1254 Outlet position [m³].
1255 waves : list of Wave
1256 All waves in the simulation.
1257 sorption : FreundlichSorption or ConstantRetardation
1258 Sorption model.
1259 flow : array-like
1260 Flow rate time series [m³/day].
1261 Piecewise constant within bins defined by tedges_days.
1262 tedges_days : numpy.ndarray
1263 Time bin edges [days]. Length len(flow) + 1.
1265 Returns
1266 -------
1267 mass_out : float
1268 Cumulative mass exited [mass].
1270 Notes
1271 -----
1272 The outlet concentration is obtained from wave solution via
1273 concentration_at_point(v_outlet, τ, waves, sorption).
1275 For each flow bin [t_i, t_{i+1}], the mass flux integral is computed
1276 exactly using identify_outlet_segments and analytical integration
1277 (constant segments and rarefaction segments).
1279 Examples
1280 --------
1281 ::
1283 mass_out = compute_cumulative_outlet_mass(
1284 t=50.0,
1285 v_outlet=500.0,
1286 waves=tracker.state.waves,
1287 sorption=sorption,
1288 flow=flow,
1289 tedges_days=tedges_days,
1290 )
1291 mass_out >= 0.0
1292 """
1293 tedges_arr = np.asarray(tedges_days, dtype=float)
1294 flow_arr = np.asarray(flow, dtype=float)
1296 if t <= tedges_arr[0]:
1297 return 0.0
1299 # Integrate bin by bin through all flow bins, then continue to t if needed
1300 total_mass = 0.0
1302 # Process bins within tedges range
1303 n_flow_bins = len(flow_arr)
1305 for i in range(n_flow_bins):
1306 t_bin_start = tedges_arr[i]
1307 t_bin_end = tedges_arr[i + 1]
1309 # Skip bins entirely before t
1310 if t_bin_end <= tedges_arr[0]:
1311 continue
1313 # Clip to [tedges[0], t]
1314 t_bin_start = max(t_bin_start, tedges_arr[0])
1315 t_bin_end = min(t_bin_end, t)
1317 flow_i = flow_arr[i]
1318 dt_i = t_bin_end - t_bin_start
1320 if dt_i <= 0:
1321 continue
1323 # Compute ∫_{t_bin_start}^{t_bin_end} cout(τ) dτ using exact integration
1324 segments = identify_outlet_segments(t_bin_start, t_bin_end, v_outlet, waves, sorption)
1326 integral_c_dt = 0.0
1328 for seg in segments:
1329 seg_t_start = max(seg["t_start"], t_bin_start)
1330 seg_t_end = min(seg["t_end"], t_bin_end)
1331 seg_dt = seg_t_end - seg_t_start
1333 if seg_dt <= EPSILON_TIME:
1334 continue
1336 if seg["type"] == "constant":
1337 integral_c_dt += seg["concentration"] * seg_dt
1338 elif seg["type"] == "rarefaction":
1339 if isinstance(sorption, FreundlichSorption):
1340 raref = seg["wave"]
1341 integral_c_dt += integrate_rarefaction_exact(raref, v_outlet, seg_t_start, seg_t_end, sorption)
1342 else:
1343 # ConstantRetardation - use midpoint
1344 c_mid = concentration_at_point(v_outlet, 0.5 * (seg_t_start + seg_t_end), waves, sorption)
1345 integral_c_dt += c_mid * seg_dt
1347 # Mass for this bin = flow * ∫ cout dt
1348 total_mass += flow_i * integral_c_dt
1350 return float(total_mass)
1353def integrate_rarefaction_total_mass(
1354 raref: RarefactionWave,
1355 v_outlet: float,
1356 t_start: float,
1357 sorption: FreundlichSorption | ConstantRetardation,
1358 flow: float,
1359) -> float:
1360 """
1361 Compute total mass exiting through a rarefaction.
1363 For a rarefaction that starts at the outlet at time t_start, compute the
1364 total mass that will exit through the rarefaction. Integration endpoint
1365 depends on the rarefaction tail concentration:
1367 - If c_tail ≈ 0: Integrate to infinity (tail extends infinitely)
1368 - If c_tail > 0: Integrate to finite tail arrival time
1370 Parameters
1371 ----------
1372 raref : RarefactionWave
1373 Rarefaction wave.
1374 v_outlet : float
1375 Outlet position [m³].
1376 t_start : float
1377 Time when rarefaction head reaches outlet [days].
1378 sorption : FreundlichSorption or ConstantRetardation
1379 Sorption model.
1380 flow : float
1381 Flow rate [m³/day] (assumed constant).
1383 Returns
1384 -------
1385 total_mass : float
1386 Total mass that exits through rarefaction [mass].
1388 Notes
1389 -----
1390 Uses the exact analytical integral:
1391 M_total = ∫_{t_start}^{t_end} Q * C(t) dt
1393 where C(t) is the concentration at the outlet from the rarefaction wave.
1395 For n > 1 (favorable sorption), rarefactions typically decrease to C=0,
1396 so t_end = ∞ and the integral converges.
1398 For n < 1 (unfavorable sorption), rarefactions typically increase from
1399 low C to high C, so c_tail > 0 and the tail arrives at finite time
1400 t_end = t_start + (v_outlet - v_start) / tail_velocity.
1402 Examples
1403 --------
1404 ::
1406 mass = integrate_rarefaction_total_mass(
1407 raref=raref_wave,
1408 v_outlet=500.0,
1409 t_start=40.0,
1410 sorption=sorption,
1411 flow=100.0,
1412 )
1413 mass >= 0.0
1414 """
1415 if isinstance(sorption, ConstantRetardation):
1416 # No rarefactions with constant retardation
1417 return 0.0
1419 # Determine integration endpoint based on c_tail
1420 # For rarefactions with c_tail ≈ 0, the tail extends to infinity
1421 # For rarefactions with c_tail > 0, the tail arrives at finite time
1422 if raref.c_tail < EPSILON_CONCENTRATION:
1423 # Rarefaction tail goes to C≈0, extends to infinite time
1424 # This is typical for n > 1 rarefactions (concentration decreases)
1425 t_end = np.inf
1426 else:
1427 # Rarefaction tail has finite concentration, arrives at finite time
1428 # This is typical for n < 1 rarefactions (concentration increases)
1429 tail_velocity = raref.tail_velocity()
1430 if tail_velocity < EPSILON_VELOCITY:
1431 # Tail velocity is effectively zero, extends to infinity
1432 t_end = np.inf
1433 else:
1434 # Compute finite tail arrival time
1435 t_end = raref.t_start + (v_outlet - raref.v_start) / tail_velocity
1437 # Integrate from t_start to t_end
1438 integral_c_dt = integrate_rarefaction_exact(raref, v_outlet, t_start, t_end, sorption)
1440 return flow * integral_c_dt
1443def compute_total_outlet_mass(
1444 v_outlet: float,
1445 waves: list[Wave],
1446 sorption: FreundlichSorption | ConstantRetardation,
1447 flow: npt.NDArray[np.floating],
1448 tedges_days: npt.NDArray[np.floating],
1449) -> tuple[float, float]:
1450 """
1451 Compute total integrated outlet mass until all mass has exited.
1453 Automatically determines when the last wave passes the outlet and
1454 integrates the outlet mass flux until that time, regardless of the
1455 provided tedges extent.
1457 Parameters
1458 ----------
1459 v_outlet : float
1460 Outlet position [m³].
1461 waves : list of Wave
1462 All waves in the simulation.
1463 sorption : FreundlichSorption or ConstantRetardation
1464 Sorption model.
1465 flow : array-like
1466 Flow rate time series [m³/day].
1467 Piecewise constant within bins defined by tedges_days.
1468 tedges_days : numpy.ndarray
1469 Time bin edges [days]. Length len(flow) + 1.
1471 Returns
1472 -------
1473 total_mass_out : float
1474 Total mass that exits through outlet [mass].
1475 t_integration_end : float
1476 Time until which integration was performed [days].
1477 This is the time when the last wave passes the outlet.
1479 Notes
1480 -----
1481 This function:
1482 1. Finds when the last rarefaction *starts* at the outlet (head arrival)
1483 2. Integrates outlet mass flux until that time
1484 3. Adds analytical integral of rarefaction mass from start to infinity
1486 For rarefactions to C=0, the tail has infinite arrival time but the
1487 total mass is finite and computed analytically.
1489 Examples
1490 --------
1491 ::
1493 total_mass, t_end = compute_total_outlet_mass(
1494 v_outlet=500.0,
1495 waves=tracker.state.waves,
1496 sorption=sorption,
1497 flow=flow,
1498 tedges_days=tedges_days,
1499 )
1500 total_mass >= 0.0
1501 t_end >= tedges_days[0]
1503 See Also
1504 --------
1505 compute_cumulative_outlet_mass : Cumulative outlet mass up to time t
1506 find_last_rarefaction_start_time : Find when last rarefaction starts
1507 integrate_rarefaction_total_mass : Total mass in rarefaction to infinity
1508 """
1509 # Find when the last rarefaction starts at the outlet
1510 t_last_raref_start = find_last_rarefaction_start_time(v_outlet, waves)
1512 tedges_arr = np.asarray(tedges_days, dtype=float)
1513 flow_arr = np.asarray(flow, dtype=float)
1515 # Integrate up to when last rarefaction starts
1516 if t_last_raref_start <= tedges_arr[-1]:
1517 # All waves start within provided time range
1518 mass_up_to_raref_start = compute_cumulative_outlet_mass(
1519 t_last_raref_start, v_outlet, waves, sorption, flow_arr, tedges_arr
1520 )
1521 flow_at_raref_start = flow_arr[-1] # Use last flow value
1522 else:
1523 # Need to extend beyond tedges to reach rarefaction start
1524 # First, compute mass up to tedges[-1]
1525 mass_within_tedges = compute_cumulative_outlet_mass(
1526 tedges_arr[-1], v_outlet, waves, sorption, flow_arr, tedges_arr
1527 )
1529 # Then, integrate from tedges[-1] to t_last_raref_start
1530 flow_extended = flow_arr[-1]
1531 t_start_extended = tedges_arr[-1]
1532 t_end_extended = t_last_raref_start
1534 # Get outlet segments for extended period
1535 segments = identify_outlet_segments(t_start_extended, t_end_extended, v_outlet, waves, sorption)
1537 integral_c_dt = 0.0
1539 for seg in segments:
1540 seg_t_start = max(seg["t_start"], t_start_extended)
1541 seg_t_end = min(seg["t_end"], t_end_extended)
1542 seg_dt = seg_t_end - seg_t_start
1544 if seg_dt <= EPSILON_TIME:
1545 continue
1547 if seg["type"] == "constant":
1548 integral_c_dt += seg["concentration"] * seg_dt
1549 elif seg["type"] == "rarefaction":
1550 if isinstance(sorption, FreundlichSorption):
1551 raref = seg["wave"]
1552 integral_c_dt += integrate_rarefaction_exact(raref, v_outlet, seg_t_start, seg_t_end, sorption)
1553 else:
1554 # ConstantRetardation - use midpoint
1555 c_mid = concentration_at_point(v_outlet, 0.5 * (seg_t_start + seg_t_end), waves, sorption)
1556 integral_c_dt += c_mid * seg_dt
1558 mass_up_to_raref_start = mass_within_tedges + flow_extended * integral_c_dt
1559 flow_at_raref_start = flow_extended
1561 # Find rarefactions that are active at the outlet after t_last_raref_start
1562 # and add their total integrated mass
1563 total_raref_mass = 0.0
1565 for wave in waves:
1566 if not wave.is_active:
1567 continue
1569 if isinstance(wave, RarefactionWave):
1570 # Check if this rarefaction reaches the outlet
1571 head_vel = wave.head_velocity()
1572 if head_vel > EPSILON_VELOCITY and wave.v_start < v_outlet:
1573 t_raref_start_at_outlet = wave.t_start + (v_outlet - wave.v_start) / head_vel
1575 # If this rarefaction starts at or after t_last_raref_start, include its total mass
1576 # (with small tolerance for numerical precision)
1577 if abs(t_raref_start_at_outlet - t_last_raref_start) < EPSILON_TIME_MATCH:
1578 # This is the last rarefaction - integrate to infinity
1579 raref_mass = integrate_rarefaction_total_mass(
1580 raref=wave,
1581 v_outlet=v_outlet,
1582 t_start=t_raref_start_at_outlet,
1583 sorption=sorption,
1584 flow=flow_at_raref_start,
1585 )
1586 total_raref_mass += raref_mass
1588 # For rarefactions with finite tails (c_tail > 0, typical for n < 1),
1589 # all mass is already accounted for in the rarefaction integration
1590 # from head to tail. No additional mass needs to be integrated after
1591 # the tail - if there were more waves, they would be in the wave list.
1592 total_mass = mass_up_to_raref_start + total_raref_mass
1594 return float(total_mass), t_last_raref_start