Coverage for src / gwtransport / fronttracking / plot.py: 75%

337 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-27 06:32 +0000

1""" 

2Visualization functions for front tracking. 

3 

4This module provides plotting utilities for visualizing front-tracking simulations: 

5- V-t diagrams showing wave propagation in space-time 

6- Breakthrough curves showing concentration at outlet over time 

7 

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

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

10""" 

11 

12import matplotlib.pyplot as plt 

13import numpy as np 

14import pandas as pd 

15 

16from gwtransport.fronttracking.output import concentration_at_point, identify_outlet_segments 

17from gwtransport.fronttracking.solver import FrontTrackerState 

18from gwtransport.fronttracking.waves import CharacteristicWave, RarefactionWave, ShockWave 

19from gwtransport.utils import step_plot_coords 

20 

21 

22def plot_vt_diagram( 

23 state: FrontTrackerState, 

24 ax=None, 

25 t_max: float | None = None, 

26 figsize: tuple[float, float] = (14, 10), 

27 *, 

28 show_inactive: bool = False, 

29 show_events: bool = False, 

30): 

31 """ 

32 Create V-t diagram showing all waves in space-time. 

33 

34 Plots characteristics (blue lines), shocks (red lines), and rarefactions 

35 (green fans) in the (time, position) plane. This visualization shows how 

36 waves propagate and interact throughout the simulation. 

37 

38 Parameters 

39 ---------- 

40 state : FrontTrackerState 

41 Complete simulation state containing all waves. 

42 ax : matplotlib.axes.Axes, optional 

43 Existing axes to plot into. If None, a new figure and axes are created 

44 using ``figsize``. 

45 t_max : float, optional 

46 Maximum time to plot [days]. If None, uses final simulation time * 1.2. 

47 figsize : tuple of float, optional 

48 Figure size in inches (width, height). Default (14, 10). 

49 show_inactive : bool, optional 

50 Whether to show inactive waves (deactivated by interactions). 

51 Default False. 

52 show_events : bool, optional 

53 Whether to show wave interaction events as markers. 

54 Default False. 

55 

56 Returns 

57 ------- 

58 fig : matplotlib.figure.Figure 

59 Figure object containing the V-t diagram. 

60 

61 Notes 

62 ----- 

63 - Characteristics appear as blue lines (constant velocity). 

64 - Shocks appear as thick red lines (jump discontinuities). 

65 - Rarefactions appear as green fans (smooth transition regions). 

66 - Outlet position is shown as a horizontal dashed line. 

67 - Only waves within domain [0, v_outlet] are plotted. 

68 

69 Examples 

70 -------- 

71 :: 

72 

73 from gwtransport.fronttracking.solver import FrontTracker 

74 

75 tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption) 

76 tracker.run() 

77 fig = plot_vt_diagram(tracker.state) 

78 fig.savefig("vt_diagram.png") 

79 """ 

80 if t_max is None: 

81 # Default to input data time range instead of simulation end time 

82 # Convert tedges[-1] from Timestamp to days from tedges[0] 

83 t_max = float((state.tedges[-1] - state.tedges[0]) / pd.Timedelta(days=1)) 

84 

85 if ax is None: 

86 _, ax = plt.subplots(figsize=figsize) 

87 

88 char_labeled = False 

89 shock_labeled = False 

90 raref_labeled = False 

91 event_labeled = False 

92 

93 # Plot characteristics (blue lines) 

94 for wave in state.waves: 

95 if isinstance(wave, CharacteristicWave): 

96 if not wave.is_active and not show_inactive: 

97 continue 

98 

99 t_plot = np.linspace(wave.t_start, t_max, 100) 

100 v_plot = [] 

101 t_plot_used = [] 

102 

103 for t in t_plot: 

104 # Compute position manually for inactive waves when show_inactive=True 

105 v = wave.position_at_time(t) if wave.is_active else wave.v_start + wave.velocity() * (t - wave.t_start) 

106 

107 if v is not None and 0 <= v <= state.v_outlet: 

108 v_plot.append(v) 

109 t_plot_used.append(t) 

110 elif v is not None and v > state.v_outlet: 

111 # Wave crossed outlet - add exact intersection point 

112 vel = wave.velocity() 

113 if vel > 0: 

114 t_outlet = wave.t_start + (state.v_outlet - wave.v_start) / vel 

115 if wave.t_start <= t_outlet <= t_max: 

116 v_plot.append(state.v_outlet) 

117 t_plot_used.append(t_outlet) 

118 break 

119 else: 

120 break 

121 

122 if len(v_plot) > 0: 

123 alpha = 0.3 if not wave.is_active else 0.7 

124 ax.plot( 

125 t_plot_used, 

126 v_plot, 

127 "b-", 

128 linewidth=0.5, 

129 alpha=alpha, 

130 label="Characteristic" if not char_labeled else "", 

131 ) 

132 char_labeled = True 

133 

134 # Plot shocks (red lines) 

135 for wave in state.waves: 

136 if isinstance(wave, ShockWave): 

137 if not wave.is_active and not show_inactive: 

138 continue 

139 

140 t_plot = np.linspace(wave.t_start, t_max, 100) 

141 v_plot = [] 

142 t_plot_used = [] 

143 

144 for t in t_plot: 

145 # Compute position manually for inactive waves when show_inactive=True 

146 v = wave.position_at_time(t) if wave.is_active else wave.v_start + wave.velocity * (t - wave.t_start) 

147 

148 if v is not None and 0 <= v <= state.v_outlet: 

