diff --git a/CHANGELOG.md b/CHANGELOG.md index d85ddb110..91977f485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,8 +55,10 @@ 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 parallel Monte Carlo simulation showing incorrect iteration count [#806](https://github.com/RocketPy-Team/RocketPy/pull/806) - BUG: Fix CSV column header spacing in FlightDataExporter [#864](https://github.com/RocketPy-Team/RocketPy/issues/864) - BUG: Fix parallel Monte Carlo simulation showing incorrect iteration count [#806](https://github.com/RocketPy-Team/RocketPy/pull/806) - BUG: Duplicate _controllers in Flight.TimeNodes.merge() [#931](https://github.com/RocketPy-Team/RocketPy/pull/931) diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index 83b0ce0fd..7ced05105 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,7 +124,8 @@ def __init__( sampling_rate, lag=0, noise=(0, 0, 0), - radius=1.5, + radius=None, + drag_coefficient=1.4, height=None, porosity=0.0432, ): @@ -172,18 +181,33 @@ 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. + 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. 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). """ self.name = name self.cd_s = cd_s @@ -200,8 +224,14 @@ 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.drag_coefficient = drag_coefficient + # Estimate radius from cd_s if not provided. + # cd_s = Cd * S = Cd * π * R² => R = sqrt(cd_s / (Cd * π)) + if radius is None: + self.radius = np.sqrt(cd_s / (drag_coefficient * np.pi)) + else: + self.radius = radius + self.height = height or self.radius self.porosity = porosity self.added_mass_coefficient = 1.068 * ( 1 @@ -309,6 +339,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 +372,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 93ab46321..ed76c1fdc 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1488,7 +1488,8 @@ def add_parachute( sampling_rate=100, lag=0, noise=(0, 0, 0), - radius=1.5, + radius=None, + drag_coefficient=1.4, height=None, porosity=0.0432, ): @@ -1550,26 +1551,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. + 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. 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. 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, @@ -1579,6 +1588,7 @@ def add_parachute( lag, noise, radius, + drag_coefficient, height, porosity, ) 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/unit/rocket/test_parachute.py b/tests/unit/rocket/test_parachute.py new file mode 100644 index 000000000..e5191cea6 --- /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 = dict( + 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 = dict( + 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)