Skip to content
Merged
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
42 changes: 42 additions & 0 deletions docs/user/parachute_triggers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,48 @@ Pass a number to deploy at a fixed height above ground level while descending:
lag=0.5,
)

Fixed-time trigger
------------------

Pass a ``("time", t_deploy)`` tuple to deploy at a fixed flight time, measured
in seconds from the start of the flight. This models a pyrotechnic delay charge
that is lit at ignition:

.. code-block:: python

rocket.add_parachute(
name="Drogue",
cd_s=1.0,
trigger=("time", 12.0), # seconds after flight start
sampling_rate=100,
lag=0.5,
)

Unlike the ``"apogee"`` and numeric-altitude forms, this one is not restricted
to the descent: it fires as soon as flight time reaches ``t_deploy``, even while
the rocket is still ascending. That is deliberate, since a delay charge burns on
its own schedule regardless of where the rocket is.

For a delay charge referenced to *burnout* rather than to ignition, compose it
with the motor's burn out time:

.. code-block:: python

rocket.add_parachute(
name="Drogue",
cd_s=1.0,
trigger=("time", motor.burn_out_time + 8.0), # 8 s delay after burnout
sampling_rate=100,
lag=0.5,
)

.. note::

Deploying while the rocket is still fast will produce very large parachute
forces, which is realistic: an over-short delay shreds canopies in reality
too. Check the loads in the results rather than assuming the deployment was
survivable.

Custom trigger: motor burnout
-----------------------------

Expand Down
73 changes: 65 additions & 8 deletions rocketpy/rocket/parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ def _is_a_height_trigger(trigger):
error. ``bool`` is excluded by hand because it *is* an ``int``, and ``True``
would otherwise be taken as a height of one metre.