149 v_plot.append(v) 

150 t_plot_used.append(t) 

151 elif v is not None and v > state.v_outlet: 

152 # Wave crossed outlet - add exact intersection point 

153 vel = wave.velocity 

154 if vel is not None and vel > 0: 

155 t_outlet = wave.t_start + (state.v_outlet - wave.v_start) / vel 

156 if wave.t_start <= t_outlet <= t_max: 

157 v_plot.append(state.v_outlet) 

158 t_plot_used.append(t_outlet) 

159 break 

160 else: 

161 break 

162 

163 if len(v_plot) > 0: 

164 alpha = 0.5 if not wave.is_active else 1.0 

165 ax.plot( 

166 t_plot_used, 

167 v_plot, 

168 "r-", 

169 linewidth=2, 

170 alpha=alpha, 

171 label="Shock" if not shock_labeled else "", 

172 ) 

173 shock_labeled = True 

174 

175 # Plot rarefactions (green fans) 

176 for wave in state.waves: 

177 if isinstance(wave, RarefactionWave): 

178 if not wave.is_active and not show_inactive: 

179 continue 

180 

181 t_plot = np.linspace(wave.t_start, t_max, 100) 

182 v_head_plot = [] 

183 v_tail_plot = [] 

184 t_plot_used = [] 

185 head_crossed = False 

186 tail_crossed = False 

187 

188 for t in t_plot: 

189 # Compute positions manually for inactive waves when show_inactive=True 

190 if wave.is_active: 

191 v_head = wave.head_position_at_time(t) 

192 v_tail = wave.tail_position_at_time(t) 

193 else: 

194 # Manually compute positions for visualization 

195 v_head = wave.v_start + wave.head_velocity() * (t - wave.t_start) 

196 v_tail = wave.v_start + wave.tail_velocity() * (t - wave.t_start) 

197 

198 # Track time points 

199 t_plot_used.append(t) 

200 

201 # Handle head 

202 if v_head is not None and 0 <= v_head <= state.v_outlet: 

203 v_head_plot.append(v_head) 

204 elif v_head is not None and v_head > state.v_outlet and not head_crossed: 

205 # Add exact outlet intersection for head 

206 head_vel = wave.head_velocity() 

207 if head_vel > 0: 

208 t_outlet_head = wave.t_start + (state.v_outlet - wave.v_start) / head_vel 

209 if wave.t_start <= t_outlet_head <= t_max: 

210 # Insert the exact crossing point 

211 v_head_plot.append(state.v_outlet) 

212 head_crossed = True 

213 v_head_plot.append(None) 

214 else: 

215 v_head_plot.append(None) 

216 

217 # Handle tail 

218 if v_tail is not None and 0 <= v_tail <= state.v_outlet: 

219 v_tail_plot.append(v_tail) 

220 elif v_tail is not None and v_tail > state.v_outlet and not tail_crossed: 

221 # Add exact outlet intersection for tail 

222 tail_vel = wave.tail_velocity() 

223 if tail_vel > 0: 

224 t_outlet_tail = wave.t_start + (state.v_outlet - wave.v_start) / tail_vel 

225 if wave.t_start <= t_outlet_tail <= t_max: 

226 # Insert the exact crossing point 

227 v_tail_plot.append(state.v_outlet) 

228 tail_crossed = True 

229 v_tail_plot.append(None) 

230 else: 

231 v_tail_plot.append(None) 

232 

233 # Plot head and tail boundaries 

234 alpha = 0.5 if not wave.is_active else 0.8 

235 label = "Rarefaction" if not raref_labeled else "" 

236 

237 # Plot head (faster boundary) 

238 valid_head = [(t, v) for t, v in zip(t_plot_used, v_head_plot, strict=False) if v is not None] 

239 if valid_head: 

240 t_h, v_h = zip(*valid_head, strict=False) 

241 ax.plot(t_h, v_h, "g-", linewidth=1.5, alpha=alpha, label=label) 

242 raref_labeled = True 

243 

244 # Plot tail (slower boundary) 

245 valid_tail = [(t, v) for t, v in zip(t_plot_used, v_tail_plot, strict=False) if v is not None] 

246 if valid_tail: 

247 t_t, v_t = zip(*valid_tail, strict=False) 

248 ax.plot(t_t, v_t, "g--", linewidth=1.5, alpha=alpha) 

249 

250 # Fill between head and tail 

251 if valid_head and valid_tail and len(valid_head) == len(valid_tail): 

252 ax.fill_between( 

253 t_h, # pyright: ignore[reportPossiblyUnboundVariable] 

254 v_h, # pyright: ignore[reportPossiblyUnboundVariable] 

255 v_t, # pyright: ignore[reportPossiblyUnboundVariable] 

256 color="green", 

257 alpha=0.1 if not wave.is_active else 0.2, 

258 ) 

259 

260 # Plot outlet position 

261 ax.axhline( 

262 state.v_outlet, 

263 color="k", 

264 linestyle="--", 

265 linewidth=1, 

266 alpha=0.5, 

267 label=f"Outlet (V={state.v_outlet:.1f} m³)", 

268 ) 

269 

270 # Plot inlet position 

271 ax.axhline( 

272 0.0, 

273 color="k", 

274 linestyle=":", 

275 linewidth=1, 

276 alpha=0.5, 

277 label="Inlet (V=0)", 

278 ) 

279 

280 # Plot wave interaction events as markers 

281 if show_events and hasattr(state, "events") and state.events: 

