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

248 statements  

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

1"""Event-driven front-tracking solver in (V, θ) coordinates. 

2 

3The simulation runs entirely in cumulative-flow space θ. Every public 

4output — wave attributes, ``state.events[i]['theta']``, 

5``theta_first_arrival`` — is in θ. Translation to user-facing time t is 

6the caller's responsibility via ``state.t_at_theta``. 

7Time-varying flow is absorbed into the precomputed ``theta_edges`` array 

8at ``__init__``; there is no flow-change event. 

9 

10Algorithm: 

11 

121. Initialize waves from inlet boundary conditions (one per cin step at θ_edges[i]). 

132. Find next event (earliest collision or outlet crossing in θ). 

143. Advance θ to event. 

154. Handle event (create new waves, deactivate old ones). 

165. Repeat until no more events. 

17 

18All calculations are exact analytical with machine precision. 

19 

20Available functions: 

21 

22- :class:`FrontTrackerState` - Dataclass carrying the whole simulation state: every wave ever created 

23 (active and deactivated), the event log, the current θ, the outlet position ``v_outlet``, the sorption 

24 model, the inlet series, and the ``tedges_days``/``theta_edges`` grids. Its ``t_at_theta``, ``theta_at_t`` 

25 and ``theta_at_t_array`` methods are the piecewise-linear θ↔t maps a caller needs to read any solver output 

26 in user-facing days; events at a zero-flow θ plateau map to the end of that plateau. 

27 

28- :class:`FrontTracker` - The event-driven solver. Construction validates the inputs (non-negative ``cin`` 

29 and ``flow``, ``len(tedges) == len(cin) + 1``, positive pore volume), builds ``theta_edges`` by cumulating 

30 ``flow · dt``, sets the resolution horizon ``theta_horizon`` (default: the last inlet edge), computes 

31 ``theta_first_arrival``, and emits the inlet waves. ``find_next_event`` returns the earliest candidate in 

32 θ — characteristic, shock and rarefaction collisions, face merges involving a decaying or doubly-fed shock, 

33 fan exhaustion, and outlet crossings — ``handle_event`` dispatches it to the handler that retires the 

34 parents and appends the successors, and ``run`` repeats until the candidate set is empty or 

35 ``max_iterations`` is reached. ``verify_physics`` asserts that every active shock still satisfies the Lax 

36 entropy condition. 

37 

38- :func:`find_unresolved_interaction` - Invariant tripwire over a completed state. The cumulative outlet mass 

39 ``m_out(θ) = m_in(θ) − m_dom(θ)`` must never decrease; this returns a short description of the first θ 

40 where it does beyond the floating-point cancellation band — the fingerprint of an interaction the solver 

41 failed to resolve — and ``None`` for a mass-conserving wave field. 

42""" 

43 

44import logging 

45from dataclasses import dataclass 

46from operator import itemgetter 

47 

48import numpy as np 

49import numpy.typing as npt 

50import pandas as pd 

51 

52from gwtransport._time import tedges_to_days 

53from gwtransport.fronttracking.events import ( 

54 Event, 

55 EventType, 

56 find_characteristic_intersection, 

57 find_outlet_crossing, 

58 find_rarefaction_boundary_intersections, 

59 find_shock_characteristic_intersection, 

60 find_shock_shock_intersection, 

61 is_outlet_crossing_pinned, 

62) 

63from gwtransport.fronttracking.handlers import ( 

64 EPSILON_CONCENTRATION, 

65 create_inlet_waves_at_theta, 

66 handle_characteristic_collision, 

67 handle_rarefaction_characteristic_collision, 

68 handle_shock_characteristic_collision, 

69 handle_shock_collision, 

70 handle_shock_rarefaction_collision, 

71) 

72from gwtransport.fronttracking.interactions import ( 

73 find_face_crossing, 

74 iter_faces, 

75 resolve_merge, 

76) 

77from gwtransport.fronttracking.math import ( 

78 SorptionModel, 

79 compute_first_front_arrival_theta, 

80) 

81from gwtransport.fronttracking.output import ( 

82 FP_CANCELLATION_CLAMP, 

83 compute_cumulative_outlet_mass, 

84) 

85from gwtransport.fronttracking.waves import ( 

86 CharacteristicWave, 

87 DecayingShockWave, 

88 DoubleFanShockWave, 

89 RarefactionWave, 

90 ShockWave, 

91 Wave, 

92) 

93 

94logger = logging.getLogger(__name__) 

95 

96 

97@dataclass 

