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

105 statements  

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

1""" 

2Physics validation utilities for front tracking in (V, θ) coordinates. 

3 

4This module provides functions to verify physical correctness of front-tracking 

5simulations, including entropy conditions, concentration bounds, mass conservation, 

6and event ordering. The solver runs in cumulative-flow coordinate 

7``θ = ∫flow(t') dt'``; events on ``state.events`` carry ``"theta"`` (m³). Because 

8``flow ≥ 0`` is enforced, θ is monotone non-decreasing in t, so θ-ordering and 

9chronological ordering are equivalent. 

10 

11Available functions: 

12 

13- :func:`verify_physics` - Run seven checks on a completed front-tracking result and return a summary 

14 dictionary with ``'all_passed'``, the check counts, the ``'failures'`` list, a per-check record list and a 

15 one-line ``'summary'``: Lax entropy for every shock, no negative concentrations, output bounded by the 

16 inlet maximum, finite first-arrival θ, no NaN after spin-up, θ-ordered events, and mass conservation 

17 comparing an independent outlet integral plus the domain mass against the injected mass at ``θ_max``. The 

18 mass-balance check uses ``max(rtol, _MASS_BALANCE_RTOL)`` because integrating a shock-bearing breakthrough 

19 curve is only first-order accurate. Passing ``verbose=False`` suppresses printing but returns the same 

20 dictionary. 

21 

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

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

24""" 

25 

26import logging 

27 

28import numpy as np 

29import numpy.typing as npt 

30import pandas as pd 

31 

32from gwtransport._time import tedges_to_days 

33from gwtransport.fronttracking.output import ( 

34 compute_breakthrough_curve, 

35 compute_cumulative_inlet_mass, 

36 compute_domain_mass, 

37) 

38from gwtransport.fronttracking.solver import FrontTrackerState 

39from gwtransport.fronttracking.waves import ShockWave 

40 

41# Numerical tolerance constants 

42EPSILON_CONCENTRATION_TOLERANCE = -1e-14 # Minimum allowed concentration (machine precision) 

43 

44# Mass-balance check (7) tolerance and grid. 

45# 

46# The independent outlet mass integrates the breakthrough curve with the trapezoid 

47# rule (see ``_independent_outlet_mass``). For a shock-bearing, sharply-curved 

48# breakthrough this is only first-order accurate: the measured relative error for the 

49# canonical favorable-sorption pulse oscillates at ~5e-4 across a modest grid band 

50# (2000-4000 points; it only falls reliably below 1e-4 above ~20000 points). The grid is 

51# deliberately kept modest because a numerical DecayingShockWave makes 

52# ``compute_breakthrough_curve`` slow (seconds to minutes for large grids), so we cannot 

53# refine the integral to machine precision. ``_MASS_BALANCE_RTOL`` therefore bounds that 

54# ~5e-4 grid noise with ~16x margin; a physical 30% inlet-mass error yields a relative 

55# error of ~0.23 (= 1 - 1/1.3) to ~0.43 (= 1/0.7 - 1), i.e. ~20-40x this floor, so the 

56# check still has strong teeth against a genuine conservation failure. 

57_MASS_BALANCE_RTOL = 1e-2 

58_MASS_BALANCE_GRID_POINTS = 3000 

59 

60logger = logging.getLogger(__name__) 

61 

62 

63def _independent_outlet_mass(tracker_state: FrontTrackerState) -> float: 

64 """Outlet-side mass total computed independently of the ``m_in - m_dom`` identity. 

65 

66 Integrated to θ_max (the last θ-bin edge). Sums the mass that has already left through 

67 the outlet, ``∫₀^θ_max c_out(τ) dτ``, and the mass still in the domain, ``m_dom(θ_max)``. 

68 The breakthrough integral uses :func:`compute_breakthrough_curve`, which dispatches 

69 :func:`concentration_at_point` directly (pure wave evaluation), so this total never 

70 references the conservation identity ``m_out = m_in − m_dom`` that the mass-balance 

71 check is meant to test. Comparing it to :func:`compute_cumulative_inlet_mass` at θ_max 

72 is therefore a genuine, non-tautological conservation check: for a pulse that has not 

73 fully broken through by θ_max, the partial breakthrough integral plus the residual 

74 domain mass still equals the cumulative inlet mass. 

75 

76 Parameters 

77 ---------- 

78 tracker_state : FrontTrackerState 

79 Solver state; must expose ``v_outlet``, ``sorption``, ``waves`` and 

80 ``theta_edges``. 

81 

82 Returns 

83 ------- 

84 float 

85 Independent outlet-side mass total [mass]. 

86 """ 

