From ab19b9ab61e7736f6c2f0b57f8efc940d29d091d Mon Sep 17 00:00:00 2001 From: Joshua Terranova Date: Mon, 10 Aug 2026 18:38:16 -0700 Subject: [PATCH] MNT: store ref_factor on rocket aero surface components (#561) --- rocketpy/plots/flight_plots.py | 2 +- rocketpy/plots/rocket_plots.py | 4 +- rocketpy/prints/rocket_prints.py | 5 +- rocketpy/rocket/components.py | 30 ++++++-- rocketpy/rocket/rocket.py | 20 +++-- rocketpy/simulation/flight.py | 10 +-- rocketpy/stochastic/stochastic_rocket.py | 2 +- tests/unit/rocket/test_components.py | 94 ++++++++++++++++++++++++ tests/unit/rocket/test_rocket.py | 4 +- 9 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 tests/unit/rocket/test_components.py diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 6dd5e8802..e10521fd8 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -694,7 +694,7 @@ def _rocket_axial_display_coordinate(self, value, display_length): """Map a rocket axial coordinate onto the centered display model.""" coordinates = [ float(position.z) - for _surface, position in self.flight.rocket.aerodynamic_surfaces + for _surface, position, _ref_factor in self.flight.rocket.aerodynamic_surfaces ] coordinates.extend( [ diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index 8e2b35558..5f55eaaab 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -243,7 +243,7 @@ def _draw_aerodynamic_surfaces(self, ax, vis_args, plane, surfaces): # diameter changes. The final point of the last surface is the final # point of the last tube - for surface, position in surfaces: + for surface, position, _ref_factor in surfaces: if isinstance(surface, NoseCone): self._draw_nose_cone(ax, surface, position.z, drawn_surfaces, vis_args) elif isinstance(surface, Tail): @@ -645,7 +645,7 @@ def _draw_nozzle_tube(self, last_radius, last_x, nozzle_position, ax, vis_args): def _draw_rail_buttons(self, ax, vis_args): """Draws the rail buttons of the rocket.""" try: - buttons, pos = self.rocket.rail_buttons[0] + buttons, pos, _ref_factor = self.rocket.rail_buttons[0] lower = pos.z upper = lower + buttons.buttons_distance * self.rocket._csys ax.scatter( diff --git a/rocketpy/prints/rocket_prints.py b/rocketpy/prints/rocket_prints.py index 7b768ea2f..6c0c667d4 100644 --- a/rocketpy/prints/rocket_prints.py +++ b/rocketpy/prints/rocket_prints.py @@ -102,19 +102,18 @@ def rocket_aerodynamics_quantities(self): None """ print("\nAerodynamics Lift Coefficient Derivatives\n") - for surface, _ in self.rocket.aerodynamic_surfaces: + for surface, _position, ref_factor in self.rocket.aerodynamic_surfaces: if isinstance(surface, GenericSurface): continue name = surface.name # ref_factor corrects lift for different reference areas - ref_factor = (surface.rocket_radius / self.rocket.radius) ** 2 print( f"{name} Lift Coefficient Derivative: " f"{ref_factor * surface.clalpha(0):.3f}/rad" ) print("\nCenter of Pressure\n") - for surface, position in self.rocket.aerodynamic_surfaces: + for surface, position, _ref_factor in self.rocket.aerodynamic_surfaces: name = surface.name cpz = surface.cp[2] # relative to the user defined coordinate system print( diff --git a/rocketpy/rocket/components.py b/rocketpy/rocket/components.py index 57e4d12f8..bfc2208e7 100644 --- a/rocketpy/rocket/components.py +++ b/rocketpy/rocket/components.py @@ -15,13 +15,15 @@ class Components: A list of named tuples representing all the components and their positions relative to the rocket. component_tuple : namedtuple - A named tuple representing a component and its position within the - rocket. + A named tuple representing a component, its position within the + rocket, and an optional reference-area correction factor. """ def __init__(self): """Initialize an empty components list instance.""" - self.component_tuple = namedtuple("component_tuple", "component position") + self.component_tuple = namedtuple( + "component_tuple", "component position ref_factor", defaults=(1.0,) + ) self._components = [] # List of components and their positions to avoid extra for loops in @@ -34,6 +36,7 @@ def __repr__(self): components_str = "\n".join( [ f"\tComponent: {str(c.component):80} Position: {c.position}" + f" Ref Factor: {c.ref_factor}" for c in self._components ] ) @@ -52,7 +55,7 @@ def __iter__(self): """Return an iterator over the list of components.""" return iter(self._components) - def add(self, component, position): + def add(self, component, position, ref_factor=1.0): """Add a component to the list of components. Parameters @@ -62,6 +65,9 @@ def add(self, component, position): position : int, float The position of the component relative to the rocket's coordinate system origin. + ref_factor : int, float, optional + Reference-area correction factor associating the component to the + rocket reference area. Defaults to 1.0 when not applicable. Returns ------- @@ -69,7 +75,9 @@ def add(self, component, position): """ self.__component_list.append(component) self.__position_list.append(position) - self._components.append(self.component_tuple(component, position)) + self._components.append( + self.component_tuple(component, position, ref_factor) + ) def get_by_type(self, component_type): """Search the list of components and return a list with all the @@ -207,7 +215,11 @@ def sort_by_position(self, reverse=False): def to_dict(self, **kwargs): # pylint: disable=unused-argument return { "components": [ - {"component": c.component, "position": c.position} + { + "component": c.component, + "position": c.position, + "ref_factor": c.ref_factor, + } for c in self._components ] } @@ -216,5 +228,9 @@ def to_dict(self, **kwargs): # pylint: disable=unused-argument def from_dict(cls, data): components = cls() for component in data["components"]: - components.add(component["component"], component["position"]) + components.add( + component["component"], + component["position"], + ref_factor=component.get("ref_factor", 1.0), + ) return components diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 3f4748ac0..9f76aa621 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -660,11 +660,10 @@ def evaluate_center_of_pressure(self): # Calculate total lift coefficient derivative and center of pressure if len(self.aerodynamic_surfaces) > 0: - for aero_surface, position in self.aerodynamic_surfaces: + for aero_surface, position, ref_factor in self.aerodynamic_surfaces: if isinstance(aero_surface, GenericSurface): continue # ref_factor corrects lift for different reference areas - ref_factor = (aero_surface.rocket_radius / self.radius) ** 2 self.total_lift_coeff_der += ref_factor * aero_surface.clalpha self.cp_position += ( ref_factor @@ -687,7 +686,7 @@ def evaluate_surfaces_cp_to_cdm(self): Dictionary mapping the relative position of each aerodynamic surface center of pressure to the rocket's center of mass. """ - for surface, position in self.aerodynamic_surfaces: + for surface, position, _ref_factor in self.aerodynamic_surfaces: self.__evaluate_single_surface_cp_to_cdm(surface, position) return self.surfaces_cp_to_cdm @@ -788,7 +787,7 @@ def warn_if_unstable(self): """ has_generic_surface = any( isinstance(aero_surface, GenericSurface) - for aero_surface, _position in self.aerodynamic_surfaces + for aero_surface, _position, _ref_factor in self.aerodynamic_surfaces ) if has_generic_surface: return False @@ -1163,7 +1162,12 @@ def __add_single_surface(self, surface, position): self.rail_buttons = Components() self.rail_buttons.add(surface, position) else: - self.aerodynamic_surfaces.add(surface, position) + # ref_factor corrects lift for different reference areas + if getattr(surface, "rocket_radius", None) is not None: + ref_factor = (surface.rocket_radius / self.radius) ** 2 + else: + ref_factor = 1.0 + self.aerodynamic_surfaces.add(surface, position, ref_factor=ref_factor) self.__evaluate_single_surface_cp_to_cdm(surface, position) def add_surfaces(self, surfaces, positions): @@ -2270,10 +2274,10 @@ def from_dict(cls, data): position=data["motor_position"], ) - for surface, position in data["aerodynamic_surfaces"]: + for surface, position, _ref_factor in data["aerodynamic_surfaces"]: rocket.add_surfaces(surfaces=surface, positions=position) - for button, position in data["rail_buttons"]: + for button, position, _ref_factor in data["rail_buttons"]: rocket.set_rail_buttons( upper_button_position=position[2] + button.buttons_distance, lower_button_position=position[2], @@ -2284,7 +2288,7 @@ def from_dict(cls, data): for parachute in data["parachutes"]: rocket.parachutes.append(parachute) - for sensor, position in data["sensors"]: + for sensor, position, _ref_factor in data["sensors"]: rocket.add_sensor(sensor, position) for air_brake in data["air_brakes"]: diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 55ca3486f..d20d833a3 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -898,7 +898,7 @@ def __measure_sensors(self, component_sensors, u_dot, t=None, y_sol=None): Parameters ---------- component_sensors : list - List of (sensor, position) tuples. + List of (sensor, position, ref_factor) component tuples. u_dot : array_like State derivative vector. t : float, optional @@ -911,7 +911,7 @@ def __measure_sensors(self, component_sensors, u_dot, t=None, y_sol=None): if y_sol is None: y_sol = self.y_sol - for sensor, position in component_sensors: + for sensor, position, _ref_factor in component_sensors: relative_position = position - self.rocket._csys * Vector( [0, 0, self.rocket.center_of_dry_mass_position] ) @@ -2087,7 +2087,7 @@ def u_dot(self, t, u, post_processing=False): # pylint: disable=too-many-locals # Calculate lift and moment for each component of the rocket velocity_in_body_frame = Vector([vx_b, vy_b, vz_b]) w = Vector([omega1, omega2, omega3]) - for aero_surface, _ in self.rocket.aerodynamic_surfaces: + for aero_surface, _, _ref_factor in self.rocket.aerodynamic_surfaces: # Component cp relative to CDM in body frame comp_cp = self.rocket.surfaces_cp_to_cdm[aero_surface] # Component absolute velocity in body frame @@ -2343,7 +2343,7 @@ def u_dot_generalized_3dof(self, t, u, post_processing=False): # Velocity in body frame vb_body = Kt @ v - for surface, _ in self.rocket.aerodynamic_surfaces: + for surface, _, _ref_factor in self.rocket.aerodynamic_surfaces: cp = self.rocket.surfaces_cp_to_cdm[surface] vb_component = vb_body + (w ^ cp) @@ -2609,7 +2609,7 @@ def u_dot_generalized(self, t, u, post_processing=False): # pylint: disable=too # Get rocket velocity in body frame velocity_in_body_frame = Kt @ v # Calculate lift and moment for each component of the rocket - for aero_surface, _ in self.rocket.aerodynamic_surfaces: + for aero_surface, _, _ref_factor in self.rocket.aerodynamic_surfaces: # Component cp relative to CDM in body frame comp_cp = self.rocket.surfaces_cp_to_cdm[aero_surface] # Component absolute velocity in body frame diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 33a364f18..bf1be38e8 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -210,7 +210,7 @@ def __reset_components(self, components, seed): input component. """ new_components = Components() - for stochastic_obj, _ in components: + for stochastic_obj, _position, _ref_factor in components: stochastic_obj_position_info = self.__components_map[stochastic_obj] stochastic_obj._set_stochastic(seed) new_components.add( diff --git a/tests/unit/rocket/test_components.py b/tests/unit/rocket/test_components.py new file mode 100644 index 000000000..cac56518e --- /dev/null +++ b/tests/unit/rocket/test_components.py @@ -0,0 +1,94 @@ +"""Unit tests for rocketpy.rocket.components.Components.""" + +import pytest + +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.rocket.components import Components + + +def test_components_add_default_ref_factor(): + """Components.add stores ref_factor=1.0 when omitted.""" + components = Components() + component = object() + position = Vector([0, 0, 1.0]) + + components.add(component, position) + + assert len(components) == 1 + assert components[0].component is component + assert components[0].position == position + assert components[0].ref_factor == pytest.approx(1.0) + + +def test_components_add_custom_ref_factor(): + """Components.add stores an explicit ref_factor on the component tuple.""" + components = Components() + component = object() + position = Vector([0, 0, -0.5]) + ref_factor = 0.25 + + components.add(component, position, ref_factor=ref_factor) + + stored = components[0] + assert stored.component is component + assert stored.position == position + assert stored.ref_factor == pytest.approx(ref_factor) + + +def test_components_to_dict_from_dict_preserves_ref_factor(): + """Serialization round-trip keeps ref_factor, defaulting when absent.""" + components = Components() + component = {"name": "dummy"} + position = Vector([0, 0, 0.1]) + components.add(component, position, ref_factor=4.0) + + restored = Components.from_dict(components.to_dict()) + assert restored[0].ref_factor == pytest.approx(4.0) + + legacy = Components.from_dict( + {"components": [{"component": component, "position": position}]} + ) + assert legacy[0].ref_factor == pytest.approx(1.0) + + +def test_rocket_add_surfaces_stores_computed_ref_factor(calisto): + """Rocket aero-surface add path stores (surface.rocket_radius / rocket.radius)**2.""" + from rocketpy import NoseCone + + surface_radius = calisto.radius / 2 + expected_ref_factor = (surface_radius / calisto.radius) ** 2 + nose = NoseCone( + length=0.55829, + kind="vonkarman", + base_radius=surface_radius, + rocket_radius=surface_radius, + name="Half-radius nose", + ) + + calisto.add_surfaces(nose, 1.16) + stored = next( + entry for entry in calisto.aerodynamic_surfaces if entry.component is nose + ) + + assert stored.ref_factor == pytest.approx(expected_ref_factor) + assert stored.ref_factor == pytest.approx(0.25) + + +def test_rocket_add_surfaces_matching_radius_stores_unit_ref_factor(calisto): + """Matching surface and rocket radii store ref_factor of 1.0.""" + from rocketpy import NoseCone + + nose = NoseCone( + length=0.55829, + kind="vonkarman", + base_radius=calisto.radius, + rocket_radius=calisto.radius, + name="Matching nose", + ) + + calisto.add_surfaces(nose, 1.16) + stored = next( + entry for entry in calisto.aerodynamic_surfaces if entry.component is nose + ) + + assert stored.ref_factor == pytest.approx(1.0) diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 7a37cbd4e..a8c88e075 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -914,7 +914,9 @@ def test_add_trapezoidal_fins_two_fins_warns_but_succeeds(calisto): fins = calisto.add_trapezoidal_fins( 2, span=0.1, root_chord=0.12, tip_chord=0.04, position=-1.0 ) - assert fins in [surface for surface, _ in calisto.aerodynamic_surfaces] + assert fins in [ + surface for surface, _position, _ref_factor in calisto.aerodynamic_surfaces + ] def test_unstable_rocket_warning_raised(calisto):