From 6ae5708623b9bb6a1f7ddf04e3f8185773592561 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:15:24 -0300 Subject: [PATCH 01/22] BUG: Fix hard-coded radius value for parachute added mass calculation (#889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix hard-coded radius value for parachute added mass calculation Calculate radius from cd_s using a typical hemispherical parachute drag coefficient (1.4) when radius is not explicitly provided. This fixes drift distance calculations for smaller parachutes like drogues. Formula: R = sqrt(cd_s / (Cd * π)) Closes #860 Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Address code review: improve docstrings and add explicit None defaults Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Add CHANGELOG entry for PR #889 Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Update rocket.add_parachute to use radius=None for consistency Changed the default radius from 1.5 to None in the add_parachute method to match the Parachute class behavior. This ensures consistent automatic radius calculation from cd_s across both APIs. Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Refactor Parachute class to remove hard-coded radius value and introduce drag_coefficient parameter for radius estimation Fix hard-coded radius value for parachute added mass calculation Calculate radius from cd_s using a typical hemispherical parachute drag coefficient (1.4) when radius is not explicitly provided. This fixes drift distance calculations for smaller parachutes like drogues. Formula: R = sqrt(cd_s / (Cd * π)) Closes #860 Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Add CHANGELOG entry for PR #889 Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Refactor Parachute class to remove hard-coded radius value and introduce drag_coefficient parameter for radius estimation MNT: Extract noise initialization to fix pylint too-many-statements in Parachute.__init__ Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * Refactor environment method access in controller test for clarity * fix pylint * fix comments * avoid breaking change with drag_coefficient * reafactors Parachute.__init__ method * fix tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Co-authored-by: Gui-FernandesBR --- CHANGELOG.md | 1 + rocketpy/rocket/parachute.py | 123 +++++++++++++++----- rocketpy/rocket/rocket.py | 32 +++-- rocketpy/stochastic/stochastic_parachute.py | 9 ++ tests/integration/simulation/test_flight.py | 87 +++++++------- tests/unit/rocket/test_parachute.py | 111 ++++++++++++++++++ 6 files changed, 277 insertions(+), 86 deletions(-) create mode 100644 tests/unit/rocket/test_parachute.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5bc989e..e46ee3faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: Fix hard-coded radius value for parachute added mass calculation [#889](https://github.com/RocketPy-Team/RocketPy/pull/889) - DOC: Fix documentation build [#908](https://github.com/RocketPy-Team/RocketPy/pull/908) - BUG: energy_data plot not working for 3 dof sims [[#906](https://github.com/RocketPy-Team/RocketPy/issues/906)] - BUG: Fix CSV column header spacing in FlightDataExporter [#864](https://github.com/RocketPy-Team/RocketPy/issues/864) diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index 83b0ce0fd..4e0318d18 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -92,17 +92,25 @@ class Parachute: Function of noisy_pressure_signal. Parachute.clean_pressure_signal_function : Function Function of clean_pressure_signal. + Parachute.drag_coefficient : float + Drag coefficient of the inflated canopy shape, used only when + ``radius`` is not provided to estimate the parachute radius from + ``cd_s``: ``R = sqrt(cd_s / (drag_coefficient * pi))``. Typical + values: 1.4 for hemispherical canopies (default), 0.75 for flat + circular canopies, 1.5 for extended-skirt canopies. Parachute.radius : float Length of the non-unique semi-axis (radius) of the inflated hemispheroid - parachute in meters. - Parachute.height : float, None + parachute in meters. If not provided at construction time, it is + estimated from ``cd_s`` and ``drag_coefficient``. + Parachute.height : float Length of the unique semi-axis (height) of the inflated hemispheroid parachute in meters. Parachute.porosity : float - Geometric porosity of the canopy (ratio of open area to total canopy area), - in [0, 1]. Affects only the added-mass scaling during descent; it does - not change ``cd_s`` (drag). The default, 0.0432, yields an added-mass - of 1.0 (“neutral” behavior). + Geometric porosity of the canopy (ratio of open area to total canopy + area), in [0, 1]. Affects only the added-mass scaling during descent; + it does not change ``cd_s`` (drag). The default value of 0.0432 is + chosen so that the resulting ``added_mass_coefficient`` equals + approximately 1.0 ("neutral" added-mass behavior). Parachute.added_mass_coefficient : float Coefficient used to calculate the added-mass due to dragged air. It is calculated from the porosity of the parachute. @@ -116,9 +124,10 @@ def __init__( sampling_rate, lag=0, noise=(0, 0, 0), - radius=1.5, + radius=None, height=None, porosity=0.0432, + drag_coefficient=1.4, ): """Initializes Parachute class. @@ -172,25 +181,83 @@ def __init__( passed to the trigger function. Default value is ``(0, 0, 0)``. Units are in Pa. radius : float, optional - Length of the non-unique semi-axis (radius) of the inflated hemispheroid - parachute. Default value is 1.5. + Length of the non-unique semi-axis (radius) of the inflated + hemispheroid parachute. If not provided, it is estimated from + ``cd_s`` and ``drag_coefficient`` using: + ``radius = sqrt(cd_s / (drag_coefficient * pi))``. Units are in meters. height : float, optional Length of the unique semi-axis (height) of the inflated hemispheroid parachute. Default value is the radius of the parachute. Units are in meters. porosity : float, optional - Geometric porosity of the canopy (ratio of open area to total canopy area), - in [0, 1]. Affects only the added-mass scaling during descent; it does - not change ``cd_s`` (drag). The default, 0.0432, yields an added-mass - of 1.0 (“neutral” behavior). + Geometric porosity of the canopy (ratio of open area to total + canopy area), in [0, 1]. Affects only the added-mass scaling + during descent; it does not change ``cd_s`` (drag). The default + value of 0.0432 is chosen so that the resulting + ``added_mass_coefficient`` equals approximately 1.0 ("neutral" + added-mass behavior). + drag_coefficient : float, optional + Drag coefficient of the inflated canopy shape, used only when + ``radius`` is not provided. It relates the aerodynamic ``cd_s`` + to the physical canopy area via + ``cd_s = drag_coefficient * pi * radius**2``. Typical values: + + - **1.4** — hemispherical canopy (default, NASA SP-8066) + - **0.75** — flat circular canopy + - **1.5** — extended-skirt canopy + + Has no effect when ``radius`` is explicitly provided. """ + + # Save arguments as attributes self.name = name self.cd_s = cd_s self.trigger = trigger self.sampling_rate = sampling_rate self.lag = lag self.noise = noise + self.drag_coefficient = drag_coefficient + self.porosity = porosity + + # Initialize derived attributes + self.radius = self.__resolve_radius(radius, cd_s, drag_coefficient) + self.height = self.__resolve_height(height, self.radius) + self.added_mass_coefficient = self.__compute_added_mass_coefficient( + self.porosity + ) + self.__init_noise(noise) + self.__evaluate_trigger_function(trigger) + + # Prints and plots + self.prints = _ParachutePrints(self) + + def __resolve_radius(self, radius, cd_s, drag_coefficient): + """Resolves parachute radius from input or aerodynamic relation.""" + if radius is not None: + return radius + + # cd_s = Cd * S = Cd * pi * R^2 => R = sqrt(cd_s / (Cd * pi)) + return np.sqrt(cd_s / (drag_coefficient * np.pi)) + + def __resolve_height(self, height, radius): + """Resolves parachute height defaulting to radius when not provided.""" + return height or radius + + def __compute_added_mass_coefficient(self, porosity): + """Computes the added-mass coefficient from canopy porosity.""" + return 1.068 * ( + 1 - 1.465 * porosity - 0.25975 * porosity**2 + 1.2626 * porosity**3 + ) + + def __init_noise(self, noise): + """Initializes all noise-related attributes. + + Parameters + ---------- + noise : tuple, list + List in the format (mean, standard deviation, time-correlation). + """ self.noise_signal = [[-1e-6, np.random.normal(noise[0], noise[1])]] self.noisy_pressure_signal = [] self.clean_pressure_signal = [] @@ -200,32 +267,19 @@ def __init__( self.clean_pressure_signal_function = Function(0) self.noisy_pressure_signal_function = Function(0) self.noise_signal_function = Function(0) - self.radius = radius - self.height = height or radius - self.porosity = porosity - self.added_mass_coefficient = 1.068 * ( - 1 - - 1.465 * self.porosity - - 0.25975 * self.porosity**2 - + 1.2626 * self.porosity**3 - ) - alpha, beta = self.noise_corr self.noise_function = lambda: ( alpha * self.noise_signal[-1][1] + beta * np.random.normal(noise[0], noise[1]) ) - self.prints = _ParachutePrints(self) - - self.__evaluate_trigger_function(trigger) - def __evaluate_trigger_function(self, trigger): """This is used to set the triggerfunc attribute that will be used to interact with the Flight class. """ # pylint: disable=unused-argument, function-redefined - # The parachute is deployed by a custom function + + # Case 1: The parachute is deployed by a custom function if callable(trigger): # work around for having added sensors to parachute triggers # to avoid breaking changes @@ -238,9 +292,10 @@ def triggerfunc(p, h, y, sensors): self.triggerfunc = triggerfunc + # Case 2: The parachute is deployed at a given height elif isinstance(trigger, (int, float)): # The parachute is deployed at a given height - def triggerfunc(p, h, y, sensors): # pylint: disable=unused-argument + def triggerfunc(p, h, y, sensors): # p = pressure considering parachute noise signal # h = height above ground level considering parachute noise signal # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3] @@ -248,9 +303,10 @@ def triggerfunc(p, h, y, sensors): # pylint: disable=unused-argument self.triggerfunc = triggerfunc + # Case 3: The parachute is deployed at apogee elif trigger.lower() == "apogee": # The parachute is deployed at apogee - def triggerfunc(p, h, y, sensors): # pylint: disable=unused-argument + def triggerfunc(p, h, y, sensors): # p = pressure considering parachute noise signal # h = height above ground level considering parachute noise signal # y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3] @@ -258,6 +314,7 @@ def triggerfunc(p, h, y, sensors): # pylint: disable=unused-argument self.triggerfunc = triggerfunc + # Case 4: Invalid trigger input else: raise ValueError( f"Unable to set the trigger function for parachute '{self.name}'. " @@ -289,7 +346,7 @@ def info(self): def all_info(self): """Prints all information about the Parachute class.""" self.info() - # self.plots.all() # Parachutes still doesn't have plots + # self.plots.all() # TODO: Parachutes still doesn't have plots def to_dict(self, **kwargs): allow_pickle = kwargs.get("allow_pickle", True) @@ -309,6 +366,7 @@ def to_dict(self, **kwargs): "lag": self.lag, "noise": self.noise, "radius": self.radius, + "drag_coefficient": self.drag_coefficient, "height": self.height, "porosity": self.porosity, } @@ -341,7 +399,8 @@ def from_dict(cls, data): sampling_rate=data["sampling_rate"], lag=data["lag"], noise=data["noise"], - radius=data.get("radius", 1.5), + radius=data.get("radius", None), + drag_coefficient=data.get("drag_coefficient", 1.4), height=data.get("height", None), porosity=data.get("porosity", 0.0432), ) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 86fa981a9..51719753d 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1502,9 +1502,10 @@ def add_parachute( sampling_rate=100, lag=0, noise=(0, 0, 0), - radius=1.5, + radius=None, height=None, porosity=0.0432, + drag_coefficient=1.4, ): """Creates a new parachute, storing its parameters such as opening delay, drag coefficients and trigger function. @@ -1564,26 +1565,34 @@ def add_parachute( passed to the trigger function. Default value is (0, 0, 0). Units are in pascal. radius : float, optional - Length of the non-unique semi-axis (radius) of the inflated hemispheroid - parachute. Default value is 1.5. + Length of the non-unique semi-axis (radius) of the inflated + hemispheroid parachute. If not provided, it is estimated from + `cd_s` and `drag_coefficient` using: + `radius = sqrt(cd_s / (drag_coefficient * pi))`. Units are in meters. height : float, optional Length of the unique semi-axis (height) of the inflated hemispheroid parachute. Default value is the radius of the parachute. Units are in meters. porosity : float, optional - Geometric porosity of the canopy (ratio of open area to total canopy area), - in [0, 1]. Affects only the added-mass scaling during descent; it does - not change ``cd_s`` (drag). The default, 0.0432, yields an added-mass - of 1.0 (“neutral” behavior). + Geometric porosity of the canopy (ratio of open area to total + canopy area), in [0, 1]. Affects only the added-mass scaling + during descent; it does not change `cd_s` (drag). The default + value of 0.0432 yields an `added_mass_coefficient` of + approximately 1.0 ("neutral" added-mass behavior). + drag_coefficient : float, optional + Drag coefficient of the inflated canopy shape, used only when + `radius` is not provided. Typical values: 1.4 for hemispherical + canopies (default), 0.75 for flat circular canopies, 1.5 for + extended-skirt canopies. Has no effect when `radius` is given. Returns ------- parachute : Parachute - Parachute containing trigger, sampling_rate, lag, cd_s, noise, radius, - height, porosity and name. Furthermore, it stores clean_pressure_signal, - noise_signal and noisyPressureSignal which are filled in during - Flight simulation. + Parachute containing trigger, sampling_rate, lag, cd_s, noise, + radius, drag_coefficient, height, porosity and name. Furthermore, + it stores clean_pressure_signal, noise_signal and + noisyPressureSignal which are filled in during Flight simulation. """ parachute = Parachute( name, @@ -1595,6 +1604,7 @@ def add_parachute( radius, height, porosity, + drag_coefficient, ) self.parachutes.append(parachute) return self.parachutes[-1] diff --git a/rocketpy/stochastic/stochastic_parachute.py b/rocketpy/stochastic/stochastic_parachute.py index dea8a077d..038907187 100644 --- a/rocketpy/stochastic/stochastic_parachute.py +++ b/rocketpy/stochastic/stochastic_parachute.py @@ -31,6 +31,9 @@ class StochasticParachute(StochasticModel): List with the name of the parachute object. This cannot be randomized. radius : tuple, list, int, float Radius of the parachute in meters. + drag_coefficient : tuple, list, int, float + Drag coefficient of the inflated canopy shape, used only when + ``radius`` is not provided. height : tuple, list, int, float Height of the parachute in meters. porosity : tuple, list, int, float @@ -46,6 +49,7 @@ def __init__( lag=None, noise=None, radius=None, + drag_coefficient=None, height=None, porosity=None, ): @@ -74,6 +78,9 @@ def __init__( time-correlation). radius : tuple, list, int, float Radius of the parachute in meters. + drag_coefficient : tuple, list, int, float + Drag coefficient of the inflated canopy shape, used only when + ``radius`` is not provided. height : tuple, list, int, float Height of the parachute in meters. porosity : tuple, list, int, float @@ -86,6 +93,7 @@ def __init__( self.lag = lag self.noise = noise self.radius = radius + self.drag_coefficient = drag_coefficient self.height = height self.porosity = porosity @@ -100,6 +108,7 @@ def __init__( noise=noise, name=None, radius=radius, + drag_coefficient=drag_coefficient, height=height, porosity=porosity, ) diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py index 7e25a8927..66f0848a4 100644 --- a/tests/integration/simulation/test_flight.py +++ b/tests/integration/simulation/test_flight.py @@ -717,6 +717,48 @@ def invalid_controller_9_params( # pylint: disable=unused-argument ) +def make_controller_test_environment_access(methods_called): + def _call_env_methods(environment, altitude_asl): + _ = environment.elevation + methods_called["elevation"] = True + _ = environment.wind_velocity_x(altitude_asl) + methods_called["wind_velocity_x"] = True + _ = environment.wind_velocity_y(altitude_asl) + methods_called["wind_velocity_y"] = True + _ = environment.speed_of_sound(altitude_asl) + methods_called["speed_of_sound"] = True + _ = environment.pressure(altitude_asl) + methods_called["pressure"] = True + _ = environment.temperature(altitude_asl) + methods_called["temperature"] = True + + def controller( # pylint: disable=unused-argument + time, + sampling_rate, + state, + state_history, + observed_variables, + air_brakes, + sensors, + environment, + ): + """Controller that tests access to various environment methods.""" + altitude_asl = state[2] + + if time < 3.9: + return None + + try: + _call_env_methods(environment, altitude_asl) + air_brakes.deployment_level = 0.3 + except AttributeError as e: + raise AssertionError(f"Environment method not accessible: {e}") from e + + return (time, air_brakes.deployment_level) + + return controller + + def test_environment_methods_accessible_in_controller( calisto_robust, example_plain_env ): @@ -742,54 +784,13 @@ def test_environment_methods_accessible_in_controller( "temperature": False, } - def controller_test_environment_access( # pylint: disable=unused-argument - time, - sampling_rate, - state, - state_history, - observed_variables, - air_brakes, - sensors, - environment, - ): - """Controller that tests access to various environment methods.""" - altitude_asl = state[2] - - if time < 3.9: - return None - - # Test accessing various environment methods - try: - _ = environment.elevation - methods_called["elevation"] = True - - _ = environment.wind_velocity_x(altitude_asl) - methods_called["wind_velocity_x"] = True - - _ = environment.wind_velocity_y(altitude_asl) - methods_called["wind_velocity_y"] = True - - _ = environment.speed_of_sound(altitude_asl) - methods_called["speed_of_sound"] = True - - _ = environment.pressure(altitude_asl) - methods_called["pressure"] = True - - _ = environment.temperature(altitude_asl) - methods_called["temperature"] = True - - air_brakes.deployment_level = 0.3 - except AttributeError as e: - # If any method is not accessible, the test should fail - raise AssertionError(f"Environment method not accessible: {e}") from e - - return (time, air_brakes.deployment_level) + controller = make_controller_test_environment_access(methods_called) # Add air brakes with environment-testing controller calisto_robust.parachutes = [] calisto_robust.add_air_brakes( drag_coefficient_curve="data/rockets/calisto/air_brakes_cd.csv", - controller_function=controller_test_environment_access, + controller_function=controller, sampling_rate=10, clamp=True, ) diff --git a/tests/unit/rocket/test_parachute.py b/tests/unit/rocket/test_parachute.py new file mode 100644 index 000000000..e193b777b --- /dev/null +++ b/tests/unit/rocket/test_parachute.py @@ -0,0 +1,111 @@ +"""Unit tests for the Parachute class, focusing on the radius and +drag_coefficient parameters introduced in PR #889.""" + +import numpy as np +import pytest + +from rocketpy import Parachute + + +def _make_parachute(**kwargs): + defaults = { + "name": "test", + "cd_s": 10.0, + "trigger": "apogee", + "sampling_rate": 100, + } + defaults.update(kwargs) + return Parachute(**defaults) + + +class TestParachuteRadiusEstimation: + """Tests for auto-computed radius from cd_s and drag_coefficient.""" + + def test_radius_auto_computed_from_cd_s_default_drag_coefficient(self): + """When radius is not provided the radius is estimated using the + default drag_coefficient of 1.4 and the formula R = sqrt(cd_s / (Cd * pi)).""" + cd_s = 10.0 + parachute = _make_parachute(cd_s=cd_s) + expected_radius = np.sqrt(cd_s / (1.4 * np.pi)) + assert parachute.radius == pytest.approx(expected_radius, rel=1e-9) + + def test_radius_auto_computed_uses_custom_drag_coefficient(self): + """When drag_coefficient is provided and radius is not, the radius + must be estimated using the given drag_coefficient.""" + cd_s = 10.0 + custom_cd = 0.75 + parachute = _make_parachute(cd_s=cd_s, drag_coefficient=custom_cd) + expected_radius = np.sqrt(cd_s / (custom_cd * np.pi)) + assert parachute.radius == pytest.approx(expected_radius, rel=1e-9) + + def test_explicit_radius_overrides_estimation(self): + """When radius is explicitly provided, it must be used directly and + drag_coefficient must be ignored for the radius calculation.""" + explicit_radius = 2.5 + parachute = _make_parachute(radius=explicit_radius, drag_coefficient=0.5) + assert parachute.radius == explicit_radius + + def test_drag_coefficient_stored_on_instance(self): + """drag_coefficient must be stored as an attribute regardless of + whether radius is provided or not.""" + parachute = _make_parachute(drag_coefficient=0.75) + assert parachute.drag_coefficient == 0.75 + + def test_drag_coefficient_default_is_1_4(self): + """Default drag_coefficient must be 1.4 for backward compatibility.""" + parachute = _make_parachute() + assert parachute.drag_coefficient == pytest.approx(1.4) + + def test_drogue_radius_smaller_than_main(self): + """A drogue (cd_s=1.0) must have a smaller radius than a main (cd_s=10.0) + when using the same drag_coefficient.""" + main = _make_parachute(cd_s=10.0) + drogue = _make_parachute(cd_s=1.0) + assert drogue.radius < main.radius + + def test_drogue_radius_approximately_0_48(self): + """For cd_s=1.0 and drag_coefficient=1.4, the estimated radius + must be approximately 0.48 m (fixes the previous hard-coded 1.5 m).""" + drogue = _make_parachute(cd_s=1.0) + assert drogue.radius == pytest.approx(0.476, abs=1e-3) + + def test_main_radius_approximately_1_51(self): + """For cd_s=10.0 and drag_coefficient=1.4, the estimated radius + must be approximately 1.51 m, matching the old hard-coded value.""" + main = _make_parachute(cd_s=10.0) + assert main.radius == pytest.approx(1.508, abs=1e-3) + + +class TestParachuteSerialization: + """Tests for to_dict / from_dict round-trip including drag_coefficient.""" + + def test_to_dict_includes_drag_coefficient(self): + """to_dict must include the drag_coefficient key.""" + parachute = _make_parachute(drag_coefficient=0.75) + data = parachute.to_dict() + assert "drag_coefficient" in data + assert data["drag_coefficient"] == 0.75 + + def test_from_dict_round_trip_preserves_drag_coefficient(self): + """A Parachute serialized to dict and restored must have the same + drag_coefficient.""" + original = _make_parachute(cd_s=5.0, drag_coefficient=0.75) + data = original.to_dict() + restored = Parachute.from_dict(data) + assert restored.drag_coefficient == pytest.approx(0.75) + assert restored.radius == pytest.approx(original.radius, rel=1e-9) + + def test_from_dict_defaults_drag_coefficient_to_1_4_when_absent(self): + """Dicts serialized before drag_coefficient was added (no key) must + fall back to 1.4 for backward compatibility.""" + data = { + "name": "legacy", + "cd_s": 10.0, + "trigger": "apogee", + "sampling_rate": 100, + "lag": 0, + "noise": (0, 0, 0), + # no drag_coefficient key — simulates old serialized data + } + parachute = Parachute.from_dict(data) + assert parachute.drag_coefficient == pytest.approx(1.4) From 7d951f530e46e898691a927a0b86618589e84ee3 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:56:49 -0300 Subject: [PATCH 02/22] ENH: Add guidelines for simulation safety, Sphinx documentation, and pytest standards (GitHub Copilot) (#937) --- .github/agents/rocketpy-reviewer.agent.md | 62 ++++ .github/copilot-instructions.md | 301 +++++------------- .../simulation-safety.instructions.md | 41 +++ .../instructions/sphinx-docs.instructions.md | 32 ++ .../instructions/tests-python.instructions.md | 36 +++ 5 files changed, 251 insertions(+), 221 deletions(-) create mode 100644 .github/agents/rocketpy-reviewer.agent.md create mode 100644 .github/instructions/simulation-safety.instructions.md create mode 100644 .github/instructions/sphinx-docs.instructions.md create mode 100644 .github/instructions/tests-python.instructions.md diff --git a/.github/agents/rocketpy-reviewer.agent.md b/.github/agents/rocketpy-reviewer.agent.md new file mode 100644 index 000000000..be1b64b13 --- /dev/null +++ b/.github/agents/rocketpy-reviewer.agent.md @@ -0,0 +1,62 @@ +--- +description: "Physics-safe RocketPy code review agent. Use for pull request review, unit consistency checks, coordinate-frame validation, cached-property risk detection, and regression-focused test-gap analysis." +name: "RocketPy Reviewer" +tools: [read, search, execute] +argument-hint: "Review these changes for physics correctness and regression risk: " +user-invocable: true +--- +You are a RocketPy-focused reviewer for physics safety and regression risk. + +## Goals + +- Detect behavioral regressions and numerical/physics risks before merge. +- Validate unit consistency and coordinate/reference-frame correctness. +- Identify stale-cache risks when `@cached_property` interacts with mutable state. +- Check test coverage quality for changed behavior. +- Verify alignment with RocketPy workflow and contributor conventions. + +## Review Priorities + +1. Correctness and safety issues (highest severity). +2. Behavioral regressions and API compatibility. +3. Numerical stability and tolerance correctness. +4. Missing tests or weak assertions. +5. Documentation mismatches affecting users. +6. Workflow violations (test placement, branch/PR conventions, or missing validation evidence). + +## RocketPy-Specific Checks + +- SI units are explicit and consistent. +- Orientation conventions are unambiguous (`tail_to_nose`, `nozzle_to_combustion_chamber`, etc.). +- New/changed simulation logic does not silently invalidate cached values. +- Floating-point assertions use `pytest.approx` where needed. +- New fixtures are wired through `tests/conftest.py` when applicable. +- Test type is appropriate for scope (`unit`, `integration`, `acceptance`) and `all_info()`-style tests + are not misclassified. +- New behavior includes at least one regression-oriented test and relevant edge-case checks. +- For docs-affecting changes, references and paths remain valid and build warnings are addressed. +- Tooling recommendations match current repository setup (prefer Makefile plus `pyproject.toml` + settings when docs are outdated). + +## Validation Expectations + +- Prefer focused test runs first, then broader relevant suites. +- Recommend `make format` and `make lint` when style/lint risks are present. +- Recommend `make build-docs` when `.rst` files or API docs are changed. + +## Output Format + +Provide findings first, ordered by severity. +For each finding include: +- Severity: Critical, High, Medium, or Low +- Location: file path and line +- Why it matters: behavioral or physics risk +- Suggested fix: concrete, minimal change + +After findings, include: +- Open questions or assumptions +- Residual risks or testing gaps +- Brief change summary +- Suggested validation commands (only when useful) + +If no findings are identified, state that explicitly and still report residual risks/testing gaps. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f5366cb3b..382aa15e0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,221 +1,80 @@ -# GitHub Copilot Instructions for RocketPy - -This file provides instructions for GitHub Copilot when working on the RocketPy codebase. -These guidelines help ensure consistency with the project's coding standards and development practices. - -## Project Overview - -RocketPy is a Python library for 6-DOF rocket trajectory simulation. -It's designed for high-power rocketry applications with focus on accuracy, performance, and ease of use. - -## Coding Standards - -### Naming Conventions -- **Use `snake_case` for all new code** - variables, functions, methods, and modules -- **Use descriptive names** - prefer `angle_of_attack` over `a` or `alpha` -- **Class names use PascalCase** - e.g., `SolidMotor`, `Environment`, `Flight` -- **Constants use UPPER_SNAKE_CASE** - e.g., `DEFAULT_GRAVITY`, `EARTH_RADIUS` - -### Code Style -- Follow **PEP 8** guidelines -- Line length: **88 characters** (Black's default) -- Organize imports with **isort** -- Our official formatter is the **ruff frmat** - -### Documentation -- **All public classes, methods, and functions must have docstrings** -- Use **NumPy style docstrings** -- Include **Parameters**, **Returns**, and **Examples** sections -- Document **units** for physical quantities (e.g., "in meters", "in radians") - -### Testing -- Write **unit tests** for all new features using pytest -- Follow **AAA pattern** (Arrange, Act, Assert) -- Use descriptive test names following: `test_methodname_expectedbehaviour` -- Include test docstrings explaining expected behavior -- Use **parameterization** for testing multiple scenarios -- Create pytest fixtures to avoid code repetition - -## Domain-Specific Guidelines - -### Physical Units and Conventions -- **SI units by default** - meters, kilograms, seconds, radians -- **Document coordinate systems** clearly (e.g., "tail_to_nose", "nozzle_to_combustion_chamber") -- **Position parameters** are critical - always document reference points -- Use **descriptive variable names** for physical quantities - -### Rocket Components -- **Motors**: SolidMotor, HybridMotor and LiquidMotor classes are children classes of the Motor class -- **Aerodynamic Surfaces**: They have Drag curves and lift coefficients -- **Parachutes**: Trigger functions, deployment conditions -- **Environment**: Atmospheric models, weather data, wind profiles - -### Mathematical Operations -- Use **numpy arrays** for vectorized operations (this improves performance) -- Prefer **scipy functions** for numerical integration and optimization -- **Handle edge cases** in calculations (division by zero, sqrt of negative numbers) -- **Validate input ranges** for physical parameters -- Monte Carlo simulations: sample from `numpy.random` for random number generation and creates several iterations to assess uncertainty in simulations. - -## File Structure and Organization - -### Source Code Organization - -Reminds that `rocketpy` is a Python package served as a library, and its source code is organized into several modules to facilitate maintainability and clarity. The following structure is recommended: - -``` -rocketpy/ -├── core/ # Core simulation classes -├── motors/ # Motor implementations -├── environment/ # Atmospheric and environmental models -├── plots/ # Plotting and visualization -├── tools/ # Utility functions -└── mathutils/ # Mathematical utilities -``` - -Please refer to popular Python packages like `scipy`, `numpy`, and `matplotlib` for inspiration on module organization. - -### Test Organization -``` -tests/ -├── unit/ # Unit tests -├── integration/ # Integration tests -├── acceptance/ # Acceptance tests -└── fixtures/ # Test fixtures organized by component -``` - -### Documentation Structure -``` -docs/ -├── user/ # User guides and tutorials -├── development/ # Development documentation -├── reference/ # API reference -├── examples/ # Flight examples and notebooks -└── technical/ # Technical documentation -``` - -## Common Patterns and Practices - -### Error Handling -- Use **descriptive error messages** with context -- **Validate inputs** at class initialization and method entry -- Raise **appropriate exception types** (ValueError, TypeError, etc.) -- Include **suggestions for fixes** in error messages - -### Performance Considerations -- Use **vectorized operations** where possible -- **Cache expensive computations** when appropriate (we frequently use `cached_property`) -- Keep in mind that RocketPy must be fast! - -### Backward Compatibility -- **Avoid breaking changes** in public APIs -- Use **deprecation warnings** before removing features -- **Document code changes** in docstrings and CHANGELOG - -## AI Assistant Guidelines - -### Code Generation -- **Always include docstrings** for new functions and classes -- **Follow existing patterns** in the codebase -- **Consider edge cases** and error conditions - -### Code Review and Suggestions -- **Check for consistency** with existing code style -- **Verify physical units** and coordinate systems -- **Ensure proper error handling** and input validation -- **Suggest performance improvements** when applicable -- **Recommend additional tests** for new functionality - -### Documentation Assistance -- **Use NumPy docstring format** consistently -- **Include practical examples** in docstrings -- **Document physical meanings** of parameters -- **Cross-reference related functions** and classes - -## Testing Guidelines - -### Unit Tests -- **Test individual methods** in isolation -- **Use fixtures** from the appropriate test fixture modules -- **Mock external dependencies** when necessary -- **Test both happy path and error conditions** - -### Integration Tests -- **Test interactions** between components -- **Verify end-to-end workflows** (Environment → Motor → Rocket → Flight) - -### Test Data -- **Use realistic parameters** for rocket simulations -- **Include edge cases** (very small/large rockets, extreme conditions) -- **Test with different coordinate systems** and orientations - -## Project-Specific Considerations - -### User Experience -- **Provide helpful error messages** with context and suggestions -- **Include examples** in docstrings and documentation -- **Support common use cases** with reasonable defaults - -## Examples of Good Practices - -### Function Definition -```python -def calculate_drag_force( - velocity, - air_density, - drag_coefficient, - reference_area -): - """Calculate drag force using the standard drag equation. - - Parameters - ---------- - velocity : float - Velocity magnitude in m/s. - air_density : float - Air density in kg/m³. - drag_coefficient : float - Dimensionless drag coefficient. - reference_area : float - Reference area in m². - - Returns - ------- - float - Drag force in N. - - Examples - -------- - >>> drag_force = calculate_drag_force(100, 1.225, 0.5, 0.01) - >>> print(f"Drag force: {drag_force:.2f} N") - """ - if velocity < 0: - raise ValueError("Velocity must be non-negative") - if air_density <= 0: - raise ValueError("Air density must be positive") - if reference_area <= 0: - raise ValueError("Reference area must be positive") - - return 0.5 * air_density * velocity**2 * drag_coefficient * reference_area -``` - -### Test Example -```python -def test_calculate_drag_force_returns_correct_value(): - """Test drag force calculation with known inputs.""" - # Arrange - velocity = 100.0 # m/s - air_density = 1.225 # kg/m³ - drag_coefficient = 0.5 - reference_area = 0.01 # m² - expected_force = 30.625 # N - - # Act - result = calculate_drag_force(velocity, air_density, drag_coefficient, reference_area) - - # Assert - assert abs(result - expected_force) < 1e-6 -``` - - -Remember: RocketPy prioritizes accuracy, performance, and usability. Always consider the physical meaning of calculations and provide clear, well-documented interfaces for users. +# RocketPy Workspace Instructions + +## Code Style +- Use snake_case for variables, functions, methods, and modules. Use descriptive names. +- Use PascalCase for classes and UPPER_SNAKE_CASE for constants. +- Keep lines at 88 characters and follow PEP 8 unless existing code in the target file differs. +- Run Ruff as the source of truth for formatting/import organization: + - `make format` + - `make lint` +- Use NumPy-style docstrings for public classes, methods, and functions, including units. +- In case of tooling drift between docs and config, prefer current repository tooling in `Makefile` + and `pyproject.toml`. + +## Architecture +- RocketPy is a modular Python library; keep feature logic in the correct package boundary: + - `rocketpy/simulation`: flight simulation and Monte Carlo orchestration. + - `rocketpy/rocket`, `rocketpy/motors`, `rocketpy/environment`: domain models. + - `rocketpy/mathutils`: numerical primitives and interpolation utilities. + - `rocketpy/plots`, `rocketpy/prints`: output and visualization layers. +- Prefer extending existing classes/patterns over introducing new top-level abstractions. +- Preserve public API stability in `rocketpy/__init__.py` exports. + +## Build and Test +- Use Makefile targets for OS-agnostic workflows: + - `make install` + - `make pytest` + - `make pytest-slow` + - `make coverage` + - `make coverage-report` + - `make build-docs` +- Before finishing code changes, run focused tests first, then broader relevant suites. +- When running Python directly in this workspace, prefer `.venv/Scripts/python.exe`. +- Slow tests are explicitly marked with `@pytest.mark.slow` and are run with `make pytest-slow`. +- For docs changes, check `make build-docs` output and resolve warnings/errors when practical. + +## Development Workflow +- Target pull requests to `develop` by default; `master` is the stable branch. +- Use branch names in `type/description` format, such as: + - `bug/` + - `doc/` + - `enh/` + - `mnt/` + - `tst/` +- Prefer rebasing feature branches on top of `develop` to keep history linear. +- Keep commit and PR titles explicit and prefixed with project acronyms when possible: + - `BUG`, `DOC`, `ENH`, `MNT`, `TST`, `BLD`, `REL`, `REV`, `STY`, `DEV`. + +## Conventions +- SI units are the default. Document units and coordinate-system references explicitly. +- Position/reference-frame arguments are critical in this codebase. Be explicit about orientation + (for example, `tail_to_nose`, `nozzle_to_combustion_chamber`). +- Include unit tests for new behavior. Follow AAA structure and clear test names. +- Use fixtures from `tests/fixtures`; if adding a new fixture module, update `tests/conftest.py`. +- Use `pytest.approx` for floating-point checks where appropriate. +- Use `@cached_property` for expensive computations when helpful, and be careful with stale-cache + behavior when underlying mutable state changes. +- Keep behavior backward compatible across the public API exported via `rocketpy/__init__.py`. +- Prefer extending existing module patterns over creating new top-level package structure. + +## Testing Taxonomy +- Unit tests are mandatory for new behavior. +- Unit tests in RocketPy can be sociable (real collaborators allowed) but should still be fast and + method-focused. +- Treat tests as integration tests when they are strongly I/O-oriented or broad across many methods, + including `all_info()` convention cases. +- Acceptance tests represent realistic user/flight scenarios and may compare simulation thresholds to + known flight data. + +## Documentation Links +- Contributor workflow and setup: `docs/development/setting_up.rst` +- Style and naming details: `docs/development/style_guide.rst` +- Testing philosophy and structure: `docs/development/testing.rst` +- API reference conventions: `docs/reference/index.rst` +- Domain/physics background: `docs/technical/index.rst` + +## Scoped Customizations +- Simulation-specific rules: `.github/instructions/simulation-safety.instructions.md` +- Test-authoring rules: `.github/instructions/tests-python.instructions.md` +- RST/Sphinx documentation rules: `.github/instructions/sphinx-docs.instructions.md` +- Specialized review persona: `.github/agents/rocketpy-reviewer.agent.md` diff --git a/.github/instructions/simulation-safety.instructions.md b/.github/instructions/simulation-safety.instructions.md new file mode 100644 index 000000000..cc2af5d27 --- /dev/null +++ b/.github/instructions/simulation-safety.instructions.md @@ -0,0 +1,41 @@ +--- +description: "Use when editing rocketpy/simulation code, including Flight state updates, Monte Carlo orchestration, post-processing, or cached computations. Covers simulation state safety, unit/reference-frame clarity, and regression checks." +name: "Simulation Safety" +applyTo: "rocketpy/simulation/**/*.py" +--- +# Simulation Safety Guidelines + +- Keep simulation logic inside `rocketpy/simulation` and avoid leaking domain behavior that belongs in + `rocketpy/rocket`, `rocketpy/motors`, or `rocketpy/environment`. +- Preserve public API behavior and exported names used by `rocketpy/__init__.py`. +- Prefer extending existing simulation components before creating new abstractions: + - `flight.py`: simulation state, integration flow, and post-processing. + - `monte_carlo.py`: orchestration and statistical execution workflows. + - `flight_data_exporter.py` and `flight_data_importer.py`: persistence and interchange. + - `flight_comparator.py`: comparative analysis outputs. +- Be explicit with physical units and reference frames in new parameters, attributes, and docstrings. +- For position/orientation-sensitive behavior, use explicit conventions (for example + `tail_to_nose`, `nozzle_to_combustion_chamber`) and avoid implicit assumptions. +- Treat state mutation carefully when cached values exist. +- If changes can invalidate `@cached_property` values, either avoid post-computation mutation or + explicitly invalidate affected caches in a controlled, documented way. +- Keep numerical behavior deterministic unless stochastic behavior is intentional and documented. +- For Monte Carlo and stochastic code paths, make randomness controllable and reproducible when tests + rely on it. +- Prefer vectorized NumPy operations for hot paths and avoid introducing Python loops in + performance-critical sections without justification. +- Guard against numerical edge cases (zero/near-zero denominators, interpolation limits, and boundary + conditions). +- Do not change default numerical tolerances or integration behavior without documenting motivation and + validating regression impact. +- Add focused regression tests for changed behavior, including edge cases and orientation-dependent + behavior. +- For floating-point expectations, use `pytest.approx` with meaningful tolerances. +- Run focused tests first, then broader relevant tests (`make pytest` and `make pytest-slow` when + applicable). + +See: +- `docs/development/testing.rst` +- `docs/development/style_guide.rst` +- `docs/development/setting_up.rst` +- `docs/technical/index.rst` diff --git a/.github/instructions/sphinx-docs.instructions.md b/.github/instructions/sphinx-docs.instructions.md new file mode 100644 index 000000000..8c24cac53 --- /dev/null +++ b/.github/instructions/sphinx-docs.instructions.md @@ -0,0 +1,32 @@ +--- +description: "Use when writing or editing docs/**/*.rst. Covers Sphinx/reStructuredText conventions, cross-references, toctree hygiene, and RocketPy unit/reference-frame documentation requirements." +name: "Sphinx RST Conventions" +applyTo: "docs/**/*.rst" +--- +# Sphinx and RST Guidelines + +- Follow existing heading hierarchy and style in the target document. +- Prefer linking to existing documentation pages instead of duplicating content. +- Use Sphinx cross-references where appropriate (`:class:`, `:func:`, `:mod:`, `:doc:`, `:ref:`). +- Keep API names and module paths consistent with current code exports. +- Document physical units and coordinate/reference-frame conventions explicitly. +- Include concise, practical examples when introducing new user-facing behavior. +- Keep prose clear and technical; avoid marketing language in development/reference docs. +- When adding a new page, update the relevant `toctree` so it appears in navigation. +- Use RocketPy docs build workflow: + - `make build-docs` from repository root for normal validation. + - If stale artifacts appear, clean docs build outputs via `cd docs && make clean`, then rebuild. +- Treat new Sphinx warnings/errors as issues to fix or explicitly call out in review notes. +- Keep `docs/index.rst` section structure coherent with user, development, reference, technical, and + examples navigation. +- Do not edit Sphinx-generated scaffolding files unless explicitly requested: + - `docs/Makefile` + - `docs/make.bat` +- For API docs, ensure references remain aligned with exported/public objects and current module paths. + +See: +- `docs/index.rst` +- `docs/development/build_docs.rst` +- `docs/development/style_guide.rst` +- `docs/reference/index.rst` +- `docs/technical/index.rst` diff --git a/.github/instructions/tests-python.instructions.md b/.github/instructions/tests-python.instructions.md new file mode 100644 index 000000000..1e9626142 --- /dev/null +++ b/.github/instructions/tests-python.instructions.md @@ -0,0 +1,36 @@ +--- +description: "Use when creating or editing pytest files in tests/. Enforces AAA structure, naming conventions, fixture usage, parameterization, slow-test marking, and numerical assertion practices for RocketPy." +name: "RocketPy Pytest Standards" +applyTo: "tests/**/*.py" +--- +# RocketPy Test Authoring Guidelines + +- Unit tests are mandatory for new behavior. +- Follow AAA structure in each test: Arrange, Act, Assert. +- Use descriptive test names matching project convention: + - `test_methodname` + - `test_methodname_stateundertest` + - `test_methodname_expectedbehaviour` +- Include docstrings that clearly state expected behavior and context. +- Prefer parameterization for scenario matrices instead of duplicated tests. +- Classify tests correctly: + - `tests/unit`: fast, method-focused tests (sociable unit tests are acceptable in RocketPy). + - `tests/integration`: broad multi-method/component interactions and strongly I/O-oriented cases. + - `tests/acceptance`: realistic end-user/flight scenarios with threshold-based expectations. +- By RocketPy convention, tests centered on `all_info()` behavior are integration tests. +- Reuse fixtures from `tests/fixtures` whenever possible. +- Keep fixture organization aligned with existing categories under `tests/fixtures` + (environment, flight, motor, rockets, surfaces, units, etc.). +- If you add a new fixture module, update `tests/conftest.py` so fixtures are discoverable. +- Keep tests deterministic: set seeds when randomness is involved and avoid unstable external + dependencies unless integration behavior explicitly requires them. +- Use `pytest.approx` for floating-point comparisons with realistic tolerances. +- Mark expensive tests with `@pytest.mark.slow` and ensure they can run under the project slow-test + workflow. +- Include at least one negative or edge-case assertion for new behaviors. +- When adding a bug fix, include a regression test that fails before the fix and passes after it. + +See: +- `docs/development/testing.rst` +- `docs/development/style_guide.rst` +- `docs/development/setting_up.rst` From 22489fa11a543a1ebc030374f08340445781832c Mon Sep 17 00:00:00 2001 From: MateusStano Date: Sun, 8 Mar 2026 12:35:38 -0300 Subject: [PATCH 03/22] REL: bump version to 1.12 --- CHANGELOG.md | 15 +++++++++++++++ docs/conf.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e46ee3faa..2658868a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,21 @@ Attention: The newest changes should be on top --> ### Added +- + +### Changed + +- + +### Fixed + +- + +## [v1.12.0] - 2026-03-08 + +### Added + + - ENH: Air brakes controller functions now support 8-parameter signature [#854](https://github.com/RocketPy-Team/RocketPy/pull/854) - TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914] (https://github.com/RocketPy-Team/RocketPy/pull/914_ - ENH: Add background map auto download functionality to Monte Carlo plots [#896](https://github.com/RocketPy-Team/RocketPy/pull/896) diff --git a/docs/conf.py b/docs/conf.py index ae8a4b17d..e535082e7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,12 +22,12 @@ # -- Project information ----------------------------------------------------- project = "RocketPy" -copyright = "2025, RocketPy Team" +copyright = "2026, RocketPy Team" author = "RocketPy Team" # The full version, including alpha/beta/rc tags -release = "1.11.0" +release = "1.12.0" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 35ea34382..b9433c6d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rocketpy" -version = "1.11.0" +version = "1.12.0" description="Advanced 6-DOF trajectory simulation for High-Power Rocketry." dynamic = ["dependencies"] readme = "README.md" From 8d49e5f97831a4e905340064d3f132594b73faaf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:34:55 -0300 Subject: [PATCH 04/22] ENH: Add explicit timeouts to ThrustCurve API requests and update changelog (#940) * Initial plan * ENH: Add explicit timeouts to ThrustCurve API requests Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> * DOC: Add timeout fix PR to changelog Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> --- CHANGELOG.md | 2 +- rocketpy/motors/motor.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2658868a7..aae77b29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ Attention: The newest changes should be on top --> ### Fixed -- +- BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) ## [v1.12.0] - 2026-03-08 diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 373154512..ea42dd71c 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1946,8 +1946,11 @@ def _call_thrustcurve_api(name: str, no_cache: bool = False): # pylint: disable ------ ValueError If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.Timeout + If a search or download request to the ThrustCurve API exceeds the + timeout limit (5 s connect / 30 s read). requests.exceptions.RequestException - If a network or HTTP error occurs during the API call. + If any other network or HTTP error occurs during the API call. Notes ----- @@ -1973,8 +1976,13 @@ def _call_thrustcurve_api(name: str, no_cache: bool = False): # pylint: disable ) base_url = "https://www.thrustcurve.org/api/v1" + _timeout = (5, 30) # (connect timeout, read timeout) in seconds # Step 1. Search motor - response = requests.get(f"{base_url}/search.json", params={"commonName": name}) + response = requests.get( + f"{base_url}/search.json", + params={"commonName": name}, + timeout=_timeout, + ) response.raise_for_status() data = response.json() @@ -1994,6 +2002,7 @@ def _call_thrustcurve_api(name: str, no_cache: bool = False): # pylint: disable dl_response = requests.get( f"{base_url}/download.json", params={"motorIds": motor_id, "format": "RASP", "data": "file"}, + timeout=_timeout, ) dl_response.raise_for_status() dl_data = dl_response.json() From 116f327f9f04a375eb528c3575b41836fed2a6a0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:06:15 -0300 Subject: [PATCH 05/22] ENH: Restore power_off/on_drag as Function objects; add _input attributes for raw user input and update changelog (#941) * Initial plan * ENH: Restore power_off/on_drag as Function, add _input attributes for raw user input Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> * DOC: Add PR #941 compatibility fix to changelog Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> * Update rocketpy/rocket/rocket.py Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * MNT: ruff pylint --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Co-authored-by: MateusStano --- CHANGELOG.md | 4 +++- rocketpy/rocket/point_mass_rocket.py | 12 ++++++++++++ rocketpy/rocket/rocket.py | 23 ++++++++++++++++------- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae77b29f..fe77d0dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ Attention: The newest changes should be on top --> ### Fixed -- BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) +- ## [v1.12.0] - 2026-03-08 @@ -73,6 +73,8 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: Restore `Rocket.power_off_drag` and `Rocket.power_on_drag` as `Function` objects while preserving raw inputs in `power_off_drag_input` and `power_on_drag_input` [#941](https://github.com/RocketPy-Team/RocketPy/pull/941) +- BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) - BUG: Fix hard-coded radius value for parachute added mass calculation [#889](https://github.com/RocketPy-Team/RocketPy/pull/889) - DOC: Fix documentation build [#908](https://github.com/RocketPy-Team/RocketPy/pull/908) - BUG: energy_data plot not working for 3 dof sims [[#906](https://github.com/RocketPy-Team/RocketPy/issues/906)] diff --git a/rocketpy/rocket/point_mass_rocket.py b/rocketpy/rocket/point_mass_rocket.py index eaddaadec..d94363d2b 100644 --- a/rocketpy/rocket/point_mass_rocket.py +++ b/rocketpy/rocket/point_mass_rocket.py @@ -41,6 +41,18 @@ class PointMassRocket(Rocket): center_of_mass_without_motor : float Position, in meters, of the rocket's center of mass without motor relative to the rocket's coordinate system. + power_off_drag : Function + Rocket's drag coefficient as a function of Mach number when the + motor is off. Alias for ``power_off_drag_by_mach``. + power_on_drag : Function + Rocket's drag coefficient as a function of Mach number when the + motor is on. Alias for ``power_on_drag_by_mach``. + power_off_drag_input : int, float, callable, array, string, Function + Original user input for the drag coefficient with motor off. + Preserved for reconstruction and Monte Carlo workflows. + power_on_drag_input : int, float, callable, array, string, Function + Original user input for the drag coefficient with motor on. + Preserved for reconstruction and Monte Carlo workflows. power_off_drag_7d : Function Drag coefficient function with seven inputs in the order: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate. diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 51719753d..0e44365d6 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -147,12 +147,18 @@ class Rocket: Rocket.static_margin : float Float value corresponding to rocket static margin when loaded with propellant in units of rocket diameter or calibers. - Rocket.power_off_drag : int, float, callable, string, array, Function + Rocket.power_off_drag : Function + Rocket's drag coefficient as a function of Mach number when the + motor is off. Alias for ``power_off_drag_by_mach``. + Rocket.power_on_drag : Function + Rocket's drag coefficient as a function of Mach number when the + motor is on. Alias for ``power_on_drag_by_mach``. + Rocket.power_off_drag_input : int, float, callable, string, array, Function Original user input for rocket's drag coefficient when the motor is - off. This is preserved for reconstruction and Monte Carlo workflows. - Rocket.power_on_drag : int, float, callable, string, array, Function + off. Preserved for reconstruction and Monte Carlo workflows. + Rocket.power_on_drag_input : int, float, callable, string, array, Function Original user input for rocket's drag coefficient when the motor is - on. This is preserved for reconstruction and Monte Carlo workflows. + on. Preserved for reconstruction and Monte Carlo workflows. Rocket.power_off_drag_7d : Function Rocket's drag coefficient with motor off as a 7D function of (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate). @@ -375,9 +381,12 @@ def __init__( # pylint: disable=too-many-statements interpolation="linear", extrapolation="constant", ) - # Saving user input for monte carlo - self.power_off_drag = power_off_drag - self.power_on_drag = power_on_drag + # Saving raw user input for reconstruction and Monte Carlo + self._power_off_drag_input = power_off_drag + self._power_on_drag_input = power_on_drag + # Public API attributes: keep as Function (Mach-only) for backward compatibility + self.power_off_drag = self.power_off_drag_by_mach + self.power_on_drag = self.power_on_drag_by_mach # Create a, possibly, temporary empty motor # self.motors = Components() # currently unused, only 1 motor is supported From 5f1a3ebe3a78b63945b386710457757e0fb94d5e Mon Sep 17 00:00:00 2001 From: MateusStano Date: Thu, 19 Mar 2026 22:56:20 -0300 Subject: [PATCH 06/22] MNT: Remove unused imports and deprecated functions from mathutils/function.py --- rocketpy/mathutils/function.py | 54 +--------------------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 622d7a676..3f73dd840 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -5,19 +5,15 @@ carefully as it may impact all the rest of the project. """ -import base64 -import functools import operator import warnings from bisect import bisect_left from collections.abc import Iterable from copy import deepcopy -from enum import Enum from functools import cached_property from inspect import signature from pathlib import Path -import dill import matplotlib.pyplot as plt import numpy as np from scipy import integrate, linalg, optimize @@ -29,6 +25,7 @@ ) from rocketpy.plots.plot_helpers import show_or_save_plot +from rocketpy.tools import deprecated # Numpy 1.x compatibility, # TODO: remove these lines when all dependencies support numpy>=2.0.0 @@ -51,55 +48,6 @@ EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2} -def deprecated(reason=None, version=None, alternative=None): - """Decorator to mark functions or methods as deprecated. - - This decorator issues a DeprecationWarning when the decorated function - is called, indicating that it will be removed in future versions. - """ - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - if reason: - message = reason - else: - message = f"The function `{func.__name__}` is deprecated" - - if version: - message += f" and will be removed in {version}" - - if alternative: - message += f". Use `{alternative}` instead" - - message += "." - warnings.warn(message, DeprecationWarning, stacklevel=2) - return func(*args, **kwargs) - - return wrapper - - return decorator - - -def to_hex_encode(obj, encoder=base64.b85encode): - """Converts an object to hex representation using dill.""" - return encoder(dill.dumps(obj)).hex() - - -def from_hex_decode(obj_bytes, decoder=base64.b85decode): - """Converts an object from hex representation using dill.""" - return dill.loads(decoder(bytes.fromhex(obj_bytes))) - - -class SourceType(Enum): - """Enumeration of the source types for the Function class. - The source can be either a callable or an array. - """ - - CALLABLE = 0 - ARRAY = 1 - - class Function: # pylint: disable=too-many-public-methods """Class converts a python function or a data sequence into an object which can be handled more naturally, enabling easy interpolation, From 877e1474e0f82e0a63d48b41e02f3140b10716d4 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Thu, 19 Mar 2026 23:03:00 -0300 Subject: [PATCH 07/22] BUG: Readd SourceType enumeration for function source types and clean up imports --- rocketpy/mathutils/function.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 3f73dd840..f11e4879e 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -10,6 +10,7 @@ from bisect import bisect_left from collections.abc import Iterable from copy import deepcopy +from enum import Enum from functools import cached_property from inspect import signature from pathlib import Path @@ -25,7 +26,7 @@ ) from rocketpy.plots.plot_helpers import show_or_save_plot -from rocketpy.tools import deprecated +from rocketpy.tools import deprecated, from_hex_decode, to_hex_encode # Numpy 1.x compatibility, # TODO: remove these lines when all dependencies support numpy>=2.0.0 @@ -47,6 +48,13 @@ } EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2} +class SourceType(Enum): + """Enumeration of the source types for the Function class. + The source can be either a callable or an array. + """ + + CALLABLE = 0 + ARRAY = 1 class Function: # pylint: disable=too-many-public-methods """Class converts a python function or a data sequence into an object From 9d0ec386c02ff7242af3c345955003c03e2caf5a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:09:22 -0300 Subject: [PATCH 08/22] BUG: Fix incorrect Jacobian in `only_radial_burn` branch of `SolidMotor.evaluate_geometry` (#944) * Initial plan * BUG: Fix incorrect Jacobian in only_radial_burn branch of evaluate_geometry Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> --- CHANGELOG.md | 2 ++ rocketpy/motors/solid_motor.py | 11 ++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe77d0dda..6b8d0aee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,8 @@ Attention: The newest changes should be on top --> - BUG: Fix parallel Monte Carlo simulation showing incorrect iteration count [#806](https://github.com/RocketPy-Team/RocketPy/pull/806) - BUG: Fix missing titles in roll parameter plots for fin sets [#934](https://github.com/RocketPy-Team/RocketPy/pull/934) - BUG: Duplicate _controllers in Flight.TimeNodes.merge() [#931](https://github.com/RocketPy-Team/RocketPy/pull/931) +- BUG: Fix incorrect Jacobian in `only_radial_burn` branch of `SolidMotor.evaluate_geometry` [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) +- BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) ## [v1.11.0] - 2025-11-01 diff --git a/rocketpy/motors/solid_motor.py b/rocketpy/motors/solid_motor.py index f5e89c2f8..590a02511 100644 --- a/rocketpy/motors/solid_motor.py +++ b/rocketpy/motors/solid_motor.py @@ -546,13 +546,14 @@ def geometry_jacobian(t, y): 2 * np.pi * (grain_inner_radius * grain_height) ** 2 ) - inner_radius_derivative_wrt_inner_radius = factor * ( - grain_height - 2 * grain_inner_radius - ) - inner_radius_derivative_wrt_height = 0 + # burn_area = 2π*r*h, so ṙ = -vdiff/(2π*r*h): + # ∂ṙ/∂r = vdiff/(2π*r²*h) = factor * h + # ∂ṙ/∂h = vdiff/(2π*r*h²) = factor * r + inner_radius_derivative_wrt_inner_radius = factor * grain_height + inner_radius_derivative_wrt_height = factor * grain_inner_radius + # dh/dt = 0, so all partial derivatives of height are zero height_derivative_wrt_inner_radius = 0 height_derivative_wrt_height = 0 - # Height is a constant, so all the derivatives with respect to it are set to zero return [ [ From b205b75df9d7fa113fcf41cce981752c51986906 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Thu, 19 Mar 2026 23:35:22 -0300 Subject: [PATCH 09/22] ENH: move weathercocking_coeff to PointMassRockt --- docs/user/three_dof_simulation.rst | 13 ++++++----- rocketpy/rocket/point_mass_rocket.py | 10 +++++++++ rocketpy/simulation/flight.py | 19 ++++------------ tests/acceptance/test_3dof_flight.py | 8 +++---- tests/fixtures/flight/flight_fixtures.py | 5 +++-- .../simulation/test_flight_3dof.py | 22 ++++++------------- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/docs/user/three_dof_simulation.rst b/docs/user/three_dof_simulation.rst index 3ac88dca0..70cec6d98 100644 --- a/docs/user/three_dof_simulation.rst +++ b/docs/user/three_dof_simulation.rst @@ -381,7 +381,7 @@ The ``weathercock_coeff`` Parameter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The weathercocking behavior is controlled by the ``weathercock_coeff`` parameter -in the :class:`rocketpy.Flight` class: +in the :class:`rocketpy.PointMassRocket` class: .. jupyter-execute:: @@ -407,10 +407,11 @@ in the :class:`rocketpy.Flight` class: center_of_mass_without_motor=0.0, power_off_drag=0.43, power_on_drag=0.43, + weathercock_coeff=1.0, # Example with weathercocking enabled ) rocket.add_motor(motor, position=0) - # Flight with weathercocking enabled + # Flight uses the weathercocking configured on the point-mass rocket flight = Flight( rocket=rocket, environment=env, @@ -418,7 +419,6 @@ in the :class:`rocketpy.Flight` class: inclination=85, heading=45, simulation_mode="3 DOF", - weathercock_coeff=1.0, # Example with weathercocking enabled ) print(f"Apogee: {flight.apogee - env.elevation:.2f} m") @@ -540,6 +540,7 @@ accuracy. center_of_mass_without_motor=0, power_off_drag=0.43, power_on_drag=0.43, + weathercock_coeff=0.0, ) rocket_3dof.add_motor(motor_3dof, -1.1356) @@ -561,6 +562,7 @@ accuracy. # 3-DOF with no weathercocking start = time.time() + rocket_3dof.weathercock_coeff = 0.0 flight_3dof_0 = Flight( rocket=rocket_3dof, environment=env, @@ -569,12 +571,12 @@ accuracy. heading=45, terminate_on_apogee=True, simulation_mode="3 DOF", - weathercock_coeff=0.0, ) time_3dof_0 = time.time() - start # 3-DOF with default weathercocking start = time.time() + rocket_3dof.weathercock_coeff = 1.0 flight_3dof_1 = Flight( rocket=rocket_3dof, environment=env, @@ -583,12 +585,12 @@ accuracy. heading=45, terminate_on_apogee=True, simulation_mode="3 DOF", - weathercock_coeff=1.0, ) time_3dof_1 = time.time() - start # 3-DOF with high weathercocking start = time.time() + rocket_3dof.weathercock_coeff = 5.0 flight_3dof_5 = Flight( rocket=rocket_3dof, environment=env, @@ -597,7 +599,6 @@ accuracy. heading=45, terminate_on_apogee=True, simulation_mode="3 DOF", - weathercock_coeff=5.0, ) time_3dof_5 = time.time() - start diff --git a/rocketpy/rocket/point_mass_rocket.py b/rocketpy/rocket/point_mass_rocket.py index d94363d2b..32681ee0d 100644 --- a/rocketpy/rocket/point_mass_rocket.py +++ b/rocketpy/rocket/point_mass_rocket.py @@ -31,6 +31,10 @@ class PointMassRocket(Rocket): as :class:`rocketpy.Rocket`, including 1D (Mach-only) and 7D (alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate) definitions. + weathercock_coeff : float, optional + Proportionality coefficient for the alignment rate of the point-mass + rocket body axis with the relative wind direction in 3-DOF + simulations. Must be non-negative. Default is 0.0. Attributes ---------- @@ -63,6 +67,9 @@ class PointMassRocket(Rocket): Convenience wrapper for power-off drag as a Mach-only function. power_on_drag_by_mach : Function Convenience wrapper for power-on drag as a Mach-only function. + weathercock_coeff : float + Proportionality coefficient for weathercocking alignment in 3-DOF + simulations. """ def __init__( @@ -72,6 +79,7 @@ def __init__( center_of_mass_without_motor: float, power_off_drag, power_on_drag, + weathercock_coeff: float = 0.0, ): self._center_of_mass_without_motor_pointmass = center_of_mass_without_motor self._center_of_dry_mass_position = center_of_mass_without_motor @@ -84,6 +92,8 @@ def __init__( self.dry_I_13 = 0.0 self.dry_I_23 = 0.0 + self.weathercock_coeff = float(weathercock_coeff) + # Call base init with safe defaults super().__init__( radius=radius, diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index eb82b1998..1443d1d80 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -504,7 +504,6 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements equations_of_motion="standard", ode_solver="LSODA", simulation_mode="6 DOF", - weathercock_coeff=0.0, ): """Run a trajectory simulation. @@ -588,16 +587,6 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements A custom ``scipy.integrate.OdeSolver`` can be passed as well. For more information on the integration methods, see the scipy documentation [1]_. - weathercock_coeff : float, optional - Proportionality coefficient (rate coefficient) for the alignment rate of the rocket's body axis - with the relative wind direction in 3-DOF simulations, in rad/s. The actual angular velocity - applied to align the rocket is calculated as ``weathercock_coeff * sin(angle)``, where ``angle`` - is the angle between the rocket's axis and the wind direction. A higher value means faster alignment - (quasi-static weathercocking). This parameter is only used when simulation_mode is '3 DOF'. - Default is 0.0 to mimic a pure 3-DOF simulation without any weathercocking (fixed attitude). - Set to a positive value to enable quasi-static weathercocking behaviour. - - Returns ------- None @@ -627,7 +616,6 @@ def __init__( # pylint: disable=too-many-arguments,too-many-statements self.equations_of_motion = equations_of_motion self.simulation_mode = simulation_mode self.ode_solver = ode_solver - self.weathercock_coeff = weathercock_coeff # Controller initialization self.__init_controllers() @@ -2310,7 +2298,8 @@ def u_dot_generalized_3dof(self, t, u, post_processing=False): r_dot = [vx, vy, vz] # Weathercocking: evolve body axis direction toward relative wind # The body z-axis (attitude vector) should align with -freestream_velocity - if self.weathercock_coeff > 0 and free_stream_speed > 1e-6: + weathercock_coeff = getattr(self.rocket, "weathercock_coeff", 0.0) + if weathercock_coeff > 0 and free_stream_speed > 1e-6: # Current body z-axis in inertial frame (attitude vector) # From rotation matrix: column 3 gives the body z-axis in inertial frame body_z_inertial = Vector( @@ -2342,7 +2331,7 @@ def u_dot_generalized_3dof(self, t, u, post_processing=False): sin_angle = min(1.0, max(-1.0, rotation_axis_mag)) # Angular velocity magnitude proportional to misalignment angle - omega_mag = self.weathercock_coeff * sin_angle + omega_mag = weathercock_coeff * sin_angle # Angular velocity in inertial frame, then transform to body frame omega_body = Kt @ (rotation_axis * omega_mag) @@ -2363,7 +2352,7 @@ def u_dot_generalized_3dof(self, t, u, post_processing=False): ) rotation_axis = perp_axis.unit_vector # 180 degree rotation: sin(angle) = 1 - omega_mag = self.weathercock_coeff * 1.0 + omega_mag = weathercock_coeff * 1.0 omega_body = Kt @ (rotation_axis * omega_mag) # else: aligned (dot > 0.999) - no rotation needed, omega_body stays None diff --git a/tests/acceptance/test_3dof_flight.py b/tests/acceptance/test_3dof_flight.py index 08c3cb2f4..cdd44f9b7 100644 --- a/tests/acceptance/test_3dof_flight.py +++ b/tests/acceptance/test_3dof_flight.py @@ -202,7 +202,7 @@ def test_3dof_weathercocking_coefficient_stored(flight_3dof_with_weathercock): flight_3dof_with_weathercock : rocketpy.Flight A 3 DOF flight simulation with weathercocking enabled. """ - assert flight_3dof_with_weathercock.weathercock_coeff == 1.0 + assert flight_3dof_with_weathercock.rocket.weathercock_coeff == 1.0 def test_3dof_flight_post_processing_attributes(flight_3dof_no_weathercock): @@ -399,6 +399,8 @@ def test_3dof_flight_reproducibility( acceptance_point_mass_rocket : rocketpy.PointMassRocket Rocket fixture for testing. """ + acceptance_point_mass_rocket.weathercock_coeff = 0.5 + # Run simulation twice with same parameters flight1 = Flight( rocket=acceptance_point_mass_rocket, @@ -407,7 +409,6 @@ def test_3dof_flight_reproducibility( inclination=LAUNCH_INCLINATION, heading=LAUNCH_HEADING, simulation_mode="3 DOF", - weathercock_coeff=0.5, ) flight2 = Flight( @@ -417,7 +418,6 @@ def test_3dof_flight_reproducibility( inclination=LAUNCH_INCLINATION, heading=LAUNCH_HEADING, simulation_mode="3 DOF", - weathercock_coeff=0.5, ) # Results should be identical @@ -452,6 +452,7 @@ def test_3dof_flight_different_weathercock_coefficients( flights = [] for coeff in coefficients: + acceptance_point_mass_rocket.weathercock_coeff = coeff flight = Flight( rocket=acceptance_point_mass_rocket, environment=example_spaceport_env, @@ -459,7 +460,6 @@ def test_3dof_flight_different_weathercock_coefficients( inclination=LAUNCH_INCLINATION, heading=LAUNCH_HEADING, simulation_mode="3 DOF", - weathercock_coeff=coeff, ) flights.append(flight) diff --git a/tests/fixtures/flight/flight_fixtures.py b/tests/fixtures/flight/flight_fixtures.py index b13b52b6b..f18b45f9e 100644 --- a/tests/fixtures/flight/flight_fixtures.py +++ b/tests/fixtures/flight/flight_fixtures.py @@ -352,6 +352,7 @@ def acceptance_point_mass_rocket(acceptance_point_mass_motor): center_of_mass_without_motor=0, power_off_drag=0.43, power_on_drag=0.43, + weathercock_coeff=0.0, ) rocket.add_motor(acceptance_point_mass_motor, position=0) return rocket @@ -376,6 +377,7 @@ def flight_3dof_no_weathercock(example_spaceport_env, acceptance_point_mass_rock rocketpy.Flight A 3 DOF flight simulation with weathercock_coeff=0.0. """ + acceptance_point_mass_rocket.weathercock_coeff = 0.0 return Flight( rocket=acceptance_point_mass_rocket, environment=example_spaceport_env, @@ -383,7 +385,6 @@ def flight_3dof_no_weathercock(example_spaceport_env, acceptance_point_mass_rock inclination=LAUNCH_INCLINATION, heading=LAUNCH_HEADING, simulation_mode="3 DOF", - weathercock_coeff=0.0, ) @@ -406,6 +407,7 @@ def flight_3dof_with_weathercock(example_spaceport_env, acceptance_point_mass_ro rocketpy.Flight A 3 DOF flight simulation with weathercock_coeff=1.0. """ + acceptance_point_mass_rocket.weathercock_coeff = 1.0 return Flight( rocket=acceptance_point_mass_rocket, environment=example_spaceport_env, @@ -413,5 +415,4 @@ def flight_3dof_with_weathercock(example_spaceport_env, acceptance_point_mass_ro inclination=LAUNCH_INCLINATION, heading=LAUNCH_HEADING, simulation_mode="3 DOF", - weathercock_coeff=1.0, ) diff --git a/tests/integration/simulation/test_flight_3dof.py b/tests/integration/simulation/test_flight_3dof.py index ff504a7c6..5c8929ddf 100644 --- a/tests/integration/simulation/test_flight_3dof.py +++ b/tests/integration/simulation/test_flight_3dof.py @@ -59,12 +59,12 @@ def flight_weathercock_zero(example_plain_env, point_mass_rocket): rocketpy.simulation.flight.Flight A Flight object configured for 3-DOF with zero weathercock coefficient. """ + point_mass_rocket.weathercock_coeff = 0.0 return Flight( rocket=point_mass_rocket, environment=example_plain_env, rail_length=1, simulation_mode="3 DOF", - weathercock_coeff=0.0, ) @@ -94,12 +94,12 @@ def flight_weathercock_pos(example_plain_env, point_mass_rocket): rocketpy.simulation.flight.Flight A Flight object configured for 3-DOF with weathercocking enabled. """ + point_mass_rocket.weathercock_coeff = 1.0 return Flight( rocket=point_mass_rocket, environment=example_plain_env, rail_length=1, simulation_mode="3 DOF", - weathercock_coeff=1.0, ) @@ -169,24 +169,16 @@ def test_invalid_simulation_mode(example_plain_env, calisto): ) -def test_weathercock_coeff_stored(example_plain_env, point_mass_rocket): - """Tests that the weathercock_coeff parameter is correctly stored. +def test_weathercock_coeff_stored(point_mass_rocket): + """Tests that weathercock coefficient is stored in PointMassRocket. Parameters ---------- - example_plain_env : rocketpy.Environment - A basic environment fixture for flight simulation. point_mass_rocket : rocketpy.PointMassRocket A point mass rocket fixture for 3-DOF simulation. """ - flight = Flight( - rocket=point_mass_rocket, - environment=example_plain_env, - rail_length=1, - simulation_mode="3 DOF", - weathercock_coeff=2.5, - ) - assert flight.weathercock_coeff == 2.5 + point_mass_rocket.weathercock_coeff = 2.5 + assert point_mass_rocket.weathercock_coeff == 2.5 def test_weathercock_coeff_default(flight_3dof): @@ -197,7 +189,7 @@ def test_weathercock_coeff_default(flight_3dof): flight_3dof : rocketpy.Flight A Flight object for a 3-DOF simulation, provided by the flight_3dof fixture. """ - assert flight_3dof.weathercock_coeff == 0.0 + assert flight_3dof.rocket.weathercock_coeff == 0.0 def test_point_mass_rocket_3dof_uses_7d_drag_inputs( From dc6e87817847bfa7f28589869febdbf63db1c247 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Thu, 19 Mar 2026 23:36:30 -0300 Subject: [PATCH 10/22] MNT: ruff --- rocketpy/mathutils/function.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index f11e4879e..e7ef294ad 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -48,6 +48,7 @@ } EXTRAPOLATION_TYPES = {"zero": 0, "natural": 1, "constant": 2} + class SourceType(Enum): """Enumeration of the source types for the Function class. The source can be either a callable or an array. @@ -56,6 +57,7 @@ class SourceType(Enum): CALLABLE = 0 ARRAY = 1 + class Function: # pylint: disable=too-many-public-methods """Class converts a python function or a data sequence into an object which can be handled more naturally, enabling easy interpolation, From df52c158226334ced85979d23cde5630e85bb4a5 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 20 Mar 2026 11:10:38 -0300 Subject: [PATCH 11/22] MNT: fix cyclic import --- rocketpy/mathutils/function.py | 9 +- .../rocket/aero_surface/generic_surface.py | 90 +++++- rocketpy/rocket/rocket.py | 130 ++++++++- rocketpy/tools.py | 256 ------------------ 4 files changed, 217 insertions(+), 268 deletions(-) diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index e7ef294ad..33a82ec01 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -17,6 +17,7 @@ import matplotlib.pyplot as plt import numpy as np +from numpy import trapezoid from scipy import integrate, linalg, optimize from scipy.interpolate import ( LinearNDInterpolator, @@ -28,14 +29,6 @@ from rocketpy.plots.plot_helpers import show_or_save_plot from rocketpy.tools import deprecated, from_hex_decode, to_hex_encode -# Numpy 1.x compatibility, -# TODO: remove these lines when all dependencies support numpy>=2.0.0 -if np.lib.NumpyVersion(np.__version__) >= "2.0.0b1": - # pylint: disable=no-name-in-module - from numpy import trapezoid # pragma: no cover -else: - from numpy import trapz as trapezoid # pragma: no cover - NUMERICAL_TYPES = (float, int, complex, np.integer, np.floating) INTERPOLATION_TYPES = { "linear": 0, diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index 23ccb0d77..8ab438620 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -1,11 +1,11 @@ import copy +import csv import math import numpy as np from rocketpy.mathutils import Function from rocketpy.mathutils.vector_matrix import Matrix, Vector -from rocketpy.tools import load_generic_surface_csv class GenericSurface: @@ -328,7 +328,7 @@ def _process_input(self, input_data, coeff_name): """ if isinstance(input_data, str): # Input is assumed to be a file path to a CSV - return load_generic_surface_csv(input_data, coeff_name) + return self.__load_generic_surface_csv(input_data, coeff_name) elif isinstance(input_data, Function): if input_data.__dom_dim__ != 7: raise ValueError( @@ -379,3 +379,89 @@ def _process_input(self, input_data, coeff_name): f"Invalid input for {coeff_name}: must be a CSV file path" " or a callable." ) + + def __load_generic_surface_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel + """Load GenericSurface coefficient CSV into a 7D Function. + + This loader expects header-based CSV data with one or more independent + variables among: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, + roll_rate. + """ + independent_vars = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + + try: + with open(file_path, mode="r") as file: + reader = csv.reader(file) + header = next(reader) + except (FileNotFoundError, IOError) as e: + raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e + except StopIteration as e: + raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e + + if not header: + raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") + + header = [column.strip() for column in header] + present_columns = [col for col in independent_vars if col in header] + + invalid_columns = [col for col in header[:-1] if col not in independent_vars] + if invalid_columns: + raise ValueError( + f"Invalid independent variable(s) in {coeff_name} CSV: " + f"{invalid_columns}. Valid options are: {independent_vars}." + ) + + if header[-1] in independent_vars: + raise ValueError( + f"Last column in {coeff_name} CSV must be the coefficient" + " value, not an independent variable." + ) + + if not present_columns: + raise ValueError(f"No independent variables found in {coeff_name} CSV.") + + ordered_present_columns = [ + col for col in header[:-1] if col in independent_vars + ] + + csv_func = Function.from_regular_grid_csv( + file_path, + ordered_present_columns, + coeff_name, + extrapolation="natural", + ) + if csv_func is None: + csv_func = Function( + file_path, + interpolation="linear", + extrapolation="natural", + ) + + def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + args_by_name = { + "alpha": alpha, + "beta": beta, + "mach": mach, + "reynolds": reynolds, + "pitch_rate": pitch_rate, + "yaw_rate": yaw_rate, + "roll_rate": roll_rate, + } + selected_args = [args_by_name[col] for col in ordered_present_columns] + return csv_func(*selected_args) + + return Function( + wrapper, + independent_vars, + [coeff_name], + interpolation="linear", + extrapolation="natural", + ) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 0e44365d6..e3692d2e8 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,3 +1,4 @@ +import csv import inspect import math import warnings @@ -27,7 +28,6 @@ from rocketpy.tools import ( deprecated, find_obj_from_hash, - load_rocket_drag_csv, parallel_axis_theorem_from_com, ) @@ -2243,7 +2243,7 @@ def _count_positional_args(callable_obj): # Case 1: string input can be a CSV path or any Function-supported source. if isinstance(input_data, str): if input_data.lower().endswith(".csv"): - return load_rocket_drag_csv(input_data, coeff_name) + return self.__load_rocket_drag_csv(input_data, coeff_name) function_data = Function(input_data) _validate_function_domain_dimension(function_data) @@ -2319,3 +2319,129 @@ def _count_positional_args(callable_obj): f"Invalid input for {coeff_name}: must be int, float, CSV file path, " "Function, or callable." ) + + def __load_rocket_drag_csv(self, file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel + """Load Rocket drag CSV into a 7D Function. + + Supports either headerless two-column (mach, coefficient) tables or + header-based multi-variable CSV tables. + """ + independent_vars = [ + "alpha", + "beta", + "mach", + "reynolds", + "pitch_rate", + "yaw_rate", + "roll_rate", + ] + + def _is_numeric(value): + try: + float(value) + return True + except (TypeError, ValueError): + try: + int(value) + return True + except (TypeError, ValueError): + return False + + try: + with open(file_path, mode="r") as file: + reader = csv.reader(file) + first_row = next(reader) + except (FileNotFoundError, IOError) as e: + raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e + except StopIteration as e: + raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e + + if not first_row: + raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") + + is_headerless_two_column = len(first_row) == 2 and all( + _is_numeric(cell) for cell in first_row + ) + + if is_headerless_two_column: + csv_func = Function( + file_path, + interpolation="linear", + extrapolation="constant", + ) + + def mach_wrapper( + _alpha, + _beta, + mach, + _reynolds, + _pitch_rate, + _yaw_rate, + _roll_rate, + ): + return csv_func(mach) + + return Function( + mach_wrapper, + independent_vars, + [coeff_name], + interpolation="linear", + extrapolation="constant", + ) + + header = [column.strip() for column in first_row] + present_columns = [col for col in independent_vars if col in header] + + invalid_columns = [col for col in header[:-1] if col not in independent_vars] + if invalid_columns: + raise ValueError( + f"Invalid independent variable(s) in {coeff_name} CSV: " + f"{invalid_columns}. Valid options are: {independent_vars}." + ) + + if header[-1] in independent_vars: + raise ValueError( + f"Last column in {coeff_name} CSV must be the coefficient " + "value, not an independent variable." + ) + + if not present_columns: + raise ValueError(f"No independent variables found in {coeff_name} CSV.") + + ordered_present_columns = [ + col for col in header[:-1] if col in independent_vars + ] + + csv_func = Function.from_regular_grid_csv( + file_path, + ordered_present_columns, + coeff_name, + extrapolation="constant", + ) + if csv_func is None: + csv_func = Function( + file_path, + interpolation="linear", + extrapolation="constant", + ) + + def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): + args_by_name = { + "alpha": alpha, + "beta": beta, + "mach": mach, + "reynolds": reynolds, + "pitch_rate": pitch_rate, + "yaw_rate": yaw_rate, + "roll_rate": roll_rate, + } + selected_args = [args_by_name[col] for col in ordered_present_columns] + return csv_func(*selected_args) + + return Function( + wrapper, + independent_vars, + [coeff_name], + interpolation="linear", + extrapolation="constant", + ) diff --git a/rocketpy/tools.py b/rocketpy/tools.py index 6c8572a47..68ab3404a 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -7,7 +7,6 @@ """ import base64 -import csv import functools import importlib import importlib.metadata @@ -117,261 +116,6 @@ def tuple_handler(value): raise ValueError("value must be a list or tuple of length 1 or 2.") -def create_regular_grid_function( - csv_source, - variable_names, - coeff_name, - extrapolation, -): # pylint: disable=import-outside-toplevel - """Create a regular-grid Function when CSV samples form a full grid. - - Parameters - ---------- - csv_source : str - Path to the CSV file. - variable_names : list[str] - Ordered independent variable names present in the CSV. - coeff_name : str - Name of the coefficient output. - extrapolation : str - Extrapolation method passed to the Function constructor. - - Returns - ------- - Function or None - A ``Function`` configured with ``regular_grid`` interpolation when the - CSV data forms a strict Cartesian grid, otherwise ``None``. - """ - from rocketpy.mathutils.function import ( # pylint: disable=import-outside-toplevel - Function, # pylint: disable=import-outside-toplevel - ) - - return Function.from_regular_grid_csv( - csv_source, - variable_names, - coeff_name, - extrapolation, - ) - - -def load_generic_surface_csv(file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load GenericSurface coefficient CSV into a 7D Function. - - This loader expects header-based CSV data with one or more independent - variables among: alpha, beta, mach, reynolds, pitch_rate, yaw_rate, - roll_rate. - """ - from rocketpy.mathutils.function import ( # pylint: disable=import-outside-toplevel - Function, # pylint: disable=import-outside-toplevel - ) - - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - try: - with open(file_path, mode="r") as file: - reader = csv.reader(file) - header = next(reader) - except (FileNotFoundError, IOError) as e: - raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e - except StopIteration as e: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e - - if not header: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") - - header = [column.strip() for column in header] - present_columns = [col for col in independent_vars if col in header] - - invalid_columns = [col for col in header[:-1] if col not in independent_vars] - if invalid_columns: - raise ValueError( - f"Invalid independent variable(s) in {coeff_name} CSV: " - f"{invalid_columns}. Valid options are: {independent_vars}." - ) - - if header[-1] in independent_vars: - raise ValueError( - f"Last column in {coeff_name} CSV must be the coefficient" - " value, not an independent variable." - ) - - if not present_columns: - raise ValueError(f"No independent variables found in {coeff_name} CSV.") - - ordered_present_columns = [col for col in header[:-1] if col in independent_vars] - - csv_func = create_regular_grid_function( - file_path, - ordered_present_columns, - coeff_name, - extrapolation="natural", - ) - if csv_func is None: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="natural", - ) - - def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): - args_by_name = { - "alpha": alpha, - "beta": beta, - "mach": mach, - "reynolds": reynolds, - "pitch_rate": pitch_rate, - "yaw_rate": yaw_rate, - "roll_rate": roll_rate, - } - selected_args = [args_by_name[col] for col in ordered_present_columns] - return csv_func(*selected_args) - - return Function( - wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="natural", - ) - - -def load_rocket_drag_csv(file_path, coeff_name): # pylint: disable=too-many-statements,import-outside-toplevel - """Load Rocket drag CSV into a 7D Function. - - Supports either headerless two-column (mach, coefficient) tables or - header-based multi-variable CSV tables. - """ - from rocketpy.mathutils.function import ( # pylint: disable=import-outside-toplevel - Function, # pylint: disable=import-outside-toplevel - ) - - independent_vars = [ - "alpha", - "beta", - "mach", - "reynolds", - "pitch_rate", - "yaw_rate", - "roll_rate", - ] - - def _is_numeric(value): - try: - float(value) - return True - except (TypeError, ValueError): - try: - int(value) - return True - except (TypeError, ValueError): - return False - - try: - with open(file_path, mode="r") as file: - reader = csv.reader(file) - first_row = next(reader) - except (FileNotFoundError, IOError) as e: - raise ValueError(f"Error reading {coeff_name} CSV file: {e}") from e - except StopIteration as e: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") from e - - if not first_row: - raise ValueError(f"Invalid or empty CSV file for {coeff_name}.") - - is_headerless_two_column = len(first_row) == 2 and all( - _is_numeric(cell) for cell in first_row - ) - - if is_headerless_two_column: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="constant", - ) - - def mach_wrapper( - _alpha, - _beta, - mach, - _reynolds, - _pitch_rate, - _yaw_rate, - _roll_rate, - ): - return csv_func(mach) - - return Function( - mach_wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - header = [column.strip() for column in first_row] - present_columns = [col for col in independent_vars if col in header] - - invalid_columns = [col for col in header[:-1] if col not in independent_vars] - if invalid_columns: - raise ValueError( - f"Invalid independent variable(s) in {coeff_name} CSV: " - f"{invalid_columns}. Valid options are: {independent_vars}." - ) - - if header[-1] in independent_vars: - raise ValueError( - f"Last column in {coeff_name} CSV must be the coefficient " - "value, not an independent variable." - ) - - if not present_columns: - raise ValueError(f"No independent variables found in {coeff_name} CSV.") - - ordered_present_columns = [col for col in header[:-1] if col in independent_vars] - - csv_func = create_regular_grid_function( - file_path, - ordered_present_columns, - coeff_name, - extrapolation="constant", - ) - if csv_func is None: - csv_func = Function( - file_path, - interpolation="linear", - extrapolation="constant", - ) - - def wrapper(alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate): - args_by_name = { - "alpha": alpha, - "beta": beta, - "mach": mach, - "reynolds": reynolds, - "pitch_rate": pitch_rate, - "yaw_rate": yaw_rate, - "roll_rate": roll_rate, - } - selected_args = [args_by_name[col] for col in ordered_present_columns] - return csv_func(*selected_args) - - return Function( - wrapper, - independent_vars, - [coeff_name], - interpolation="linear", - extrapolation="constant", - ) - - def calculate_cubic_hermite_coefficients(x0, x1, y0, yp0, y1, yp1): """Calculate the coefficients of a cubic Hermite interpolation function. The function is defined as ax**3 + bx**2 + cx + d. From 1f4f7927b4e957e7308011e230ef3f97393ca31c Mon Sep 17 00:00:00 2001 From: Khushal Kottaru Date: Wed, 25 Mar 2026 03:29:20 -0700 Subject: [PATCH 12/22] BUG: Add wraparound logic for wind direction in environment plots (#939) * chore: added personal toolkit files * update branch name in workflow * chore: update toolkit files * Fix: add wraparound logic for wind direction and related tests * style: fix ruff formatting * Remove unused import Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor: move repetitive logic into helper method * fix: update test logic in test_environment * add changelog entry --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> --- CHANGELOG.md | 1 + rocketpy/plots/environment_plots.py | 42 +++++++++++++-- .../environment/test_environment.py | 53 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8d0aee4..f838cc64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: Add wraparound logic for wind direction in environment plots [#939](https://github.com/RocketPy-Team/RocketPy/pull/939) - BUG: Restore `Rocket.power_off_drag` and `Rocket.power_on_drag` as `Function` objects while preserving raw inputs in `power_off_drag_input` and `power_on_drag_input` [#941](https://github.com/RocketPy-Team/RocketPy/pull/941) - BUG: Add explicit timeouts to ThrustCurve API requests [#935](https://github.com/RocketPy-Team/RocketPy/pull/935) - BUG: Fix hard-coded radius value for parachute added mass calculation [#889](https://github.com/RocketPy-Team/RocketPy/pull/889) diff --git a/rocketpy/plots/environment_plots.py b/rocketpy/plots/environment_plots.py index 4b8a91e15..f53cecc1b 100644 --- a/rocketpy/plots/environment_plots.py +++ b/rocketpy/plots/environment_plots.py @@ -33,6 +33,30 @@ def __init__(self, environment): self.grid = np.linspace(environment.elevation, environment.max_expected_height) self.environment = environment + def _break_direction_wraparound(self, directions, altitudes): + """Inserts NaN into direction and altitude arrays at 0°/360° wraparound + points so matplotlib does not draw a horizontal line across the plot. + + Parameters + ---------- + directions : numpy.ndarray + Wind direction values in degrees, dtype float. + altitudes : numpy.ndarray + Altitude values corresponding to each direction, dtype float. + + Returns + ------- + directions : numpy.ndarray + Direction array with NaN inserted at wraparound points. + altitudes : numpy.ndarray + Altitude array with NaN inserted at wraparound points. + """ + WRAP_THRESHOLD = 180 # degrees; half the full circle + wrap_indices = np.where(np.abs(np.diff(directions)) > WRAP_THRESHOLD)[0] + 1 + directions = np.insert(directions, wrap_indices, np.nan) + altitudes = np.insert(altitudes, wrap_indices, np.nan) + return directions, altitudes + def __wind(self, ax): """Adds wind speed and wind direction graphs to the same axis. @@ -55,9 +79,14 @@ def __wind(self, ax): ax.set_xlabel("Wind Speed (m/s)", color="#ff7f0e") ax.tick_params("x", colors="#ff7f0e") axup = ax.twiny() + directions = np.array( + [self.environment.wind_direction(i) for i in self.grid], dtype=float + ) + altitudes = np.array(self.grid, dtype=float) + directions, altitudes = self._break_direction_wraparound(directions, altitudes) axup.plot( - [self.environment.wind_direction(i) for i in self.grid], - self.grid, + directions, + altitudes, color="#1f77b4", label="Wind Direction", ) @@ -311,9 +340,14 @@ def ensemble_member_comparison(self, *, filename=None): ax8 = plt.subplot(324) for i in range(self.environment.num_ensemble_members): self.environment.select_ensemble_member(i) + dirs = np.array( + [self.environment.wind_direction(j) for j in self.grid], dtype=float + ) + alts = np.array(self.grid, dtype=float) + dirs, alts = self._break_direction_wraparound(dirs, alts) ax8.plot( - [self.environment.wind_direction(i) for i in self.grid], - self.grid, + dirs, + alts, label=i, ) ax8.set_ylabel("Height Above Sea Level (m)") diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py index 3bdd5209a..d919c535d 100644 --- a/tests/integration/environment/test_environment.py +++ b/tests/integration/environment/test_environment.py @@ -92,6 +92,59 @@ def test_standard_atmosphere(mock_show, example_plain_env): # pylint: disable=u assert example_plain_env.prints.print_earth_details() is None +@patch("matplotlib.pyplot.show") +def test_wind_plots_wrapping_direction(mock_show, example_plain_env): # pylint: disable=unused-argument + """Tests that wind direction plots handle 360°→0° wraparound without + drawing a horizontal line across the graph. + + Parameters + ---------- + mock_show : mock + Mock object to replace matplotlib.pyplot.show() method. + example_plain_env : rocketpy.Environment + Example environment object to be tested. + """ + # Set a custom atmosphere where wind direction wraps from ~350° to ~10° + # across the altitude range by choosing wind_u and wind_v to create a + # direction near 350° at low altitude and ~10° at higher altitude. + # wind_direction = (180 + atan2(wind_u, wind_v)) % 360 + # For direction ~350°: need atan2(wind_u, wind_v) ≈ 170° → wind_u>0, wind_v<0 + # For direction ~10°: need atan2(wind_u, wind_v) ≈ -170° → wind_u<0, wind_v<0 + example_plain_env.set_atmospheric_model( + type="custom_atmosphere", + pressure=None, + temperature=300, + wind_u=[(0, 1), (5000, -1)], # changes sign across altitude + wind_v=[(0, -6), (5000, -6)], # stays negative → heading near 350°/10° + ) + # Verify that the wind direction actually wraps through 0°/360° in this + # atmosphere so the test exercises the wraparound code path. + low_dir = example_plain_env.wind_direction(0) + high_dir = example_plain_env.wind_direction(5000) + assert abs(low_dir - high_dir) > 180, ( + "Test setup error: wind direction should cross 0°/360° boundary" + ) + # Verify that the helper inserts NaN breaks into the direction and altitude + # arrays at the wraparound point, which is the core of the fix. + directions = np.array( + [example_plain_env.wind_direction(i) for i in example_plain_env.plots.grid], + dtype=float, + ) + altitudes = np.array(example_plain_env.plots.grid, dtype=float) + directions_broken, altitudes_broken = ( + example_plain_env.plots._break_direction_wraparound(directions, altitudes) + ) + assert np.any(np.isnan(directions_broken)), ( + "Expected NaN breaks in direction array at 0°/360° wraparound" + ) + assert np.any(np.isnan(altitudes_broken)), ( + "Expected NaN breaks in altitude array at 0°/360° wraparound" + ) + # Verify info() and atmospheric_model() plots complete without error + assert example_plain_env.info() is None + assert example_plain_env.plots.atmospheric_model() is None + + @pytest.mark.parametrize( "model_name", [ From d0ce62af0cfa89fa8ed496b1d9a2ee54869a213b Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 27 Mar 2026 19:28:56 -0300 Subject: [PATCH 13/22] MNT: add numpy import to test_environment.py --- tests/integration/environment/test_environment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py index d919c535d..c5e1103bd 100644 --- a/tests/integration/environment/test_environment.py +++ b/tests/integration/environment/test_environment.py @@ -2,6 +2,7 @@ from datetime import date, datetime, timezone from unittest.mock import patch +import numpy as np import pytest From 9cd2d34541b181d51b84cd4e0bf6e945a13a0af5 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Fri, 27 Mar 2026 19:34:28 -0300 Subject: [PATCH 14/22] MNT: rename constant for wraparound threshold in _break_direction_wraparound method --- rocketpy/plots/environment_plots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rocketpy/plots/environment_plots.py b/rocketpy/plots/environment_plots.py index f53cecc1b..add5e4efb 100644 --- a/rocketpy/plots/environment_plots.py +++ b/rocketpy/plots/environment_plots.py @@ -51,8 +51,8 @@ def _break_direction_wraparound(self, directions, altitudes): altitudes : numpy.ndarray Altitude array with NaN inserted at wraparound points. """ - WRAP_THRESHOLD = 180 # degrees; half the full circle - wrap_indices = np.where(np.abs(np.diff(directions)) > WRAP_THRESHOLD)[0] + 1 + wrap_threshold = 180 # degrees; half the full circle + wrap_indices = np.where(np.abs(np.diff(directions)) > wrap_threshold)[0] + 1 directions = np.insert(directions, wrap_indices, np.nan) altitudes = np.insert(altitudes, wrap_indices, np.nan) return directions, altitudes From e0173e20c5951fd17c38d357af656e516bfb05a9 Mon Sep 17 00:00:00 2001 From: "Mohammed S. Al-Mahrouqi" Date: Sat, 28 Mar 2026 21:26:19 -0400 Subject: [PATCH 15/22] ENH: Adaptive Monte Carlo via Convergence Criteria (#922) * ENH: added a new function (simulate_convergence) * DOC: added a cell to show simulate_convergence function usage * TST: integration test for simulate_convergence * DOC: updated changelog for this PR * ENH: ran black to lint intg test file * new fixes thx to copilot comments * linted rocketpy/simulation/monte_carlo.py --------- Co-authored-by: Malmahrouqi3 --- CHANGELOG.md | 3 +- .../monte_carlo_class_usage.ipynb | 22 ++++++ rocketpy/simulation/monte_carlo.py | 67 +++++++++++++++++++ .../simulation/test_monte_carlo.py | 27 ++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f838cc64a..6d3e6e053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,8 @@ Attention: The newest changes should be on top --> ### Added -- +- ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) +- TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914](https://github.com/RocketPy-Team/RocketPy/pull/914) ### Changed diff --git a/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb b/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb index 2fb46fa86..8181c03ba 100644 --- a/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb +++ b/docs/notebooks/monte_carlo_analysis/monte_carlo_class_usage.ipynb @@ -800,6 +800,28 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, we can target an attribute using the method `MonteCarlo.simulate_convergence()` such that when the tolerance is met, the flight simulations would terminate early." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "test_dispersion.simulate_convergence(\n", + " target_attribute=\"apogee_time\",\n", + " target_confidence=0.95,\n", + " tolerance=0.5, # in seconds\n", + " max_simulations=1000,\n", + " batch_size=50,\n", + ")" + ] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py index e10789a7d..42a566b7b 100644 --- a/rocketpy/simulation/monte_carlo.py +++ b/rocketpy/simulation/monte_carlo.py @@ -525,6 +525,73 @@ def estimate_confidence_interval( return res.confidence_interval + def simulate_convergence( + self, + target_attribute="apogee_time", + target_confidence=0.95, + tolerance=0.5, + max_simulations=1000, + batch_size=50, + parallel=False, + n_workers=None, + ): + """Run Monte Carlo simulations in batches until the confidence interval + width converges within the specified tolerance or the maximum number of + simulations is reached. + + Parameters + ---------- + target_attribute : str + The target attribute to track its convergence (e.g., "apogee", "apogee_time", etc.). + target_confidence : float, optional + The confidence level for the interval (between 0 and 1). Default is 0.95. + tolerance : float, optional + The desired width of the confidence interval in seconds, meters, or other units. Default is 0.5. + max_simulations : int, optional + The maximum number of simulations to run to avoid infinite loops. Default is 1000. + batch_size : int, optional + The number of simulations to run in each batch. Default is 50. + parallel : bool, optional + Whether to run simulations in parallel. Default is False. + n_workers : int, optional + The number of worker processes to use if running in parallel. Default is None. + + Returns + ------- + confidence_interval_history : list of float + History of confidence interval widths, one value per batch of simulations. + The last element corresponds to the width when the simulation stopped for + either meeting the tolerance or reaching the maximum number of simulations. + """ + + self.import_outputs(self.filename.with_suffix(".outputs.txt")) + confidence_interval_history = [] + + while self.num_of_loaded_sims < max_simulations: + total_sims = min(self.num_of_loaded_sims + batch_size, max_simulations) + + self.simulate( + number_of_simulations=total_sims, + append=True, + include_function_data=False, + parallel=parallel, + n_workers=n_workers, + ) + + self.import_outputs(self.filename.with_suffix(".outputs.txt")) + + ci = self.estimate_confidence_interval( + attribute=target_attribute, + confidence_level=target_confidence, + ) + + confidence_interval_history.append(float(ci.high - ci.low)) + + if float(ci.high - ci.low) <= tolerance: + break + + return confidence_interval_history + def __evaluate_flight_inputs(self, sim_idx): """Evaluates the inputs of a single flight simulation. diff --git a/tests/integration/simulation/test_monte_carlo.py b/tests/integration/simulation/test_monte_carlo.py index 4b1b82392..98af2431d 100644 --- a/tests/integration/simulation/test_monte_carlo.py +++ b/tests/integration/simulation/test_monte_carlo.py @@ -236,3 +236,30 @@ def invalid_data_collector(flight): monte_carlo_calisto.simulate(number_of_simulations=10, append=False) finally: _post_test_file_cleanup() + + +@pytest.mark.slow +def test_monte_carlo_simulate_convergence(monte_carlo_calisto): + """Tests the simulate_convergence method of the MonteCarlo class. + + Parameters + ---------- + monte_carlo_calisto : MonteCarlo + The MonteCarlo object, this is a pytest fixture. + """ + try: + ci_history = monte_carlo_calisto.simulate_convergence( + target_attribute="apogee", + target_confidence=0.95, + tolerance=5.0, + max_simulations=20, + batch_size=5, + parallel=False, + ) + + assert isinstance(ci_history, list) + assert all(isinstance(width, float) for width in ci_history) + assert len(ci_history) >= 1 + assert monte_carlo_calisto.num_of_loaded_sims <= 20 + finally: + _post_test_file_cleanup() From de291003630585989624350e30a73b7ba70201d4 Mon Sep 17 00:00:00 2001 From: ZuoRen Chen <180084773+zuorenchen@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:48:31 +0100 Subject: [PATCH 16/22] BUG: Remove duplicate controller process (#949) * Remove duplicate controller process * Bug: process sensor if node._component_sensors exist instead of flight.sensor --- rocketpy/simulation/flight.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 1443d1d80..2293d9706 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -698,15 +698,6 @@ def __simulate(self, verbose): self.__process_sensors_and_controllers_at_current_node(node, phase) - for controller in node._controllers: - controller( - self.t, - self.y_sol, - self.solution, - self.sensors, - self.env, - ) - for parachute in node.parachutes: # Calculate and save pressure signal ( @@ -853,7 +844,7 @@ def __process_sensors_and_controllers_at_current_node(self, node, phase): phase : FlightPhase The current flight phase. """ - if self.sensors: + if node._component_sensors: u_dot = phase.derivative(self.t, self.y_sol) self.__measure_sensors(node._component_sensors, u_dot) From 1927001f4ccb8dd915d0ab81dc9b82d6c8083ce6 Mon Sep 17 00:00:00 2001 From: "Mohammed S. Al-Mahrouqi" Date: Sat, 4 Apr 2026 18:24:38 -0400 Subject: [PATCH 17/22] ENH: Auto Populate Changelog (#919) * Create changelog.yml for release drafting Add changelog.yml for processing merged PRs. * new workflow * Change branch reference for changelog update * Update auto-assign.yml * Fix typo in changelog.yml conditional statement * Update README with main features section Added new section for main features of RocketPy. * docs: update changelog for PR #3 * Enhance community section in README Added emphasis to the community invitation. * docs: update changelog for PR #4 * Update README with RocketPy import example Added import statement example for RocketPy classes. * docs: update changelog for PR #5 * Fix typo in changelog commit message * Update .gitignore * DOC: Ipdate Changelog for PR #6 * Fix typo in changelog commit message * Add branch filter for changelog workflow * Update README with aerodynamic models section * Change target branch for pull request workflow * DOC: Update Changelog for PR #7 * updating new things * Remove floating point precision fix entry from CHANGELOG Removed entry for floating point precision errors from changelog. * re-org * Few adjustments to work on the base repo, develop branch. * new changes * restored stuff * doc changes * updated changelog for this PR * updated changelog * PR # included in changelog * DOC: clearer words and corrected grammar * ENH: Updated changelog.yml to include permissions --------- Co-authored-by: Malmahrouqi3 Co-authored-by: github-actions[bot] Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Co-authored-by: Mateus Stano Junqueira <69485049+MateusStano@users.noreply.github.com> --- .github/workflows/changelog.yml | 62 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + docs/development/first_pr.rst | 9 +++-- docs/development/style_guide.rst | 11 +++--- 4 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/changelog.yml diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 000000000..483b0604f --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,62 @@ +name: Populate Changelog +on: + pull_request: + types: [closed] + branches: + - develop + +permissions: + contents: write + +jobs: + Changelog: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Clone RocketPy + uses: actions/checkout@main + with: + repository: RocketPy-Team/RocketPy + ref: develop + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update Changelog + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: | + SECTION="### Added" + PREFIX="ENH" + + if [[ "$PR_LABELS" == *"Bug"* ]]; then + SECTION="### Fixed" + PREFIX="BUG" + elif [[ "$PR_LABELS" == *"Refactor"* ]]; then + SECTION="### Changed" + PREFIX="MNT" + elif [[ "$PR_LABELS" == *"Docs"* ]] && [[ "$PR_LABELS" == *"Git housekeeping"* ]]; then + SECTION="### Changed" + PREFIX="DOC" + elif [[ "$PR_LABELS" == *"Tests"* ]]; then + SECTION="### Changed" + PREFIX="TST" + elif [[ "$PR_LABELS" == *"Docs"* ]]; then + # Only documentation -> Added section + SECTION="### Added" + PREFIX="DOC" + fi + + ENTRY="- $PREFIX: $PR_TITLE [#$PR_NUMBER](https://github.com/RocketPy-Team/RocketPy/pull/$PR_NUMBER)" + SECTION_LINE=$(grep -n "^$SECTION$" CHANGELOG.md | head -1 | cut -d: -f1) + + sed -i "$((SECTION_LINE + 1))a\\$ENTRY" CHANGELOG.md + + - name: Push Changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "DOC: Update Changelog for PR #${{ github.event.pull_request.number }}" + git push diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d3e6e053..11ddec0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Auto Populate Changelog [#919](https://github.com/RocketPy-Team/RocketPy/pull/919) - ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) - TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914](https://github.com/RocketPy-Team/RocketPy/pull/914) diff --git a/docs/development/first_pr.rst b/docs/development/first_pr.rst index 5f3eb876c..c0b07e7f1 100644 --- a/docs/development/first_pr.rst +++ b/docs/development/first_pr.rst @@ -96,14 +96,13 @@ The CHANGELOG file ------------------ We keep track of the changes in the ``CHANGELOG.md`` file. -When you open a PR, you should add a new entry to the "Unreleased" section of the file. -This entry should simply be the title of your PR. +When you open a PR, you should see the "Unreleased" section of the file. +An entry will simply contain the title of your PR if merged. .. note:: - In the future we would like to automate the CHANGELOG update, but for now \ - it is a manual process, unfortunately. - + The CHANGELOG is auto-updated once a PR is merged based on the associated labels, \ + which are assigned by the maintainers. The review process ------------------ diff --git a/docs/development/style_guide.rst b/docs/development/style_guide.rst index 510a49560..15a80e5e4 100644 --- a/docs/development/style_guide.rst +++ b/docs/development/style_guide.rst @@ -162,15 +162,16 @@ Standard acronyms to start the commit message with are:: Pull Requests ^^^^^^^^^^^^^ -When opening a Pull Request, the name of the PR should be clear and concise. -Similarly to the commit messages, the PR name should start with an acronym indicating the type of PR -and then a brief description of the changes. +When opening a Pull Request, the title should be clear and concise. +It should contain only a brief desctiption of the changes without the acronym (e.g. ENH:, BUG:). +The maintainers will label your PR accordingly, which will add a prefix via a workflow to indicate +type of the PR in the CHANGELOG file. Here is an example of a good PR name: -- ``BUG: fix the Frequency Response plot of the Flight class`` +- ``fix the Frequency Response plot of the Flight class`` -The PR description explain the changes and motivation behind them. There is a template \ +The PR description explains the changes and motivation behind them. There is a template \ available when opening a PR that can be used to guide you through the process of both \ describing the changes and making sure all the necessary steps were taken. Of course, \ you can always modify the template or add more information if you think it is necessary. From ad9400ff8f11de7de0b62f65acd3c243a03050b5 Mon Sep 17 00:00:00 2001 From: Mateus Stano Junqueira <69485049+MateusStano@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:15:57 -0300 Subject: [PATCH 18/22] ENH: Individual Fins (#818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ENH: add individual fin class Co-authored-by: kevin-alcaniz * ENH: add trapezoidal fin and elliptical fin Co-authored-by: kevin-alcaniz * ENH: add fin method to rocket class * ENH: add print and plots to individual fins classes. * ENH: Lint corrections, mainly attribute names. * ENH: reorder imports for fins module * ENH: fix rotation matrixes * ENH: create _BaseFin class * MNT: fix variable names in Fin * BUG: correct interference factors * ENH: create _TrapezoidalMixin * MNT: remove unused methods and variables in fin classes * ENH: move evaluate cp out of mixin and include to/from_dict * ENH: free form mixin * ENH: elliptical mixin * ENH: add to_dict in Fin * ENH: add FreeFormFin prints and plots * ENH: add aero surfaces to body rotation matrixes * ENH: adapt position related calculations * Squashed commit of the following: commit c67472556c4659cdfbab7a630c3906fce384af87 Author: Kevin Alcañiz Date: Sat Apr 12 13:40:25 2025 +0200 ENH: Introduce Net Thrust with pressure corrections (#789) * wind factor bug corrected the wind factor wasn't applied to the env.wind_velocity properties * BUG: StochasticModel visualize attributes of a uniform distribution It showed the nominal and the standard deviation values and it doesn't make sense in a uniform distribution. In a np.random.uniform the 'nominal value' is the lower bound of the distribution, and the 'standard deviation' value is the upper bound. Now, a new condition has been added for the uniform distributions where the mean and semi range are calculated and showed. This way the visualize_attribute function will show the whole range where the random values are uniformly taken in * variable names corrections * Corrections requested by the pylint test * ENH: Add pressure corrections for thrust in SolidMotor The thrust generated by a SolidMotor is now adjusted for the atmospheric pressure. To achieve that, a new attribute, 'vacuum_thrust', has been created. The 'net_thrust' is the result of 'vacuum_thrust' minus the atmospheric pressure multiplied by the nozzle area. * ENH: pylint recommendations done * ENH: net thrust method extended to the rest of the motor classes * BUG: __post_processed_variables inconsistent array * ENH: ruff reformatting * Update rocketpy/motors/motor.py Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * ENH: Avoid breaking change * ENH: Pressure Thrust method added * BUG: call to the thrust function wrong * BUG: pressure thrust evaluated when motor is turned off * ENH: CHANGELOG updated * DOC: definition of exhaust velocity improved --------- Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> commit 9f2644a70ea420ec2e581017ed2722aa81751a6e Author: Lucas Prates <57069366+Lucas-Prates@users.noreply.github.com> Date: Sat Apr 12 11:27:53 2025 +0200 ENH: Implement Multivariate Rejection Sampling (MRS) (#738) * ENH: implementing a draft version of the Multivarite Rejectio Sampler (MRS). * MNT: quick notebook to test MRS during development * MNT: refactoring class to match review suggestions * ENH: add comparison prints, plots and ellipses to MonteCarlo and finally checks in MRS * MNT: add MultivariateRejectionSampler class to inits and apply format * DOC: writting .rst documentation for MRS * MNT: adding pylint flags to skip checks * DOC: completing missing sections in mrs.rst * DOC: add changelog and apply sugestions in MRS class * DOC: apply suggestions to the MRS.rst * MNT: use Union instead of | for type hinting since we have to support python3.9 * TST: adding unit and integration tests to MRS * MNT: use pylint flag to fix linter * TST: adding tests to MonteCarlo comparison features * MNT: applying suggestions in .rst, better handling nested variables in MRS and applying linters * MNT: removing TODO comments from monte_carlo_plots * MNT: remove useless TODO * MNT: inserting pragmas for no cover and resolving changelog conflict commit d49c40e75c1817851bacb34ac1666a4585bc4231 Author: ArthurJWH <167456467+ArthurJWH@users.noreply.github.com> Date: Fri Apr 11 16:11:20 2025 -0400 ENH: Create a rocketpy file to store flight simulations (#800) * ENH: added .rpy file functionality (see issue 668) This commit add 'save_to_rpy' and 'load_from_rpy' functions, that allows saving and loading flights. * MNT: adjusting minor changes to .rpy functions and tests. Formatted docstrings correctly. Reverted duplication of `test_encoding.py` files. Version warning will be called when loaded version is more recent. * MNT: incorporating previous comments Change file management from os to Path Adjust docstrings * DOC: Added comment about outputs in `to_dict` method * MNT: Refactoring `RocketPyDecoder` unpacking operation and other small adjustments * DOC: update changelog * STY: formatted according to ruff * MNT: changing `str | Path` operation to support Python 3.9 * MNT: fixed trailing commas on .rpy and added shield against `ruff` formatting .rpy and .json files * MNT: fixing error related to `test_flight_save_load_no_resimulate` When `include_outputs` were set to `True`, it would try to include the additional data into the flight, breaking the test * MNT: fixing a typo and adding comment on test coverage --------- Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> commit 6bf70f3ea05b6eafc46c77eda57ce4e6b68defd6 Author: Júlio Machado <85506246+juliomachad0@users.noreply.github.com> Date: Sat Apr 5 15:08:53 2025 -0300 ENH: Support for the RSE file format has been added to the library (#798) * ENH: Support for the RSE file format has been added to the library. The import_rse method in the Abstract Motor class and the load_from_rse_file method in the GenericMotor class are now available. With this update, the library natively supports Rock Sim software data, eliminating the need for users to manually convert motor files. The implementation was based on the import_eng and load_from_eng_file methods, utilizing Python's standard XML library. * ENH: Adding tests to the methods of .rse file treatment. * ENH: fixing mistakes on the method and test file * MNT: Running ruff * MNT: Adding the PR to CHANGELOG.md commit 220bb590b0815a54844435e3d1c60ce4777fe1e1 Merge: 4a41f7ac 4df0b383 Author: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Date: Thu Mar 27 06:14:22 2025 -0300 Merge pull request #797 from RocketPy-Team/master Updates develop after 1.9.0 commit 4df0b383c2886c7804b6848bbe5b9cbe612b2cd5 Author: MateusStano <69485049+MateusStano@users.noreply.github.com> Date: Mon Mar 24 17:35:03 2025 +0100 REL: Update version to 1.9.0 (#795) commit 5328d66c6869145ed6c8488b5b72f2f305b67db6 Author: MateusStano <69485049+MateusStano@users.noreply.github.com> Date: Mon Mar 24 13:07:52 2025 +0100 DEP: Remove Pending Deprecations and Add Warnings Where Needed (#794) * DEP: Add deprecation warnings for outdated methods and functions * DEP: Remove deprecated methods for NOAA RUC soundings and power drag plots * DEV: changelog * MNT: ruff * DEP: Update deprecation warning for post_process method to specify removal in v1.10 * MNT: Remove unused imports commit 76fb5ef341832450cb5e0707f0ef78ec5b61fc64 Merge: a4b42c3b 4a41f7ac Author: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Date: Sun Mar 23 19:17:16 2025 -0300 Merge pull request #793 from RocketPy-Team/develop DEV: Master to v1.9.0 commit 4a41f7ac12eec669d16b9b0cb4e1659fefc116ec Author: Kevin Alcañiz Date: Sun Mar 23 21:52:51 2025 +0100 ENH: Introduce the StochasticAirBrakes class (#785) * wind factor bug corrected the wind factor wasn't applied to the env.wind_velocity properties * BUG: StochasticModel visualize attributes of a uniform distribution It showed the nominal and the standard deviation values and it doesn't make sense in a uniform distribution. In a np.random.uniform the 'nominal value' is the lower bound of the distribution, and the 'standard deviation' value is the upper bound. Now, a new condition has been added for the uniform distributions where the mean and semi range are calculated and showed. This way the visualize_attribute function will show the whole range where the random values are uniformly taken in * variable names corrections * Corrections requested by the pylint test * ENH: add multiplication for 2D functions in rocketpy.function Added the ability to multiply functions with 2D domains in the __mul__ function * ENH: StochasticAirBrakes class created The StochasticAirBrakes class has been created. The __init__.py files in the stochastic and rocketpy folders have also been modified accordingly to incorporate this new class * ENH: set_air_brakes function created This functions appends an airbrake and controller objects previuosly created to the rocket * ENH: add StochasticAirBrake to rocketpy.stochastic_rocket Some functions has been modified and other has been created in order to include the new StochasticAirBrakes feature into the StochasticRocket class. A new function named 'add_air_brakes' has been created to append a StochasticAirBrakes and Controller objects to the StochasticRocket object. A new function '_create_air_brake' has been introduced to create a sample of an AirBrake object through a StochasticAirBrake object. Enventually, the 'create_object' function has been modified to add the sampled AirBrakes to the sampled Rocket * BUG: StochasticAirBrake object input in _Controller When defining the _Controller object a StochasticAirBrake was input. This is already corrected and a AirBrake object is now introduced * ENH: add time_overshoot option to rocketpy.stochastic_flight Since the new StochasticAirBrake class is defined, we need the 'time_overshoot' option in the Flight class to ensure that the time step defined in the simulation is the controller sampling rate. The MonteCarlo class has had to be modified as well to include this option. * DOC: StochasticAirBrakes related documentation added Documentation related to the StochasticAirBrakes implementation has been added in StochasticAirBrakes, StochasticRocket and Rocket classes. * ENH: pylint recommendations done * ENH: Reformatted files to pass Ruff linting checks * ENH: Update rocketpy/stochastic/stochastic_rocket.py Unnecessary comment Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * DOC: improve drag curve factor definition in StochasticAirBrakes * ENH: Change assert statement to if Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * DOC: better explanation of __mul__ function Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> * ENH: delete set_air_brakes function for simplicity * DOC: CHANGELOG file updated --------- Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> commit 90553f52364231b520b9db769e3c2596176e17ac Author: Kevin Alcañiz Date: Sun Mar 23 20:31:50 2025 +0100 ENH: Add Eccentricity to Stochastic Simulations (#792) * wind factor bug corrected the wind factor wasn't applied to the env.wind_velocity properties * BUG: StochasticModel visualize attributes of a uniform distribution It showed the nominal and the standard deviation values and it doesn't make sense in a uniform distribution. In a np.random.uniform the 'nominal value' is the lower bound of the distribution, and the 'standard deviation' value is the upper bound. Now, a new condition has been added for the uniform distributions where the mean and semi range are calculated and showed. This way the visualize_attribute function will show the whole range where the random values are uniformly taken in * variable names corrections * Corrections requested by the pylint test * ENH: more intuitive uniform distribution display in StochasticModel Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> * ENH: Eccentricities added to the StochasticRocket class A bug has been corrected in Flight class and an enhancement has been performed in the Rocket class as well * BUG: thrust eccentricity bug corrected eccentricity_y was defined by x coordinate and eccentricity_x was defined by y coordinate * BUG: Undo some Rocket class changes * ENH: add eccentricities to StochasticRocket * BUG: fix MonteCarlo eccentricity inputs * ENH: pylint and ruff recommended changes * TST: fix tests with eccentricity --------- Co-authored-by: Gui-FernandesBR commit 73480535d439b837daed397e91ff773d50c954ca Author: Kevin Alcañiz Date: Sun Mar 23 13:49:35 2025 +0100 BUG: fix the wind velocity factors usage and better visualization of uniform distributions in Stochastic Classes (#783) * wind factor bug corrected the wind factor wasn't applied to the env.wind_velocity properties * BUG: StochasticModel visualize attributes of a uniform distribution It showed the nominal and the standard deviation values and it doesn't make sense in a uniform distribution. In a np.random.uniform the 'nominal value' is the lower bound of the distribution, and the 'standard deviation' value is the upper bound. Now, a new condition has been added for the uniform distributions where the mean and semi range are calculated and showed. This way the visualize_attribute function will show the whole range where the random values are uniformly taken in * variable names corrections * Corrections requested by the pylint test * ENH: more intuitive uniform distribution display in StochasticModel Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> --------- Co-authored-by: MateusStano <69485049+MateusStano@users.noreply.github.com> commit d2f89ba36ef5bc3afaa3d64cc7c3ca2cd5c4bbc7 Author: Leonardo Rosa Date: Fri Mar 21 18:57:49 2025 -0300 DEV: add requirements-tests.txt on make install target (#791) * DEV: adds 'pip install -r requirements-tests.txt' recipe to 'make install' target on Makefile Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> commit 91ac567266d20bb74a7f5810f57b708a455a55d0 Author: Leonardo Rosa Date: Fri Mar 21 18:53:53 2025 -0300 BUG: fixes get_instance_attributes for Flight objects containing a Rocket object without rail buttons (#786) * DOC: fixed a typo in funcify_method() description * TST: created test for get_instante_attributes() with flight without rail buttons * BUG: fixed __calculate_rail_button_forces() by assigning a Function(0) to null_force instead of an empty array * DEV: updates CHANGELOG commit 9407470667ba98d89e30a9cc575bf56fb4dbe31d Author: Leonard <74966503+L30-stack@users.noreply.github.com> Date: Wed Mar 19 16:01:59 2025 +0100 BUG: fixed AGL altitude in _FlightPrints.events_registered (#788) * BUG: fixed AGL altitude in _FlightPrints.events_registered * updeted CHANGELOG * MNT: rename base_fin to _base_fin * DOC: finish docstrings * ENH: add title to some fins plots * ENH: individual fin in rocket draw * DOC: add individual fin docs * DOC: typo * DEV: remove merge conflicts * TST: fix tests * MNT: ruff * ENH: add from_dict to new classes * MNT: ruff * MNT: lint * ENH: address comments and change mixin classes to geometry classes * TST: add relevant tests * MNT: ruff pylint * Update docs/reference/classes/aero_surfaces/index.rst Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * MNT: address copilot review * ENH: remove mixin classes files * DOC: typo Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * DOC: typo Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> * MNT: add TODO * TST: add unit tests for fin geometries * MNT: reference barrowman on ellipitcal fins cp * MNT: improve todo description * ENH: add filename args to fins plots * ENH: change fin.d and fin.ref_area to fin.rocket_diameter and fin.reference_area * MNT: improve name of run_geometry_update_chain * ENH: change geometry class to be compute only * ENH: use math instead of numpy in non vector operations * TST: replace pytest.approx with np.testing.assert_allclose for numerical assertions * ENH: update type checks for surfaces and positions to use Iterable * ENH: fix attribute access for sweep_angle in trapezoidal fins encoder test --------- Co-authored-by: kevin-alcaniz Co-authored-by: Julio Machado Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> --- .pylintrc | 5 + .../classes/aero_surfaces/EllipticalFin.rst | 5 + docs/reference/classes/aero_surfaces/Fin.rst | 5 + .../classes/aero_surfaces/FreeFormFin.rst | 5 + .../classes/aero_surfaces/FreeFormFins.rst | 5 + .../classes/aero_surfaces/TrapezoidalFin.rst | 5 + .../classes/aero_surfaces/_BaseFin.rst | 5 + .../reference/classes/aero_surfaces/index.rst | 7 +- docs/static/rocket/fin_forces.png | Bin 0 -> 30191 bytes docs/static/rocket/individual_fin_frame.png | Bin 0 -> 47139 bytes .../aerodynamics/individual_fins.rst | 498 ++++++++++++++++ docs/technical/index.rst | 1 + docs/user/rocket/rocket_axes.rst | 2 + rocketpy/__init__.py | 4 + rocketpy/plots/aero_surface_plots.py | 403 ++++++++++++- rocketpy/plots/rocket_plots.py | 90 ++- rocketpy/prints/aero_surface_prints.py | 114 +++- rocketpy/rocket/__init__.py | 4 + rocketpy/rocket/aero_surface/__init__.py | 4 + rocketpy/rocket/aero_surface/aero_surface.py | 12 +- rocketpy/rocket/aero_surface/fins/__init__.py | 4 + .../rocket/aero_surface/fins/_base_fin.py | 344 +++++++++++ .../rocket/aero_surface/fins/_geometry.py | 564 ++++++++++++++++++ .../aero_surface/fins/elliptical_fin.py | 193 ++++++ .../aero_surface/fins/elliptical_fins.py | 167 +----- rocketpy/rocket/aero_surface/fins/fin.py | 464 ++++++++++++++ rocketpy/rocket/aero_surface/fins/fins.py | 182 +----- .../rocket/aero_surface/fins/free_form_fin.py | 189 ++++++ .../aero_surface/fins/free_form_fins.py | 249 +------- .../aero_surface/fins/trapezoidal_fin.py | 240 ++++++++ .../aero_surface/fins/trapezoidal_fins.py | 184 +----- .../rocket/aero_surface/generic_surface.py | 2 + rocketpy/rocket/aero_surface/rail_buttons.py | 1 + rocketpy/rocket/components.py | 10 +- rocketpy/rocket/rocket.py | 122 ++-- tests/fixtures/surfaces/surface_fixtures.py | 63 ++ tests/integration/test_encoding.py | 2 +- tests/integration/test_rocket.py | 4 +- .../rocket/aero_surface/test_fin_geometry.py | 317 ++++++++++ .../aero_surface/test_individual_fins.py | 424 +++++++++++++ 40 files changed, 4116 insertions(+), 783 deletions(-) create mode 100644 docs/reference/classes/aero_surfaces/EllipticalFin.rst create mode 100644 docs/reference/classes/aero_surfaces/Fin.rst create mode 100644 docs/reference/classes/aero_surfaces/FreeFormFin.rst create mode 100644 docs/reference/classes/aero_surfaces/FreeFormFins.rst create mode 100644 docs/reference/classes/aero_surfaces/TrapezoidalFin.rst create mode 100644 docs/reference/classes/aero_surfaces/_BaseFin.rst create mode 100644 docs/static/rocket/fin_forces.png create mode 100644 docs/static/rocket/individual_fin_frame.png create mode 100644 docs/technical/aerodynamics/individual_fins.rst create mode 100644 rocketpy/rocket/aero_surface/fins/_base_fin.py create mode 100644 rocketpy/rocket/aero_surface/fins/_geometry.py create mode 100644 rocketpy/rocket/aero_surface/fins/elliptical_fin.py create mode 100644 rocketpy/rocket/aero_surface/fins/fin.py create mode 100644 rocketpy/rocket/aero_surface/fins/free_form_fin.py create mode 100644 rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py create mode 100644 tests/unit/rocket/aero_surface/test_fin_geometry.py create mode 100644 tests/unit/rocket/aero_surface/test_individual_fins.py diff --git a/.pylintrc b/.pylintrc index b0aaf655c..709250978 100644 --- a/.pylintrc +++ b/.pylintrc @@ -225,6 +225,11 @@ good-names=FlightPhases, center_of_mass_without_motor_to_CDM, motor_center_of_dry_mass_to_CDM, generic_motor_cesaroni_M1520, + R_phi, + R_delta, + R_pi, + R_uncanted, + R_body_to_fin, Re, # Reynolds number # Good variable names regexes, separated by a comma. If names match any regex, diff --git a/docs/reference/classes/aero_surfaces/EllipticalFin.rst b/docs/reference/classes/aero_surfaces/EllipticalFin.rst new file mode 100644 index 000000000..3f1403e8e --- /dev/null +++ b/docs/reference/classes/aero_surfaces/EllipticalFin.rst @@ -0,0 +1,5 @@ +EllipticalFin Class +------------------- + +.. autoclass:: rocketpy.EllipticalFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/Fin.rst b/docs/reference/classes/aero_surfaces/Fin.rst new file mode 100644 index 000000000..b9c70c25e --- /dev/null +++ b/docs/reference/classes/aero_surfaces/Fin.rst @@ -0,0 +1,5 @@ +Fin Class +--------- + +.. autoclass:: rocketpy.Fin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/FreeFormFin.rst b/docs/reference/classes/aero_surfaces/FreeFormFin.rst new file mode 100644 index 000000000..636be2c9e --- /dev/null +++ b/docs/reference/classes/aero_surfaces/FreeFormFin.rst @@ -0,0 +1,5 @@ +FreeFormFin Class +----------------- + +.. autoclass:: rocketpy.FreeFormFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/FreeFormFins.rst b/docs/reference/classes/aero_surfaces/FreeFormFins.rst new file mode 100644 index 000000000..c641ce156 --- /dev/null +++ b/docs/reference/classes/aero_surfaces/FreeFormFins.rst @@ -0,0 +1,5 @@ +FreeFormFins Class +------------------ + +.. autoclass:: rocketpy.FreeFormFins + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/TrapezoidalFin.rst b/docs/reference/classes/aero_surfaces/TrapezoidalFin.rst new file mode 100644 index 000000000..34fa631df --- /dev/null +++ b/docs/reference/classes/aero_surfaces/TrapezoidalFin.rst @@ -0,0 +1,5 @@ +TrapezoidalFin Class +-------------------- + +.. autoclass:: rocketpy.TrapezoidalFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/_BaseFin.rst b/docs/reference/classes/aero_surfaces/_BaseFin.rst new file mode 100644 index 000000000..a04b02ee5 --- /dev/null +++ b/docs/reference/classes/aero_surfaces/_BaseFin.rst @@ -0,0 +1,5 @@ +_BaseFin Class +-------------- + +.. autoclass:: rocketpy.rocket.aero_surface.fins._base_fin._BaseFin + :members: \ No newline at end of file diff --git a/docs/reference/classes/aero_surfaces/index.rst b/docs/reference/classes/aero_surfaces/index.rst index c6e6a3efe..a3dad0417 100644 --- a/docs/reference/classes/aero_surfaces/index.rst +++ b/docs/reference/classes/aero_surfaces/index.rst @@ -11,7 +11,12 @@ AeroSurface Classes Fins TrapezoidalFins EllipticalFins + FreeFormFins + Fin + TrapezoidalFin + EllipticalFin + FreeFormFin RailButtons AirBrakes GenericSurface - LinearGenericSurface + LinearGenericSurface \ No newline at end of file diff --git a/docs/static/rocket/fin_forces.png b/docs/static/rocket/fin_forces.png new file mode 100644 index 0000000000000000000000000000000000000000..41be9bfa2c58b094d63f4adcf087ceb0deb01796 GIT binary patch literal 30191 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 literal 0 HcmV?d00001 diff --git a/docs/static/rocket/individual_fin_frame.png b/docs/static/rocket/individual_fin_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..67d417cdb3a898abfae2be7ca13f63f77a1bc0ee GIT binary patch literal 47139 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. + + +The fin can also be rotated by a cant angle :math:`\delta`. + +The image below shows the fin coordinate frame: + +.. image:: ../../static/rocket/individual_fin_frame.png + :width: 800 + :align: center + +.. note:: + A positive cant angle :math:`\delta` produces a negative body axis rolling + moment at zero angle of attack. + +The rotation matrix from the fin coordinate frame to the rocket's body frame is +define by, first a rotation around the y-axis by 180 degrees: + +.. math:: + \mathbf{R}_{y(\pi)} = \begin{bmatrix} + -1 & 0 & 0 \\ + 0 & 1 & 0 \\ + 0 & 0 & -1 + \end{bmatrix} + +Then a rotation around the z-axis by the angle :math:`\Gamma`: + +.. math:: + \mathbf{R}_{z(\Gamma)} = \begin{bmatrix} + \cos(\Gamma) & -\sin(\Gamma) & 0 \\ + \sin(\Gamma) & \cos(\Gamma) & 0 \\ + 0 & 0 & 1 + \end{bmatrix} + +Then a rotation around the y-axis by the cant angle :math:`\delta`: + +.. math:: + \mathbf{R}_{y(\delta)} = \begin{bmatrix} + \cos(\delta) & 0 & \sin(\delta) \\ + 0 & 1 & 0 \\ + -\sin(\delta) & 0 & \cos(\delta) + \end{bmatrix} + +The final rotation matrix is given by: + +.. math:: + \mathbf{R} = \mathbf{R}_{y(\delta)} \cdot \mathbf{R}_{z(\Gamma)} \cdot \mathbf{R}_{y(\pi)} + + +The position of the fin's coordinate frame origin in the rocket's body frame +is calculated by first assuming no a fin frame with no cant angle, then +calculating the position of the fin's leading edge (with cant angle) in this +frame, and finally translating this position to the fin's position in the +rocket's body frame. The position of the fin's real leading edge in this no cant +angle fin frame is given by the point :math:`\mathbf{P}^{\delta}_{le_f}`: + +.. math:: + \mathbf{P}^{\delta}_{le_f} = \begin{bmatrix} + -\frac{Cr}{2} \sin(\delta) \\ + 0 \\ + \frac{Cr}{2} (1 - \cos(\delta)) + \end{bmatrix} + +Then, describing this point to the rocket's body frame orientation (no +translation): + +.. math:: + \mathbf{P}^{\delta}_{le_b} = (\mathbf{R}_{z(\Gamma)} \cdot \mathbf{R}_{y(\pi)}) \cdot \mathbf{P}^{\delta}_{le_f} + +The position of the fin's leading edge with no cant angle in the rocket's body +frame is given by: + +.. math:: + \mathbf{P}^{\overline{\delta}}_{le_b} = \begin{bmatrix} + -r \sin(\Gamma) \\ + r \cos(\Gamma) \\ + p + \end{bmatrix} + +Finally, we add the position of the fin's leading edge with no cant angle to the +position of the fin's leading edge with cant angle in the rocket's body frame: + +.. math:: + \mathbf{P}_{le_b} = \mathbf{P}^{\overline{\delta}}_{le_b} + \mathbf{P}^{\delta}_{le_b} + + +Center of Pressure Position +=========================== + +In the Fin Coordinate Frame, the center of pressure is given by the Barrowman +method, and will here only be defined symbolically: + +.. math:: + \mathbf{cp}_f = \begin{bmatrix} + cp_x \\ + cp_y \\ + cp_z + \end{bmatrix} + +The center of pressure position in the rocket's body frame is given by: + +.. math:: + \mathbf{cp}_{rocket} = \mathbf{R} \cdot \mathbf{cp}_f + \mathbf{P}_{le_b} + +Aerodynamic Forces +================== + +.. note:: + The aerodynamic coefficients are defined according the Barrowman method. + +Given a stream velocity in the fin frame :math:`\mathbf{v}_{0f} = [v_{0x}, v_{0y}, v_{0z}]^{T}`, +the effective angle of attack of the fin is given by: + +.. math:: + \alpha_f = \arctan\left(\frac{v_{0x}}{v_{0z}}\right) + +This can also be seen as the angle between the fin's root chord and the stream +velocity vector in the fin frame. + +The aerodynamic force in the x-direction of the fin is given by: + +.. math:: + F_{x} = \frac{1}{2} \cdot \rho \cdot \|\mathbf{v}_{0f}\|^2 \cdot A_{r} \cdot C_{N}(\alpha_f, Ma) + +Where :math:`A_{r}` is the reference area of the fin, and :math:`C_{N}` is the +normal force coefficient, which is a function of the angle of attack and the +Mach number :math:`Ma`. +This force is then transformed to the rocket's body frame by the rotation matrix: + +.. math:: + \begin{bmatrix} + F_{x} \\ + F_{y} \\ + F_{z} + \end{bmatrix}_{rocket} = \mathbf{R} \cdot \begin{bmatrix} + F_{x} \\ + 0 \\ + 0 + \end{bmatrix}_{fin} + +Then, the moments are calculated by the cross product of the center of pressure +and the aerodynamic force: + +.. math:: + \begin{bmatrix} + M_{x} \\ + M_{y} \\ + M_{z} + \end{bmatrix}_{rocket} = \mathbf{cp}_{rocket} \times \begin{bmatrix} + F_{x} \\ + F_{y} \\ + F_{z} + \end{bmatrix}_{rocket} + +From the Barrowman method, the moment along the center axis of the rocket +(:math:`M_{z}`) is still missing the damping term, which is given by: + +.. math:: + M_{damp} = \frac{1}{2} \cdot \rho \cdot \|v_{0}\| \cdot A_{r} \cdot L_{r}^2 \cdot C_{ld\omega}(Ma) \cdot \frac{1}{2} \cdot \omega_z + +.. math:: + M_{z \, \text{final}} = M_{z} + M_{damp} + +Where :math:`C_{ld}` is the roll moment damping coefficient, :math:`L_{r}` +is the reference length, which is equal to the rocket diameter, and +:math:`\omega_z` is the angular velocity of the rocket around the z-axis. + +Adding Individual Fins to the Rocket +==================================== +.. jupyter-execute:: + :hide-code: + :hide-output: + + from rocketpy import * + env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) + Pro75M1670 = SolidMotor( + thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + # IMPORTANT: modify the file paths below to match your own system + + example_rocket = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + + rail_buttons = example_rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + example_rocket.add_motor(Pro75M1670, position=-1.255) + nose_cone = example_rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + tail = example_rocket.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + example_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + cant_angle=0.0, + position=-1.04956, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + +Given a defined ``Rocket`` object, we can add individual fins to the rocket by +using the ``add_surfaces`` method. Here is an example of adding two canards +in the Calisto rocket from the :ref:`First Simulation ` example: + +.. jupyter-execute:: + + canard1 = TrapezoidalFin( + angular_position=0, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=180, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + # Position along the center axis of the rocket is specified here. + # If different positions are desired, the position can be specified as a list. + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + example_rocket.draw(plane="yz") + +.. seealso:: + + There are three classes for defining fins in RocketPy given their geometry: + + - :class:`rocketpy.TrapezoidalFin` - For how to define a trapezoidal fin + - :class:`rocketpy.EllipticalFin` - For how to define an elliptical fin + - :class:`rocketpy.FreeFormFin` - For how to define a free form fin + + + +Fin Force Conventions +===================== + +.. - Explain positive cant angle resultant force +.. - if all fins have positive cant angle then they will all generate a force that will rotate the rocket in the same direction +.. which is negative roll +.. - show what a positive and negative cant angle on oposing fins looks like. (generate pitch moment -> pitch control) +.. - show what a positive and negative cant angle on oposing fins looks like. (generate yaw moment -> yaw control) +.. - example for 4 fins, show how to count the number of fins and how to access each of them + + + +Here we exemplify the fin force conventions relating the cant angle +(deflection angle) of the fins to the pitch, yaw and roll moments. We will +consider a rocket with four fins, to illustrate the concepts. The image below +show the sign convention for the forces acting on the fins, given positive cant +angles: + +.. image:: ../../static/rocket/fin_forces.png + :width: 800 + :align: center + + +Roll +^^^^ + +.. jupyter-execute:: + :hide-code: + :hide-output: + + example_rocket.aerodynamic_surfaces.pop() + example_rocket.aerodynamic_surfaces.pop() + + +A positive cant angle :math:`\delta` produces a negative roll moment at zero +angle of attack. Any fin with a positive cant angle will produce a negative roll +moment, and any fin with a negative cant angle will produce a positive roll +moment. + +Here is a flight of the calisto with canards defined with a positive cant angle: + +.. jupyter-execute:: + + + canard1 = TrapezoidalFin( + angular_position=0, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=180, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + test_flight = Flight( + rocket=example_rocket, + environment=env, rail_length=5.2, inclination=85, heading=0, + terminate_on_apogee=True, + ) + + # Rolling Moment + test_flight.M3() + + # Rolling Speed + test_flight.w3() + + # Angle of attack + test_flight.partial_angle_of_attack.plot(test_flight.out_of_rail_time, 5) + + # Angle of sideslip + test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) + +Pitch +^^^^^ + +.. jupyter-execute:: + :hide-code: + :hide-output: + + example_rocket.aerodynamic_surfaces.pop() + example_rocket.aerodynamic_surfaces.pop() + +Given canards fins at 90 degrees and 270 degrees, having opposite cant angles, +a positive pitch moment will be generated. The following example shows the +effect of this configuration in the non-zero angle of attack flight of the +rocket: + +.. jupyter-execute:: + + + canard1 = TrapezoidalFin( + angular_position=90, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=270, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=-0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + test_flight = Flight( + rocket=example_rocket, + environment=env, rail_length=5.2, inclination=85, heading=0, + terminate_on_apogee=True, + ) + + # Angle of attack + test_flight.partial_angle_of_attack.plot(test_flight.out_of_rail_time, 5) + + # Angle of sideslip + test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) + +Yaw +^^^ + +.. jupyter-execute:: + :hide-code: + :hide-output: + + example_rocket.aerodynamic_surfaces.pop() + example_rocket.aerodynamic_surfaces.pop() + +Given opposing canards at 0 degrees and 180 degrees, having opposite cant angles, +a positive yaw moment will be generated. The following example shows the +effect of this configuration in the non-zero angle of attack flight of the +rocket: + +.. jupyter-execute:: + + + canard1 = TrapezoidalFin( + angular_position=0, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + canard2 = TrapezoidalFin( + angular_position=180, + root_chord=0.060, + tip_chord=0.020, + span=0.03, + rocket_radius=example_rocket.radius, + cant_angle=-0.5, + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), + ) + + example_rocket.add_surfaces([canard1, canard2], positions = 0.35) + + test_flight = Flight( + rocket=example_rocket, + environment=env, rail_length=5.2, inclination=85, heading=0, + terminate_on_apogee=True, + ) + + # Angle of attack + test_flight.partial_angle_of_attack.plot(test_flight.out_of_rail_time, 5) + + # Angle of sideslip + test_flight.angle_of_sideslip.plot(test_flight.out_of_rail_time, 5) + + + + + diff --git a/docs/technical/index.rst b/docs/technical/index.rst index b96e611ed..73583eba9 100644 --- a/docs/technical/index.rst +++ b/docs/technical/index.rst @@ -13,6 +13,7 @@ in their code. Equations of Motion v0 Equations of Motion v1 Elliptical Fins + Individual Fin Roll Moment Sensitivity Analysis References diff --git a/docs/user/rocket/rocket_axes.rst b/docs/user/rocket/rocket_axes.rst index 56fe17cad..aa99cb231 100644 --- a/docs/user/rocket/rocket_axes.rst +++ b/docs/user/rocket/rocket_axes.rst @@ -61,6 +61,8 @@ The following figure shows the two possibilities for the user input coordinate s the same as the **Body Axes Coordinate System**. The origin of the coordinate \ system may still be different. +.. _angular_position: + Angular Position Inputs ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index 852a16aef..129317bea 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -29,8 +29,11 @@ AeroSurface, AirBrakes, Components, + EllipticalFin, EllipticalFins, + Fin, Fins, + FreeFormFin, FreeFormFins, GenericSurface, LinearGenericSurface, @@ -40,6 +43,7 @@ RailButtons, Rocket, Tail, + TrapezoidalFin, TrapezoidalFins, ) from .sensitivity import SensitivityModel diff --git a/rocketpy/plots/aero_surface_plots.py b/rocketpy/plots/aero_surface_plots.py index 397ad8f55..eb97ce19b 100644 --- a/rocketpy/plots/aero_surface_plots.py +++ b/rocketpy/plots/aero_surface_plots.py @@ -1,3 +1,5 @@ +# pylint: disable=too-many-statements + from abc import ABC, abstractmethod import matplotlib.pyplot as plt @@ -139,14 +141,97 @@ class _FinsPlots(_AeroSurfacePlots): """Abstract class that contains all fin plots. This class inherits from the _AeroSurfacePlots class.""" - @abstractmethod - def draw(self, *, filename=None): - pass + def airfoil(self, *, filename=None): + """Plots the airfoil information when the fin has an airfoil shape. If + the fin does not have an airfoil shape, this method does nothing. + + 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. + + Returns + ------- + None + """ + + if self.aero_surface.airfoil: + print("Airfoil lift curve:") + self.aero_surface.airfoil_cl.plot_1d(force_data=True, filename=filename) + + def roll(self, *, filename=None): + """Plots the roll parameters of the fin set. + + 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. + + Returns + ------- + None + """ + print("Roll parameters:") + self.aero_surface.roll_parameters[0](filename=filename) + self.aero_surface.roll_parameters[1](filename=filename) + + def lift(self, *, filename=None): + """Plots the lift coefficient of the aero surface as a function of Mach + and the angle of attack. A 3D plot is expected. See the rocketpy.Function + class for more information on how this plot is made. Also, this method + plots the lift coefficient considering a single fin and the lift + coefficient considering all fins. + + 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. + + Returns + ------- + None + """ + print("Lift coefficient:") + self.aero_surface.cl(filename=filename) + self.aero_surface.clalpha_single_fin(filename=filename) + self.aero_surface.clalpha_multiple_fins(filename=filename) + + def all(self, *, filename=None): + """Plots all available fin plots. + + 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. + + Returns + ------- + None + """ + self.draw(filename=filename) + self.airfoil(filename=filename) + self.roll(filename=filename) + self.lift(filename=filename) - def airfoil(self): + +class _FinPlots(_AeroSurfacePlots): + """Abstract class that contains all fin plots. This class inherits from the + _AeroSurfacePlots class.""" + + def airfoil(self, *, filename=None): """Plots the airfoil information when the fin has an airfoil shape. If the fin does not have an airfoil shape, this method does nothing. + 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. + Returns ------- None @@ -154,52 +239,68 @@ def airfoil(self): if self.aero_surface.airfoil: print("Airfoil lift curve:") - self.aero_surface.airfoil_cl.plot_1d(force_data=True) + self.aero_surface.airfoil_cl.plot_1d(force_data=True, filename=filename) - def roll(self): + def roll(self, *, filename=None): """Plots the roll parameters of the fin set. + 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. + Returns ------- None """ print("Roll parameters:") - self.aero_surface.roll_parameters[0]() - self.aero_surface.roll_parameters[1]() + self.aero_surface.roll_parameters[0](filename=filename) + self.aero_surface.roll_parameters[1](filename=filename) - def lift(self): + def lift(self, *, filename=None): """Plots the lift coefficient of the aero surface as a function of Mach and the angle of attack. A 3D plot is expected. See the rocketpy.Function class for more information on how this plot is made. Also, this method plots the lift coefficient considering a single fin and the lift coefficient considering all fins. + 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. + Returns ------- None """ print("Lift coefficient:") - self.aero_surface.cl() - self.aero_surface.clalpha_single_fin() - self.aero_surface.clalpha_multiple_fins() + self.aero_surface.cl(filename=filename) + self.aero_surface.clalpha_single_fin(filename=filename) - def all(self): + def all(self, *, filename=None): """Plots all available fin plots. + 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. + Returns ------- None """ - self.draw() - self.airfoil() - self.roll() - self.lift() + self.draw(filename=filename) + self.airfoil(filename=filename) + self.roll(filename=filename) + self.lift(filename=filename) class _TrapezoidalFinsPlots(_FinsPlots): """Class that contains all trapezoidal fin plots.""" - # pylint: disable=too-many-statements def draw(self, *, filename=None): """Draw the fin shape along with some important information, including the center line, the quarter line and the center of pressure position. @@ -325,10 +426,129 @@ def draw(self, *, filename=None): show_or_save_plot(filename) +class _TrapezoidalFinPlots(_FinPlots): + """Class that contains all trapezoidal fin plots.""" + + def draw(self, *, filename=None): + """Draw the fin shape along with some important information, including + the center line, the quarter line and the center of pressure position. + + Returns + ------- + None + """ + # Color cycle [#348ABD, #A60628, #7A68A6, #467821, #D55E00, #CC79A7, + # #56B4E9, #009E73, #F0E442, #0072B2] + # Fin + leading_edge = plt.Line2D( + (0, self.aero_surface.sweep_length), + (0, self.aero_surface.span), + color="#A60628", + ) + tip = plt.Line2D( + ( + self.aero_surface.sweep_length, + self.aero_surface.sweep_length + self.aero_surface.tip_chord, + ), + (self.aero_surface.span, self.aero_surface.span), + color="#A60628", + ) + back_edge = plt.Line2D( + ( + self.aero_surface.sweep_length + self.aero_surface.tip_chord, + self.aero_surface.root_chord, + ), + (self.aero_surface.span, 0), + color="#A60628", + ) + root = plt.Line2D((self.aero_surface.root_chord, 0), (0, 0), color="#A60628") + + # Center and Quarter line + center_line = plt.Line2D( + ( + self.aero_surface.root_chord / 2, + self.aero_surface.sweep_length + self.aero_surface.tip_chord / 2, + ), + (0, self.aero_surface.span), + color="#7A68A6", + alpha=0.35, + linestyle="--", + label="Center Line", + ) + quarter_line = plt.Line2D( + ( + self.aero_surface.root_chord / 4, + self.aero_surface.sweep_length + self.aero_surface.tip_chord / 4, + ), + (0, self.aero_surface.span), + color="#7A68A6", + alpha=1, + linestyle="--", + label="Quarter Line", + ) + + # Center of pressure + cp_point = [self.aero_surface.cpz, self.aero_surface.Yma] + + # Mean Aerodynamic Chord + yma_start = ( + self.aero_surface.sweep_length + * (self.aero_surface.root_chord + 2 * self.aero_surface.tip_chord) + / (3 * (self.aero_surface.root_chord + self.aero_surface.tip_chord)) + ) + yma_end = ( + 2 * self.aero_surface.root_chord**2 + + self.aero_surface.root_chord * self.aero_surface.sweep_length + + 2 * self.aero_surface.root_chord * self.aero_surface.tip_chord + + 2 * self.aero_surface.sweep_length * self.aero_surface.tip_chord + + 2 * self.aero_surface.tip_chord**2 + ) / (3 * (self.aero_surface.root_chord + self.aero_surface.tip_chord)) + yma_line = plt.Line2D( + (yma_start, yma_end), + (self.aero_surface.Yma, self.aero_surface.Yma), + color="#467821", + linestyle="--", + label="Mean Aerodynamic Chord", + ) + + # Plotting + fig = plt.figure(figsize=(7, 4)) + with plt.style.context("bmh"): + ax = fig.add_subplot(111) + + # Fin + ax.add_line(leading_edge) + ax.add_line(tip) + ax.add_line(back_edge) + ax.add_line(root) + + ax.add_line(center_line) + ax.add_line(quarter_line) + ax.add_line(yma_line) + ax.scatter(*cp_point, label="Center of Pressure", color="red", s=100, zorder=10) + ax.scatter(*cp_point, facecolors="none", edgecolors="red", s=500, zorder=10) + + # Plot settings + xlim = ( + self.aero_surface.root_chord + if self.aero_surface.sweep_length + self.aero_surface.tip_chord + < self.aero_surface.root_chord + else self.aero_surface.sweep_length + self.aero_surface.tip_chord + ) + ax.set_xlim(0, xlim * 1.1) + ax.set_ylim(0, self.aero_surface.span * 1.1) + ax.set_xlabel("Root chord (m)") + ax.set_ylabel("Span (m)") + ax.set_title("Trapezoidal Fin Cross Section") + ax.legend(bbox_to_anchor=(1.05, 1.0), loc="upper left") + + plt.tight_layout() + show_or_save_plot(filename) + + class _EllipticalFinsPlots(_FinsPlots): """Class that contains all elliptical fin plots.""" - # pylint: disable=too-many-statements def draw(self, *, filename=None): """Draw the fin shape along with some important information. These being: the center line and the center of pressure position. @@ -404,10 +624,153 @@ def draw(self, *, filename=None): show_or_save_plot(filename) +class _EllipticalFinPlots(_FinPlots): + """Class that contains all elliptical fin plots.""" + + def draw(self, *, filename=None): + """Draw the fin shape along with some important information. + These being: the center line and the center of pressure position. + + Returns + ------- + None + """ + # Ellipse + ellipse = Ellipse( + (self.aero_surface.root_chord / 2, 0), + self.aero_surface.root_chord, + self.aero_surface.span * 2, + fill=False, + edgecolor="#A60628", + linewidth=2, + ) + + # Mean Aerodynamic Chord # From Barrowman's theory + yma_length = 8 * self.aero_surface.root_chord / (3 * np.pi) + yma_start = (self.aero_surface.root_chord - yma_length) / 2 + yma_end = ( + self.aero_surface.root_chord + - (self.aero_surface.root_chord - yma_length) / 2 + ) + yma_line = plt.Line2D( + (yma_start, yma_end), + (self.aero_surface.Yma, self.aero_surface.Yma), + label="Mean Aerodynamic Chord", + color="#467821", + ) + + # Center Line + center_line = plt.Line2D( + (self.aero_surface.root_chord / 2, self.aero_surface.root_chord / 2), + (0, self.aero_surface.span), + color="#7A68A6", + alpha=0.35, + linestyle="--", + label="Center Line", + ) + + # Center of pressure + cp_point = [self.aero_surface.cpz, self.aero_surface.Yma] + + # Plotting + fig = plt.figure(figsize=(7, 4)) + with plt.style.context("bmh"): + ax = fig.add_subplot(111) + ax.add_patch(ellipse) + ax.add_line(yma_line) + ax.add_line(center_line) + ax.scatter(*cp_point, label="Center of Pressure", color="red", s=100, zorder=10) + ax.scatter(*cp_point, facecolors="none", edgecolors="red", s=500, zorder=10) + + # Plot settings + ax.set_xlim(0, self.aero_surface.root_chord) + ax.set_ylim(0, self.aero_surface.span * 1.1) + ax.set_xlabel("Root chord (m)") + ax.set_ylabel("Span (m)") + ax.set_title("Elliptical Fin Cross Section") + ax.legend(bbox_to_anchor=(1.05, 1.0), loc="upper left") + + plt.tight_layout() + show_or_save_plot(filename) + + class _FreeFormFinsPlots(_FinsPlots): """Class that contains all free form fin plots.""" - # pylint: disable=too-many-statements + def draw(self, *, filename=None): + """Draw the fin shape along with some important information. + These being: the center line and the center of pressure position. + + 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 + """ + # Color cycle [#348ABD, #A60628, #7A68A6, #467821, #D55E00, #CC79A7, + # #56B4E9, #009E73, #F0E442, #0072B2] + + # Center of pressure + cp_point = [self.aero_surface.cpz, self.aero_surface.Yma] + + # Mean Aerodynamic Chord + yma_line = plt.Line2D( + ( + self.aero_surface.mac_lead, + self.aero_surface.mac_lead + self.aero_surface.mac_length, + ), + (self.aero_surface.Yma, self.aero_surface.Yma), + color="#467821", + linestyle="--", + label="Mean Aerodynamic Chord", + ) + + # Plotting + fig = plt.figure(figsize=(7, 4)) + with plt.style.context("bmh"): + ax = fig.add_subplot(111) + + # Fin + ax.scatter( + self.aero_surface.shape_vec[0], + self.aero_surface.shape_vec[1], + color="#A60628", + ) + ax.plot( + self.aero_surface.shape_vec[0], + self.aero_surface.shape_vec[1], + color="#A60628", + ) + # line from the last point to the first point + ax.plot( + [self.aero_surface.shape_vec[0][-1], self.aero_surface.shape_vec[0][0]], + [self.aero_surface.shape_vec[1][-1], self.aero_surface.shape_vec[1][0]], + color="#A60628", + ) + + ax.add_line(yma_line) + ax.scatter(*cp_point, label="Center of Pressure", color="red", s=100, zorder=10) + ax.scatter(*cp_point, facecolors="none", edgecolors="red", s=500, zorder=10) + + # Plot settings + ax.set_xlabel("Root chord (m)") + ax.set_ylabel("Span (m)") + ax.set_title("Free Form Fin Cross Section") + ax.legend(bbox_to_anchor=(1.05, 1.0), loc="upper left") + + plt.tight_layout() + show_or_save_plot(filename) + + +class _FreeFormFinPlots(_FinPlots): + """Class that contains all free form fin plots.""" + def draw(self, *, filename=None): """Draw the fin shape along with some important information. These being: the center line and the center of pressure position. diff --git a/rocketpy/plots/rocket_plots.py b/rocketpy/plots/rocket_plots.py index e208c775f..47da8a78b 100644 --- a/rocketpy/plots/rocket_plots.py +++ b/rocketpy/plots/rocket_plots.py @@ -1,8 +1,9 @@ import matplotlib.pyplot as plt import numpy as np +from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors import EmptyMotor, HybridMotor, LiquidMotor, SolidMotor -from rocketpy.rocket.aero_surface import Fins, NoseCone, Tail +from rocketpy.rocket.aero_surface import Fin, Fins, NoseCone, Tail from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from .plot_helpers import show_or_save_plot @@ -183,7 +184,7 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): and webp (these are the formats supported by matplotlib). """ - self.__validate_aerodynamic_surfaces() + self.__validate_aerodynamic_surfaces(plane) if vis_args is None: vis_args = { @@ -203,9 +204,9 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): csys = self.rocket._csys reverse = csys == 1 - self.rocket.aerodynamic_surfaces.sort_by_position(reverse=reverse) + surfaces = self.rocket.aerodynamic_surfaces.sort_by_position(reverse=reverse) - drawn_surfaces = self._draw_aerodynamic_surfaces(ax, vis_args, plane) + drawn_surfaces = self._draw_aerodynamic_surfaces(ax, vis_args, plane, surfaces) last_radius, last_x = self._draw_tubes(ax, drawn_surfaces, vis_args) self._draw_motor(last_radius, last_x, ax, vis_args) self._draw_rail_buttons(ax, vis_args) @@ -221,13 +222,15 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): plt.tight_layout() show_or_save_plot(filename) - def __validate_aerodynamic_surfaces(self): + def __validate_aerodynamic_surfaces(self, plane): if not self.rocket.aerodynamic_surfaces: raise ValueError( "The rocket must have at least one aerodynamic surface to be drawn." ) + if plane not in ("xz", "yz"): + raise ValueError("The plane must be 'xz' or 'yz'. The default is 'xz'.") - def _draw_aerodynamic_surfaces(self, ax, vis_args, plane): + def _draw_aerodynamic_surfaces(self, ax, vis_args, plane, surfaces): """Draws the aerodynamic surfaces and saves the position of the points of interest for the tubes.""" # List of drawn surfaces with the position of points of interest @@ -240,13 +243,17 @@ def _draw_aerodynamic_surfaces(self, ax, vis_args, plane): # diameter changes. The final point of the last surface is the final # point of the last tube - for surface, position in self.rocket.aerodynamic_surfaces: + for surface, position in surfaces: if isinstance(surface, NoseCone): self._draw_nose_cone(ax, surface, position.z, drawn_surfaces, vis_args) elif isinstance(surface, Tail): self._draw_tail(ax, surface, position.z, drawn_surfaces, vis_args) elif isinstance(surface, Fins): - self._draw_fins(ax, surface, position.z, drawn_surfaces, vis_args) + self._draw_fins( + ax, surface, position.z, drawn_surfaces, vis_args, plane + ) + elif isinstance(surface, Fin): + self._draw_fin(ax, surface, position, drawn_surfaces, vis_args, plane) elif isinstance(surface, GenericSurface): self._draw_generic_surface( ax, surface, position, drawn_surfaces, vis_args, plane @@ -309,13 +316,15 @@ def _draw_tail(self, ax, surface, position, drawn_surfaces, vis_args): # Add the tail to the list of drawn surfaces drawn_surfaces.append((surface, position, surface.bottom_radius, x_tail[-1])) - def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args): + def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args, plane): """Draws the fins and saves the position of the points of interest for the tubes.""" num_fins = surface.n x_fin = -self.rocket._csys * surface.shape_vec[0] + position y_fin = surface.shape_vec[1] + surface.rocket_radius - rotation_angles = [2 * np.pi * i / num_fins for i in range(num_fins)] + rotation_angles = np.array([2 * np.pi * i / num_fins for i in range(num_fins)]) + if plane == "xz": + rotation_angles -= np.pi / 2 for angle in rotation_angles: # Create a rotation matrix for the current angle around the x-axis @@ -327,13 +336,6 @@ def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args): # Extract x and y coordinates of the rotated points x_rotated, y_rotated = rotated_points_2d - # Project points above the XY plane back into the XY plane (set z-coordinate to 0) - x_rotated = np.where( - rotated_points_2d[1] > 0, rotated_points_2d[0], x_rotated - ) - y_rotated = np.where( - rotated_points_2d[1] > 0, rotated_points_2d[1], y_rotated - ) ax.plot( x_rotated, y_rotated, @@ -343,6 +345,56 @@ def _draw_fins(self, ax, surface, position, drawn_surfaces, vis_args): drawn_surfaces.append((surface, position, surface.rocket_radius, x_rotated[-1])) + def _draw_fin(self, ax, surface, position, drawn_surfaces, vis_args, plane): + """Draws individual fins.""" + + # Get shape vec + xs = surface.shape_vec[0] + ys = surface.shape_vec[1] + zs = np.zeros_like(xs) + + # Define shape in fin coordinate system + x_fin = -zs + y_fin = ys + z_fin = xs + points = np.column_stack((x_fin, y_fin, z_fin)) + + # Move drawing coordinates to center of fin for cant angle rotation + xd = np.array([0, 0, max(xs) / 2]) + points -= xd + + # Rotate to body coordinate system + for i, p in enumerate(points): + points[i] = surface._rotation_fin_to_body @ Vector(p) + + rotated_xd = surface._rotation_fin_to_body @ Vector(xd) + points += np.array(rotated_xd) + + # Back to the drawing system + x_fin_rotated = points[:, 0] + y_fin_rotated = points[:, 1] + z_fin_rotated = points[:, 2] + + if plane == "xz": + x_rotated = self.rocket._csys * z_fin_rotated + position.z + y_rotated = x_fin_rotated + position.x + elif plane == "yz": + x_rotated = self.rocket._csys * z_fin_rotated + position.z + y_rotated = y_fin_rotated + position.y + else: # pragma: no cover + raise ValueError("Plane must be 'xz' or 'yz'.") + + ax.plot( + x_rotated, + y_rotated, + color=vis_args["fins"], + linewidth=vis_args["line_width"], + ) + + drawn_surfaces.append( + (surface, position.z, surface.rocket_radius, x_rotated[-1]) + ) + def _draw_generic_surface( self, ax, @@ -445,7 +497,7 @@ def _draw_motor(self, last_radius, last_x, ax, vis_args): self._draw_nozzle_tube(last_radius, last_x, nozzle_position, ax, vis_args) - def _generate_motor_patches(self, total_csys, ax): # pylint: disable=unused-argument + def _generate_motor_patches(self, total_csys, ax): """Generates motor patches for drawing""" motor_patches = [] @@ -654,7 +706,7 @@ def all(self): # Rocket draw if len(self.rocket.aerodynamic_surfaces) > 0: - print("\nRocket Draw") + print("\nRocket Drawing") print("-" * 40) self.draw() diff --git a/rocketpy/prints/aero_surface_prints.py b/rocketpy/prints/aero_surface_prints.py index 4eb42b08d..cc36f1b01 100644 --- a/rocketpy/prints/aero_surface_prints.py +++ b/rocketpy/prints/aero_surface_prints.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod +# TODO: the rocketpy/prints/aero_surface_prints.py file could be separated into different, smaller files. class _AeroSurfacePrints(ABC): def __init__(self, aero_surface): self.aero_surface = aero_surface @@ -77,10 +78,8 @@ def geometry(self): print("-------------------------------------") print(f"Number of fins: {self.aero_surface.n}") print(f"Reference rocket radius: {self.aero_surface.rocket_radius:.3f} m") - try: + if hasattr(self.aero_surface, "tip_chord"): print(f"Tip chord: {self.aero_surface.tip_chord:.3f} m") - except AttributeError: - pass # it isn't a trapezoidal fin, just don't worry about tip chord print(f"Root chord: {self.aero_surface.root_chord:.3f} m") print(f"Span: {self.aero_surface.span:.3f} m") print( @@ -156,9 +155,102 @@ def lift(self): "Lift Coefficient derivative (single fin) at Mach 0 and AoA 0: " f"{self.aero_surface.clalpha_single_fin(0):.3f}" ) + + def all(self): + """Prints all information of the fin set. + + Returns + ------- + None + """ + self.identity() + self.geometry() + self.airfoil() + self.roll() + self.lift() + + +class _FinPrints(_AeroSurfacePrints): + def geometry(self): + print("Geometric information of the fin set:") + print("-------------------------------------") + print(f"Reference rocket radius: {self.aero_surface.rocket_radius:.3f} m") + if hasattr(self.aero_surface, "tip_chord"): + print(f"Tip chord: {self.aero_surface.tip_chord:.3f} m") + print(f"Root chord: {self.aero_surface.root_chord:.3f} m") + print(f"Span: {self.aero_surface.span:.3f} m") + print( + f"Cant angle: {self.aero_surface.cant_angle:.3f} ° or " + f"{self.aero_surface.cant_angle_rad:.3f} rad" + ) + print(f"Longitudinal section area: {self.aero_surface.Af:.3f} m²") + print(f"Aspect ratio: {self.aero_surface.AR:.3f} ") + print(f"Gamma_c: {self.aero_surface.gamma_c:.3f} m") + print(f"Mean aerodynamic chord: {self.aero_surface.Yma:.3f} m\n") + + def airfoil(self): + """Prints out airfoil related information of the fin set. + + Returns + ------- + None + """ + if self.aero_surface.airfoil: + print("Airfoil information:") + print("--------------------") + print( + "Number of points defining the lift curve: " + f"{len(self.aero_surface.airfoil_cl.x_array)}" + ) + print( + "Lift coefficient derivative at Mach 0 and AoA 0: " + f"{self.aero_surface.clalpha(0):.5f} 1/rad\n" + ) + + def roll(self): + """Prints out information about roll parameters + of the fin set. + + Returns + ------- + None + """ + print("Roll information of the fin set:") + print("--------------------------------") + print( + f"Geometric constant: {self.aero_surface.roll_geometrical_constant:.3f} m" + ) + print( + "Damping interference factor: " + f"{self.aero_surface.roll_damping_interference_factor:.3f} rad" + ) + print( + "Forcing interference factor: " + f"{self.aero_surface.roll_forcing_interference_factor:.3f} rad\n" + ) + + def lift(self): + """Prints out information about lift parameters + of the fin set. + + Returns + ------- + None + """ + print("Lift information of the fin set:") + print("--------------------------------") print( - "Lift Coefficient derivative (fin set) at Mach 0 and AoA 0: " - f"{self.aero_surface.clalpha_multiple_fins(0):.3f}" + "Lift interference factor: " + f"{self.aero_surface.lift_interference_factor:.3f} m" + ) + print( + "Center of Pressure position in local coordinates: " + f"({self.aero_surface.cpx:.3f}, {self.aero_surface.cpy:.3f}, " + f"{self.aero_surface.cpz:.3f})" + ) + print( + "Lift Coefficient derivative (single fin) at Mach 0 and AoA 0: " + f"{self.aero_surface.clalpha_single_fin(0):.3f}" ) def all(self): @@ -179,14 +271,26 @@ class _TrapezoidalFinsPrints(_FinsPrints): """Class that contains all trapezoidal fins prints.""" +class _TrapezoidalFinPrints(_FinPrints): + """Class that contains all trapezoidal fin prints.""" + + class _EllipticalFinsPrints(_FinsPrints): """Class that contains all elliptical fins prints.""" +class _EllipticalFinPrints(_FinPrints): + """Class that contains all elliptical fin prints.""" + + class _FreeFormFinsPrints(_FinsPrints): """Class that contains all free form fins prints.""" +class _FreeFormFinPrints(_FinPrints): + """Class that contains all free form fins prints.""" + + class _TailPrints(_AeroSurfacePrints): """Class that contains all tail prints.""" diff --git a/rocketpy/rocket/__init__.py b/rocketpy/rocket/__init__.py index 463cbe3b3..afb7f0bb6 100644 --- a/rocketpy/rocket/__init__.py +++ b/rocketpy/rocket/__init__.py @@ -2,14 +2,18 @@ from rocketpy.rocket.aero_surface import ( AeroSurface, AirBrakes, + EllipticalFin, EllipticalFins, + Fin, Fins, + FreeFormFin, FreeFormFins, GenericSurface, LinearGenericSurface, NoseCone, RailButtons, Tail, + TrapezoidalFin, TrapezoidalFins, ) from rocketpy.rocket.components import Components diff --git a/rocketpy/rocket/aero_surface/__init__.py b/rocketpy/rocket/aero_surface/__init__.py index ad784f8d0..7634d3500 100644 --- a/rocketpy/rocket/aero_surface/__init__.py +++ b/rocketpy/rocket/aero_surface/__init__.py @@ -1,9 +1,13 @@ from rocketpy.rocket.aero_surface.aero_surface import AeroSurface from rocketpy.rocket.aero_surface.air_brakes import AirBrakes from rocketpy.rocket.aero_surface.fins import ( + EllipticalFin, EllipticalFins, + Fin, Fins, + FreeFormFin, FreeFormFins, + TrapezoidalFin, TrapezoidalFins, ) from rocketpy.rocket.aero_surface.generic_surface import GenericSurface diff --git a/rocketpy/rocket/aero_surface/aero_surface.py b/rocketpy/rocket/aero_surface/aero_surface.py index 15ca14f1d..6727476c6 100644 --- a/rocketpy/rocket/aero_surface/aero_surface.py +++ b/rocketpy/rocket/aero_surface/aero_surface.py @@ -2,6 +2,8 @@ import numpy as np +from rocketpy.mathutils.vector_matrix import Matrix + class AeroSurface(ABC): """Abstract class used to define aerodynamic surfaces.""" @@ -15,6 +17,14 @@ def __init__(self, name, reference_area, reference_length): self.cpy = 0 self.cpz = 0 + self._rotation_surface_to_body = Matrix( + [ + [-1, 0, 0], + [0, 1, 0], + [0, 0, -1], + ] + ) + @staticmethod def _beta(mach): """Defines a parameter that is often used in aerodynamic @@ -130,7 +140,7 @@ def compute_forces_and_moments( R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0 cpz = cp[2] stream_vx, stream_vy, stream_vz = stream_velocity - if stream_vx**2 + stream_vy**2 != 0: # TODO: maybe try/except + if stream_vx**2 + stream_vy**2 != 0: # Normalize component stream velocity in body frame stream_vzn = stream_vz / stream_speed if -1 * stream_vzn < 1: diff --git a/rocketpy/rocket/aero_surface/fins/__init__.py b/rocketpy/rocket/aero_surface/fins/__init__.py index 941aa5465..dd678c625 100644 --- a/rocketpy/rocket/aero_surface/fins/__init__.py +++ b/rocketpy/rocket/aero_surface/fins/__init__.py @@ -1,4 +1,8 @@ +from rocketpy.rocket.aero_surface.fins.elliptical_fin import EllipticalFin from rocketpy.rocket.aero_surface.fins.elliptical_fins import EllipticalFins +from rocketpy.rocket.aero_surface.fins.fin import Fin from rocketpy.rocket.aero_surface.fins.fins import Fins +from rocketpy.rocket.aero_surface.fins.free_form_fin import FreeFormFin from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins +from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin from rocketpy.rocket.aero_surface.fins.trapezoidal_fins import TrapezoidalFins diff --git a/rocketpy/rocket/aero_surface/fins/_base_fin.py b/rocketpy/rocket/aero_surface/fins/_base_fin.py new file mode 100644 index 000000000..f6b09f797 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/_base_fin.py @@ -0,0 +1,344 @@ +import math +from abc import abstractmethod + +import numpy as np + +from rocketpy.mathutils.function import Function + +from ..aero_surface import AeroSurface + + +class _BaseFin(AeroSurface): + """ + Base class for fins, shared by both Fin and Fins classes. + Inherits from AeroSurface. + + Handles shared initialization logic and common properties. + """ + + def __init__( + self, name, rocket_radius, root_chord, span, airfoil=None, cant_angle=0 + ): + """ + Initialize the base fin class. + + Parameters + ---------- + name : str + Name of the fin or fin set. + rocket_radius : float + Rocket radius in meters. + root_chord : float + Root chord of the fin in meters. + span : float + Span of the fin in meters. + airfoil : tuple, optional + Tuple containing airfoil data and unit ('degrees' or 'radians'). + cant_angle : float, optional + Cant angle in degrees. + """ + self.name = name + self._rocket_radius = rocket_radius + self._root_chord = root_chord + self._span = span + self._airfoil = airfoil + self._cant_angle = cant_angle + self._cant_angle_rad = math.radians(cant_angle) + self.geometry = None + + self.reference_area = np.pi * rocket_radius**2 + + super().__init__(name, self.reference_area, self.rocket_diameter) + + def _update_reference_quantities(self): + """Update quantities that depend on rocket radius.""" + self.reference_area = np.pi * self._rocket_radius**2 + self.reference_length = self.rocket_diameter + + def _update_geometry_chain(self): + """Update geometry-dependent quantities in dependency order.""" + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + + @property + def rocket_radius(self): + """Rocket radius in meters. + + Returns + ------- + float + Rocket radius in meters. + """ + return self._rocket_radius + + @rocket_radius.setter + def rocket_radius(self, value): + """Set rocket radius and update dependent properties. + + Parameters + ---------- + value : float + Rocket radius in meters. + """ + self._rocket_radius = value + self._update_reference_quantities() + self._update_geometry_chain() + + @property + def rocket_diameter(self): + """Reference rocket diameter in meters.""" + return 2 * self._rocket_radius + + @rocket_diameter.setter + def rocket_diameter(self, value): + """Set reference rocket diameter in meters.""" + self.rocket_radius = value / 2 + + @property + def diameter(self): + """Backward-compatible alias for :attr:`rocket_diameter`.""" + return self.rocket_diameter + + @diameter.setter + def diameter(self, value): + """Backward-compatible alias setter for :attr:`rocket_diameter`.""" + self.rocket_diameter = value + + @property + def d(self): + """Backward-compatible alias for :attr:`rocket_diameter`.""" + return self.rocket_diameter + + @d.setter + def d(self, value): + """Backward-compatible alias setter for :attr:`rocket_diameter`.""" + self.rocket_diameter = value + + @property + def ref_area(self): + """Backward-compatible alias for :attr:`reference_area`.""" + return self.reference_area + + @ref_area.setter + def ref_area(self, value): + """Backward-compatible alias setter for :attr:`reference_area`.""" + self.reference_area = value + + @property + def root_chord(self): + """Root chord length in meters. + + Returns + ------- + float + Root chord length in meters. + """ + return self._root_chord + + @root_chord.setter + def root_chord(self, value): + """Set root chord and update dependent properties. + + Parameters + ---------- + value : float + Root chord length in meters. + """ + self._root_chord = value + self._update_geometry_chain() + self.evaluate_shape() + + @property + def span(self): + """Fin span in meters. + + Returns + ------- + float + Fin span in meters. + """ + return self._span + + @span.setter + def span(self, value): + """Set fin span and update dependent properties. + + Parameters + ---------- + value : float + Fin span in meters. + """ + self._span = value + self._update_geometry_chain() + self.evaluate_shape() + + @property + def cant_angle(self): + """Cant angle in degrees. + + Returns + ------- + float + Cant angle in degrees. + """ + return self._cant_angle + + @cant_angle.setter + def cant_angle(self, value): + """Set cant angle and update radian representation. + + Parameters + ---------- + value : float + Cant angle in degrees. + """ + self._cant_angle = value + self.cant_angle_rad = math.radians(value) + + @property + def cant_angle_rad(self): + """Cant angle in radians. + + Returns + ------- + float + Cant angle in radians. + """ + return self._cant_angle_rad + + @cant_angle_rad.setter + def cant_angle_rad(self, value): + """Set cant angle in radians and update dependent properties. + + Parameters + ---------- + value : float + Cant angle in radians. + """ + self._cant_angle_rad = value + self._update_geometry_chain() + + @property + def airfoil(self): + """Airfoil data for the fin. + + Returns + ------- + tuple or None + Tuple containing airfoil data and unit ('degrees' or 'radians'), + or None if using planar fin. + """ + return self._airfoil + + @airfoil.setter + def airfoil(self, value): + """Set airfoil data and update dependent properties. + + Parameters + ---------- + value : tuple or None + Tuple containing airfoil data and unit ('degrees' or 'radians'), + or None for planar fin. + """ + self._airfoil = value + self._update_geometry_chain() + + def info(self): + """Print fin geometry and lift information.""" + self.prints.geometry() + self.prints.lift() + + def all_info(self): + """Print all available fin information and show all fin plots.""" + self.prints.all() + self.plots.all() + + def evaluate_single_fin_lift_coefficient(self): + """Evaluate the lift coefficient derivative for a single fin. + + Computes the lift coefficient derivative (clalpha) considering the + fin's geometry, airfoil characteristics (if provided), and Mach number + effects using Prandtl-Glauert compressibility correction and + Diederich's planform correlation. + + Sets the `clalpha_single_fin` attribute as a Function of Mach number. + """ + if not self.airfoil: + # Defines clalpha2D as 2*pi for planar fins + clalpha2D_incompressible = 2 * np.pi + else: + # Defines clalpha2D as the derivative of the lift coefficient curve + # for the specific airfoil + self.airfoil_cl = Function( + self.airfoil[0], + title="Airfoil lift coefficient", + interpolation="linear", + ) + + # Differentiating at alpha = 0 to get cl_alpha + clalpha2D_incompressible = self.airfoil_cl.differentiate(x=1e-3, dx=1e-3) + + # Convert to radians if needed + if self.airfoil[1] == "degrees": + clalpha2D_incompressible *= 180 / np.pi + + # Correcting for compressible flow (apply Prandtl-Glauert correction) + clalpha2D = Function(lambda mach: clalpha2D_incompressible / self._beta(mach)) + + # Diederich's Planform Correlation Parameter + planform_correlation_parameter = ( + 2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c)) + ) + + # Lift coefficient derivative for a single fin + def lift_source(mach): + return ( + clalpha2D(mach) + * planform_correlation_parameter(mach) + * (self.Af / self.reference_area) + * np.cos(self.gamma_c) + ) / ( + 2 + + planform_correlation_parameter(mach) + * np.sqrt(1 + (2 / planform_correlation_parameter(mach)) ** 2) + ) + + self.clalpha_single_fin = Function( + lift_source, + "Mach", + "Lift coefficient derivative for a single fin", + ) + + @abstractmethod + def evaluate_lift_coefficient(self): + """Evaluate the lift coefficient for the fin.""" + + @abstractmethod + def evaluate_roll_parameters(self): + """Evaluate roll-related parameters for the fin.""" + + @abstractmethod + def evaluate_center_of_pressure(self): + """Evaluate the center of pressure for the fin.""" + + def evaluate_geometrical_parameters(self): + """Evaluate geometric parameters of the fin. + + This method delegates to the configured geometry strategy. + """ + geometry_data = self.geometry.evaluate_geometrical_parameters() + for key, value in geometry_data.items(): + setattr(self, key, value) + + def evaluate_shape(self): + """Evaluate the shape representation of the fin. + + This method delegates to the configured geometry strategy. + """ + self.shape_vec = self.geometry.evaluate_shape() + + @abstractmethod + def draw(self): + """Draw or render the fin.""" diff --git a/rocketpy/rocket/aero_surface/fins/_geometry.py b/rocketpy/rocket/aero_surface/fins/_geometry.py new file mode 100644 index 000000000..71b213909 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/_geometry.py @@ -0,0 +1,564 @@ +"""Geometry strategy classes for fin aerodynamic surfaces.""" + +import warnings +from abc import ABC, abstractmethod + +import numpy as np + + +class _FinGeometry(ABC): + """Base geometry strategy for fin shapes.""" + + def __init__(self, owner): + self.owner = owner + + @abstractmethod + def evaluate_geometrical_parameters(self): + """Evaluate and return geometry-dependent aerodynamic parameters.""" + + @abstractmethod + def evaluate_shape(self): + """Evaluate and return the shape vector used by plotting and outputs.""" + + def get_data(self, include_outputs=False): + """Return geometry-specific serialization data.""" + _ = include_outputs + return {} + + +class _TrapezoidalGeometry(_FinGeometry): + """Geometry strategy for trapezoidal fins.""" + + def __init__( + self, + owner, + tip_chord, + sweep_length=None, + sweep_angle=None, + ): + super().__init__(owner) + if sweep_length is not None and sweep_angle is not None: + raise ValueError("Cannot use sweep_length and sweep_angle together") + + if sweep_angle is not None: + sweep_length = np.tan(np.radians(sweep_angle)) * owner.span + elif sweep_length is None: + sweep_length = owner.root_chord - tip_chord + + self._tip_chord = tip_chord + self._sweep_length = sweep_length + self._sweep_angle = sweep_angle + + @property + def tip_chord(self): + return self._tip_chord + + @tip_chord.setter + def tip_chord(self, value): + self._tip_chord = value + + @property + def sweep_length(self): + return self._sweep_length + + @sweep_length.setter + def sweep_length(self, value): + self._sweep_length = value + + @property + def sweep_angle(self): + return self._sweep_angle + + @sweep_angle.setter + def sweep_angle(self, value): + self._sweep_angle = value + self._sweep_length = np.tan(np.radians(value)) * self.owner.span + + def evaluate_geometrical_parameters(self): + """Calculate trapezoidal fin geometric parameters.""" + # pylint: disable=invalid-name + owner = self.owner + Yr = owner.root_chord + self.tip_chord + Af = Yr * owner.span / 2 + AR = 2 * owner.span**2 / Af + gamma_c = np.arctan( + (self.sweep_length + 0.5 * self.tip_chord - 0.5 * owner.root_chord) + / owner.span + ) + Yma = (owner.span / 3) * (owner.root_chord + 2 * self.tip_chord) / Yr + + tau = (owner.span + owner.rocket_radius) / owner.rocket_radius + lift_interference_factor = 1 + 1 / tau + lambda_ = self.tip_chord / owner.root_chord + + roll_geometrical_constant = ( + (owner.root_chord + 3 * self.tip_chord) * owner.span**3 + + 4 + * (owner.root_chord + 2 * self.tip_chord) + * owner.rocket_radius + * owner.span**2 + + 6 + * (owner.root_chord + self.tip_chord) + * owner.span + * owner.rocket_radius**2 + ) / 12 + roll_damping_interference_factor = 1 + ( + ((tau - lambda_) / tau) - ((1 - lambda_) / (tau - 1)) * np.log(tau) + ) / ( + ((tau + 1) * (tau - lambda_)) / 2 + - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1)) + ) + roll_forcing_interference_factor = (1 / np.pi**2) * ( + (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) + + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) + + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 + - (4 * (tau + 1)) + / (tau * (tau - 1)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) + ) + + self.Yr = Yr + self.Af = Af + self.AR = AR + self.gamma_c = gamma_c + self.Yma = Yma + self.roll_geometrical_constant = roll_geometrical_constant + self.tau = tau + self.lift_interference_factor = lift_interference_factor + self.λ = lambda_ # pylint: disable=non-ascii-name + self.roll_damping_interference_factor = roll_damping_interference_factor + self.roll_forcing_interference_factor = roll_forcing_interference_factor + + return { + "Yr": Yr, + "Af": Af, + "AR": AR, + "gamma_c": gamma_c, + "Yma": Yma, + "roll_geometrical_constant": roll_geometrical_constant, + "tau": tau, + "lift_interference_factor": lift_interference_factor, + "λ": lambda_, # pylint: disable=non-ascii-name + "roll_damping_interference_factor": roll_damping_interference_factor, + "roll_forcing_interference_factor": roll_forcing_interference_factor, + } + + def evaluate_shape(self): + owner = self.owner + if self.sweep_length: + points = [ + (0, 0), + (self.sweep_length, owner.span), + (self.sweep_length + self.tip_chord, owner.span), + (owner.root_chord, 0), + ] + else: + points = [ + (0, 0), + (owner.root_chord - self.tip_chord, owner.span), + (owner.root_chord, owner.span), + (owner.root_chord, 0), + ] + + x_array, y_array = zip(*points) + shape_vec = [np.array(x_array), np.array(y_array)] + self.shape_vec = shape_vec + return shape_vec + + def get_data(self, include_outputs=False): + data = { + "tip_chord": self.tip_chord, + "sweep_length": self.sweep_length, + "sweep_angle": self.sweep_angle, + } + if include_outputs: + data.update( + { + "shape_vec": getattr(self, "shape_vec", None), + "Af": getattr(self, "Af", None), + "AR": getattr(self, "AR", None), + "gamma_c": getattr(self, "gamma_c", None), + "Yma": getattr(self, "Yma", None), + "roll_geometrical_constant": getattr( + self, "roll_geometrical_constant", None + ), + "tau": getattr(self, "tau", None), + "lift_interference_factor": getattr( + self, "lift_interference_factor", None + ), + "roll_damping_interference_factor": getattr( + self, "roll_damping_interference_factor", None + ), + "roll_forcing_interference_factor": getattr( + self, "roll_forcing_interference_factor", None + ), + } + ) + return data + + +class _EllipticalGeometry(_FinGeometry): + """Geometry strategy for elliptical fins.""" + + def evaluate_geometrical_parameters(self): # pylint: disable=too-many-statements + """Calculate elliptical fin geometric parameters.""" + owner = self.owner + + # pylint: disable=invalid-name + Af = (np.pi * owner.root_chord / 2 * owner.span) / 2 + gamma_c = 0 + AR = 2 * owner.span**2 / Af + Yma = owner.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64) + roll_geometrical_constant = ( + owner.root_chord + * owner.span + * ( + 3 * np.pi * owner.span**2 + + 32 * owner.rocket_radius * owner.span + + 12 * np.pi * owner.rocket_radius**2 + ) + / 48 + ) + + tau = (owner.span + owner.rocket_radius) / owner.rocket_radius + lift_interference_factor = 1 + 1 / tau + if owner.span > owner.rocket_radius: + roll_damping_interference_factor = 1 + ( + owner.rocket_radius**2 + * ( + 2 + * owner.rocket_radius**2 + * np.sqrt(owner.span**2 - owner.rocket_radius**2) + * np.log( + ( + 2 + * owner.span + * np.sqrt(owner.span**2 - owner.rocket_radius**2) + + 2 * owner.span**2 + ) + / owner.rocket_radius + ) + - 2 + * owner.rocket_radius**2 + * np.sqrt(owner.span**2 - owner.rocket_radius**2) + * np.log(2 * owner.span) + + 2 * owner.span**3 + - np.pi * owner.rocket_radius * owner.span**2 + - 2 * owner.rocket_radius**2 * owner.span + + np.pi * owner.rocket_radius**3 + ) + ) / ( + 2 + * owner.span**2 + * (owner.span / 3 + np.pi * owner.rocket_radius / 4) + * (owner.span**2 - owner.rocket_radius**2) + ) + elif owner.span < owner.rocket_radius: + roll_damping_interference_factor = 1 - ( + owner.rocket_radius**2 + * ( + 2 * owner.span**3 + - np.pi * owner.span**2 * owner.rocket_radius + - 2 * owner.span * owner.rocket_radius**2 + + np.pi * owner.rocket_radius**3 + + 2 + * owner.rocket_radius**2 + * np.sqrt(-(owner.span**2) + owner.rocket_radius**2) + * np.arctan( + owner.span / np.sqrt(-(owner.span**2) + owner.rocket_radius**2) + ) + - np.pi + * owner.rocket_radius**2 + * np.sqrt(-(owner.span**2) + owner.rocket_radius**2) + ) + ) / ( + 2 + * owner.span + * (-(owner.span**2) + owner.rocket_radius**2) + * (owner.span**2 / 3 + np.pi * owner.span * owner.rocket_radius / 4) + ) + else: + roll_damping_interference_factor = (28 - 3 * np.pi) / (4 + 3 * np.pi) + + roll_forcing_interference_factor = (1 / np.pi**2) * ( + (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) + + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) + + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 + - (4 * (tau + 1)) + / (tau * (tau - 1)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) + ) + + self.Af = Af + self.AR = AR + self.gamma_c = gamma_c + self.Yma = Yma + self.roll_geometrical_constant = roll_geometrical_constant + self.tau = tau + self.lift_interference_factor = lift_interference_factor + self.roll_damping_interference_factor = roll_damping_interference_factor + self.roll_forcing_interference_factor = roll_forcing_interference_factor + + return { + "Af": Af, + "AR": AR, + "gamma_c": gamma_c, + "Yma": Yma, + "roll_geometrical_constant": roll_geometrical_constant, + "tau": tau, + "lift_interference_factor": lift_interference_factor, + "roll_damping_interference_factor": roll_damping_interference_factor, + "roll_forcing_interference_factor": roll_forcing_interference_factor, + } + + def evaluate_shape(self): + owner = self.owner + angles = np.arange(0, 180, 5) + x_array = owner.root_chord / 2 + owner.root_chord / 2 * np.cos( + np.radians(angles) + ) + y_array = owner.span * np.sin(np.radians(angles)) + shape_vec = [x_array, y_array] + self.shape_vec = shape_vec + return shape_vec + + def get_data(self, include_outputs=False): + if not include_outputs: + return {} + return { + "Af": getattr(self, "Af", None), + "AR": getattr(self, "AR", None), + "gamma_c": getattr(self, "gamma_c", None), + "Yma": getattr(self, "Yma", None), + "roll_geometrical_constant": getattr( + self, "roll_geometrical_constant", None + ), + "tau": getattr(self, "tau", None), + "lift_interference_factor": getattr(self, "lift_interference_factor", None), + "roll_damping_interference_factor": getattr( + self, "roll_damping_interference_factor", None + ), + "roll_forcing_interference_factor": getattr( + self, "roll_forcing_interference_factor", None + ), + } + + +class _FreeFormGeometry(_FinGeometry): + """Geometry strategy for free-form fins.""" + + def __init__(self, owner, shape_points): + super().__init__(owner) + self.shape_points = shape_points + + @staticmethod + def infer_dimensions(shape_points): + """Infer root chord and span from free-form points.""" + down = False + for i in range(1, len(shape_points)): + if shape_points[i][1] > shape_points[i - 1][1] and down: + warnings.warn( + "Jagged fin shape detected. This may cause small " + "inaccuracies center of pressure and pitch moment " + "calculations." + ) + break + if shape_points[i][1] < shape_points[i - 1][1]: + down = True + + root_chord = abs(shape_points[0][0] - shape_points[-1][0]) + ys = [point[1] for point in shape_points] + span = max(ys) - min(ys) + return root_chord, span + + def evaluate_geometrical_parameters( + self, + ): # pylint: disable=too-many-statements,too-many-locals,invalid-name + """Calculate free-form fin geometric parameters.""" + owner = self.owner + + Af = 0 + for i in range(len(self.shape_points) - 1): + x1, y1 = self.shape_points[i] + x2, y2 = self.shape_points[i + 1] + Af += (y1 + y2) * (x1 - x2) + Af = abs(Af) / 2 + if Af < 1e-6: + raise ValueError("Fin area is too small. Check the shape_points.") + + AR = 2 * owner.span**2 / Af + tau = (owner.span + owner.rocket_radius) / owner.rocket_radius + lift_interference_factor = 1 + 1 / tau + + roll_forcing_interference_factor = (1 / np.pi**2) * ( + (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) + + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) + + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 + - (4 * (tau + 1)) + / (tau * (tau - 1)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) + ) + + points_per_line = 40 + chord_lead = np.ones(points_per_line) * np.inf + chord_trail = np.ones(points_per_line) * -np.inf + chord_length = np.zeros(points_per_line) + + for p in range(1, len(self.shape_points)): + x1, y1 = self.shape_points[p - 1] + x2, y2 = self.shape_points[p] + + prev_idx = int(y1 / owner.span * (points_per_line - 1)) + curr_idx = int(y2 / owner.span * (points_per_line - 1)) + prev_idx = np.clip(prev_idx, 0, points_per_line - 1) + curr_idx = np.clip(curr_idx, 0, points_per_line - 1) + + if prev_idx > curr_idx: + prev_idx, curr_idx = curr_idx, prev_idx + + for i in range(prev_idx, curr_idx + 1): + y = i * owner.span / (points_per_line - 1) + if y1 != y2: + x = np.clip( + (y - y2) / (y1 - y2) * x1 + (y1 - y) / (y1 - y2) * x2, + min(x1, x2), + max(x1, x2), + ) + else: + x = x1 + + chord_lead[i] = min(chord_lead[i], x) + chord_trail[i] = max(chord_trail[i], x) + + if y1 < y2: + chord_length[i] -= x + else: + chord_length[i] += x + + invalid_lead = np.isnan(chord_lead) | np.isinf(chord_lead) + invalid_trail = np.isnan(chord_trail) | np.isinf(chord_trail) + chord_lead[invalid_lead | invalid_trail] = 0 + chord_trail[invalid_lead | invalid_trail] = 0 + + chord_length[chord_length < 0] = 0 + chord_length[np.isnan(chord_length)] = 0 + max_chord = chord_trail - chord_lead + chord_length = np.minimum(chord_length, max_chord) + + radius = owner.rocket_radius + total_area = 0 + mac_length = 0 + mac_lead = 0 + mac_span = 0 + cos_gamma_sum = 0 + roll_geometrical_constant = 0 + roll_damping_numerator = 0 + roll_damping_denominator = 0 + + dy = owner.span / (points_per_line - 1) + for i in range(points_per_line): + chord = chord_trail[i] - chord_lead[i] + y = i * dy + + mac_length += chord * chord + mac_span += y * chord + mac_lead += chord_lead[i] * chord + total_area += chord + roll_geometrical_constant += chord_length[i] * (radius + y) ** 2 + roll_damping_numerator += radius**3 * chord / (radius + y) ** 2 + roll_damping_denominator += (radius + y) * chord + + if i > 0: + dx = (chord_trail[i] + chord_lead[i]) / 2 - ( + chord_trail[i - 1] + chord_lead[i - 1] + ) / 2 + cos_gamma_sum += dy / np.hypot(dx, dy) + + mac_length *= dy + mac_span *= dy + mac_lead *= dy + total_area *= dy + roll_geometrical_constant *= dy + roll_damping_numerator *= dy + roll_damping_denominator *= dy + + mac_length /= total_area + mac_span /= total_area + mac_lead /= total_area + cos_gamma = cos_gamma_sum / (points_per_line - 1) + + gamma_c = np.arccos(cos_gamma) + + self.Af = Af + self.AR = AR + self.gamma_c = gamma_c + self.Yma = mac_span + self.mac_length = mac_length + self.mac_lead = mac_lead + self.tau = tau + self.roll_geometrical_constant = roll_geometrical_constant + self.lift_interference_factor = lift_interference_factor + self.roll_forcing_interference_factor = roll_forcing_interference_factor + self.roll_damping_interference_factor = 1 + ( + roll_damping_numerator / roll_damping_denominator + ) + + return { + "Af": Af, + "AR": AR, + "gamma_c": gamma_c, + "Yma": mac_span, + "mac_length": mac_length, + "mac_lead": mac_lead, + "tau": tau, + "roll_geometrical_constant": roll_geometrical_constant, + "lift_interference_factor": lift_interference_factor, + "roll_forcing_interference_factor": roll_forcing_interference_factor, + "roll_damping_interference_factor": self.roll_damping_interference_factor, + } + + def evaluate_shape(self): + x_array, y_array = zip(*self.shape_points) + shape_vec = [np.array(x_array), np.array(y_array)] + self.shape_vec = shape_vec + return shape_vec + + def get_data(self, include_outputs=False): + data = {"shape_points": self.shape_points} + if include_outputs: + data.update( + { + "Af": getattr(self, "Af", None), + "AR": getattr(self, "AR", None), + "gamma_c": getattr(self, "gamma_c", None), + "Yma": getattr(self, "Yma", None), + "mac_length": getattr(self, "mac_length", None), + "mac_lead": getattr(self, "mac_lead", None), + "roll_geometrical_constant": getattr( + self, "roll_geometrical_constant", None + ), + "tau": getattr(self, "tau", None), + "lift_interference_factor": getattr( + self, "lift_interference_factor", None + ), + "roll_forcing_interference_factor": getattr( + self, "roll_forcing_interference_factor", None + ), + "roll_damping_interference_factor": getattr( + self, "roll_damping_interference_factor", None + ), + } + ) + return data diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py new file mode 100644 index 000000000..f809bca29 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py @@ -0,0 +1,193 @@ +from rocketpy.plots.aero_surface_plots import _EllipticalFinPlots +from rocketpy.prints.aero_surface_prints import _EllipticalFinPrints +from rocketpy.rocket.aero_surface.fins._geometry import _EllipticalGeometry +from rocketpy.rocket.aero_surface.fins.fin import Fin + + +class EllipticalFin(Fin): + """Class that defines and holds information for an elliptical fin set. + + This class inherits from the Fin class. + + Note + ---- + Local coordinate system: + - Origin located at the top of the root chord. + - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom). + - Y axis perpendicular to the Z axis, in the span direction, positive upwards. + - X axis completes the right-handed coordinate system. + + See Also + -------- + Fin + + Attributes + ---------- + EllipticalFin.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, in + meters. + EllipticalFin.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees) + EllipticalFin.cant_angle : float + Fins cant angle with respect to the rocket centerline, in degrees. + EllipticalFin.cant_angle_rad : float + Fins cant angle with respect to the rocket centerline, in radians. + EllipticalFin.root_chord : float + Fin root chord in meters. + EllipticalFin.span : float + Fin span in meters. + EllipticalFin.name : string + Name of fin set. + EllipticalFin.sweep_length : float + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading edge + measured parallel to the rocket centerline. + EllipticalFin.sweep_angle : float + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. + EllipticalFin.rocket_diameter : float + Reference diameter of the rocket, in meters. + EllipticalFin.reference_area : float + Reference area of the rocket. + EllipticalFin.Af : float + Area of the longitudinal section of each fin in the set. + EllipticalFin.AR : float + Aspect ratio of the fin. + EllipticalFin.gamma_c : float + Fin mid-chord sweep angle. + EllipticalFin.Yma : float + Span wise position of the mean aerodynamic chord. + EllipticalFin.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + EllipticalFin.tau : float + Geometrical relation used to simplify lift and roll calculations. + EllipticalFin.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + EllipticalFin.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + EllipticalFin.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + EllipticalFin.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + EllipticalFin.cpz : float + Fin set local center of pressure z coordinate. Has units of length and + is given in meters. + EllipticalFin.cl : Function + Function which defines the lift coefficient as a function of the angle + of attack and the Mach number. Takes as input the angle of attack in + radians and the Mach number. Returns the lift coefficient. + EllipticalFin.clalpha : float + Lift coefficient slope. Has units of 1/rad. + """ + + def __init__( + self, + angular_position, + root_chord, + span, + rocket_radius, + cant_angle=0, + airfoil=None, + name="Elliptical Fin", + ): + """Initialize EllipticalFin class. + + Parameters + ---------- + angular_position : float + Angular position of the fin in degrees measured as the rotation + around the symmetry axis of the rocket relative to one of the other + principal axis. See :ref:`Angular Position Inputs ` + root_chord : int, float + Fin root chord in meters. + span : int, float + Fin span in meters. + rocket_radius : int, float + Reference radius to calculate lift coefficient, in meters. + cant_angle : int, float, optional + Fins cant angle with respect to the rocket centerline. Must + be given in degrees. + sweep_length : int, float, optional + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading + edge measured parallel to the rocket centerline. If not given, the + sweep length is assumed to be equal the root chord minus the tip + chord, in which case the fin is a right trapezoid with its base + perpendicular to the rocket's axis. Cannot be used in conjunction + with sweep_angle. + sweep_angle : int, float, optional + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. If not given, the sweep angle is automatically + calculated, in which case the fin is assumed to be a right trapezoid + with its base perpendicular to the rocket's axis. + Cannot be used in conjunction with sweep_length. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of elliptical fin. + + Returns + ------- + None + """ + + super().__init__( + angular_position, + root_chord, + span, + rocket_radius, + cant_angle, + airfoil, + name, + ) + + self.geometry = _EllipticalGeometry(self) + self._update_geometry_chain() + self.evaluate_shape() + + self.prints = _EllipticalFinPrints(self) + self.plots = _EllipticalFinPlots(self) + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the fin in local + coordinates. The center of pressure position is saved and stored as a + tuple.""" + # Barrowman elliptical-fin center of pressure location. + cpz = 0.288 * self.root_chord + self.cpx = 0 + self.cpy = self.Yma + self.cpz = cpz + self.cp = (self.cpx, self.cpy, self.cpz) + + def to_dict(self, include_outputs=False): + data = super().to_dict(include_outputs=include_outputs) + data.update(self.geometry.get_data(include_outputs=include_outputs)) + return data + + @classmethod + def from_dict(cls, data): + return cls( + angular_position=data["angular_position"], + root_chord=data["root_chord"], + span=data["span"], + rocket_radius=data["rocket_radius"], + cant_angle=data["cant_angle"], + airfoil=data["airfoil"], + name=data["name"], + ) diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py index 61d98bc0d..4576bd1f3 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py @@ -1,7 +1,6 @@ -import numpy as np - from rocketpy.plots.aero_surface_plots import _EllipticalFinsPlots from rocketpy.prints.aero_surface_prints import _EllipticalFinsPrints +from rocketpy.rocket.aero_surface.fins._geometry import _EllipticalGeometry from .fins import Fins @@ -35,9 +34,6 @@ class EllipticalFins(Fins): Second is the unit of the curve (radians or degrees) EllipticalFins.cant_angle : float Fins cant angle with respect to the rocket centerline, in degrees. - EllipticalFins.changing_attribute_dict : dict - Dictionary that stores the name and the values of the attributes that - may be changed during a simulation. Useful for control systems. EllipticalFins.cant_angle_rad : float Fins cant angle with respect to the rocket centerline, in radians. EllipticalFins.root_chord : float @@ -53,9 +49,9 @@ class EllipticalFins(Fins): EllipticalFins.sweep_angle : float Fins sweep angle with respect to the rocket centerline. Must be given in degrees. - EllipticalFins.d : float + EllipticalFins.rocket_diameter : float Reference diameter of the rocket, in meters. - EllipticalFins.ref_area : float + EllipticalFins.reference_area : float Reference area of the rocket. EllipticalFins.Af : float Area of the longitudinal section of each fin in the set. @@ -162,10 +158,9 @@ def __init__( name, ) - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() + self.geometry = _EllipticalGeometry(self) + self._update_geometry_chain() + self.evaluate_shape() self.prints = _EllipticalFinsPrints(self) self.plots = _EllipticalFinsPlots(self) @@ -179,160 +174,18 @@ def evaluate_center_of_pressure(self): ------- None """ - # Center of pressure position in local coordinates + # Barrowman elliptical-fin center of pressure location. cpz = 0.288 * self.root_chord self.cpx = 0 self.cpy = 0 self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def evaluate_geometrical_parameters(self): # pylint: disable=too-many-statements - """Calculates and saves fin set's geometrical parameters such as the - fins' area, aspect ratio and parameters for roll movement. - - Returns - ------- - None - """ - - # Compute auxiliary geometrical parameters - # pylint: disable=invalid-name - Af = (np.pi * self.root_chord / 2 * self.span) / 2 # Fin area - gamma_c = 0 # Zero for elliptical fins - AR = 2 * self.span**2 / Af # Fin aspect ratio - Yma = ( - self.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64) - ) # Span wise coord of mean aero chord - roll_geometrical_constant = ( - self.root_chord - * self.span - * ( - 3 * np.pi * self.span**2 - + 32 * self.rocket_radius * self.span - + 12 * np.pi * self.rocket_radius**2 - ) - / 48 - ) - - # Fin–body interference correction parameters - tau = (self.span + self.rocket_radius) / self.rocket_radius - lift_interference_factor = 1 + 1 / tau - if self.span > self.rocket_radius: - roll_damping_interference_factor = 1 + ( - (self.rocket_radius**2) - * ( - 2 - * (self.rocket_radius**2) - * np.sqrt(self.span**2 - self.rocket_radius**2) - * np.log( - ( - 2 - * self.span - * np.sqrt(self.span**2 - self.rocket_radius**2) - + 2 * self.span**2 - ) - / self.rocket_radius - ) - - 2 - * (self.rocket_radius**2) - * np.sqrt(self.span**2 - self.rocket_radius**2) - * np.log(2 * self.span) - + 2 * self.span**3 - - np.pi * self.rocket_radius * self.span**2 - - 2 * (self.rocket_radius**2) * self.span - + np.pi * self.rocket_radius**3 - ) - ) / ( - 2 - * (self.span**2) - * (self.span / 3 + np.pi * self.rocket_radius / 4) - * (self.span**2 - self.rocket_radius**2) - ) - elif self.span < self.rocket_radius: - roll_damping_interference_factor = 1 - ( - self.rocket_radius**2 - * ( - 2 * self.span**3 - - np.pi * self.span**2 * self.rocket_radius - - 2 * self.span * self.rocket_radius**2 - + np.pi * self.rocket_radius**3 - + 2 - * self.rocket_radius**2 - * np.sqrt(-(self.span**2) + self.rocket_radius**2) - * np.arctan( - (self.span) / (np.sqrt(-(self.span**2) + self.rocket_radius**2)) - ) - - np.pi - * self.rocket_radius**2 - * np.sqrt(-(self.span**2) + self.rocket_radius**2) - ) - ) / ( - 2 - * self.span - * (-(self.span**2) + self.rocket_radius**2) - * (self.span**2 / 3 + np.pi * self.span * self.rocket_radius / 4) - ) - else: - roll_damping_interference_factor = (28 - 3 * np.pi) / (4 + 3 * np.pi) - - roll_forcing_interference_factor = (1 / np.pi**2) * ( - (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) - + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) - + ((tau**2 + 1) ** 2) - / (tau**2 * (tau - 1) ** 2) - * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 - - (4 * (tau + 1)) - / (tau * (tau - 1)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) - ) - - # Store values - # pylint: disable=invalid-name - self.Af = Af # Fin area - self.AR = AR # Fin aspect ratio - self.gamma_c = gamma_c # Mid chord angle - self.Yma = Yma # Span wise coord of mean aero chord - self.roll_geometrical_constant = roll_geometrical_constant - self.tau = tau - self.lift_interference_factor = lift_interference_factor - self.roll_damping_interference_factor = roll_damping_interference_factor - self.roll_forcing_interference_factor = roll_forcing_interference_factor - - self.evaluate_shape() - - def evaluate_shape(self): - angles = np.arange(0, 180, 5) - x_array = self.root_chord / 2 + self.root_chord / 2 * np.cos(np.radians(angles)) - y_array = self.span * np.sin(np.radians(angles)) - self.shape_vec = [x_array, y_array] - - def info(self): - self.prints.geometry() - self.prints.lift() - - def all_info(self): - self.prints.all() - self.plots.all() - def to_dict(self, **kwargs): data = super().to_dict(**kwargs) - if kwargs.get("include_outputs", False): - data.update( - { - "Af": self.Af, - "AR": self.AR, - "gamma_c": self.gamma_c, - "Yma": self.Yma, - "roll_geometrical_constant": self.roll_geometrical_constant, - "tau": self.tau, - "lift_interference_factor": self.lift_interference_factor, - "roll_damping_interference_factor": self.roll_damping_interference_factor, - "roll_forcing_interference_factor": self.roll_forcing_interference_factor, - } - ) + data.update( + self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False)) + ) return data @classmethod diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py new file mode 100644 index 000000000..5fec8a099 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/fin.py @@ -0,0 +1,464 @@ +import math + +import numpy as np + +from rocketpy.mathutils.function import Function +from rocketpy.mathutils.vector_matrix import Matrix, Vector +from rocketpy.rocket.aero_surface.fins._base_fin import _BaseFin + + +class Fin(_BaseFin): + """Abstract class that holds common methods for the individual fin classes. + Cannot be instantiated. + + Note + ---- + Local coordinate system: + - Origin located at the top of the root chord. + - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom). + - Y axis perpendicular to the Z axis, in the span direction, positive upwards. + - X axis completes the right-handed coordinate system. + + Attributes + ---------- + Fin.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, + in meters. + Fin.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees). + Fin.cant_angle : float + Fin cant angle with respect to the rocket centerline, in degrees. + Fin.changing_attribute_dict : dict + Dictionary that stores the name and the values of the attributes that + may be changed during a simulation. Useful for control systems. + Fin.cant_angle_rad : float + Fin cant angle with respect to the rocket centerline, in radians. + Fin.root_chord : float + Fin root chord in meters. + Fin.tip_chord : float + Fin tip chord in meters. + Fin.span : float + Fin span in meters. + Fin.name : string + Name of fin set. + Fin.sweep_length : float + Fin sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading edge + measured parallel to the rocket centerline. + Fin.sweep_angle : float + Fin sweep angle with respect to the rocket centerline. Must + be given in degrees. + Fin.rocket_diameter : float + Reference diameter of the rocket. Has units of length and is given + in meters. + Fin.reference_area : float + Reference area of the rocket. + Fin.Af : float + Area of the longitudinal section of each fin in the set. + Fin.AR : float + Aspect ratio of each fin in the set. + Fin.gamma_c : float + Fin mid-chord sweep angle. + Fin.Yma : float + Span wise position of the mean aerodynamic chord. + Fin.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + Fin.tau : float + Geometrical relation used to simplify lift and roll calculations. + Fin.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + Fin.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + Fin.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + Fin.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + Fin.cpz : float + Fin set local center of pressure z coordinate. Has units of length and + is given in meters. + Fin.cl : Function + Function which defines the lift coefficient as a function of the angle + of attack and the Mach number. Takes as input the angle of attack in + radians and the Mach number. Returns the lift coefficient. + Fin.clalpha : float + Lift coefficient slope. Has units of 1/rad. + Fin.roll_parameters : list + List containing the roll moment lift coefficient, the roll moment + damping coefficient and the cant angle in radians. + """ + + def __init__( + self, + angular_position, + root_chord, + span, + rocket_radius, + cant_angle=0, + airfoil=None, + name="Fin", + ): + """Initialize Fin class. + + Parameters + ---------- + angular_position : float + Angular position of the fin in degrees measured as the rotation + around the symmetry axis of the rocket relative to one of the other + principal axis. See :ref:`Angular Position Inputs ` + root_chord : int, float + Fin root chord in meters. + span : int, float + Fin span in meters. + rocket_radius : int, float + Reference rocket radius used for lift coefficient normalization. + cant_angle : int, float, optional + Fin cant angle with respect to the rocket centerline. Must + be given in degrees. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of fin. + """ + super().__init__( + name=name, + rocket_radius=rocket_radius, + root_chord=root_chord, + span=span, + airfoil=airfoil, + cant_angle=cant_angle, + ) + + # Store values + self._angular_position = angular_position + self._angular_position_rad = math.radians(angular_position) + + @property + def cant_angle(self): + return self._cant_angle + + @cant_angle.setter + def cant_angle(self, value): + self._cant_angle = value + self.cant_angle_rad = math.radians(value) + + @property + def cant_angle_rad(self): + return self._cant_angle_rad + + @cant_angle_rad.setter + def cant_angle_rad(self, value): + self._cant_angle_rad = value + self.evaluate_geometrical_parameters() + self.evaluate_center_of_pressure() + self.evaluate_lift_coefficient() + self.evaluate_roll_parameters() + self.evaluate_rotation_matrix() + + @property + def angular_position(self): + return self._angular_position + + @angular_position.setter + def angular_position(self, value): + self._angular_position = value + self.angular_position_rad = math.radians(value) + + @property + def angular_position_rad(self): + return self._angular_position_rad + + @angular_position_rad.setter + def angular_position_rad(self, value): + self._angular_position_rad = value + self.evaluate_rotation_matrix() + + def evaluate_lift_coefficient(self): + """Calculates and returns the fin set's lift coefficient. + The lift coefficient is saved and returned. This function + also calculates and saves the lift coefficient derivative + for a single fin and the lift coefficient derivative for + a number of n fins corrected for Fin-Body interference. + + Returns + ------- + None + """ + self.evaluate_single_fin_lift_coefficient() + + self.clalpha = self.clalpha_single_fin * self.lift_interference_factor + + # Cl = clalpha * alpha + self.cl = Function( + lambda alpha, mach: alpha * self.clalpha(mach), + ["Alpha (rad)", "Mach"], + "Lift coefficient", + ) + + return self.cl + + def evaluate_roll_parameters(self): + """Calculates and returns the fin set's roll coefficients. + The roll coefficients are saved in a list. + + Returns + ------- + self.roll_parameters : list + List containing the roll moment lift coefficient, the + roll moment damping coefficient and the cant angle in + radians + """ + clf_delta = ( + self.roll_forcing_interference_factor + * (self.Yma + self.rocket_radius) + * self.clalpha_single_fin + / self.reference_length + ) # Function of mach number + clf_delta.set_inputs("Mach") + clf_delta.set_outputs("Roll moment forcing coefficient derivative") + clf_delta.set_title( + "Roll moment forcing coefficient derivative vs. Mach number" + ) + cld_omega = -( + 2 + * self.roll_damping_interference_factor + * self.clalpha_single_fin + * np.cos(self.cant_angle_rad) + * self.roll_geometrical_constant + / (self.reference_area * self.reference_length**2) + ) # Function of mach number + cld_omega.set_inputs("Mach") + cld_omega.set_outputs("Roll moment damping coefficient derivative") + cld_omega.set_title( + "Roll moment damping coefficient derivative vs. Mach number" + ) + self.roll_parameters = [clf_delta, cld_omega, self.cant_angle_rad] + return self.roll_parameters + + def evaluate_rotation_matrix(self): + """Calculates and returns the rotation matrix from the rocket body frame + to the fin frame. + + Note + ---- + Local coordinate system: + + - Origin located at the leading edge of the root chord. + - Z axis along the longitudinal axis of the fin, positive + downwards (leading edge -> trailing edge). + - Y axis perpendicular to the Z axis, in the span direction, + positive upwards (root chord -> tip chord). + - X axis completes the right-handed coordinate system. + + + Returns + ------- + None + + References + ---------- + :ref:`Individual Fin Model ` + """ + phi = self.angular_position_rad + delta = self.cant_angle_rad + sin_phi = math.sin(phi) + cos_phi = math.cos(phi) + sin_delta = math.sin(delta) + cos_delta = math.cos(delta) + + # Rotation about body Z by angular position + R_phi = Matrix( + [ + [cos_phi, -sin_phi, 0], + [sin_phi, cos_phi, 0], + [0, 0, 1], + ] + ) + + # Cant rotation about body Y + R_delta = Matrix( + [ + [cos_delta, 0, -sin_delta], + [0, 1, 0], + [sin_delta, 0, cos_delta], + ] + ) + + # 180 flip about Y to align fin leading/trailing edge + R_pi = Matrix( + [ + [-1, 0, 0], + [0, 1, 0], + [0, 0, -1], + ] + ) + + # Uncanted body to fin, then apply cant + R_uncanted = R_phi @ R_pi + R_body_to_fin = R_delta @ R_uncanted + + # Store for downstream transforms + self._rotation_fin_to_body_uncanted = R_uncanted.transpose + self._rotation_body_to_fin = R_body_to_fin + self._rotation_fin_to_body = R_body_to_fin.transpose + self._rotation_surface_to_body = self._rotation_fin_to_body + + def compute_forces_and_moments( + self, + stream_velocity, + stream_speed, + stream_mach, + rho, + cp, + omega, + *args, + ): # pylint: disable=arguments-differ + """Computes the forces and moments acting on the aerodynamic surface. + + Parameters + ---------- + stream_velocity : tuple of float + The velocity of the airflow relative to the surface. + stream_speed : float + The magnitude of the airflow speed. + stream_mach : float + The Mach number of the airflow. + rho : float + Air density. + cp : Vector + Center of pressure coordinates in the body frame. + omega: tuple[float, float, float] + Tuple containing angular velocities around the x, y, z axes. + + Returns + ------- + tuple of float + The aerodynamic forces (lift, side_force, drag) and moments + (pitch, yaw, roll) in the body frame. + """ + R1, R2, R3, M1, M2, M3 = 0, 0, 0, 0, 0, 0 + + # stream velocity in fin frame + stream_velocity_f = self._rotation_body_to_fin @ stream_velocity + + attack_angle = np.arctan2(stream_velocity_f[0], stream_velocity_f[2]) + # Force in the X direction of the fin + X = ( + 0.5 + * rho + * stream_speed**2 + * self.reference_area + * self.cl.get_value_opt(attack_angle, stream_mach) + ) + # Force in body frame + R1, R2, R3 = self._rotation_fin_to_body @ Vector([X, 0, 0]) + # Moments + M1, M2, M3 = cp ^ Vector([R1, R2, R3]) + # Apply roll interference factor, disregarding lift interference factor + M3 *= self.roll_forcing_interference_factor / self.lift_interference_factor + + # Roll damping + _, cld_omega, _ = self.roll_parameters + M3_damping = ( + (1 / 2 * rho * stream_speed) + * self.reference_area + * (self.reference_length) ** 2 + * cld_omega.get_value_opt(stream_mach) + * omega[2] # omega3 + / 2 + ) + M3 += M3_damping + return R1, R2, R3, M1, M2, M3 + + def _compute_leading_edge_position(self, position, _csys): + """Computes the position of the fin leading edge in a rocket's user, + given its position in a rocket.""" + # Point from deflection from cant angle in the plane perpendicular to + # the fuselage where the fin is located in the fin frame + p = Vector( + [ + -self.root_chord / 2 * np.sin(self.cant_angle_rad), + 0, + self.root_chord / 2 * (1 - np.cos(self.cant_angle_rad)), + ] + ) + # Rotate the point to the body frame orientation + p = self._rotation_fin_to_body_uncanted @ p + + # Rotate the point to the user-defined coordinate system + p = Vector([p.x * _csys, p.y, p.z * _csys]) + + # Build the leading-edge position in the user frame as if no cant + # angle was applied. Scalars are interpreted as z coordinates only, + # while vectors/tuples/lists are interpreted as full (x, y, z) + # coordinates. + if isinstance(position, (Vector, tuple, list)): + position = Vector(position) + else: + position = Vector( + [ + -self.rocket_radius * math.sin(self.angular_position_rad) * _csys, + self.rocket_radius * math.cos(self.angular_position_rad), + position, + ] + ) + + # Translate the position of the fin leading edge to the position of the + # fin leading edge with cant angle + position += p + return position + + def to_dict(self, include_outputs=False): + data = { + "angular_position": self.angular_position, + "root_chord": self.root_chord, + "span": self.span, + "rocket_radius": self.rocket_radius, + "cant_angle": self.cant_angle, + "airfoil": self.airfoil, + "name": self.name, + } + + if include_outputs: + data.update( + { + "cp": self.cp, + "cl": self.cl, + "roll_parameters": self.roll_parameters, + "rocket_diameter": self.rocket_diameter, + "diameter": self.rocket_diameter, + "d": self.rocket_diameter, + "reference_area": self.reference_area, + "ref_area": self.reference_area, + } + ) + return data + + def draw(self, *, filename=None): + """Draw the fin shape along with some important information, including + the center line, the quarter line and the center of pressure position. + + 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). + """ + self.plots.draw(filename=filename) diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index fe24a84f4..b61bfcc19 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -1,11 +1,10 @@ import numpy as np from rocketpy.mathutils.function import Function +from rocketpy.rocket.aero_surface.fins._base_fin import _BaseFin -from ..aero_surface import AeroSurface - -class Fins(AeroSurface): +class Fins(_BaseFin): """Abstract class that holds common methods for the fin classes. Cannot be instantiated. @@ -49,10 +48,10 @@ class Fins(AeroSurface): Fins.sweep_angle : float Fins sweep angle with respect to the rocket centerline. Must be given in degrees. - Fins.d : float + Fins.rocket_diameter : float Reference diameter of the rocket. Has units of length and is given in meters. - Fins.ref_area : float + Fins.reference_area : float Reference area of the rocket. Fins.Af : float Area of the longitudinal section of each fin in the set. @@ -132,27 +131,18 @@ def __init__( accepting either "radians" or "degrees". name : str Name of fin set. - - Returns - ------- - None """ - # Compute auxiliary geometrical parameters - d = 2 * rocket_radius - ref_area = np.pi * rocket_radius**2 # Reference area - - super().__init__(name, ref_area, d) + super().__init__( + name=name, + rocket_radius=rocket_radius, + root_chord=root_chord, + span=span, + airfoil=airfoil, + cant_angle=-cant_angle, + ) # Store values self._n = n - self._rocket_radius = rocket_radius - self._airfoil = airfoil - self._cant_angle = cant_angle - self._root_chord = root_chord - self._span = span - self.name = name - self.d = d - self.ref_area = ref_area # Reference area @property def n(self): @@ -166,134 +156,26 @@ def n(self, value): self.evaluate_lift_coefficient() self.evaluate_roll_parameters() - @property - def root_chord(self): - return self._root_chord - - @root_chord.setter - def root_chord(self, value): - self._root_chord = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def span(self): - return self._span - - @span.setter - def span(self, value): - self._span = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def rocket_radius(self): - return self._rocket_radius - - @rocket_radius.setter - def rocket_radius(self, value): - self._rocket_radius = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def cant_angle(self): - return self._cant_angle - - @cant_angle.setter - def cant_angle(self, value): - self._cant_angle = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - - @property - def airfoil(self): - return self._airfoil - - @airfoil.setter - def airfoil(self, value): - self._airfoil = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() - def evaluate_lift_coefficient(self): """Calculates and returns the fin set's lift coefficient. The lift coefficient is saved and returned. This function also calculates and saves the lift coefficient derivative for a single fin and the lift coefficient derivative for a number of n fins corrected for Fin-Body interference. - - Returns - ------- - None """ - if not self.airfoil: - # Defines clalpha2D as 2*pi for planar fins - clalpha2D_incompressible = 2 * np.pi - else: - # Defines clalpha2D as the derivative of the lift coefficient curve - # for the specific airfoil - self.airfoil_cl = Function( - self.airfoil[0], - interpolation="linear", - ) - - # Differentiating at alpha = 0 to get cl_alpha - clalpha2D_incompressible = self.airfoil_cl.differentiate_complex_step( - x=1e-3, dx=1e-3 - ) - - # Convert to radians if needed - if self.airfoil[1] == "degrees": - clalpha2D_incompressible *= 180 / np.pi - - # Correcting for compressible flow (apply Prandtl-Glauert correction) - clalpha2D = Function(lambda mach: clalpha2D_incompressible / self._beta(mach)) - - # Diederich's Planform Correlation Parameter - planform_correlation_parameter = ( - 2 * np.pi * self.AR / (clalpha2D * np.cos(self.gamma_c)) - ) - - # Lift coefficient derivative for a single fin - def lift_source(mach): - return ( - clalpha2D(mach) - * planform_correlation_parameter(mach) - * (self.Af / self.ref_area) - * np.cos(self.gamma_c) - ) / ( - 2 - + planform_correlation_parameter(mach) - * np.sqrt(1 + (2 / planform_correlation_parameter(mach)) ** 2) - ) - - self.clalpha_single_fin = Function( - lift_source, - "Mach", - "Lift coefficient derivative for a single fin", - ) + self.evaluate_single_fin_lift_coefficient() # Lift coefficient derivative for n fins corrected with Fin-Body interference self.clalpha_multiple_fins = ( - self.lift_interference_factor - * self.fin_num_correction(self.n) + self.fin_num_correction(self.n) + * self.lift_interference_factor * self.clalpha_single_fin ) # Function of mach number self.clalpha_multiple_fins.set_inputs("Mach") self.clalpha_multiple_fins.set_outputs( f"Lift coefficient derivative for {self.n:.0f} fins" ) + self.clalpha = self.clalpha_multiple_fins # Cl = clalpha * alpha @@ -316,31 +198,32 @@ def evaluate_roll_parameters(self): roll moment damping coefficient and the cant angle in radians """ - - self.cant_angle_rad = np.radians(self.cant_angle) - clf_delta = ( self.roll_forcing_interference_factor - * self.n + * self.fin_num_correction(self.n) * (self.Yma + self.rocket_radius) * self.clalpha_single_fin - / self.d + / self.reference_length ) # Function of mach number clf_delta.set_inputs("Mach") clf_delta.set_outputs("Roll moment forcing coefficient derivative") - clf_delta.set_title(None) - cld_omega = ( + clf_delta.set_title( + "Roll moment forcing coefficient derivative vs. Mach number" + ) + cld_omega = -( 2 * self.roll_damping_interference_factor * self.n * self.clalpha_single_fin * np.cos(self.cant_angle_rad) * self.roll_geometrical_constant - / (self.ref_area * self.d**2) + / (self.reference_area * self.reference_length**2) ) # Function of mach number cld_omega.set_inputs("Mach") cld_omega.set_outputs("Roll moment damping coefficient derivative") - cld_omega.set_title(None) + cld_omega.set_title( + "Roll moment damping coefficient derivative vs. Mach number" + ) self.roll_parameters = [clf_delta, cld_omega, self.cant_angle_rad] return self.roll_parameters @@ -425,7 +308,7 @@ def compute_forces_and_moments( * omega[2] / 2 ) - M3 = M3_forcing - M3_damping + M3 = M3_forcing + M3_damping return R1, R2, R3, M1, M2, M3 def to_dict(self, **kwargs): @@ -463,8 +346,11 @@ def to_dict(self, **kwargs): "cp": self.cp, "cl": cl, "roll_parameters": self.roll_parameters, - "d": self.d, - "ref_area": self.ref_area, + "rocket_diameter": self.rocket_diameter, + "diameter": self.rocket_diameter, + "d": self.rocket_diameter, + "reference_area": self.reference_area, + "ref_area": self.reference_area, } ) @@ -481,9 +367,5 @@ def draw(self, *, filename=None): 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 """ self.plots.draw(filename=filename) diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py new file mode 100644 index 000000000..5099d322b --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py @@ -0,0 +1,189 @@ +from rocketpy.plots.aero_surface_plots import _FreeFormFinPlots +from rocketpy.prints.aero_surface_prints import _FreeFormFinPrints +from rocketpy.rocket.aero_surface.fins._geometry import _FreeFormGeometry +from rocketpy.rocket.aero_surface.fins.fin import Fin + + +class FreeFormFin(Fin): + """Class that defines and holds information for a free form fin set. + + This class inherits from the Fin class. + + Note + ---- + Local coordinate system: + - Origin located at the top of the root chord. + - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom). + - Y axis perpendicular to the Z axis, in the span direction, positive upwards. + - X axis completes the right-handed coordinate system. + + See Also + -------- + Fin + + Attributes + ---------- + FreeFormFin.n : int + Number of fins in fin set. + FreeFormFin.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, in + meters. + FreeFormFin.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees). + FreeFormFin.cant_angle : float + Fins cant angle with respect to the rocket centerline, in degrees. + FreeFormFin.cant_angle_rad : float + Fins cant angle with respect to the rocket centerline, in radians. + FreeFormFin.root_chord : float + Fin root chord in meters. + FreeFormFin.span : float + Fin span in meters. + FreeFormFin.name : string + Name of fin set. + FreeFormFin.rocket_diameter : float + Reference diameter of the rocket, in meters. + FreeFormFin.reference_area : float + Reference area of the rocket, in m². + FreeFormFin.Af : float + Area of the longitudinal section of each fin in the set. + FreeFormFin.AR : float + Aspect ratio of the fin. + FreeFormFin.gamma_c : float + Fin mid-chord sweep angle. + FreeFormFin.Yma : float + Span wise position of the mean aerodynamic chord. + FreeFormFin.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + FreeFormFin.tau : float + Geometrical relation used to simplify lift and roll calculations. + FreeFormFin.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + FreeFormFin.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + FreeFormFin.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + FreeFormFin.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + FreeFormFin.cpz : float + Fin set local center of pressure z coordinate. Has units of length and + is given in meters. + FreeFormFin.cl : Function + Function which defines the lift coefficient as a function of the angle + of attack and the Mach number. Takes as input the angle of attack in + radians and the Mach number. Returns the lift coefficient. + FreeFormFin.clalpha : float + Lift coefficient slope. Has units of 1/rad. + FreeFormFin.mac_length : float + Mean aerodynamic chord length of the fin set. + FreeFormFin.mac_lead : float + Mean aerodynamic chord leading edge x coordinate. + """ + + def __init__( + self, + angular_position, + shape_points, + rocket_radius, + cant_angle=0, + airfoil=None, + name="Free Form Fin", + ): + """Initialize FreeFormFin class. + + Parameters + ---------- + angular_position : float + Angular position of the fin in degrees measured as the rotation + around the symmetry axis of the rocket relative to one of the other + principal axis. See :ref:`Angular Position Inputs ` + shape_points : list + List of tuples (x, y) containing the coordinates of the fin's + geometry defining points. The point (0, 0) is the root leading edge. + Positive x is rearwards, positive y is upwards (span direction). + The shape will be interpolated between the points, in the order + they are given. The last point connects to the first point, and + represents the trailing edge. + rocket_radius : int, float + Reference radius to calculate lift coefficient, in meters. + cant_angle : int, float, optional + Fins cant angle with respect to the rocket centerline. Must + be given in degrees. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of the free form fin. + + Returns + ------- + None + """ + root_chord, span = _FreeFormGeometry.infer_dimensions(shape_points) + + super().__init__( + angular_position, + root_chord, + span, + rocket_radius, + cant_angle, + airfoil, + name, + ) + + self.geometry = _FreeFormGeometry(self, shape_points) + self._update_geometry_chain() + self.evaluate_shape() + + self.prints = _FreeFormFinPrints(self) + self.plots = _FreeFormFinPlots(self) + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the fin in local + coordinates. The center of pressure position is saved and stored as a + tuple. + + Returns + ------- + None + """ + # Center of pressure position in local coordinates + cpz = self.mac_lead + 0.25 * self.mac_length + self.cpx = 0 + self.cpy = self.Yma + self.cpz = cpz + self.cp = (self.cpx, self.cpy, self.cpz) + + @property + def shape_points(self): + return self.geometry.shape_points + + def to_dict(self, include_outputs=False): + data = super().to_dict(include_outputs=include_outputs) + data.update(self.geometry.get_data(include_outputs=include_outputs)) + return data + + @classmethod + def from_dict(cls, data): + return cls( + angular_position=data["angular_position"], + shape_points=data["shape_points"], + rocket_radius=data["rocket_radius"], + cant_angle=data["cant_angle"], + airfoil=data["airfoil"], + name=data["name"], + ) diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fins.py b/rocketpy/rocket/aero_surface/fins/free_form_fins.py index 72758171e..d7c7e9512 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fins.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fins.py @@ -1,9 +1,6 @@ -import warnings - -import numpy as np - from rocketpy.plots.aero_surface_plots import _FreeFormFinsPlots from rocketpy.prints.aero_surface_prints import _FreeFormFinsPrints +from rocketpy.rocket.aero_surface.fins._geometry import _FreeFormGeometry from .fins import Fins @@ -45,9 +42,9 @@ class FreeFormFins(Fins): Fin span in meters. FreeFormFins.name : string Name of fin set. - FreeFormFins.d : float + FreeFormFins.rocket_diameter : float Reference diameter of the rocket, in meters. - FreeFormFins.ref_area : float + FreeFormFins.reference_area : float Reference area of the rocket, in m². FreeFormFins.Af : float Area of the longitudinal section of each fin in the set. @@ -135,23 +132,7 @@ def __init__( ------- None """ - self.shape_points = shape_points - - down = False - for i in range(1, len(shape_points)): - if shape_points[i][1] > shape_points[i - 1][1] and down: - warnings.warn( - "Jagged fin shape detected. This may cause small inaccuracies " - "center of pressure and pitch moment calculations." - ) - break - if shape_points[i][1] < shape_points[i - 1][1]: - down = True - i += 1 - - root_chord = abs(shape_points[0][0] - shape_points[-1][0]) - ys = [point[1] for point in shape_points] - span = max(ys) - min(ys) + root_chord, span = _FreeFormGeometry.infer_dimensions(shape_points) super().__init__( n, @@ -163,10 +144,9 @@ def __init__( name, ) - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() + self.geometry = _FreeFormGeometry(self, shape_points) + self._update_geometry_chain() + self.evaluate_shape() self.prints = _FreeFormFinsPrints(self) self.plots = _FreeFormFinsPlots(self) @@ -187,215 +167,24 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def evaluate_geometrical_parameters(self): # pylint: disable=too-many-statements - """ - Calculates and saves the fin set's geometrical parameters such as the - fin area, aspect ratio, and parameters related to roll movement. This - method uses the same calculations to those in OpenRocket for free-form - fin shapes. - - Returns - ------- - None - """ - # pylint: disable=invalid-name - # pylint: disable=too-many-locals - # Calculate the fin area (Af) using the Shoelace theorem (polygon area formula) - Af = 0 - for i in range(len(self.shape_points) - 1): - x1, y1 = self.shape_points[i] - x2, y2 = self.shape_points[i + 1] - Af += (y1 + y2) * (x1 - x2) - Af = abs(Af) / 2 - if Af < 1e-6: - raise ValueError("Fin area is too small. Check the shape_points.") - - # Calculate aspect ratio (AR) and lift interference factors - AR = 2 * self.span**2 / Af # Aspect ratio - tau = (self.span + self.rocket_radius) / self.rocket_radius - lift_interference_factor = 1 + 1 / tau - - # Calculate roll forcing interference factor using OpenRocket's approach - roll_forcing_interference_factor = (1 / np.pi**2) * ( - (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) - + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) - + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) - * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 - - (4 * (tau + 1)) - / (tau * (tau - 1)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) - ) - - # Define number of interpolation points along the span of the fin - points_per_line = 40 # Same as OpenRocket - - # Initialize arrays for leading/trailing edge and chord lengths - chord_lead = np.ones(points_per_line) * np.inf # Leading edge x coordinates - chord_trail = np.ones(points_per_line) * -np.inf # Trailing edge x coordinates - chord_length = np.zeros( - points_per_line - ) # Chord length for each spanwise section - - # Discretize fin shape and calculate chord length, leading, and trailing edges - for p in range(1, len(self.shape_points)): - x1, y1 = self.shape_points[p - 1] - x2, y2 = self.shape_points[p] - - # Compute corresponding points along the fin span (clamp to valid range) - prev_idx = int(y1 / self.span * (points_per_line - 1)) - curr_idx = int(y2 / self.span * (points_per_line - 1)) - prev_idx = np.clip(prev_idx, 0, points_per_line - 1) - curr_idx = np.clip(curr_idx, 0, points_per_line - 1) - - if prev_idx > curr_idx: - prev_idx, curr_idx = curr_idx, prev_idx - - # Compute intersection of fin edge with each spanwise section - for i in range(prev_idx, curr_idx + 1): - y = i * self.span / (points_per_line - 1) - if y1 != y2: - x = np.clip( - (y - y2) / (y1 - y2) * x1 + (y1 - y) / (y1 - y2) * x2, - min(x1, x2), - max(x1, x2), - ) - else: - x = x1 # Handle horizontal segments - - # Update leading and trailing edge positions - chord_lead[i] = min(chord_lead[i], x) - chord_trail[i] = max(chord_trail[i], x) - - # Update chord length - if y1 < y2: - chord_length[i] -= x - else: - chord_length[i] += x - - # Replace infinities and handle invalid values in chord_lead and chord_trail - for i in range(points_per_line): - if ( - np.isinf(chord_lead[i]) - or np.isinf(chord_trail[i]) - or np.isnan(chord_lead[i]) - or np.isnan(chord_trail[i]) - ): - chord_lead[i] = 0 - chord_trail[i] = 0 - if chord_length[i] < 0 or np.isnan(chord_length[i]): - chord_length[i] = 0 - if chord_length[i] > chord_trail[i] - chord_lead[i]: - chord_length[i] = chord_trail[i] - chord_lead[i] - - # Initialize integration variables for various aerodynamic and roll properties - radius = self.rocket_radius - total_area = 0 - mac_length = 0 # Mean aerodynamic chord length - mac_lead = 0 # Mean aerodynamic chord leading edge - mac_span = 0 # Mean aerodynamic chord spanwise position (Yma) - cos_gamma_sum = 0 # Sum of cosine of the sweep angle - roll_geometrical_constant = 0 - roll_damping_numerator = 0 - roll_damping_denominator = 0 - - # Perform integration over spanwise sections - dy = self.span / (points_per_line - 1) - for i in range(points_per_line): - chord = chord_trail[i] - chord_lead[i] - y = i * dy - - # Update integration variables - mac_length += chord * chord - mac_span += y * chord - mac_lead += chord_lead[i] * chord - total_area += chord - roll_geometrical_constant += chord_length[i] * (radius + y) ** 2 - roll_damping_numerator += radius**3 * chord / (radius + y) ** 2 - roll_damping_denominator += (radius + y) * chord - - # Update cosine of sweep angle (cos_gamma) - if i > 0: - dx = (chord_trail[i] + chord_lead[i]) / 2 - ( - chord_trail[i - 1] + chord_lead[i - 1] - ) / 2 - cos_gamma_sum += dy / np.hypot(dx, dy) - - # Finalize mean aerodynamic chord properties - mac_length *= dy - mac_span *= dy - mac_lead *= dy - total_area *= dy - roll_geometrical_constant *= dy - roll_damping_numerator *= dy - roll_damping_denominator *= dy - - mac_length /= total_area - mac_span /= total_area - mac_lead /= total_area - cos_gamma = cos_gamma_sum / (points_per_line - 1) - - # Store computed values - self.Af = Af # Fin area - self.AR = AR # Aspect ratio - self.gamma_c = np.arccos(cos_gamma) # Sweep angle - self.Yma = mac_span # Mean aerodynamic chord spanwise position - self.mac_length = mac_length - self.mac_lead = mac_lead - self.tau = tau - self.roll_geometrical_constant = roll_geometrical_constant - self.lift_interference_factor = lift_interference_factor - self.roll_forcing_interference_factor = roll_forcing_interference_factor - self.roll_damping_interference_factor = 1 + ( - roll_damping_numerator / roll_damping_denominator - ) - - # Evaluate the shape and finalize geometry - self.evaluate_shape() - - def evaluate_shape(self): - x_array, y_array = zip(*self.shape_points) - self.shape_vec = [np.array(x_array), np.array(y_array)] + @property + def shape_points(self): + return self.geometry.shape_points def to_dict(self, **kwargs): data = super().to_dict(**kwargs) - data["shape_points"] = self.shape_points - - if kwargs.get("include_outputs", False): - data.update( - { - "Af": self.Af, - "AR": self.AR, - "gamma_c": self.gamma_c, - "Yma": self.Yma, - "mac_length": self.mac_length, - "mac_lead": self.mac_lead, - "roll_geometrical_constant": self.roll_geometrical_constant, - "tau": self.tau, - "lift_interference_factor": self.lift_interference_factor, - "roll_forcing_interference_factor": self.roll_forcing_interference_factor, - "roll_damping_interference_factor": self.roll_damping_interference_factor, - } - ) + data.update( + self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False)) + ) return data @classmethod def from_dict(cls, data): return cls( - data["n"], - data["shape_points"], - data["rocket_radius"], - data["cant_angle"], - data["airfoil"], - data["name"], + n=data["n"], + shape_points=data["shape_points"], + rocket_radius=data["rocket_radius"], + cant_angle=data["cant_angle"], + airfoil=data["airfoil"], + name=data["name"], ) - - def info(self): - self.prints.geometry() - self.prints.lift() - - def all_info(self): - self.prints.all() - self.plots.all() diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py new file mode 100644 index 000000000..c58055945 --- /dev/null +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py @@ -0,0 +1,240 @@ +from rocketpy.plots.aero_surface_plots import _TrapezoidalFinPlots +from rocketpy.prints.aero_surface_prints import _TrapezoidalFinPrints +from rocketpy.rocket.aero_surface.fins._geometry import _TrapezoidalGeometry + +from .fin import Fin + + +class TrapezoidalFin(Fin): + """A class used to represent a single trapezoidal fin. + + This class inherits from the Fin class. + + Note + ---- + Local coordinate system: + - Origin located at the top of the root chord. + - Z axis along the longitudinal axis of symmetry, positive downwards (top -> bottom). + - Y axis perpendicular to the Z axis, in the span direction, positive upwards. + - X axis completes the right-handed coordinate system. + + See Also + -------- + Fin : Parent class + + Attributes + ---------- + TrapezoidalFin.angular_position : float + Angular position of the fin set with respect to the rocket centerline, + in degrees. + TrapezoidalFin.rocket_radius : float + The reference rocket radius used for lift coefficient normalization, in + meters. + TrapezoidalFin.airfoil : tuple + Tuple of two items. First is the airfoil lift curve. + Second is the unit of the curve (radians or degrees). + TrapezoidalFin.cant_angle : float + Fins cant angle with respect to the rocket centerline, in degrees. + TrapezoidalFin.cant_angle_rad : float + Fins cant angle with respect to the rocket centerline, in radians. + TrapezoidalFin.root_chord : float + Fin root chord in meters. + TrapezoidalFin.tip_chord : float + Fin tip chord in meters. + TrapezoidalFin.span : float + Fin span in meters. + TrapezoidalFin.name : string + Name of fin set. + TrapezoidalFin.sweep_length : float + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading edge + measured parallel to the rocket centerline. + TrapezoidalFin.sweep_angle : float + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. + TrapezoidalFin.rocket_diameter : float + Reference diameter of the rocket, in meters. + TrapezoidalFins.fin_area : float + Area of the longitudinal section of each fin in the set. + TrapezoidalFins.AR : float + Aspect ratio of the fin. + TrapezoidalFin.gamma_c : float + Fin mid-chord sweep angle. + TrapezoidalFin.yma : float + Span wise position of the mean aerodynamic chord. + TrapezoidalFin.roll_geometrical_constant : float + Geometrical constant used in roll calculations. + TrapezoidalFin.tau : float + Geometrical relation used to simplify lift and roll calculations. + TrapezoidalFin.lift_interference_factor : float + Factor of Fin-Body interference in the lift coefficient. + TrapezoidalFin.cp : tuple + Tuple with the x, y and z local coordinates of the fin set center of + pressure. Has units of length and is given in meters. + TrapezoidalFin.cpx : float + Fin set local center of pressure x coordinate. Has units of length and + is given in meters. + TrapezoidalFin.cpy : float + Fin set local center of pressure y coordinate. Has units of length and + is given in meters. + TrapezoidalFin.cpz : float + Fin set local center of pressure z coordinate. Has units of length and + is given in meters. + """ + + def __init__( + self, + angular_position, + root_chord, + tip_chord, + span, + rocket_radius, + cant_angle=0, + sweep_length=None, + sweep_angle=None, + airfoil=None, + name="Trapezoidal Fin", + ): + """Initializes the TrapezoidalFin class. + + Parameters + ---------- + angular_position : float + Angular position of the fin in degrees measured as the rotation + around the symmetry axis of the rocket relative to one of the other + principal axis. See :ref:`Angular Position Inputs ` + root_chord : int, float + Fin root chord in meters. + tip_chord : int, float + Fin tip chord in meters. + span : int, float + Fin span in meters. + rocket_radius : int, float + Reference radius to calculate lift coefficient, in meters. + cant_angle : int, float, optional + Fins cant angle with respect to the rocket centerline. Must + be given in degrees. + sweep_length : int, float, optional + Fins sweep length in meters. By sweep length, understand the axial + distance between the fin root leading edge and the fin tip leading + edge measured parallel to the rocket centerline. If not given, the + sweep length is assumed to be equal the root chord minus the tip + chord, in which case the fin is a right trapezoid with its base + perpendicular to the rocket's axis. Cannot be used in conjunction + with sweep_angle. + sweep_angle : int, float, optional + Fins sweep angle with respect to the rocket centerline. Must + be given in degrees. If not given, the sweep angle is automatically + calculated, in which case the fin is assumed to be a right trapezoid + with its base perpendicular to the rocket's axis. + Cannot be used in conjunction with sweep_length. + airfoil : tuple, optional + Default is null, in which case fins will be treated as flat plates. + Otherwise, if tuple, fins will be considered as airfoils. The + tuple's first item specifies the airfoil's lift coefficient + by angle of attack and must be either a .csv, .txt, ndarray + or callable. The .csv and .txt files can contain a single line + header and the first column must specify the angle of attack, while + the second column must specify the lift coefficient. The + ndarray should be as [(x0, y0), (x1, y1), (x2, y2), ...] + where x0 is the angle of attack and y0 is the lift coefficient. + If callable, it should take an angle of attack as input and + return the lift coefficient at that angle of attack. + The tuple's second item is the unit of the angle of attack, + accepting either "radians" or "degrees". + name : str + Name of the trapezoidal fin. + """ + super().__init__( + angular_position, + root_chord, + span, + rocket_radius, + cant_angle, + airfoil, + name, + ) + + self.geometry = _TrapezoidalGeometry( + self, + tip_chord=tip_chord, + sweep_length=sweep_length, + sweep_angle=sweep_angle, + ) + self._update_geometry_chain() + self.evaluate_shape() + self.evaluate_rotation_matrix() + + self.prints = _TrapezoidalFinPrints(self) + self.plots = _TrapezoidalFinPlots(self) + + @property + def tip_chord(self): + return self.geometry.tip_chord + + @tip_chord.setter + def tip_chord(self, value): + self.geometry.tip_chord = value + self._update_geometry_chain() + self.evaluate_shape() + + @property + def sweep_angle(self): + return self.geometry.sweep_angle + + @sweep_angle.setter + def sweep_angle(self, value): + self.geometry.sweep_angle = value + self._update_geometry_chain() + self.evaluate_shape() + + @property + def sweep_length(self): + return self.geometry.sweep_length + + @sweep_length.setter + def sweep_length(self, value): + self.geometry.sweep_length = value + self._update_geometry_chain() + self.evaluate_shape() + + def evaluate_center_of_pressure(self): + """Calculates and returns the center of pressure of the fin in local + coordinates. The center of pressure position is saved and stored as a + tuple. + + Returns + ------- + None + """ + # Center of pressure position in local coordinates + cpz = (self.sweep_length / 3) * ( + (self.root_chord + 2 * self.tip_chord) / (self.root_chord + self.tip_chord) + ) + (1 / 6) * ( + self.root_chord + + self.tip_chord + - self.root_chord * self.tip_chord / (self.root_chord + self.tip_chord) + ) + self.cpx = 0 + self.cpy = self.Yma + self.cpz = cpz + self.cp = (self.cpx, self.cpy, self.cpz) + + def to_dict(self, include_outputs=False): + data = super().to_dict(include_outputs=include_outputs) + data.update(self.geometry.get_data(include_outputs=include_outputs)) + return data + + @classmethod + def from_dict(cls, data): + return cls( + angular_position=data["angular_position"], + root_chord=data["root_chord"], + tip_chord=data["tip_chord"], + span=data["span"], + rocket_radius=data["rocket_radius"], + cant_angle=data["cant_angle"], + sweep_length=data.get("sweep_length"), + airfoil=data["airfoil"], + name=data["name"], + ) diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py index c6b4ea633..2c9adea58 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py @@ -1,7 +1,6 @@ -import numpy as np - from rocketpy.plots.aero_surface_plots import _TrapezoidalFinsPlots from rocketpy.prints.aero_surface_prints import _TrapezoidalFinsPrints +from rocketpy.rocket.aero_surface.fins._geometry import _TrapezoidalGeometry from .fins import Fins @@ -35,9 +34,6 @@ class TrapezoidalFins(Fins): Second is the unit of the curve (radians or degrees). TrapezoidalFins.cant_angle : float Fins cant angle with respect to the rocket centerline, in degrees. - TrapezoidalFins.changing_attribute_dict : dict - Dictionary that stores the name and the values of the attributes that - may be changed during a simulation. Useful for control systems. TrapezoidalFins.cant_angle_rad : float Fins cant angle with respect to the rocket centerline, in radians. TrapezoidalFins.root_chord : float @@ -55,9 +51,9 @@ class TrapezoidalFins(Fins): TrapezoidalFins.sweep_angle : float Fins sweep angle with respect to the rocket centerline. Must be given in degrees. - TrapezoidalFins.d : float + TrapezoidalFins.rocket_diameter : float Reference diameter of the rocket, in meters. - TrapezoidalFins.ref_area : float + TrapezoidalFins.reference_area : float Reference area of the rocket, in m². TrapezoidalFins.Af : float Area of the longitudinal section of each fin in the set. @@ -169,62 +165,47 @@ def __init__( name, ) - # Check if sweep angle or sweep length is given - if sweep_length is not None and sweep_angle is not None: - raise ValueError("Cannot use sweep_length and sweep_angle together") - elif sweep_angle is not None: - sweep_length = np.tan(sweep_angle * np.pi / 180) * span - elif sweep_length is None: - sweep_length = root_chord - tip_chord - - self._tip_chord = tip_chord - self._sweep_length = sweep_length - self._sweep_angle = sweep_angle - - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() + self.geometry = _TrapezoidalGeometry( + self, + tip_chord=tip_chord, + sweep_length=sweep_length, + sweep_angle=sweep_angle, + ) + self._update_geometry_chain() + self.evaluate_shape() self.prints = _TrapezoidalFinsPrints(self) self.plots = _TrapezoidalFinsPlots(self) @property def tip_chord(self): - return self._tip_chord + return self.geometry.tip_chord @tip_chord.setter def tip_chord(self, value): - self._tip_chord = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() + self.geometry.tip_chord = value + self._update_geometry_chain() + self.evaluate_shape() @property def sweep_angle(self): - return self._sweep_angle + return self.geometry.sweep_angle @sweep_angle.setter def sweep_angle(self, value): - self._sweep_angle = value - self._sweep_length = np.tan(value * np.pi / 180) * self.span - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() + self.geometry.sweep_angle = value + self._update_geometry_chain() + self.evaluate_shape() @property def sweep_length(self): - return self._sweep_length + return self.geometry.sweep_length @sweep_length.setter def sweep_length(self, value): - self._sweep_length = value - self.evaluate_geometrical_parameters() - self.evaluate_center_of_pressure() - self.evaluate_lift_coefficient() - self.evaluate_roll_parameters() + self.geometry.sweep_length = value + self._update_geometry_chain() + self.evaluate_shape() def evaluate_center_of_pressure(self): """Calculates and returns the center of pressure of the fin set in local @@ -248,124 +229,11 @@ def evaluate_center_of_pressure(self): self.cpz = cpz self.cp = (self.cpx, self.cpy, self.cpz) - def evaluate_geometrical_parameters(self): # pylint: disable=too-many-statements - """Calculates and saves fin set's geometrical parameters such as the - fins' area, aspect ratio and parameters for roll movement. - - Returns - ------- - None - """ - # pylint: disable=invalid-name - Yr = self.root_chord + self.tip_chord - Af = Yr * self.span / 2 # Fin area - AR = 2 * self.span**2 / Af # Fin aspect ratio - gamma_c = np.arctan( - (self.sweep_length + 0.5 * self.tip_chord - 0.5 * self.root_chord) - / (self.span) - ) - Yma = ( - (self.span / 3) * (self.root_chord + 2 * self.tip_chord) / Yr - ) # Span wise coord of mean aero chord - - # Fin–body interference correction parameters - tau = (self.span + self.rocket_radius) / self.rocket_radius - lift_interference_factor = 1 + 1 / tau - lambda_ = self.tip_chord / self.root_chord - - # Parameters for Roll Moment. - # Documented at: https://docs.rocketpy.org/en/latest/technical/ - roll_geometrical_constant = ( - (self.root_chord + 3 * self.tip_chord) * self.span**3 - + 4 - * (self.root_chord + 2 * self.tip_chord) - * self.rocket_radius - * self.span**2 - + 6 * (self.root_chord + self.tip_chord) * self.span * self.rocket_radius**2 - ) / 12 - roll_damping_interference_factor = 1 + ( - ((tau - lambda_) / (tau)) - ((1 - lambda_) / (tau - 1)) * np.log(tau) - ) / ( - ((tau + 1) * (tau - lambda_)) / (2) - - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1)) - ) - roll_forcing_interference_factor = (1 / np.pi**2) * ( - (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) - + ((np.pi * (tau**2 + 1) ** 2) / (tau**2 * (tau - 1) ** 2)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) - + ((tau**2 + 1) ** 2) - / (tau**2 * (tau - 1) ** 2) - * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 - - (4 * (tau + 1)) - / (tau * (tau - 1)) - * np.arcsin((tau**2 - 1) / (tau**2 + 1)) - + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) - ) - - # Store values - self.Yr = Yr - self.Af = Af # Fin area - self.AR = AR # Aspect Ratio - self.gamma_c = gamma_c # Mid chord angle - self.Yma = Yma # Span wise coord of mean aero chord - self.roll_geometrical_constant = roll_geometrical_constant - self.tau = tau - self.lift_interference_factor = lift_interference_factor - self.λ = lambda_ # pylint: disable=non-ascii-name - self.roll_damping_interference_factor = roll_damping_interference_factor - self.roll_forcing_interference_factor = roll_forcing_interference_factor - - self.evaluate_shape() - - def evaluate_shape(self): - if self.sweep_length: - points = [ - (0, 0), - (self.sweep_length, self.span), - (self.sweep_length + self.tip_chord, self.span), - (self.root_chord, 0), - ] - else: - points = [ - (0, 0), - (self.root_chord - self.tip_chord, self.span), - (self.root_chord, self.span), - (self.root_chord, 0), - ] - - x_array, y_array = zip(*points) - self.shape_vec = [np.array(x_array), np.array(y_array)] - - def info(self): - self.prints.geometry() - self.prints.lift() - - def all_info(self): - self.prints.all() - self.plots.all() - def to_dict(self, **kwargs): data = super().to_dict(**kwargs) - data["tip_chord"] = self.tip_chord - data["sweep_length"] = self.sweep_length - data["sweep_angle"] = self.sweep_angle - - if kwargs.get("include_outputs", False): - data.update( - { - "shape_vec": self.shape_vec, - "Af": self.Af, - "AR": self.AR, - "gamma_c": self.gamma_c, - "Yma": self.Yma, - "roll_geometrical_constant": self.roll_geometrical_constant, - "tau": self.tau, - "lift_interference_factor": self.lift_interference_factor, - "roll_damping_interference_factor": self.roll_damping_interference_factor, - "roll_forcing_interference_factor": self.roll_forcing_interference_factor, - } - ) + data.update( + self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False)) + ) return data @classmethod diff --git a/rocketpy/rocket/aero_surface/generic_surface.py b/rocketpy/rocket/aero_surface/generic_surface.py index 8ab438620..d1ae3a8f0 100644 --- a/rocketpy/rocket/aero_surface/generic_surface.py +++ b/rocketpy/rocket/aero_surface/generic_surface.py @@ -85,6 +85,8 @@ def __init__( self.cpz = center_of_pressure[2] self.name = name + self._rotation_surface_to_body = Matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + default_coefficients = self._get_default_coefficients() self._check_coefficients(coefficients, default_coefficients) coefficients = self._complete_coefficients(coefficients, default_coefficients) diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index 89331c99f..7d3a9bd30 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -17,6 +17,7 @@ class RailButtons(AeroSurface): Angular position of the rail buttons in degrees measured as the rotation around the symmetry axis of the rocket relative to one of the other principal axis. + See :ref:`Angular Position Inputs ` RailButtons.angular_position_rad : float Angular position of the rail buttons in radians. RailButtons.button_height : float, optional diff --git a/rocketpy/rocket/components.py b/rocketpy/rocket/components.py index 9ce8595b3..57e4d12f8 100644 --- a/rocketpy/rocket/components.py +++ b/rocketpy/rocket/components.py @@ -1,4 +1,5 @@ from collections import namedtuple +from copy import deepcopy class Components: @@ -186,7 +187,7 @@ def clear(self): self._components.clear() def sort_by_position(self, reverse=False): - """Sort the list of components by z axis position. + """Returns a new Components object sorted components by z axis position. Parameters ---------- @@ -196,9 +197,12 @@ def sort_by_position(self, reverse=False): Returns ------- - None + Components + A new Components object sorted by component position. """ - self._components.sort(key=lambda x: x.position.z, reverse=reverse) + components = deepcopy(self) + components._components.sort(key=lambda x: x.position.z, reverse=reverse) + return components def to_dict(self, **kwargs): # pylint: disable=unused-argument return { diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index e3692d2e8..84655b6d7 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -21,7 +21,10 @@ Tail, TrapezoidalFins, ) +from rocketpy.rocket.aero_surface.fins.elliptical_fin import EllipticalFin +from rocketpy.rocket.aero_surface.fins.free_form_fin import FreeFormFin from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins +from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute @@ -661,14 +664,20 @@ def __evaluate_single_surface_cp_to_cdm(self, surface, position): """Calculates the relative position of each aerodynamic surface center of pressure to the rocket's center of dry mass in Body Axes Coordinate System.""" - pos = Vector( + # position of the surfaces coordinate system origin in body frame + pos_origin = Vector( [ - (position.x - self.cm_eccentricity_x) * self._csys - surface.cpx, - (position.y - self.cm_eccentricity_y) - surface.cpy, - (position.z - self.center_of_dry_mass_position) * self._csys - - surface.cpz, + (position.x - self.cm_eccentricity_x) * self._csys, + (position.y - self.cm_eccentricity_y), + (position.z - self.center_of_dry_mass_position) * self._csys, ] ) + # position of the center of pressure in body frame + pos = ( + surface._rotation_surface_to_body + @ Vector([surface.cpx, surface.cpy, surface.cpz]) + + pos_origin + ) # TODO: this should be recomputed whenever cant angle changes for fin self.surfaces_cp_to_cdm[surface] = pos def evaluate_stability_margin(self): @@ -1066,11 +1075,20 @@ def __add_single_surface(self, surface, position): """Adds a single aerodynamic surface to the rocket. Makes checks for rail buttons case, and position type. """ - position = ( - Vector([0, 0, position]) - if not isinstance(position, (Vector, tuple, list)) - else Vector(position) - ) + if isinstance(surface, (TrapezoidalFin, EllipticalFin, FreeFormFin)): + # TODO: the leading edge position should be recomputed whenever cant + # angle of the fin changes, but currently it is only computed at the + # moment the fin is added to the rocket. Detecting when the cant + # angle changes is hard, because it is a parameter of the fin, while + # the leading edge position is only defined on the rocket + position = surface._compute_leading_edge_position(position, self._csys) + else: + position = ( + Vector([0, 0, position]) + if not isinstance(position, (Vector, tuple, list)) + else Vector(position) + ) + if isinstance(surface, RailButtons): self.rail_buttons = Components() self.rail_buttons.add(surface, position) @@ -1085,17 +1103,23 @@ def add_surfaces(self, surfaces, positions): Parameters ---------- - surfaces : list, AeroSurface, NoseCone, TrapezoidalFins, EllipticalFins, Tail, RailButtons + surfaces : list[AeroSurface], AeroSurface Aerodynamic surface to be added to the rocket. Can be a list of AeroSurface if more than one surface is to be added. - positions : int, float, list, tuple, Vector - Position, in m, of the aerodynamic surface's center of pressure - relative to the user defined rocket coordinate system. - If a list is passed, it will correspond to the position of each item - in the surfaces list. - For NoseCone type, position is relative to the nose cone tip. - For Fins type, position is relative to the point belonging to - the root chord which is highest in the rocket coordinate system. + positions : int, float, tuple, list, Vector + Position(s) of the aerodynamic surface's reference point. Can be: + + - a single number (int or float) giving the z-coordinate along + the rocket axis. + - a sequence of three numbers (x, y, z) representing the full + position in the user-defined coordinate system. + + If passing multiple surfaces, provide a list of positions matching + each surface in order. + For NoseCone type, position is the tip coordinate along the axis. + For Fins type, position refers to the z-coordinate of the root + chord leading-edge point closest to the nose cone, before any + cant-angle offset is considered. For Tail type, position is relative to the point belonging to the tail which is highest in the rocket coordinate system. For RailButtons type, position is relative to the lower rail button. @@ -1108,10 +1132,18 @@ def add_surfaces(self, surfaces, positions): ------- None """ - try: + if isinstance(surfaces, Iterable): + if isinstance(positions, Iterable): + if len(surfaces) != len(positions): + raise ValueError( + "The number of surfaces and positions must be the same." + ) + else: + positions = [positions] * len(surfaces) + for surface, position in zip(surfaces, positions): self.__add_single_surface(surface, position) - except TypeError: + else: self.__add_single_surface(surfaces, positions) self.evaluate_center_of_pressure() @@ -1285,10 +1317,10 @@ def add_trapezoidal_fins( tip_chord : int, float Fin tip chord in meters. position : int, float - Fin set position relative to the rocket's coordinate system. - By fin set position, understand the point belonging to the root - chord which is highest in the rocket coordinate system (i.e. - the point closest to the nose cone tip). + Fin set position in the z coordinate of the user defined rocket + coordinate system. By fin set position, understand the point + belonging to the root chord which is highest in the rocket + coordinate system (i.e. the point closest to the nose cone tip). See Also -------- @@ -1334,6 +1366,12 @@ def add_trapezoidal_fins( fin_set : TrapezoidalFins Fin set object created. """ + if n <= 2: + raise ValueError( + "Number of fins must be greater than 2. " + "For 1 or 2 fins, create a FreeFormFin object " + "and add it to the rocket using the add_surfaces method." + ) # Modify radius if not given, use rocket radius, otherwise use given. radius = radius if radius is not None else self.radius @@ -1381,10 +1419,10 @@ def add_elliptical_fins( span : int, float Fin span in meters. position : int, float - Fin set position relative to the rocket's coordinate system. By fin - set position, understand the point belonging to the root chord which - is highest in the rocket coordinate system (i.e. the point - closest to the nose cone tip). + Fin set position in the z coordinate of the user defined rocket + coordinate system. By fin set position, understand the point + belonging to the root chord which is highest in the rocket + coordinate system (i.e. the point closest to the nose cone tip). See Also -------- @@ -1420,6 +1458,13 @@ def add_elliptical_fins( fin_set : EllipticalFins Fin set object created. """ + if n <= 2: + raise ValueError( + "Number of fins must be greater than 2. " + "For 1 or 2 fins, create a FreeFormFin object " + "and add it to the rocket using the add_surfaces method." + ) + radius = radius if radius is not None else self.radius fin_set = EllipticalFins(n, root_chord, span, radius, cant_angle, airfoil, name) self.add_surfaces(fin_set, position) @@ -1451,10 +1496,10 @@ def add_free_form_fins( The shape will be interpolated between the points, in the order they are given. The last point connects to the first point. position : int, float - Fin set position relative to the rocket's coordinate system. - By fin set position, understand the point belonging to the root - chord which is highest in the rocket coordinate system (i.e. - the point closest to the nose cone tip). + Fin set position in the z coordinate of the user defined rocket + coordinate system. By fin set position, understand the point + belonging to the root chord which is highest in the rocket + coordinate system (i.e. the point closest to the nose cone tip). See Also -------- @@ -1486,6 +1531,12 @@ def add_free_form_fins( fin_set : FreeFormFins Fin set object created. """ + if n <= 2: + raise ValueError( + "Number of fins must be greater than 2. " + "For 1 or 2 fins, create a FreeFormFin object " + "and add it to the rocket using the add_surfaces method." + ) # Modify radius if not given, use rocket radius, otherwise use given. radius = radius if radius is not None else self.radius @@ -1630,8 +1681,7 @@ def add_sensor(self, sensor, position): must be in the format (x, y, z) where x, y, and z are defined in the rocket's user defined coordinate system. If a single value is passed, it is assumed to be along the z-axis (centerline) of the - rocket's user defined coordinate system and angular_position and - radius must be given. + rocket's user defined coordinate system. Returns ------- @@ -1825,7 +1875,7 @@ def set_rail_buttons( as the rotation around the symmetry axis of the rocket relative to one of the other principal axis. Default value is 45 degrees, generally used in rockets with - 4 fins. + 4 fins. See :ref:`Angular Position Inputs ` radius : int, float, optional Fuselage radius where the rail buttons are located. diff --git a/tests/fixtures/surfaces/surface_fixtures.py b/tests/fixtures/surfaces/surface_fixtures.py index c48627478..3819595dc 100644 --- a/tests/fixtures/surfaces/surface_fixtures.py +++ b/tests/fixtures/surfaces/surface_fixtures.py @@ -1,11 +1,14 @@ import pytest from rocketpy.rocket.aero_surface import ( + EllipticalFin, EllipticalFins, + FreeFormFin, FreeFormFins, NoseCone, RailButtons, Tail, + TrapezoidalFin, TrapezoidalFins, ) @@ -69,6 +72,29 @@ def calisto_trapezoidal_fins(): ) +@pytest.fixture +def calisto_trapezoidal_fin(): + """A single trapezoidal fin based on Calisto dimensions. + + Returns + ------- + rocketpy.TrapezoidalFin + A single trapezoidal fin. + """ + return TrapezoidalFin( + angular_position=0, + span=0.100, + root_chord=0.120, + tip_chord=0.040, + rocket_radius=0.0635, + name="calisto_trapezoidal_fin", + cant_angle=0, + sweep_length=None, + sweep_angle=None, + airfoil=None, + ) + + @pytest.fixture def calisto_trapezoidal_fins_custom_sweep_length(): """The trapezoidal fins of the Calisto rocket with @@ -130,6 +156,23 @@ def calisto_free_form_fins(): ) +@pytest.fixture +def calisto_free_form_fin(): + """A single free-form fin based on Calisto-like dimensions. + + Returns + ------- + rocketpy.FreeFormFin + A single free-form fin. + """ + return FreeFormFin( + angular_position=0, + shape_points=[(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)], + rocket_radius=0.0635, + name="calisto_free_form_fin", + ) + + @pytest.fixture def calisto_rail_buttons(): """The rail buttons of the Calisto rocket. @@ -157,3 +200,23 @@ def elliptical_fin_set(): airfoil=None, name="Test Elliptical Fins", ) + + +@pytest.fixture +def calisto_elliptical_fin(): + """A single elliptical fin based on Calisto-like dimensions. + + Returns + ------- + rocketpy.EllipticalFin + A single elliptical fin. + """ + return EllipticalFin( + angular_position=0, + span=0.100, + root_chord=0.120, + rocket_radius=0.0635, + cant_angle=0, + airfoil=None, + name="calisto_elliptical_fin", + ) diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py index 38d5aef41..cd2de7ee1 100644 --- a/tests/integration/test_encoding.py +++ b/tests/integration/test_encoding.py @@ -273,7 +273,7 @@ def test_trapezoidal_fins_encoder(fin_name, request): assert np.isclose(fin_to_encode.tip_chord, fin_loaded.tip_chord) assert np.isclose(fin_to_encode.rocket_radius, fin_loaded.rocket_radius) assert np.isclose(fin_to_encode.sweep_length, fin_loaded.sweep_length) - if fin_to_encode._sweep_angle is not None and fin_loaded._sweep_angle is not None: + if fin_to_encode.sweep_angle is not None and fin_loaded.sweep_angle is not None: assert np.isclose(fin_to_encode.sweep_angle, fin_loaded.sweep_angle) diff --git a/tests/integration/test_rocket.py b/tests/integration/test_rocket.py index c47096617..e36dd933e 100644 --- a/tests/integration/test_rocket.py +++ b/tests/integration/test_rocket.py @@ -19,7 +19,7 @@ def test_airfoil( calisto.add_surfaces(calisto_tail, -1.313) test_rocket.add_trapezoidal_fins( - 2, + 3, span=0.100, root_chord=0.120, tip_chord=0.040, @@ -28,7 +28,7 @@ def test_airfoil( name="NACA0012", ) test_rocket.add_trapezoidal_fins( - 2, + 3, span=0.100, root_chord=0.120, tip_chord=0.040, diff --git a/tests/unit/rocket/aero_surface/test_fin_geometry.py b/tests/unit/rocket/aero_surface/test_fin_geometry.py new file mode 100644 index 000000000..9531a5c98 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_fin_geometry.py @@ -0,0 +1,317 @@ +"""Unit tests for fin geometry strategy classes.""" + +import numpy as np +import pytest + +from rocketpy.rocket.aero_surface.fins._geometry import ( + _EllipticalGeometry, + _FreeFormGeometry, + _TrapezoidalGeometry, +) + + +def test_trapezoidal_geometry_evaluate_geometrical_parameters( + calisto_trapezoidal_fin, +): + """Ensure trapezoidal geometry populates the derived fin parameters.""" + # Arrange + geometry = calisto_trapezoidal_fin.geometry + + # Act + geometry.evaluate_geometrical_parameters() + + # Assert + owner = calisto_trapezoidal_fin + expected_area = (owner.root_chord + owner.tip_chord) * owner.span / 2 + expected_aspect_ratio = 2 * owner.span**2 / expected_area + expected_gamma_c = np.arctan( + (geometry.sweep_length + 0.5 * owner.tip_chord - 0.5 * owner.root_chord) + / owner.span + ) + expected_mid_aerodynamic_span = ( + owner.span + / 3 + * (owner.root_chord + 2 * owner.tip_chord) + / (owner.root_chord + owner.tip_chord) + ) + tau = (owner.span + owner.rocket_radius) / owner.rocket_radius + lambda_ = owner.tip_chord / owner.root_chord + expected_roll_constant = ( + (owner.root_chord + 3 * owner.tip_chord) * owner.span**3 + + 4 + * (owner.root_chord + 2 * owner.tip_chord) + * owner.rocket_radius + * owner.span**2 + + 6 * (owner.root_chord + owner.tip_chord) * owner.span * owner.rocket_radius**2 + ) / 12 + expected_lift_interference_factor = 1 + 1 / tau + expected_roll_damping_factor = 1 + ( + ((tau - lambda_) / tau) - ((1 - lambda_) / (tau - 1)) * np.log(tau) + ) / ( + ((tau + 1) * (tau - lambda_)) / 2 + - ((1 - lambda_) * (tau**3 - 1)) / (3 * (tau - 1)) + ) + expected_roll_forcing_factor = (1 / np.pi**2) * ( + (np.pi**2 / 4) * ((tau + 1) ** 2 / tau**2) + + (np.pi * (tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + - (2 * np.pi * (tau + 1)) / (tau * (tau - 1)) + + ((tau**2 + 1) ** 2 / (tau**2 * (tau - 1) ** 2)) + * (np.arcsin((tau**2 - 1) / (tau**2 + 1))) ** 2 + - (4 * (tau + 1)) / (tau * (tau - 1)) * np.arcsin((tau**2 - 1) / (tau**2 + 1)) + + (8 / (tau - 1) ** 2) * np.log((tau**2 + 1) / (2 * tau)) + ) + + assert isinstance(geometry, _TrapezoidalGeometry) + assert owner.Af == pytest.approx(expected_area) + assert owner.AR == pytest.approx(expected_aspect_ratio) + assert owner.gamma_c == pytest.approx(expected_gamma_c) + assert owner.Yma == pytest.approx(expected_mid_aerodynamic_span) + assert owner.roll_geometrical_constant == pytest.approx(expected_roll_constant) + assert owner.tau == pytest.approx(tau) + assert owner.lift_interference_factor == pytest.approx( + expected_lift_interference_factor + ) + assert owner.roll_damping_interference_factor == pytest.approx( + expected_roll_damping_factor + ) + assert owner.roll_forcing_interference_factor == pytest.approx( + expected_roll_forcing_factor + ) + + +def test_trapezoidal_geometry_evaluate_shape_sets_expected_points( + calisto_trapezoidal_fin, +): + """Ensure trapezoidal geometry shape points match the configured sweep.""" + # Arrange + geometry = calisto_trapezoidal_fin.geometry + + # Act + geometry.evaluate_shape() + + # Assert + np.testing.assert_allclose( + calisto_trapezoidal_fin.shape_vec[0], + np.array([0.0, 0.08, 0.12, 0.12]), + ) + np.testing.assert_allclose( + calisto_trapezoidal_fin.shape_vec[1], + np.array([0.0, 0.1, 0.1, 0.0]), + ) + + +def test_trapezoidal_geometry_get_data_returns_inputs_and_outputs( + calisto_trapezoidal_fin, +): + """Ensure trapezoidal geometry serialization includes optional outputs.""" + # Arrange + geometry = calisto_trapezoidal_fin.geometry + + # Act + geometry.evaluate_geometrical_parameters() + data_without_outputs = geometry.get_data() + data_with_outputs = geometry.get_data(include_outputs=True) + + # Assert + assert data_without_outputs["tip_chord"] == pytest.approx( + calisto_trapezoidal_fin.tip_chord + ) + assert data_without_outputs["sweep_length"] == pytest.approx( + calisto_trapezoidal_fin.sweep_length + ) + assert data_without_outputs["sweep_angle"] is None + assert set(data_with_outputs) >= { + "tip_chord", + "sweep_length", + "sweep_angle", + "shape_vec", + "Af", + "AR", + "gamma_c", + "Yma", + "roll_geometrical_constant", + "tau", + "lift_interference_factor", + "roll_damping_interference_factor", + "roll_forcing_interference_factor", + } + np.testing.assert_allclose( + data_with_outputs["shape_vec"][0], calisto_trapezoidal_fin.shape_vec[0] + ) + + +def test_elliptical_geometry_evaluate_geometrical_parameters( + calisto_elliptical_fin, +): + """Ensure elliptical geometry populates the derived fin parameters.""" + # Arrange + geometry = calisto_elliptical_fin.geometry + + # Act + geometry.evaluate_geometrical_parameters() + + # Assert + owner = calisto_elliptical_fin + expected_area = np.pi * owner.root_chord / 2 * owner.span / 2 + expected_aspect_ratio = 2 * owner.span**2 / expected_area + expected_mid_aerodynamic_span = ( + owner.span / (3 * np.pi) * np.sqrt(9 * np.pi**2 - 64) + ) + expected_roll_constant = ( + owner.root_chord + * owner.span + * ( + 3 * np.pi * owner.span**2 + + 32 * owner.rocket_radius * owner.span + + 12 * np.pi * owner.rocket_radius**2 + ) + / 48 + ) + tau = (owner.span + owner.rocket_radius) / owner.rocket_radius + expected_lift_interference_factor = 1 + 1 / tau + + assert isinstance(geometry, _EllipticalGeometry) + assert owner.Af == pytest.approx(expected_area) + assert owner.AR == pytest.approx(expected_aspect_ratio) + assert owner.gamma_c == pytest.approx(0) + assert owner.Yma == pytest.approx(expected_mid_aerodynamic_span) + assert owner.roll_geometrical_constant == pytest.approx(expected_roll_constant) + assert owner.tau == pytest.approx(tau) + assert owner.lift_interference_factor == pytest.approx( + expected_lift_interference_factor + ) + assert owner.roll_damping_interference_factor > 0 + assert owner.roll_forcing_interference_factor > 0 + + +def test_elliptical_geometry_evaluate_shape_sets_expected_points( + calisto_elliptical_fin, +): + """Ensure elliptical geometry evaluates the expected semi-ellipse shape.""" + # Arrange + geometry = calisto_elliptical_fin.geometry + + # Act + geometry.evaluate_shape() + + # Assert + angles = np.arange(0, 180, 5) + expected_x = calisto_elliptical_fin.root_chord / 2 + ( + calisto_elliptical_fin.root_chord / 2 * np.cos(np.radians(angles)) + ) + expected_y = calisto_elliptical_fin.span * np.sin(np.radians(angles)) + np.testing.assert_allclose(calisto_elliptical_fin.shape_vec[0], expected_x) + np.testing.assert_allclose(calisto_elliptical_fin.shape_vec[1], expected_y) + + +def test_elliptical_geometry_get_data_returns_expected_outputs( + calisto_elliptical_fin, +): + """Ensure elliptical geometry serialization includes optional outputs.""" + # Arrange + geometry = calisto_elliptical_fin.geometry + + # Act + geometry.evaluate_geometrical_parameters() + data_without_outputs = geometry.get_data() + data_with_outputs = geometry.get_data(include_outputs=True) + + # Assert + assert data_without_outputs == {} + assert data_with_outputs["Af"] == pytest.approx(calisto_elliptical_fin.Af) + assert data_with_outputs["AR"] == pytest.approx(calisto_elliptical_fin.AR) + assert data_with_outputs["gamma_c"] == pytest.approx(calisto_elliptical_fin.gamma_c) + assert data_with_outputs["Yma"] == pytest.approx(calisto_elliptical_fin.Yma) + assert data_with_outputs["roll_geometrical_constant"] == pytest.approx( + calisto_elliptical_fin.roll_geometrical_constant + ) + assert data_with_outputs["tau"] == pytest.approx(calisto_elliptical_fin.tau) + + +def test_free_form_geometry_infer_dimensions_warns_on_jagged_shape(): + """Ensure jagged free-form fins emit a warning while dimensions are inferred.""" + # Arrange + shape_points = [(0, 0), (0.05, 0.1), (0.06, 0.05), (0.09, 0.07), (0.12, 0)] + + # Act + with pytest.warns(UserWarning, match="Jagged fin shape detected"): + root_chord, span = _FreeFormGeometry.infer_dimensions(shape_points) + + # Assert + assert root_chord == pytest.approx(0.12) + assert span == pytest.approx(0.1) + + +def test_free_form_geometry_evaluate_geometrical_parameters(calisto_free_form_fin): + """Ensure free-form geometry populates the derived fin parameters.""" + # Arrange + geometry = calisto_free_form_fin.geometry + + # Act + geometry.evaluate_geometrical_parameters() + + # Assert + owner = calisto_free_form_fin + assert isinstance(geometry, _FreeFormGeometry) + assert owner.Af == pytest.approx(0.008) + assert owner.AR == pytest.approx(2.5) + assert owner.gamma_c > 0 + assert owner.Yma > 0 + assert owner.mac_length > 0 + assert owner.mac_lead >= 0 + assert owner.roll_geometrical_constant > 0 + assert owner.tau > 0 + assert owner.lift_interference_factor > 1 + assert owner.roll_damping_interference_factor > 1 + assert owner.roll_forcing_interference_factor > 0 + + +def test_free_form_geometry_evaluate_shape_sets_expected_points( + calisto_free_form_fin, +): + """Ensure free-form geometry exposes the configured shape points.""" + # Arrange + geometry = calisto_free_form_fin.geometry + + # Act + geometry.evaluate_shape() + + # Assert + np.testing.assert_allclose( + calisto_free_form_fin.shape_vec[0], + np.array([0.0, 0.08, 0.12, 0.12]), + ) + np.testing.assert_allclose( + calisto_free_form_fin.shape_vec[1], + np.array([0.0, 0.1, 0.1, 0.0]), + ) + + +def test_free_form_geometry_get_data_returns_expected_outputs( + calisto_free_form_fin, +): + """Ensure free-form geometry serialization includes optional outputs.""" + # Arrange + geometry = calisto_free_form_fin.geometry + + # Act + geometry.evaluate_geometrical_parameters() + data_without_outputs = geometry.get_data() + data_with_outputs = geometry.get_data(include_outputs=True) + + # Assert + assert data_without_outputs == { + "shape_points": [(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)], + } + assert data_with_outputs["shape_points"] == calisto_free_form_fin.shape_points + assert data_with_outputs["Af"] == pytest.approx(calisto_free_form_fin.Af) + assert data_with_outputs["AR"] == pytest.approx(calisto_free_form_fin.AR) + assert data_with_outputs["gamma_c"] == pytest.approx(calisto_free_form_fin.gamma_c) + assert data_with_outputs["Yma"] == pytest.approx(calisto_free_form_fin.Yma) + assert data_with_outputs["mac_length"] == pytest.approx( + calisto_free_form_fin.mac_length + ) + assert data_with_outputs["mac_lead"] == pytest.approx( + calisto_free_form_fin.mac_lead + ) diff --git a/tests/unit/rocket/aero_surface/test_individual_fins.py b/tests/unit/rocket/aero_surface/test_individual_fins.py new file mode 100644 index 000000000..d232e0772 --- /dev/null +++ b/tests/unit/rocket/aero_surface/test_individual_fins.py @@ -0,0 +1,424 @@ +"""Unit tests for individual fin classes.""" + +from unittest.mock import patch + +import numpy as np +import pytest + +from rocketpy import ( + EllipticalFin, + FreeFormFin, + Rocket, + TrapezoidalFin, + TrapezoidalFins, +) +from rocketpy.mathutils.vector_matrix import Vector + + +@pytest.mark.parametrize( + "fixture_name,expected_class", + [ + ("calisto_trapezoidal_fin", TrapezoidalFin), + ("calisto_elliptical_fin", EllipticalFin), + ("calisto_free_form_fin", FreeFormFin), + ], +) +def test_individual_fin_info_returns_none(request, fixture_name, expected_class): + """Ensure info() executes for all individual fin classes.""" + # Arrange + fin = request.getfixturevalue(fixture_name) + + # Act + result = fin.info() + + # Assert + assert isinstance(fin, expected_class) + assert result is None + + +@patch("matplotlib.pyplot.show") +@pytest.mark.parametrize( + "fixture_name", + [ + "calisto_trapezoidal_fin", + "calisto_elliptical_fin", + "calisto_free_form_fin", + ], +) +def test_individual_fin_draw_returns_none(mock_show, request, fixture_name): # pylint: disable=unused-argument + """Ensure draw() executes for all individual fin classes.""" + # Arrange + fin = request.getfixturevalue(fixture_name) + + # Act + result = fin.draw(filename=None) + + # Assert + assert result is None + + +@pytest.mark.parametrize( + "fixture_name", + [ + "calisto_trapezoidal_fin", + "calisto_elliptical_fin", + "calisto_free_form_fin", + ], +) +def test_individual_fin_angular_position_updates_radians(request, fixture_name): + """Ensure angular_position setter updates angular_position_rad.""" + # Arrange + fin = request.getfixturevalue(fixture_name) + + # Act + fin.angular_position = 45 + + # Assert + assert fin.angular_position == 45 + np.testing.assert_allclose(fin.angular_position_rad, np.pi / 4) + + +def test_trapezoidal_fin_setters_update_geometry(calisto_trapezoidal_fin): + """Ensure trapezoidal fin geometry setters update exposed values.""" + # Arrange + fin = calisto_trapezoidal_fin + + # Act + fin.tip_chord = 0.05 + fin.sweep_angle = 12.0 + fin.sweep_length = 0.03 + + # Assert + np.testing.assert_allclose(fin.tip_chord, 0.05) + np.testing.assert_allclose(fin.sweep_angle, 12.0) + np.testing.assert_allclose(fin.sweep_length, 0.03) + + +def test_individual_fin_rocket_diameter_aliases_are_kept_in_sync( + calisto_trapezoidal_fin, +): + """Ensure rocket_diameter is canonical and old aliases remain compatible.""" + # Arrange + fin = calisto_trapezoidal_fin + + # Act + fin.rocket_diameter = 0.15 + + # Assert + np.testing.assert_allclose(fin.rocket_diameter, 0.15) + np.testing.assert_allclose(fin.diameter, 0.15) + np.testing.assert_allclose(fin.d, 0.15) + np.testing.assert_allclose(fin.rocket_radius, 0.075) + np.testing.assert_allclose(fin.reference_length, 0.15) + + # Act + fin.d = 0.20 + + # Assert + np.testing.assert_allclose(fin.rocket_diameter, 0.20) + np.testing.assert_allclose(fin.diameter, 0.20) + np.testing.assert_allclose(fin.d, 0.20) + np.testing.assert_allclose(fin.rocket_radius, 0.10) + np.testing.assert_allclose(fin.reference_length, 0.20) + + +def test_individual_fin_reference_area_and_ref_area_alias_are_kept_in_sync( + calisto_trapezoidal_fin, +): + """Ensure reference_area is canonical and ref_area remains compatible.""" + # Arrange + fin = calisto_trapezoidal_fin + + # Act + fin.reference_area = 0.123 + + # Assert + np.testing.assert_allclose(fin.reference_area, 0.123) + np.testing.assert_allclose(fin.ref_area, 0.123) + + # Act + fin.ref_area = 0.456 + + # Assert + np.testing.assert_allclose(fin.reference_area, 0.456) + np.testing.assert_allclose(fin.ref_area, 0.456) + + +def test_individual_fin_to_dict_include_outputs_exposes_diameter_aliases( + calisto_trapezoidal_fin, +): + """Ensure output serialization exposes canonical and alias diameter keys.""" + # Arrange + fin = calisto_trapezoidal_fin + + # Act + data = fin.to_dict(include_outputs=True) + + # Assert + np.testing.assert_allclose(data["rocket_diameter"], fin.rocket_diameter) + np.testing.assert_allclose(data["diameter"], fin.rocket_diameter) + np.testing.assert_allclose(data["d"], fin.rocket_diameter) + np.testing.assert_allclose(data["reference_area"], fin.reference_area) + np.testing.assert_allclose(data["ref_area"], fin.reference_area) + + +def test_trapezoidal_fin_rejects_inconsistent_sweep_inputs(): + """Ensure trapezoidal fin rejects sweep_length with sweep_angle together.""" + # Arrange / Act / Assert + with pytest.raises( + ValueError, match="Cannot use sweep_length and sweep_angle together" + ): + TrapezoidalFin( + angular_position=0, + root_chord=0.12, + tip_chord=0.04, + span=0.1, + rocket_radius=0.0635, + sweep_length=0.02, + sweep_angle=10.0, + ) + + +def test_free_form_fin_shape_points_property(calisto_free_form_fin): + """Ensure free-form fin exposes the original shape points.""" + # Arrange + fin = calisto_free_form_fin + + # Act + shape_points = fin.shape_points + + # Assert + assert shape_points == [(0, 0), (0.08, 0.1), (0.12, 0.1), (0.12, 0)] + + +@pytest.mark.parametrize( + "fixture_name,required_keys", + [ + ( + "calisto_trapezoidal_fin", + { + "angular_position", + "root_chord", + "span", + "rocket_radius", + "cant_angle", + "airfoil", + "name", + "tip_chord", + "sweep_length", + "sweep_angle", + }, + ), + ( + "calisto_elliptical_fin", + { + "angular_position", + "root_chord", + "span", + "rocket_radius", + "cant_angle", + "airfoil", + "name", + }, + ), + ( + "calisto_free_form_fin", + { + "angular_position", + "rocket_radius", + "cant_angle", + "airfoil", + "name", + "shape_points", + }, + ), + ], +) +def test_individual_fin_to_dict_contains_expected_keys( + request, fixture_name, required_keys +): + """Ensure to_dict for each individual fin exposes expected input keys.""" + # Arrange + fin = request.getfixturevalue(fixture_name) + + # Act + data = fin.to_dict() + + # Assert + assert required_keys.issubset(data.keys()) + + +@pytest.mark.parametrize( + "fixture_name,fin_class,comparisons", + [ + ( + "calisto_trapezoidal_fin", + TrapezoidalFin, + ["angular_position", "root_chord", "tip_chord", "span", "rocket_radius"], + ), + ( + "calisto_elliptical_fin", + EllipticalFin, + ["angular_position", "root_chord", "span", "rocket_radius"], + ), + ( + "calisto_free_form_fin", + FreeFormFin, + ["angular_position", "rocket_radius"], + ), + ], +) +def test_individual_fin_from_dict_roundtrip( + request, fixture_name, fin_class, comparisons +): + """Ensure each individual fin can be reconstructed with from_dict.""" + # Arrange + fin = request.getfixturevalue(fixture_name) + data = fin.to_dict() + + # Act + reconstructed = fin_class.from_dict(data) + + # Assert + assert isinstance(reconstructed, fin_class) + for field in comparisons: + np.testing.assert_allclose(getattr(reconstructed, field), getattr(fin, field)) + + if fin_class is FreeFormFin: + assert reconstructed.shape_points == fin.shape_points + + +def test_trapezoidal_fin_from_dict_roundtrip_preserves_sweep_length(): + """Ensure TrapezoidalFin round-trip preserves non-default sweep geometry.""" + # Arrange + original = TrapezoidalFin( + angular_position=0, + root_chord=0.12, + tip_chord=0.04, + span=0.1, + rocket_radius=0.0635, + cant_angle=0, + sweep_angle=15.0, + name="roundtrip_trapezoidal_fin", + ) + data = original.to_dict() + + # Act + reconstructed = TrapezoidalFin.from_dict(data) + + # Assert + np.testing.assert_allclose(reconstructed.sweep_length, original.sweep_length) + + +def test_calisto_finset_vs_four_individual_fins_close(): + """Ensure a 4-fin set and 4 individual fins produce close aerodynamics. + + Notes + ----- + A fin set model includes finite-set lift correction for the number of fins. + For 4 fins, this correction is equivalent to scaling the sum of 4 + individual-fin lift derivatives by 1/2. + """ + # Arrange + finset_rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + finset_rocket.add_surfaces( + TrapezoidalFins( + n=4, + span=0.100, + root_chord=0.120, + tip_chord=0.040, + rocket_radius=0.0635, + name="calisto_trapezoidal_fins", + cant_angle=0, + sweep_length=None, + sweep_angle=None, + airfoil=None, + ), + -1.168, + ) + + individual_fins_rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + + individual_fins = [ + TrapezoidalFin( + angular_position=angle, + root_chord=0.120, + tip_chord=0.040, + span=0.100, + rocket_radius=0.0635, + name=f"calisto_trapezoidal_fin_{i}", + cant_angle=0, + sweep_length=None, + sweep_angle=None, + airfoil=None, + ) + for i, angle in enumerate((0, 90, 180, 270), start=1) + ] + individual_fins_rocket.add_surfaces(individual_fins, [-1.168] * 4) + + mach_grid = np.linspace(0, 2, 21) + + # Act + cp_finset = finset_rocket.cp_position(mach_grid) + cp_individual = individual_fins_rocket.cp_position(mach_grid) + clalpha_finset = finset_rocket.total_lift_coeff_der(mach_grid) + clalpha_individual = individual_fins_rocket.total_lift_coeff_der(mach_grid) + lift_correction = TrapezoidalFins.fin_num_correction(4) / 4 + clalpha_individual_corrected = np.array(clalpha_individual) * lift_correction + + # Assert + np.testing.assert_allclose(cp_individual, cp_finset, rtol=1e-6, atol=1e-6) + np.testing.assert_allclose(clalpha_individual_corrected, clalpha_finset) + + +@pytest.mark.parametrize( + "position_input", + [ + (0.02, -0.01, -1.2), + Vector([0.02, -0.01, -1.2]), + ], +) +def test_add_individual_fin_accepts_full_3d_position(position_input): + """Ensure individual fins accept full (x, y, z) position inputs.""" + # Arrange + rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag="data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="data/rockets/calisto/powerOnDragCurve.csv", + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + fin = TrapezoidalFin( + angular_position=30, + root_chord=0.120, + tip_chord=0.040, + span=0.100, + rocket_radius=0.0635, + cant_angle=0, + name="position_test_fin", + ) + + # Act + rocket.add_surfaces(fin, position_input) + stored_position = rocket.aerodynamic_surfaces[0].position + + # Assert + assert stored_position == Vector([0.02, -0.01, -1.2]) From e2ddc45a32a3726f64a40ce0b2fd94ce0f0e011c Mon Sep 17 00:00:00 2001 From: Mateus Stano Junqueira <69485049+MateusStano@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:09:37 -0300 Subject: [PATCH 19/22] ENH: add AIGFS and HRRR Models to Forecast (#951) * ENH: Add AIGFS and HRRR dataset fetchers * ENH: Update documentation for atmospheric models and add AIGFS and HRRR support * TST: Add tests for new models --- .../environment/1-atm-models/ensemble.rst | 4 +- .../environment/1-atm-models/forecast.rst | 93 ++++++++++++++++++- .../environment/1-atm-models/soundings.rst | 2 +- .../user/environment/3-further/other_apis.rst | 5 +- rocketpy/environment/environment.py | 52 +++++++---- rocketpy/environment/fetchers.py | 72 ++++++++++++++ rocketpy/environment/weather_model_mapping.py | 70 ++++++-------- .../environment/test_environment.py | 39 +++++++- tests/unit/environment/test_environment.py | 53 ++++++++++- 9 files changed, 318 insertions(+), 72 deletions(-) diff --git a/docs/user/environment/1-atm-models/ensemble.rst b/docs/user/environment/1-atm-models/ensemble.rst index 504cbfe60..8dffaac00 100644 --- a/docs/user/environment/1-atm-models/ensemble.rst +++ b/docs/user/environment/1-atm-models/ensemble.rst @@ -25,8 +25,8 @@ Global Ensemble Forecast System (GEFS) .. danger:: - **GEFS shortcut unavailable**: ``file="GEFS"`` is currently disabled in - RocketPy because NOMADS OPeNDAP is deactivated for this endpoint. + **GEFS unavailable**: ``file="GEFS"`` is currently disabled in + RocketPy because NOMADS OPeNDAP has been deactivated. .. note:: diff --git a/docs/user/environment/1-atm-models/forecast.rst b/docs/user/environment/1-atm-models/forecast.rst index ac91504e0..92f5b6b9e 100644 --- a/docs/user/environment/1-atm-models/forecast.rst +++ b/docs/user/environment/1-atm-models/forecast.rst @@ -6,8 +6,8 @@ Forecasts Weather forecasts can be used to set the atmospheric model in RocketPy. Here, we will showcase how to import global forecasts such as GFS, as well as -local forecasts like NAM and RAP for North America, all available through -OPeNDAP on the `NOAA's NCEP NOMADS `_ website. +local forecasts like NAM, RAP and HRRR for North America, all available through +OPeNDAP on the `UCAR THREDDS `_ server. Other generic forecasts can also be imported. .. important:: @@ -22,6 +22,10 @@ Other generic forecasts can also be imported. Global Forecast System (GFS) ---------------------------- +GFS is NOAA's global numerical weather prediction model. It provides worldwide +atmospheric forecasts and is usually a good default choice when you need broad +coverage, consistent availability, and launch planning several days ahead. + Using the latest forecast from GFS is simple. Set the atmospheric model to ``forecast`` and specify that GFS is the file you want. Note that since data is downloaded from a remote OPeNDAP server, this line of code can @@ -48,9 +52,34 @@ take longer than usual. `GFS overview page `_. +Artificial Intelligence Global Forecast System (AIGFS) +------------------------------------------------------ + +AIGFS is a global AI-based forecast product distributed through the same THREDDS +ecosystem used by other RocketPy forecast inputs. It is useful when you want a +global forecast alternative to traditional physics-only models. + +RocketPy supports the latest AIGFS global forecast through THREDDS. + +.. jupyter-execute:: + + env_aigfs = Environment(date=tomorrow) + env_aigfs.set_atmospheric_model(type="forecast", file="AIGFS") + env_aigfs.plots.atmospheric_model() + +.. note:: + + AIGFS is currently available as a global 0.25 degree forecast product on + UCAR THREDDS. + + North American Mesoscale Forecast System (NAM) ---------------------------------------------- +NAM is a regional forecast model focused on North America. It is best suited +for launches inside its coverage area when you want finer regional detail than +global models typically provide. + You can also request the latest forecasts from NAM. Since this is a regional model for North America, you need to specify latitude and longitude points within North America. @@ -78,6 +107,10 @@ We will use **SpacePort America** for this, represented by coordinates Rapid Refresh (RAP) ------------------- +RAP is a short-range, high-frequency regional model for North America. It is +especially useful for near-term operations, where fast update cycles are more +important than long forecast horizon. + The Rapid Refresh (RAP) model is another regional model for North America. It is similar to NAM, but with a higher resolution and a shorter forecast range. The same coordinates for SpacePort America will be used. @@ -111,6 +144,17 @@ The same coordinates for SpacePort America will be used. High Resolution Window (HIRESW) ------------------------------- +HIRESW is a convection-allowing, high-resolution regional system designed to +resolve local weather structure better than coarser grids. It is most useful +for short-range, local analysis where small-scale wind and weather features +matter. + +The High Resolution Window (HIRESW) model is a sophisticated weather forecasting +system that operates at a high spatial resolution of approximately 3 km. +It utilizes two main dynamical cores: the Advanced Research WRF (WRF-ARW) and +the Finite Volume Cubed Sphere (FV3), each designed to enhance the accuracy of +weather predictions. + .. danger:: **HIRESW shortcut unavailable**: ``file="HIRESW"`` is currently disabled in @@ -121,6 +165,33 @@ you can still load it explicitly by passing the path/URL in ``file`` and an appropriate mapping in ``dictionary``. +High-Resolution Rapid Refresh (HRRR) +------------------------------------ + +HRRR is a high-resolution, short-range forecast model for North America with +hourly updates. It is generally best for day-of-launch weather assessment and +rapidly changing local conditions. + +RocketPy supports HRRR through a dedicated THREDDS shortcut. +Like NAM and RAP, HRRR is a regional model over North America. + +If you have a HIRESW-compatible dataset from another provider (or a local copy), +you can still load it explicitly by passing the path/URL in ``file`` and an +appropriate mapping in ``dictionary``. + + env_hrrr = Environment( + date=now_plus_twelve, + latitude=32.988528, + longitude=-106.975056, + ) + env_hrrr.set_atmospheric_model(type="forecast", file="HRRR") + env_hrrr.plots.atmospheric_model() + +.. note:: + + HRRR is a high-resolution regional model with approximately 2.5 km grid + spacing over CONUS. Availability depends on upstream THREDDS data services. + Using Windy Atmosphere ---------------------- @@ -154,6 +225,10 @@ to EuRoC's launch area in Portugal. ECMWF ^^^^^ +ECMWF (HRES) is a global, high-skill forecast model known for strong +medium-range performance. It is often a good choice for mission planning when +you need reliable synoptic-scale forecasts several days ahead. + We can use the ``ECMWF`` model from Windy.com. .. jupyter-execute:: @@ -173,6 +248,10 @@ We can use the ``ECMWF`` model from Windy.com. GFS ^^^ +Windy's GFS option provides NOAA's global model through Windy's interface. It +is a practical baseline for global coverage and for comparing against other +models when assessing forecast uncertainty. + The ``GFS`` model is also available on Windy.com. This is the same model as described in the :ref:`global-forecast-system` section. @@ -186,6 +265,10 @@ described in the :ref:`global-forecast-system` section. ICON ^^^^ +ICON is DWD's global weather model, available in Windy for broad-scale +forecasting. It is useful as an independent global model source to cross-check +wind and temperature trends against GFS or ECMWF. + The ICON model is a global weather forecasting model already available on Windy.com. .. jupyter-execute:: @@ -203,6 +286,10 @@ The ICON model is a global weather forecasting model already available on Windy. ICON-EU ^^^^^^^ +ICON-EU is the regional European configuration of ICON, with higher spatial +detail over Europe than ICON-Global. It is best for European launch sites when +regional structure is important. + The ICON-EU model is a regional weather forecasting model available on Windy.com. .. code-block:: python @@ -228,4 +315,4 @@ Also, the servers may be down or may face high traffic. .. seealso:: To browse available NCEP model collections on UCAR THREDDS, visit - `THREDDS NCEP Catalog `_. \ No newline at end of file + `THREDDS NCEP Catalog `_. diff --git a/docs/user/environment/1-atm-models/soundings.rst b/docs/user/environment/1-atm-models/soundings.rst index 279750df5..4cf82543f 100644 --- a/docs/user/environment/1-atm-models/soundings.rst +++ b/docs/user/environment/1-atm-models/soundings.rst @@ -59,7 +59,7 @@ Integrated Global Radiosonde Archive (IGRA). These options can be retrieved as a text file in GSD format. However, RocketPy no longer provides a dedicated ``set_atmospheric_model`` type for -NOAA RUC Soundings. +NOAA RUC Soundings, since NOAA has discontinued the OPENDAP service. .. note:: diff --git a/docs/user/environment/3-further/other_apis.rst b/docs/user/environment/3-further/other_apis.rst index 01d4b9a30..37a9a0949 100644 --- a/docs/user/environment/3-further/other_apis.rst +++ b/docs/user/environment/3-further/other_apis.rst @@ -89,8 +89,10 @@ Instead of a custom dictionary, you can pass a built-in mapping name in the - ``"ECMWF_v0"`` - ``"NOAA"`` - ``"GFS"`` +- ``"AIGFS"`` - ``"NAM"`` - ``"RAP"`` +- ``"HRRR"`` - ``"HIRESW"`` (mapping available; latest-model shortcut currently disabled) - ``"GEFS"`` (mapping available; latest-model shortcut currently disabled) - ``"MERRA2"`` @@ -116,10 +118,7 @@ legacy aliases: - ``"NAM_LEGACY"`` - ``"NOAA_LEGACY"`` - ``"RAP_LEGACY"`` -- ``"CMC_LEGACY"`` - ``"GEFS_LEGACY"`` -- ``"HIRESW_LEGACY"`` -- ``"MERRA2_LEGACY"`` Legacy aliases primarily cover older variable naming patterns such as ``lev``, ``tmpprs``, ``hgtprs``, ``ugrdprs`` and ``vgrdprs``. diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 8e379800c..a2ed22370 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -11,10 +11,12 @@ import pytz from rocketpy.environment.fetchers import ( + fetch_aigfs_file_return_dataset, fetch_atmospheric_data_from_windy, fetch_gefs_ensemble, fetch_gfs_file_return_dataset, fetch_hiresw_file_return_dataset, + fetch_hrrr_file_return_dataset, fetch_nam_file_return_dataset, fetch_open_elevation, fetch_rap_file_return_dataset, @@ -369,9 +371,11 @@ def __initialize_constants(self): self.__weather_model_map = WeatherModelMapping() self.__atm_type_file_to_function_map = { "forecast": { + "AIGFS": fetch_aigfs_file_return_dataset, "GFS": fetch_gfs_file_return_dataset, "NAM": fetch_nam_file_return_dataset, "RAP": fetch_rap_file_return_dataset, + "HRRR": fetch_hrrr_file_return_dataset, "HIRESW": fetch_hiresw_file_return_dataset, }, "ensemble": { @@ -665,9 +669,11 @@ def __resolve_dictionary_for_dataset(self, dictionary, dataset): def __validate_dictionary(self, file, dictionary): # removed CMC until it is fixed. available_models = [ + "AIGFS", "GFS", "NAM", "RAP", + "HRRR", "HIRESW", "GEFS", "ERA5", @@ -1132,8 +1138,9 @@ def set_atmospheric_model( # pylint: disable=too-many-statements - ``"windy"``: one of ``"ECMWF"``, ``"GFS"``, ``"ICON"`` or ``"ICONEU"``. - ``"forecast"``: local path, OPeNDAP URL, open - ``netCDF4.Dataset``, or one of ``"GFS"``, ``"NAM"`` or ``"RAP"`` - for the latest available forecast. + ``netCDF4.Dataset``, or one of ``"AIGFS"``, ``"GFS"``, + ``"NAM"``, ``"RAP"``, ``"HRRR"`` or ``"HIRESW"`` for the + latest available forecast. - ``"reanalysis"``: local path, OPeNDAP URL, or open ``netCDF4.Dataset``. - ``"ensemble"``: local path, OPeNDAP URL, open @@ -1143,8 +1150,9 @@ def set_atmospheric_model( # pylint: disable=too-many-statements Variable-name mapping for ``"forecast"``, ``"reanalysis"`` and ``"ensemble"``. It may be a custom dictionary or a built-in mapping name (for example: ``"ECMWF"``, ``"ECMWF_v0"``, - ``"NOAA"``, ``"GFS"``, ``"NAM"``, ``"RAP"``, ``"HIRESW"``, - ``"GEFS"``, ``"MERRA2"`` or ``"CMC"``). + ``"NOAA"``, ``"AIGFS"``, ``"GFS"``, ``"NAM"``, ``"RAP"``, + ``"HRRR"``, ``"HIRESW"``, ``"GEFS"``, ``"MERRA2"`` or + ``"CMC"``). If ``dictionary`` is omitted and ``file`` is one of RocketPy's latest-model shortcuts, the matching built-in mapping is selected @@ -1761,13 +1769,17 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- # Some THREDDS datasets use projected x/y coordinates. if dictionary.get("projection") is not None: projection_variable = data.variables[dictionary["projection"]] - x_units = getattr(lon_array, "units", "m") - target_lon, target_lat = geodesic_to_lambert_conformal( - self.latitude, - self.longitude, - projection_variable, - x_units=x_units, - ) + if dictionary.get("projection") == "LambertConformal_Projection": + x_units = getattr(lon_array, "units", "m") + target_lon, target_lat = geodesic_to_lambert_conformal( + self.latitude, + self.longitude, + projection_variable, + x_units=x_units, + ) + else: + target_lon = self.longitude + target_lat = self.latitude else: target_lon = self.longitude target_lat = self.latitude @@ -2065,13 +2077,17 @@ class for some dictionary examples. # coordinate system before locating the nearest grid cell. if dictionary.get("projection") is not None: projection_variable = data.variables[dictionary["projection"]] - x_units = getattr(lon_array, "units", "m") - target_lon, target_lat = geodesic_to_lambert_conformal( - self.latitude, - self.longitude, - projection_variable, - x_units=x_units, - ) + if dictionary.get("projection") == "LambertConformal_Projection": + x_units = getattr(lon_array, "units", "m") + target_lon, target_lat = geodesic_to_lambert_conformal( + self.latitude, + self.longitude, + projection_variable, + x_units=x_units, + ) + else: + target_lon = self.longitude + target_lat = self.latitude else: target_lon = self.longitude target_lat = self.latitude diff --git a/rocketpy/environment/fetchers.py b/rocketpy/environment/fetchers.py index de63d53ad..64d5dd479 100644 --- a/rocketpy/environment/fetchers.py +++ b/rocketpy/environment/fetchers.py @@ -195,6 +195,78 @@ def fetch_rap_file_return_dataset(max_attempts=10, base_delay=2): raise RuntimeError("Unable to load latest weather data for RAP through " + file_url) +def fetch_hrrr_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest HRRR (High-Resolution Rapid Refresh) dataset from + the NOAA's GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The HRRR dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for HRRR. + """ + file_url = "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/HRRR/CONUS_2p5km/Best" + attempt_count = 0 + while attempt_count < max_attempts: + try: + return netCDF4.Dataset(file_url) + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + raise RuntimeError( + "Unable to load latest weather data for HRRR through " + file_url + ) + + +def fetch_aigfs_file_return_dataset(max_attempts=10, base_delay=2): + """Fetches the latest AIGFS (Artificial Intelligence GFS) dataset from + the NOAA's GrADS data server using the OpenDAP protocol. + + Parameters + ---------- + max_attempts : int, optional + The maximum number of attempts to fetch the dataset. Default is 10. + base_delay : int, optional + The base delay in seconds between attempts. Default is 2. + + Returns + ------- + netCDF4.Dataset + The AIGFS dataset. + + Raises + ------ + RuntimeError + If unable to load the latest weather data for AIGFS. + """ + file_url = ( + "https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/AIGFS/Global_0p25deg/Best" + ) + attempt_count = 0 + while attempt_count < max_attempts: + try: + return netCDF4.Dataset(file_url) + except OSError: + attempt_count += 1 + time.sleep(base_delay**attempt_count) + + raise RuntimeError( + "Unable to load latest weather data for AIGFS through " + file_url + ) + + def fetch_hiresw_file_return_dataset(max_attempts=10, base_delay=2): """Fetches the latest HiResW (High-Resolution Window) dataset from the NOAA's GrADS data server using the OpenDAP protocol. diff --git a/rocketpy/environment/weather_model_mapping.py b/rocketpy/environment/weather_model_mapping.py index b054a35c4..c8617a523 100644 --- a/rocketpy/environment/weather_model_mapping.py +++ b/rocketpy/environment/weather_model_mapping.py @@ -48,11 +48,11 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } - NAM = { + AIGFS = { "time": "time", - "latitude": "y", - "longitude": "x", - "projection": "LambertConformal_Projection", + "latitude": "lat", + "longitude": "lon", + "projection": "LatLon_Projection", "level": "isobaric", "temperature": "Temperature_isobaric", "surface_geopotential_height": None, @@ -73,6 +73,19 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } + NAM = { + "time": "time", + "latitude": "y", + "longitude": "x", + "projection": "LambertConformal_Projection", + "level": "isobaric", + "temperature": "Temperature_isobaric", + "surface_geopotential_height": None, + "geopotential_height": "Geopotential_height_isobaric", + "geopotential": None, + "u_wind": "u-component_of_wind_isobaric", + "v_wind": "v-component_of_wind_isobaric", + } ECMWF_v0 = { "time": "time", "latitude": "latitude", @@ -148,20 +161,20 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } - CMC = { + HRRR = { "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "ensemble": "ens", - "temperature": "tmpprs", + "latitude": "y", + "longitude": "x", + "projection": "LambertConformal_Projection", + "level": "isobaric", + "temperature": "Temperature_isobaric", "surface_geopotential_height": None, - "geopotential_height": "hgtprs", + "geopotential_height": "Geopotential_height_isobaric", "geopotential": None, - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", + "u_wind": "u-component_of_wind_isobaric", + "v_wind": "v-component_of_wind_isobaric", } - CMC_LEGACY = { + CMC = { "time": "time", "latitude": "lat", "longitude": "lon", @@ -211,17 +224,6 @@ class WeatherModelMapping: "u_wind": "ugrdprs", "v_wind": "vgrdprs", } - HIRESW_LEGACY = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "tmpprs", - "surface_geopotential_height": "hgtsfc", - "geopotential_height": "hgtprs", - "u_wind": "ugrdprs", - "v_wind": "vgrdprs", - } MERRA2 = { "time": "time", "latitude": "lat", @@ -235,19 +237,6 @@ class WeatherModelMapping: "u_wind": "U", "v_wind": "V", } - MERRA2_LEGACY = { - "time": "time", - "latitude": "lat", - "longitude": "lon", - "level": "lev", - "temperature": "T", - "surface_geopotential_height": None, - "surface_geopotential": "PHIS", - "geopotential_height": "H", - "geopotential": None, - "u_wind": "U", - "v_wind": "V", - } def __init__(self): """Build the lookup table with default and legacy mapping aliases.""" @@ -255,6 +244,7 @@ def __init__(self): self.all_dictionaries = { "GFS": self.GFS, "GFS_LEGACY": self.GFS_LEGACY, + "AIGFS": self.AIGFS, "NAM": self.NAM, "NAM_LEGACY": self.NAM_LEGACY, "ECMWF_v0": self.ECMWF_v0, @@ -263,14 +253,12 @@ def __init__(self): "NOAA_LEGACY": self.NOAA_LEGACY, "RAP": self.RAP, "RAP_LEGACY": self.RAP_LEGACY, + "HRRR": self.HRRR, "CMC": self.CMC, - "CMC_LEGACY": self.CMC_LEGACY, "GEFS": self.GEFS, "GEFS_LEGACY": self.GEFS_LEGACY, "HIRESW": self.HIRESW, - "HIRESW_LEGACY": self.HIRESW_LEGACY, "MERRA2": self.MERRA2, - "MERRA2_LEGACY": self.MERRA2_LEGACY, } def get(self, model): diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py index 5802650dc..ff752d6c8 100644 --- a/tests/integration/environment/test_environment.py +++ b/tests/integration/environment/test_environment.py @@ -1,5 +1,5 @@ import time -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from unittest.mock import patch import numpy as np @@ -146,6 +146,7 @@ def test_wind_plots_wrapping_direction(mock_show, example_plain_env): # pylint: assert example_plain_env.plots.atmospheric_model() is None +@pytest.mark.slow @pytest.mark.parametrize( "model_name", [ @@ -195,6 +196,42 @@ def test_gfs_atmosphere(mock_show, example_spaceport_env): # pylint: disable=un assert example_spaceport_env.all_info() is None +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +def test_aigfs_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument + """Tests the Forecast model with the AIGFS file. + + Parameters + ---------- + mock_show : mock + Mock object to replace matplotlib.pyplot.show() method. + example_spaceport_env : rocketpy.Environment + Example environment object to be tested. + """ + example_spaceport_env.set_atmospheric_model(type="Forecast", file="AIGFS") + assert example_spaceport_env.all_info() is None + + +@pytest.mark.slow +@patch("matplotlib.pyplot.show") +def test_hrrr_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument + """Tests the Forecast model with the HRRR file. + + Parameters + ---------- + mock_show : mock + Mock object to replace matplotlib.pyplot.show() method. + example_spaceport_env : rocketpy.Environment + Example environment object to be tested. + """ + # Sometimes the HRRR latest-model can fail due to not having at least 24 + # hours in the future in the forecast, so we try with 12 hours in the future + # only. + example_spaceport_env.set_date(datetime.now() + timedelta(hours=12)) + example_spaceport_env.set_atmospheric_model(type="Forecast", file="HRRR") + assert example_spaceport_env.all_info() is None + + @pytest.mark.slow @patch("matplotlib.pyplot.show") def test_nam_atmosphere(mock_show, example_spaceport_env): # pylint: disable=unused-argument diff --git a/tests/unit/environment/test_environment.py b/tests/unit/environment/test_environment.py index beb6d5ac6..8d30e4606 100644 --- a/tests/unit/environment/test_environment.py +++ b/tests/unit/environment/test_environment.py @@ -336,8 +336,8 @@ def test_resolve_dictionary_keeps_compatible_mapping(example_plain_env): assert resolved is gfs_mapping -def test_resolve_dictionary_falls_back_to_legacy_mapping(example_plain_env): - """Fallback to a compatible built-in mapping for legacy NOMADS-style files.""" +def test_resolve_dictionary_falls_back_to_first_compatible_mapping(example_plain_env): + """Fallback to the first compatible built-in mapping for legacy-style files.""" thredds_gfs_mapping = example_plain_env._Environment__weather_model_map.get("GFS") dataset = _DummyDataset( [ @@ -356,7 +356,6 @@ def test_resolve_dictionary_falls_back_to_legacy_mapping(example_plain_env): thredds_gfs_mapping, dataset ) - # Explicit legacy mappings should be preferred over unrelated model mappings. assert resolved == example_plain_env._Environment__weather_model_map.get( "GFS_LEGACY" ) @@ -602,3 +601,51 @@ def test_set_atmospheric_model_raises_for_unknown_model_type(example_plain_env): # Act / Assert with pytest.raises(ValueError, match="Unknown model type"): environment.set_atmospheric_model(type="unknown_type") + + +@pytest.mark.parametrize("shortcut_name", ["AIGFS", "HRRR"]) +def test_forecast_shortcut_and_dictionary_are_case_insensitive( + monkeypatch, shortcut_name +): + """Ensure forecast shortcuts and built-in dictionaries ignore input casing.""" + # Arrange + env = Environment(date=(2026, 3, 17, 12), latitude=32.99, longitude=-106.97) + + sentinel_dataset = object() + env._Environment__atm_type_file_to_function_map["forecast"][shortcut_name] = ( + lambda: sentinel_dataset + ) + + captured = {} + + def fake_process_forecast_reanalysis(file, dictionary): + captured["file"] = file + captured["dictionary"] = dictionary + + monkeypatch.setattr( + env, "process_forecast_reanalysis", fake_process_forecast_reanalysis + ) + monkeypatch.setattr(env, "calculate_density_profile", lambda: None) + monkeypatch.setattr(env, "calculate_speed_of_sound_profile", lambda: None) + monkeypatch.setattr(env, "calculate_dynamic_viscosity", lambda: None) + + # Act + env.set_atmospheric_model( + type="forecast", + file=shortcut_name.lower(), + dictionary=shortcut_name.lower(), + ) + + # Assert + expected_dictionary = env._Environment__weather_model_map.get(shortcut_name) + assert captured["file"] is sentinel_dataset + assert captured["dictionary"] == expected_dictionary + assert env.atmospheric_model_file == shortcut_name + assert env.atmospheric_model_dict == expected_dictionary + + +def test_weather_model_mapping_get_is_case_insensitive(): + """Ensure built-in mapping names are resolved regardless of casing.""" + mapping = WeatherModelMapping() + assert mapping.get("aigfs") == mapping.get("AIGFS") + assert mapping.get("ecmwf_v0") == mapping.get("ECMWF_v0") From 7f85c072498b183a7d11cdac433f4cdd636dcf27 Mon Sep 17 00:00:00 2001 From: MateusStano Date: Mon, 6 Apr 2026 20:11:17 -0300 Subject: [PATCH 20/22] DEV: correct auto-changelog access token --- .github/workflows/changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 483b0604f..528ca1729 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -19,7 +19,7 @@ jobs: with: repository: RocketPy-Team/RocketPy ref: develop - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.RELEASE_TOKEN }} - name: Update Changelog env: From 642e3070bccd95d126a6db0271a344347b86090f Mon Sep 17 00:00:00 2001 From: Lucas Prates <57069366+Lucas-Prates@users.noreply.github.com> Date: Tue, 26 May 2026 22:25:17 -0300 Subject: [PATCH 21/22] MNT: introduce pressure unit conversion when using forecast/reanalysis/ensemble data (#955) * MNT: remove conversion from mbar to Pa in netcdf4 fetched data * ENH: introducing pressure conversion factor when using forecast or reanalysis * fix: adding conversion factor argument to ensemble as well --- rocketpy/environment/environment.py | 49 ++++++++++++++++--- rocketpy/environment/tools.py | 8 +-- tests/acceptance/test_bella_lui_rocket.py | 1 + tests/acceptance/test_ndrt_2020_rocket.py | 1 + .../environment/environment_fixtures.py | 1 + .../environment/test_environment.py | 1 + tests/unit/environment/test_environment.py | 6 ++- tests/unit/test_tools.py | 37 ++++++++++++++ 8 files changed, 93 insertions(+), 11 deletions(-) diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index a2ed22370..3b585fd44 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -1120,6 +1120,7 @@ def set_atmospheric_model( # pylint: disable=too-many-statements temperature=None, wind_u=0, wind_v=0, + pressure_conversion_factor="Pa", ): """Define the atmospheric model for this Environment. @@ -1216,6 +1217,12 @@ def set_atmospheric_model( # pylint: disable=too-many-statements m/s). Finally, a callable or function is also accepted. The function should take one argument, the height above sea level in meters and return a corresponding wind-v in m/s. + pressure_conversion_factor : string, int, float + This defines the pressure conversion factor to Pa when type is + ``forecast`` or ``reanalysis``. The pressure unit from the data may + not be in Pascal, so the correction is necessary. Valid strings are + ("mbar", "hPa", "Pa"), or a strictly positive number if using a + custom pressure unit. Returns ------- @@ -1265,6 +1272,28 @@ def set_atmospheric_model( # pylint: disable=too-many-statements case "windy": self.process_windy_atmosphere(file) case "forecast" | "reanalysis" | "ensemble": + conversion_factor = 1 + if not isinstance(pressure_conversion_factor, (float, int, str)): + raise ValueError( + "Argument 'pressure_conversion_factor' must be numeric or a standard pressure unit ('mbar', 'hPa', 'Pa')!" + ) + if isinstance(pressure_conversion_factor, (float, int)): + if pressure_conversion_factor <= 0: + raise ValueError( + "Argument 'pressure_conversion_factor' must be strictly positive!" + ) + else: + conversion_factor = pressure_conversion_factor + if isinstance(pressure_conversion_factor, str): + if pressure_conversion_factor.lower() in ("mbar", "hpa"): + conversion_factor = 100 + elif pressure_conversion_factor.lower() == "pa": + conversion_factor = 1 + else: + raise ValueError( + "Argument 'pressure_conversion_factor' unit must be a standard pressure unit ('mbar', 'hPa', 'Pa')!" + ) + if isinstance(file, str): shortcut_map = self.__atm_type_file_to_function_map.get(type, {}) matching_shortcut = next( @@ -1305,9 +1334,11 @@ def set_atmospheric_model( # pylint: disable=too-many-statements dataset = fetch_function() if fetch_function is not None else file if type in ["forecast", "reanalysis"]: - self.process_forecast_reanalysis(dataset, dictionary) + self.process_forecast_reanalysis( + dataset, dictionary, conversion_factor=conversion_factor + ) else: - self.process_ensemble(dataset, dictionary) + self.process_ensemble(dataset, dictionary, conversion_factor) case _: # pragma: no cover raise ValueError(f"Unknown model type '{type}'.") @@ -1686,7 +1717,7 @@ def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements # Save maximum expected height self._max_expected_height = data_array[-1, 1] - def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too-many-locals,too-many-statements + def process_forecast_reanalysis(self, file, dictionary, conversion_factor): # pylint: disable=too-many-locals,too-many-statements """Import and process atmospheric data from weather forecasts and reanalysis given as ``netCDF`` or ``OPeNDAP`` files. Sets pressure, temperature, wind-u and wind-v @@ -1738,6 +1769,9 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- "u_wind": "ugrdprs", "v_wind": "vgrdprs", } + conversion_factor : float, int + Specifies the factor by which the pressure will be multiplied + in order to transform it to Pascal. Returns ------- @@ -1790,7 +1824,7 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- _, lat_index = find_latitude_index(target_lat, lat_array) # Get pressure level data from file - levels = get_pressure_levels_from_file(data, dictionary) + levels = get_pressure_levels_from_file(data, dictionary, conversion_factor) # Get geopotential data from file try: @@ -1991,7 +2025,7 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- # Close weather data data.close() - def process_ensemble(self, file, dictionary): # pylint: disable=too-many-locals,too-many-statements + def process_ensemble(self, file, dictionary, conversion_factor): # pylint: disable=too-many-locals,too-many-statements """Import and process atmospheric data from weather ensembles given as ``netCDF`` or ``OPeNDAP`` files. Sets pressure, temperature, wind-u and wind-v profiles and surface elevation obtained from a weather @@ -2042,6 +2076,9 @@ def process_ensemble(self, file, dictionary): # pylint: disable=too-many-locals "u_wind": "ugrdprs", "v_wind": "vgrdprs", } + conversion_factor : float, int + Specifies the factor by which the pressure will be multiplied + in order to transform it to Pascal. See also -------- @@ -2106,7 +2143,7 @@ class for some dictionary examples. num_members = 1 # Get pressure level data from file - levels = get_pressure_levels_from_file(data, dictionary) + levels = get_pressure_levels_from_file(data, dictionary, conversion_factor) inverse_dictionary = {v: k for k, v in dictionary.items()} param_dictionary = { diff --git a/rocketpy/environment/tools.py b/rocketpy/environment/tools.py index 4a06ef2c4..816d9dd12 100644 --- a/rocketpy/environment/tools.py +++ b/rocketpy/environment/tools.py @@ -169,7 +169,7 @@ def geodesic_to_lambert_conformal(lat, lon, projection_variable, x_units="m"): ## These functions are meant to be used with netcdf4 datasets -def get_pressure_levels_from_file(data, dictionary): +def get_pressure_levels_from_file(data, dictionary, conversion_factor): """Extracts pressure levels from a netCDF4 dataset and converts them to Pa. Parameters @@ -178,6 +178,9 @@ def get_pressure_levels_from_file(data, dictionary): The netCDF4 dataset containing the pressure level data. dictionary : dict A dictionary mapping variable names to dataset keys. + conversion_factor : float, int + Specifies the factor by which the pressure will be multiplied + in order to transform it to Pascal. Returns ------- @@ -190,8 +193,7 @@ def get_pressure_levels_from_file(data, dictionary): If the pressure levels cannot be read from the file. """ try: - # Convert mbar to Pa - levels = 100 * data.variables[dictionary["level"]][:] + levels = conversion_factor * data.variables[dictionary["level"]][:] except KeyError as e: raise ValueError( "Unable to read pressure levels from file. Check file and dictionary." diff --git a/tests/acceptance/test_bella_lui_rocket.py b/tests/acceptance/test_bella_lui_rocket.py index bcfe325bc..cdccb67d8 100644 --- a/tests/acceptance/test_bella_lui_rocket.py +++ b/tests/acceptance/test_bella_lui_rocket.py @@ -67,6 +67,7 @@ def test_bella_lui_rocket_data_asserts_acceptance(): type="Reanalysis", file="data/weather/bella_lui_weather_data_ERA5.nc", dictionary="ECMWF", + pressure_conversion_factor="hPa", ) env.max_expected_height = 2000 diff --git a/tests/acceptance/test_ndrt_2020_rocket.py b/tests/acceptance/test_ndrt_2020_rocket.py index 7874ee164..04dae8725 100644 --- a/tests/acceptance/test_ndrt_2020_rocket.py +++ b/tests/acceptance/test_ndrt_2020_rocket.py @@ -83,6 +83,7 @@ def test_ndrt_2020_rocket_data_asserts_acceptance(env_file): type="Reanalysis", file=env_file, dictionary="ECMWF", + pressure_conversion_factor="hPa", ) env.max_expected_height = 2000 diff --git a/tests/fixtures/environment/environment_fixtures.py b/tests/fixtures/environment/environment_fixtures.py index 818f434c7..5a3f3cd64 100644 --- a/tests/fixtures/environment/environment_fixtures.py +++ b/tests/fixtures/environment/environment_fixtures.py @@ -111,6 +111,7 @@ def environment_spaceport_america_2023(): type="Reanalysis", file="data/weather/spaceport_america_pressure_levels_2023_hourly.nc", dictionary="ECMWF", + pressure_conversion_factor="hPa", ) env.max_expected_height = 6000 diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py index ff752d6c8..271f7574e 100644 --- a/tests/integration/environment/test_environment.py +++ b/tests/integration/environment/test_environment.py @@ -45,6 +45,7 @@ def test_era5_atmosphere(mock_show, example_spaceport_env): # pylint: disable=u type="Reanalysis", file="data/weather/SpaceportAmerica_2018_ERA-5.nc", dictionary="ECMWF", + pressure_conversion_factor="hPa", ) assert example_spaceport_env.all_info() is None diff --git a/tests/unit/environment/test_environment.py b/tests/unit/environment/test_environment.py index 8d30e4606..222eb9a2d 100644 --- a/tests/unit/environment/test_environment.py +++ b/tests/unit/environment/test_environment.py @@ -577,9 +577,10 @@ def test_set_atmospheric_model_normalizes_shortcut_case_for_forecast(example_pla called_arguments = {} - def fake_process_forecast_reanalysis(dataset, dictionary): + def fake_process_forecast_reanalysis(dataset, dictionary, conversion_factor): called_arguments["dataset"] = dataset called_arguments["dictionary"] = dictionary + called_arguments["conversion_factr"] = conversion_factor environment.process_forecast_reanalysis = fake_process_forecast_reanalysis @@ -618,9 +619,10 @@ def test_forecast_shortcut_and_dictionary_are_case_insensitive( captured = {} - def fake_process_forecast_reanalysis(file, dictionary): + def fake_process_forecast_reanalysis(file, dictionary, conversion_factor): captured["file"] = file captured["dictionary"] = dictionary + captured["conversion_factor"] = conversion_factor monkeypatch.setattr( env, "process_forecast_reanalysis", fake_process_forecast_reanalysis diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index fcf67ad37..a1e96eb9e 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -1,6 +1,7 @@ import numpy as np import pytest +from rocketpy import Environment from rocketpy.tools import ( calculate_cubic_hermite_coefficients, euler313_to_quaternions, @@ -100,3 +101,39 @@ def test_tuple_handler(input_value, expected_output): def test_tuple_handler_exceptions(input_value, expected_exception): with pytest.raises(expected_exception): tuple_handler(input_value) + + +@pytest.mark.parametrize("pressure_conversion_factor", ["hPa", "mbar", "Pa", 100]) +def test_valid_pressure_conversion_factor(pressure_conversion_factor): + env = Environment( + gravity=9.81, + latitude=47.213476, + longitude=9.003336, + date=(2020, 2, 22, 13), + elevation=407, + ) + env.set_atmospheric_model( + type="Reanalysis", + file="data/weather/bella_lui_weather_data_ERA5.nc", + dictionary="ECMWF", + pressure_conversion_factor=pressure_conversion_factor, + ) + + +@pytest.mark.parametrize("pressure_conversion_factor", [-1, "mPa"]) +def test_invalid_pressure_conversion_factor(pressure_conversion_factor): + env = Environment( + gravity=9.81, + latitude=47.213476, + longitude=9.003336, + date=(2020, 2, 22, 13), + elevation=407, + ) + + with pytest.raises(ValueError): + env.set_atmospheric_model( + type="Reanalysis", + file="data/weather/bella_lui_weather_data_ERA5.nc", + dictionary="ECMWF", + pressure_conversion_factor=pressure_conversion_factor, + ) From f0fb5c2ea2ea794c26a33b3e14cd933bd3f6a297 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 May 2026 01:25:32 +0000 Subject: [PATCH 22/22] DOC: Update Changelog for PR #955 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49913dfbb..1cdd8479c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Attention: The newest changes should be on top --> ### Added +- ENH: MNT: introduce pressure unit conversion when using forecast/reanalysis/ensemble data [#955](https://github.com/RocketPy-Team/RocketPy/pull/955) - ENH: Auto Populate Changelog [#919](https://github.com/RocketPy-Team/RocketPy/pull/919) - ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) - TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914](https://github.com/RocketPy-Team/RocketPy/pull/914)