87 v_outlet = tracker_state.v_outlet 

88 sorption = tracker_state.sorption 

89 waves = tracker_state.waves 

90 theta_max = float(np.asarray(tracker_state.theta_edges, dtype=float)[-1]) 

91 

92 if theta_max <= 0.0: 

93 return compute_domain_mass(theta=theta_max, v_outlet=v_outlet, waves=waves, sorption=sorption) 

94 

95 theta_grid: npt.NDArray[np.floating] = np.linspace(0.0, theta_max, _MASS_BALANCE_GRID_POINTS) 

96 breakthrough = compute_breakthrough_curve(theta_grid, v_outlet, waves, sorption) 

97 mass_out = float(np.trapezoid(breakthrough, theta_grid)) 

98 mass_dom = compute_domain_mass(theta=theta_max, v_outlet=v_outlet, waves=waves, sorption=sorption) 

99 return mass_out + mass_dom 

100 

101 

102def verify_physics( 

103 structure: dict, 

104 cout: npt.ArrayLike, 

105 cout_tedges: pd.DatetimeIndex, 

106 cin: npt.ArrayLike, 

107 *, 

108 verbose: bool = True, 

109 rtol: float = 1e-10, 

110) -> dict: 

111 """ 

112 Run comprehensive physics verification checks on front tracking results. 

113 

114 Performs the following checks: 

115 

116 1. Entropy condition for all shocks 

117 2. No negative concentrations (within tolerance) 

118 3. Output concentration <= input maximum 

119 4. Finite first arrival θ 

120 5. No NaN values after spin-up period 

121 6. Events θ-ordered (equivalent to chronological under non-negative flow) 

122 7. Mass conservation: independent outlet integral + domain mass == inlet mass at θ_max 

123 

124 Parameters 

125 ---------- 

126 structure : dict 

127 Structure returned from ``infiltration_to_extraction_nonlinear_sorption``. 

128 Must contain keys: ``'waves'``, ``'theta_first_arrival'``, ``'events'``, 

129 and optionally ``'tracker_state'``. 

130 cout : array-like 

131 Bin-averaged output concentrations. 

132 cout_tedges : pandas.DatetimeIndex 

133 Output time edges for bins (only used for the spin-up mask). 

134 cin : array-like 

135 Input concentrations. 

136 verbose : bool, optional 

137 If True, print detailed results. If False, only return summary. Default True. 

138 rtol : float, optional 

139 Relative tolerance for numerical checks. Default 1e-10. For the mass-balance 

140 check (7) the effective tolerance is ``max(rtol, _MASS_BALANCE_RTOL)`` because 

141 that check integrates a shock-bearing breakthrough curve and is only first-order 

142 accurate (see ``_MASS_BALANCE_RTOL``). 

143 

144 Returns 

145 ------- 

146 results : dict 

147 Dictionary containing: 

148 

149 - ``'all_passed'``: bool - True if all checks passed 

150 - ``'n_checks'``: int - Total number of checks performed 

151 - ``'n_passed'``: int - Number of checks that passed 

152 - ``'failures'``: list of str - Description of failed checks (empty if all passed) 

153 - ``'checks'``: list of dict - Per-check result records; each has ``'name'``, 

154 ``'passed'``, ``'message'`` keys. 

155 - ``'summary'``: str - One-line summary 

156 

157 Examples 

158 -------- 

159 .. disable_try_examples 

160 

161 :: 

162 

163 results = verify_physics(structure, cout, cout_tedges, cin, verbose=False) 

164 print(results["summary"]) 

165 assert results["all_passed"] 

166 """ 

167 cout = np.asarray(cout, dtype=float) 

168 cin = np.asarray(cin, dtype=float) 

169 failures: list[str] = [] 

170 checks: list[dict] = [] 