98class FrontTrackerState: 

99 """Complete state of the front-tracking simulation in (V, θ). 

100 

101 Parameters 

102 ---------- 

103 waves : list of Wave 

104 All waves created during simulation (includes inactive waves). 

105 events : list of dict 

106 Event history. Records use the ``"theta"`` key carrying the 

107 cumulative flow at which the event occurred [m³]. Callers translate 

108 to user-facing time via ``FrontTrackerState.t_at_theta``. 

109 theta_current : float 

110 Current simulation cumulative flow [m³]. 

111 v_outlet : float 

112 Outlet position [m³]. 

113 sorption : SorptionModel 

114 Sorption model. 

115 cin : numpy.ndarray 

116 Inlet concentration time series [mass/volume]. 

117 flow : numpy.ndarray 

118 Flow rate time series [m³/day], one value per bin. 

119 tedges : pandas.DatetimeIndex 

120 Time bin edges. 

121 tedges_days : numpy.ndarray 

122 ``tedges`` as days from ``tedges[0]``, length ``len(flow) + 1``. 

123 theta_edges : numpy.ndarray 

124 Cumulative flow at every bin edge. ``theta_edges[i] = sum_{k<i} flow[k] * 

125 (tedges_days[k+1] - tedges_days[k])``. Length ``len(flow) + 1``. 

126 """ 

127 

128 waves: list[Wave] 

129 events: list[dict] 

130 theta_current: float 

131 v_outlet: float 

132 sorption: SorptionModel 

133 cin: np.ndarray 

134 flow: np.ndarray 

135 tedges: pd.DatetimeIndex 

136 tedges_days: npt.NDArray[np.floating] 

137 theta_edges: npt.NDArray[np.floating] 

138 theta_horizon: float = float("inf") 

139 

140 def t_at_theta(self, theta: float) -> float: 

141 """Translate cumulative flow θ back to user-facing time t [days]. 

142 

143 Piecewise linear inversion of the (tedges_days → theta_edges) map. 

144 Implementation note on the (rare) zero-flow case: when a bin has 

145 ``flow[i] == 0``, θ is constant across ``[tedges_days[i], tedges_days[i+1])``; 

146 ``np.searchsorted(..., side='right') - 1`` lands on the rightmost 

147 such bin, so this function returns ``tedges_days[i]`` for the 

148 right-most i sharing that θ. Convention: events at a zero-flow θ 

149 plateau map to the END of the zero-flow interval. 

150 """ 

151 if theta <= self.theta_edges[0]: 

152 return float(self.tedges_days[0]) 

153 if theta >= self.theta_edges[-1]: 

154 # Extrapolate: pretend the last bin continues at its current flow. 

155 last_flow = float(self.flow[-1]) 

156 if last_flow > 0: 

157 return float(self.tedges_days[-1] + (theta - self.theta_edges[-1]) / last_flow) 

158 return float(self.tedges_days[-1]) 

159 

160 # Find bin index i with theta_edges[i] <= theta < theta_edges[i+1]. 

161 # np.searchsorted with side='right' returns the smallest i+1 such that 

162 # theta_edges[i+1] > theta, so subtracting 1 gives i. 

163 # The boundary returns above guarantee strictly-interior theta here, so 

164 # searchsorted lands in [0, len(flow)-1] without an extra index clamp. 

165 i = int(np.searchsorted(self.theta_edges, theta, side="right")) - 1 

166 flow_i = float(self.flow[i]) 

167 if flow_i <= 0: 

168 return float(self.tedges_days[i]) 

169 return float(self.tedges_days[i] + (theta - self.theta_edges[i]) / flow_i) 

170 

171 def theta_at_t(self, t: float) -> float: 

172 """Translate user-facing time t [days] to cumulative flow θ [m³]. 

173 

174 Scalar form of :meth:`theta_at_t_array`. 

175 """ 

176 return float(self.theta_at_t_array(t)) 

177 

178 def theta_at_t_array(self, t: npt.ArrayLike) -> npt.NDArray[np.floating]: 

179 """Map times t [days] to cumulative flow θ [m³]. 

180 

181 Piecewise linear forward map. Outside the input range the boundary flow is 

182 extrapolated. 

183 

184 Parameters 

185 ---------- 

186 t : array-like 

187 User-facing time points [days]. 

188 

189 Returns 

190 ------- 

191 ndarray 

192 Cumulative flow θ at each ``t`` [m³]. 

193 """ 

194 t_arr = np.asarray(t, dtype=float) 

