diff --git a/docs/user/parachute_triggers.rst b/docs/user/parachute_triggers.rst index 061ac840e..abf246304 100644 --- a/docs/user/parachute_triggers.rst +++ b/docs/user/parachute_triggers.rst @@ -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 ----------------------------- diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index 1efc66d34..c5b2b5422 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -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) @@ -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: @@ -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 @@ -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: @@ -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:: @@ -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): @@ -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." ) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index b23f6afa0..68c2d102e 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -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: @@ -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:: diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 47802b4ee..200464ef1 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -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) diff --git a/rocketpy/stochastic/stochastic_parachute.py b/rocketpy/stochastic/stochastic_parachute.py index bda6446b4..c1b24e365 100644 --- a/rocketpy/stochastic/stochastic_parachute.py +++ b/rocketpy/stochastic/stochastic_parachute.py @@ -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) @@ -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 @@ -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 @@ -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 @@ -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): diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py index a2060d888..a3d4bb9c2 100644 --- a/tests/integration/simulation/test_flight.py +++ b/tests/integration/simulation/test_flight.py @@ -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 diff --git a/tests/unit/rocket/test_parachute.py b/tests/unit/rocket/test_parachute.py index c40dd5a5c..7a4fae041 100644 --- a/tests/unit/rocket/test_parachute.py +++ b/tests/unit/rocket/test_parachute.py @@ -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 diff --git a/tests/unit/stochastic/test_stochastic_parachute.py b/tests/unit/stochastic/test_stochastic_parachute.py index e444a2ae7..476384477 100644 --- a/tests/unit/stochastic/test_stochastic_parachute.py +++ b/tests/unit/stochastic/test_stochastic_parachute.py @@ -38,8 +38,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 @@ -63,6 +69,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, ) @@ -90,6 +99,9 @@ def test_a_trigger_that_is_not_a_list_of_those_is_refused(calisto_main_chute, tr np.float32(800), np.int64(800), np.int32(800), + ("time", 5.0), + ("TIME", np.float64(2.5)), + ["time", 1], ], ids=str, ) @@ -150,6 +162,14 @@ def test_neither_check_can_drift_from_the_other_again(): "apogee", "banana", None, + ("time", 5.0), + ("TIME", np.float64(2.5)), + ["time", 1], + ("time", -1.0), + ("time", True), + ("time", "3.0"), + ("time",), + ("burnout", 3.0), ] for member in boundary: diff --git a/tests/unit/test_parachute_triggers.py b/tests/unit/test_parachute_triggers.py index e96d55cb8..30742fb43 100644 --- a/tests/unit/test_parachute_triggers.py +++ b/tests/unit/test_parachute_triggers.py @@ -146,3 +146,48 @@ def counting_trigger(_p, h, _y): "parachute trigger evaluated more than once at some height/node; " f"duplicates among {len(calls)} calls" ) + + +def test_time_trigger_uses_flight_eval_time(): + """Flight must set parachute._eval_time before calling triggerfunc (#437).""" + + def derivative_func(_t, _y): + raise RuntimeError("derivative should not be called for time triggers") + + parachute = Parachute( + name="timer", + cd_s=1.0, + trigger=("time", 2.5), + sampling_rate=100, + ) + dummy = type("D", (), {})() + + assert ( + Flight._evaluate_parachute_trigger( + dummy, + parachute, + pressure=0.0, + height=100.0, + y=np.zeros(13), + sensors=[], + derivative_func=derivative_func, + t=2.4, + ) + is False + ) + assert parachute._eval_time == 2.4 + + assert ( + Flight._evaluate_parachute_trigger( + dummy, + parachute, + pressure=0.0, + height=100.0, + y=np.zeros(13), + sensors=[], + derivative_func=derivative_func, + t=2.5, + ) + is True + ) + assert parachute._eval_time == 2.5