Skip to content

Commit 545c82c

Browse files
authored
Merge pull request #2 from LIBRA-project/Hippolyte
correlations, I/O handling, and visualization features
2 parents b810ab7 + 84ac0e5 commit 545c82c

6 files changed

Lines changed: 990 additions & 205 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,8 @@ cython_debug/
205205
marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208+
209+
.vscode/
210+
*.yaml
211+
*.csv
212+
mwe.py

animation.py

Lines changed: 226 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,74 @@
11
import numpy as np
22
import matplotlib.pyplot as plt
3+
from matplotlib import gridspec
34
from matplotlib.widgets import Slider, Button
5+
from model import SimulationResults
6+
7+
molar_mass_T2 = 3.016 * 2 # g/mol T2
8+
specific_activity_tritium = 3.57e14 # Bq/g
9+
molT2_to_activity = molar_mass_T2 * specific_activity_tritium # Bq/mol T2
10+
sec_to_hour = 1 / 3600
11+
12+
EPS = 1e-20
413

514

615
class ConcentrationAnimator:
716
"""Interactive animation for concentration profiles over time."""
817

9-
def __init__(self, times, c_T_solutions, y_T2_solutions, x_ct, x_y):
18+
def __init__(
19+
self,
20+
results: SimulationResults,
21+
show_activity=False,
22+
figsize=None,
23+
hspace=0.4,
24+
):
1025
"""
1126
Initialize the animator with solution data.
1227
1328
Parameters:
1429
-----------
15-
times : array_like
16-
Time points
17-
c_T_solutions : array_like
18-
Total concentration solutions at each time
19-
y_T2_solutions : array_like
20-
Y-component concentration solutions at each time
21-
x_ct : array_like
22-
Spatial coordinates for total concentration
23-
x_y : array_like
24-
Spatial coordinates for y-component concentration
30+
results : SimulationResults
31+
The simulation results object containing all necessary data
32+
show_activity : bool, optional
33+
If True, convert integrated tritium amount from molT2 to activity in Bq
34+
figsize : tuple, optional
35+
Figure size as (width, height) in inches
36+
hspace : float, optional
37+
Vertical spacing between subplots
2538
"""
26-
self.times = np.array(times)
27-
self.c_T_solutions = np.array(c_T_solutions)
28-
self.y_T2_solutions = np.array(y_T2_solutions)
29-
self.x_ct = x_ct
30-
self.x_y = x_y
39+
self.times_hr = np.array(results.times) * sec_to_hour
40+
self.c_T2_solutions = np.array(results.c_T2_solutions)
41+
self.y_T2_solutions = np.array(results.y_T2_solutions)
42+
self.x_ct = results.x_ct
43+
self.x_y = results.x_y
44+
self.inventories_T2_salt = results.inventories_T2_salt
45+
self.source_T2 = (
46+
None if results.source_T2 is None else np.array(results.source_T2)
47+
)
48+
self.fluxes_T2 = (
49+
None if results.fluxes_T2 is None else np.array(results.fluxes_T2)
50+
)
51+
self.show_activity = show_activity
52+
self.figsize = figsize
53+
self.hspace = hspace
54+
55+
if self.inventories_T2_salt is not None and self.show_activity:
56+
self.inventories_T2_salt_display = (
57+
np.array(self.inventories_T2_salt) * molT2_to_activity
58+
)
59+
else:
60+
self.inventories_T2_salt_display = self.inventories_T2_salt
61+
62+
if (
63+
self.source_T2 is not None
64+
and self.source_T2.shape[0] != self.times_hr.shape[0]
65+
):
66+
raise ValueError("source_T2 must have the same length as times")
67+
if (
68+
self.fluxes_T2 is not None
69+
and self.fluxes_T2.shape[0] != self.times_hr.shape[0]
70+
):
71+
raise ValueError("fluxes_T2 must have the same length as times")
3172

3273
# Animation state
3374
self.is_animating = False
@@ -39,65 +80,190 @@ def __init__(self, times, c_T_solutions, y_T2_solutions, x_ct, x_y):
3980