195 # Interior map: i = searchsorted(tedges_days, t, 'right') - 1, clipped to 

196 # a valid bin so the boundary branches can overwrite the extrapolated tails. 

197 i = np.clip(np.searchsorted(self.tedges_days, t_arr, side="right") - 1, 0, len(self.flow) - 1) 

198 theta = self.theta_edges[i] + (t_arr - self.tedges_days[i]) * self.flow[i] 

199 # Left of the first edge: clamp to theta_edges[0]. 

200 theta = np.where(t_arr <= self.tedges_days[0], self.theta_edges[0], theta) 

201 # Right of the last edge: extrapolate at the final-bin flow. 

202 return np.where( 

203 t_arr >= self.tedges_days[-1], 

204 self.theta_edges[-1] + (t_arr - self.tedges_days[-1]) * float(self.flow[-1]), 

205 theta, 

206 ) 

207 

208 

209class FrontTracker: 

210 """Event-driven front-tracking solver for nonlinear sorption transport. 

211 

212 Parameters 

213 ---------- 

214 cin : numpy.ndarray 

215 Inlet concentration time series [mass/volume]; length ``n``. 

216 flow : numpy.ndarray 

217 Flow rate time series [m³/day]; length ``n`` (one value per bin). 

218 tedges : pandas.DatetimeIndex 

219 Time bin edges (length ``n+1``). 

220 aquifer_pore_volume : float 

221 Total pore volume [m³] — used as the outlet position. 

222 sorption : SorptionModel 

223 Sorption model. 

224 

225 Attributes 

226 ---------- 

227 state : FrontTrackerState 

228 Complete simulation state. 

229 theta_first_arrival : float 

230 Cumulative flow θ at which the first nonzero-concentration wave reaches 

231 the outlet [m³]. Translate to user-facing time via 

232 ``state.t_at_theta(theta_first_arrival)``. 

233 

234 Notes 

235 ----- 

236 The solver works exclusively in cumulative flow θ; events appended to 

237 ``state.events`` carry ``"theta"``. Translation to user-facing time t is 

238 the caller's responsibility (use ``state.t_at_theta``). 

239 """ 

240 

241 def __init__( 

242 self, 

243 cin: npt.ArrayLike, 

244 flow: npt.ArrayLike, 

245 tedges: pd.DatetimeIndex, 

246 aquifer_pore_volume: float, 

247 sorption: SorptionModel, 

248 theta_horizon: float | None = None, 

249 ): 

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

251 flow = np.asarray(flow, dtype=float) 

252 if len(tedges) != len(cin) + 1: 

253 msg = f"tedges must have length len(cin) + 1, got {len(tedges)} vs {len(cin) + 1}" 

254 raise ValueError(msg) 

255 if len(flow) != len(cin): 

256 msg = f"flow must have same length as cin, got {len(flow)} vs {len(cin)}" 

257 raise ValueError(msg) 

258 if np.any(cin < 0): 

259 msg = "cin must be non-negative" 

260 raise ValueError(msg) 

261 if np.any(flow < 0): 

262 msg = "flow must be non-negative (negative flow not supported)" 

263 raise ValueError(msg) 

264 if aquifer_pore_volume <= 0: 

265 msg = "aquifer_pore_volume must be positive" 

266 raise ValueError(msg) 

267 

268 tedges_days = tedges_to_days(tedges) 

269 dt_days = np.diff(tedges_days) 

270 bin_volumes = flow * dt_days 

271 theta_edges = np.concatenate(([0.0], np.cumsum(bin_volumes))) 

272 

273 # Global interaction resolution runs to a θ-horizon: an unresolved crossing at 

274 # θ > horizon cannot affect any reader query at θ ≤ horizon (information propagates 

275 # strictly downstream, all speeds ≥ 0). Default = the last inlet edge; the public 

276 # API passes the last requested output edge so beyond-outlet merges that still feed 

277 # an in-window outlet query are resolved. 

278 resolved_horizon = float(theta_edges[-1]) if theta_horizon is None else float(theta_horizon) 

279 

280 self.state = FrontTrackerState( 

281 waves=[], 

282 events=[], 

283 theta_current=0.0, 

284 v_outlet=aquifer_pore_volume, 

285 sorption=sorption, 

286 cin=cin, 

287 flow=flow, 

288 tedges=tedges, 

289 tedges_days=tedges_days, 

290 theta_edges=theta_edges, 

291 theta_horizon=resolved_horizon, 

292 ) 

293 