282 for event in state.events: 

283 if "time" in event and "position" in event: 

284 t_event = event["time"] 

285 v_event = event["position"] 

286 if 0 <= t_event <= t_max and 0 <= v_event <= state.v_outlet: 

287 # Determine marker style based on event type 

288 event_type = event.get("type", "unknown") 

289 if "shock" in event_type.lower() or "collision" in event_type.lower(): 

290 marker = "X" 

291 color = "red" 

292 size = 100 

293 elif "rarefaction" in event_type.lower(): 

294 marker = "o" 

295 color = "green" 

296 size = 80 

297 elif "outlet" in event_type.lower(): 

298 marker = "s" 

299 color = "black" 

300 size = 80 

301 else: 

302 marker = "D" 

303 color = "gray" 

304 size = 60 

305 

306 ax.scatter( 

307 t_event, 

308 v_event, 

309 marker=marker, 

310 s=size, 

311 color=color, 

312 edgecolors="black", 

313 linewidths=1.5, 

314 alpha=0.8, 

315 zorder=10, 

316 label="Event" if not event_labeled else "", 

317 ) 

318 event_labeled = True 

319 

320 ax.set_xlabel("Time [days]", fontsize=12) 

321 ax.set_ylabel("Position (Pore Volume) [m³]", fontsize=12) 

322 ax.set_title("V-t Diagram: Front Tracking Simulation", fontsize=14, fontweight="bold") 

323 ax.grid(True, alpha=0.3) 

324 ax.legend(loc="best") 

325 ax.set_xlim(0, t_max) 

326 ax.set_ylim(-state.v_outlet * 0.05, state.v_outlet * 1.05) 

327 

328 return ax 

329 

330 

331def plot_breakthrough_curve( 

332 state: FrontTrackerState, 

333 ax=None, 

334 t_max: float | None = None, 

335 n_rarefaction_points: int = 50, 

336 figsize: tuple[float, float] = (12, 6), 

337 t_first_arrival: float | None = None, 

338): 

339 """ 

340 Plot exact analytical concentration breakthrough curve at outlet. 

341 

342 Uses wave segment information to plot the exact analytical solution 

343 without discretization. Constant concentration regions are plotted 

344 as horizontal lines, and rarefaction regions are plotted using their 

345 exact self-similar solutions. 

346 

347 Parameters 

348 ---------- 

349 state : FrontTrackerState 

350 Complete simulation state containing all waves. 

351 ax : matplotlib.axes.Axes, optional 

352 Existing axes to plot into. If None, a new figure and axes are created 

353 using ``figsize``. 

354 t_max : float, optional 

355 Maximum time to plot [days]. If None, uses final simulation time * 1.1. 

356 n_rarefaction_points : int, optional 

357 Number of points to use for plotting rarefaction segments (analytical 

358 curves). Default 50 per rarefaction segment. 

359 figsize : tuple of float, optional 

360 Figure size in inches (width, height). Default (12, 6). 

361 t_first_arrival : float, optional 

362 First arrival time for marking spin-up period [days]. If None, spin-up 

363 period is not plotted. 

364 

365 Returns 

366 ------- 

367 fig : matplotlib.figure.Figure 

368 Figure object containing the breakthrough curve. 

369 

370 Notes 

371 ----- 

372 - Uses identify_outlet_segments to get exact analytical segment boundaries 

373 - Constant concentration segments plotted as horizontal lines (no discretization) 

374 - Rarefaction segments plotted using exact self-similar solution 

375 - Shocks appear as instantaneous jumps at exact crossing times 

376 - No bin averaging or discretization artifacts 

377 

378 Examples 

379 -------- 

380 :: 

381 

382 from gwtransport.fronttracking.solver import FrontTracker 

383 

384 tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption) 

385 tracker.run() 

386 fig = plot_breakthrough_curve(tracker.state) 

387 fig.savefig("exact_breakthrough.png") 

388 """ 

389 if ax is None: 

390 _, ax = plt.subplots(figsize=figsize) 

391 

392 if t_max is None: 

393 # Default to input data time range instead of simulation end time 

394 # Convert tedges[-1] from Timestamp to days from tedges[0] 

395 t_max = float((state.tedges[-1] - state.tedges[0]) / pd.Timedelta(days=1)) 

396 

397 # Use exact analytical segments 

398 segments = identify_outlet_segments(0.0, t_max, state.v_outlet, state.waves, state.sorption) 

399 

400 for i, segment in enumerate(segments): 

401 t_start = segment["t_start"] 

402 t_end = segment["t_end"] 

403 

404 if segment["type"] == "constant": 

405 # Constant concentration segment - plot as horizontal line 

406 c_const = segment["concentration"] 

407 ax.plot( 

408 [t_start, t_end], 

409 [c_const, c_const], 

410 "b-", 

411 linewidth=2, 

412 label="Outlet concentration" if i == 0 else "", 

413 ) 

414 elif segment["type"] == "rarefaction": 

415 # Rarefaction segment - plot exact analytical curve 

416 raref = segment["wave"] 

417 t_raref = np.linspace(t_start, t_end, n_rarefaction_points) 

418 c_raref = np.zeros_like(t_raref) 

419 

420 for j, t in enumerate(t_raref): 

421 # Use the rarefaction wave's own concentration_at_point method 

422 c_at_point = raref.concentration_at_point(state.v_outlet, t) 

423 if c_at_point is not None: 

424 c_raref[j] = c_at_point 

425 else: 

426 # Fallback to boundary values if not in fan 

