|
| 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