11import numpy as np
22import matplotlib .pyplot as plt
3+ from matplotlib import gridspec
34from 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
615class 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
0 commit comments