427 c_raref[j] = segment.get("c_start", raref.c_tail) 

428 

429 ax.plot(t_raref, c_raref, "b-", linewidth=2, label="Outlet concentration" if i == 0 else "") 

430 

431 # Mark first arrival time if provided 

432 if t_first_arrival is not None and np.isfinite(t_first_arrival): 

433 ax.axvline( 

434 t_first_arrival, 

435 color="r", 

436 linestyle="--", 

437 linewidth=1.5, 

438 alpha=0.7, 

439 label=f"First arrival (t={t_first_arrival:.2f} days)", 

440 ) 

441 

442 # Shade spin-up region 

443 ax.axvspan( 

444 0, 

445 t_first_arrival, 

446 alpha=0.1, 

447 color="gray", 

448 label="Spin-up period", 

449 ) 

450 

451 ax.set_xlabel("Time [days]", fontsize=12) 

452 ax.set_ylabel("Concentration [mass/volume]", fontsize=12) 

453 ax.set_title("Breakthrough Curve at Outlet (Exact Analytical)", fontsize=14, fontweight="bold") 

454 ax.grid(True, alpha=0.3) 

455 ax.legend(loc="best") 

456 ax.set_xlim(0, t_max) 

457 ax.set_ylim(bottom=0) 

458 

459 return ax 

460 

461 

462def plot_wave_interactions( 

463 state: FrontTrackerState, 

464 figsize: tuple[float, float] = (14, 8), 

465 ax=None, 

466): 

467 """ 

468 Plot event timeline showing wave interactions. 

469 

470 Creates a scatter plot showing when and where different types of wave 

471 interactions occur during the simulation. 

472 

473 Parameters 

474 ---------- 

475 state : FrontTrackerState 

476 Complete simulation state containing all events. 

477 ax : matplotlib.axes.Axes, optional 

478 Existing axes to plot into. If None, a new figure and axes are created 

479 using ``figsize``. 

480 figsize : tuple of float, optional 

481 Figure size in inches (width, height). Default (14, 8). 

482 

483 Returns 

484 ------- 

485 fig : matplotlib.figure.Figure 

486 Figure object containing the event timeline. 

487 

488 Notes 

489 ----- 

490 - Each event type is shown with a different color and marker. 

491 - Outlet crossings are shown separately from internal collisions. 

492 - Event locations are plotted in the (time, position) plane. 

493 

494 Examples 

495 -------- 

496 :: 

497 

498 from gwtransport.fronttracking.solver import FrontTracker 

499 

500 tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption) 

501 tracker.run() 

502 fig = plot_wave_interactions(tracker.state) 

503 fig.savefig("wave_interactions.png") 

504 """ 

505 if ax is None: 

506 _, ax = plt.subplots(figsize=figsize) 

507 

508 # Group events by type 

509 event_types = {} 

510 for event_dict in state.events: 

511 event_type = event_dict["type"] 

512 if event_type not in event_types: 

513 event_types[event_type] = {"times": [], "locations": []} 

514 event_types[event_type]["times"].append(event_dict["time"]) 

515 event_types[event_type]["locations"].append(event_dict.get("location", 0.0)) 

516 

517 # Define colors and markers for each event type 

518 event_style = { 

519 "CHAR_CHAR_COLLISION": {"color": "blue", "marker": "o", "label": "Char-Char"}, 

520 "SHOCK_SHOCK_COLLISION": {"color": "red", "marker": "s", "label": "Shock-Shock"}, 

521 "SHOCK_CHAR_COLLISION": {"color": "purple", "marker": "^", "label": "Shock-Char"}, 

522 "RAREF_CHAR_COLLISION": {"color": "green", "marker": "v", "label": "Raref-Char"}, 

523 "SHOCK_RAREF_COLLISION": {"color": "orange", "marker": "d", "label": "Shock-Raref"}, 

524 "RAREF_RAREF_COLLISION": {"color": "cyan", "marker": "p", "label": "Raref-Raref"}, 

525 "OUTLET_CROSSING": {"color": "black", "marker": "x", "label": "Outlet Crossing"}, 

526 "INLET_CHANGE": {"color": "gray", "marker": "+", "label": "Inlet Change"}, 

527 } 

528 

529 # Plot each event type 

530 for event_type, data in event_types.items(): 

531 style = event_style.get(event_type, {"color": "gray", "marker": "o", "label": event_type}) 

532 ax.scatter( 

533 data["times"], 

534 data["locations"], 

535 c=style["color"], 

536 marker=style["marker"], 

537 s=100, 

538 alpha=0.7, 

539 label=f"{style['label']} ({len(data['times'])})", 

540 ) 

541 

542 # Plot outlet line for reference 

543 if state.events: 

544 ax.axhline( 

545 state.v_outlet, 

546 color="k", 

547 linestyle="--", 

548 linewidth=1, 

549 alpha=0.3, 

550 label=f"Outlet (V={state.v_outlet:.1f} m³)", 

551 ) 

552 

553 ax.set_xlabel("Time [days]", fontsize=12) 

554 ax.set_ylabel("Position (Pore Volume) [m³]", fontsize=12) 

555 ax.set_title("Wave Interaction Events", fontsize=14, fontweight="bold") 

556 ax.grid(True, alpha=0.3) 

557 ax.legend(loc="best", ncol=2) 

558 

559 if state.events: 

560 ax.set_xlim(left=0) 

561 ax.set_ylim(-state.v_outlet * 0.05, state.v_outlet * 1.05) 

562 

