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

100 statements  

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

1"""Event detection for front tracking in (V, θ) coordinates. 

2 

3All intersections are pure line/line geometry in the (V, θ) plane because 

4every wave speed dV/dθ is independent of flow. Functions return θ-coordinates 

5of intersections; the solver translates to user-facing t at the API boundary. 

6 

7Events include: 

8 

9- Characteristic-characteristic collisions 

10- Shock-shock collisions 

11- Shock-characteristic collisions 

12- Rarefaction boundary interactions 

13- Outlet crossings 

14 

15All calculations return exact floating-point results with machine precision. 

16The ``theta`` coordinate is cumulative flow [m³]; see 

17:mod:`gwtransport.fronttracking.waves` for the (V, θ) convention. 

18 

19Available functions: 

20 

21- :class:`EventType` - Enumeration of the event kinds the solver dispatches on: characteristic-characteristic, 

22 shock-shock, shock-characteristic, rarefaction-characteristic and shock-rarefaction collisions, decaying-shock 

23 fan exhaustion, wave merges from the face calculus, and outlet crossings. 

24 

25- :class:`Event` - Record of one scheduled event: the θ at which it occurs, its :class:`EventType`, the waves 

26 involved, the position ``location`` [m³], the ``'head'``/``'tail'`` ``boundary_type`` for rarefaction 

27 collisions, and the colliding face pair for merges. It defines no ordering, because the solver orders 

28 ``(theta, counter, ...)`` tuples rather than the objects themselves. 

29 

30- :func:`find_characteristic_intersection` - First strictly-future crossing ``(θ, V)`` of two characteristics, 

31 or ``None`` when their speeds are equal or they never meet after ``theta_current``. Both lines are evaluated 

32 from the shared reference ``max(θ_start_1, θ_start_2, θ_current)``. 

33 

34- :func:`find_shock_shock_intersection` - Same line/line crossing for two shocks, using each shock's constant 

35 Rankine-Hugoniot speed. 

36 

37- :func:`find_shock_characteristic_intersection` - Same for a shock against a characteristic. The operand 

38 order is load-bearing: ``V`` is evaluated on the first line, so it fixes the successor wave's ``v_start`` 

39 bit for bit. 

40 

41- :func:`find_rarefaction_boundary_intersections` - Crossings of a rarefaction's head and tail lines (which 

42 travel at ``1/R(c_head)`` and ``1/R(c_tail)``) with another wave, returned as a list of 

43 ``(θ, V, boundary_type)`` with ``boundary_type`` in ``{'head', 'tail'}``. 

44 

45- :func:`find_outlet_crossing` - Cumulative flow at which a wave reaches ``v_outlet``, assuming positive flow: 

46 a straight-line inverse for characteristics and shocks, and the cached-trajectory inverse 

47 ``outlet_crossing_theta`` for the fan-fed shocks (both the decaying and the doubly-fed one). Returns 

48 ``None`` if the wave is inactive, moves backward, has a pinned characteristic speed, or has already passed 

49 the outlet within a relative tolerance that stops a just-processed crossing from re-firing. Rarefactions 

50 are excluded — their callers split head and tail into separate boundary crossings. 

51 

52- :func:`is_outlet_crossing_pinned` - Whether a boundary state sits on the ``c_min`` retardation floor with an 

53 inflated ``R(c_min)``, so that its scheduled outlet crossing is a floor artifact rather than physics and the 

54 caller should drop it. 

55""" 

56 

57from dataclasses import dataclass 

58from enum import Enum 

59 

60from gwtransport.fronttracking.math import characteristic_position, characteristic_speed 

61from gwtransport.fronttracking.waves import ( 

62 CharacteristicWave, 

63 DecayingShockWave, 

64 DoubleFanShockWave, 

65 RarefactionWave, 

66 ShockWave, 

67) 

68 

69# Numerical tolerance constants 

70EPSILON_SPEED = 1e-15 # Tolerance for checking if two speeds are equal (machine precision) 

71# A boundary state at/below the c_min retardation floor whose floored retardation 

72# exceeds this value is "pinned": for the n>1 dry-soil singularity R(c_min) is 

73# inflated to ~1e6, so the state advects orders of magnitude slower than any 

74# physical wave and its outlet crossing lands at a non-physical θ (~1e8) that only 