294 self.theta_first_arrival = compute_first_front_arrival_theta(cin, theta_edges, aquifer_pore_volume, sorption) 

295 

296 self._initialize_inlet_waves() 

297 

298 def _initialize_inlet_waves(self): 

299 """Emit one wave per nonzero inlet step at the corresponding ``theta_edges[i]``.""" 

300 c_prev = 0.0 

301 theta_edges = self.state.theta_edges 

302 

303 for i in range(len(self.state.cin)): 

304 # A zero-flow (pump-off) bin carries no water: its θ-interval has zero 

305 # width, so it is transport-invisible. Skipping it carries the inlet 

306 # step through the gap (effective transition c_before_gap → c_after_gap) 

307 # instead of emitting coincident spurious waves at the degenerate θ. 

308 if theta_edges[i + 1] <= theta_edges[i]: 

309 continue 

310 

311 c_new = float(self.state.cin[i]) 

312 

313 if abs(c_new - c_prev) > EPSILON_CONCENTRATION: 

314 new_waves = create_inlet_waves_at_theta( 

315 c_prev=c_prev, 

316 c_new=c_new, 

317 theta=float(theta_edges[i]), 

318 sorption=self.state.sorption, 

319 ) 

320 self.state.waves.extend(new_waves) 

321 

322 c_prev = c_new 

323 

324 def find_next_event(self) -> Event | None: 

325 """Return the next event in θ-order, or ``None`` if none.""" 

326 # Each call collects every candidate and selects the single earliest via 

327 # ``min`` over the (theta, counter, ...) tuples; the unique ``counter`` 

328 # breaks θ-ties deterministically (and stops comparison before the 

329 # non-orderable EventType/Wave fields). A heap would only pay for its 

330 # invariant to extract one minimum, so a flat list + ``min`` is leaner. 

331 candidates: list[tuple] = [] 

332 counter = 0 # Unique counter to break θ-ties deterministically 

333 

334 active_waves = [w for w in self.state.waves if w.is_active] 

335 theta_current = self.state.theta_current 

336 

337 # Defense-in-depth loop guard: reject collision candidates at or below 

338 # θ_current + tol so a degenerate geometry cannot re-fire the identical 

339 # event forever (same FP-tolerance scale as the outlet-crossing guard 

340 # below). The structural fix is routing all shock↔rarefaction collisions 

341 # through DecayingShockWave; this backstops any future near-coincident 

342 # event from looping the solver. 

343 collision_tol = 1e-12 * max(abs(theta_current), 1.0) 

344 

345 def push_collision(theta, event_type, waves, v, boundary): 

346 nonlocal counter 

347 # Global resolution: collisions are resolved wherever they occur, 

348 # up to θ_horizon — not only within [0, v_outlet]. The single-owner reader needs a 

349 # globally interaction-consistent front order, since the nearest downstream face 

350 # of an in-domain query may lie beyond the outlet (e.g. a rarefaction head that 

351 # catches a shock past v_outlet still bounds the in-domain fan). Information 

352 # propagates strictly downstream (all speeds ≥ 0), so an event at θ > θ_horizon 

353 # cannot affect any reader query at θ ≤ θ_horizon. 

354 if v < -collision_tol: 

355 return 

356 if theta <= theta_current + collision_tol or theta > self.state.theta_horizon: 

357 return 

358 candidates.append((theta, counter, event_type, waves, v, boundary, None)) 

359 counter += 1 

360 

361 chars = [w for w in active_waves if isinstance(w, CharacteristicWave)] 

362 for i, w1 in enumerate(chars): 

363 for w2 in chars[i + 1 :]: 

364 result = find_characteristic_intersection(w1, w2, theta_current) 

365 if result: 

366 theta, v = result 

367 push_collision(theta, EventType.CHAR_CHAR_COLLISION, [w1, w2], v, None) 

368 

369 shocks = [w for w in active_waves if isinstance(w, ShockWave)] 

370 for i, w1 in enumerate(shocks): 

371 for w2 in shocks[i + 1 :]: 

372 result = find_shock_shock_intersection(w1, w2, theta_current) 

373 if result: 

374 theta, v = result 

375 push_collision(theta, EventType.SHOCK_SHOCK_COLLISION, [w1, w2], v, None) 

376 

377 for shock in shocks: 

378 for char in chars: 

379 result = find_shock_characteristic_intersection(shock, char, theta_current) 

380 if result: 

381 theta, v = result 

382 push_collision(theta, EventType.SHOCK_CHAR_COLLISION, [shock, char], v, None) 