563 return ax 

564 

565 

566def plot_inlet_concentration( 

567 tedges, 

568 cin, 

569 ax=None, 

570 *, 

571 t_first_arrival=None, 

572 event_markers=None, 

573 color="blue", 

574 t_max=None, 

575 xlabel="Time [days]", 

576 ylabel="Concentration", 

577 title="Inlet Concentration", 

578 figsize=(8, 5), 

579 **step_kwargs, 

580): 

581 """ 

582 Plot inlet concentration as step function with optional markers. 

583 

584 Parameters 

585 ---------- 

586 tedges : pandas.DatetimeIndex 

587 Time bin edges for inlet concentration. 

588 Length = len(cin) + 1. 

589 cin : array-like 

590 Inlet concentration values. 

591 Length = len(tedges) - 1. 

592 ax : matplotlib.axes.Axes, optional 

593 Existing axes to plot into. If None, creates new figure. 

594 t_first_arrival : float, optional 

595 First arrival time to mark with vertical line [days]. 

596 event_markers : list of dict, optional 

597 Event markers to add. Each dict should have keys: 'time', 'label', 'color'. 

598 color : str, optional 

599 Color for inlet concentration line. Default 'blue'. 

600 t_max : float, optional 

601 Maximum time for x-axis [days]. If None, uses full range. 

602 xlabel : str, optional 

603 Label for x-axis. Default 'Time [days]'. 

604 ylabel : str, optional 

605 Label for y-axis. Default 'Concentration'. 

606 title : str, optional 

607 Plot title. Default 'Inlet Concentration'. 

608 figsize : tuple, optional 

609 Figure size if creating new figure. Default (8, 5). 

610 **step_kwargs 

611 Additional arguments passed to ax.plot(). 

612 

613 Returns 

614 ------- 

615 fig : matplotlib.figure.Figure 

616 Figure object. 

617 ax : matplotlib.axes.Axes 

618 Axes object. 

619 """ 

620 if ax is None: 

621 _, ax = plt.subplots(figsize=figsize) 

622 

623 # Convert tedges to days from start 

624 tedges_array = tedges.to_numpy() if hasattr(tedges, "to_numpy") else np.array(tedges) 

625 

626 t_days = (tedges_array - tedges_array[0]) / pd.Timedelta(days=1) 

627 

628 # Plot inlet concentration using step function 

629 x_plot, y_plot = step_plot_coords(t_days, cin) 

630 ax.plot(x_plot, y_plot, linewidth=2, color=color, label="Inlet", **step_kwargs) 

631 

632 # Add first arrival marker if provided 

633 if t_first_arrival is not None and np.isfinite(t_first_arrival): 

634 ax.axvline( 

635 t_first_arrival, 

636 color="green", 

637 linestyle="--", 

638 linewidth=1.5, 

639 alpha=0.7, 

640 label=f"First arrival ({t_first_arrival:.1f} days)", 

641 ) 

642 

643 # Add event markers if provided 

644 if event_markers is not None: 

645 for marker in event_markers: 

646 t = marker.get("time") 

647 label = marker.get("label", "") 

648 marker_color = marker.get("color", "gray") 

649 linestyle = marker.get("linestyle", "--") 

650 

651 if t is not None: 

652 ax.axvline( 

653 t, 

654 color=marker_color, 

655 linestyle=linestyle, 

656 linewidth=1.5, 

657 alpha=0.7, 

658 label=label, 

659 ) 

660 

661 ax.set_xlabel(xlabel, fontsize=10) 

662 ax.set_ylabel(ylabel, fontsize=10) 

663 ax.set_title(title, fontsize=12, fontweight="bold") 

664 ax.grid(True, alpha=0.3) 

665 ax.legend(fontsize=8) 

666 

667 if t_max is not None: 

668 ax.set_xlim(0, t_max) 

669 else: 

670 ax.set_xlim(0, t_days[-1]) 

671 

672 return ax 

673 

674 

675def plot_front_tracking_summary( 

676 structure, 

677 tedges, 

678 cin, 

679 cout_tedges, 

680 cout, 

681 *, 

682 figsize=(16, 10), 

683 show_exact=True, 

684 show_bin_averaged=True, 

685 show_events=True, 

686 show_inactive=False, 

687 t_max=None, 

688 title=None, 

689 inlet_color="blue", 

690 outlet_exact_color="blue", 

691 outlet_binned_color="red", 

692 first_arrival_color="green", 

693): 