75# pollutes the diagnostic event record. n<1 clean water (R(c_min) ≈ 1, fast) stays 

76# well below this threshold and is NOT pinned, so its outlet crossing is kept. 

77OUTLET_PIN_RETARDATION = 1e4 

78 

79 

80def is_outlet_crossing_pinned(concentration: float, sorption) -> bool: 

81 """Whether a boundary state is pinned by the ``c_min`` retardation floor. 

82 

83 A crossing scheduled for such a state is a non-physical artifact (its speed is 

84 a floor artifact, not physics); the caller drops it so it does not pollute the 

85 solver's event record / ``theta_current``. 

86 

87 Parameters 

88 ---------- 

89 concentration : float 

90 Boundary-state concentration [mass/volume]. 

91 sorption : SorptionModel 

92 Sorption model (supplies ``c_min`` and ``retardation``). 

93 

94 Returns 

95 ------- 

96 bool 

97 ``True`` only when ``concentration`` is at/below ``c_min`` AND the floored 

98 retardation ``R(c_min)`` is inflated past :data:`OUTLET_PIN_RETARDATION`. 

99 """ 

100 c_min = getattr(sorption, "c_min", 0.0) 

101 if concentration > c_min: 

102 return False 

103 return float(sorption.retardation(c_min)) > OUTLET_PIN_RETARDATION 

104 

105 

106class EventType(Enum): 

107 """All possible event types in front tracking simulation.""" 

108 

109 CHAR_CHAR_COLLISION = "characteristic_collision" 

110 """Two characteristics intersect (will form shock).""" 

111 SHOCK_SHOCK_COLLISION = "shock_collision" 

112 """Two shocks collide (will merge).""" 

113 SHOCK_CHAR_COLLISION = "shock_characteristic_collision" 

114 """Shock catches or is caught by characteristic.""" 

115 RAREF_CHAR_COLLISION = "rarefaction_characteristic_collision" 

116 """Rarefaction boundary intersects with characteristic.""" 

117 SHOCK_RAREF_COLLISION = "shock_rarefaction_collision" 

118 """Shock intersects with rarefaction boundary.""" 

119 DSW_FAN_EXHAUSTED = "decaying_shock_fan_exhausted" 

120 """A decaying shock's fan is exhausted (c_decay reached c_fan_tail).""" 

121 WAVE_MERGE = "wave_merge" 

122 """Two faces overtake (universal merge): any interaction involving a decaying/doubly-fed 

123 shock — fan-entry, doubly-fed formation, same-apex annihilation, side exhaustion (a 

124 doubly-fed shock crossing its own fan boundary line), and their compositions.""" 

125 OUTLET_CROSSING = "outlet_crossing" 

126 """Wave crosses outlet boundary.""" 

127 

128 

129@dataclass 

130class Event: 

131 """A single event in the simulation, ordered by cumulative flow θ. 

132 

133 The solver's priority queue orders ``(theta, counter, ...)`` tuples, not 

134 ``Event`` objects, so this dataclass intentionally defines no ordering. 

135 

136 Parameters 

137 ---------- 

138 theta : float 

139 Cumulative flow at which the event occurs [m³]. 

140 event_type : EventType 

141 Type of event. 

142 waves_involved : list 

143 List of wave objects involved in this event. 

144 location : float 

145 Volumetric position at which the event occurs [m³]. 

146 boundary_type : str or None 

147 Which rarefaction boundary collided: ``'head'`` or ``'tail'``. 

148 Set for rarefaction collision events. 

149 """ 

150 

151 theta: float 

152 event_type: EventType 

153 waves_involved: list # List[Wave] - can't type hint due to circular import 

154 location: float 

155 boundary_type: str | None = None 

156 faces: tuple | None = None # (Face, Face) for WAVE_MERGE 

157 

158 def __repr__(self): # noqa: D105 

159 return ( 

160 f"Event(θ={self.theta:.3f}, type={self.event_type.value}, " 

161 f"location={self.location:.3f}, n_waves={len(self.waves_involved)})" 

162 ) 

163 

164 

165def _line_intersection( 

166 theta_start_a: float, 

167 v_start_a: float, 

168 speed_a: float, 

169 theta_start_b: float, 

170 v_start_b: float, 

171 speed_b: float, 

172 theta_current: float, 

173) -> tuple[float, float] | None: 