4081
def _setup_plot(self):
4182
"""Setup the initial plot with subplots."""
42-
self.fig, self.axs = plt.subplots(2, sharex=True, figsize=(10, 8))
43-
plt.subplots_adjust(bottom=0.2) # Make room for controls
83+
nrows = 3 if self.inventories_T2_salt is not None else 2
84+
default_figsize = (11, 8) if nrows == 3 else (11, 6.3)
85+
self.fig = plt.figure(figsize=self.figsize or default_figsize)
86+
gs = gridspec.GridSpec(nrows, 1, figure=self.fig, hspace=self.hspace)
87+
88+
ax1 = self.fig.add_subplot(gs[0])
89+
ax2 = self.fig.add_subplot(gs[1], sharex=ax1)
90+
self.axs = [ax1, ax2]
91+
92+
if self.inventories_T2_salt is not None:
93+
# Third axis intentionally has an independent x-scale (time).
94+
ax3 = self.fig.add_subplot(gs[2])
95+
self.axs.append(ax3)
96+
97+
# Balance horizontal margins so the plot area is visually centered.
98+
plt.subplots_adjust(left=0.12, right=0.92, top=0.94, bottom=0.125)
4499

45100
# Create initial plots
46101
(self.line1,) = self.axs[0].plot(
47-
self.x_ct, self.c_T_solutions[0], "b-", linewidth=2
102+
self.x_ct, self.c_T2_solutions[0], "b-", linewidth=2
48103
)
49104
(self.line2,) = self.axs[1].plot(
50105
self.x_y, self.y_T2_solutions[0], "r-", linewidth=2
51106
)
107+
if self.inventories_T2_salt is not None:
108+
(self.line3,) = self.axs[2].plot(
109+
self.times_hr, self.inventories_T2_salt_display, "g-", linewidth=2
110+
)
111+
(self.time_marker,) = self.axs[2].plot(
112+
[self.times_hr[0]],
113+
[self.inventories_T2_salt_display[0]],
114+
"ko",
115+
markersize=6,
116+
)
117+
118+
self.ax3_secondary = None
119+
self.source_line = None
120+
self.flux_line = None
121+
self.source_marker = None
122+
self.flux_marker = None
123+
secondary_lines = []
124+
secondary_labels = []
125+
if self.source_T2 is not None or self.fluxes_T2 is not None:
126+
self.ax3_secondary = self.axs[2].twinx()
127+
if self.source_T2 is not None:
128+
(self.source_line,) = self.ax3_secondary.plot(
129+
self.times_hr,
130+
self.source_T2,
131+
color="tab:orange",
132+
linestyle=":",
133+
linewidth=1.8,
134+
)
135+
(self.source_marker,) = self.ax3_secondary.plot(
136+
[self.times_hr[0]],
137+
[self.source_T2[0]],
138+
marker="o",
139+
color="tab:orange",
140+
markersize=5,
141+
linestyle="None",
142+
)
143+
secondary_lines.append(self.source_line)
144+
secondary_labels.append(r"$S_{T_2}$")
145+
if self.fluxes_T2 is not None:
146+
(self.flux_line,) = self.ax3_secondary.plot(
147+
self.times_hr,
148+
self.fluxes_T2,
149+
color="magenta",
150+
linestyle="dashdot",
151+
linewidth=1.8,
152+
)
153+
(self.flux_marker,) = self.ax3_secondary.plot(
154+
[self.times_hr[0]],
155+
[self.fluxes_T2[0]],
156+
marker="s",
157+
color="magenta",
158+
markersize=5,
159+
linestyle="None",
160+
)
161+
secondary_lines.append(self.flux_line)
162+
secondary_labels.append(r"$\Phi_{T_2}$")
163+
164+
self.ax3_secondary.set_ylabel("Source / Flux [molT2/s]")
165+
self.ax3_secondary.grid(False)
166+
167+
sec_vals = []
168+
if self.source_T2 is not None:
169+
sec_vals.append(self.source_T2)
170+
if self.fluxes_T2 is not None:
171+
sec_vals.append(self.fluxes_T2)
172+
sec_vals = np.concatenate(sec_vals)
173+
sec_min = np.min(sec_vals)
174+
sec_max = np.max(sec_vals)
175+
self.ax3_secondary.set_ylim(
176+
(sec_min - EPS) * 0.9, (sec_max + EPS) * 1.1
177+
)
178+
179+
primary_lines = [self.line3]
180+
primary_labels = ["Inventory"]
181+
self.axs[2].legend(
182+
primary_lines + secondary_lines,
183+
primary_labels + secondary_labels,
184+
loc="best",
185+
)
52186

