Skip to content

Commit d34b093

Browse files
committed
ENH: store the flight solution as per-phase states and derived quantities
Replace the flat 13-state solution table with a Solution container holding one PhaseSolution per flight phase. A phase declares the states it integrates through a StateSchema and still reports the full canonical state, reconstructing or freezing whatever it does not integrate. The parachute descent now integrates only position and velocity. Derived quantities (accelerations, aerodynamic forces and moments, net thrust) are stored per phase alongside the states. A phase's derivative returns them rather than writing into the flight, and the caller records them, so the flight no longer has to track which phase is currently receiving rows. DerivedQuantity says how to label each quantity and what to report for the phases that do not compute it. Also in this change: - pass the descending parachute to the parachute derivative as an argument instead of reading it off the flight - guard the degenerate cases in find_roots_cubic_function, which a cubic Hermite fit reaches whenever a quantity changes at a constant rate - when loading a .rpy, rebuild the solution before the other attributes, since the fallbacks read flight outputs computed from it, and stop the net_thrust fallback from resampling the motor's own thrust curve
1 parent 5f917f2 commit d34b093

14 files changed

Lines changed: 1154 additions & 537 deletions

rocketpy/_encoders.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ def set_minimal_flight_attributes(flight, obj):
161161
"atol",
162162
"time_overshoot",
163163
"name",
164-
"solution",
165164
"out_of_rail_time",
166165
"apogee_time",
167166
"apogee",
@@ -189,22 +188,29 @@ def set_minimal_flight_attributes(flight, obj):
189188
"net_thrust",
190189
)
191190

191+
# The solution is stored either as the new phase-based dict or, for older
192+
# saved flights, as a flat list of canonical rows. It is restored before the
193+
# attributes above because the fallbacks below read flight outputs, which
194+
# are all computed from the solution.
195+
raw_solution = obj["solution"]
196+
if isinstance(raw_solution, dict):
197+
flight.solution = Solution.from_dict(raw_solution)
198+
else:
199+
flight.solution = Solution.from_legacy_list(raw_solution)
200+
192201
for attribute in attributes:
193202
try:
194203
setattr(flight, attribute, obj[attribute])
195204
except KeyError:
196205
# Manual resolution of new attributes
197206
if attribute == "net_thrust":
198-
flight.net_thrust = obj["rocket"].motor.thrust
199-
flight.net_thrust.set_discrete_based_on_model(flight.speed)
200-
201-
# The solution is stored either as the new segment-based dict or, for older
202-
# saved flights, as a flat list of canonical rows.
203-
raw_solution = obj["solution"]
204-
if isinstance(raw_solution, dict):
205-
flight.solution = Solution.from_dict(raw_solution)
206-
else:
207-
flight.solution = Solution.from_legacy_list(raw_solution)
207+
# Resample the thrust curve onto the flight's time grid without
208+
# mutating the motor's own thrust Function.
209+
flight.net_thrust = obj[
210+
"rocket"
211+
].motor.thrust.set_discrete_based_on_model(
212+
flight.speed, mutate_self=False
213+
)
208214

209215
flight.t_initial = flight.initial_solution[0]
210216

rocketpy/rocket/parachute.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -347,14 +347,23 @@ def triggerfunc(**kwargs):
347347
elif trigger.lower() == "apogee":
348348

349349
def triggerfunc(**kwargs):
350-
state_history = kwargs.get("state_history")
351-
if not state_history:
352-
return False
353-
return state_history[-1][5] > 0 >= kwargs["state"][5]
350+
# Deploy when the rocket stops climbing, by comparing this
351+
# check against the previous one. Both come from consecutive
352+
# checks of this same parachute, so the moment the vertical
353+
# velocity turns negative always falls between two of them and
354+
# cannot be stepped over. Comparing against the last stored
355+
# trajectory point instead would not be safe: those points
356+
# advance on their own schedule, and the turn can happen
357+
# between two of this parachute's checks without ever showing
358+
# up between two stored points.
359+
previous = kwargs["previous_check_state"]
360+
if previous is None:
361+
return False # nothing to compare against yet
362+
return previous[5] > 0 >= kwargs["state"][5]
354363

355364
self.triggerfunc = triggerfunc
356365
self._trigger_is_positional = False
357-
self._trigger_needs = frozenset({"state_history"})
366+
self._trigger_needs = frozenset()
358367

359368
# Case 4: Invalid trigger input
360369
else:
@@ -392,14 +401,16 @@ def trigger_callback(**kwargs):
392401
time = kwargs.get("time", None)
393402
flight = kwargs.get("flight", None)
394403

395-
flight._active_parachute = self
396404
flight.parachute_events.append([time, self])
397405

398-
kwargs["event"].commands.set_derivative(flight.u_dot_parachute)
406+
# The descent dynamics carry this parachute, so the phase keeps
407+
# using the right one even when another parachute deploys later.
408+
kwargs["event"].commands.set_derivative(
409+
flight.u_dot_parachute.rebind(parachute=self)
410+
)
399411
kwargs["event"].commands.start_flight_phase(
400412
f"{self.name}_parachute_descent",
401413
lag=self.lag,
402-
parachute=self,
403414
)
404415

405416
# Resolve effective needs: explicit override > auto-detected default.

rocketpy/simulation/events/commands.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ def reset(self):
1919
self.new_flight_phase = None
2020
self.new_flight_phase_name = None
2121
self.new_flight_phase_lag = 0
22-
self.new_flight_phase_parachute = None
2322
self._terminate = False
2423
self.terminate_phase_name = None
2524

