Coverage for src/gwtransport/fronttracking/handlers.py: 78%

120 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +0000

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

2 

3Each handler receives the waves involved in an event and returns the new 

4waves created by the interaction. In (V, θ) coordinates every wave speed is 

5flow-free, so handlers depend only on concentrations and the sorption 

6isotherm — flow does not appear. 

7 

8All handlers enforce physical correctness: 

9 

10- Mass conservation (Rankine-Hugoniot condition) 

11- Entropy conditions (Lax condition for shocks) 

12- Causality (no backward-traveling information) 

13 

14Handlers modify wave states in-place by deactivating parent waves and 

15creating new child waves. 

16""" 

17 

18from gwtransport.fronttracking.math import ( 

19 FreundlichSorption, 

20 NonlinearSorption, 

21 SorptionModel, 

22 characteristic_speed, 

23) 

24from gwtransport.fronttracking.waves import CharacteristicWave, DecayingShockWave, RarefactionWave, ShockWave 

25 

26# Numerical tolerance constants 

27# Shared single source for the negligible-concentration-change tolerance; the 

28# solver imports this rather than redefining it. 

29EPSILON_CONCENTRATION = 1e-15 

30 

31 

32def handle_characteristic_collision( 

33 char1: CharacteristicWave, 

34 char2: CharacteristicWave, 

35 theta_event: float, 

36 v_event: float, 

37) -> list[ShockWave]: 

38 """Two characteristics collide → emit a shock. 

39 

40 The faster characteristic catches the slower one from behind. By the 

41 entropy condition this compressive interaction is always a shock, 

42 independently of the sorption regime (Freundlich n>1, n<1, or constant 

43 retardation). 

44 

45 Parameters 

46 ---------- 

47 char1, char2 : CharacteristicWave 

48 Colliding characteristics. 

49 theta_event : float 

50 Cumulative flow at which the collision occurs [m³]. 

51 v_event : float 

52 Position at which the collision occurs [m³]. 

53 

54 Returns 

55 ------- 

56 list of ShockWave 

57 Single shock created at the collision point. 

58 

59 Raises 

60 ------ 

61 RuntimeError 

62 If the resulting shock fails the Lax entropy condition. 

63 """ 

64 s1 = characteristic_speed(char1.concentration, char1.sorption) 

65 s2 = characteristic_speed(char2.concentration, char2.sorption) 

66 

67 if s1 > s2: 

68 c_left = char1.concentration 

69 c_right = char2.concentration 

70 else: 

71 c_left = char2.concentration 

72 c_right = char1.concentration 

73 

74 shock = ShockWave( 

75 theta_start=theta_event, 

76 v_start=v_event, 

77 c_left=c_left, 

78 c_right=c_right, 

79 sorption=char1.sorption, 

80 ) 

81 

82 if not shock.satisfies_entropy(): 

83 msg = ( 

84 f"Characteristic collision created non-entropic shock at θ={theta_event:.3f}, " 

85 f"V={v_event:.3f}. c_left={c_left:.3f}, c_right={c_right:.3f}, " 

86 f"shock_speed={shock.speed:.6g}" 

87 ) 

88 raise RuntimeError(msg) 

89 

90 char1.deactivate(theta_event) 

91 char2.deactivate(theta_event) 

92 return [shock] 

93 

94 

95def handle_shock_collision( 

96 shock1: ShockWave, 

97 shock2: ShockWave, 

98 theta_event: float, 

99 v_event: float, 

100) -> list[ShockWave]: 

101 """Two shocks collide → merge into a single shock connecting outer states. 

102 

103 The merged shock has ``c_left`` from the faster (upstream) shock, 

104 ``c_right`` from the slower (downstream) shock; its speed is recomputed 

105 via Rankine-Hugoniot. 

106 

107 Parameters 

108 ---------- 

109 shock1, shock2 : ShockWave 

110 Colliding shocks. 

111 theta_event, v_event : float 

112 Cumulative flow [m³] and position [m³] of the collision. 

113 

114 Returns 

115 ------- 

116 list of ShockWave 

117 Single merged shock. 

118 

119 Raises 

120 ------ 