694 """ 

695 Create comprehensive 3-panel summary figure for front tracking simulation. 

696 

697 Creates a multi-panel visualization with: 

698 - Top-left: V-t diagram showing wave propagation 

699 - Top-right: Inlet concentration time series 

700 - Bottom: Outlet concentration (exact and/or bin-averaged) 

701 

702 Parameters 

703 ---------- 

704 structure : dict 

705 Structure returned from infiltration_to_extraction_front_tracking_detailed. 

706 Must contain keys: 'tracker_state', 't_first_arrival'. 

707 tedges : pandas.DatetimeIndex 

708 Time bin edges for inlet concentration. 

709 Length = len(cin) + 1. 

710 cin : array-like 

711 Inlet concentration values. 

712 Length = len(tedges) - 1. 

713 cout_tedges : pandas.DatetimeIndex 

714 Output time bin edges for bin-averaged concentration. 

715 Length = len(cout) + 1. 

716 cout : array-like 

717 Bin-averaged output concentration values. 

718 Length = len(cout_tedges) - 1. 

719 figsize : tuple, optional 

720 Figure size (width, height). Default (16, 10). 

721 show_exact : bool, optional 

722 Whether to show exact analytical breakthrough curve. Default True. 

723 show_bin_averaged : bool, optional 

724 Whether to show bin-averaged concentration. Default True. 

725 show_events : bool, optional 

726 Whether to show wave interaction events on V-t diagram. Default True. 

727 show_inactive : bool, optional 

728 Whether to show inactive waves on V-t diagram. Default False. 

729 t_max : float, optional 

730 Maximum time for plots [days]. If None, uses input data range. 

731 title : str, optional 

732 Overall figure title. If None, uses generic title. 

733 inlet_color : str, optional 

734 Color for inlet concentration. Default 'blue'. 

735 outlet_exact_color : str, optional 

736 Color for exact outlet curve. Default 'blue'. 

737 outlet_binned_color : str, optional 

738 Color for bin-averaged outlet. Default 'red'. 

739 first_arrival_color : str, optional 

740 Color for first arrival marker. Default 'green'. 

741 

742 Returns 

743 ------- 

744 fig : matplotlib.figure.Figure 

745 Figure object. 

746 axes : dict 

747 Dictionary with keys 'vt', 'inlet', 'outlet' containing axes objects. 

748 """ 

749 # Create figure with 3-panel layout 

750 fig = plt.figure(figsize=figsize) 

751 gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.3) 

752 

753 axes = {} 

754 

755 # Top left: V-t diagram 

756 ax_vt = fig.add_subplot(gs[0, 0]) 

757 plot_vt_diagram( 

758 structure["tracker_state"], 

759 ax=ax_vt, 

760 show_inactive=show_inactive, 

761 show_events=show_events, 

762 t_max=t_max, 

763 ) 

764 ax_vt.set_title("V-t Diagram", fontsize=12, fontweight="bold") 

765 axes["vt"] = ax_vt 

766 

767 # Top right: Inlet concentration 

768 ax_inlet = fig.add_subplot(gs[0, 1]) 

769 plot_inlet_concentration( 

770 tedges, 

771 cin, 

772 ax=ax_inlet, 

773 t_first_arrival=structure["t_first_arrival"], 

774 color=inlet_color, 

775 t_max=t_max, 

776 ) 

777 axes["inlet"] = ax_inlet 

778 

779 # Bottom: Outlet concentration (exact and bin-averaged) 

780 ax_outlet = fig.add_subplot(gs[1, :]) 

781 

782 # Compute time range 

783 if t_max is None: 

784 t_max = (tedges.to_numpy()[-1] - tedges.to_numpy()[0]) / pd.Timedelta(days=1) 

785 

786 # Exact breakthrough curve 

787 if show_exact: 

788 t_exact = np.linspace(0, t_max, 1000) 

789 c_exact = [ 

790 concentration_at_point( 

791 structure["tracker_state"].v_outlet, 

792 t, 

793 structure["tracker_state"].waves, 

794 structure["tracker_state"].sorption, 

795 ) 

796 for t in t_exact 

797 ] 

798 ax_outlet.plot( 

799 t_exact, 

800 c_exact, 

801 color=outlet_exact_color, 

802 linewidth=2.5, 

803 label="Exact outlet concentration", 

804 zorder=3, 

805 ) 

806 

807 # Bin-averaged outlet 

808 if show_bin_averaged: 

809 t_edges_days = ((cout_tedges - cout_tedges[0]) / pd.Timedelta(days=1)).values 

810 xstep_cout, ystep_cout = step_plot_coords(t_edges_days, cout) 

811 ax_outlet.plot( 

812 xstep_cout, 

813 ystep_cout, 

814 color=outlet_binned_color, 

815 linestyle="--", 

816 linewidth=1.5, 

817 alpha=0.7, 

818 label="Bin-averaged outlet", 

819 zorder=2, 

820 ) 

821 

822 # First arrival marker 

823 t_first = structure["t_first_arrival"] 

824 if np.isfinite(t_first): 

825 ax_outlet.axvline( 

826 t_first, 

827 color=first_arrival_color, 

828 linestyle="--", 

829 linewidth=1.5, 

830 alpha=0.7, 

831 label=f"First arrival ({t_first:.1f} days)", 

832 zorder=1, 

833 ) 

834 

835 ax_outlet.set_xlabel("Time [days]", fontsize=11) 

836 ax_outlet.set_ylabel("Concentration", fontsize=11) 

837 ax_outlet.set_title("Outlet Concentration: Exact vs Bin-Averaged", fontsize=12, fontweight="bold") 

838 ax_outlet.grid(True, alpha=0.3) 

839 ax_outlet.legend(fontsize=9) 

840 ax_outlet.set_xlim(0, t_max) 

841 axes["outlet"] = ax_outlet 

842 

843 # Overall title 

844 if title is not None: 

845 plt.suptitle(title, fontsize=14, fontweight="bold", y=0.995) 

846 

847 return fig 

848 

849 

850def plot_sorption_comparison( 

851 pulse_favorable_structure, 

852 pulse_unfavorable_structure, 

853 pulse_tedges, 

854 pulse_cin, 

855 dip_favorable_structure, 

856 dip_unfavorable_structure, 

857 dip_tedges, 

858 dip_cin, 

859 *, 

860 figsize=(16, 12), 

861 t_max_pulse=None, 

862 t_max_dip=None, 

863): 