171 

172 # Check 1: Entropy condition for all shocks (Lax in (V, θ): λ_θ(C_L) >= s >= λ_θ(C_R)). 

173 shocks = [w for w in structure["waves"] if isinstance(w, ShockWave)] 

174 entropy_violations = [s for s in shocks if not s.satisfies_entropy()] 

175 check1_pass = len(entropy_violations) == 0 

176 checks.append({ 

177 "name": "Shock entropy condition", 

178 "passed": check1_pass, 

179 "message": f"Entropy violations: {len(entropy_violations)}/{len(shocks)} shocks", 

180 }) 

181 if not check1_pass: 

182 failures.append(f"Entropy violations: {len(entropy_violations)} shocks violate entropy condition") 

183 

184 # Check 2: No negative concentrations (within tolerance) 

185 valid_cout = cout[~np.isnan(cout)] 

186 min_cout = np.min(valid_cout) if len(valid_cout) > 0 else 0.0 

187 check2_pass = min_cout >= EPSILON_CONCENTRATION_TOLERANCE 

188 checks.append({ 

189 "name": "Non-negative concentrations", 

190 "passed": check2_pass, 

191 "message": f"Minimum concentration: {min_cout:.2e}", 

192 }) 

193 if not check2_pass: 

194 failures.append(f"Negative concentrations found: min = {min_cout:.2e}") 

195 

196 # Check 3: Output doesn't exceed input (within tight tolerance) 

197 max_cout = np.max(valid_cout) if len(valid_cout) > 0 else 0.0 

198 max_cin = np.max(cin) 

199 check3_pass = max_cout <= max_cin * (1.0 + rtol) 

200 checks.append({ 

201 "name": "Output <= input maximum", 

202 "passed": check3_pass, 

203 "message": f"Max output: {max_cout:.2f}, Max input: {max_cin:.2f}", 

204 }) 

205 if not check3_pass: 

206 failures.append(f"Output exceeds input: {max_cout:.2f} > {max_cin:.2f}") 

207 

208 # Check 4: Finite first arrival θ 

209 theta_first = structure["theta_first_arrival"] 

210 check4_pass = np.isfinite(theta_first) 

211 checks.append({ 

212 "name": "Finite first arrival θ", 

213 "passed": check4_pass, 

214 "message": f"First arrival: θ={theta_first:.2f}", 

215 }) 

216 if not check4_pass: 

217 failures.append(f"First arrival θ is not finite: {theta_first}") 

218 

219 # Check 5: No NaN values after spin-up 

220 tracker_state = structure.get("tracker_state") 

221 if tracker_state is not None and np.isfinite(theta_first): 

222 # theta_at_t measures days from the input origin tracker_state.tedges[0]; the output 

223 # grid must be referenced to that same origin (not its own first edge) or the mask 

224 # shifts and NaNs after spin-up slip through. 

225 t_days = tedges_to_days(cout_tedges, ref=tracker_state.tedges[0])[:-1] 

226 theta_at_edge = tracker_state.theta_at_t_array(t_days) 

227 mask_after_spinup = theta_at_edge >= theta_first 

228 elif not np.isfinite(theta_first): 

229 # No spin-up bound — every output row counts as "after spin-up". 

230 mask_after_spinup = np.ones(len(cout), dtype=bool) 

231 else: 

232 # No tracker state to translate — nothing to check. 

233 mask_after_spinup = np.zeros(len(cout), dtype=bool) 

234 cout_after_spinup = cout[mask_after_spinup] 

235 nan_count = np.sum(np.isnan(cout_after_spinup)) 

236 check5_pass = nan_count == 0 

237 checks.append({ 

238 "name": "No NaN after spin-up", 

239 "passed": check5_pass, 

240 "message": f"NaN values after spin-up: {nan_count}/{len(cout_after_spinup)}", 

241 }) 

242 if not check5_pass: 

243 failures.append(f"Found {nan_count} NaN values after spin-up period") 

244 

245 # Check 6: Events θ-ordered. ``np.all(np.diff(...) >= 0)`` is vacuously True 

246 # for an empty/singleton sequence, so the same expression covers the N/A case; 

247 # only the message differs. 

