From 7ef85edae555f8578277f306092a5810a002dd99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:45:20 +0000 Subject: [PATCH 1/2] Initial plan From af2fa34946f9aed18b490bed4a5481c96208f46f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:56:48 +0000 Subject: [PATCH 2/2] Add Event class for deployables and multistage simulation support Co-authored-by: aZira371 <99824864+aZira371@users.noreply.github.com> --- rocketpy/__init__.py | 2 +- rocketpy/prints/flight_prints.py | 6 +- rocketpy/simulation/__init__.py | 1 + rocketpy/simulation/event.py | 163 ++++++++++++++++++++++ rocketpy/simulation/flight.py | 17 ++- tests/unit/simulation/test_event.py | 209 ++++++++++++++++++++++++++++ 6 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 rocketpy/simulation/event.py create mode 100644 tests/unit/simulation/test_event.py diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index f99a70f28..f2c6c0ba6 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -42,7 +42,7 @@ ) from .sensitivity import SensitivityModel from .sensors import Accelerometer, Barometer, GnssReceiver, Gyroscope -from .simulation import Flight, MonteCarlo, MultivariateRejectionSampler +from .simulation import Event, Flight, MonteCarlo, MultivariateRejectionSampler from .stochastic import ( CustomSampler, StochasticAirBrakes, diff --git a/rocketpy/prints/flight_prints.py b/rocketpy/prints/flight_prints.py index d098fd776..d64316b2c 100644 --- a/rocketpy/prints/flight_prints.py +++ b/rocketpy/prints/flight_prints.py @@ -233,8 +233,8 @@ def events_registered(self): if len(self.flight.parachute_events) == 0: print("No Parachute Events Were Triggered.") for event in self.flight.parachute_events: - trigger_time = event[0] - parachute = event[1] + trigger_time = event.time + parachute = event.action open_time = trigger_time + parachute.lag speed = self.flight.free_stream_speed(open_time) altitude = self.flight.z(open_time) @@ -270,7 +270,7 @@ def impact_conditions(self): num_parachute_events = sum( 1 for event in self.flight.parachute_events - if event[0] < self.flight.t_final + if event.time < self.flight.t_final ) print( f"Number of parachutes triggered until impact: {num_parachute_events}" diff --git a/rocketpy/simulation/__init__.py b/rocketpy/simulation/__init__.py index 1ade0f16f..c491ed1e6 100644 --- a/rocketpy/simulation/__init__.py +++ b/rocketpy/simulation/__init__.py @@ -1,3 +1,4 @@ +from .event import Event from .flight import Flight from .flight_data_exporter import FlightDataExporter from .flight_data_importer import FlightDataImporter diff --git a/rocketpy/simulation/event.py b/rocketpy/simulation/event.py new file mode 100644 index 000000000..e66c949ce --- /dev/null +++ b/rocketpy/simulation/event.py @@ -0,0 +1,163 @@ +"""Module containing the Event class used to record discrete occurrences +during a rocket flight simulation. + +Design rationale +---------------- +Events are fundamentally a **simulation-time** concept: they mark the instant +at which something happens to the rocket during flight (e.g. a parachute +deploys, a stage separates, or a deployable is released). Because the timing +of an event is determined by the ODE solver running inside +:class:`~rocketpy.simulation.flight.Flight`, the natural home for this class +is the ``simulation`` sub-package, not the ``rocket`` sub-package. + +The ``rocket`` sub-package defines *what* components exist on the rocket +(motors, fins, parachutes …). The ``simulation`` sub-package records *when* +and *how* those components become active. Placing :class:`Event` here keeps +the ``rocket`` sub-package free of simulation state and allows future +simulation components (e.g. ``MonteCarlo``, multistage managers) to import +:class:`Event` without creating circular dependencies. +""" + + +class Event: + """Records a discrete event that occurs during a flight simulation. + + An :class:`Event` captures the simulation time at which something + happened (e.g. a parachute deployed, a stage separated, a deployable + was released) together with metadata about *why* it happened and + *what* object was involved. + + This class is the intended building block for deployable-deployment + tracking and future multistage simulation support in RocketPy. + + .. note:: + + For **backward compatibility**, :class:`Event` also supports + index-based access (``event[0]`` → ``event.time``, + ``event[1]`` → ``event.action``), matching the legacy + ``[time, parachute]`` list format previously stored in + :attr:`~rocketpy.simulation.flight.Flight.parachute_events`. + + Attributes + ---------- + time : float + Simulation time, in seconds, at which the event was triggered. + trigger : callable, float, or str + The condition or mechanism that caused the event. For parachutes + this is the same object as :attr:`~rocketpy.rocket.Parachute.trigger` + (a callable, a float height, or the string ``"apogee"``). + event_type : str + A string label that categorises the event. Common values are: + + - ``"parachute"`` — a parachute deployment. + - ``"stage_separation"`` — a rocket-stage separation event. + - ``"deployable"`` — release of a generic deployable payload. + action : object or None + The object associated with the event (e.g. the + :class:`~rocketpy.rocket.Parachute` instance that was deployed). + ``None`` when no specific action object is present. + + Examples + -------- + Creating an event that records a parachute deployment: + + >>> from rocketpy.simulation.event import Event + >>> drogue = object() # stand-in for a Parachute instance + >>> ev = Event(time=45.3, trigger="apogee", event_type="parachute", + ... action=drogue) + >>> ev.time + 45.3 + >>> ev.event_type + 'parachute' + >>> ev[0] # backward-compatible index access + 45.3 + >>> ev[1] is drogue + True + """ + + def __init__(self, time, trigger, event_type="parachute", action=None): + """Initialise a new :class:`Event`. + + Parameters + ---------- + time : float + Simulation time, in seconds, at which the event was triggered. + trigger : callable, float, or str + The condition or mechanism that caused the event to fire. + event_type : str, optional + Category label for the event. Typical values are + ``"parachute"``, ``"stage_separation"``, and + ``"deployable"``. Default is ``"parachute"``. + action : object or None, optional + The object linked to the event (e.g. the + :class:`~rocketpy.rocket.Parachute` that was deployed). + Default is ``None``. + """ + self.time = time + self.trigger = trigger + self.event_type = event_type + self.action = action + + # ------------------------------------------------------------------ + # Backward-compatibility interface + # ------------------------------------------------------------------ + + def __getitem__(self, index): + """Allow index-based access for backward compatibility. + + The legacy ``parachute_events`` list stored plain + ``[time, parachute]`` two-element lists. Code that accesses + ``event[0]`` (time) or ``event[1]`` (action / parachute) will + continue to work unchanged. + + Parameters + ---------- + index : int + ``0`` returns :attr:`time`; ``1`` returns :attr:`action`. + + Returns + ------- + float or object + The requested attribute value. + + Raises + ------ + IndexError + If ``index`` is not ``0`` or ``1``. + """ + if index == 0: + return self.time + if index == 1: + return self.action + raise IndexError( + f"Event index {index} is out of range. " + "Use index 0 for 'time' or 1 for 'action'." + ) + + # ------------------------------------------------------------------ + # Dunder helpers + # ------------------------------------------------------------------ + + def __repr__(self): + return ( + f"" + ) + + def __str__(self): + return ( + f"Event of type '{self.event_type}' triggered at t={self.time:.3f} s" + ) + + def __eq__(self, other): + """Two events are equal when all their attributes match.""" + if not isinstance(other, Event): + return NotImplemented + return ( + self.time == other.time + and self.trigger == other.trigger + and self.event_type == other.event_type + and self.action == other.action + ) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index a38be7d93..d42c8a1ac 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -8,6 +8,7 @@ from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, OdeSolver, Radau from rocketpy.simulation.flight_data_exporter import FlightDataExporter +from rocketpy.simulation.event import Event from ..mathutils.function import Function, funcify_method from ..mathutils.vector_matrix import Matrix, Vector @@ -791,7 +792,14 @@ def __simulate(self, verbose): phase.time_nodes.add_node(self.t, [], [], []) phase.solver.status = "finished" # Save parachute event - self.parachute_events.append([self.t, parachute]) + self.parachute_events.append( + Event( + time=self.t, + trigger=parachute.trigger, + event_type="parachute", + action=parachute, + ) + ) # Step through simulation while phase.solver.status == "running": @@ -1072,7 +1080,12 @@ def __simulate(self, verbose): phase.solver.status = "finished" # Save parachute event self.parachute_events.append( - [self.t, parachute] + Event( + time=self.t, + trigger=parachute.trigger, + event_type="parachute", + action=parachute, + ) ) # If controlled flight, post process must be done on sim time diff --git a/tests/unit/simulation/test_event.py b/tests/unit/simulation/test_event.py new file mode 100644 index 000000000..92cc6c0cc --- /dev/null +++ b/tests/unit/simulation/test_event.py @@ -0,0 +1,209 @@ +"""Unit tests for the Event class. + +The Event class records discrete occurrences that happen during a flight +simulation (e.g. parachute deployments, stage separations, deployable +releases). These tests cover construction, attribute access, backward- +compatible index access, string representations, and equality checking. +""" + +import pytest + +from rocketpy import Event +from rocketpy.simulation.event import Event as EventDirect + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def dummy_parachute(): + """A lightweight stand-in for a Parachute instance.""" + + class _FakeParachute: + name = "drogue" + lag = 1.5 + trigger = "apogee" + + return _FakeParachute() + + +@pytest.fixture +def parachute_event(dummy_parachute): + """An Event that mimics a parachute deployment.""" + return Event( + time=45.0, + trigger=dummy_parachute.trigger, + event_type="parachute", + action=dummy_parachute, + ) + + +# --------------------------------------------------------------------------- +# Construction and attribute access +# --------------------------------------------------------------------------- + + +def test_event_init_stores_time(): + """Test that Event stores the time parameter correctly in the time attribute.""" + ev = Event(time=10.5, trigger="apogee") + assert ev.time == 10.5 + + +def test_event_init_stores_trigger(): + """Test that Event stores string trigger values correctly in the trigger attribute.""" + ev = Event(time=5.0, trigger="apogee") + assert ev.trigger == "apogee" + + +def test_event_init_stores_trigger_callable(): + """Test that Event stores a callable trigger function correctly in the trigger attribute.""" + + def func(p, h, y, s): + return h < 500 + + ev = Event(time=3.0, trigger=func) + assert ev.trigger is func + + +def test_event_init_stores_float_trigger(): + """Test that Event stores float trigger values (altitude thresholds) correctly.""" + ev = Event(time=3.0, trigger=800.0) + assert ev.trigger == 800.0 + + +def test_event_default_event_type(): + """Test that event_type defaults to 'parachute' when not specified.""" + ev = Event(time=1.0, trigger="apogee") + assert ev.event_type == "parachute" + + +def test_event_custom_event_type(): + """Test that a custom event_type is stored correctly when supplied.""" + ev = Event(time=1.0, trigger=None, event_type="stage_separation") + assert ev.event_type == "stage_separation" + + +def test_event_deployable_event_type(): + """Test that event_type 'deployable' is accepted and stored correctly.""" + ev = Event(time=2.0, trigger=None, event_type="deployable") + assert ev.event_type == "deployable" + + +def test_event_default_action_is_none(): + """action defaults to None when not provided.""" + ev = Event(time=1.0, trigger="apogee") + assert ev.action is None + + +def test_event_action_stored(dummy_parachute, parachute_event): + """action attribute holds the associated object.""" + assert parachute_event.action is dummy_parachute + + +# --------------------------------------------------------------------------- +# Backward-compatible index access +# --------------------------------------------------------------------------- + + +def test_event_index_zero_returns_time(parachute_event): + """event[0] returns the time (backward-compatible with [time, parachute]).""" + assert parachute_event[0] == parachute_event.time + + +def test_event_index_one_returns_action(parachute_event, dummy_parachute): + """event[1] returns the action (backward-compatible with [time, parachute]).""" + assert parachute_event[1] is dummy_parachute + + +def test_event_index_out_of_range_raises_index_error(parachute_event): + """Accessing index >= 2 raises IndexError.""" + with pytest.raises(IndexError): + _ = parachute_event[2] + + +def test_event_negative_index_raises_index_error(parachute_event): + """Negative indices raise IndexError.""" + with pytest.raises(IndexError): + _ = parachute_event[-1] + + +# --------------------------------------------------------------------------- +# String representations +# --------------------------------------------------------------------------- + + +def test_event_repr_is_string(parachute_event): + """repr() returns a non-empty string.""" + r = repr(parachute_event) + assert isinstance(r, str) + assert len(r) > 0 + + +def test_event_repr_contains_time(parachute_event): + """repr() includes the event time.""" + assert "45.000" in repr(parachute_event) + + +def test_event_repr_contains_type(parachute_event): + """Test that repr() includes the event_type.""" + assert "parachute" in repr(parachute_event) + + +def test_event_str_is_string(parachute_event): + """str() returns a non-empty string.""" + s = str(parachute_event) + assert isinstance(s, str) + assert len(s) > 0 + + +def test_event_str_contains_type(parachute_event): + """str() mentions the event type.""" + assert "parachute" in str(parachute_event) + + +def test_event_str_contains_time(parachute_event): + """str() mentions the trigger time.""" + assert "45.000" in str(parachute_event) + + +# --------------------------------------------------------------------------- +# Equality +# --------------------------------------------------------------------------- + + +def test_event_equality_same_attributes(dummy_parachute): + """Two Event instances with the same attributes are equal.""" + ev1 = Event(time=5.0, trigger="apogee", event_type="parachute", action=dummy_parachute) + ev2 = Event(time=5.0, trigger="apogee", event_type="parachute", action=dummy_parachute) + assert ev1 == ev2 + + +def test_event_inequality_different_time(dummy_parachute): + """Events with different times are not equal.""" + ev1 = Event(time=5.0, trigger="apogee", action=dummy_parachute) + ev2 = Event(time=6.0, trigger="apogee", action=dummy_parachute) + assert ev1 != ev2 + + +def test_event_inequality_different_type(): + """Test that events with different event_type values are not equal.""" + ev1 = Event(time=5.0, trigger=None, event_type="parachute") + ev2 = Event(time=5.0, trigger=None, event_type="stage_separation") + assert ev1 != ev2 + + +def test_event_not_equal_to_non_event(parachute_event): + """Comparing an Event with a non-Event returns NotImplemented / False.""" + assert parachute_event != [45.0, None] + + +# --------------------------------------------------------------------------- +# Module-level import +# --------------------------------------------------------------------------- + + +def test_event_importable_from_rocketpy(): + """Event can be imported directly from the top-level rocketpy package.""" + assert Event is EventDirect