53187
# Setup axes properties
54-
self.axs[0].set_ylabel("Total Concentration c_T")
55-
self.axs[0].set_title(f"Total Concentration at t={self.times[0]:.1f}s")
188+
self.axs[0].set_ylabel(r"$c_{T_2} \: [molT_2/m^3]$")
189+
self.axs[0].set_title(
190+
f"Concentration profile in breeder at t={self.times_hr[0]:.1f} hr"
191+
)
56192
self.axs[0].grid(True, alpha=0.3)
57193
self.axs[0].set_ylim(
58-
self.c_T_solutions.min() * 0.9, self.c_T_solutions.max() * 1.1
194+
(self.c_T2_solutions.min() - EPS) * 0.9,
195+
(self.c_T2_solutions.max() + EPS) * 1.1,
59196
)
60197

61-
self.axs[1].set_ylabel("Y-Component Concentration y_T2")
62-
self.axs[1].set_xlabel("Position (x)")
63-
self.axs[1].set_title(f"Y-Component Concentration at t={self.times[0]:.1f}s")
198+
self.axs[1].set_ylabel(r"$y_{T_2} \: [-]$")
199+
self.axs[1].set_xlabel("Position along tank height [m]")
200+
self.axs[1].set_title(
201+
f"$T_2$ fraction in sparging gas at t={self.times_hr[0]:.1f} hr"
202+
)
64203
self.axs[1].grid(True, alpha=0.3)
65204
self.axs[1].set_ylim(
66-
self.y_T2_solutions.min() * 0.9, self.y_T2_solutions.max() * 1.1
205+
(self.y_T2_solutions.min() - EPS) * 0.9,
206+
(self.y_T2_solutions.max() + EPS) * 1.1,
67207
)
68208

209+
if self.inventories_T2_salt is not None:
210+
if self.show_activity:
211+
self.axs[2].set_ylabel(r"$A_{T} \: [Bq]$")
212+
self.axs[2].set_title("Total T activity in breeder [Bq]")
213+
else:
214+
self.axs[2].set_ylabel(r"$n_{T_2} \: [mol_{T_2}]$")
215+
self.axs[2].set_title(r"Total $T_2$ quantity in breeder [mol_{T_2}]")
216+
self.axs[2].set_xlabel("Time [hours]")
217+
self.axs[2].grid(True, alpha=0.3)
218+
self.axs[2].set_ylim(
219+
(self.inventories_T2_salt_display.min() - EPS) * 0.9,
220+
(self.inventories_T2_salt_display.max() + EPS) * 1.1,
221+
)
222+
69223
def _setup_slider(self):
70224
"""Setup the time slider."""
71-
ax_slider = plt.axes([0.2, 0.05, 0.5, 0.03])
225+
ax_slider = plt.axes([0.2, 0.03, 0.55, 0.04])
72226
self.time_slider = Slider(
73227
ax_slider,
74-
"Time (s)",
75-
self.times.min(),
76-
self.times.max(),
77-
valinit=self.times[0],
228+
"Time (hr)",
229+
self.times_hr.min(),
230+
self.times_hr.max(),
231+
valinit=self.times_hr[0],
78232
valfmt="%.1f",
79233
)
80234
self.time_slider.on_changed(self._update_plot)
81235

82236
def _setup_animation_button(self):
83237
"""Setup the animation toggle button."""
84-
ax_button = plt.axes([0.8, 0.05, 0.1, 0.05])
238+
ax_button = plt.axes([0.8, 0.03, 0.1, 0.035])
85239
self.anim_button = Button(ax_button, "Animate")
86240
self.anim_button.on_clicked(self._animate_toggle)
87241

