Skip to content
Open
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
61 changes: 57 additions & 4 deletions rocketpy/rocket/parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 @@ -56,6 +56,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 @@ -148,7 +154,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 @@ -171,6 +177,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 @@ -376,11 +386,54 @@ 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"
):
if isinstance(trigger[1], bool):
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}."
)
try:
t_deploy = float(trigger[1])
except (TypeError, ValueError) as exc:
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}."
) from exc
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.
t = getattr(self, "_eval_time", None)
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 @@ -1662,7 +1662,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 @@ -1685,6 +1685,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
6 changes: 5 additions & 1 deletion rocketpy/simulation/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ def __simulate(self, verbose):
self.y_sol,
self.sensors,
phase.derivative,
self.t,
node.t,
):
# Remove parachute from flight parachutes
self.parachutes.remove(parachute)
Expand Down Expand Up @@ -1538,6 +1538,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
33 changes: 25 additions & 8 deletions rocketpy/stochastic/stochastic_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,31 @@


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

``(int, float)`` deliberately, matching ``Parachute``'s own check rather
than ``numbers.Real``: that would take ``numpy.int64``, which ``Parachute``
refuses, so widening here only moves the failure to create time. ``bool``
is excluded because it is an ``int``, and would arrive as a height of one.
refuses for height triggers, so widening here only moves the failure to
create time. ``bool`` is excluded because it is an ``int``, and would
arrive as a height of one. Time triggers ``("time", t_deploy)`` accept any
non-bool value that ``float()`` can convert (including ``numpy`` scalars).
"""
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"
):
if isinstance(member[1], bool):
return False
try:
return float(member[1]) >= 0
except (TypeError, ValueError):
return False
return isinstance(member, (int, float)) and not isinstance(member, bool)


Expand All @@ -34,7 +48,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 @@ -81,7 +96,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 @@ -130,8 +146,9 @@ def __init__(

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 @@ -147,7 +164,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
68 changes: 68 additions & 0 deletions tests/unit/rocket/test_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,71 @@ def test_callable_trigger_arities_route_arguments(trigger, expects_udot):
result = parachute.triggerfunc(800.0, 500.0, [0.0] * 6, [], [1.0] * 6)
assert result is True
assert parachute.triggerfunc._expects_udot is expects_udot


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"),
("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
25 changes: 22 additions & 3 deletions tests/unit/stochastic/test_stochastic_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ def _at_apogee(pressure, height, state): # pylint: disable=unused-argument

@pytest.mark.parametrize(
"trigger",
[[_at_apogee], ["apogee"], [800], [_at_apogee, "apogee", 800]],
ids=["callable", "apogee", "height", "mixed"],
[
[_at_apogee],
["apogee"],
[800],
[("time", 5.0)],
[_at_apogee, "apogee", 800, ("time", 3.0)],
],
ids=["callable", "apogee", "height", "time", "mixed"],
)
def test_every_documented_trigger_form_is_accepted(calisto_main_chute, trigger):
"""The docstring promises callables, "apogee" and numbers. The check read
Expand All @@ -62,6 +68,9 @@ def test_every_documented_trigger_form_is_accepted(calisto_main_chute, trigger):
["banana"],
[True],
[_at_apogee, None],
[("time", -1.0)],
[("time", True)],
[("burnout", 3.0)],
],
ids=str,
)
Expand All @@ -79,7 +88,17 @@ def test_a_trigger_that_is_not_a_list_of_those_is_refused(calisto_main_chute, tr

@pytest.mark.parametrize(
"member",
[_at_apogee, "apogee", "APOGEE", 800, 800.0, np.float64(800)],
[
_at_apogee,
"apogee",
"APOGEE",
800,
800.0,
np.float64(800),
("time", 5.0),
("TIME", np.float64(2.5)),
["time", 1],
],
ids=str,
)
def test_what_this_accepts_is_what_a_parachute_accepts(calisto_main_chute, member):
Expand Down
Loading