From c8a9995a12e3d5760ab0c7a4851ab64c6bef00f8 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 14:54:26 +0100 Subject: [PATCH 01/12] Added a button_height attribute to RailButtons with a default value of 15mm and updated the docstring --- rocketpy/rocket/aero_surface/rail_buttons.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index c27e2949a..e8266168d 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -19,12 +19,17 @@ class RailButtons(AeroSurface): relative to one of the other principal axis. RailButtons.angular_position_rad : float Angular position of the rail buttons in radians. + RailButtons.button_height : float + Height (standoff distance) of the rail button from the rocket + body surface to the rail contact point, in meters. Used for + calculating bending moments at the attachment point. """ def __init__( self, buttons_distance, angular_position=45, + button_height=0.015, name="Rail Buttons", rocket_radius=None, ): @@ -48,6 +53,7 @@ def __init__( super().__init__(name, None, None) self.buttons_distance = buttons_distance self.angular_position = angular_position + self.button_height = button_height self.name = name self.rocket_radius = rocket_radius self.evaluate_lift_coefficient() @@ -104,6 +110,7 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument return { "buttons_distance": self.buttons_distance, "angular_position": self.angular_position, + "button_height": self.button_height, "name": self.name, "rocket_radius": self.rocket_radius, } @@ -113,6 +120,7 @@ def from_dict(cls, data): return cls( data["buttons_distance"], data["angular_position"], + data.get("button_height", 0.015), data["name"], data["rocket_radius"], ) From b30e40c91ec2ab5c4232960ff1159c4a64251e4e Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 15:47:51 +0100 Subject: [PATCH 02/12] added a _calculate_rail_button_bending_moments to calculate the desired attributes --- rocketpy/simulation/flight.py | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 30ea66466..e3943c730 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -470,6 +470,18 @@ class Flight: array. Flight.simulation_mode : str Simulation mode for the flight. Can be "6 DOF" or "3 DOF". + Flight.rail_button1_bending_moment : Function + Internal bending moment at upper rail button attachment point in N·m + as a function of time. Calculated using beam theory during rail phase. + Flight.max_rail_button1_bending_moment : float + Maximum internal bending moment experienced at upper rail button + attachment point during rail flight phase in N·m. + Flight.rail_button2_bending_moment : Function + Internal bending moment at lower rail button attachment point in N·m + as a function of time. Calculated using beam theory during rail phase. + Flight.max_rail_button2_bending_moment : float + Maximum internal bending moment experienced at lower rail button + attachment point during rail flight phase in N·m. """ def __init__( # pylint: disable=too-many-arguments,too-many-statements @@ -3958,3 +3970,62 @@ def __lt__(self, other): otherwise. """ return self.t < other.t + def _calculate_rail_button_bending_moments(self): + """ + Calculate internal bending moments at rail button attachment points. + + Uses beam theory with simple support assumptions (ΣF≠0, M_contact=0) + to determine internal structural moments for stress analysis. + + The bending moment at each button consists of two components: + 1. Main bending from the opposing button's normal force (M = N × L) + 2. Additional moment from shear force at button tip (M = S × h) + + Returns + ------- + None + """ + # Check if rail buttons exist + if len(self.rocket.rail_buttons) == 0: + warnings.warn( + "Trying to calculate rail button bending moments without " + "rail buttons defined. Setting moments to zero.", + UserWarning, + ) + null_moment = Function(0) + self.rail_button1_bending_moment = null_moment + self.rail_button2_bending_moment = null_moment + self.max_rail_button1_bending_moment = 0.0 + self.max_rail_button2_bending_moment = 0.0 + return + #distance between points + button1_pos = self.rocket.rail_buttons.component_coordinate_system_orientation * (self.rocket.rail_buttons.position) + button2_pos = button1_pos + self.rocket.rail_buttons.buttons_distance + d1 = abs(button1_pos - self.rocket.center_of_mass_without_motor) + d2 = abs(button2_pos - self.rocket.center_of_mass_without_motor) + L = d1 + d2 # Distance between buttons + h_button = self.rocket.rail_buttons.button_height #rail button height + #forces + N1 = self.rail_button1_normal_force + N2 = self.rail_button2_normal_force + S1 = self.rail_button1_shear_force + S2 = self.rail_button2_shear_force + t = N1.source[:, 0] + # Main bending from opposing button + additional moment from shear at tip + M1_values = N2.source[:, 1] * L + S1.source[:, 1] * h_button + M2_values = N1.source[:, 1] * L + S2.source[:, 1] * h_button + self.rail_button1_bending_moment = Function( + np.column_stack([t, M1_values]), + inputs="Time (s)", + outputs="Bending Moment (N·m)", + interpolation="linear", + ) + self.rail_button2_bending_moment = Function( + np.column_stack([t, M2_values]), + inputs="Time (s)", + outputs="Bending Moment (N·m)", + interpolation="linear", + ) + # Maximum bending moments in terms of absolute value for stress calculations + self.max_rail_button1_bending_moment = float(np.max(np.abs(M1_values))) + self.max_rail_button2_bending_moment = float(np.max(np.abs(M2_values))) \ No newline at end of file From 28fb82dd8607ebfb9e76f471e86796a36e28021b Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 16:02:37 +0100 Subject: [PATCH 03/12] fixed new method added properties --- rocketpy/simulation/flight.py | 40 +++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index e3943c730..a2e07858b 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3970,20 +3970,23 @@ def __lt__(self, other): otherwise. """ return self.t < other.t - def _calculate_rail_button_bending_moments(self): + @cachedproperty + def calculate_rail_button_bending_moments(self): """ Calculate internal bending moments at rail button attachment points. - + Uses beam theory with simple support assumptions (ΣF≠0, M_contact=0) to determine internal structural moments for stress analysis. - + The bending moment at each button consists of two components: 1. Main bending from the opposing button's normal force (M = N × L) 2. Additional moment from shear force at button tip (M = S × h) - + Returns ------- - None + tuple + (rail_button1_bending_moment, max_rail_button1_bending_moment, + rail_button2_bending_moment, max_rail_button2_bending_moment) """ # Check if rail buttons exist if len(self.rocket.rail_buttons) == 0: @@ -4028,4 +4031,29 @@ def _calculate_rail_button_bending_moments(self): ) # Maximum bending moments in terms of absolute value for stress calculations self.max_rail_button1_bending_moment = float(np.max(np.abs(M1_values))) - self.max_rail_button2_bending_moment = float(np.max(np.abs(M2_values))) \ No newline at end of file + self.max_rail_button2_bending_moment = float(np.max(np.abs(M2_values))) + return ( + self.rail_button1_bending_moment, + self.max_rail_button1_bending_moment, + self.rail_button2_bending_moment, + self.max_rail_button2_bending_moment, + ) + @property + def rail_button1_bending_moment(self): + """Upper rail button bending moment as a Function of time.""" + return self.calculate_rail_button_bending_moments[0] + + @property + def max_rail_button1_bending_moment(self): + """Maximum upper rail button bending moment, in N·m.""" + return self.calculate_rail_button_bending_moments[1] + + @property + def rail_button2_bending_moment(self): + """Lower rail button bending moment as a Function of time.""" + return self.calculate_rail_button_bending_moments[2] + + @property + def max_rail_button2_bending_moment(self): + """Maximum lower rail button bending moment, in N·m.""" + return self.calculate_rail_button_bending_moments[3] \ No newline at end of file From 77cece065a3546ddd9c7e941465959e00aefb6e7 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 16:07:36 +0100 Subject: [PATCH 04/12] fixed typo --- rocketpy/simulation/flight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index a2e07858b..bf77c70e5 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3970,7 +3970,7 @@ def __lt__(self, other): otherwise. """ return self.t < other.t - @cachedproperty + @cached_property def calculate_rail_button_bending_moments(self): """ Calculate internal bending moments at rail button attachment points. From 4bf04d5573b6c87f84437d723ad1a2a6dfb1212e Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 18:48:37 +0100 Subject: [PATCH 05/12] fixed code, added documentation, updated changelog, added tests --- CHANGELOG.md | 1 + docs/user/flight.rst | 30 +++++ rocketpy/simulation/flight.py | 104 +++++++++++------ .../unit/test_rail_buttons_bending_moments.py | 108 ++++++++++++++++++ 4 files changed, 207 insertions(+), 36 deletions(-) create mode 100644 tests/unit/test_rail_buttons_bending_moments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5503705ea..35d1add52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added +-- 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) - 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) diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 17dde836b..a4f697243 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -266,6 +266,36 @@ The Flight object provides access to all forces and accelerations acting on the M2 = flight.M2 # Pitch moment M3 = flight.M3 # Yaw moment +Rail Button Forces and Bending Moments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +During the rail launch phase, RocketPy calculates reaction forces and internal bending moments at the rail button attachment points: + +**Rail Button Forces (N):** + +- ``rail_button1_normal_force`` : Normal reaction force at upper rail button +- ``rail_button1_shear_force`` : Shear (tangential) reaction force at upper rail button +- ``rail_button2_normal_force`` : Normal reaction force at lower rail button +- ``rail_button2_shear_force`` : Shear (tangential) reaction force at lower rail button + +**Rail Button Bending Moments (N⋅m):** + +- ``rail_button1_bending_moment`` : Time-dependent bending moment at upper rail button attachment +- ``max_rail_button1_bending_moment`` : Maximum absolute bending moment at upper rail button +- ``rail_button2_bending_moment`` : Time-dependent bending moment at lower rail button attachment +- ``max_rail_button2_bending_moment`` : Maximum absolute bending moment at lower rail button + +These bending moments are calculated using beam theory, combining: +1. Shear force x button height (cantilever moment) +2. Normal reaction forces x distance to center of dry mass (lever arm moment) + +The moments are zero after rail departure and represent internal structural loads for airframe and fastener stress analysis. + +.. note:: + Requires rail buttons to be defined on the Rocket using ``rocket.set_rail_buttons()``. + See Issue #893 for implementation details. + + Attitude and Orientation ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index bf77c70e5..fd0300875 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3970,74 +3970,106 @@ def __lt__(self, other): otherwise. """ return self.t < other.t + @cached_property def calculate_rail_button_bending_moments(self): """ Calculate internal bending moments at rail button attachment points. - Uses beam theory with simple support assumptions (ΣF≠0, M_contact=0) - to determine internal structural moments for stress analysis. + Uses beam theory to determine internal structural moments for stress + analysis of the rail button attachments (fasteners and airframe). + + The bending moment at each button attachment consists of: + 1. Bending from shear force at button contact point: M = S × h + where S is the shear (tangential) force and h is button height + 2. Direct moment contribution from the button's reaction forces - The bending moment at each button consists of two components: - 1. Main bending from the opposing button's normal force (M = N × L) - 2. Additional moment from shear force at button tip (M = S × h) + Notes + ----- + - Calculated only during the rail phase of flight + - Maximum values use absolute values for worst-case stress analysis + - The bending moments represent internal stresses in the rocket + airframe at the rail button attachment points Returns ------- tuple - (rail_button1_bending_moment, max_rail_button1_bending_moment, - rail_button2_bending_moment, max_rail_button2_bending_moment) + (rail_button1_bending_moment : Function, + max_rail_button1_bending_moment : float, + rail_button2_bending_moment : Function, + max_rail_button2_bending_moment : float) + + Where rail_button1/2_bending_moment are Function objects of time + in N·m, and max values are floats in N·m. """ # Check if rail buttons exist + null_moment = Function(0) if len(self.rocket.rail_buttons) == 0: warnings.warn( "Trying to calculate rail button bending moments without " "rail buttons defined. Setting moments to zero.", UserWarning, ) - null_moment = Function(0) - self.rail_button1_bending_moment = null_moment - self.rail_button2_bending_moment = null_moment - self.max_rail_button1_bending_moment = 0.0 - self.max_rail_button2_bending_moment = 0.0 - return - #distance between points - button1_pos = self.rocket.rail_buttons.component_coordinate_system_orientation * (self.rocket.rail_buttons.position) - button2_pos = button1_pos + self.rocket.rail_buttons.buttons_distance - d1 = abs(button1_pos - self.rocket.center_of_mass_without_motor) - d2 = abs(button2_pos - self.rocket.center_of_mass_without_motor) - L = d1 + d2 # Distance between buttons - h_button = self.rocket.rail_buttons.button_height #rail button height - #forces + return (null_moment, 0.0, null_moment, 0.0) + + # Get rail button geometry + rail_buttons_tuple = self.rocket.rail_buttons[0] + upper_button_position = ( + rail_buttons_tuple.component.buttons_distance + + rail_buttons_tuple.position.z + ) + lower_button_position = rail_buttons_tuple.position.z + + # Signed distances from buttons to center of dry mass + D1 = upper_button_position - self.rocket.center_of_dry_mass_position( + self.rocket._csys + ) + D2 = lower_button_position - self.rocket.center_of_dry_mass_position( + self.rocket._csys + ) + d1 = abs(D1) + d2 = abs(D2) + + # Rail button standoff height + h_button = rail_buttons_tuple.component.button_height + + # forces N1 = self.rail_button1_normal_force N2 = self.rail_button2_normal_force S1 = self.rail_button1_shear_force S2 = self.rail_button2_shear_force t = N1.source[:, 0] - # Main bending from opposing button + additional moment from shear at tip - M1_values = N2.source[:, 1] * L + S1.source[:, 1] * h_button - M2_values = N1.source[:, 1] * L + S2.source[:, 1] * h_button - self.rail_button1_bending_moment = Function( - np.column_stack([t, M1_values]), + + # Calculate bending moments at attachment points + # Primary contribution from shear force acting at button height + # Secondary contribution from normal force creating moment about attachment + m1_values = N2.source[:, 1] * d2 + S1.source[:, 1] * h_button + m2_values = N1.source[:, 1] * d1 + S2.source[:, 1] * h_button + + rail_button1_bending_moment = Function( + np.column_stack([t, m1_values]), inputs="Time (s)", outputs="Bending Moment (N·m)", interpolation="linear", ) - self.rail_button2_bending_moment = Function( - np.column_stack([t, M2_values]), + rail_button2_bending_moment = Function( + np.column_stack([t, m2_values]), inputs="Time (s)", outputs="Bending Moment (N·m)", interpolation="linear", ) - # Maximum bending moments in terms of absolute value for stress calculations - self.max_rail_button1_bending_moment = float(np.max(np.abs(M1_values))) - self.max_rail_button2_bending_moment = float(np.max(np.abs(M2_values))) + + # Maximum bending moments (absolute value for stress calculations) + max_rail_button1_bending_moment = float(np.max(np.abs(m1_values))) + max_rail_button2_bending_moment = float(np.max(np.abs(m2_values))) + return ( - self.rail_button1_bending_moment, - self.max_rail_button1_bending_moment, - self.rail_button2_bending_moment, - self.max_rail_button2_bending_moment, + rail_button1_bending_moment, + max_rail_button1_bending_moment, + rail_button2_bending_moment, + max_rail_button2_bending_moment, ) + @property def rail_button1_bending_moment(self): """Upper rail button bending moment as a Function of time.""" @@ -4056,4 +4088,4 @@ def rail_button2_bending_moment(self): @property def max_rail_button2_bending_moment(self): """Maximum lower rail button bending moment, in N·m.""" - return self.calculate_rail_button_bending_moments[3] \ No newline at end of file + return self.calculate_rail_button_bending_moments[3] diff --git a/tests/unit/test_rail_buttons_bending_moments.py b/tests/unit/test_rail_buttons_bending_moments.py new file mode 100644 index 000000000..a3a620b55 --- /dev/null +++ b/tests/unit/test_rail_buttons_bending_moments.py @@ -0,0 +1,108 @@ +"""Unit tests for RailButtons bending moment formulas and calculation logic. + + +These tests focus on verifying the mathematical correctness and realism +of the calculate_rail_button_bending_moments method in isolation. +""" + +import warnings + +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.rocket.aero_surface.rail_buttons import RailButtons + + +def test_bending_moment_zero_without_rail_buttons(): + """Verify bending moments return zero functions if no rail buttons present.""" + + class NoRailButtonsMock: + """Mock Flight class with no rail buttons for testing zero moment case.""" + + def __init__(self): + self.rocket = type("", (), {})() + self.rocket.rail_buttons = [] + self.rocket.center_of_dry_mass_position = lambda x: 0 + self.rocket._csys = 1 + + def calculate_rail_button_bending_moments(self): + null_moment = Function(0) + if len(self.rocket.rail_buttons) == 0: + warnings.warn( + "Trying to calculate rail button bending moments without " + "rail buttons defined. Setting moments to zero.", + UserWarning, + ) + return (null_moment, 0.0, null_moment, 0.0) + + flight = NoRailButtonsMock() + moments = flight.calculate_rail_button_bending_moments() + + m1_func, max_m1, m2_func, max_m2 = moments + + # Verify types + assert isinstance(m1_func, Function) + assert isinstance(m2_func, Function) + assert isinstance(max_m1, float) + assert isinstance(max_m2, float) + + # Verify zero functions - check first few values instead of full source + assert m1_func(0) == 0 + assert m1_func(1) == 0 + assert m2_func(0) == 0 + assert m2_func(1) == 0 + assert max_m1 == 0.0 + assert max_m2 == 0.0 + + +def test_railbuttons_serialization_roundtrip(): + """Test RailButtons to_dict and from_dict serialization roundtrip.""" + rb_orig = RailButtons( + buttons_distance=0.7, + angular_position=60, + button_height=0.02, + name="Test Rail Buttons", + rocket_radius=0.045, + ) + + data = rb_orig.to_dict() + rb_reconstructed = RailButtons.from_dict(data) + + assert rb_reconstructed.buttons_distance == rb_orig.buttons_distance + assert rb_reconstructed.angular_position == rb_orig.angular_position + assert rb_reconstructed.button_height == rb_orig.button_height + assert rb_reconstructed.name == rb_orig.name + assert rb_reconstructed.rocket_radius == rb_orig.rocket_radius + + +def test_railbuttons_serialization_backward_compat(): + """Test backward compatibility when button_height is missing from dict.""" + # Simulate old serialized data without button_height + old_data = { + "buttons_distance": 0.5, + "angular_position": 45, + "name": "Legacy Buttons", + "rocket_radius": 0.05, + } + + rb = RailButtons.from_dict(old_data) + assert rb.button_height == 0.015 # Should use default + + +def test_railbuttons_angular_position_rad_property(): + """Test angular_position_rad property calculation.""" + rb = RailButtons(buttons_distance=0.5, angular_position=30) + expected_rad = np.radians(30) + assert np.isclose(rb.angular_position_rad, expected_rad, rtol=1e-10) + + +def test_railbuttons_no_aero_contribution(): + """Test RailButtons provide zero aerodynamic contributions.""" + rb = RailButtons(buttons_distance=0.5) + + rb.evaluate_center_of_pressure() + assert rb.cp == (0, 0, 0) + + rb.evaluate_lift_coefficient() + assert rb.clalpha(1.0) == 0 # Zero lift derivative + assert rb.cl(0.1, 1.0) == 0 # Zero lift coefficient From 618b37371f2f0290d24803411bf5911b1a1f4cb5 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 19:15:04 +0100 Subject: [PATCH 06/12] formatting --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d1add52..a91a671ec 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: Rail button bending moments calculation in Flight class [#893](https://github.com/RocketPy-Team/RocketPy/pull/893) +- 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) - 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 4fc9581bcc170c8b629bd9a6573b873a00819351 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 19:28:38 +0100 Subject: [PATCH 07/12] FIX: Handle center_of_dry_mass_position as property or callable --- rocketpy/simulation/flight.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index fd0300875..12f716eaf 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -4020,15 +4020,15 @@ def calculate_rail_button_bending_moments(self): ) lower_button_position = rail_buttons_tuple.position.z - # Signed distances from buttons to center of dry mass - D1 = upper_button_position - self.rocket.center_of_dry_mass_position( - self.rocket._csys - ) - D2 = lower_button_position - self.rocket.center_of_dry_mass_position( - self.rocket._csys - ) - d1 = abs(D1) - d2 = abs(D2) + # Get center of dry mass (handle both callable and property) + if callable(self.rocket.center_of_dry_mass_position): + cdm = self.rocket.center_of_dry_mass_position(self.rocket._csys) + else: + cdm = self.rocket.center_of_dry_mass_position + + # Distances from buttons to center of dry mass + d1 = abs(upper_button_position - cdm) + d2 = abs(lower_button_position - cdm) # Rail button standoff height h_button = rail_buttons_tuple.component.button_height From df0500f95ff472eec03d3aa36f8d4d6d61e833e2 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 20:00:17 +0100 Subject: [PATCH 08/12] ENH: Address review feedback - make button_height optional (None), add prints/plots, improve documentation --- rocketpy/plots/flight_plots.py | 67 ++++++++ rocketpy/prints/flight_prints.py | 31 ++++ rocketpy/rocket/aero_surface/rail_buttons.py | 8 +- rocketpy/simulation/flight.py | 80 ++++++---- .../unit/test_rail_buttons_bending_moments.py | 150 +++++++++++++++--- 5 files changed, 277 insertions(+), 59 deletions(-) diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index b2d3ae8a3..fc495ef3c 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -395,6 +395,70 @@ def angular_kinematics_data(self, *, filename=None): # pylint: disable=too-many plt.subplots_adjust(hspace=0.5) show_or_save_plot(filename) + def rail_buttons_bending_moments(self, *, filename=None): + """Prints out Rail Buttons Bending Moments graphs. + + Parameters + ---------- + filename : str | None, optional + The path the plot should be saved to. By default None, in which case + the plot will be shown instead of saved. Supported file endings are: + eps, jpg, jpeg, pdf, pgf, png, ps, raw, rgba, svg, svgz, tif, tiff + and webp (these are the formats supported by matplotlib). + + Returns + ------- + None + """ + if len(self.flight.rocket.rail_buttons) == 0: + print( + "No rail buttons were defined. Skipping rail button bending moment plots." + ) + elif self.flight.out_of_rail_time_index == 0: + print("No rail phase was found. Skipping rail button bending moment plots.") + else: + # Check if button_height is defined + rail_buttons_tuple = self.flight.rocket.rail_buttons[0] + if rail_buttons_tuple.component.button_height is None: + print("Rail button height not defined. Skipping bending moment plots.") + else: + plt.figure(figsize=(9, 3)) + + ax1 = plt.subplot(111) + ax1.plot( + self.flight.rail_button1_bending_moment[ + : self.flight.out_of_rail_time_index, 0 + ], + self.flight.rail_button1_bending_moment[ + : self.flight.out_of_rail_time_index, 1 + ], + label="Upper Rail Button", + ) + ax1.plot( + self.flight.rail_button2_bending_moment[ + : self.flight.out_of_rail_time_index, 0 + ], + self.flight.rail_button2_bending_moment[ + : self.flight.out_of_rail_time_index, 1 + ], + label="Lower Rail Button", + ) + ax1.set_xlim( + 0, + ( + self.flight.out_of_rail_time + if self.flight.out_of_rail_time > 0 + else self.flight.tFinal + ), + ) + ax1.legend() + ax1.grid(True) + ax1.set_xlabel("Time (s)") + ax1.set_ylabel("Bending Moment (N·m)") + ax1.set_title("Rail Button Bending Moments") + + show_or_save_plot(filename) + def rail_buttons_forces(self, *, filename=None): # pylint: disable=too-many-statements """Prints out all Rail Buttons Forces graphs available about the Flight. @@ -959,6 +1023,9 @@ def all(self): # pylint: disable=too-many-statements print("\n\nAerodynamic Forces Plots\n") self.aerodynamic_forces() + print("\n\nRail Buttons Bending Moments Plots\n") + self.rail_buttons_bending_moments() + print("\n\nRail Buttons Forces Plots\n") self.rail_buttons_forces() diff --git a/rocketpy/prints/flight_prints.py b/rocketpy/prints/flight_prints.py index d098fd776..1c0e3cb0c 100644 --- a/rocketpy/prints/flight_prints.py +++ b/rocketpy/prints/flight_prints.py @@ -358,6 +358,34 @@ def maximum_values(self): f"{self.flight.max_rail_button2_shear_force:.3f} N" ) + def rail_button_bending_moments(self): + """Prints rail button bending moment data. + + Returns + ------- + None + """ + if ( + len(self.flight.rocket.rail_buttons) == 0 + or self.flight.out_of_rail_time_index == 0 + ): + return + + # Check if button_height is defined + rail_buttons_tuple = self.flight.rocket.rail_buttons[0] + if rail_buttons_tuple.component.button_height is None: + return + + print("\nRail Button Bending Moments\n") + print( + "Maximum Upper Rail Button Bending Moment: " + f"{self.flight.max_rail_button1_bending_moment:.3f} N·m" + ) + print( + "Maximum Lower Rail Button Bending Moment: " + f"{self.flight.max_rail_button2_bending_moment:.3f} N·m" + ) + def stability_margin(self): """Prints out the stability margins of the flight at different times. @@ -429,5 +457,8 @@ def all(self): self.maximum_values() print() + self.rail_button_bending_moments() + print() + self.numerical_integration_settings() print() diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index e8266168d..89331c99f 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -19,17 +19,19 @@ class RailButtons(AeroSurface): relative to one of the other principal axis. RailButtons.angular_position_rad : float Angular position of the rail buttons in radians. - RailButtons.button_height : float + RailButtons.button_height : float, optional Height (standoff distance) of the rail button from the rocket body surface to the rail contact point, in meters. Used for calculating bending moments at the attachment point. + Default is None. If not provided, bending moments cannot be + calculated but flight dynamics remain unaffected. """ def __init__( self, buttons_distance, angular_position=45, - button_height=0.015, + button_height=None, name="Rail Buttons", rocket_radius=None, ): @@ -120,7 +122,7 @@ def from_dict(cls, data): return cls( data["buttons_distance"], data["angular_position"], - data.get("button_height", 0.015), + data.get("button_height", None), data["name"], data["rocket_radius"], ) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 12f716eaf..daa8719f8 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3974,33 +3974,47 @@ def __lt__(self, other): @cached_property def calculate_rail_button_bending_moments(self): """ - Calculate internal bending moments at rail button attachment points. - - Uses beam theory to determine internal structural moments for stress - analysis of the rail button attachments (fasteners and airframe). - - The bending moment at each button attachment consists of: - 1. Bending from shear force at button contact point: M = S × h - where S is the shear (tangential) force and h is button height - 2. Direct moment contribution from the button's reaction forces - - Notes - ----- - - Calculated only during the rail phase of flight - - Maximum values use absolute values for worst-case stress analysis - - The bending moments represent internal stresses in the rocket - airframe at the rail button attachment points - - Returns - ------- - tuple - (rail_button1_bending_moment : Function, - max_rail_button1_bending_moment : float, - rail_button2_bending_moment : Function, - max_rail_button2_bending_moment : float) - - Where rail_button1/2_bending_moment are Function objects of time - in N·m, and max values are floats in N·m. + Calculate internal bending moments at rail button attachment points. + + Uses beam theory to determine internal structural moments for stress + analysis of the rail button attachments (fasteners and airframe). + + The bending moment at each button attachment consists of: + 1. Bending from shear force at button contact point: M = S × h + where S is the shear (tangential) force and h is button height + 2. Direct moment contribution from the button's reaction forces + + Assumptions + ----------- + - Rail buttons act as simple supports: provide reaction forces (normal + and shear) but no moment reaction at the rail contact point. + - The rocket acts as a beam supported at two points (rail buttons). + - Bending moments arise from the lever arm effect of reaction forces + and the cantilever moment from button standoff height. + + The bending moment at each button attachment consists of: + 1. Normal force moment: M = N x d, where N is normal reaction force + and d is distance from button to center of dry mass + 2. Shear force cantilever moment: M = S x h, where S is shear force + and h is button standoff height + + Notes + ----- + - Calculated only during the rail phase of flight + - Maximum values use absolute values for worst-case stress analysis + - The bending moments represent internal stresses in the rocket + airframe at the rail button attachment points + + Returns + ------- + tuple + (rail_button1_bending_moment : Function, + max_rail_button1_bending_moment : float, + rail_button2_bending_moment : Function, + max_rail_button2_bending_moment : float) + + Where rail_button1/2_bending_moment are Function objects of time + in N·m, and max values are floats in N·m. """ # Check if rail buttons exist null_moment = Function(0) @@ -4014,6 +4028,15 @@ def calculate_rail_button_bending_moments(self): # Get rail button geometry rail_buttons_tuple = self.rocket.rail_buttons[0] + # Rail button standoff height + h_button = rail_buttons_tuple.component.button_height + if h_button is None: + warnings.warn( + "Rail button height not defined. Bending moments cannot be " + "calculated. Setting moments to zero.", + UserWarning, + ) + return (null_moment, 0.0, null_moment, 0.0) upper_button_position = ( rail_buttons_tuple.component.buttons_distance + rail_buttons_tuple.position.z @@ -4030,9 +4053,6 @@ def calculate_rail_button_bending_moments(self): d1 = abs(upper_button_position - cdm) d2 = abs(lower_button_position - cdm) - # Rail button standoff height - h_button = rail_buttons_tuple.component.button_height - # forces N1 = self.rail_button1_normal_force N2 = self.rail_button2_normal_force diff --git a/tests/unit/test_rail_buttons_bending_moments.py b/tests/unit/test_rail_buttons_bending_moments.py index a3a620b55..c398af023 100644 --- a/tests/unit/test_rail_buttons_bending_moments.py +++ b/tests/unit/test_rail_buttons_bending_moments.py @@ -1,43 +1,34 @@ """Unit tests for RailButtons bending moment formulas and calculation logic. - These tests focus on verifying the mathematical correctness and realism of the calculate_rail_button_bending_moments method in isolation. """ import warnings +import matplotlib.pyplot as plt import numpy as np +from rocketpy import Environment, Flight from rocketpy.mathutils.function import Function from rocketpy.rocket.aero_surface.rail_buttons import RailButtons -def test_bending_moment_zero_without_rail_buttons(): - """Verify bending moments return zero functions if no rail buttons present.""" - - class NoRailButtonsMock: - """Mock Flight class with no rail buttons for testing zero moment case.""" - - def __init__(self): - self.rocket = type("", (), {})() - self.rocket.rail_buttons = [] - self.rocket.center_of_dry_mass_position = lambda x: 0 - self.rocket._csys = 1 +def test_bending_moment_zero_without_rail_buttons(calisto_motorless): + """Verify bending moments return zero functions if no rail buttons present. - def calculate_rail_button_bending_moments(self): - null_moment = Function(0) - if len(self.rocket.rail_buttons) == 0: - warnings.warn( - "Trying to calculate rail button bending moments without " - "rail buttons defined. Setting moments to zero.", - UserWarning, - ) - return (null_moment, 0.0, null_moment, 0.0) - - flight = NoRailButtonsMock() - moments = flight.calculate_rail_button_bending_moments() + Parameters + ---------- + calisto_motorless : rocketpy.Rocket + Basic rocket without rail buttons. + """ + # Create a flight with this rocket (no rail buttons) + env = Environment(latitude=0, longitude=0) + env.set_atmospheric_model(type="standard_atmosphere") + flight = Flight(rocket=calisto_motorless, environment=env, rail_length=1) + # Should return zero moments + moments = flight.calculate_rail_button_bending_moments m1_func, max_m1, m2_func, max_m2 = moments # Verify types @@ -46,7 +37,7 @@ def calculate_rail_button_bending_moments(self): assert isinstance(max_m1, float) assert isinstance(max_m2, float) - # Verify zero functions - check first few values instead of full source + # Verify zero functions assert m1_func(0) == 0 assert m1_func(1) == 0 assert m2_func(0) == 0 @@ -55,6 +46,46 @@ def calculate_rail_button_bending_moments(self): assert max_m2 == 0.0 +def test_bending_moment_zero_with_none_button_height(calisto_motorless): + """Verify bending moments return zero when button_height is None. + + Parameters + ---------- + calisto_motorless : rocketpy.Rocket + Basic rocket fixture. + """ + # Add rail buttons with None height (default) + calisto_motorless.set_rail_buttons( + upper_button_position=0.5, + lower_button_position=-0.5, + angular_position=45, + ) + + # Verify button_height is None + assert calisto_motorless.rail_buttons[0].component.button_height is None + + # Create flight + env = Environment(latitude=0, longitude=0) + env.set_atmospheric_model(type="standard_atmosphere") + flight = Flight(rocket=calisto_motorless, environment=env, rail_length=1) + + # Should warn and return zero moments + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + moments = flight.calculate_rail_button_bending_moments + + # Verify warning was issued + assert len(w) == 1 + assert "button height not defined" in str(w[0].message).lower() + + m1_func, max_m1, _, max_m2 = moments + + # Verify zero moments returned + assert m1_func(0) == 0 + assert max_m1 == 0.0 + assert max_m2 == 0.0 + + def test_railbuttons_serialization_roundtrip(): """Test RailButtons to_dict and from_dict serialization roundtrip.""" rb_orig = RailButtons( @@ -86,7 +117,7 @@ def test_railbuttons_serialization_backward_compat(): } rb = RailButtons.from_dict(old_data) - assert rb.button_height == 0.015 # Should use default + assert rb.button_height is None # Should use None default def test_railbuttons_angular_position_rad_property(): @@ -106,3 +137,70 @@ def test_railbuttons_no_aero_contribution(): rb.evaluate_lift_coefficient() assert rb.clalpha(1.0) == 0 # Zero lift derivative assert rb.cl(0.1, 1.0) == 0 # Zero lift coefficient + + +def test_rail_button_bending_moments_prints(flight_calisto_robust, capsys): + """Test that bending moments are printed correctly in flight.prints. + + Parameters + ---------- + flight_calisto_robust : rocketpy.Flight + Flight fixture with motor and rail buttons. + capsys : pytest fixture + Captures stdout/stderr. + """ + # Set button height on existing rail buttons + flight_calisto_robust.rocket.rail_buttons[0].component.button_height = 0.02 + + # Call the print method + flight_calisto_robust.prints.rail_button_bending_moments() + + # Capture output + captured = capsys.readouterr() + + # Verify output contains bending moment data + assert "Rail Button Bending Moments" in captured.out + assert "Maximum Upper Rail Button Bending Moment" in captured.out + assert "Maximum Lower Rail Button Bending Moment" in captured.out + assert "N·m" in captured.out + + +def test_rail_button_bending_moments_plot_with_height(flight_calisto_robust): + """Test that bending moments plot is created when button_height is defined. + + Parameters + ---------- + flight_calisto_robust : rocketpy.Flight + Flight fixture with motor and rail buttons. + """ + # Set button height on existing rail buttons + flight_calisto_robust.rocket.rail_buttons[0].component.button_height = 0.02 + + # Should not raise an error + flight_calisto_robust.plots.rail_buttons_bending_moments() + + # Close the figure to avoid memory issues + plt.close("all") + + +def test_rail_button_bending_moments_plot_without_height(flight_calisto_robust, capsys): + """Test that bending moments plot is skipped when button_height is None. + + Parameters + ---------- + flight_calisto_robust : rocketpy.Flight + Flight fixture with motor and rail buttons. + capsys : pytest fixture + Captures stdout/stderr. + """ + # Ensure button_height is None (it should be by default now) + flight_calisto_robust.rocket.rail_buttons[0].component.button_height = None + + # Call plot method + flight_calisto_robust.plots.rail_buttons_bending_moments() + + # Capture output + captured = capsys.readouterr() + + # Should print skip message + assert "height not defined" in captured.out From cc4fa74658d1274b66a1d7078d08d23384273342 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 2 Dec 2025 20:05:55 +0100 Subject: [PATCH 09/12] clarified documentation(assumptions) in flight.rst --- docs/user/flight.rst | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/user/flight.rst b/docs/user/flight.rst index a4f697243..31e7ab588 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -285,15 +285,18 @@ During the rail launch phase, RocketPy calculates reaction forces and internal b - ``rail_button2_bending_moment`` : Time-dependent bending moment at lower rail button attachment - ``max_rail_button2_bending_moment`` : Maximum absolute bending moment at lower rail button -These bending moments are calculated using beam theory, combining: -1. Shear force x button height (cantilever moment) -2. Normal reaction forces x distance to center of dry mass (lever arm moment) +**Calculation Method:** -The moments are zero after rail departure and represent internal structural loads for airframe and fastener stress analysis. +Bending moments are calculated using beam theory assuming simple supports (rail buttons provide reaction forces but no moment reaction at rail contact). The total moment combines: + +1. Shear force × button height (cantilever moment from button standoff) +2. Normal force × distance to center of dry mass (lever arm effect) + +Moments are zero after rail departure and represent internal structural loads for airframe and fastener stress analysis. Requires ``button_height`` to be defined when adding rail buttons via ``rocket.set_rail_buttons()``. .. note:: - Requires rail buttons to be defined on the Rocket using ``rocket.set_rail_buttons()``. - See Issue #893 for implementation details. + See Issue #893 for implementation details and validation approach. + Attitude and Orientation From d2df51e44fb8758f21fe0d052efbff45423f8064 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 3 Dec 2025 13:51:11 +0100 Subject: [PATCH 10/12] TST: Split default vs explicit None button_height tests --- .../unit/test_rail_buttons_bending_moments.py | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_rail_buttons_bending_moments.py b/tests/unit/test_rail_buttons_bending_moments.py index c398af023..2463f3476 100644 --- a/tests/unit/test_rail_buttons_bending_moments.py +++ b/tests/unit/test_rail_buttons_bending_moments.py @@ -47,45 +47,62 @@ def test_bending_moment_zero_without_rail_buttons(calisto_motorless): def test_bending_moment_zero_with_none_button_height(calisto_motorless): - """Verify bending moments return zero when button_height is None. + """Verify bending moments return zero when button_height is explicitly None. Parameters ---------- calisto_motorless : rocketpy.Rocket Basic rocket fixture. """ - # Add rail buttons with None height (default) + from rocketpy import Environment, Flight + + # Create rail buttons, then explicitly set height to None calisto_motorless.set_rail_buttons( upper_button_position=0.5, lower_button_position=-0.5, angular_position=45, ) + calisto_motorless.rail_buttons[0].component.button_height = None # explicit None - # Verify button_height is None + # Sanity check assert calisto_motorless.rail_buttons[0].component.button_height is None - # Create flight env = Environment(latitude=0, longitude=0) env.set_atmospheric_model(type="standard_atmosphere") flight = Flight(rocket=calisto_motorless, environment=env, rail_length=1) - # Should warn and return zero moments with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") moments = flight.calculate_rail_button_bending_moments - - # Verify warning was issued assert len(w) == 1 assert "button height not defined" in str(w[0].message).lower() m1_func, max_m1, _, max_m2 = moments - - # Verify zero moments returned assert m1_func(0) == 0 assert max_m1 == 0.0 assert max_m2 == 0.0 +def test_bending_moment_zero_with_default_button_height(calisto_motorless): + """Verify bending moments return zero when button_height uses default value. + + Parameters + ---------- + calisto_motorless : rocketpy.Rocket + Basic rocket fixture. + """ + # Add rail buttons without specifying button_height (tests default) + calisto_motorless.set_rail_buttons( + upper_button_position=0.5, + lower_button_position=-0.5, + angular_position=45, + # button_height not specified - should default to None + ) + + # Verify default is None + assert calisto_motorless.rail_buttons[0].component.button_height is None + + def test_railbuttons_serialization_roundtrip(): """Test RailButtons to_dict and from_dict serialization roundtrip.""" rb_orig = RailButtons( From 1dabcc44397be784ede18c181f0db5f5b36eab22 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 3 Dec 2025 13:53:09 +0100 Subject: [PATCH 11/12] make format --- tests/unit/test_rail_buttons_bending_moments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_rail_buttons_bending_moments.py b/tests/unit/test_rail_buttons_bending_moments.py index 2463f3476..e68d3a4d3 100644 --- a/tests/unit/test_rail_buttons_bending_moments.py +++ b/tests/unit/test_rail_buttons_bending_moments.py @@ -85,7 +85,7 @@ def test_bending_moment_zero_with_none_button_height(calisto_motorless): def test_bending_moment_zero_with_default_button_height(calisto_motorless): """Verify bending moments return zero when button_height uses default value. - + Parameters ---------- calisto_motorless : rocketpy.Rocket @@ -98,7 +98,7 @@ def test_bending_moment_zero_with_default_button_height(calisto_motorless): angular_position=45, # button_height not specified - should default to None ) - + # Verify default is None assert calisto_motorless.rail_buttons[0].component.button_height is None From 679b63b292a8d186ca390934062ad634483dea7d Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 3 Dec 2025 14:00:45 +0100 Subject: [PATCH 12/12] MNT: Fix pylint config and remove duplicate test imports --- tests/unit/test_rail_buttons_bending_moments.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_rail_buttons_bending_moments.py b/tests/unit/test_rail_buttons_bending_moments.py index e68d3a4d3..b61720d3d 100644 --- a/tests/unit/test_rail_buttons_bending_moments.py +++ b/tests/unit/test_rail_buttons_bending_moments.py @@ -54,7 +54,6 @@ def test_bending_moment_zero_with_none_button_height(calisto_motorless): calisto_motorless : rocketpy.Rocket Basic rocket fixture. """ - from rocketpy import Environment, Flight # Create rail buttons, then explicitly set height to None calisto_motorless.set_rail_buttons(