Skip to content

Commit a5d67c7

Browse files
ENH: add animations for motor propellant mass and tank fluid volumes (#894)
* ENH: add animations for motor propellant mass and tank fluid volumes * DOC: update changelog for animation enhancement * TST:add test_show_or_save_animation_unsupported_format and solve some problems * TST:add tests for the methods animate_propellant_mass and animate_fluid_volume and solve linters issues * DOC:update documentation docs/user/motors/liquidmotor.rst and docs/user/motors/tanks.rst * fix hangelog * fix changelog --------- Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com>
1 parent afb3e3e commit a5d67c7

7 files changed

Lines changed: 288 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Attention: The newest changes should be on top -->
3232

3333
### Added
3434

35+
- ENH: add animations for motor propellant mass and tank fluid volumes [#894](https://github.com/RocketPy-Team/RocketPy/pull/894)
3536
- ENH: Add axial_acceleration attribute to the Flight class [#876](https://github.com/RocketPy-Team/RocketPy/pull/876)
3637
- ENH: Rail button bending moments calculation in Flight class [#893](https://github.com/RocketPy-Team/RocketPy/pull/893)
3738
- ENH: Built-in flight comparison tool (`FlightComparator`) to validate simulations against external data [#888](https://github.com/RocketPy-Team/RocketPy/pull/888)

docs/user/motors/liquidmotor.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,21 @@ For example:
160160

161161
example_liquid.exhaust_velocity.plot(0, 5)
162162

163+
The tanks added to a ``LiquidMotor`` can now be animated to visualize
164+
how the liquid and gas volumes evolve during the burn.
165+
166+
To animate the tanks, we can use the ``animate_fluid_volume()`` method:
167+
168+
.. jupyter-execute::
169+
170+
example_liquid.animate_fluid_volume(fps=30)
171+
172+
Optionally, the animation can be saved to a GIF file:
173+
174+
.. jupyter-execute::
175+
176+
example_liquid.animate_fluid_volume(fps=30, save_as="liquid_motor.gif")
177+
163178
Alternatively, you can plot all the information at once:
164179

165180
.. jupyter-execute::

docs/user/motors/tanks.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,21 @@ We can see some outputs with:
263263
# Evolution of the Propellant center of mass position
264264
N2O_mass_tank.center_of_mass.plot()
265265

266+
All tank types now include a built-in method for animating the evolution
267+
of liquid and gas volumes over time. This visualization aids in understanding the dynamic behavior
268+
of the tank's contents. To animate the tanks, we can use the
269+
``animate_fluid_volume()`` method:
270+
271+
.. jupyter-execute::
272+
273+
N2O_mass_tank.animate_fluid_volume(fps=30)
274+
275+
Optionally, the animation can be saved to a GIF file:
276+
277+
.. jupyter-execute::
278+
279+
N2O_mass_tank.animate_fluid_volume(fps=30, save_as="mass_based_tank.gif")
280+
266281

267282
Ullage Based Tank
268283
-----------------

rocketpy/plots/motor_plots.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import matplotlib.pyplot as plt
22
import numpy as np
33
from matplotlib.patches import Polygon
4+
from matplotlib.animation import FuncAnimation
45

5-
from ..plots.plot_helpers import show_or_save_plot
6+
from ..plots.plot_helpers import show_or_save_plot, show_or_save_animation
67

78

89
class _MotorPlots:
@@ -520,6 +521,71 @@ def _set_plot_properties(self, ax):
520521
plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left")
521522
plt.tight_layout()
522523

524+
def animate_propellant_mass(self, filename=None, fps=30):
525+
"""Animates the propellant mass of the motor as a function of time.
526+
527+
Parameters
528+
----------
529+
filename : str | None, optional
530+
The path the animation should be saved to. By default None, in which
531+
case the animation will be shown instead of saved.Supported file
532+
ending is: .gif
533+
fps : int, optional
534+
Frames per second for the animation. Default is 30.
535+
536+
Returns
537+
-------
538+
matplotlib.animation.FuncAnimation
539+
The created animation object.
540+
"""
541+
542+
# Extract time and mass data
543+
times = self.motor.propellant_mass.x_array
544+
values = self.motor.propellant_mass.y_array
545+
546+
# Create figure and axis
547+
fig, ax = plt.subplots()
548+
549+
# Configure axis
550+
ax.set_xlim(times[0], times[-1])
551+
ax.set_ylim(min(values), max(values))
552+
ax.set_xlabel("Time (s)")
553+
ax.set_ylabel("Propellant Mass (kg)")
554+
ax.set_title("Propellant Mass Evolution")
555+
556+
# Create line and current point marker
557+
(line,) = ax.plot([], [], lw=2, color="blue", label="Propellant Mass")
558+
(point,) = ax.plot([], [], "ko")
559+
560+
ax.legend()
561+
562+
# Initialization
563+
def init():
564+
line.set_data([], [])
565+
point.set_data([], [])
566+
return line, point
567+
568+
# Update per frame
569+
def update(frame_index):
570+
line.set_data(times[: frame_index + 1], values[: frame_index + 1])
571+
point.set_data([times[frame_index]], [values[frame_index]])
572+
return line, point
573+
574+
# Build animation
575+
animation = FuncAnimation(
576+
fig,
577+
update,
578+
frames=len(times),
579+
init_func=init,
580+
interval=1000 / fps,
581+
blit=True,
582+
)
583+
584+
# Show or save animation
585+
show_or_save_animation(animation, filename, fps=fps)
586+
587+
return animation
588+
523589
def all(self):
524590
"""Prints out all graphs available about the Motor. It simply calls
525591
all the other plotter methods in this class.

rocketpy/plots/plot_helpers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,37 @@ def show_or_save_fig(fig: Figure, filename=None):
6565
Path(filename).parent.mkdir(parents=True, exist_ok=True)
6666

6767
fig.savefig(filename, dpi=SAVEFIG_DPI)
68+
69+
70+
def show_or_save_animation(animation, filename=None, fps=30):
71+
"""Shows or saves the given matplotlib animation. If a filename is given,
72+
the animation will be saved. Otherwise, it will be shown.
73+
74+
Parameters
75+
----------
76+
animation : matplotlib.animation.FuncAnimation
77+
The animation object to be saved or shown.
78+
filename : str | None, optional
79+
The path the animation should be saved to, by default None. Supported
80+
file ending is: gif.
81+
fps : int, optional
82+
Frames per second when saving the animation. Default is 30.
83+
"""
84+
if filename is None:
85+
plt.show()
86+
else:
87+
file_ending = Path(filename).suffix
88+
supported_endings = [".gif"]
89+
90+
if file_ending not in supported_endings:
91+
raise ValueError(
92+
f"Unsupported file ending '{file_ending}'. "
93+
f"Supported file endings are: {supported_endings}."
94+
)
95+
96+
# Before export, ensure the folder the file should go into exists
97+
Path(filename).parent.mkdir(parents=True, exist_ok=True)
98+
99+
animation.save(filename, fps=fps, writer="pillow")
100+
101+
plt.close()

rocketpy/plots/tank_plots.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import matplotlib.pyplot as plt
22
import numpy as np
33
from matplotlib.patches import Polygon
4+
from matplotlib.animation import FuncAnimation
45

56
from rocketpy.mathutils.function import Function
67

7-
from .plot_helpers import show_or_save_plot
8+
from .plot_helpers import show_or_save_plot, show_or_save_animation
89

910

1011
class _TankPlots:
@@ -180,6 +181,77 @@ def fluid_center_of_mass(self, filename=None):
180181
ax.legend(["Liquid", "Gas", "Total"])
181182
show_or_save_plot(filename)
182183

184+
def animate_fluid_volume(self, filename=None, fps=30):
185+
"""Animates the liquid and gas volumes inside the tank as a function of time.
186+
187+
Parameters
188+
----------
189+
filename : str | None, optional
190+
The path the animation should be saved to. By default None, in which
191+
case the animation will be shown instead of saved. Supported file
192+
ending is: .gif
193+
fps : int, optional
194+
Frames per second for the animation. Default is 30.
195+
196+
Returns
197+
-------
198+
matplotlib.animation.FuncAnimation
199+
The created animation object.
200+
"""
201+
202+
t_start, t_end = self.flux_time
203+
times = np.linspace(t_start, t_end, 200)
204+
205+
liquid_values = self.tank.liquid_volume.get_value(times)
206+
gas_values = self.tank.gas_volume.get_value(times)
207+
208+
fig, ax = plt.subplots()
209+
210+
ax.set_xlim(times[0], times[-1])
211+
ax.set_ylim(0, max(liquid_values.max(), gas_values.max()) * 1.1)
212+
213+
ax.set_xlabel("Time (s)")
214+
ax.set_ylabel("Volume (m³)")
215+
ax.set_title("Liquid/Gas Volume Evolution")
216+
(line_liquid,) = ax.plot([], [], lw=2, color="blue", label="Liquid Volume")
217+
(line_gas,) = ax.plot([], [], lw=2, color="red", label="Gas Volume")
218+
219+
(point_liquid,) = ax.plot([], [], "ko")
220+
(point_gas,) = ax.plot([], [], "ko")
221+
222+
ax.legend()
223+
224+
def init():
225+
for item in (line_liquid, line_gas, point_liquid, point_gas):
226+
item.set_data([], [])
227+
return line_liquid, line_gas, point_liquid, point_gas
228+
229+
def update(frame_index):
230+
# Liquid part
231+
line_liquid.set_data(
232+
times[: frame_index + 1], liquid_values[: frame_index + 1]
233+
)
234+
point_liquid.set_data([times[frame_index]], [liquid_values[frame_index]])
235+
236+
# Gas part
237+
line_gas.set_data(times[: frame_index + 1], gas_values[: frame_index + 1])
238+
point_gas.set_data([times[frame_index]], [gas_values[frame_index]])
239+
240+
return line_liquid, line_gas, point_liquid, point_gas
241+
242+
animation = FuncAnimation(
243+
fig,
244+
update,
245+
frames=len(times),
246+
init_func=init,
247+
interval=1000 / fps,
248+
blit=True,
249+
)
250+
251+
show_or_save_animation(animation, filename, fps=fps)
252+
253+
return animation
254+
183255
def all(self):
184256
"""Prints out all graphs available about the Tank. It simply calls
185257
all the other plotter methods in this class.

tests/unit/test_plots.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
from unittest.mock import MagicMock, patch
33

44
import matplotlib.pyplot as plt
5+
from matplotlib.animation import FuncAnimation
56
import pytest
67

78
from rocketpy.plots.compare import Compare
8-
from rocketpy.plots.plot_helpers import show_or_save_fig, show_or_save_plot
9+
from rocketpy.plots.plot_helpers import (
10+
show_or_save_fig,
11+
show_or_save_plot,
12+
show_or_save_animation,
13+
)
914

1015

1116
@patch("matplotlib.pyplot.show")
@@ -89,3 +94,80 @@ def test_show_or_save_fig(filename):
8994
else:
9095
assert os.path.exists(filename)
9196
os.remove(filename)
97+
98+
99+
@pytest.mark.parametrize("filename", [None, "test.gif"])
100+
@patch("matplotlib.pyplot.show")
101+
def test_show_or_save_animation(mock_show, filename):
102+
"""This test is to check if the show_or_save_animation function is
103+
working properly.
104+
105+
Parameters
106+
----------
107+
mock_show :
108+
Mocks the matplotlib.pyplot.show() function to avoid showing the animation.
109+
filename : str
110+
Name of the file to save the animation. If None, the animation will be
111+
shown instead.
112+
"""
113+
114+
# Create a simple animation object
115+
fig, ax = plt.subplots()
116+
117+
def update(frame):
118+
ax.plot([0, frame], [0, frame])
119+
return ax
120+
121+
animation = FuncAnimation(fig, update, frames=5)
122+
123+
show_or_save_animation(animation, filename)
124+
125+
if filename is None:
126+
mock_show.assert_called_once()
127+
else:
128+
assert os.path.exists(filename)
129+
os.remove(filename)
130+
131+
132+
def test_show_or_save_animation_unsupported_format():
133+
# Test that show_or_save_animation raises ValueError for unsupported formats.
134+
fig, ax = plt.subplots()
135+
136+
def update(frame):
137+
ax.plot([0, frame], [0, frame])
138+
return ax
139+
140+
animation = FuncAnimation(fig, update, frames=5)
141+
142+
with pytest.raises(ValueError, match="Unsupported file ending"):
143+
show_or_save_animation(animation, "test.mp4")
144+
145+
146+
def test_animate_propellant_mass(cesaroni_m1670):
147+
"""Test that animate_propellant_mass saves a .gif file correctly."""
148+
149+
motor = cesaroni_m1670
150+
animation = motor.plots.animate_propellant_mass(filename="cesaroni_m1670.gif")
151+
152+
# Check animation type
153+
assert isinstance(animation, FuncAnimation)
154+
155+
# check if file exists
156+
assert os.path.exists("cesaroni_m1670.gif")
157+
158+
os.remove("cesaroni_m1670.gif")
159+
160+
161+
def test_animate_fluid_volume(example_mass_flow_rate_based_tank_seblm):
162+
"""Test that animate_fluid_volume saves a .gif file correctly."""
163+
164+
tank = example_mass_flow_rate_based_tank_seblm
165+
animation = tank.plots.animate_fluid_volume(filename="test_fluid_volume.gif")
166+
167+
# Check animation type
168+
assert isinstance(animation, FuncAnimation)
169+
170+
# Check if file exists
171+
assert os.path.exists("test_fluid_volume.gif")
172+
173+
os.remove("test_fluid_volume.gif")

0 commit comments

Comments
 (0)