Skip to content

Commit 485e9de

Browse files
committed
ENH: time based triggering of parachute
- ENH: added ability to parse time nodes and rocket properties in parachute.py - ENH: added and modified functionalities in time node calculations for parachute in flight.py - TST: added separate integration test_parachute_time_trig.py focusing on parachute triggers in flight simulations - TST: added separate unit test_parachute.py focusing on parachute triggers mechanisms
1 parent 7a10e29 commit 485e9de

File tree

4 files changed

+696
-18
lines changed

4 files changed

+696
-18
lines changed

rocketpy/rocket/parachute.py

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,22 @@ class Parachute:
4646
4747
- The string "apogee" which triggers the parachute at apogee, i.e.,
4848
when the rocket reaches its highest point and starts descending.
49-
49+
50+
- The string "launch + X" where X is a number in seconds. The parachute
51+
will be ejected X seconds after launch (t=0). This is useful for
52+
simulating delay charges that activate at a fixed time from launch.
53+
54+
- The string "burnout + X" where X is a number in seconds. The parachute
55+
will be ejected X seconds after motor burnout. This is useful for
56+
simulating delay charges in motors with delay elements.
5057
5158
Parachute.triggerfunc : function
5259
Trigger function created from the trigger used to evaluate the trigger
5360
condition for the parachute ejection system. It is a callable function
54-
that takes three arguments: Freestream pressure in Pa, Height above
55-
ground level in meters, and the state vector of the simulation. The
56-
returns ``True`` if the parachute ejection system should be triggered
57-
and ``False`` otherwise.
61+
that takes six arguments: Freestream pressure in Pa, Height above
62+
ground level in meters, the state vector of the simulation, sensors
63+
list, current time t, and rocket object. It returns ``True`` if the
64+
parachute ejection system should be triggered and ``False`` otherwise.
5865
5966
.. note:
6067
@@ -153,7 +160,14 @@ def __init__(
153160
height above ground level.
154161
- The string "apogee" which triggers the parachute at apogee, i.e., \
155162
when the rocket reaches its highest point and starts descending.
156-
163+
- The string "launch + X" where X is the delay in seconds from launch. \
164+
For example, "launch + 5" triggers 5 seconds after launch. This is \
165+
useful for simulating delay charges that activate at a fixed time \
166+
from launch.
167+
- The string "burnout + X" where X is the delay in seconds from motor \
168+
burnout. For example, "burnout + 3.5" triggers 3.5 seconds after \
169+
motor burnout. This is useful for simulating delay charges in motors \
170+
with delay elements.
157171
.. note::
158172
159173
The function will be called according to the sampling rate specified.
@@ -232,35 +246,92 @@ def __evaluate_trigger_function(self, trigger):
232246
sig = signature(triggerfunc)
233247
if len(sig.parameters) == 3:
234248

235-
def triggerfunc(p, h, y, sensors):
249+
def triggerfunc(p, h, y, sensors, t=None, rocket=None):
236250
return trigger(p, h, y)
251+
elif len(sig.parameters) == 4:
237252

253+
def triggerfunc(p, h, y, sensors, t=None, rocket=None):
254+
return trigger(p, h, y, sensors)
238255
self.triggerfunc = triggerfunc
239256

240257
elif isinstance(trigger, (int, float)):
241258
# The parachute is deployed at a given height
242-
def triggerfunc(p, h, y, sensors): # pylint: disable=unused-argument
259+
def triggerfunc(p, h, y, sensors, t=None, rocket=None): # pylint: disable=unused-argument
243260
# p = pressure considering parachute noise signal
244261
# h = height above ground level considering parachute noise signal
245262
# y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3]
246263
return y[5] < 0 and h < trigger
247264

248265
self.triggerfunc = triggerfunc
249266

250-
elif trigger.lower() == "apogee":
251-
# The parachute is deployed at apogee
252-
def triggerfunc(p, h, y, sensors): # pylint: disable=unused-argument
253-
# p = pressure considering parachute noise signal
254-
# h = height above ground level considering parachute noise signal
255-
# y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3]
256-
return y[5] < 0
257-
258-
self.triggerfunc = triggerfunc
267+
elif isinstance(trigger, str):
268+
trigger_lower = trigger.lower().strip()
269+
270+
if trigger_lower == "apogee":
271+
# The parachute is deployed at apogee
272+
def triggerfunc(p, h, y, sensors, t=None, rocket=None): # pylint: disable=unused-argument
273+
# p = pressure considering parachute noise signal
274+
# h = height above ground level considering parachute noise signal
275+
# y = [x, y, z, vx, vy, vz, e0, e1, e2, e3, w1, w2, w3]
276+
return y[5] < 0
277+
278+
self.triggerfunc = triggerfunc
279+
280+
elif "+" in trigger_lower:
281+
# Time-based trigger: "launch + X" or "burnout + X"
282+
parts = trigger_lower.split("+")
283+
if len(parts) != 2:
284+
raise ValueError(
285+
f"Invalid time-based trigger format for parachute '{self.name}'. "
286+
+ "Expected format: 'launch + delay' or 'burnout + delay' "
287+
+ "where delay is a number in seconds."
288+
)
289+
290+
event = parts[0].strip()
291+
try:
292+
delay = float(parts[1].strip())
293+
except ValueError:
294+
raise ValueError(
295+
f"Invalid delay value in trigger '{trigger}' for parachute '{self.name}'. "
296+
+ "Delay must be a number in seconds."
297+
)
298+
299+
if event == "launch":
300+
# Deploy at launch time + delay
301+
def triggerfunc(p, h, y, sensors, t=None, rocket=None): # pylint: disable=unused-argument
302+
if t is None:
303+
return False
304+
return t >= delay
305+
306+
self.triggerfunc = triggerfunc
307+
308+
elif event == "burnout":
309+
# Deploy at motor burnout time + delay
310+
def triggerfunc(p, h, y, sensors, t=None, rocket=None): # pylint: disable=unused-argument
311+
if t is None or rocket is None:
312+
return False
313+
burnout_time = rocket.motor.burn_out_time
314+
return t >= burnout_time + delay
315+
316+
self.triggerfunc = triggerfunc
317+
318+
else:
319+
raise ValueError(
320+
f"Invalid time-based trigger event '{event}' for parachute '{self.name}'. "
321+
+ "Supported events are 'launch' and 'burnout'."
322+
)
323+
324+
else:
325+
raise ValueError(
326+
f"Unable to set the trigger function for parachute '{self.name}'. "
327+
+ "Trigger string must be 'apogee', 'launch + <delay>', or 'burnout + <delay>'. "
328+
+ "See the Parachute class documentation for more information."
329+
)
259330

260331
else:
261332
raise ValueError(
262333
f"Unable to set the trigger function for parachute '{self.name}'. "
263-
+ "Trigger must be a callable, a float value or the string 'apogee'. "
334+
+ "Trigger must be a callable, a float value or a string. "
264335
+ "See the Parachute class documentation for more information."
265336
)
266337

rocketpy/simulation/flight.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,8 @@ def __simulate(self, verbose):
731731
height_above_ground_level,
732732
self.y_sol,
733733
self.sensors,
734+
node.t,
735+
self.rocket,
734736
):
735737
# Remove parachute from flight parachutes
736738
self.parachutes.remove(parachute)
@@ -800,6 +802,125 @@ def __simulate(self, verbose):
800802
if self.__check_simulation_events(phase, phase_index, node_index):
801803
break # Stop if simulation termination event occurred
802804

