From 58b916e35f59b5b0ec5c43a61041c819784325cc Mon Sep 17 00:00:00 2001 From: Bizo883 <15618311793@163.com> Date: Sun, 30 Nov 2025 19:35:03 +0100 Subject: [PATCH 1/7] ENH: add animations for motor propellant mass and tank fluid volumes --- rocketpy/plots/motor_plots.py | 67 +++++++++++++++++++++++++++++- rocketpy/plots/plot_helpers.py | 35 ++++++++++++++++ rocketpy/plots/tank_plots.py | 74 +++++++++++++++++++++++++++++++++- tests/unit/test_plots.py | 36 ++++++++++++++++- 4 files changed, 209 insertions(+), 3 deletions(-) diff --git a/rocketpy/plots/motor_plots.py b/rocketpy/plots/motor_plots.py index 8081371a4..adb25e518 100644 --- a/rocketpy/plots/motor_plots.py +++ b/rocketpy/plots/motor_plots.py @@ -1,8 +1,9 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Polygon +from matplotlib.animation import FuncAnimation -from ..plots.plot_helpers import show_or_save_plot +from ..plots.plot_helpers import show_or_save_plot, show_or_save_animation class _MotorPlots: @@ -520,6 +521,70 @@ def _set_plot_properties(self, ax): plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left") plt.tight_layout() + def animate_propellant_mass(self, filename=None, fps=30): + """Animates the propellant mass of the motor as a function of time. + + Parameters + ---------- + filename : str | None, optional + The path the animation should be saved to. By default None, in which + case the animation will be shown instead of saved. + fps : int, optional + Frames per second for the animation. Default is 30. + + Returns + ------- + matplotlib.animation.FuncAnimation + The created animation object. + """ + + # Extract time and mass data + times = self.motor.propellant_mass.times + values = self.motor.propellant_mass.values + + # Create figure and axis + fig, ax = plt.subplots() + + # Configure axis + ax.set_xlim(times[0], times[-1]) + ax.set_ylim(min(values), max(values)) + ax.set_xlabel("Time (s)") + ax.set_ylabel("Propellant Mass (kg)") + ax.set_title("Propellant Mass Evolution") + + # Create line and current point marker + (line,) = ax.plot([], [], lw=2, color="blue", label="Propellant Mass") + (point,) = ax.plot([], [], "ko") + + ax.legend() + + # Initialization + def init(): + line.set_data([], []) + point.set_data([], []) + return line, point + + # Update per frame + def update(frame_index): + line.set_data(times[:frame_index], values[:frame_index]) + point.set_data(times[frame_index], values[frame_index]) + return line, point + + # Build animation + animation = FuncAnimation( + fig, + update, + frames=len(times), + init_func=init, + interval=1000 / fps, + blit=True, + ) + + # Show or save animation + show_or_save_animation(animation, filename, fps=fps) + + return animation + def all(self): """Prints out all graphs available about the Motor. It simply calls all the other plotter methods in this class. diff --git a/rocketpy/plots/plot_helpers.py b/rocketpy/plots/plot_helpers.py index f795d46e9..7e1bac81f 100644 --- a/rocketpy/plots/plot_helpers.py +++ b/rocketpy/plots/plot_helpers.py @@ -65,3 +65,38 @@ def show_or_save_fig(fig: Figure, filename=None): Path(filename).parent.mkdir(parents=True, exist_ok=True) fig.savefig(filename, dpi=SAVEFIG_DPI) + + +def show_or_save_animation(animation, filename=None, fps=30): + """Shows or saves the given matplotlib animation. If a filename is given, + the animation will be saved. Otherwise, it will be shown. + + Parameters + ---------- + animation : matplotlib.animation.FuncAnimation + The animation object to be saved or shown. + filename : str | None, optional + The path the animation should be saved to, by default None. Supported + file endings is: gif. + fps : int, optional + Frames per second when saving the animation. Default is 30. + """ + if filename is None: + plt.show() + else: + file_ending = Path(filename).suffix + supported_endings = [".gif"] + + if file_ending not in supported_endings: + raise ValueError( + f"Unsupported file ending '{file_ending}'." + f"Supported file endings are: {supported_endings}." + ) + + # Before export, ensure the folder the file should go into exists + Path(filename).parent.mkdir(parents=True, exist_ok=True) + + if file_ending == ".gif": + animation.save(filename, fps=fps, writer="pillow") + + plt.close() diff --git a/rocketpy/plots/tank_plots.py b/rocketpy/plots/tank_plots.py index 9c6ebb78a..7cad4e8ad 100644 --- a/rocketpy/plots/tank_plots.py +++ b/rocketpy/plots/tank_plots.py @@ -1,10 +1,11 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib.patches import Polygon +from matplotlib.animation import FuncAnimation from rocketpy.mathutils.function import Function -from .plot_helpers import show_or_save_plot +from .plot_helpers import show_or_save_plot, show_or_save_animation class _TankPlots: @@ -180,6 +181,77 @@ def fluid_center_of_mass(self, filename=None): ax.legend(["Liquid", "Gas", "Total"]) show_or_save_plot(filename) + def animate_fluid_volume(self, filename=None, fps=30): + """Animates the liquid and gas volumes inside the tank as a function of time. + + Parameters + ---------- + filename : str | None, optional + The path the animation should be saved to. By default None, in which + case the animation will be shown instead of saved. + fps : int, optional + Frames per second for the animation. Default is 30. + + Returns + ------- + matplotlib.animation.FuncAnimation + The created animation object. + """ + + t_start, t_end = self.flux_time + times = np.linspace(t_start, t_end, 200) + + liquid_values = self.tank.liquid_volume.get_value(times) + gas_values = self.tank.gas_volume.get_value(times) + + fig, ax = plt.subplots() + + ax.set_xlim(times[0], times[-1]) + max_val = max(liquid_values.max(), gas_values.max()) + ax.set_ylim(0, max_val * 1.1) + + ax.set_xlabel("Time (s)") + ax.set_ylabel("Volume (m³)") + ax.set_title("Liquid/Gas Volume Evolution") + (line_liquid,) = ax.plot([], [], lw=2, color="blue", label="Liquid Volume") + (line_gas,) = ax.plot([], [], lw=2, color="red", label="Gas Volume") + + (point_liquid,) = ax.plot([], [], "ko") + (point_gas,) = ax.plot([], [], "ko") + + ax.legend() + + def init(): + line_liquid.set_data([], []) + line_gas.set_data([], []) + point_liquid.set_data([], []) + point_gas.set_data([], []) + return line_liquid, line_gas, point_liquid, point_gas + + def update(frame_index): + # Liquid part + line_liquid.set_data(times[:frame_index], liquid_values[:frame_index]) + point_liquid.set_data(times[frame_index], liquid_values[frame_index]) + + # Gas part + line_gas.set_data(times[:frame_index], gas_values[:frame_index]) + point_gas.set_data(times[frame_index], gas_values[frame_index]) + + return line_liquid, line_gas, point_liquid, point_gas + + animation = FuncAnimation( + fig, + update, + frames=len(times), + init_func=init, + interval=1000 / fps, + blit=True, + ) + + show_or_save_animation(animation, filename, fps=fps) + + return animation + def all(self): """Prints out all graphs available about the Tank. It simply calls all the other plotter methods in this class. diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 6d7c0cce2..51717e6c5 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -2,10 +2,11 @@ from unittest.mock import MagicMock, patch import matplotlib.pyplot as plt +from matplotlib.animation import FuncAnimation import pytest from rocketpy.plots.compare import Compare -from rocketpy.plots.plot_helpers import show_or_save_fig, show_or_save_plot +from rocketpy.plots.plot_helpers import show_or_save_fig, show_or_save_plot, show_or_save_animation @patch("matplotlib.pyplot.show") @@ -89,3 +90,36 @@ def test_show_or_save_fig(filename): else: assert os.path.exists(filename) os.remove(filename) + + +@pytest.mark.parametrize("filename", [None, "test.gif"]) +@patch("matplotlib.pyplot.show") +def test_show_or_save_animation(mock_show, filename): + """This test is to check if the show_or_save_animation function is + working properly. + + Parameters + ---------- + mock_show : + Mocks the matplotlib.pyplot.show() function to avoid showing the animation. + filename : str + Name of the file to save the animation. If None, the animation will be + shown instead. + """ + + # Create a simple animation object + fig, ax = plt.subplots() + + def update(frame): + ax.plot([0, frame], [0, frame]) + return ax + + animation = FuncAnimation(fig, update, frames=5) + + show_or_save_animation(animation, filename) + + if filename is None: + mock_show.assert_called_once() + else: + assert os.path.exists(filename) + os.remove(filename) From 553380305b4e824dd392ffce7208b9310beb0bd8 Mon Sep 17 00:00:00 2001 From: Bizo883 <15618311793@163.com> Date: Sun, 30 Nov 2025 20:00:12 +0100 Subject: [PATCH 2/7] DOC: update changelog for animation enhancement --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3aa2373..a00dce598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added - +- ENH: Add animations for motor propellant mass and tank fluid volume [#656](https://github.com/RocketPy-Team/RocketPy/issues/656) - ENH: Add save functionality to `_MonteCarloPlots.all` method [#848](https://github.com/RocketPy-Team/RocketPy/pull/848) - ENH: Add persistent caching for ThrustCurve API [#881](https://github.com/RocketPy-Team/RocketPy/pull/881) - ENH: Compatibility with MERRA-2 atmosphere reanalysis files [#825](https://github.com/RocketPy-Team/RocketPy/pull/825) From 2f87bef770f86b201a71e99f7bb2244cb95727ca Mon Sep 17 00:00:00 2001 From: Bizo883 <15618311793@163.com> Date: Mon, 1 Dec 2025 22:49:55 +0100 Subject: [PATCH 3/7] TST:add test_show_or_save_animation_unsupported_format and solve some problems --- rocketpy/plots/motor_plots.py | 11 ++++++----- rocketpy/plots/plot_helpers.py | 7 +++---- rocketpy/plots/tank_plots.py | 7 ++++--- tests/unit/test_plots.py | 20 +++++++++++++++++++- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/rocketpy/plots/motor_plots.py b/rocketpy/plots/motor_plots.py index adb25e518..78745b659 100644 --- a/rocketpy/plots/motor_plots.py +++ b/rocketpy/plots/motor_plots.py @@ -528,7 +528,8 @@ def animate_propellant_mass(self, filename=None, fps=30): ---------- filename : str | None, optional The path the animation should be saved to. By default None, in which - case the animation will be shown instead of saved. + case the animation will be shown instead of saved.Supported file + ending is: .gif fps : int, optional Frames per second for the animation. Default is 30. @@ -539,8 +540,8 @@ def animate_propellant_mass(self, filename=None, fps=30): """ # Extract time and mass data - times = self.motor.propellant_mass.times - values = self.motor.propellant_mass.values + times = self.motor.propellant_mass.x_array + values = self.motor.propellant_mass.y_array # Create figure and axis fig, ax = plt.subplots() @@ -566,8 +567,8 @@ def init(): # Update per frame def update(frame_index): - line.set_data(times[:frame_index], values[:frame_index]) - point.set_data(times[frame_index], values[frame_index]) + line.set_data(times[:frame_index+1], values[:frame_index+1]) + point.set_data([times[frame_index]], [values[frame_index]]) return line, point # Build animation diff --git a/rocketpy/plots/plot_helpers.py b/rocketpy/plots/plot_helpers.py index 7e1bac81f..629ebd22c 100644 --- a/rocketpy/plots/plot_helpers.py +++ b/rocketpy/plots/plot_helpers.py @@ -77,7 +77,7 @@ def show_or_save_animation(animation, filename=None, fps=30): The animation object to be saved or shown. filename : str | None, optional The path the animation should be saved to, by default None. Supported - file endings is: gif. + file ending is: gif. fps : int, optional Frames per second when saving the animation. Default is 30. """ @@ -89,14 +89,13 @@ def show_or_save_animation(animation, filename=None, fps=30): if file_ending not in supported_endings: raise ValueError( - f"Unsupported file ending '{file_ending}'." + f"Unsupported file ending '{file_ending}'. " f"Supported file endings are: {supported_endings}." ) # Before export, ensure the folder the file should go into exists Path(filename).parent.mkdir(parents=True, exist_ok=True) - if file_ending == ".gif": - animation.save(filename, fps=fps, writer="pillow") + animation.save(filename, fps=fps, writer="pillow") plt.close() diff --git a/rocketpy/plots/tank_plots.py b/rocketpy/plots/tank_plots.py index 7cad4e8ad..256e90cbf 100644 --- a/rocketpy/plots/tank_plots.py +++ b/rocketpy/plots/tank_plots.py @@ -188,7 +188,8 @@ def animate_fluid_volume(self, filename=None, fps=30): ---------- filename : str | None, optional The path the animation should be saved to. By default None, in which - case the animation will be shown instead of saved. + case the animation will be shown instead of saved. Supported file + ending is: .gif fps : int, optional Frames per second for the animation. Default is 30. @@ -230,11 +231,11 @@ def init(): def update(frame_index): # Liquid part - line_liquid.set_data(times[:frame_index], liquid_values[:frame_index]) + line_liquid.set_data(times[:frame_index+1], liquid_values[:frame_index+1]) point_liquid.set_data(times[frame_index], liquid_values[frame_index]) # Gas part - line_gas.set_data(times[:frame_index], gas_values[:frame_index]) + line_gas.set_data(times[:frame_index+1], gas_values[:frame_index+1]) point_gas.set_data(times[frame_index], gas_values[frame_index]) return line_liquid, line_gas, point_liquid, point_gas diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 51717e6c5..7d8f07def 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -6,7 +6,11 @@ import pytest from rocketpy.plots.compare import Compare -from rocketpy.plots.plot_helpers import show_or_save_fig, show_or_save_plot, show_or_save_animation +from rocketpy.plots.plot_helpers import ( + show_or_save_fig, + show_or_save_plot, + show_or_save_animation, +) @patch("matplotlib.pyplot.show") @@ -123,3 +127,17 @@ def update(frame): else: assert os.path.exists(filename) os.remove(filename) + + +def test_show_or_save_animation_unsupported_format(): + # Test that show_or_save_animation raises ValueError for unsupported formats. + fig, ax = plt.subplots() + + def update(frame): + ax.plot([0, frame], [0, frame]) + return ax + + animation = FuncAnimation(fig, update, frames=5) + + with pytest.raises(ValueError, match="Unsupported file ending"): + show_or_save_animation(animation, "test.mp4") From 59ab0079b169e01c1ea59425553d2371a6916b14 Mon Sep 17 00:00:00 2001 From: Bizo883 <15618311793@163.com> Date: Tue, 2 Dec 2025 15:09:02 +0100 Subject: [PATCH 4/7] TST:add tests for the methods animate_propellant_mass and animate_fluid_volume and solve linters issues --- rocketpy/plots/motor_plots.py | 4 ++-- rocketpy/plots/tank_plots.py | 10 ++++++---- tests/unit/test_plots.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/rocketpy/plots/motor_plots.py b/rocketpy/plots/motor_plots.py index 78745b659..7e8a072da 100644 --- a/rocketpy/plots/motor_plots.py +++ b/rocketpy/plots/motor_plots.py @@ -528,7 +528,7 @@ def animate_propellant_mass(self, filename=None, fps=30): ---------- filename : str | None, optional The path the animation should be saved to. By default None, in which - case the animation will be shown instead of saved.Supported file + case the animation will be shown instead of saved.Supported file ending is: .gif fps : int, optional Frames per second for the animation. Default is 30. @@ -567,7 +567,7 @@ def init(): # Update per frame def update(frame_index): - line.set_data(times[:frame_index+1], values[:frame_index+1]) + line.set_data(times[: frame_index + 1], values[: frame_index + 1]) point.set_data([times[frame_index]], [values[frame_index]]) return line, point diff --git a/rocketpy/plots/tank_plots.py b/rocketpy/plots/tank_plots.py index 256e90cbf..98e4fddf7 100644 --- a/rocketpy/plots/tank_plots.py +++ b/rocketpy/plots/tank_plots.py @@ -231,12 +231,14 @@ def init(): def update(frame_index): # Liquid part - line_liquid.set_data(times[:frame_index+1], liquid_values[:frame_index+1]) - point_liquid.set_data(times[frame_index], liquid_values[frame_index]) + line_liquid.set_data( + times[: frame_index + 1], liquid_values[: frame_index + 1] + ) + point_liquid.set_data([times[frame_index]], [liquid_values[frame_index]]) # Gas part - line_gas.set_data(times[:frame_index+1], gas_values[:frame_index+1]) - point_gas.set_data(times[frame_index], gas_values[frame_index]) + line_gas.set_data(times[: frame_index + 1], gas_values[: frame_index + 1]) + point_gas.set_data([times[frame_index]], [gas_values[frame_index]]) return line_liquid, line_gas, point_liquid, point_gas diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 7d8f07def..3883c9459 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -141,3 +141,33 @@ def update(frame): with pytest.raises(ValueError, match="Unsupported file ending"): show_or_save_animation(animation, "test.mp4") + + +def test_animate_propellant_mass(cesaroni_m1670): + """Test that animate_propellant_mass saves a .gif file correctly.""" + + motor = cesaroni_m1670 + animation = motor.plots.animate_propellant_mass(filename="cesaroni_m1670.gif") + + # Check animation type + assert isinstance(animation, FuncAnimation) + + # check if file exists + assert os.path.exists("cesaroni_m1670.gif") + + os.remove("cesaroni_m1670.gif") + + +def test_animate_fluid_volume(example_mass_flow_rate_based_tank_seblm): + """Test that animate_fluid_volume saves a .gif file correctly.""" + + tank = example_mass_flow_rate_based_tank_seblm + animation = tank.plots.animate_fluid_volume(filename="test_fluid_volume.gif") + + # Check animation type + assert isinstance(animation, FuncAnimation) + + # Check if file exists + assert os.path.exists("test_fluid_volume.gif") + + os.remove("test_fluid_volume.gif") From 6a9fc6a38c13eb43a87724ca1ad211347afda4ff Mon Sep 17 00:00:00 2001 From: Bizo883 <15618311793@163.com> Date: Wed, 3 Dec 2025 17:55:04 +0100 Subject: [PATCH 5/7] DOC:update documentation docs/user/motors/liquidmotor.rst and docs/user/motors/tanks.rst --- docs/user/motors/liquidmotor.rst | 15 +++++++++++++++ docs/user/motors/tanks.rst | 15 +++++++++++++++ rocketpy/plots/tank_plots.py | 9 +++------ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/user/motors/liquidmotor.rst b/docs/user/motors/liquidmotor.rst index 401ab62c6..6a9a22635 100644 --- a/docs/user/motors/liquidmotor.rst +++ b/docs/user/motors/liquidmotor.rst @@ -160,6 +160,21 @@ For example: example_liquid.exhaust_velocity.plot(0, 5) +The tanks added to a ``LiquidMotor`` can now be animated to visualize +how the liquid and gas volumes evolve during the burn. + +To animate the tanks, we can use the ``animate_fluid_volume()`` method: + +.. jupyter-execute:: + + example_liquid.animate_fluid_volume(fps=30) + +Optionally, the animation can be saved to a GIF file: + +.. jupyter-execute:: + + example_liquid.animate_fluid_volume(fps=30, save_as="liquid_motor.gif") + Alternatively, you can plot all the information at once: .. jupyter-execute:: diff --git a/docs/user/motors/tanks.rst b/docs/user/motors/tanks.rst index f1a3342b8..f57383476 100644 --- a/docs/user/motors/tanks.rst +++ b/docs/user/motors/tanks.rst @@ -263,6 +263,21 @@ We can see some outputs with: # Evolution of the Propellant center of mass position N2O_mass_tank.center_of_mass.plot() +All tank types now include a built-in method for animating the evolution +of liquid and gas volumes over time. This visualization aids in understanding the dynamic behavior +of the tank's contents. To animate the tanks, we can use the +``animate_fluid_volume()`` method: + +.. jupyter-execute:: + + N2O_mass_tank.animate_fluid_volume(fps=30) + +Optionally, the animation can be saved to a GIF file: + +.. jupyter-execute:: + + N2O_mass_tank.animate_fluid_volume(fps=30, save_as="mass_based_tank.gif") + Ullage Based Tank ----------------- diff --git a/rocketpy/plots/tank_plots.py b/rocketpy/plots/tank_plots.py index 98e4fddf7..68df643e7 100644 --- a/rocketpy/plots/tank_plots.py +++ b/rocketpy/plots/tank_plots.py @@ -208,8 +208,7 @@ def animate_fluid_volume(self, filename=None, fps=30): fig, ax = plt.subplots() ax.set_xlim(times[0], times[-1]) - max_val = max(liquid_values.max(), gas_values.max()) - ax.set_ylim(0, max_val * 1.1) + ax.set_ylim(0, max(liquid_values.max(), gas_values.max()) * 1.1) ax.set_xlabel("Time (s)") ax.set_ylabel("Volume (m³)") @@ -223,10 +222,8 @@ def animate_fluid_volume(self, filename=None, fps=30): ax.legend() def init(): - line_liquid.set_data([], []) - line_gas.set_data([], []) - point_liquid.set_data([], []) - point_gas.set_data([], []) + for item in (line_liquid, line_gas, point_liquid, point_gas): + item.set_data([], []) return line_liquid, line_gas, point_liquid, point_gas def update(frame_index): From 605964571c6455b2a88f1b887c865cc960e500df Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:45:52 -0300 Subject: [PATCH 6/7] fix hangelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1036740f..7566ecf1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,8 @@ Attention: The newest changes should be on top --> ### Added -- ENH: Add animations for motor propellant mass and tank fluid volume [#656](https://github.com/RocketPy-Team/RocketPy/issues/656) -- ENH: Add save functionality to `_MonteCarloPlots.all` method [#848](https://github.com/RocketPy-Team/RocketPy/pull/848) +- ENH: add animations for motor propellant mass and tank fluid volumes + [#894](https://github.com/RocketPy-Team/RocketPy/pull/894) - ENH: Add axial_acceleration attribute to the Flight class [#876](https://github.com/RocketPy-Team/RocketPy/pull/876) - ENH: Rail button bending moments calculation in Flight class [#893](https://github.com/RocketPy-Team/RocketPy/pull/893) - ENH: Built-in flight comparison tool (`FlightComparator`) to validate simulations against external data [#888](https://github.com/RocketPy-Team/RocketPy/pull/888) From c755c0ccdb2880835dcf031dc2ee46a2674bdaec Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:47:04 -0300 Subject: [PATCH 7/7] fix changelog --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7566ecf1c..b5a0edf74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,7 @@ Attention: The newest changes should be on top --> ### Added -- ENH: add animations for motor propellant mass and tank fluid volumes - [#894](https://github.com/RocketPy-Team/RocketPy/pull/894) +- ENH: add animations for motor propellant mass and tank fluid volumes [#894](https://github.com/RocketPy-Team/RocketPy/pull/894) - ENH: Add axial_acceleration attribute to the Flight class [#876](https://github.com/RocketPy-Team/RocketPy/pull/876) - ENH: Rail button bending moments calculation in Flight class [#893](https://github.com/RocketPy-Team/RocketPy/pull/893) - ENH: Built-in flight comparison tool (`FlightComparator`) to validate simulations against external data [#888](https://github.com/RocketPy-Team/RocketPy/pull/888)