383 

384 rarefs = [w for w in active_waves if isinstance(w, RarefactionWave)] 

385 for raref in rarefs: 

386 for char in chars: 

387 intersections = find_rarefaction_boundary_intersections(raref, char, theta_current) 

388 for theta, v, boundary in intersections: 

389 push_collision(theta, EventType.RAREF_CHAR_COLLISION, [raref, char], v, boundary) 

390 

391 for shock in shocks: 

392 for raref in rarefs: 

393 intersections = find_rarefaction_boundary_intersections(raref, shock, theta_current) 

394 for theta, v, boundary in intersections: 

395 push_collision(theta, EventType.SHOCK_RAREF_COLLISION, [shock, raref], v, boundary) 

396 

397 # Interaction events: every face pair with at least one decaying/doubly-fed shock. The 

398 # closed-form loops above cover char/shock/rarefaction pairs; here a face of a 

399 # DSW/DFSW (its curved shock face or a free fan boundary line) meets any other wave's face. 

400 # Resolution is GLOBAL — allowed beyond v_outlet up to θ_horizon — because the sweep reader 

401 # needs a globally interaction-consistent front order for the nearest-downstream lookup. 

402 special_types = (DecayingShockWave, DoubleFanShockWave) 

403 if any(isinstance(w, special_types) for w in active_waves): 

404 all_faces = [face for wave in active_waves for face in iter_faces(wave, theta_current)] 

405 theta_horizon = self.state.theta_horizon 

406 for i, fa in enumerate(all_faces): 

407 for fb in all_faces[i + 1 :]: 

408 if not (isinstance(fa.wave, special_types) or isinstance(fb.wave, special_types)): 

409 continue 

410 same_wave = fa.wave is fb.wave 

411 if same_wave and not ( 

412 isinstance(fa.wave, DoubleFanShockWave) and {fa.role, fb.role} == {"shock", "boundary"} 

413 ): 

414 # Same-wave crossings other than a doubly-fed shock meeting its own fan 

415 # boundary line never occur (a DSW's fan exhaustion has its own exact 

416 # detector; two boundary lines of one wave diverge). 

417 continue 

418 # A doubly-fed shock crossing its OWN fan boundary line is that side's 

419 # exhaustion: the fan between them is spent, and the universal merge 

420 # (const far-bound feeder | surviving fan feeder) degrades the wave to a 

421 # DecayingShockWave while retiring the boundary — uniformly with every 

422 # other face merge. Entropy (λ(c_L) ≥ σ ≥ λ(c_R)) makes these crossings 

423 # the only finite-θ side endings: the left fan's slow (upstream-far) char 

424 # can only catch the shock from behind, the shock can only catch the 

425 # right fan's downstream-far char — exactly the two exposed lines. 

426 crossing = find_face_crossing(fa, fb, theta_current, theta_horizon) 

427 if crossing is None: 

428 continue 

429 theta, v = crossing 

430 if v < -collision_tol or theta > self.state.theta_horizon: 

431 continue 

432 # A born-coincident merge is reported at theta == theta_current (a wave 

433 # created this step is already touching/past a neighbour it must merge 

434 # with). Admit it — resolve_merge deactivates both parents, so it cannot 

435 # re-fire — but keep the strict-future filter for every ordinary crossing 

436 # so a just-processed event is not re-emitted one ULP later. 

437 born_coincident = theta <= theta_current + collision_tol and ( 

438 abs(fa.wave.theta_start - theta_current) <= collision_tol 

439 or abs(fb.wave.theta_start - theta_current) <= collision_tol 

440 ) 

441 if theta <= theta_current + collision_tol and not born_coincident: 

442 continue 

443 candidates.append((theta, counter, EventType.WAVE_MERGE, [fa.wave, fb.wave], v, None, (fa, fb))) 

444 counter += 1 

445 

446 # Fan-exhaustion: a DecayingShockWave is valid only while c_decay stays 

447 # above c_fan_tail. When c_decay reaches c_fan_tail the fan is spent and 

448 # the wave hands off to a regular ShockWave(c_fan_tail, c_fixed). 

449 for wave in active_waves: 

450 if isinstance(wave, DecayingShockWave): 

451 theta_exhaust = wave.theta_at_fan_exhaustion() 

452 if theta_exhaust is None or theta_exhaust <= theta_current + collision_tol: 

453 continue 

454 if theta_exhaust > self.state.theta_horizon: 

455 continue 

456 v_exhaust = wave.position_at_theta(theta_exhaust) 