864 """ 

865 Compare how each inlet produces different outputs with n>1 vs n<1 sorption. 

866 

867 Creates a 2x3 grid: 

868 - Row 1: Pulse inlet and its outputs with n>1 and n<1 sorption 

869 - Row 2: Dip inlet and its outputs with n>1 and n<1 sorption 

870 

871 This demonstrates how the SAME inlet timeseries produces DIFFERENT breakthrough 

872 curves depending on the sorption isotherm. 

873 

874 Parameters 

875 ---------- 

876 pulse_favorable_structure : dict 

877 Structure from pulse inlet with n>1 (higher C travels faster). 

878 pulse_unfavorable_structure : dict 

879 Structure from pulse inlet with n<1 (lower C travels faster). 

880 pulse_tedges : pandas.DatetimeIndex 

881 Time bin edges for pulse inlet. 

882 Length = len(pulse_cin) + 1. 

883 pulse_cin : array-like 

884 Pulse inlet concentration (e.g., 0→10→0). 

885 Length = len(pulse_tedges) - 1. 

886 dip_favorable_structure : dict 

887 Structure from dip inlet with n>1 (higher C travels faster). 

888 dip_unfavorable_structure : dict 

889 Structure from dip inlet with n<1 (lower C travels faster). 

890 dip_tedges : pandas.DatetimeIndex 

891 Time bin edges for dip inlet. 

892 Length = len(dip_cin) + 1. 

893 dip_cin : array-like 

894 Dip inlet concentration (e.g., 10→2→10). 

895 Length = len(dip_tedges) - 1. 

896 figsize : tuple, optional 

897 Figure size (width, height). Default (16, 12). 

898 t_max_pulse : float, optional 

899 Max time for pulse plots [days]. If None, auto-computed. 

900 t_max_dip : float, optional 

901 Max time for dip plots [days]. If None, auto-computed. 

902 

903 Returns 

904 ------- 

905 fig : matplotlib.figure.Figure 

906 Figure object. 

907 axes : numpy.ndarray 

908 2x3 array of axes objects. 

909 """ 

910 fig, axes = plt.subplots(2, 3, figsize=figsize) 

911 fig.suptitle( 

912 "Sorption Comparison: How Each Inlet Responds to n>1 vs n<1 Sorption", 

913 fontsize=15, 

914 fontweight="bold", 

915 y=0.995, 

916 ) 

917 

918 # Compute time ranges 

919 if t_max_pulse is None: 

920 t_max_pulse = (pulse_tedges.to_numpy()[-1] - pulse_tedges.to_numpy()[0]) / pd.Timedelta(days=1) 

921 if t_max_dip is None: 

922 t_max_dip = (dip_tedges.to_numpy()[-1] - dip_tedges.to_numpy()[0]) / pd.Timedelta(days=1) 

923 

924 # === ROW 1: Pulse inlet (0→10→0) === 

925 t_days_pulse = (pulse_tedges.to_numpy() - pulse_tedges.to_numpy()[0]) / pd.Timedelta(days=1) 

926 

927 # Column 1: Pulse inlet 

928 ax_pulse_inlet = axes[0, 0] 

929 x_pulse, y_pulse = step_plot_coords(t_days_pulse, pulse_cin) 

930 ax_pulse_inlet.plot(x_pulse, y_pulse, linewidth=2.5, color="black") 

931 ax_pulse_inlet.set_xlabel("Time [days]", fontsize=10) 

932 ax_pulse_inlet.set_ylabel("Concentration", fontsize=10) 

933 ax_pulse_inlet.set_title("Pulse Inlet\n(0→10→0)", fontsize=11, fontweight="bold") 

934 ax_pulse_inlet.grid(True, alpha=0.3) 

935 ax_pulse_inlet.set_xlim(0, t_max_pulse) 

936 

937 # Column 2: Pulse → n>1 outlet 

938 ax_pulse_fav = axes[0, 1] 

939 t_exact_pulse_fav = np.linspace(0, t_max_pulse, 1500) 

940 c_exact_pulse_fav = [ 

941 concentration_at_point( 

942 pulse_favorable_structure["tracker_state"].v_outlet, 

943 t, 

944 pulse_favorable_structure["tracker_state"].waves, 

945 pulse_favorable_structure["tracker_state"].sorption, 

946 ) 

947 for t in t_exact_pulse_fav 

948 ] 

949 ax_pulse_fav.plot(t_exact_pulse_fav, c_exact_pulse_fav, "b-", linewidth=2.5) 

950 ax_pulse_fav.set_xlabel("Time [days]", fontsize=10) 

951 ax_pulse_fav.set_ylabel("Concentration", fontsize=10) 

952 ax_pulse_fav.set_title("n>1\nShock→Rarefaction", fontsize=11, fontweight="bold", color="darkblue") 

953 ax_pulse_fav.grid(True, alpha=0.3) 

954 ax_pulse_fav.set_xlim(0, t_max_pulse) 

955 ax_pulse_fav.text( 

956 0.05, 

957 0.95, 

958 "High C: FAST\nRise: Sharp\nFall: Smooth", 

959 transform=ax_pulse_fav.transAxes, 

960 verticalalignment="top", 

961 bbox={"boxstyle": "round", "facecolor": "lightblue", "alpha": 0.7}, 

962 fontsize=8, 

963 ) 

964 

965 # Column 3: Pulse → n<1 outlet 

966 ax_pulse_unfav = axes[0, 2] 

967 t_exact_pulse_unfav = np.linspace(0, t_max_pulse, 1500) 