88242
def _update_plot(self, val):
89243
"""Update the plots based on slider value."""
90244
# Find the closest time index
91245
current_time = self.time_slider.val
92-
idx = np.argmin(np.abs(self.times - current_time))
246+
idx = np.argmin(np.abs(self.times_hr - current_time))
93247

94248
# Update the plots
95-
self.line1.set_ydata(self.c_T_solutions[idx])
249+
self.line1.set_ydata(self.c_T2_solutions[idx])
96250
self.line2.set_ydata(self.y_T2_solutions[idx])
97251

98252
# Update titles
99-
self.axs[0].set_title(f"Total Concentration at t={self.times[idx]:.1f}s")
100-
self.axs[1].set_title(f"Y-Component Concentration at t={self.times[idx]:.1f}s")
253+
self.axs[0].set_title(
254+
f"Concentration profile in breeder at t={self.times_hr[idx]:.1f} hr"
255+
)
256+
self.axs[1].set_title(
257+
f"$T_2$ fraction in sparging gas at t={self.times_hr[idx]:.1f} hr"
258+
)
259+
if self.inventories_T2_salt is not None:
260+
self.time_marker.set_data(
261+
[self.times_hr[idx]], [self.inventories_T2_salt_display[idx]]
262+
)
263+
if self.source_marker is not None:
264+
self.source_marker.set_data([self.times_hr[idx]], [self.source_T2[idx]])
265+
if self.flux_marker is not None:
266+
self.flux_marker.set_data([self.times_hr[idx]], [self.fluxes_T2[idx]])
101267

102268
self.fig.canvas.draw_idle()
103269

@@ -121,10 +287,10 @@ def animate_step():
121287
return
122288

123289
current_val = self.time_slider.val
124-
next_val = current_val + (self.times.max() - self.times.min()) / 50
290+
next_val = current_val + (self.times_hr.max() - self.times_hr.min()) / 50
125291

126-
if next_val > self.times.max():
127-
next_val = self.times.min()
292+
if next_val > self.times_hr.max():
293+
next_val = self.times_hr.min()
128294

129295
self.time_slider.set_val(next_val)
130296

@@ -137,28 +303,37 @@ def show(self):
137303
plt.show()
138304

139305

140-
def create_animation(times, c_T_solutions, y_T2_solutions, x_ct, x_y):
306+
def create_animation(
307+
results: SimulationResults,
308+
show_activity=False,
309+
figsize=None,
310+
hspace=0.4,
311+
):
141312
"""
142313
Convenience function to create and show animation.
143314
144315
Parameters:
145316
-----------
146-
times : array_like
147-
Time points
148-
c_T_solutions : array_like
149-
Total concentration solutions at each time
150-
y_T2_solutions : array_like
151-
Y-component concentration solutions at each time
152-
x_ct : array_like
153-
Spatial coordinates for total concentration
154-
x_y : array_like
155-
Spatial coordinates for y-component concentration
317+
results : SimulationResults
318+
The simulation results object
319+
show_activity : bool, optional
320+
If True, convert integrated tritium amount from molT2 to activity in Bq
321+
figsize : tuple, optional
322+
Figure size as (width, height) in inches
323+
hspace : float, optional
324+
Vertical spacing between subplots
156325
157326
Returns:
158327
--------
159328
ConcentrationAnimator
160329
The animator instance
161330
"""
162-
animator = ConcentrationAnimator(times, c_T_solutions, y_T2_solutions, x_ct, x_y)
331+
332+
animator = ConcentrationAnimator(
333+
results,
334+
show_activity=show_activity,
335+
figsize=figsize,
336+
hspace=hspace,
337+
)
163338
animator.show()
164339
return animator

environment.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ channels:
44
dependencies:
55
- fenics-dolfinx
66
- matplotlib
7-
- pyvista
7+
- pyvista
8+
- pyyaml
9+
- numpy
10+
- scipy
11+
- pandas

0 commit comments

Comments
 (0)