457 if v_exhaust is None or v_exhaust < -collision_tol: 

458 continue 

459 candidates.append((theta_exhaust, counter, EventType.DSW_FAN_EXHAUSTED, [wave], v_exhaust, None, None)) 

460 counter += 1 

461 

462 v_outlet = self.state.v_outlet 

463 # Same FP-tolerance discipline as events.find_outlet_crossing — prevents 

464 # re-emitting an outlet crossing for a boundary that's at v_outlet ± ULPs. 

465 outlet_tol = 1e-12 * max(abs(v_outlet), 1.0) 

466 

467 for wave in active_waves: 

468 if isinstance(wave, RarefactionWave): 

469 theta_eval = max(theta_current, wave.theta_start) 

470 for c_boundary, pos_fn, speed_fn in ( 

471 (wave.c_head, wave.head_position_at_theta, wave.head_speed), 

472 (wave.c_tail, wave.tail_position_at_theta, wave.tail_speed), 

473 ): 

474 v_pos = pos_fn(theta_eval) 

475 if v_pos is None or v_pos >= v_outlet - outlet_tol: 

476 continue 

477 s = speed_fn() 

478 # Skip a c_min-floored (pinned) boundary — R(c_min) inflated 

479 # for n>1, c→0: its crossing lands at a non-physical θ~1e8 and 

480 # only pollutes the event record. 

481 if s <= 0 or is_outlet_crossing_pinned(c_boundary, wave.sorption): 

482 continue 

483 theta_cross = theta_eval + (v_outlet - v_pos) / s 

484 if theta_cross > theta_current: 

485 candidates.append(( 

486 theta_cross, 

487 counter, 

488 EventType.OUTLET_CROSSING, 

489 [wave], 

490 v_outlet, 

491 None, 

492 None, 

493 )) 

494 counter += 1 

495 else: 

496 theta_cross = find_outlet_crossing(wave, self.state.v_outlet, theta_current) 

497 if theta_cross and theta_cross > theta_current: 

498 candidates.append(( 

499 theta_cross, 

500 counter, 

501 EventType.OUTLET_CROSSING, 

502 [wave], 

503 self.state.v_outlet, 

504 None, 

505 None, 

506 )) 

507 counter += 1 

508 

509 if not candidates: 

510 return None 

511 

512 theta_event, _, event_type, waves, v, extra, faces = min(candidates, key=itemgetter(slice(2))) 

513 

514 raref_types = {EventType.RAREF_CHAR_COLLISION, EventType.SHOCK_RAREF_COLLISION} 

515 boundary_type = extra if event_type in raref_types else None 

516 

517 return Event( 

518 theta=theta_event, 

519 event_type=event_type, 

520 waves_involved=waves, 

521 location=v, 

522 boundary_type=boundary_type, 

523 faces=faces, 

524 ) 

525 

526 def handle_event(self, event: Event): 

527 """Dispatch an event to its handler and record it (with t translated from θ).""" 

528 new_waves: list = [] 

529 

530 if event.event_type == EventType.CHAR_CHAR_COLLISION: 

531 new_waves = handle_characteristic_collision( 

532 event.waves_involved[0], event.waves_involved[1], event.theta, event.location 

533 ) 

534 

535 elif event.event_type == EventType.SHOCK_SHOCK_COLLISION: 

536 new_waves = handle_shock_collision( 

537 event.waves_involved[0], event.waves_involved[1], event.theta, event.location 

538 ) 

539 

540 elif event.event_type == EventType.SHOCK_CHAR_COLLISION: 

541 new_waves = handle_shock_characteristic_collision( 

542 event.waves_involved[0], event.waves_involved[1], event.theta, event.location 

543 ) 

544 

545 elif event.event_type == EventType.RAREF_CHAR_COLLISION: 

546 new_waves = handle_rarefaction_characteristic_collision( 

547 event.waves_involved[0], 

548 event.waves_involved[1], 

549 event.theta, 

550 event.location, 

551 boundary_type=event.boundary_type, 

552 ) 

553 

554 elif event.event_type == EventType.SHOCK_RAREF_COLLISION: 

555 new_waves = handle_shock_rarefaction_collision( 

556 event.waves_involved[0], 

557 event.waves_involved[1], 

558 event.theta, 

559 event.location, 

560 boundary_type=event.boundary_type, 

561 ) 

562 

563 elif event.event_type == EventType.WAVE_MERGE: 

564 assert event.faces is not None # noqa: S101 # WAVE_MERGE always carries its two faces 