This is the single definition of the height form. ``StochasticParachute``
validates the same triggers before a ``Parachute`` is ever built and calls
this rather than restating it, because the two spellings drifted apart once
already.
This is the single definition of the numeric boundary. The height form and
the delay of a ``("time", t_deploy)`` trigger both use it, and
``StochasticParachute`` validates the same triggers before a ``Parachute``
is ever built and calls this rather than restating it, because the two
spellings drifted apart once already.
"""
return isinstance(trigger, Real) and not isinstance(trigger, bool)

Expand All @@ -42,7 +43,7 @@ class Parachute:
Parachute.cd_s : float
Drag coefficient times reference area for parachute. It has units of
area and must be given in squared meters.
Parachute.trigger : callable, float, str
Parachute.trigger : callable, float, str, tuple
This parameter defines the trigger condition for the parachute ejection
system. It can be one of the following:

Expand Down Expand Up @@ -78,6 +79,12 @@ class Parachute:
- The string "apogee" which triggers the parachute at apogee, i.e.,
when the rocket reaches its highest point and starts descending.

- A tuple ``("time", t_deploy)`` where ``t_deploy`` is the flight time
in seconds at or after which the parachute triggers (from ``t = 0``
at flight start). Useful for fixed delay charges that start at
ignition/launch. For a motor delay charge that starts at burnout,
pass ``("time", motor.burn_out_time + delay)``.


Parachute.triggerfunc : function
Trigger function created from the trigger used to evaluate the trigger
Expand Down Expand Up @@ -171,7 +178,7 @@ def __init__(
organized matter.
cd_s : float
Drag coefficient times reference area of the parachute.
trigger : callable, float, str
trigger : callable, float, str, tuple
Defines the trigger condition for the parachute ejection system. It
can be one of the following:

Expand All @@ -194,6 +201,10 @@ def __init__(
height above ground level.
- The string "apogee" which triggers the parachute at apogee, i.e., \
when the rocket reaches its highest point and starts descending.
- A tuple ``("time", t_deploy)`` that triggers when flight time \
``t >= t_deploy`` (seconds from flight start). For a delay \
charge referenced to motor burnout, use \
``("time", motor.burn_out_time + delay)``.

.. note::

Expand Down Expand Up @@ -331,6 +342,10 @@ def __evaluate_trigger_function(self, trigger): # pylint: disable=too-many-stat
# pylint: disable=function-redefined
self._trigger_falling_only = False
self._trigger_needs_height = True
# Flight overwrites this with the current flight time before every
# trigger evaluation. Declared here so a ("time", t_deploy) trigger has
# something defined to read when it is called outside a Flight.
self._eval_time = None

# Helper to wrap any callable to the internal (p, h, y, sensors, u_dot) API
def _make_wrapper(fn):
Expand Down Expand Up @@ -410,11 +425,53 @@ def triggerfunc(p, h, y, sensors, u_dot): # pylint: disable=unused-argument
self.triggerfunc = triggerfunc
return

# Fixed-time trigger: ("time", t_deploy) [seconds from flight start]
if (
isinstance(trigger, (tuple, list))
and len(trigger) == 2
and isinstance(trigger[0], str)
and trigger[0].lower() == "time"
):
# Same numeric boundary as a height, so the two forms cannot
# disagree about what counts as a number. Notably this refuses a
# string delay rather than quietly coercing it: float("3.0") would
# otherwise make ("time", "3.0") work by accident.
if not _is_a_height_trigger(trigger[1]):
raise ValueError(
f"Unable to set the trigger function for parachute '{self.name}'. "
+ "Time trigger delay must be a non-negative number of seconds, "
+ f"got {trigger[1]!r}."
)
t_deploy = float(trigger[1])
if t_deploy < 0:
raise ValueError(
f"Unable to set the trigger function for parachute '{self.name}'. "
+ "Time trigger delay must be non-negative, "
+ f"got {t_deploy}."
)

# Delay charges fire on ascent; height is unused.
self._trigger_falling_only = False
self._trigger_needs_height = False

def triggerfunc(p, h, y, sensors, u_dot): # pylint: disable=unused-argument
# Flight sets ``self._eval_time`` immediately before each call.
# It is None only when the trigger is called outside a Flight,
# which cannot deploy anything, so refuse rather than guess.
t = self._eval_time
if t is None:
return False
return t >= t_deploy

triggerfunc._expects_udot = False
self.triggerfunc = triggerfunc
return

# If we reach this point, the trigger is invalid
raise ValueError(
f"Unable to set the trigger function for parachute '{self.name}'. "
+ "Trigger must be a callable, a float value or one of the strings "
+ "('apogee'). "
+ "Trigger must be a callable, a float value, the string 'apogee', "
+ "or a tuple ('time', t_deploy). "
+ "See the Parachute class documentation for more information."
)

Expand Down
6 changes: 5 additions & 1 deletion rocketpy/rocket/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -1755,7 +1755,7 @@ def add_parachute(
force is the dynamic pressure computed on the parachute
times its cd_s coefficient. Has units of area and must be
given in squared meters.
trigger : callable, float, str
trigger : callable, float, str, tuple
Defines the trigger condition for the parachute ejection system. It
can be one of the following:

Expand All @@ -1778,6 +1778,10 @@ def add_parachute(
height above ground level.
- The string "apogee" which triggers the parachute at apogee, i.e., \
when the rocket reaches its highest point and starts descending.
- A tuple ``("time", t_deploy)`` that triggers when flight time \
``t >= t_deploy`` (seconds from flight start). For a delay \
charge referenced to motor burnout, use \
``("time", motor.burn_out_time + delay)``.

.. note::

Expand Down
4 changes: 4 additions & 0 deletions rocketpy/simulation/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,10 @@ def _evaluate_parachute_trigger(
if expects_udot:
u_dot = derivative_func(t, y)

# Expose flight time for built-in ("time", t_deploy) triggers without
# changing the public (p, h, y, sensors, u_dot) triggerfunc signature.
parachute._eval_time = t

# Call the wrapper with both sensors and u_dot
# The wrapper will decide which args to pass to the user's function
return triggerfunc(pressure, height, y, sensors, u_dot)
Expand Down
27 changes: 20 additions & 7 deletions rocketpy/stochastic/stochastic_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,29 @@


def _is_a_trigger(member):
"""One of the three forms ``Parachute`` accepts, and no more.
"""One of the forms ``Parachute`` accepts, and no more.

The height form defers to ``Parachute``'s own predicate instead of
The numeric forms defer to ``Parachute``'s own predicate instead of
restating it. Both were written out separately before and drifted: this one
kept ``(int, float)`` while ``Parachute`` widened to ``numbers.Real``, so a
``numpy.int64`` height was refused here even though the ``Parachute`` it
would have built accepts it. Calling the same function is what keeps the
promise that what this accepts is what a parachute accepts.

That applies to the delay of a ``("time", t_deploy)`` trigger too, so a
string is refused rather than quietly coerced by ``float()``.
"""
if callable(member):
return True
if isinstance(member, str):
return member.lower() == "apogee"
if (
isinstance(member, (tuple, list))
and len(member) == 2
and isinstance(member[0], str)
and member[0].lower() == "time"
):
return bool(_is_a_height_trigger(member[1]) and member[1] >= 0)
return _is_a_height_trigger(member)


