From 61e414a1c6addc8ab24fc6666ab459808b2aa6c9 Mon Sep 17 00:00:00 2001 From: Matan Date: Sun, 5 Jul 2026 15:02:18 +0300 Subject: [PATCH] ENH: Add opening shock force estimation to Parachute class Adds an opening_shock_coefficient parameter and a calculate_opening_shock_force method to estimate the peak transient force during parachute inflation, following the simplified model in Knacke's Parachute Recovery Systems Design Manual (1992, Section 5.5). Closes #161 --- CHANGELOG.md | 2 +- rocketpy/rocket/parachute.py | 48 +++++++++++++++++++ tests/unit/rocket/test_parachute.py | 71 +++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 183479c4b..ae02b69c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ Attention: The newest changes should be on top --> ### Added -- +- ENH: Add opening_shock_coefficient parameter and calculate_opening_shock_force method to Parachute class. [#161](https://github.com/RocketPy-Team/RocketPy/issues/161) ### Changed diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index 4e0318d18..1c3be4957 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -114,6 +114,11 @@ class Parachute: 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. + Parachute.opening_shock_coefficient : float + Empirical coefficient (commonly noted Cx) used to estimate the peak + transient force experienced during parachute inflation. Typical + values range from 1.2 to 2.0 depending on the deployment method and + canopy type. Default value is 1.5. """ def __init__( @@ -128,6 +133,7 @@ def __init__( height=None, porosity=0.0432, drag_coefficient=1.4, + opening_shock_coefficient=1.5, ): """Initializes Parachute class. @@ -208,6 +214,12 @@ def __init__( - **1.5** — extended-skirt canopy Has no effect when ``radius`` is explicitly provided. + opening_shock_coefficient : float, optional + Empirical coefficient (commonly noted Cx) used to estimate the + peak transient force experienced during parachute inflation via + :meth:`calculate_opening_shock_force`. Typical values range from + 1.2 to 2.0 depending on the deployment method and canopy type. + Default value is 1.5. """ # Save arguments as attributes @@ -219,6 +231,7 @@ def __init__( self.noise = noise self.drag_coefficient = drag_coefficient self.porosity = porosity + self.opening_shock_coefficient = opening_shock_coefficient # Initialize derived attributes self.radius = self.__resolve_radius(radius, cd_s, drag_coefficient) @@ -250,6 +263,39 @@ def __compute_added_mass_coefficient(self, porosity): 1 - 1.465 * porosity - 0.25975 * porosity**2 + 1.2626 * porosity**3 ) + def calculate_opening_shock_force(self, air_density, velocity): + """Estimates the peak transient force experienced by the recovery + hardware during parachute inflation (the "opening shock"). + + The estimate follows the simplified model described in Knacke's + "Parachute Recovery Systems Design Manual" (1992, Section 5.5): + + .. math:: + + F_0 = C_x \\cdot C_{d} S \\cdot q + + where :math:`C_x` is the ``opening_shock_coefficient``, + :math:`C_{d} S` is the parachute's ``cd_s``, and :math:`q` is the + dynamic pressure (:math:`q = \\tfrac{1}{2} \\rho V^2`) at the instant + the canopy begins to inflate. + + Parameters + ---------- + air_density : float + Freestream air density, in kg/m^3, at the moment of parachute + deployment. + velocity : float + Freestream velocity relative to the rocket, in m/s, at the moment + of parachute deployment. + + Returns + ------- + float + Estimated peak opening shock force, in Newtons. + """ + dynamic_pressure = 0.5 * air_density * velocity**2 + return self.opening_shock_coefficient * self.cd_s * dynamic_pressure + def __init_noise(self, noise): """Initializes all noise-related attributes. @@ -369,6 +415,7 @@ def to_dict(self, **kwargs): "drag_coefficient": self.drag_coefficient, "height": self.height, "porosity": self.porosity, + "opening_shock_coefficient": self.opening_shock_coefficient, } if kwargs.get("include_outputs", False): @@ -403,6 +450,7 @@ def from_dict(cls, data): drag_coefficient=data.get("drag_coefficient", 1.4), height=data.get("height", None), porosity=data.get("porosity", 0.0432), + opening_shock_coefficient=data.get("opening_shock_coefficient", 1.5), ) return parachute diff --git a/tests/unit/rocket/test_parachute.py b/tests/unit/rocket/test_parachute.py index e193b777b..f66c4ee9a 100644 --- a/tests/unit/rocket/test_parachute.py +++ b/tests/unit/rocket/test_parachute.py @@ -109,3 +109,74 @@ def test_from_dict_defaults_drag_coefficient_to_1_4_when_absent(self): } parachute = Parachute.from_dict(data) assert parachute.drag_coefficient == pytest.approx(1.4) + + +class TestParachuteOpeningShockForce: + """Tests for the opening_shock_coefficient parameter and + calculate_opening_shock_force method, addressing issue #161.""" + + def test_opening_shock_coefficient_default_is_1_5(self): + """Default opening_shock_coefficient must be 1.5.""" + parachute = _make_parachute() + assert parachute.opening_shock_coefficient == pytest.approx(1.5) + + def test_opening_shock_coefficient_stored_on_instance(self): + """opening_shock_coefficient must be stored as given.""" + parachute = _make_parachute(opening_shock_coefficient=1.8) + assert parachute.opening_shock_coefficient == pytest.approx(1.8) + + def test_calculate_opening_shock_force_matches_formula(self): + """calculate_opening_shock_force must return + Cx * cd_s * 0.5 * rho * V^2.""" + cd_s = 10.0 + cx = 1.6 + air_density = 1.225 + velocity = 50.0 + parachute = _make_parachute(cd_s=cd_s, opening_shock_coefficient=cx) + + expected_force = cx * cd_s * 0.5 * air_density * velocity**2 + assert parachute.calculate_opening_shock_force( + air_density, velocity + ) == pytest.approx(expected_force, rel=1e-9) + + def test_calculate_opening_shock_force_scales_with_velocity_squared(self): + """Doubling velocity must quadruple the opening shock force.""" + parachute = _make_parachute() + force_v = parachute.calculate_opening_shock_force(1.225, 40.0) + force_2v = parachute.calculate_opening_shock_force(1.225, 80.0) + assert force_2v == pytest.approx(4 * force_v, rel=1e-9) + + def test_calculate_opening_shock_force_zero_velocity_is_zero(self): + """No dynamic pressure means no opening shock force.""" + parachute = _make_parachute() + assert parachute.calculate_opening_shock_force(1.225, 0.0) == pytest.approx(0.0) + + def test_to_dict_includes_opening_shock_coefficient(self): + """to_dict must include the opening_shock_coefficient key.""" + parachute = _make_parachute(opening_shock_coefficient=1.8) + data = parachute.to_dict() + assert "opening_shock_coefficient" in data + assert data["opening_shock_coefficient"] == 1.8 + + def test_from_dict_round_trip_preserves_opening_shock_coefficient(self): + """A Parachute serialized to dict and restored must have the same + opening_shock_coefficient.""" + original = _make_parachute(cd_s=5.0, opening_shock_coefficient=1.8) + data = original.to_dict() + restored = Parachute.from_dict(data) + assert restored.opening_shock_coefficient == pytest.approx(1.8) + + def test_from_dict_defaults_opening_shock_coefficient_to_1_5_when_absent(self): + """Dicts serialized before opening_shock_coefficient was added (no + key) must fall back to 1.5 for backward compatibility.""" + data = { + "name": "legacy", + "cd_s": 10.0, + "trigger": "apogee", + "sampling_rate": 100, + "lag": 0, + "noise": (0, 0, 0), + # no opening_shock_coefficient key — simulates old serialized data + } + parachute = Parachute.from_dict(data) + assert parachute.opening_shock_coefficient == pytest.approx(1.5)