248 event_thetas = [e["theta"] for e in structure.get("events", [])] 

249 check6_pass = bool(np.all(np.diff(event_thetas) >= 0)) 

250 checks.append({ 

251 "name": "Events θ-ordered", 

252 "passed": check6_pass, 

253 "message": f"{len(event_thetas)} events" if len(event_thetas) > 1 else f"{len(event_thetas)} events (N/A)", 

254 }) 

255 if not check6_pass: 

256 failures.append("Events are not θ-ordered") 

257 

258 # Check 7: Total integrated outlet mass vs total inlet mass (in θ-space). 

259 # 

260 # The outlet-side total is computed *independently* of the conservation identity 

261 # ``m_out = m_in − m_dom``; using that identity on both sides would be an algebraic 

262 # tautology that passes for any input. ``_independent_outlet_mass`` integrates the 

263 # breakthrough curve and adds the spatial domain mass; both come from direct wave 

264 # evaluation, so a mismatch with the cumulative inlet mass signals a real conservation 

265 # failure. Integrated to θ_max (the last θ-bin edge); for pulses that have not fully 

266 # broken through there, the partial breakthrough integral plus the residual domain mass 

267 # still equals the cumulative inlet mass. 

268 if tracker_state is not None and hasattr(tracker_state, "theta_edges"): 

269 theta_edges_arr = np.asarray(tracker_state.theta_edges, dtype=float) 

270 theta_integration_end = float(theta_edges_arr[-1]) 

271 

272 total_mass_in = compute_cumulative_inlet_mass(theta=theta_integration_end, cin=cin, theta_edges=theta_edges_arr) 

273 independent_mass_out = _independent_outlet_mass(tracker_state) 

274 

275 if total_mass_in > 0: 

276 relative_error_total = abs(independent_mass_out - total_mass_in) / total_mass_in 

277 else: 

278 relative_error_total = abs(independent_mass_out - total_mass_in) 

279 

280 mass_balance_threshold = max(rtol, _MASS_BALANCE_RTOL) 

281 check7_pass = relative_error_total <= mass_balance_threshold 

282 checks.append({ 

283 "name": "Total integrated outlet mass", 

284 "passed": check7_pass, 

285 "message": ( 

286 f"Relative error: {relative_error_total:.2e} (independent outlet integral to " 

287 f"θ={theta_integration_end:.1f}; threshold {mass_balance_threshold:.2e})" 

288 ), 

289 }) 

290 if not check7_pass: 

291 failures.append( 

292 f"Total outlet mass mismatch: relative_error={relative_error_total:.2e} > " 

293 f"{mass_balance_threshold:.2e} (independent_mass_out={independent_mass_out:.6e}, " 

294 f"total_mass_in={total_mass_in:.6e}, θ_integration_end={theta_integration_end:.1f})" 

295 ) 

296 else: 

297 check7_pass = True 

298 checks.append({ 

299 "name": "Total integrated outlet mass", 

300 "passed": True, 

301 "message": "Skipped (tracker state not available)", 

302 }) 

303 

304 # Compile results 

305 n_checks = len(checks) 

306 n_passed = sum(c["passed"] for c in checks) 

307 all_passed = len(failures) == 0 

308 

309 if all_passed: 

310 summary = f"All {n_checks} physics checks passed" 

311 else: 

312 summary = f"{n_passed}/{n_checks} checks passed ({len(failures)} failures)" 

313 

314 results = { 

315 "all_passed": all_passed, 

316 "n_checks": n_checks, 

317 "n_passed": n_passed, 

318 "failures": failures, 

319 "checks": checks, 

320 "summary": summary, 

321 } 

322 

323 if verbose: 

324 logger.info("\nPhysics Verification:") 

325 for i, check in enumerate(checks, 1): 

326 status = "PASS" if check["passed"] else "FAIL" 

327 logger.info(" %d. %s: %s %s", i, check["name"], status, check["message"]) 

328 

329 if all_passed: 

330 logger.info("\n%s", summary) 

331 else: 

332 logger.warning("\n%s", summary) 

333 logger.warning("\nFailures:") 

334 for i, failure in enumerate(failures, 1): 

335 logger.warning(" %d. %s", i, failure) 

336 

337 return results