diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index da99743ce..7497948d6 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -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: @@ -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 @@ -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: @@ -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:: @@ -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." ) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 3f4748ac0..c74abaad3 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -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: @@ -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:: diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 55ca3486f..24be39914 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -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) @@ -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) diff --git a/rocketpy/stochastic/stochastic_parachute.py b/rocketpy/stochastic/stochastic_parachute.py index 19ab3dab0..2ce2443cd 100644 --- a/rocketpy/stochastic/stochastic_parachute.py +++ b/rocketpy/stochastic/stochastic_parachute.py @@ -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) @@ -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 @@ -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 @@ -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 @@ -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): 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 7a61c2349..863e1cbf9 100644 --- a/tests/unit/rocket/test_parachute.py +++ b/tests/unit/rocket/test_parachute.py @@ -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 diff --git a/tests/unit/stochastic/test_stochastic_parachute.py b/tests/unit/stochastic/test_stochastic_parachute.py index 8fc128f54..29f434e3f 100644 --- a/tests/unit/stochastic/test_stochastic_parachute.py +++ b/tests/unit/stochastic/test_stochastic_parachute.py @@ -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 @@ -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, ) @@ -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): diff --git a/tests/unit/test_parachute_triggers.py b/tests/unit/test_parachute_triggers.py index 907b16268..36c03a873 100644 --- a/tests/unit/test_parachute_triggers.py +++ b/tests/unit/test_parachute_triggers.py @@ -108,3 +108,48 @@ def basic_trigger(_p, _h, _y): assert res is True assert called.get("ok", False) is True + + +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