Skip to content

Commit 689d628

Browse files
committed
ENH: estimate parachute opening shock force (#161)
1 parent ce3342f commit 689d628

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

rocketpy/rocket/parachute.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ class Parachute:
123123
Parachute.added_mass_coefficient : float
124124
Coefficient used to calculate the added-mass due to dragged air. It is
125125
calculated from the porosity of the parachute.
126+
Parachute.opening_shock_coefficient : float
127+
Dimensionless opening-force coefficient (``Cx``) used to estimate the
128+
peak opening shock force via
129+
:meth:`Parachute.evaluate_opening_shock_force`. ``None`` when the
130+
estimate is disabled.
126131
"""
127132

128133
def __init__(
@@ -137,6 +142,7 @@ def __init__(
137142
height=None,
138143
porosity=0.0432,
139144
drag_coefficient=1.4,
145+
opening_shock_coefficient=None,
140146
):
141147
"""Initializes Parachute class.
142148
@@ -217,6 +223,15 @@ def __init__(
217223
- **1.5** — extended-skirt canopy
218224
219225
Has no effect when ``radius`` is explicitly provided.
226+
opening_shock_coefficient : float, optional
227+
Dimensionless opening-force coefficient (``Cx``) used to estimate
228+
the peak parachute opening shock force via
229+
:meth:`evaluate_opening_shock_force`, following Knacke's
230+
*Parachute Recovery Systems Design Manual*. It lumps together the
231+
opening-force coefficient and the infinite-mass factor (``X1``).
232+
Typical values range from about 1.2 to 2.0 depending on canopy
233+
type. If ``None`` (default), the opening shock force is not
234+
estimated. Units are dimensionless.
220235
"""
221236

222237
# Save arguments as attributes
@@ -228,6 +243,7 @@ def __init__(
228243
self.noise = noise
229244
self.drag_coefficient = drag_coefficient
230245
self.porosity = porosity
246+
self.opening_shock_coefficient = opening_shock_coefficient
231247

232248
# Initialize derived attributes
233249
self.radius = self.__resolve_radius(radius, cd_s, drag_coefficient)
@@ -259,6 +275,43 @@ def __compute_added_mass_coefficient(self, porosity):
259275
1 - 1.465 * porosity - 0.25975 * porosity**2 + 1.2626 * porosity**3
260276
)
261277

278+
def evaluate_opening_shock_force(self, air_density, velocity):
279+
"""Estimate the peak parachute opening shock force.
280+
281+
Uses the standard approximation from Knacke's *Parachute Recovery
282+
Systems Design Manual*: ``F_o = Cx * q * cd_s``, where ``q`` is the
283+
dynamic pressure ``0.5 * air_density * velocity ** 2`` at inflation and
284+
``Cx`` is the ``opening_shock_coefficient``. This is an empirical
285+
estimate of the peak transient (inflation) load on the recovery
286+
harness, not a result of the equation-of-motion integration, and it is
287+
typically several times larger than the steady-state drag force
288+
``q * cd_s``.
289+
290+
Parameters
291+
----------
292+
air_density : float
293+
Air density at inflation, in kg/m^3.
294+
velocity : float
295+
Freestream speed at inflation, in m/s.
296+
297+
Returns
298+
-------
299+
float
300+
Peak opening shock force, in newtons.
301+
302+
Raises
303+
------
304+
ValueError
305+
If ``opening_shock_coefficient`` was not set at construction.
306+
"""
307+
if self.opening_shock_coefficient is None:
308+
raise ValueError(
309+
"opening_shock_coefficient must be set to estimate the "
310+
"opening shock force."
311+
)
312+
dynamic_pressure = 0.5 * air_density * velocity**2
313+
return self.opening_shock_coefficient * dynamic_pressure * self.cd_s
314+
262315
def __init_noise(self, noise):
263316
"""Initializes all noise-related attributes.
264317
@@ -421,6 +474,7 @@ def to_dict(self, **kwargs):
421474
"drag_coefficient": self.drag_coefficient,
422475
"height": self.height,
423476
"porosity": self.porosity,
477+
"opening_shock_coefficient": self.opening_shock_coefficient,
424478
}
425479

426480
if kwargs.get("include_outputs", False):
@@ -455,6 +509,7 @@ def from_dict(cls, data):
455509
drag_coefficient=data.get("drag_coefficient", 1.4),
456510
height=data.get("height", None),
457511
porosity=data.get("porosity", 0.0432),
512+
opening_shock_coefficient=data.get("opening_shock_coefficient", None),
458513
)
459514

460515
return parachute

tests/unit/rocket/test_parachute.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,58 @@ def test_from_dict_defaults_drag_coefficient_to_1_4_when_absent(self):
109109
}
110110
parachute = Parachute.from_dict(data)
111111
assert parachute.drag_coefficient == pytest.approx(1.4)
112+
113+
114+
class TestParachuteOpeningShockForce:
115+
"""Tests for the opening shock force estimation (issue #161)."""
116+
117+
def test_opening_shock_force_matches_knacke_formula(self):
118+
"""The estimate must equal Cx * q * cd_s with q = 0.5 * rho * v**2."""
119+
cd_s = 10.0
120+
cx = 1.6
121+
air_density = 1.05
122+
velocity = 30.0
123+
parachute = _make_parachute(cd_s=cd_s, opening_shock_coefficient=cx)
124+
125+
force = parachute.evaluate_opening_shock_force(air_density, velocity)
126+
127+
expected = cx * (0.5 * air_density * velocity**2) * cd_s
128+
assert force == pytest.approx(expected, rel=1e-12)
129+
130+
def test_opening_shock_force_exceeds_steady_drag(self):
131+
"""For a coefficient above 1, the peak load must exceed the steady
132+
drag force q * cd_s at the same condition."""
133+
cd_s = 10.0
134+
air_density = 1.05
135+
velocity = 30.0
136+
parachute = _make_parachute(cd_s=cd_s, opening_shock_coefficient=1.6)
137+
138+
force = parachute.evaluate_opening_shock_force(air_density, velocity)
139+
140+
steady_drag = (0.5 * air_density * velocity**2) * cd_s
141+
assert force > steady_drag
142+
143+
def test_opening_shock_force_scales_with_velocity_squared(self):
144+
"""Doubling the inflation velocity must quadruple the peak load."""
145+
parachute = _make_parachute(opening_shock_coefficient=1.5)
146+
147+
force_low = parachute.evaluate_opening_shock_force(1.2, 20.0)
148+
force_high = parachute.evaluate_opening_shock_force(1.2, 40.0)
149+
150+
assert force_high == pytest.approx(4.0 * force_low, rel=1e-12)
151+
152+
def test_opening_shock_force_requires_coefficient(self):
153+
"""Without an opening_shock_coefficient the estimate is disabled."""
154+
parachute = _make_parachute() # opening_shock_coefficient defaults to None
155+
156+
assert parachute.opening_shock_coefficient is None
157+
with pytest.raises(ValueError, match="opening_shock_coefficient"):
158+
parachute.evaluate_opening_shock_force(1.2, 30.0)
159+
160+
def test_opening_shock_coefficient_survives_dict_round_trip(self):
161+
"""to_dict/from_dict must preserve the opening_shock_coefficient."""
162+
parachute = _make_parachute(opening_shock_coefficient=1.7)
163+
164+
restored = Parachute.from_dict(parachute.to_dict())
165+
166+
assert restored.opening_shock_coefficient == 1.7

0 commit comments

Comments
 (0)