@@ -39,11 +38,10 @@ def set_derivative(self, derivative):
3938
self.new_derivative = derivative
4039
self.new_derivative_set = True
4140

42-
def start_flight_phase(self, phase_name=None, lag=0, parachute=None):
41+
def start_flight_phase(self, phase_name=None, lag=0):
4342
self.new_flight_phase = True
4443
self.new_flight_phase_name = phase_name
4544
self.new_flight_phase_lag = lag
46-
self.new_flight_phase_parachute = parachute
4745

4846
def terminate_flight(self):
4947
self._terminate = True

rocketpy/simulation/events/event.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
PRESETS = {
1717
"apogee": lambda **kwargs: (
1818
len(kwargs["flight"].solution) >= 2
19-
and kwargs["flight"].solution.view(-2)[1]["vz"] > 0 >= kwargs["state"][5]
19+
and kwargs["flight"].solution.at_index(-2)["vz"] > 0 >= kwargs["state"][5]
2020
),
2121
"burnout": lambda **kwargs: (
2222
kwargs.get("time") >= kwargs["rocket"].motor.burn_out_time
@@ -83,7 +83,26 @@ def __init__( # pylint: disable=too-many-arguments
8383
``step_size`` (float, s),
8484
``height_agl`` (float, m),
8585
``event`` (this :class:`Event` instance),
86-
``sampling_rate`` (float, Hz or ``None``).
86+
``sampling_rate`` (float, Hz or ``None``),
87+
``previous_check_state`` (the ``state`` from the previous time this
88+
event was checked, or ``None`` on the first check),
89+
``previous_check_time`` (float, s, or ``None`` on the first check).
90+
91+
To fire when a value crosses a threshold, compare ``state``
92+
against ``previous_check_state``. Those two are always consecutive
93+
checks of this same event, so a crossing cannot slip between
94+
them::
95+
96+
def trigger(**kwargs):
97+
previous = kwargs["previous_check_state"]
98+
if previous is None:
99+
return False
100+
return previous[5] > 0 >= kwargs["state"][5]
101+
102+
Do not use ``state_history[-1]`` for this. That is the last
103+
*stored* trajectory point, which advances independently of when
104+
this event is checked, so a crossing can fall between the two and
105+
never be seen.
87106
The following keys are only injected when declared via ``needs``:
88107
``pressure`` (float, Pa),
89108
``state_dot`` (list, time derivative of ``state``),
@@ -215,6 +234,10 @@ def __init__( # pylint: disable=too-many-arguments
215234
self.callback_log = []
216235
self.triggered_times = []
217236
self._trigger_checked = False
237+
# State and time from the last time this event's trigger was checked.
238+
# ``None`` until the first check.
239+
self._previous_state = None
240+
self._previous_time = None
218241

219242
self.is_discrete = self.sampling_rate is not None
220243
self.sampling_interval = (
@@ -275,6 +298,8 @@ def reset(self):
275298
self.callback_log.clear()
276299
self.triggered_times.clear()
277300
self._trigger_checked = False
301+
self._previous_state = None
302+
self._previous_time = None
278303
self.context = deepcopy(self._initial_context)
279304

280305
# Restore enable/disable time history to initial snapshot
@@ -327,14 +352,27 @@ def __call__(self, trigger_only=False, callback_only=False, reset=True, **kwargs
327352

328353
kwargs["event"] = self
329354
kwargs["sampling_rate"] = self.sampling_rate
355+
# What this event saw the last time it was checked. Comparing against
356+
# it is the reliable way to notice a value crossing a threshold, since
357+
# the two states are always consecutive checks of this same event.
358+
kwargs["previous_check_state"] = self._previous_state
359+
kwargs["previous_check_time"] = self._previous_time
330360

331361
# --- Trigger Phase ---
332362
# Skip evaluating triggers if we are only running the callback.
333363
if callback_only is False:
334364
if self._call_enable_on(**kwargs) is False:
335365
return False
336366

337-
if self._call_trigger(**kwargs) is False:
367+
triggered = self._call_trigger(**kwargs)
368+
369+
# Remember this check, whether or not it triggered, so the next one
370+
# can be compared against it. Recorded before the early return so a
371+
# trigger that did not fire is still part of the sequence.
372+
self._previous_state = kwargs.get("state")
373+
self._previous_time = kwargs.get("time")
374+
375+
if triggered is False:
338376
return False
339377

340378
if trigger_only:
@@ -447,7 +485,7 @@ def _compute_exact_time(self, **kwargs):
447485
# The exact-time functions read the state by its canonical position, so
448486
# feed them canonical endpoints and a canonical view of the dense
449487
# output. The raw dense output is kept so the exact state can be written
450-
# back into the (possibly reduced) tail segment of the solution.
488+
# back into the (possibly reduced) tail phase of the solution.
451489
schema = phase.dynamics.schema
452490
raw_interpolator = phase.solver.dense_output()
453491
if schema.is_canonical:

rocketpy/simulation/events/event_builders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def apogee_trigger(**kwargs):
118118
if len(flight.apogee_state) != 1 or len(flight.solution) < 2:
119119
return False
120120

121-
previous_vz = flight.solution.view(-2)[1]["vz"]
121+
previous_vz = flight.solution.at_index(-2)["vz"]
122122
current_vz = state[5]
123123
return previous_vz > 0 >= current_vz
124124

0 commit comments

Comments
 (0)