From cb3b0bc3ac5edfb0e9ee81886adfc1603e54c034 Mon Sep 17 00:00:00 2001 From: Matanski Date: Wed, 5 Aug 2026 15:28:31 +0300 Subject: [PATCH 1/4] ENH: Add opening shock force estimation to Parachute class (#1050) 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 Co-authored-by: ArthurJWH <167456467+ArthurJWH@users.noreply.github.com> --- rocketpy/rocket/parachute.py | 48 ++++++++++++++++++++ tests/unit/rocket/test_parachute.py | 69 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index da99743ce..ce6e947ed 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -123,6 +123,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__( @@ -137,6 +142,7 @@ def __init__( height=None, porosity=0.0432, drag_coefficient=1.4, + opening_shock_coefficient=1.5, ): """Initializes Parachute class. @@ -217,6 +223,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 @@ -228,6 +240,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) @@ -259,6 +272,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. @@ -431,6 +477,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): @@ -465,6 +512,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 7a61c2349..e4e6e7698 100644 --- a/tests/unit/rocket/test_parachute.py +++ b/tests/unit/rocket/test_parachute.py @@ -111,6 +111,75 @@ def test_from_dict_defaults_drag_coefficient_to_1_4_when_absent(self): 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) @pytest.mark.parametrize( "trigger, expects_udot", [ From 03be433fd84110cbb825dbbebd24c52938c134c1 Mon Sep 17 00:00:00 2001 From: ArthurJWH Date: Wed, 5 Aug 2026 09:08:46 -0400 Subject: [PATCH 2/4] ENH: Moving opening shock force function to utilities --- rocketpy/rocket/parachute.py | 48 -------------------- rocketpy/utilities.py | 44 ++++++++++++++++++ tests/unit/rocket/test_parachute.py | 69 ----------------------------- tests/unit/test_utilities.py | 35 +++++++++++++++ 4 files changed, 79 insertions(+), 117 deletions(-) diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index ce6e947ed..da99743ce 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -123,11 +123,6 @@ 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__( @@ -142,7 +137,6 @@ def __init__( height=None, porosity=0.0432, drag_coefficient=1.4, - opening_shock_coefficient=1.5, ): """Initializes Parachute class. @@ -223,12 +217,6 @@ 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 @@ -240,7 +228,6 @@ 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) @@ -272,39 +259,6 @@ 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. @@ -477,7 +431,6 @@ 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): @@ -512,7 +465,6 @@ 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/rocketpy/utilities.py b/rocketpy/utilities.py index 6dbd25380..c1a931d1f 100644 --- a/rocketpy/utilities.py +++ b/rocketpy/utilities.py @@ -780,3 +780,47 @@ def load_from_rpy(filename: str, resimulate=False): simulation = json.dumps(data["simulation"]) flight = json.loads(simulation, cls=RocketPyDecoder, resimulate=resimulate) return flight + + +def calculate_simplified_opening_shock_force( + cd_s, air_density, velocity, opening_shock_coefficient=1.5 +): + """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 + ---------- + cd_s : float + Drag coefficient times reference area of the parachute. + 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. + 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. + + Returns + ------- + float + Estimated peak opening shock force, in Newtons. + """ + dynamic_pressure = 0.5 * air_density * velocity**2 + return opening_shock_coefficient * cd_s * dynamic_pressure diff --git a/tests/unit/rocket/test_parachute.py b/tests/unit/rocket/test_parachute.py index e4e6e7698..7a61c2349 100644 --- a/tests/unit/rocket/test_parachute.py +++ b/tests/unit/rocket/test_parachute.py @@ -111,75 +111,6 @@ def test_from_dict_defaults_drag_coefficient_to_1_4_when_absent(self): 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) @pytest.mark.parametrize( "trigger, expects_udot", [ diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py index 146ff1be1..a6ed1f3eb 100644 --- a/tests/unit/test_utilities.py +++ b/tests/unit/test_utilities.py @@ -348,6 +348,41 @@ def test_load_from_rpy(mock_show): # pylint: disable=unused-argument assert loaded_flight.all_info() is None +def test_opening_shock_coefficient_default_is_1_5(): + """Default opening_shock_coefficient must be 1.5.""" + force_default = utilities.calculate_simplified_opening_shock_force(10.0, 1.225, 10) + force_1_5 = utilities.calculate_simplified_opening_shock_force(10.0, 1.225, 10, 1.5) + assert force_default == force_1_5 + + +def test_calculate_simplified_opening_shock_force_matches_formula(): + """calculate_simplified_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 + + expected_force = cx * cd_s * 0.5 * air_density * velocity**2 + assert utilities.calculate_simplified_opening_shock_force( + cd_s, air_density, velocity, cx + ) == pytest.approx(expected_force, rel=1e-9) + + +def test_calculate_simplified_opening_shock_force_scales_with_velocity_squared(): + """Doubling velocity must quadruple the opening shock force.""" + force_v = utilities.calculate_simplified_opening_shock_force(10.0, 1.225, 40.0) + force_2v = utilities.calculate_simplified_opening_shock_force(10.0, 1.225, 80.0) + assert force_2v == pytest.approx(4 * force_v, rel=1e-9) + + +def test_calculate_simplified_opening_shock_force_zero_velocity_is_zero(): + """No dynamic pressure means no opening shock force.""" + assert utilities.calculate_simplified_opening_shock_force( + 10.0, 1.225, 0.0 + ) == pytest.approx(0.0) + + # --- Logging (rocketpy.utilities.enable_logging) ------------------------------ From 10e2d2f0584abeb881a3c08e6dfb6a2c74ec209f Mon Sep 17 00:00:00 2001 From: ArthurJWH Date: Wed, 5 Aug 2026 09:18:30 -0400 Subject: [PATCH 3/4] DOC: Updated the CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af452f44b..a8886f642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Add simplified opening shock force estimation [#1092](https://github.com/RocketPy-Team/RocketPy/pull/1092) - ENH: Support for Meteomatics API in the `Environment` class [#1079](https://github.com/RocketPy-Team/RocketPy/pull/1079) - ENH: update master with develop [#1081](https://github.com/RocketPy-Team/RocketPy/pull/1081) From 0d320619922754b7946daab2c9327fc25c03f6f8 Mon Sep 17 00:00:00 2001 From: ArthurJWH Date: Sat, 8 Aug 2026 09:06:01 -0400 Subject: [PATCH 4/4] DOC: Removed cross-reference from previous Parachute method --- rocketpy/utilities.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rocketpy/utilities.py b/rocketpy/utilities.py index c1a931d1f..97ebe3f45 100644 --- a/rocketpy/utilities.py +++ b/rocketpy/utilities.py @@ -812,10 +812,9 @@ def calculate_simplified_opening_shock_force( of parachute deployment. 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. + 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. Returns -------