174 """First future crossing of two straight (V, θ) fronts, or ``None``. 

175 

176 Every characteristic/shock/rarefaction-boundary travels at a flow-free constant 

177 speed, so each is a line ``V = v_start + speed·(θ − θ_start)``. Both are evaluated 

178 from the shared reference ``θ_both = max(θ_start_a, θ_start_b, θ_current)`` and the 

179 crossing is returned only when strictly in the future (``dθ > 0``). 

180 

181 ``V_intersect`` is evaluated on line ``a``: the ``dθ`` is invariant under an a↔b swap 

182 (exact IEEE negation of numerator and denominator) but ``V_intersect`` is not, so the 

183 operand order each public wrapper passes is load-bearing for bit-reproducibility. 

184 """ 

185 if abs(speed_a - speed_b) < EPSILON_SPEED: 

186 return None 

187 theta_both = max(theta_start_a, theta_start_b, theta_current) 

188 v_a = v_start_a + speed_a * (theta_both - theta_start_a) 

189 v_b = v_start_b + speed_b * (theta_both - theta_start_b) 

190 dtheta = (v_b - v_a) / (speed_a - speed_b) 

191 if dtheta <= 0: 

192 return None 

193 return (theta_both + dtheta, v_a + speed_a * dtheta) 

194 

195 

196def find_characteristic_intersection(char1, char2, theta_current: float) -> tuple[float, float] | None: 

197 """Find exact analytical intersection of two characteristics in (V, θ). 

198 

199 Returns (θ_intersect, V_intersect) if the intersection lies in the future 

200 (θ > θ_current) and both characteristics are active there; otherwise None. 

201 """ 

202 return _line_intersection( 

203 char1.theta_start, char1.v_start, char1.speed(), char2.theta_start, char2.v_start, char2.speed(), theta_current 

204 ) 

205 

206 

207def find_shock_shock_intersection(shock1, shock2, theta_current: float) -> tuple[float, float] | None: 

208 """Find exact analytical intersection of two shocks in (V, θ).""" 

209 return _line_intersection( 

210 shock1.theta_start, 

211 shock1.v_start, 

212 shock1.speed, 

213 shock2.theta_start, 

214 shock2.v_start, 

215 shock2.speed, 

216 theta_current, 

217 ) 

218 

219 

220def find_shock_characteristic_intersection(shock, char, theta_current: float) -> tuple[float, float] | None: 

221 """Find exact analytical intersection of a shock and a characteristic in (V, θ).""" 

222 return _line_intersection( 

223 shock.theta_start, shock.v_start, shock.speed, char.theta_start, char.v_start, char.speed(), theta_current 

224 ) 

225 

226 

227def find_rarefaction_boundary_intersections(raref, other_wave, theta_current: float) -> list[tuple[float, float, str]]: 

228 """Intersections of a rarefaction's head/tail with another wave. 

229 

230 Both rarefaction boundaries propagate at characteristic speeds (head at 

231 ``1/R(c_head)``, tail at ``1/R(c_tail)``), so each is a straight (V, θ) line 

232 fed directly into :func:`_line_intersection` — no temporary ``CharacteristicWave`` 

233 objects. The operand order is per-branch: ``a`` is the raref boundary against a 

234 characteristic, but the SHOCK against a raref boundary, so ``V_intersect`` — the new 

235 wave's ``v_start`` — matches :func:`find_shock_characteristic_intersection` bit for bit. 

236 

237 Returns 

238 ------- 

239 list of tuple 

240 ``(θ_intersect, V_intersect, boundary_type)`` for each intersection, 

241 where boundary_type is ``'head'`` or ``'tail'``. 

242 """ 

243 intersections = [] 

244 raref_boundaries = ((raref.head_speed(), "head"), (raref.tail_speed(), "tail")) 

245 

246 if isinstance(other_wave, CharacteristicWave): 

247 for s_raref, tag in raref_boundaries: 

248 hit = _line_intersection( 

249 raref.theta_start, 

250 raref.v_start, 

251 s_raref, 

252 other_wave.theta_start, 

253 other_wave.v_start, 

254 other_wave.speed(), 

255 theta_current, 

256 ) 

257 if hit: 