Expand All @@ -37,7 +47,8 @@ class StochasticParachute(StochasticModel):
cd_s : tuple, list, int, float
Drag coefficient of the parachute.
trigger : list
List of callables, string "apogee" or ints/floats.
List of callables, string "apogee", ints/floats, or
``("time", t_deploy)`` tuples.
sampling_rate : tuple, list, int, float
Sampling rate of the parachute in seconds.
lag : tuple, list, int, float
Expand Down Expand Up @@ -84,7 +95,8 @@ def __init__(
cd_s : tuple, list, int, float
Drag coefficient of the parachute.
trigger : list
List of callables, string "apogee" or ints/floats.
List of callables, string "apogee", ints/floats, or
``("time", t_deploy)`` tuples.
sampling_rate : tuple, list, int, float
Sampling rate of the parachute in seconds.
lag : tuple, list, int, float
Expand Down Expand Up @@ -146,8 +158,9 @@ def _set_stochastic(self, seed=None):

def _validate_trigger(self, trigger):
"""Validates the trigger input. If not None, it must be a non-empty
list whose members are each a callable, the string "apogee", or a
height. One of those is chosen per simulation.
list whose members are each a callable, the string "apogee", a height,
or a ``("time", t_deploy)`` tuple. One of those is chosen per
simulation.
"""
if trigger is None:
return
Expand All @@ -163,7 +176,7 @@ def _validate_trigger(self, trigger):
if not valid:
raise AssertionError(
"`trigger` must be a non-empty list whose members are "
"callables, the string 'apogee', or heights"
"callables, the string 'apogee', heights, or ('time', t_deploy)"
)

def _validate_noise(self, noise):
Expand Down
27 changes: 27 additions & 0 deletions tests/integration/simulation/test_flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,3 +1014,30 @@ def acc_trigger(p, h, y, u_dot): # pylint: disable=unused-argument
deploy_time, deployed = flight.parachute_events[0]
assert deployed.name == "acc_chute"
assert abs(flight.z(deploy_time) - flight.apogee) <= 5


def test_flight_with_fixed_time_parachute_trigger(calisto_robust, example_plain_env):
"""Integration test for #437: ``("time", t_deploy)`` fires near t_deploy."""
t_deploy = 3.0
calisto_robust.parachutes = []
calisto_robust.add_parachute(
name="timer_chute",
cd_s=5.0,
trigger=("time", t_deploy),
sampling_rate=100,
lag=0,
)

flight = Flight(
rocket=calisto_robust,
environment=example_plain_env,
rail_length=5.2,
inclination=85,
heading=0,
)

assert len(flight.parachute_events) >= 1
deploy_time, deployed = flight.parachute_events[0]
assert deployed.name == "timer_chute"
# Sampling at 100 Hz; allow one sample interval of slack.
assert abs(deploy_time - t_deploy) <= 0.02
70 changes: 70 additions & 0 deletions tests/unit/rocket/test_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,73 @@ def test_what_is_not_a_height_is_still_refused(trigger):
the complex types need no special case, since neither is ``Real``."""
with pytest.raises(ValueError, match="Unable to set the trigger"):
_make_parachute(trigger=trigger)


class TestParachuteTimeTrigger:
"""Fixed-time parachute triggers: ``("time", t_deploy)`` (#437)."""

def test_time_trigger_fires_at_and_after_deploy_time(self):
parachute = _make_parachute(trigger=("time", 5.0))
state = [0.0] * 13

parachute._eval_time = 4.999
assert parachute.triggerfunc(101325.0, 1000.0, state, [], None) is False

parachute._eval_time = 5.0
assert parachute.triggerfunc(101325.0, 1000.0, state, [], None) is True

parachute._eval_time = 7.5
assert parachute.triggerfunc(101325.0, 1000.0, state, [], None) is True

def test_time_trigger_list_form_and_case_insensitive_kind(self):
parachute = _make_parachute(trigger=["TIME", 3])
state = [0.0] * 13

parachute._eval_time = 2.9
assert parachute.triggerfunc(101325.0, 1000.0, state, [], None) is False
parachute._eval_time = 3.0
assert parachute.triggerfunc(101325.0, 1000.0, state, [], None) is True

def test_time_trigger_does_not_require_descent_or_height(self):
parachute = _make_parachute(trigger=("time", 1.0))
assert parachute._trigger_falling_only is False
assert parachute._trigger_needs_height is False

# Ascending state at altitude well above any height trigger.
ascending = [0.0, 0.0, 2000.0, 0.0, 0.0, 50.0] + [0.0] * 7
parachute._eval_time = 1.0
assert parachute.triggerfunc(101325.0, 2000.0, ascending, [], None) is True

def test_time_trigger_false_when_eval_time_unset(self):
parachute = _make_parachute(trigger=("time", 0.0))
assert parachute.triggerfunc(101325.0, 0.0, [0.0] * 13, [], None) is False

def test_time_trigger_accepts_numpy_scalar_delay(self):
parachute = _make_parachute(trigger=("time", np.float64(2.5)))
parachute._eval_time = 2.5
assert parachute.triggerfunc(101325.0, 0.0, [0.0] * 13, [], None) is True

@pytest.mark.parametrize(
"trigger",
[
("time", -1.0),
("time", True),
("time", "soon"),
# float() would happily eat this one; the numeric boundary must not
("time", "3.0"),
("time",),
("burnout", 3.0),
("launch", 5.0),
],
ids=str,
)
def test_invalid_time_triggers_are_refused(self, trigger):
with pytest.raises(ValueError, match="Unable to set the trigger"):
_make_parachute(trigger=trigger)

def test_to_dict_round_trip_preserves_time_trigger(self):
original = _make_parachute(trigger=("time", 4.0))
restored = Parachute.from_dict(original.to_dict())
assert restored.trigger == ("time", 4.0)
restored._eval_time = 4.0
assert restored.triggerfunc(101325.0, 0.0, [0.0] * 13, [], None) is True
Loading
Loading