968 c_exact_pulse_unfav = [ 

969 concentration_at_point( 

970 pulse_unfavorable_structure["tracker_state"].v_outlet, 

971 t, 

972 pulse_unfavorable_structure["tracker_state"].waves, 

973 pulse_unfavorable_structure["tracker_state"].sorption, 

974 ) 

975 for t in t_exact_pulse_unfav 

976 ] 

977 ax_pulse_unfav.plot(t_exact_pulse_unfav, c_exact_pulse_unfav, "r-", linewidth=2.5) 

978 ax_pulse_unfav.set_xlabel("Time [days]", fontsize=10) 

979 ax_pulse_unfav.set_ylabel("Concentration", fontsize=10) 

980 ax_pulse_unfav.set_title("n<1\nRarefaction→Shock", fontsize=11, fontweight="bold", color="darkred") 

981 ax_pulse_unfav.grid(True, alpha=0.3) 

982 ax_pulse_unfav.set_xlim(0, t_max_pulse) 

983 ax_pulse_unfav.text( 

984 0.05, 

985 0.95, 

986 "High C: SLOW\nRise: Smooth\nFall: Sharp", 

987 transform=ax_pulse_unfav.transAxes, 

988 verticalalignment="top", 

989 bbox={"boxstyle": "round", "facecolor": "lightcoral", "alpha": 0.7}, 

990 fontsize=8, 

991 ) 

992 

993 # === ROW 2: Dip inlet (10→2→10) === 

994 t_days_dip = (dip_tedges.to_numpy() - dip_tedges.to_numpy()[0]) / pd.Timedelta(days=1) 

995 

996 # Column 1: Dip inlet 

997 ax_dip_inlet = axes[1, 0] 

998 x_dip, y_dip = step_plot_coords(t_days_dip, dip_cin) 

999 ax_dip_inlet.plot(x_dip, y_dip, linewidth=2.5, color="black") 

1000 ax_dip_inlet.set_xlabel("Time [days]", fontsize=10) 

1001 ax_dip_inlet.set_ylabel("Concentration", fontsize=10) 

1002 ax_dip_inlet.set_title("Dip Inlet\n(10→2→10)", fontsize=11, fontweight="bold") 

1003 ax_dip_inlet.grid(True, alpha=0.3) 

1004 ax_dip_inlet.set_xlim(0, t_max_dip) 

1005 

1006 # Column 2: Dip → n>1 outlet 

1007 ax_dip_fav = axes[1, 1] 

1008 t_exact_dip_fav = np.linspace(0, t_max_dip, 1500) 

1009 c_exact_dip_fav = [ 

1010 concentration_at_point( 

1011 dip_favorable_structure["tracker_state"].v_outlet, 

1012 t, 

1013 dip_favorable_structure["tracker_state"].waves, 

1014 dip_favorable_structure["tracker_state"].sorption, 

1015 ) 

1016 for t in t_exact_dip_fav 

1017 ] 

1018 ax_dip_fav.plot(t_exact_dip_fav, c_exact_dip_fav, "b-", linewidth=2.5) 

1019 ax_dip_fav.set_xlabel("Time [days]", fontsize=10) 

1020 ax_dip_fav.set_ylabel("Concentration", fontsize=10) 

1021 ax_dip_fav.set_title("n>1\nRarefaction→Shock", fontsize=11, fontweight="bold", color="darkblue") 

1022 ax_dip_fav.grid(True, alpha=0.3) 

1023 ax_dip_fav.set_xlim(0, t_max_dip) 

1024 ax_dip_fav.text( 

1025 0.05, 

1026 0.95, 

1027 "High C: FAST\nDrop: Smooth\nRise: Sharp", 

1028 transform=ax_dip_fav.transAxes, 

1029 verticalalignment="top", 

1030 bbox={"boxstyle": "round", "facecolor": "lightblue", "alpha": 0.7}, 

1031 fontsize=8, 

1032 ) 

1033 

1034 # Column 3: Dip → n<1 outlet 

1035 ax_dip_unfav = axes[1, 2] 

1036 t_exact_dip_unfav = np.linspace(0, t_max_dip, 1500) 

1037 c_exact_dip_unfav = [ 

1038 concentration_at_point( 

1039 dip_unfavorable_structure["tracker_state"].v_outlet, 

1040 t, 

1041 dip_unfavorable_structure["tracker_state"].waves, 

1042 dip_unfavorable_structure["tracker_state"].sorption, 

1043 ) 

1044 for t in t_exact_dip_unfav 

1045 ] 

1046 ax_dip_unfav.plot(t_exact_dip_unfav, c_exact_dip_unfav, "r-", linewidth=2.5) 

1047 ax_dip_unfav.set_xlabel("Time [days]", fontsize=10) 

1048 ax_dip_unfav.set_ylabel("Concentration", fontsize=10) 

1049 ax_dip_unfav.set_title("n<1\nShock→Rarefaction", fontsize=11, fontweight="bold", color="darkred") 

1050 ax_dip_unfav.grid(True, alpha=0.3) 

1051 ax_dip_unfav.set_xlim(0, t_max_dip) 

1052 ax_dip_unfav.text( 

1053 0.05, 

1054 0.95, 

1055 "High C: SLOW\nDrop: Sharp\nRise: Smooth", 

1056 transform=ax_dip_unfav.transAxes, 

1057 verticalalignment="top", 

1058 bbox={"boxstyle": "round", "facecolor": "lightcoral", "alpha": 0.7}, 

1059 fontsize=8, 

1060 ) 

1061 

1062 return fig, axes