258 intersections.append((hit[0], hit[1], tag)) 

259 

260 elif isinstance(other_wave, ShockWave): 

261 # a=SHOCK, b=raref boundary (matches find_shock_characteristic_intersection's order). 

262 for s_raref, tag in raref_boundaries: 

263 hit = _line_intersection( 

264 other_wave.theta_start, 

265 other_wave.v_start, 

266 other_wave.speed, 

267 raref.theta_start, 

268 raref.v_start, 

269 s_raref, 

270 theta_current, 

271 ) 

272 if hit: 

273 intersections.append((hit[0], hit[1], tag)) 

274 

275 elif isinstance(other_wave, RarefactionWave): 

276 other_boundaries = (other_wave.head_speed(), other_wave.tail_speed()) 

277 for s_raref, tag in raref_boundaries: 

278 for s_other in other_boundaries: 

279 hit = _line_intersection( 

280 raref.theta_start, 

281 raref.v_start, 

282 s_raref, 

283 other_wave.theta_start, 

284 other_wave.v_start, 

285 s_other, 

286 theta_current, 

287 ) 

288 if hit: 

289 intersections.append((hit[0], hit[1], tag)) 

290 

291 return intersections 

292 

293 

294def find_outlet_crossing(wave, v_outlet: float, theta_current: float) -> float | None: 

295 """Find the cumulative flow θ at which the wave crosses ``v_outlet``. 

296 

297 Handles ``CharacteristicWave``, ``ShockWave``, and ``DecayingShockWave``. 

298 Rarefaction outlet crossings are handled by the callers directly (the 

299 solver and ``output.py`` split them into head/tail boundary crossings), so 

300 a ``RarefactionWave`` never reaches this function and returns ``None``. 

301 

302 Assumes positive flow (waves always move toward larger V). Returns None if 

303 the wave has already passed the outlet, is not active, or moves backward. 

304 The "already past" check uses a relative tolerance so that a wave whose 

305 crossing event has just been processed (and is at v_outlet ± a few ULPs) 

306 does not re-emit a duplicate crossing one ULP later. 

307 """ 

308 if not wave.is_active: 

309 return None 

310 

311 # Suppress re-emission when v_current is within FP of v_outlet: the 

312 # crossing was already recorded on the prior iteration. 

313 tol = 1e-12 * max(abs(v_outlet), abs(wave.v_start), 1.0) 

314 

315 if isinstance(wave, CharacteristicWave): 

316 theta_eval = max(theta_current, wave.theta_start) 

317 v_current = characteristic_position( 

318 wave.concentration, wave.sorption, wave.theta_start, wave.v_start, theta_eval 

319 ) 

320 

321 if v_current is None or v_current >= v_outlet - tol: 

322 return None 

323 

324 speed = characteristic_speed(wave.concentration, wave.sorption) 

325 

326 # A c_min-floored (pinned) characteristic — R(c_min) inflated for n>1, 

327 # c→0 — advects too slowly to cross at any physical θ; suppress the 

328 # artifact crossing rather than scheduling it at θ~1e8. 

329 if speed <= 0 or is_outlet_crossing_pinned(wave.concentration, wave.sorption): 

330 return None 

331 

332 dtheta = (v_outlet - v_current) / speed 

333 return theta_eval + dtheta 

334 

335 if isinstance(wave, ShockWave): 

336 theta_eval = max(theta_current, wave.theta_start) 

337 v_current = wave.v_start + wave.speed * (theta_eval - wave.theta_start) 

338 

339 if v_current >= v_outlet - tol: 

340 return None 

341 

342 if wave.speed <= 0: 

343 return None 

344 

345 dtheta = (v_outlet - v_current) / wave.speed 

346 return theta_eval + dtheta 

347 

348 if isinstance(wave, (DecayingShockWave, DoubleFanShockWave)): 

349 # Closed-form / cached-trajectory inverse V_s(theta) = v_outlet. 

350 theta_cross = wave.outlet_crossing_theta(v_outlet) 

351 if theta_cross is None: 

352 return None 

353 # Suppress re-emission within FP of the prior crossing (same convention 

354 # as the linear-shock branch above). 

355 if theta_cross <= theta_current + 1e-15 * max(abs(theta_current), 1.0): 

356 return None 

357 return theta_cross 

358 

359 return None