121 RuntimeError 

122 If the merged shock violates the entropy condition. 

123 """ 

124 if shock1.speed > shock2.speed: 

125 c_left = shock1.c_left 

126 c_right = shock2.c_right 

127 else: 

128 c_left = shock2.c_left 

129 c_right = shock1.c_right 

130 

131 merged = ShockWave( 

132 theta_start=theta_event, 

133 v_start=v_event, 

134 c_left=c_left, 

135 c_right=c_right, 

136 sorption=shock1.sorption, 

137 ) 

138 

139 if not merged.satisfies_entropy(): 

140 msg = ( 

141 f"Shock merger created non-entropic shock at θ={theta_event:.3f}. " 

142 f"This may indicate complex wave interaction requiring special handling." 

143 ) 

144 raise RuntimeError(msg) 

145 

146 shock1.deactivate(theta_event) 

147 shock2.deactivate(theta_event) 

148 

149 return [merged] 

150 

151 

152def handle_shock_characteristic_collision( 

153 shock: ShockWave, 

154 char: CharacteristicWave, 

155 theta_event: float, 

156 v_event: float, 

157) -> list: 

158 """Shock catches or is caught by a characteristic. 

159 

160 The characteristic concentration modifies one side of the shock: 

161 

162 - Shock catches char (shock faster): modifies ``c_right``. 

163 - Char catches shock (char faster): modifies ``c_left``. 

164 

165 If the resulting shock satisfies entropy it is emitted (compression); 

166 otherwise a rarefaction is created (expansion) to preserve mass balance. 

167 """ 

168 s_shock = shock.speed 

169 s_char = characteristic_speed(char.concentration, char.sorption) 

170 

171 if s_shock > s_char: 

172 new_shock = ShockWave( 

173 theta_start=theta_event, 

174 v_start=v_event, 

175 c_left=shock.c_left, 

176 c_right=char.concentration, 

177 sorption=shock.sorption, 

178 ) 

179 else: 

180 new_shock = ShockWave( 

181 theta_start=theta_event, 

182 v_start=v_event, 

183 c_left=char.concentration, 

184 c_right=shock.c_right, 

185 sorption=shock.sorption, 

186 ) 

187 

188 if not new_shock.satisfies_entropy(): 

189 # Expansion regime: emit a rarefaction whose head is the faster 

190 # state and tail the slower state. 

191 if s_shock > s_char: 

192 c_head = shock.c_left 

193 c_tail = char.concentration 

194 else: 

195 c_head = char.concentration 

196 c_tail = shock.c_right 

197 

198 s_head = characteristic_speed(c_head, shock.sorption) 

199 s_tail = characteristic_speed(c_tail, shock.sorption) 

200 

201 if s_head > s_tail: 

202 raref = RarefactionWave( 

203 theta_start=theta_event, 

204 v_start=v_event, 

205 c_head=c_head, 

206 c_tail=c_tail, 

207 sorption=shock.sorption, 

208 ) 

209 shock.deactivate(theta_event) 

210 char.deactivate(theta_event) 

211 return [raref] 

212 # Edge case (s_head == s_tail within machine precision): deactivate 

213 # and emit nothing. 

214 shock.deactivate(theta_event) 

215 char.deactivate(theta_event) 

216 return [] 

217 

218 shock.deactivate(theta_event) 

219 char.deactivate(theta_event) 

220 return [new_shock] 

221 

222 

223def handle_shock_rarefaction_collision( 

224 shock: ShockWave, 

225 raref: RarefactionWave, 

226 theta_event: float, 

227 v_event: float, 

228 boundary_type: str | None, 

229) -> list: 

230 """Shock interacts with a rarefaction fan (tail or head boundary). 

231 

232 Every shock↔rarefaction collision is resolved exactly by a single 

233 :class:`~gwtransport.fronttracking.waves.DecayingShockWave` whose trajectory 

234 subsumes the fan + shock together, for any 

235 :class:`~gwtransport.fronttracking.math.NonlinearSorption`: 

236 

237 - **Head collision** (rarefaction head catches the leading shock): the 

238 decaying side is the left, ``c_decay_initial = raref.c_head``, 