565 face_a, face_b = event.faces 

566 new_waves = resolve_merge(face_a, face_b, event.theta, event.location, self.state.sorption) 

567 

568 elif event.event_type == EventType.DSW_FAN_EXHAUSTED: 

569 new_waves = self._handle_fan_exhaustion(event.waves_involved[0], event.theta, event.location) 

570 

571 elif event.event_type == EventType.OUTLET_CROSSING: 

572 # The wave is NOT deactivated: it stays queryable for concentrations 

573 # between its origin and the outlet. 

574 wave = event.waves_involved[0] 

575 self.state.events.append({ 

576 "theta": event.theta, 

577 "type": "outlet_crossing", 

578 "wave": wave, 

579 "location": event.location, 

580 "concentration_left": wave.concentration_left(), 

581 "concentration_right": wave.concentration_right(), 

582 }) 

583 return 

584 

585 self.state.waves.extend(new_waves) 

586 

587 self.state.events.append({ 

588 "theta": event.theta, 

589 "type": event.event_type.value, 

590 "location": event.location, 

591 "waves_before": event.waves_involved, 

592 "waves_after": new_waves, 

593 }) 

594 

595 def _handle_fan_exhaustion(self, dsw: DecayingShockWave, theta_event: float, v_event: float) -> list[Wave]: 

596 """Hand a fan-exhausted decaying shock off to a regular shock. 

597 

598 When ``c_decay`` reaches ``c_fan_tail`` the decaying side is no longer 

599 fed by the fan; the wave continues as a constant-speed 

600 ``ShockWave(c_fan_tail, c_fixed)`` (sides assigned per ``decay_side``). 

601 The handoff is C1-continuous (``dV_s/dθ → S(c_fan_tail, c_fixed)`` = the 

602 spawned shock's speed). The decaying shock is deactivated. 

603 

604 Returns 

605 ------- 

606 list of Wave 

607 ``[ShockWave]`` for the continuation, or ``[]`` if it fails entropy. 

608 """ 

609 if dsw.decay_side == "left": 

610 c_left, c_right = dsw.c_fan_tail, dsw.c_fixed 

611 else: 

612 c_left, c_right = dsw.c_fixed, dsw.c_fan_tail 

613 

614 dsw.deactivate(theta_event) 

615 

616 if abs(c_left - c_right) < EPSILON_CONCENTRATION: 

617 # Fan decayed onto the fixed state — no discontinuity remains. 

618 return [] 

619 

620 shock = ShockWave( 

621 theta_start=theta_event, 

622 v_start=v_event, 

623 c_left=c_left, 

624 c_right=c_right, 

625 sorption=self.state.sorption, 

626 ) 

627 if not shock.satisfies_entropy(): 

628 return [] 

629 return [shock] 

630 

631 def run(self, max_iterations: int = 10000): 

632 """Process events in θ-order until the queue is empty or ``max_iterations`` is reached.""" 

633 iteration = 0 

634 

635 while iteration < max_iterations: 

636 event = self.find_next_event() 

637 

638 if event is None: 

639 break 

640 

641 self.state.theta_current = event.theta 

642 self.handle_event(event) 

643 iteration += 1 

644 

645 if iteration >= max_iterations: 

646 logger.warning("Reached max_iterations=%d", max_iterations) 

647 

648 def verify_physics(self): 

649 """Verify physical correctness: every active shock satisfies Lax entropy. 

650 

651 Mass conservation is intentionally NOT checked here. The closed-form 

652 identity ``m_out(θ) = m_in(θ) − m_dom(θ)`` makes any 

653 ``m_in_domain + m_out_cumulative == m_in_cumulative`` test tautological 

654 (residual identically zero, regardless of any ``compute_domain_mass`` 

655 bug), so it cannot catch a conservation error. The non-tautological, 

656 integral-based conservation check (an independent breakthrough integral 

657 compared to the inlet mass) lives in 

658 :func:`gwtransport.fronttracking.validation.verify_physics` check 7. 

659 

660 Every shock the solver creates is entropy-checked at construction and a 

661 shock's ``(c_left, c_right)`` never change, so this scan is an assertion 

662 for externally assembled wave lists, not a solver self-check. 

663 

664 Raises 

665 ------ 

666 RuntimeError 

667 If an active shock violates the Lax entropy condition. 

668 """ 

669 for wave in self.state.waves: 

670 if isinstance(wave, ShockWave) and wave.is_active and not wave.satisfies_entropy(): 

