Coverage for src / gwtransport / fronttracking / plot.py: 75%
332 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
1"""
2Visualization functions for front tracking.
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
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"""
12import matplotlib.pyplot as plt
13import numpy as np
14import pandas as pd
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
21def plot_vt_diagram(
22 state: FrontTrackerState,
23 ax=None,
24 t_max: float | None = None,
25 figsize: tuple[float, float] = (14, 10),
26 *,
27 show_inactive: bool = False,
28 show_events: bool = False,
29):
30 """
31 Create V-t diagram showing all waves in space-time.
33 Plots characteristics (blue lines), shocks (red lines), and rarefactions
34 (green fans) in the (time, position) plane. This visualization shows how
35 waves propagate and interact throughout the simulation.
37 Parameters
38 ----------
39 state : FrontTrackerState
40 Complete simulation state containing all waves.
41 ax : matplotlib.axes.Axes, optional
42 Existing axes to plot into. If None, a new figure and axes are created
43 using ``figsize``.
44 t_max : float, optional
45 Maximum time to plot [days]. If None, uses final simulation time * 1.2.
46 figsize : tuple of float, optional
47 Figure size in inches (width, height). Default (14, 10).
48 show_inactive : bool, optional
49 Whether to show inactive waves (deactivated by interactions).
50 Default False.
51 show_events : bool, optional
52 Whether to show wave interaction events as markers.
53 Default False.
55 Returns
56 -------
57 fig : matplotlib.figure.Figure
58 Figure object containing the V-t diagram.
60 Notes
61 -----
62 - Characteristics appear as blue lines (constant velocity).
63 - Shocks appear as thick red lines (jump discontinuities).
64 - Rarefactions appear as green fans (smooth transition regions).
65 - Outlet position is shown as a horizontal dashed line.
66 - Only waves within domain [0, v_outlet] are plotted.
68 Examples
69 --------
70 ::
72 from gwtransport.fronttracking.solver import FrontTracker
74 tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)
75 tracker.run()
76 fig = plot_vt_diagram(tracker.state)
77 fig.savefig("vt_diagram.png")
78 """
79 if t_max is None:
80 # Default to input data time range instead of simulation end time
81 # Convert tedges[-1] from Timestamp to days from tedges[0]
82 t_max = (state.tedges[-1] - state.tedges[0]) / pd.Timedelta(days=1)
84 if ax is None:
85 _, ax = plt.subplots(figsize=figsize)
87 # Plot characteristics (blue lines)
88 for wave in state.waves:
89 if isinstance(wave, CharacteristicWave):
90 if not wave.is_active and not show_inactive:
91 continue
93 t_plot = np.linspace(wave.t_start, t_max, 100)
94 v_plot = []
95 t_plot_used = []
97 for t in t_plot:
98 # Compute position manually for inactive waves when show_inactive=True
99 v = wave.position_at_time(t) if wave.is_active else wave.v_start + wave.velocity() * (t - wave.t_start)
101 if v is not None and 0 <= v <= state.v_outlet:
102 v_plot.append(v)
103 t_plot_used.append(t)
104 elif v is not None and v > state.v_outlet:
105 # Wave crossed outlet - add exact intersection point
106 vel = wave.velocity()
107 if vel > 0:
108 t_outlet = wave.t_start + (state.v_outlet - wave.v_start) / vel
109 if wave.t_start <= t_outlet <= t_max:
110 v_plot.append(state.v_outlet)
111 t_plot_used.append(t_outlet)
112 break
113 else:
114 break
116 if len(v_plot) > 0:
117 alpha = 0.3 if not wave.is_active else 0.7
118 ax.plot(
119 t_plot_used,
120 v_plot,
121 "b-",
122 linewidth=0.5,
123 alpha=alpha,
124 label="Characteristic" if not hasattr(ax, "gw_char_labeled") else "",
125 )
126 ax.gw_char_labeled = True # type: ignore[attr-defined]
128 # Plot shocks (red lines)
129 for wave in state.waves:
130 if isinstance(wave, ShockWave):
131 if not wave.is_active and not show_inactive:
132 continue
134 t_plot = np.linspace(wave.t_start, t_max, 100)
135 v_plot = []
136 t_plot_used = []
138 for t in t_plot:
139 # Compute position manually for inactive waves when show_inactive=True
140 v = wave.position_at_time(t) if wave.is_active else wave.v_start + wave.velocity * (t - wave.t_start)
142 if v is not None and 0 <= v <= state.v_outlet:
143 v_plot.append(v)
144 t_plot_used.append(t)
145 elif v is not None and v > state.v_outlet:
146 # Wave crossed outlet - add exact intersection point
147 vel = wave.velocity
148 if vel is not None and vel > 0:
149 t_outlet = wave.t_start + (state.v_outlet - wave.v_start) / vel
150 if wave.t_start <= t_outlet <= t_max:
151 v_plot.append(state.v_outlet)
152 t_plot_used.append(t_outlet)
153 break
154 else:
155 break
157 if len(v_plot) > 0:
158 alpha = 0.5 if not wave.is_active else 1.0
159 ax.plot(
160 t_plot_used,
161 v_plot,
162 "r-",
163 linewidth=2,
164 alpha=alpha,
165 label="Shock" if not hasattr(ax, "gw_shock_labeled") else "",
166 )
167 ax.gw_shock_labeled = True # type: ignore[attr-defined]
169 # Plot rarefactions (green fans)
170 for wave in state.waves:
171 if isinstance(wave, RarefactionWave):
172 if not wave.is_active and not show_inactive:
173 continue
175 t_plot = np.linspace(wave.t_start, t_max, 100)
176 v_head_plot = []
177 v_tail_plot = []
178 t_plot_used = []
179 head_crossed = False
180 tail_crossed = False
182 for t in t_plot:
183 # Compute positions manually for inactive waves when show_inactive=True
184 if wave.is_active:
185 v_head = wave.head_position_at_time(t)
186 v_tail = wave.tail_position_at_time(t)
187 else:
188 # Manually compute positions for visualization
189 v_head = wave.v_start + wave.head_velocity() * (t - wave.t_start)
190 v_tail = wave.v_start + wave.tail_velocity() * (t - wave.t_start)
192 # Track time points
193 t_plot_used.append(t)
195 # Handle head
196 if v_head is not None and 0 <= v_head <= state.v_outlet:
197 v_head_plot.append(v_head)
198 elif v_head is not None and v_head > state.v_outlet and not head_crossed:
199 # Add exact outlet intersection for head
200 head_vel = wave.head_velocity()
201 if head_vel > 0:
202 t_outlet_head = wave.t_start + (state.v_outlet - wave.v_start) / head_vel
203 if wave.t_start <= t_outlet_head <= t_max:
204 # Insert the exact crossing point
205 v_head_plot.append(state.v_outlet)
206 head_crossed = True
207 v_head_plot.append(None)
208 else:
209 v_head_plot.append(None)
211 # Handle tail
212 if v_tail is not None and 0 <= v_tail <= state.v_outlet:
213 v_tail_plot.append(v_tail)
214 elif v_tail is not None and v_tail > state.v_outlet and not tail_crossed:
215 # Add exact outlet intersection for tail
216 tail_vel = wave.tail_velocity()
217 if tail_vel > 0:
218 t_outlet_tail = wave.t_start + (state.v_outlet - wave.v_start) / tail_vel
219 if wave.t_start <= t_outlet_tail <= t_max:
220 # Insert the exact crossing point
221 v_tail_plot.append(state.v_outlet)
222 tail_crossed = True
223 v_tail_plot.append(None)
224 else:
225 v_tail_plot.append(None)
227 # Plot head and tail boundaries
228 alpha = 0.5 if not wave.is_active else 0.8
229 label = "Rarefaction" if not hasattr(ax, "gw_raref_labeled") else ""
231 # Plot head (faster boundary)
232 valid_head = [(t, v) for t, v in zip(t_plot_used, v_head_plot, strict=False) if v is not None]
233 if valid_head:
234 t_h, v_h = zip(*valid_head, strict=False)
235 ax.plot(t_h, v_h, "g-", linewidth=1.5, alpha=alpha, label=label)
236 ax.gw_raref_labeled = True # type: ignore[attr-defined]
238 # Plot tail (slower boundary)
239 valid_tail = [(t, v) for t, v in zip(t_plot_used, v_tail_plot, strict=False) if v is not None]
240 if valid_tail:
241 t_t, v_t = zip(*valid_tail, strict=False)
242 ax.plot(t_t, v_t, "g--", linewidth=1.5, alpha=alpha)
244 # Fill between head and tail
245 if valid_head and valid_tail and len(valid_head) == len(valid_tail):
246 ax.fill_between(
247 t_h,
248 v_h,
249 v_t,
250 color="green",
251 alpha=0.1 if not wave.is_active else 0.2,
252 )
254 # Plot outlet position
255 ax.axhline(
256 state.v_outlet,
257 color="k",
258 linestyle="--",
259 linewidth=1,
260 alpha=0.5,
261 label=f"Outlet (V={state.v_outlet:.1f} m³)",
262 )
264 # Plot inlet position
265 ax.axhline(
266 0.0,
267 color="k",
268 linestyle=":",
269 linewidth=1,
270 alpha=0.5,
271 label="Inlet (V=0)",
272 )
274 # Plot wave interaction events as markers
275 if show_events and hasattr(state, "events") and state.events:
276 for event in state.events:
277 if "time" in event and "position" in event:
278 t_event = event["time"]
279 v_event = event["position"]
280 if 0 <= t_event <= t_max and 0 <= v_event <= state.v_outlet:
281 # Determine marker style based on event type
282 event_type = event.get("type", "unknown")
283 if "shock" in event_type.lower() or "collision" in event_type.lower():
284 marker = "X"
285 color = "red"
286 size = 100
287 elif "rarefaction" in event_type.lower():
288 marker = "o"
289 color = "green"
290 size = 80
291 elif "outlet" in event_type.lower():
292 marker = "s"
293 color = "black"
294 size = 80
295 else:
296 marker = "D"
297 color = "gray"
298 size = 60
300 ax.scatter(
301 t_event,
302 v_event,
303 marker=marker,
304 s=size,
305 color=color,
306 edgecolors="black",
307 linewidths=1.5,
308 alpha=0.8,
309 zorder=10,
310 label="Event" if not hasattr(ax, "gw_event_labeled") else "",
311 )
312 ax.gw_event_labeled = True # type: ignore[attr-defined]
314 ax.set_xlabel("Time [days]", fontsize=12)
315 ax.set_ylabel("Position (Pore Volume) [m³]", fontsize=12)
316 ax.set_title("V-t Diagram: Front Tracking Simulation", fontsize=14, fontweight="bold")
317 ax.grid(True, alpha=0.3)
318 ax.legend(loc="best")
319 ax.set_xlim(0, t_max)
320 ax.set_ylim(-state.v_outlet * 0.05, state.v_outlet * 1.05)
322 return ax
325def plot_breakthrough_curve(
326 state: FrontTrackerState,
327 ax=None,
328 t_max: float | None = None,
329 n_rarefaction_points: int = 50,
330 figsize: tuple[float, float] = (12, 6),
331 t_first_arrival: float | None = None,
332):
333 """
334 Plot exact analytical concentration breakthrough curve at outlet.
336 Uses wave segment information to plot the exact analytical solution
337 without discretization. Constant concentration regions are plotted
338 as horizontal lines, and rarefaction regions are plotted using their
339 exact self-similar solutions.
341 Parameters
342 ----------
343 state : FrontTrackerState
344 Complete simulation state containing all waves.
345 ax : matplotlib.axes.Axes, optional
346 Existing axes to plot into. If None, a new figure and axes are created
347 using ``figsize``.
348 t_max : float, optional
349 Maximum time to plot [days]. If None, uses final simulation time * 1.1.
350 n_rarefaction_points : int, optional
351 Number of points to use for plotting rarefaction segments (analytical
352 curves). Default 50 per rarefaction segment.
353 figsize : tuple of float, optional
354 Figure size in inches (width, height). Default (12, 6).
355 t_first_arrival : float, optional
356 First arrival time for marking spin-up period [days]. If None, spin-up
357 period is not plotted.
359 Returns
360 -------
361 fig : matplotlib.figure.Figure
362 Figure object containing the breakthrough curve.
364 Notes
365 -----
366 - Uses identify_outlet_segments to get exact analytical segment boundaries
367 - Constant concentration segments plotted as horizontal lines (no discretization)
368 - Rarefaction segments plotted using exact self-similar solution
369 - Shocks appear as instantaneous jumps at exact crossing times
370 - No bin averaging or discretization artifacts
372 Examples
373 --------
374 ::
376 from gwtransport.fronttracking.solver import FrontTracker
378 tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)
379 tracker.run()
380 fig = plot_breakthrough_curve(tracker.state)
381 fig.savefig("exact_breakthrough.png")
382 """
383 if ax is None:
384 _, ax = plt.subplots(figsize=figsize)
386 if t_max is None:
387 # Default to input data time range instead of simulation end time
388 # Convert tedges[-1] from Timestamp to days from tedges[0]
389 t_max = (state.tedges[-1] - state.tedges[0]) / pd.Timedelta(days=1)
391 # Use exact analytical segments
392 segments = identify_outlet_segments(0.0, t_max, state.v_outlet, state.waves, state.sorption)
394 for i, segment in enumerate(segments):
395 t_start = segment["t_start"]
396 t_end = segment["t_end"]
398 if segment["type"] == "constant":
399 # Constant concentration segment - plot as horizontal line
400 c_const = segment["concentration"]
401 ax.plot(
402 [t_start, t_end],
403 [c_const, c_const],
404 "b-",
405 linewidth=2,
406 label="Outlet concentration" if i == 0 else "",
407 )
408 elif segment["type"] == "rarefaction":
409 # Rarefaction segment - plot exact analytical curve
410 raref = segment["wave"]
411 t_raref = np.linspace(t_start, t_end, n_rarefaction_points)
412 c_raref = np.zeros_like(t_raref)
414 for j, t in enumerate(t_raref):
415 # Use the rarefaction wave's own concentration_at_point method
416 c_at_point = raref.concentration_at_point(state.v_outlet, t)
417 if c_at_point is not None:
418 c_raref[j] = c_at_point
419 else:
420 # Fallback to boundary values if not in fan
421 c_raref[j] = segment.get("c_start", raref.c_tail)
423 ax.plot(t_raref, c_raref, "b-", linewidth=2, label="Outlet concentration" if i == 0 else "")
425 # Mark first arrival time if provided
426 if t_first_arrival is not None and np.isfinite(t_first_arrival):
427 ax.axvline(
428 t_first_arrival,
429 color="r",
430 linestyle="--",
431 linewidth=1.5,
432 alpha=0.7,
433 label=f"First arrival (t={t_first_arrival:.2f} days)",
434 )
436 # Shade spin-up region
437 ax.axvspan(
438 0,
439 t_first_arrival,
440 alpha=0.1,
441 color="gray",
442 label="Spin-up period",
443 )
445 ax.set_xlabel("Time [days]", fontsize=12)
446 ax.set_ylabel("Concentration [mass/volume]", fontsize=12)
447 ax.set_title("Breakthrough Curve at Outlet (Exact Analytical)", fontsize=14, fontweight="bold")
448 ax.grid(True, alpha=0.3)
449 ax.legend(loc="best")
450 ax.set_xlim(0, t_max)
451 ax.set_ylim(bottom=0)
453 return ax
456def plot_wave_interactions(
457 state: FrontTrackerState,
458 figsize: tuple[float, float] = (14, 8),
459 ax=None,
460):
461 """
462 Plot event timeline showing wave interactions.
464 Creates a scatter plot showing when and where different types of wave
465 interactions occur during the simulation.
467 Parameters
468 ----------
469 state : FrontTrackerState
470 Complete simulation state containing all events.
471 ax : matplotlib.axes.Axes, optional
472 Existing axes to plot into. If None, a new figure and axes are created
473 using ``figsize``.
474 figsize : tuple of float, optional
475 Figure size in inches (width, height). Default (14, 8).
477 Returns
478 -------
479 fig : matplotlib.figure.Figure
480 Figure object containing the event timeline.
482 Notes
483 -----
484 - Each event type is shown with a different color and marker.
485 - Outlet crossings are shown separately from internal collisions.
486 - Event locations are plotted in the (time, position) plane.
488 Examples
489 --------
490 ::
492 from gwtransport.fronttracking.solver import FrontTracker
494 tracker = FrontTracker(cin, flow, tedges, aquifer_pore_volume, sorption)
495 tracker.run()
496 fig = plot_wave_interactions(tracker.state)
497 fig.savefig("wave_interactions.png")
498 """
499 if ax is None:
500 _, ax = plt.subplots(figsize=figsize)
502 # Group events by type
503 event_types = {}
504 for event_dict in state.events:
505 event_type = event_dict["type"]
506 if event_type not in event_types:
507 event_types[event_type] = {"times": [], "locations": []}
508 event_types[event_type]["times"].append(event_dict["time"])
509 event_types[event_type]["locations"].append(event_dict.get("location", 0.0))
511 # Define colors and markers for each event type
512 event_style = {
513 "CHAR_CHAR_COLLISION": {"color": "blue", "marker": "o", "label": "Char-Char"},
514 "SHOCK_SHOCK_COLLISION": {"color": "red", "marker": "s", "label": "Shock-Shock"},
515 "SHOCK_CHAR_COLLISION": {"color": "purple", "marker": "^", "label": "Shock-Char"},
516 "RAREF_CHAR_COLLISION": {"color": "green", "marker": "v", "label": "Raref-Char"},
517 "SHOCK_RAREF_COLLISION": {"color": "orange", "marker": "d", "label": "Shock-Raref"},
518 "RAREF_RAREF_COLLISION": {"color": "cyan", "marker": "p", "label": "Raref-Raref"},
519 "OUTLET_CROSSING": {"color": "black", "marker": "x", "label": "Outlet Crossing"},
520 "INLET_CHANGE": {"color": "gray", "marker": "+", "label": "Inlet Change"},
521 }
523 # Plot each event type
524 for event_type, data in event_types.items():
525 style = event_style.get(event_type, {"color": "gray", "marker": "o", "label": event_type})
526 ax.scatter(
527 data["times"],
528 data["locations"],
529 c=style["color"],
530 marker=style["marker"],
531 s=100,
532 alpha=0.7,
533 label=f"{style['label']} ({len(data['times'])})",
534 )
536 # Plot outlet line for reference
537 if state.events:
538 ax.axhline(
539 state.v_outlet,
540 color="k",
541 linestyle="--",
542 linewidth=1,
543 alpha=0.3,
544 label=f"Outlet (V={state.v_outlet:.1f} m³)",
545 )
547 ax.set_xlabel("Time [days]", fontsize=12)
548 ax.set_ylabel("Position (Pore Volume) [m³]", fontsize=12)
549 ax.set_title("Wave Interaction Events", fontsize=14, fontweight="bold")
550 ax.grid(True, alpha=0.3)
551 ax.legend(loc="best", ncol=2)
553 if state.events:
554 ax.set_xlim(left=0)
555 ax.set_ylim(-state.v_outlet * 0.05, state.v_outlet * 1.05)
557 return ax
560def plot_inlet_concentration(
561 tedges,
562 cin,
563 ax=None,
564 *,
565 t_first_arrival=None,
566 event_markers=None,
567 color="blue",
568 t_max=None,
569 xlabel="Time [days]",
570 ylabel="Concentration",
571 title="Inlet Concentration",
572 figsize=(8, 5),
573 **step_kwargs,
574):
575 """
576 Plot inlet concentration as step function with optional markers.
578 Parameters
579 ----------
580 tedges : pandas.DatetimeIndex
581 Time bin edges for inlet concentration.
582 Length = len(cin) + 1.
583 cin : array-like
584 Inlet concentration values.
585 Length = len(tedges) - 1.
586 ax : matplotlib.axes.Axes, optional
587 Existing axes to plot into. If None, creates new figure.
588 t_first_arrival : float, optional
589 First arrival time to mark with vertical line [days].
590 event_markers : list of dict, optional
591 Event markers to add. Each dict should have keys: 'time', 'label', 'color'.
592 color : str, optional
593 Color for inlet concentration line. Default 'blue'.
594 t_max : float, optional
595 Maximum time for x-axis [days]. If None, uses full range.
596 xlabel : str, optional
597 Label for x-axis. Default 'Time [days]'.
598 ylabel : str, optional
599 Label for y-axis. Default 'Concentration'.
600 title : str, optional
601 Plot title. Default 'Inlet Concentration'.
602 figsize : tuple, optional
603 Figure size if creating new figure. Default (8, 5).
604 **step_kwargs
605 Additional arguments passed to ax.plot().
607 Returns
608 -------
609 fig : matplotlib.figure.Figure
610 Figure object.
611 ax : matplotlib.axes.Axes
612 Axes object.
613 """
614 if ax is None:
615 _, ax = plt.subplots(figsize=figsize)
617 # Convert tedges to days from start
618 tedges_array = tedges.to_numpy() if hasattr(tedges, "to_numpy") else np.array(tedges)
620 t_days = (tedges_array - tedges_array[0]) / pd.Timedelta(days=1)
622 # Plot inlet concentration using repeat pattern for step function
623 x_plot, y_plot = np.repeat(t_days, 2)[1:-1], np.repeat(cin, 2)
624 ax.plot(x_plot, y_plot, linewidth=2, color=color, label="Inlet", **step_kwargs)
626 # Add first arrival marker if provided
627 if t_first_arrival is not None and np.isfinite(t_first_arrival):
628 ax.axvline(
629 t_first_arrival,
630 color="green",
631 linestyle="--",
632 linewidth=1.5,
633 alpha=0.7,
634 label=f"First arrival ({t_first_arrival:.1f} days)",
635 )
637 # Add event markers if provided
638 if event_markers is not None:
639 for marker in event_markers:
640 t = marker.get("time")
641 label = marker.get("label", "")
642 marker_color = marker.get("color", "gray")
643 linestyle = marker.get("linestyle", "--")
645 if t is not None:
646 ax.axvline(
647 t,
648 color=marker_color,
649 linestyle=linestyle,
650 linewidth=1.5,
651 alpha=0.7,
652 label=label,
653 )
655 ax.set_xlabel(xlabel, fontsize=10)
656 ax.set_ylabel(ylabel, fontsize=10)
657 ax.set_title(title, fontsize=12, fontweight="bold")
658 ax.grid(True, alpha=0.3)
659 ax.legend(fontsize=8)
661 if t_max is not None:
662 ax.set_xlim(0, t_max)
663 else:
664 ax.set_xlim(0, t_days[-1])
666 return ax
669def plot_front_tracking_summary(
670 structure,
671 tedges,
672 cin,
673 cout_tedges,
674 cout,
675 *,
676 figsize=(16, 10),
677 show_exact=True,
678 show_bin_averaged=True,
679 show_events=True,
680 show_inactive=False,
681 t_max=None,
682 title=None,
683 inlet_color="blue",
684 outlet_exact_color="blue",
685 outlet_binned_color="red",
686 first_arrival_color="green",
687):
688 """
689 Create comprehensive 3-panel summary figure for front tracking simulation.
691 Creates a multi-panel visualization with:
692 - Top-left: V-t diagram showing wave propagation
693 - Top-right: Inlet concentration time series
694 - Bottom: Outlet concentration (exact and/or bin-averaged)
696 Parameters
697 ----------
698 structure : dict
699 Structure returned from infiltration_to_extraction_front_tracking_detailed.
700 Must contain keys: 'tracker_state', 't_first_arrival'.
701 tedges : pandas.DatetimeIndex
702 Time bin edges for inlet concentration.
703 Length = len(cin) + 1.
704 cin : array-like
705 Inlet concentration values.
706 Length = len(tedges) - 1.
707 cout_tedges : pandas.DatetimeIndex
708 Output time bin edges for bin-averaged concentration.
709 Length = len(cout) + 1.
710 cout : array-like
711 Bin-averaged output concentration values.
712 Length = len(cout_tedges) - 1.
713 figsize : tuple, optional
714 Figure size (width, height). Default (16, 10).
715 show_exact : bool, optional
716 Whether to show exact analytical breakthrough curve. Default True.
717 show_bin_averaged : bool, optional
718 Whether to show bin-averaged concentration. Default True.
719 show_events : bool, optional
720 Whether to show wave interaction events on V-t diagram. Default True.
721 show_inactive : bool, optional
722 Whether to show inactive waves on V-t diagram. Default False.
723 t_max : float, optional
724 Maximum time for plots [days]. If None, uses input data range.
725 title : str, optional
726 Overall figure title. If None, uses generic title.
727 inlet_color : str, optional
728 Color for inlet concentration. Default 'blue'.
729 outlet_exact_color : str, optional
730 Color for exact outlet curve. Default 'blue'.
731 outlet_binned_color : str, optional
732 Color for bin-averaged outlet. Default 'red'.
733 first_arrival_color : str, optional
734 Color for first arrival marker. Default 'green'.
736 Returns
737 -------
738 fig : matplotlib.figure.Figure
739 Figure object.
740 axes : dict
741 Dictionary with keys 'vt', 'inlet', 'outlet' containing axes objects.
742 """
743 # Create figure with 3-panel layout
744 fig = plt.figure(figsize=figsize)
745 gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.3)
747 axes = {}
749 # Top left: V-t diagram
750 ax_vt = fig.add_subplot(gs[0, 0])
751 plot_vt_diagram(
752 structure["tracker_state"],
753 ax=ax_vt,
754 show_inactive=show_inactive,
755 show_events=show_events,
756 t_max=t_max,
757 )
758 ax_vt.set_title("V-t Diagram", fontsize=12, fontweight="bold")
759 axes["vt"] = ax_vt
761 # Top right: Inlet concentration
762 ax_inlet = fig.add_subplot(gs[0, 1])
763 plot_inlet_concentration(
764 tedges,
765 cin,
766 ax=ax_inlet,
767 t_first_arrival=structure["t_first_arrival"],
768 color=inlet_color,
769 t_max=t_max,
770 )
771 axes["inlet"] = ax_inlet
773 # Bottom: Outlet concentration (exact and bin-averaged)
774 ax_outlet = fig.add_subplot(gs[1, :])
776 # Compute time range
777 if t_max is None:
778 t_max = (tedges.to_numpy()[-1] - tedges.to_numpy()[0]) / pd.Timedelta(days=1)
780 # Exact breakthrough curve
781 if show_exact:
782 t_exact = np.linspace(0, t_max, 1000)
783 c_exact = [
784 concentration_at_point(
785 structure["tracker_state"].v_outlet,
786 t,
787 structure["tracker_state"].waves,
788 structure["tracker_state"].sorption,
789 )
790 for t in t_exact
791 ]
792 ax_outlet.plot(
793 t_exact,
794 c_exact,
795 color=outlet_exact_color,
796 linewidth=2.5,
797 label="Exact outlet concentration",
798 zorder=3,
799 )
801 # Bin-averaged outlet
802 if show_bin_averaged:
803 t_edges_days = ((cout_tedges - cout_tedges[0]) / pd.Timedelta(days=1)).values
804 xstep_cout, ystep_cout = np.repeat(t_edges_days, 2)[1:-1], np.repeat(cout, 2)
805 ax_outlet.plot(
806 xstep_cout,
807 ystep_cout,
808 color=outlet_binned_color,
809 linestyle="--",
810 linewidth=1.5,
811 alpha=0.7,
812 label="Bin-averaged outlet",
813 zorder=2,
814 )
816 # First arrival marker
817 t_first = structure["t_first_arrival"]
818 if np.isfinite(t_first):
819 ax_outlet.axvline(
820 t_first,
821 color=first_arrival_color,
822 linestyle="--",
823 linewidth=1.5,
824 alpha=0.7,
825 label=f"First arrival ({t_first:.1f} days)",
826 zorder=1,
827 )
829 ax_outlet.set_xlabel("Time [days]", fontsize=11)
830 ax_outlet.set_ylabel("Concentration", fontsize=11)
831 ax_outlet.set_title("Outlet Concentration: Exact vs Bin-Averaged", fontsize=12, fontweight="bold")
832 ax_outlet.grid(True, alpha=0.3)
833 ax_outlet.legend(fontsize=9)
834 ax_outlet.set_xlim(0, t_max)
835 axes["outlet"] = ax_outlet
837 # Overall title
838 if title is not None:
839 plt.suptitle(title, fontsize=14, fontweight="bold", y=0.995)
841 return fig
844def plot_sorption_comparison(
845 pulse_favorable_structure,
846 pulse_unfavorable_structure,
847 pulse_tedges,
848 pulse_cin,
849 dip_favorable_structure,
850 dip_unfavorable_structure,
851 dip_tedges,
852 dip_cin,
853 *,
854 figsize=(16, 12),
855 t_max_pulse=None,
856 t_max_dip=None,
857):
858 """
859 Compare how each inlet produces different outputs with n>1 vs n<1 sorption.
861 Creates a 2x3 grid:
862 - Row 1: Pulse inlet and its outputs with n>1 and n<1 sorption
863 - Row 2: Dip inlet and its outputs with n>1 and n<1 sorption
865 This demonstrates how the SAME inlet timeseries produces DIFFERENT breakthrough
866 curves depending on the sorption isotherm.
868 Parameters
869 ----------
870 pulse_favorable_structure : dict
871 Structure from pulse inlet with n>1 (higher C travels faster).
872 pulse_unfavorable_structure : dict
873 Structure from pulse inlet with n<1 (lower C travels faster).
874 pulse_tedges : pandas.DatetimeIndex
875 Time bin edges for pulse inlet.
876 Length = len(pulse_cin) + 1.
877 pulse_cin : array-like
878 Pulse inlet concentration (e.g., 0→10→0).
879 Length = len(pulse_tedges) - 1.
880 dip_favorable_structure : dict
881 Structure from dip inlet with n>1 (higher C travels faster).
882 dip_unfavorable_structure : dict
883 Structure from dip inlet with n<1 (lower C travels faster).
884 dip_tedges : pandas.DatetimeIndex
885 Time bin edges for dip inlet.
886 Length = len(dip_cin) + 1.
887 dip_cin : array-like
888 Dip inlet concentration (e.g., 10→2→10).
889 Length = len(dip_tedges) - 1.
890 figsize : tuple, optional
891 Figure size (width, height). Default (16, 12).
892 t_max_pulse : float, optional
893 Max time for pulse plots [days]. If None, auto-computed.
894 t_max_dip : float, optional
895 Max time for dip plots [days]. If None, auto-computed.
897 Returns
898 -------
899 fig : matplotlib.figure.Figure
900 Figure object.
901 axes : numpy.ndarray
902 2x3 array of axes objects.
903 """
904 fig, axes = plt.subplots(2, 3, figsize=figsize)
905 fig.suptitle(
906 "Sorption Comparison: How Each Inlet Responds to n>1 vs n<1 Sorption",
907 fontsize=15,
908 fontweight="bold",
909 y=0.995,
910 )
912 # Compute time ranges
913 if t_max_pulse is None:
914 t_max_pulse = (pulse_tedges.to_numpy()[-1] - pulse_tedges.to_numpy()[0]) / pd.Timedelta(days=1)
915 if t_max_dip is None:
916 t_max_dip = (dip_tedges.to_numpy()[-1] - dip_tedges.to_numpy()[0]) / pd.Timedelta(days=1)
918 # === ROW 1: Pulse inlet (0→10→0) ===
919 t_days_pulse = (pulse_tedges.to_numpy() - pulse_tedges.to_numpy()[0]) / pd.Timedelta(days=1)
921 # Column 1: Pulse inlet
922 ax_pulse_inlet = axes[0, 0]
923 x_pulse, y_pulse = np.repeat(t_days_pulse, 2)[1:-1], np.repeat(pulse_cin, 2)
924 ax_pulse_inlet.plot(x_pulse, y_pulse, linewidth=2.5, color="black")
925 ax_pulse_inlet.set_xlabel("Time [days]", fontsize=10)
926 ax_pulse_inlet.set_ylabel("Concentration", fontsize=10)
927 ax_pulse_inlet.set_title("Pulse Inlet\n(0→10→0)", fontsize=11, fontweight="bold")
928 ax_pulse_inlet.grid(True, alpha=0.3)
929 ax_pulse_inlet.set_xlim(0, t_max_pulse)
931 # Column 2: Pulse → n>1 outlet
932 ax_pulse_fav = axes[0, 1]
933 t_exact_pulse_fav = np.linspace(0, t_max_pulse, 1500)
934 c_exact_pulse_fav = [
935 concentration_at_point(
936 pulse_favorable_structure["tracker_state"].v_outlet,
937 t,
938 pulse_favorable_structure["tracker_state"].waves,
939 pulse_favorable_structure["tracker_state"].sorption,
940 )
941 for t in t_exact_pulse_fav
942 ]
943 ax_pulse_fav.plot(t_exact_pulse_fav, c_exact_pulse_fav, "b-", linewidth=2.5)
944 ax_pulse_fav.set_xlabel("Time [days]", fontsize=10)
945 ax_pulse_fav.set_ylabel("Concentration", fontsize=10)
946 ax_pulse_fav.set_title("n>1\nShock→Rarefaction", fontsize=11, fontweight="bold", color="darkblue")
947 ax_pulse_fav.grid(True, alpha=0.3)
948 ax_pulse_fav.set_xlim(0, t_max_pulse)
949 ax_pulse_fav.text(
950 0.05,
951 0.95,
952 "High C: FAST\nRise: Sharp\nFall: Smooth",
953 transform=ax_pulse_fav.transAxes,
954 verticalalignment="top",
955 bbox={"boxstyle": "round", "facecolor": "lightblue", "alpha": 0.7},
956 fontsize=8,
957 )
959 # Column 3: Pulse → n<1 outlet
960 ax_pulse_unfav = axes[0, 2]
961 t_exact_pulse_unfav = np.linspace(0, t_max_pulse, 1500)
962 c_exact_pulse_unfav = [
963 concentration_at_point(
964 pulse_unfavorable_structure["tracker_state"].v_outlet,
965 t,
966 pulse_unfavorable_structure["tracker_state"].waves,
967 pulse_unfavorable_structure["tracker_state"].sorption,
968 )
969 for t in t_exact_pulse_unfav
970 ]
971 ax_pulse_unfav.plot(t_exact_pulse_unfav, c_exact_pulse_unfav, "r-", linewidth=2.5)
972 ax_pulse_unfav.set_xlabel("Time [days]", fontsize=10)
973 ax_pulse_unfav.set_ylabel("Concentration", fontsize=10)
974 ax_pulse_unfav.set_title("n<1\nRarefaction→Shock", fontsize=11, fontweight="bold", color="darkred")
975 ax_pulse_unfav.grid(True, alpha=0.3)
976 ax_pulse_unfav.set_xlim(0, t_max_pulse)
977 ax_pulse_unfav.text(
978 0.05,
979 0.95,
980 "High C: SLOW\nRise: Smooth\nFall: Sharp",
981 transform=ax_pulse_unfav.transAxes,
982 verticalalignment="top",
983 bbox={"boxstyle": "round", "facecolor": "lightcoral", "alpha": 0.7},
984 fontsize=8,
985 )
987 # === ROW 2: Dip inlet (10→2→10) ===
988 t_days_dip = (dip_tedges.to_numpy() - dip_tedges.to_numpy()[0]) / pd.Timedelta(days=1)
990 # Column 1: Dip inlet
991 ax_dip_inlet = axes[1, 0]
992 x_dip, y_dip = np.repeat(t_days_dip, 2)[1:-1], np.repeat(dip_cin, 2)
993 ax_dip_inlet.plot(x_dip, y_dip, linewidth=2.5, color="black")
994 ax_dip_inlet.set_xlabel("Time [days]", fontsize=10)
995 ax_dip_inlet.set_ylabel("Concentration", fontsize=10)
996 ax_dip_inlet.set_title("Dip Inlet\n(10→2→10)", fontsize=11, fontweight="bold")
997 ax_dip_inlet.grid(True, alpha=0.3)
998 ax_dip_inlet.set_xlim(0, t_max_dip)
1000 # Column 2: Dip → n>1 outlet
1001 ax_dip_fav = axes[1, 1]
1002 t_exact_dip_fav = np.linspace(0, t_max_dip, 1500)
1003 c_exact_dip_fav = [
1004 concentration_at_point(
1005 dip_favorable_structure["tracker_state"].v_outlet,
1006 t,
1007 dip_favorable_structure["tracker_state"].waves,
1008 dip_favorable_structure["tracker_state"].sorption,
1009 )
1010 for t in t_exact_dip_fav
1011 ]
1012 ax_dip_fav.plot(t_exact_dip_fav, c_exact_dip_fav, "b-", linewidth=2.5)
1013 ax_dip_fav.set_xlabel("Time [days]", fontsize=10)
1014 ax_dip_fav.set_ylabel("Concentration", fontsize=10)
1015 ax_dip_fav.set_title("n>1\nRarefaction→Shock", fontsize=11, fontweight="bold", color="darkblue")
1016 ax_dip_fav.grid(True, alpha=0.3)
1017 ax_dip_fav.set_xlim(0, t_max_dip)
1018 ax_dip_fav.text(
1019 0.05,
1020 0.95,
1021 "High C: FAST\nDrop: Smooth\nRise: Sharp",
1022 transform=ax_dip_fav.transAxes,
1023 verticalalignment="top",
1024 bbox={"boxstyle": "round", "facecolor": "lightblue", "alpha": 0.7},
1025 fontsize=8,
1026 )
1028 # Column 3: Dip → n<1 outlet
1029 ax_dip_unfav = axes[1, 2]
1030 t_exact_dip_unfav = np.linspace(0, t_max_dip, 1500)
1031 c_exact_dip_unfav = [
1032 concentration_at_point(
1033 dip_unfavorable_structure["tracker_state"].v_outlet,
1034 t,
1035 dip_unfavorable_structure["tracker_state"].waves,
1036 dip_unfavorable_structure["tracker_state"].sorption,
1037 )
1038 for t in t_exact_dip_unfav
1039 ]
1040 ax_dip_unfav.plot(t_exact_dip_unfav, c_exact_dip_unfav, "r-", linewidth=2.5)
1041 ax_dip_unfav.set_xlabel("Time [days]", fontsize=10)
1042 ax_dip_unfav.set_ylabel("Concentration", fontsize=10)
1043 ax_dip_unfav.set_title("n<1\nShock→Rarefaction", fontsize=11, fontweight="bold", color="darkred")
1044 ax_dip_unfav.grid(True, alpha=0.3)
1045 ax_dip_unfav.set_xlim(0, t_max_dip)
1046 ax_dip_unfav.text(
1047 0.05,
1048 0.95,
1049 "High C: SLOW\nDrop: Sharp\nRise: Smooth",
1050 transform=ax_dip_unfav.transAxes,
1051 verticalalignment="top",
1052 bbox={"boxstyle": "round", "facecolor": "lightcoral", "alpha": 0.7},
1053 fontsize=8,
1054 )
1056 return fig, axes