239 ``c_fixed = shock.c_right``, and ``c_fan_tail = raref.c_tail`` (the fan's 

240 other boundary, which bounds the decay so partial drying is handled). 

241 - **Tail collision** (trailing shock catches the rarefaction tail): the 

242 decaying side is the right, ``c_decay_initial = raref.c_tail``, 

243 ``c_fixed = shock.c_left``, and ``c_fan_tail = raref.c_head``. 

244 

245 The fan is bounded by ``c_fan_tail``: the solver's ``DSW_FAN_EXHAUSTED`` 

246 event spawns a regular shock once the decaying side reaches it, so partial 

247 drying (``raref.c_tail != shock.c_right``) is resolved exactly. If the 

248 rarefaction boundary is not faster than the shock (degenerate solver/test 

249 input), both waves are deactivated and nothing is emitted. 

250 

251 Returns 

252 ------- 

253 list of Wave 

254 ``[DecayingShockWave]`` for a physical collision, or ``[]`` for 

255 degenerate input. 

256 """ 

257 sorption = raref.sorption 

258 # Rarefactions only form for nonlinear isotherms, so the DecayingShockWave's 

259 # NonlinearSorption requirement is always met here. 

260 assert isinstance(sorption, NonlinearSorption) # noqa: S101 

261 

262 if boundary_type == "head": 

263 # Rarefaction head catches the shock; decaying side is the left. 

264 s_raref_boundary = characteristic_speed(raref.c_head, sorption) 

265 if s_raref_boundary <= shock.speed: 

266 shock.deactivate(theta_event) 

267 raref.deactivate(theta_event) 

268 return [] 

269 c_decay_initial = raref.c_head 

270 c_fixed = shock.c_right 

271 c_fan_tail = raref.c_tail 

272 decay_side = "left" 

273 elif boundary_type == "tail": 

274 # Trailing shock catches the rarefaction tail; decaying side is the right. 

275 s_raref_boundary = characteristic_speed(raref.c_tail, sorption) 

276 if shock.speed <= s_raref_boundary: 

277 shock.deactivate(theta_event) 

278 raref.deactivate(theta_event) 

279 return [] 

280 c_decay_initial = raref.c_tail 

281 c_fixed = shock.c_left 

282 c_fan_tail = raref.c_head 

283 decay_side = "right" 

284 else: 

285 msg = f"handle_shock_rarefaction_collision: unknown boundary_type {boundary_type!r}" 

286 raise RuntimeError(msg) 

287 

288 decaying = DecayingShockWave( 

289 theta_start=theta_event, 

290 v_start=v_event, 

291 c_decay_initial=c_decay_initial, 

292 c_fixed=c_fixed, 

293 c_fan_tail=c_fan_tail, 

294 decay_side=decay_side, 

295 v_origin=raref.v_start, 

296 theta_origin=raref.theta_start, 

297 sorption=sorption, 

298 ) 

299 

300 shock.deactivate(theta_event) 

301 raref.deactivate(theta_event) 

302 return [decaying] 

303 

304 

305def handle_rarefaction_characteristic_collision( 

306 raref: RarefactionWave, 

307 char: CharacteristicWave, 

308 theta_event: float, 

309 v_event: float, 

310 boundary_type: str | None, 

311) -> list: 

312 """Rarefaction boundary intersects a characteristic. 

313 

314 The safe option (b) from the front-tracking rebuild plan: when a 

315 characteristic's concentration matches the boundary concentration to 

316 within tolerance the characteristic is absorbed; otherwise an 

317 informative ``RuntimeError`` is raised because deactivating it would 

318 silently destroy mass. 

319 

320 Raises 

321 ------ 

322 RuntimeError 

323 If the characteristic's concentration does not match the colliding 

324 rarefaction boundary concentration within tolerance, or if 

325 ``boundary_type`` is not ``'head'`` or ``'tail'``. 