671 msg = ( 

672 f"Shock at θ_start={wave.theta_start:.3f} violates entropy! " 

673 f"c_left={wave.c_left:.3f}, c_right={wave.c_right:.3f}, " 

674 f"speed={wave.speed:.6g}" 

675 ) 

676 raise RuntimeError(msg) 

677 

678 

679def find_unresolved_interaction(state: FrontTrackerState) -> str | None: 

680 """Tripwire for a solver-left inconsistency in the resolved wave field. 

681 

682 The solver resolves *every* wave interaction (shock↔shock, fan-entry, doubly-fed 

683 formation, same-apex annihilation, and their compositions), so the wave list is 

684 interaction-consistent and the single-owner sweep reader is exact — overlapping fans are 

685 normal and correct, not an error. This is an internal invariant check: the cumulative 

686 outlet mass ``m_out(θ) = m_in(θ) − m_dom(θ)`` must be non-decreasing in θ (mass leaves 

687 the column, it never re-enters). A decrease beyond the FP-cancellation band means the 

688 reader's domain-mass field transiently over-counts stored mass — the fingerprint of an 

689 interaction the solver failed to resolve (a bug). The public API turns a non-``None`` 

690 return into a fail-loud ``RuntimeError`` rather than returning a silently wrong ``cout``. 

691 

692 Parameters 

693 ---------- 

694 state : FrontTrackerState 

695 Completed simulation state (after :meth:`FrontTracker.run`). 

696 

697 Returns 

698 ------- 

699 str or None 

700 A short description (θ and mechanism) of the first monotonicity violation, or 

701 ``None`` when the resolved field conserves mass (the normal case). 

702 

703 Notes 

704 ----- 

705 The scan stays strictly inside the inlet θ-window, so the benign out-of-window saturation 

706 clamp (a run whose output bins extend past the last injected mass) does not trip it. 

707 """ 

708 waves = state.waves 

709 v_outlet = state.v_outlet 

710 theta_hi = float(state.theta_edges[-1]) 

711 thetas = np.linspace(theta_hi / 400.0, theta_hi, 400) 

712 

713 # Conservation symptom: cumulative outlet mass must be monotone non-decreasing. The band 

714 # mirrors ``compute_bin_averaged_concentration_exact``'s FP-cancellation clamp (same 

715 # constant, same ``max(scale, 1.0)`` floor) scaled to the total injected mass, so 

716 # pre-breakthrough cancellation dust stays silent while a genuine over-count (orders of 

717 # magnitude above the band) is caught. 

718 grid = np.concatenate(([0.0], thetas)) 

719 m_out = np.array([ 

720 compute_cumulative_outlet_mass( 

721 float(theta), v_outlet, waves, state.sorption, cin=state.cin, theta_edges=state.theta_edges 

722 ) 

723 for theta in grid 

724 ]) 

725 m_in_total = float(np.sum(state.cin * np.diff(state.theta_edges))) 

726 # Tolerance band: the larger of the FP-cancellation floor and a 1e-6 relative transient 

727 # allowance. The latter absorbs the benign ``c_min``-floor artifact at a fan-entry (the 

728 # decaying side is born at ``c ≈ 1e-12`` rather than exactly 0, perturbing the near-apex 

729 # fan integral by ~1e-3 for one θ-sample). A genuine unresolved interaction over-counts by 

730 # O(a pulse's mass) — orders of magnitude above this band — so the tripwire still fires. 

731 band = max(FP_CANCELLATION_CLAMP * np.finfo(float).eps, 1e-6) * max(m_in_total, 1.0) 

732 # A real over-count is SUSTAINED — m_out stays below its running maximum across a θ-range. 

733 # A single isolated sub-maximum sample is a measure-zero knife-edge at an exact collision θ 

734 # (the parents are deactivated and the successor just born); it does not affect the 

735 # integral-based bin-averaged cout, so require two consecutive samples below the running 

736 # max before flagging. (The actual cout the API returns never sees the knife-edge.) 

737 running_max = np.maximum.accumulate(m_out) 

738 below = m_out < running_max - band 

739 sustained = below[:-1] & below[1:] 

740 if np.any(sustained): 

741 idx = int(np.argmax(sustained)) 

742 drop = float(running_max[idx] - m_out[idx + 1]) 

743 return ( 

744 f"cumulative outlet mass drops by {drop:.4g} near θ={grid[idx + 1]:.4g}" 

745 "(the reader over-counts stored mass there — an interaction the solver failed to resolve)" 

746 ) 

747 return None