805+
# List and feed overshootable time nodes
806+
if self.time_overshoot:
807+
# Initialize phase overshootable time nodes
808+
overshootable_nodes = self.TimeNodes()
809+
# Add overshootable parachute time nodes
810+
overshootable_nodes.add_parachutes(
811+
self.parachutes, self.solution[-2][0], self.t
812+
)
813+
# Add last time node (always skipped)
814+
overshootable_nodes.add_node(self.t, [], [], [])
815+
if len(overshootable_nodes) > 1:
816+
# Sort and merge equal overshootable time nodes
817+
overshootable_nodes.sort()
818+
overshootable_nodes.merge()
819+
# Clear if necessary
820+
if overshootable_nodes[0].t == phase.t and phase.clear:
821+
overshootable_nodes[0].parachutes = []
822+
overshootable_nodes[0].callbacks = []
823+
# Feed overshootable time nodes trigger
824+
interpolator = phase.solver.dense_output()
825+
for (
826+
overshootable_index,
827+
overshootable_node,
828+
) in self.time_iterator(overshootable_nodes):
829+
# Calculate state at node time
830+
overshootable_node.y_sol = interpolator(
831+
overshootable_node.t
832+
)
833+
for parachute in overshootable_node.parachutes:
834+
# Calculate and save pressure signal
835+
(
836+
noisy_pressure,
837+
height_above_ground_level,
838+
) = self.__calculate_and_save_pressure_signals(
839+
parachute,
840+
overshootable_node.t,
841+
overshootable_node.y_sol[2],
842+
)
843+
844+
# Check for parachute trigger
845+
if parachute.triggerfunc(
846+
noisy_pressure,
847+
height_above_ground_level,
848+
overshootable_node.y_sol,
849+
self.sensors,
850+
overshootable_node.t,
851+
self.rocket,
852+
):
853+
# Remove parachute from flight parachutes
854+
self.parachutes.remove(parachute)
855+
# Create phase for time after detection and
856+
# before inflation
857+
# Must only be created if parachute has any lag
858+
i = 1
859+
if parachute.lag != 0:
860+
self.flight_phases.add_phase(
861+
overshootable_node.t,
862+
phase.derivative,
863+
clear=True,
864+
index=phase_index + i,
865+
)
866+
i += 1
867+
# Create flight phase for time after inflation
868+
callbacks = [
869+
lambda self,
870+
parachute_cd_s=parachute.cd_s: setattr(
871+
self, "parachute_cd_s", parachute_cd_s
872+
),
873+
lambda self,
874+
parachute_radius=parachute.radius: setattr(
875+
self,
876+
"parachute_radius",
877+
parachute_radius,
878+
),
879+
lambda self,
880+
parachute_height=parachute.height: setattr(
881+
self,
882+
"parachute_height",
883+
parachute_height,
884+
),
885+
lambda self,
886+
parachute_porosity=parachute.porosity: setattr(
887+
self,
888+
"parachute_porosity",
889+
parachute_porosity,
890+
),
891+
lambda self,
892+
added_mass_coefficient=parachute.added_mass_coefficient: setattr(
893+
self,
894+
"parachute_added_mass_coefficient",
895+
added_mass_coefficient,
896+
),
897+
]
898+
self.flight_phases.add_phase(
899+
overshootable_node.t + parachute.lag,
900+
self.u_dot_parachute,
901+
callbacks,
902+
clear=False,
903+
index=phase_index + i,
904+
)
905+
# Rollback history
906+
self.t = overshootable_node.t
907+
self.y_sol = overshootable_node.y_sol
908+
self.solution[-1] = [
909+
overshootable_node.t,
910+
*overshootable_node.y_sol,
911+
]
912+
# Prepare to leave loops and start new flight phase
913+
overshootable_nodes.flush_after(
914+
overshootable_index
915+
)
916+
phase.time_nodes.flush_after(node_index)
917+
phase.time_nodes.add_node(self.t, [], [], [])
918+
phase.solver.status = "finished"
919+
# Save parachute event
920+
self.parachute_events.append(
921+
[self.t, parachute]
922+
)
923+
803924
# Process overshootable time nodes if enabled
804925
if self.time_overshoot and self.__process_overshootable_nodes(
805926
phase, phase_index, node_index
@@ -946,6 +1067,8 @@ def __check_and_handle_parachute_triggers(
9461067
height_above_ground_level,
9471068
self.y_sol,
9481069
self.sensors,
1070+
node.t,
1071+
self.rocket,
9491072
):
9501073
continue # Check next parachute
9511074

@@ -1355,6 +1478,8 @@ def __check_overshootable_parachute_triggers(
13551478
height_above_ground_level,
13561479
overshootable_node.y_sol,
13571480
self.sensors,
1481+
overshootable_node.t,
1482+
self.rocket,
13581483
):
13591484
continue # Check next parachute
13601485

0 commit comments

Comments
 (0)