Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 48 additions & 16 deletions rocketpy/rocket/parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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),
)
Expand Down
32 changes: 21 additions & 11 deletions rocketpy/rocket/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -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,
Expand All @@ -1579,6 +1588,7 @@ def add_parachute(
lag,
noise,
radius,
drag_coefficient,
height,
porosity,
)
Expand Down
9 changes: 9 additions & 0 deletions rocketpy/stochastic/stochastic_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,6 +49,7 @@ def __init__(
lag=None,
noise=None,
radius=None,
drag_coefficient=None,
height=None,
porosity=None,
):
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -100,6 +108,7 @@ def __init__(
noise=noise,
name=None,
radius=radius,
drag_coefficient=drag_coefficient,
height=height,
porosity=porosity,
)
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/rocket/test_parachute.py
Original file line number Diff line number Diff line change
@@ -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)
Loading