326 """ 

327 rel_tol = 1e-9 

328 abs_tol = 1e-12 

329 raref_range = abs(raref.c_head - raref.c_tail) 

330 tol = max(rel_tol * raref_range, abs_tol) 

331 

332 if boundary_type == "head": 

333 boundary_c = raref.c_head 

334 elif boundary_type == "tail": 

335 boundary_c = raref.c_tail 

336 else: 

337 msg = f"handle_rarefaction_characteristic_collision: unknown boundary_type {boundary_type!r}" 

338 raise RuntimeError(msg) 

339 

340 if abs(char.concentration - boundary_c) > tol: 

341 msg = ( 

342 f"Rarefaction-characteristic collision at θ={theta_event:.6f}, V={v_event:.6f} would silently " 

343 f"destroy mass: characteristic concentration {char.concentration:.6g} differs from " 

344 f"rarefaction {boundary_type} concentration {boundary_c:.6g} by " 

345 f"{abs(char.concentration - boundary_c):.3g} (tolerance {tol:.3g}). " 

346 f"Proper wave splitting at the rarefaction boundary is required for this case." 

347 ) 

348 raise RuntimeError(msg) 

349 

350 char.deactivate(theta_event) 

351 return [] 

352 

353 

354def handle_outlet_crossing(wave, theta_event: float, v_outlet: float) -> dict: 

355 """Record a wave crossing the outlet boundary. 

356 

357 The wave is NOT deactivated — it remains for concentration queries at 

358 points between its origin and the outlet. The returned event record 

359 holds the cumulative flow ``theta`` at which the crossing occurs; the 

360 solver translates this to the user-facing time when appending to 

361 ``state.events``. 

362 """ 

363 return { 

364 "theta": theta_event, 

365 "type": "outlet_crossing", 

366 "wave": wave, 

367 "location": v_outlet, 

368 "concentration_left": wave.concentration_left(), 

369 "concentration_right": wave.concentration_right(), 

370 } 

371 

372 

373def create_inlet_waves_at_theta( 

374 c_prev: float, 

375 c_new: float, 

376 theta: float, 

377 sorption: SorptionModel, 

378) -> list: 

379 """Emit the wave produced by a step change in inlet concentration. 

380 

381 All inlet waves originate at the inlet face ``V = 0``. Wave type is 

382 determined by characteristic speed comparison in (V, θ): 

383 

384 - ``s_new > s_prev``: compression → shock. 

385 - ``s_new < s_prev``: expansion → rarefaction. 

386 - equal: contact discontinuity → characteristic. 

387 

388 For shocks the entropy condition is verified; if violated, an empty list 

389 is returned (mass balance may be affected — a known limitation handled 

390 by ``DecayingShockWave``). 

391 """ 

392 if abs(c_new - c_prev) < EPSILON_CONCENTRATION: 

393 return [] 

394 

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

396 is_n_lt_1 = isinstance(sorption, FreundlichSorption) and sorption.n < 1.0 

397 

398 # n<1, c_prev=0 or c_new=0: emit a single CharacteristicWave; clean water 

399 # has a well-defined speed since R(0)=1. 

400 if (c_prev <= c_min or c_new <= c_min) and is_n_lt_1 and c_min == 0: 

401 return [ 

402 CharacteristicWave( 

403 theta_start=theta, 

404 v_start=0.0, 

405 concentration=c_new, 

406 sorption=sorption, 

407 ) 

408 ] 

409 

410 s_prev = characteristic_speed(c_prev, sorption) 

411 s_new = characteristic_speed(c_new, sorption) 

412 

413 if s_new > s_prev + 1e-15: 

414 shock = ShockWave( 

415 theta_start=theta, 

416 v_start=0.0, 

417 c_left=c_new, 

418 c_right=c_prev, 

419 sorption=sorption, 

420 ) 

421 if not shock.satisfies_entropy(): 

422 return [] 

423 return [shock] 

424 

425 if s_new < s_prev - 1e-15: 

426 return [ 

427 RarefactionWave( 

428 theta_start=theta, 

429 v_start=0.0, 

430 c_head=c_prev, 

431 c_tail=c_new, 

432 sorption=sorption, 

433 ) 

434 ] 

435 

436 return [ 

437 CharacteristicWave( 

438 theta_start=theta, 

439 v_start=0.0, 

440 concentration=c_new, 

441 sorption=sorption, 

442 ) 

443 ]