Skip to content

Commit 1dd2365

Browse files
thatrandomasiandevGui-FernandesBRclaude
authored
DOC: add SIL parachute ejection integration example (#524) (#1131)
* DOC: add SIL parachute ejection integration example (#524) * DOC: make the SIL mechanism table render as a table RST has no pipe-table syntax. A line starting with "|" is parsed as a line block, so the "Dual path: trigger vs controller callback" section rendered as a column of ragged lines with the pipes and the "---------" separator showing through, rather than as a table. Sphinx does not warn about this, which is why the docs job stayed green and it went unnoticed. Converted to a list-table, matching the two-column explanatory tables already used in docs/user/flight.rst (:header-rows: 1, :widths: 30 70). Verified by building the page with Sphinx 8.1.3 under -W --keep-going: the section now emits one <table class="docutils"> with a real header row, where before it emitted zero tables and one line-block. No new warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gui-FernandesBR <guilherme_fernandes@usp.br> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 24861fd commit 1dd2365

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

‎docs/user/index.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ RocketPy's User Guide
2626
Compare Flights Class<compare_flights.rst>
2727
Flight Comparator Class <flight_comparator.rst>
2828
Parachute Triggers (Acceleration-Based) <parachute_triggers.rst>
29+
Software-in-the-Loop Parachute Ejection <sil_parachute_ejection.rst>
2930
Deployable Payload <deployable.rst>
3031
Controllers <controllers.rst>
3132
Air Brakes Example <airbrakes.rst>

‎docs/user/parachute_triggers.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ a lower altitude:
274274
See Also
275275
--------
276276

277+
- :doc:`Software-in-the-Loop Parachute Ejection <sil_parachute_ejection>`
277278
- :doc:`Parachute Class Reference </reference/classes/Parachute>`
278279
- :doc:`Flight Simulation </user/flight>`
279280
- :doc:`Sensors </notebooks/sensors>`
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
.. _sil-parachute-ejection:
2+
3+
Software-in-the-Loop Parachute Ejection
4+
=======================================
5+
6+
RocketPy can run a **Software-in-the-Loop (SIL)** recovery system by calling
7+
your ejection-detection algorithm from the parachute trigger (and, when useful,
8+
from a discrete :doc:`controller <controllers>`). During ``Flight``, RocketPy
9+
samples pressure, height and the state vector at the parachute
10+
``sampling_rate`` and asks your callable whether to fire the ejection charge.
11+
12+
This page shows a minimal SIL pattern: a small stateful detector that mimics a
13+
flight-computer recovery algorithm, wired as a parachute ``trigger``. For a
14+
full research-grade example that wraps a compiled C/C++ recovery stack
15+
(``SisRec``) inside Monte Carlo runs, see the
16+
`Valetudo Monte Carlo analysis <https://github.com/RocketPy-Team/RocketPaper/tree/main/Valetudo_Monte_Carlo>`_.
17+
18+
.. seealso::
19+
20+
- :doc:`Parachute Triggers (Acceleration-Based) <parachute_triggers>`:
21+
trigger signatures, ``u_dot``, and sensor-aware callables.
22+
- :doc:`Controllers <controllers>`: discrete sampling of in-flight logic.
23+
- :ref:`Parachute Trigger Details <triggerdetails>`: callable
24+
``(pressure, height, state)`` contract on ``Rocket.add_parachute``.
25+
26+
Why SIL for recovery
27+
--------------------
28+
29+
Real recovery firmware rarely uses the ideal ``"apogee"`` or altitude string
30+
triggers. It filters barometer (and often IMU) samples, keeps an internal
31+
flight phase, and decides when to fire drogue or main charges. In SIL you:
32+
33+
1. Keep that detection logic in a callable (pure Python, or a thin wrapper
34+
around C/C++/Rust via SWIG, ctypes, cffi, or pybind11).
35+
2. Attach the callable as ``trigger=...`` on ``Rocket.add_parachute``.
36+
3. Let ``Flight`` drive the loop at a fixed ``sampling_rate`` (Hz), with
37+
optional ``noise`` on the pressure channel and ``lag`` for charge-to-open
38+
delay.
39+
40+
RocketPy then evaluates aerodynamics with the canopy after the lag, so you can
41+
compare trigger times, inflation velocities and landing footprints against the
42+
same algorithm that flies on the vehicle.
43+
44+
Simplified ejection detector
45+
----------------------------
46+
47+
The following class is a **teaching stand-in** for a barometric apogee
48+
detector. It is not Valetudo's ``SisRec``; it only illustrates the stateful
49+
pattern: feed noisy pressure each sample, return a discrete flight state, and
50+
map that state to a boolean trigger.
51+
52+
.. code-block:: python
53+
54+
class SimpleBarometricEjectionDetector:
55+
"""Minimal pressure-based apogee detector for SIL demos.
56+
57+
States:
58+
0: armed / ascent
59+
1: apogee candidate (pressure rising while still high)
60+
2: drogue fire command
61+
"""
62+
63+
def __init__(self, min_ascent_samples=20, pressure_eps=20.0):
64+
self.min_ascent_samples = min_ascent_samples
65+
self.pressure_eps = pressure_eps # Pa
66+
self.reset()
67+
68+
def reset(self):
69+
self.samples = 0
70+
self.min_pressure = None
71+
self.state = 0
72+
73+
def update(self, pressure_pa):
74+
"""Ingest one pressure sample [Pa]; return internal state."""
75+
self.samples += 1
76+
if self.min_pressure is None or pressure_pa < self.min_pressure:
77+
self.min_pressure = pressure_pa
78+
if self.state == 1:
79+
self.state = 0
80+
return self.state
81+
82+
# Pressure rising relative to the running minimum → descending.
83+
if (
84+
self.samples >= self.min_ascent_samples
85+
and pressure_pa > self.min_pressure + self.pressure_eps
86+
):
87+
self.state = 1 if self.state == 0 else 2
88+
return self.state
89+
90+
91+
Wire the detector as a parachute trigger
92+
----------------------------------------
93+
94+
A parachute trigger receives freestream pressure (with the parachute
95+
``noise`` model applied), height AGL, and the state vector. Return ``True``
96+
exactly when your algorithm commands the charge.
97+
98+
.. code-block:: python
99+
100+
from rocketpy import Environment, SolidMotor, Rocket, Flight
101+
102+
env = Environment(latitude=32.99, longitude=-106.97, elevation=1400)
103+
env.set_atmospheric_model(type="standard_atmosphere")
104+
105+
# Build motor + rocket as usual (Calisto / your vehicle).
106+
# motor = SolidMotor(...)
107+
# rocket = Rocket(...)
108+
# rocket.add_motor(motor, position=...)
109+
# rocket.add_nose(...); rocket.add_trapezoidal_fins(...); ...
110+
111+
drogue_detector = SimpleBarometricEjectionDetector(
112+
min_ascent_samples=25,
113+
pressure_eps=30.0,
114+
)
115+
116+
def drogue_sil_trigger(pressure, height, state_vector):
117+
# Optional guards: ignore rail / early flight.
118+
if height < 50.0:
119+
return False
120+
state = drogue_detector.update(pressure)
121+
return state == 2 # fire when detector reaches "drogue command"
122+
123+
rocket.add_parachute(
124+
name="Drogue",
125+
cd_s=1.0,
126+
trigger=drogue_sil_trigger,
127+
sampling_rate=100, # Hz; match your flight computer loop rate
128+
lag=1.5, # seconds between fire command and full open
129+
noise=(0, 8.3, 0.5), # (mean, std, time-correlation) on pressure [Pa]
130+
)
131+
132+
133+
def main_sil_trigger(pressure, height, state_vector):
134+
# Main: descending and below a deployment altitude.
135+
vz = state_vector[5]
136+
return vz < -1.0 and height < 800.0
137+
138+
rocket.add_parachute(
139+
name="Main",
140+
cd_s=10.0,
141+
trigger=main_sil_trigger,
142+
sampling_rate=100,
143+
lag=0.5,
144+
noise=(0, 8.3, 0.5),
145+
)
146+
147+
flight = Flight(
148+
rocket=rocket,
149+
environment=env,
150+
rail_length=5.2,
151+
inclination=85,
152+
heading=0,
153+
# Stop the integrator on trigger sample times when using discrete rates:
154+
time_overshoot=False,
155+
)
156+
157+
print(flight.parachute_events)
158+
flight.info()
159+
160+
.. tip::
161+
162+
Reset detector state (``drogue_detector.reset()``) before every new
163+
``Flight`` if you reuse the same detector instance across Monte Carlo
164+
samples or parameter sweeps.
165+
166+
.. note::
167+
168+
Set ``time_overshoot=False`` when the SIL loop must see samples at exactly
169+
``1 / sampling_rate``. The same rule applies to discrete
170+
:doc:`controllers <controllers>`.
171+
172+
Dual path: trigger vs controller callback
173+
-----------------------------------------
174+
175+
.. list-table::
176+
:header-rows: 1
177+
:widths: 30 70
178+
179+
* - Mechanism
180+
- Use when
181+
* - Parachute ``trigger``
182+
- The algorithm's output is "fire this canopy now". This is the usual SIL
183+
recovery path.
184+
* - Controller function (:doc:`controllers`)
185+
- You need a fixed-rate loop that updates actuators (air brakes, canards)
186+
or logs observed variables alongside recovery logic.
187+
* - Acceleration / sensor triggers (:doc:`parachute_triggers`)
188+
- The algorithm needs ``u_dot`` or attached IMU/barometer sensor objects
189+
instead of (or in addition to) the noisy pressure channel.
190+
191+
You can combine them: a controller may update shared state that a parachute
192+
trigger reads, or a 5-argument trigger can read ``sensors`` and ``u_dot``
193+
directly. Prefer a single source of truth for the fire decision so Monte Carlo
194+
and hardware stay aligned.
195+
196+
Wrapping compiled recovery firmware
197+
-----------------------------------
198+
199+
Issue #524's bonus path is hardware-faithful binaries, not a RocketPy API
200+
change. Practical options:
201+
202+
1. **Compile the flight algorithm** to a shared library (``.so`` / ``.dylib`` /
203+
``.dll``) with the same interface your avionics uses (for example
204+
``update(pressure) -> state``).
205+
2. **Expose it to Python** with SWIG (as Valetudo's ``SisRec`` does), ctypes,
206+
cffi, or pybind11.
207+
3. **Call it from the trigger** exactly like the pure-Python detector above.
208+
209+
Sketch of a SWIG/ctypes-style wrapper (API names are illustrative):
210+
211+
.. code-block:: python
212+
213+
# After building your recovery library and its Python wrapper:
214+
# import SisRec # Valetudo-style SWIG module
215+
#
216+
# detector = SisRec.SisRecSt(main_pressure_ratio, mu)
217+
# detector.initializeBuffers(p0)
218+
# detector.enable()
219+
#
220+
# def drogue_trigger(pressure, height, state_vector):
221+
# # SisRec historically expected pressure in bar-like units;
222+
# # convert to match your firmware's input convention.
223+
# return detector.update(pressure / 1e5) == detector.detectDrogue
224+
225+
Keep unit conversions and enable/reset semantics identical to the flight
226+
computer. The `Valetudo Monte Carlo folder
227+
<https://github.com/RocketPy-Team/RocketPaper/tree/main/Valetudo_Monte_Carlo>`_
228+
shows this pattern end-to-end with ``SisRec.py`` / ``_SisRec.so`` driving
229+
drogue deployment inside thousands of ``Flight`` runs.
230+
231+
Toward Hardware-in-the-Loop (optional)
232+
--------------------------------------
233+
234+
SIL stops at "same software, simulated sensors." A **Hardware-in-the-Loop
235+
(HIL)** step would stream RocketPy's pressure/IMU time history to the real
236+
flight computer (serial, CAN, or a board-level harness) and feed the board's
237+
fire discrete back into the simulation. RocketPy does not ship a HIL bridge
238+
today; teams usually:
239+
240+
- export ``Flight`` solution / sensor histories, or
241+
- call a thin I/O shim from a discrete controller / trigger that talks to the
242+
device under test.
243+
244+
Treat HIL as an integration project on top of the SIL trigger contract above,
245+
not as a built-in ``Flight`` mode.
246+
247+
See also
248+
--------
249+
250+
- `Valetudo Monte Carlo (RocketPaper) <https://github.com/RocketPy-Team/RocketPaper/tree/main/Valetudo_Monte_Carlo>`_
251+
- :doc:`parachute_triggers`
252+
- :doc:`controllers`
253+
- :doc:`stochastic`
254+
- :class:`rocketpy.Parachute`
255+
- :class:`rocketpy.Flight`

0 commit comments

Comments
 (0)