hpr_io/ork/staging.rs
1//! When a `.ork` design's motors light and where its stack comes apart, in the terms hpr's flight
2//! takes ([M1.9c][m1-9c], decision [ADR-076][adr-076]).
3//!
4//! **Ignition.** Each motor's `<ignitionevent>` and `<ignitiondelay>` ([`Ignition`]) become an
5//! [`hpr_design::Ignition`]. OpenRocket's words are measured by a committed probe (the
6//! [`.ork` format page][format]):
7//!
8//! | the file says | hpr lights the motor |
9//! |---|---|
10//! | `launch` plus `d` | at `t = d` |
11//! | `automatic` plus `d`, in the bottom stage | at `t = d` |
12//! | `automatic` plus `d`, in a stage above | as `ejectioncharge` |
13//! | `ejectioncharge` plus `d` | `x + d` after the burnout of the stage below's motor, whose ejection delay is `x` |
14//! | `burnout` plus `d` | `d` after the stage below's first burnout |
15//! | `never` | never ([`hpr_design::Ignition::Never`]) |
16//! | `burnout` or `ejectioncharge`, in the bottom stage | never: it has no stage below |
17//! | `ejectioncharge` or `automatic`, the stage below's motor plugged | never: that fires no charge |
18//!
19//! "The stage below" is the next stage aft. Its first burnout is its first motor's to burn out, by
20//! each motor's ignition and curve, the first in the file on a tie, as OpenRocket 24.12 flies it
21//! ([ADR-172][adr-172], `first_burnout.py`'s probe). For a charge its motors must sit in one mount (one tube
22//! or one cluster): which of several fires first is not measured. A motor that never lights is carried
23//! loaded, with no thrust, as OpenRocket 24.12 flies it ([M2.2e10][m2-2e10]), and so is one that
24//! waits on it, which has no burnout or charge to wait on (inferred: no probe chains two). `unlit_motors.py` sets the booster of OpenRocket's two-stage example so
25//! that it, or the sustainer waiting on its plugged charge, never lights: OpenRocket lights only
26//! the other motor and loses only that one's propellant. A configuration is not flown when a
27//! motor waits on a stage below that holds no motor, or on the charge of a stage below with
28//! motors in more than one mount, or of a motor below that states no ejection delay, or has a
29//! negative delay, or when no motor of it lights at all.
30//!
31//! **Separation.** A stage's `<separationevent>` says when it drops away from the stage ahead of
32//! it. hpr flies a separation, as a [`Staging`], when its time is known before the flight (a time
33//! after launch, or a motor's burnout or ejection charge). When the part ahead of it still has a
34//! motor to burn then, burning or due to light, that part is a sustainer, and hpr flies it on
35//! ([Staging][staging]). The motors of the part that drops away must have burned out by then, but
36//! at a stage's own `burnout` (its first) under power: there the stage's other motors drop still
37//! burning, as in OpenRocket, and the flight leaves their thrust out of the part's flight as a
38//! point ([ADR-172][adr-172]). A motor behind still to light is refused either way.
39//!
40//! A separation at its own motor's burnout or ejection charge, when that motor never lights, never
41//! comes, as in the probes, and is left out. One at the ignition of a motor that never lights, or
42//! at launch (hpr's flight fires a separation only once the rocket is off the rod), is not flown.
43//!
44//! A separation at `apogee` or at a height on the way down can only come at or after apogee, so the
45//! climb is the whole stack's in both programs, as long as every motor is spent by then, and the
46//! separation belongs to the descent, which hpr's flights of a `.ork` do not fly yet (the decision
47//! record on reading recovery, [ADR-056][adr-056]): it is left out, and the configuration flies
48//! whole. Whether every motor is spent by apogee is known only once flown; `cargo xtask
49//! ork-flights` checks it of every flight it reports. Any other separation with nothing ahead of
50//! it left to burn, such as a payload's at the booster's ejection charge, is read when it is the
51//! configuration's only one: hpr's flight flies each part from it as a point with its devices'
52//! drag alone (the decision record on separation, [ADR-014][adr-014]), so `hpr::ork::tumbling`
53//! flies it only when the part keeping the nose has a device of its own open from the split
54//! ([ADR-165][adr-165]). Beside another separation it is not flown, as each of several must hand
55//! the flight on to a sustainer.
56//!
57//! **Flying it.** A configuration with a [`Staging`] is among the design's rocket configurations,
58//! with each motor's ignition, but the separation is not part of it: flown without one, the stack
59//! would carry its booster to the ground with the sustainer lit on it. `hpr::ork::separation` turns
60//! a [`Staging`] into the flight's separation, and the example `ork_two_stage` in the `hpr` crate
61//! flies one.
62//!
63//! [m1-9c]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-9c
64//! [m2-2e10]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-2e10
65//! [adr-076]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-076-a-ork-files-ignitions-and-one-powered-separation-flown-against-openrocket-2026-09-25
66//! [adr-056]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-056-a-ork-designs-recovery-and-separation-read-as-written-with-openrockets-words-measured-2026-09-21
67//! [adr-014]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-014-separation-bodies-their-masses-and-their-descents-2026-09-17
68//! [adr-165]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0165-an-unpowered-separation-in-hpr-sim.md
69//! [adr-172]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0172-a-stage-s-first-burnout.md
70//! [format]: https://nrdptel.github.io/hpr-sim/format/ork.html#delays-and-ignition
71//! [staging]: https://nrdptel.github.io/hpr-sim/physics/staging.html
72
73use hpr_motor::Delay;
74use serde::{Deserialize, Serialize};
75
76use super::motors::{Ignition, IgnitionEvent, OrkMotor};
77use super::recovery::{SeparationEvent, StageSeparation};
78
79/// When a separation fires, in the terms of hpr's flight triggers
80/// (`hpr_sim::recovery::Trigger`, which this crate does not depend on).
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
82#[serde(rename_all = "snake_case", deny_unknown_fields)]
83#[non_exhaustive]
84pub enum StagingTrigger {
85 /// At a time after launch, s.
86 Time {
87 /// The time after launch, s.
88 time_s: f64,
89 },
90 /// A delay after the burnout of the motor in a mount (the first tube's, for a cluster, whose
91 /// tubes light together).
92 Burnout {
93 /// The mount's component id.
94 mount: String,
95 /// The delay after its burnout, s.
96 delay_s: f64,
97 },
98}
99
100/// A separation a configuration flies: where the stack comes apart and when. It is powered when
101/// the part ahead of it has a motor burning or still to light then (the flight's test,
102/// `hpr_sim::Separation::powered_at`), and otherwise the configuration's only separation.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
104#[serde(deny_unknown_fields)]
105#[non_exhaustive]
106pub struct Staging {
107 /// The last stage that stays with the nose; the stages after it drop away.
108 pub after_stage: usize,
109 /// When.
110 pub trigger: StagingTrigger,
111 /// When that is, s after launch: known before the flight.
112 pub time_s: f64,
113 /// Whether it drops motors of its own stage still burning, as OpenRocket's first burnout of a
114 /// stage drops the stage's other motors (the decision record on a stage's first burnout,
115 /// [ADR-172][adr-172]): only a split at the stage's own `burnout` under power does. The flight
116 /// leaves their thrust out of the dropped part's flight as a point.
117 ///
118 /// [adr-172]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0172-a-stage-s-first-burnout.md
119 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120 pub drops_burning: bool,
121}
122
123/// The motors of `motors` in stage `stage`, with the one mount they sit in, or why there is no
124/// single such mount.
125fn one_mount(motors: &[OrkMotor], stage: usize) -> Result<Option<&OrkMotor>, String> {
126 let mut in_stage = motors.iter().filter(|m| m.stage == stage);
127 let Some(first) = in_stage.next() else {
128 return Ok(None);
129 };
130 // A mount holds one motor per configuration, so each other motor is another mount.
131 let others = in_stage.filter(|m| m.mount != first.mount).count();
132 if others > 0 {
133 return Err(format!(
134 "stage {stage} holds motors in {} mounts, and which fires its charge first is not \
135 measured",
136 others + 1
137 ));
138 }
139 Ok(Some(first))
140}
141
142/// Each of `motors`' ignition in hpr's terms, in their order, given the stage each stage hangs on
143/// (`None` for a stage on the axis, [`hpr_design::ParallelStage`]); or, for each, why hpr can't
144/// light it as the file says ([`ignition`]).
145///
146/// A motor lit at the stage below's `burnout` waits on that stage's first burnout, as OpenRocket
147/// 24.12 flies it ([ADR-172][adr-172], `first_burnout.py`'s probe): the first of its motors to
148/// burn out, each at its ignition plus its curve's burn time, the first in the file on a tie (no
149/// probe sets one). A stage in one mount needs no curve for it: its motor is its own first. With
150/// none of a stage's motors lit, the first in the file stands for them: its burnout never comes
151/// ([`never_lit`]). A motor waits only on stages further aft, so one pass from the tail forward
152/// settles every stage's first burnout before any motor waits on it, each motor once.
153///
154/// [adr-172]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0172-a-stage-s-first-burnout.md
155pub(super) fn ignitions(
156 motors: &[OrkMotor],
157 hung_on: &[Option<usize>],
158) -> Vec<Result<hpr_design::Ignition, String>> {
159 // Every motor is in one stage, so each entry is written below.
160 let mut lit: Vec<Result<hpr_design::Ignition, String>> =
161 vec![Ok(hpr_design::Ignition::Never); motors.len()];
162 // Each motor's burnout, s after launch: `None` when it never lights.
163 let mut out_s: Vec<Result<Option<f64>, String>> = vec![Ok(None); motors.len()];
164 // Each stage's first burnout, by the motor's index: `None` when it holds no motor.
165 let mut firsts: Vec<Result<Option<usize>, String>> = vec![Ok(None); hung_on.len()];
166 for stage in (0..hung_on.len()).rev() {
167 let in_stage: Vec<usize> = (0..motors.len())
168 .filter(|&i| motors[i].stage == stage)
169 .collect();
170 for &i in &in_stage {
171 lit[i] = ignition(&motors[i], motors, hung_on, &firsts);
172 let lit_s = match &lit[i] {
173 Ok(hpr_design::Ignition::Launch) => Ok(Some(0.0)),
174 Ok(hpr_design::Ignition::Time { time_s }) => Ok(Some(*time_s)),
175 Ok(hpr_design::Ignition::Burnout { mount, delay_s }) => motors
176 .iter()
177 .position(|m| m.mount == *mount)
178 .ok_or_else(|| format!("no motor sits in mount `{mount}`"))
179 .and_then(|waited| out_s[waited].clone())
180 .map(|waited_s| waited_s.map(|waited_s| waited_s + delay_s)),
181 Ok(hpr_design::Ignition::Never) => Ok(None),
182 Ok(_) => Err(format!(
183 "{} lights at an event hpr can't time",
184 motors[i].designation
185 )),
186 Err(why) => Err(why.clone()),
187 };
188 out_s[i] = lit_s.and_then(|lit_s| {
189 lit_s
190 .map(|lit_s| burn_s(&motors[i]).map(|burn_s| lit_s + burn_s))
191 .transpose()
192 });
193 }
194 let Some(&first) = in_stage.first() else {
195 continue;
196 };
197 firsts[stage] = if in_stage
198 .iter()
199 .all(|&i| motors[i].mount == motors[first].mount)
200 {
201 Ok(Some(first))
202 } else {
203 in_stage
204 .iter()
205 .try_fold(None, |earliest: Option<(f64, usize)>, &i| {
206 Ok(match out_s[i].clone()? {
207 Some(at_s) if earliest.is_none_or(|(least_s, _)| at_s < least_s) => {
208 Some((at_s, i))
209 }
210 _ => earliest,
211 })
212 })
213 .map(|earliest| Some(earliest.map_or(first, |(_, i)| i)))
214 };
215 }
216 // A motor in no stage of the rocket's waits on none of its stages.
217 for i in (0..motors.len()).filter(|&i| motors[i].stage >= hung_on.len()) {
218 lit[i] = ignition(&motors[i], motors, hung_on, &firsts);
219 }
220 lit
221}
222
223/// `delay_s`, or why it can't be flown: a delay is a time after its event.
224fn delay(delay_s: f64, what: &str) -> Result<f64, String> {
225 if delay_s.is_finite() && delay_s >= 0.0 {
226 Ok(delay_s)
227 } else {
228 Err(format!(
229 "its {what} delay, {delay_s} s, is not a time after its event"
230 ))
231 }
232}
233
234/// When `motor` lights in hpr's terms, given every motor of its configuration, the stage each
235/// stage hangs on (`None` for a stage on the axis, [`hpr_design::ParallelStage`]) and the first
236/// burnout of each stage aft of `motor`'s ([`ignitions`]); or why hpr can't light it as the file
237/// says.
238///
239/// The stage below is the next stage on the axis: a parallel stage is beside its stage, not below
240/// it. A motor in a parallel stage lights `automatic`ally as one in the stage it hangs on does, at
241/// launch when that is the bottom stage, as OpenRocket 24.12 lights its parallel-booster
242/// example's E12s with the core's I115W (`validation/fixtures/ork/openrocket-flights.json`); at
243/// the stage below's events it is not lit (ADR-171).
244fn ignition(
245 motor: &OrkMotor,
246 motors: &[OrkMotor],
247 hung_on: &[Option<usize>],
248 firsts: &[Result<Option<usize>, String>],
249) -> Result<hpr_design::Ignition, String> {
250 let Ignition { event, delay_s } = &motor.ignition;
251 let delay_s = delay(*delay_s, "ignition")?;
252 let at = |time_s: f64| {
253 if time_s == 0.0 {
254 hpr_design::Ignition::Launch
255 } else {
256 hpr_design::Ignition::Time { time_s }
257 }
258 };
259 let axial = |stage: usize| hung_on.get(stage).is_some_and(Option::is_none);
260 let last_stage = (0..hung_on.len()).rev().find(|&stage| axial(stage));
261 let carrier = hung_on.get(motor.stage).copied().flatten();
262 if let Some(carrier) = carrier {
263 return match event {
264 IgnitionEvent::Launch => Ok(at(delay_s)),
265 IgnitionEvent::Automatic if Some(carrier) == last_stage => Ok(at(delay_s)),
266 IgnitionEvent::Never => Ok(hpr_design::Ignition::Never),
267 IgnitionEvent::Other(word) => Err(format!("its ignition event `{word}` is not known")),
268 _ => Err(format!(
269 "it lights at `{}` in a parallel stage, which hpr has no reading for",
270 event.as_str()
271 )),
272 };
273 }
274 // The stage below's motor, or `None` in the bottom stage, which has no stage below: its event
275 // never comes, and OpenRocket flies the motor unlit. Its first burnout's for a `burnout`,
276 // its one mount's for a charge.
277 let below = |at_burnout: bool| -> Result<Option<&OrkMotor>, String> {
278 let Some(below) = (motor.stage + 1..hung_on.len()).find(|&stage| axial(stage)) else {
279 return Ok(None);
280 };
281 if at_burnout {
282 firsts
283 .get(below)
284 .cloned()
285 .unwrap_or(Ok(None))?
286 .and_then(|first| motors.get(first))
287 } else {
288 one_mount(motors, below)?
289 }
290 .ok_or_else(|| {
291 format!(
292 "it lights at `{}` of the stage below, and stage {below} holds no motor",
293 event.as_str(),
294 )
295 })
296 .map(Some)
297 };
298 let ejection = || -> Result<hpr_design::Ignition, String> {
299 let Some(below) = below(false)? else {
300 return Ok(hpr_design::Ignition::Never);
301 };
302 match below.delay {
303 Some(Delay::Seconds(charge_s)) => Ok(hpr_design::Ignition::Burnout {
304 mount: below.mount.clone(),
305 delay_s: delay(charge_s, "ejection")? + delay_s,
306 }),
307 // Plugged: no charge fires, and OpenRocket flies the motor unlit.
308 Some(Delay::Plugged) => Ok(hpr_design::Ignition::Never),
309 _ => Err(format!(
310 "it lights at the ejection charge of {} in the stage below, which states no \
311 delay",
312 below.designation
313 )),
314 }
315 };
316 match event {
317 IgnitionEvent::Launch => Ok(at(delay_s)),
318 IgnitionEvent::Automatic if Some(motor.stage) == last_stage => Ok(at(delay_s)),
319 IgnitionEvent::Automatic | IgnitionEvent::EjectionCharge => ejection(),
320 IgnitionEvent::Burnout => Ok(below(true)?.map_or(hpr_design::Ignition::Never, |below| {
321 hpr_design::Ignition::Burnout {
322 mount: below.mount.clone(),
323 delay_s,
324 }
325 })),
326 IgnitionEvent::Never => Ok(hpr_design::Ignition::Never),
327 IgnitionEvent::Other(word) => Err(format!("its ignition event `{word}` is not known")),
328 }
329}
330
331/// How long `motor` burns, s.
332fn burn_s(motor: &OrkMotor) -> Result<f64, String> {
333 motor
334 .curve
335 .motor()
336 .map(hpr_motor::SolidMotor::burnout_time_s)
337 .ok_or_else(|| format!("{} has no thrust curve", motor.designation))
338}
339
340/// When each of `motors` lights, s after launch, given their ignitions `lit` in the same order:
341/// `None` for one that never lights ([`never_lit`]). Every other one is known before the flight: a
342/// `.ork` lights a motor at a time, or at the burnout of a motor in the stage below, whose time is
343/// known in turn.
344fn ignition_times_s(
345 motors: &[OrkMotor],
346 lit: &[hpr_design::Ignition],
347 burns_s: &[f64],
348) -> Result<Vec<Option<f64>>, String> {
349 let never = never_lit(motors, lit);
350 let mut times: Vec<Option<f64>> = vec![None; motors.len()];
351 // Each pass settles at least one motor while any can be, so this many passes settle them all.
352 for _ in 0..motors.len() {
353 let mut settled = false;
354 for index in 0..motors.len() {
355 if times[index].is_some() || never[index] {
356 continue;
357 }
358 let time = match &lit[index] {
359 hpr_design::Ignition::Launch => Some(0.0),
360 hpr_design::Ignition::Time { time_s } => Some(*time_s),
361 hpr_design::Ignition::Burnout { mount, delay_s } => motors
362 .iter()
363 .position(|m| m.mount == *mount)
364 .and_then(|at| times[at].map(|time| time + burns_s[at] + delay_s)),
365 _ => None,
366 };
367 if time.is_some() {
368 times[index] = time;
369 settled = true;
370 }
371 }
372 if !settled {
373 break;
374 }
375 }
376 if (0..motors.len()).any(|index| times[index].is_none() && !never[index]) {
377 return Err("a motor's ignition waits on one whose time is not known".to_owned());
378 }
379 Ok(times)
380}
381
382/// `lit`, the ignitions of `motors` in the same order, with each motor that never lights
383/// ([`never_lit`]) written as [`hpr_design::Ignition::Never`]; or why the configuration can't be
384/// flown: no motor of it lights.
385pub(super) fn never_when_waiting_on_never(
386 motors: &[OrkMotor],
387 lit: Vec<hpr_design::Ignition>,
388) -> Result<Vec<hpr_design::Ignition>, String> {
389 let never = never_lit(motors, &lit);
390 if never.iter().all(|&never| never) {
391 return Err("no motor of it ever lights, so the rocket would not leave the pad".to_owned());
392 }
393 Ok(lit
394 .into_iter()
395 .zip(never)
396 .map(|(ignition, never)| {
397 if never {
398 hpr_design::Ignition::Never
399 } else {
400 ignition
401 }
402 })
403 .collect())
404}
405
406/// Whether each of `motors`, lit by `lit` in the same order, never lights: it is set so
407/// ([`hpr_design::Ignition::Never`]), or lit by the burnout of a mount whose motors never light,
408/// as hpr's flight lights it.
409fn never_lit(motors: &[OrkMotor], lit: &[hpr_design::Ignition]) -> Vec<bool> {
410 let mut never: Vec<bool> = lit
411 .iter()
412 .map(|ignition| *ignition == hpr_design::Ignition::Never)
413 .collect();
414 // Each pass settles at least one more link of a chain, so this many passes settle them all.
415 for _ in 0..motors.len() {
416 let mut changed = false;
417 for index in 0..motors.len() {
418 if never[index] {
419 continue;
420 }
421 if let hpr_design::Ignition::Burnout { mount, .. } = &lit[index] {
422 let mut in_mount = (0..motors.len()).filter(|&i| motors[i].mount == *mount);
423 if in_mount.all(|i| never[i]) {
424 never[index] = true;
425 changed = true;
426 }
427 }
428 }
429 if !changed {
430 break;
431 }
432 }
433 never
434}
435
436/// The ignition of the motors in `stage` as a separation's trigger `delay_s` after it, when they
437/// light together at a time or a burnout.
438fn lit_at(
439 motors: &[OrkMotor],
440 lit: &[hpr_design::Ignition],
441 stage: usize,
442 delay_s: f64,
443 what: &str,
444) -> Result<StagingTrigger, String> {
445 let mut ignitions = motors.iter().zip(lit).filter(|(m, _)| m.stage == stage);
446 let Some((_, first)) = ignitions.next() else {
447 return Err(format!(
448 "it separates at {what}'s ignition, and stage {stage} holds no motor"
449 ));
450 };
451 if ignitions.any(|(_, other)| other != first) {
452 return Err(format!(
453 "it separates at {what}'s ignition, and stage {stage}'s motors light at different times"
454 ));
455 }
456 match first {
457 hpr_design::Ignition::Launch => Ok(StagingTrigger::Time { time_s: delay_s }),
458 hpr_design::Ignition::Time { time_s } => Ok(StagingTrigger::Time {
459 time_s: time_s + delay_s,
460 }),
461 hpr_design::Ignition::Burnout {
462 mount,
463 delay_s: after_s,
464 } => Ok(StagingTrigger::Burnout {
465 mount: mount.clone(),
466 delay_s: after_s + delay_s,
467 }),
468 hpr_design::Ignition::Never => Err(format!(
469 "it separates at {what}'s ignition, which never comes"
470 )),
471 // The `.ork` reading never gives one, and a separation timed from the ignition that a
472 // separation causes could never fire.
473 _ => Err(format!(
474 "it separates at {what}'s ignition, which waits on a separation"
475 )),
476 }
477}
478
479/// The separations configuration `id` flies, in the order they fire (from the tail
480/// forward), none when it flies none, or why hpr can't fly its stages as the file says. `motors`
481/// are its motors and `lit` their ignitions, in order; `stages` is the count of the rocket's
482/// stages.
483pub(super) fn staging(
484 id: &str,
485 motors: &[OrkMotor],
486 lit: &[hpr_design::Ignition],
487 separations: &[StageSeparation],
488 hung_on: &[Option<usize>],
489) -> Result<Vec<Staging>, String> {
490 let stages = hung_on.len();
491 let never = never_lit(motors, lit);
492 // A stage's own motors, when it holds some and none of them ever lights: a separation at
493 // their burnout or ejection charge never comes, and OpenRocket keeps the stage on. A
494 // parallel stage holding none keeps on too: OpenRocket 24.12 flies its parallel-booster
495 // example's `[I115W-10; None]` with the boosters on to the ground (ADR-171).
496 let unlit = |stage: usize| {
497 let mut own = (0..motors.len()).filter(|&i| motors[i].stage == stage);
498 let parallel = hung_on.get(stage).is_some_and(Option::is_some);
499 (parallel || own.clone().next().is_some()) && own.all(|i| never[i])
500 };
501 let mut active = Vec::new();
502 for stage in 1..stages {
503 let setting = separations
504 .iter()
505 .find(|s| s.stage == stage)
506 .map(|s| s.separation_in(id));
507 match setting.and_then(|s| s.event.as_ref().map(|event| (event, s))) {
508 Some((SeparationEvent::Never, _)) => {}
509 Some((SeparationEvent::Burnout | SeparationEvent::Ejection, _)) if unlit(stage) => {}
510 Some((event, setting)) => active.push((stage, event, setting)),
511 None => return Err(format!("stage {stage} states no separation event")),
512 }
513 }
514 let late = |event: &SeparationEvent| {
515 matches!(
516 event,
517 SeparationEvent::Apogee | SeparationEvent::AltitudeDescending
518 )
519 };
520 if let [(_, event, _)] = active.as_slice()
521 && late(event)
522 {
523 // At or after apogee: the climb is the whole stack's, and the descent is not flown. That
524 // assumes every motor is spent by apogee, which `cargo xtask ork-flights` checks of each
525 // flight.
526 return Ok(Vec::new());
527 }
528 if active.len() > 1
529 && let Some((stage, ..)) = active.iter().find(|(_, event, _)| late(event))
530 {
531 return Err(format!(
532 "{} stages separate, stage {stage} at or after apogee, and hpr flies more than one \
533 separation only under power",
534 active.len()
535 ));
536 }
537 // From the tail forward, as the stack drops them; each must come no earlier than the one
538 // behind it, which holds its stages on until then.
539 let mut flown: Vec<Staging> = Vec::with_capacity(active.len());
540 for &(stage, event, setting) in active.iter().rev() {
541 // The stage above a parallel stage is no stage: it hangs beside one.
542 if hung_on.get(stage).is_some_and(Option::is_some)
543 && matches!(event, SeparationEvent::UpperIgnition)
544 {
545 return Err(format!(
546 "stage {stage}, a parallel stage, separates at `{}`, which hpr has no reading for",
547 event.as_str()
548 ));
549 }
550 let staging = separation(motors, lit, (stage, active.len() == 1), event, setting)?;
551 if let Some(behind) = flown.last()
552 && staging.time_s < behind.time_s
553 {
554 return Err(format!(
555 "stage {stage} separates at {} s, before stage {} behind it does at {} s, while \
556 that stage still holds it",
557 staging.time_s,
558 behind.after_stage + 1,
559 behind.time_s
560 ));
561 }
562 flown.push(staging);
563 }
564 Ok(flown)
565}
566
567/// The separation at the boundary ahead of stage `stage` on `event`, or why hpr can't fly it as
568/// the file says. `alone` says it is the configuration's only one: only then may it come with
569/// nothing ahead of it left to burn.
570fn separation(
571 motors: &[OrkMotor],
572 lit: &[hpr_design::Ignition],
573 (stage, alone): (usize, bool),
574 event: &SeparationEvent,
575 setting: &super::recovery::Separation,
576) -> Result<Staging, String> {
577 let delay_s = delay(setting.delay_s.unwrap_or(0.0), "separation")?;
578 let own = || {
579 one_mount(motors, stage)?
580 .ok_or_else(|| format!("stage {stage} separates at its own motor, and holds none"))
581 };
582 let burns_s = motors.iter().map(burn_s).collect::<Result<Vec<_>, _>>()?;
583 let times_s = ignition_times_s(motors, lit, &burns_s)?;
584 // The stage's first burnout (ADR-172): its lit motors' earliest, the first in the file on a
585 // tie; `staging` lets through only a stage with a motor that lights.
586 let first_out = || {
587 if !motors.iter().any(|m| m.stage == stage) {
588 return Err(format!(
589 "stage {stage} separates at its own motor, and holds none"
590 ));
591 }
592 (0..motors.len())
593 .filter(|&i| motors[i].stage == stage)
594 .filter_map(|i| times_s[i].map(|lit_s| (lit_s + burns_s[i], i)))
595 .fold(
596 None,
597 |earliest: Option<(f64, usize)>, (out_s, i)| match earliest {
598 Some((least_s, _)) if least_s <= out_s => earliest,
599 _ => Some((out_s, i)),
600 },
601 )
602 .map(|(_, i)| &motors[i])
603 .ok_or_else(|| format!("stage {stage} separates at its own motor, and none lights"))
604 };
605 let trigger = match event {
606 SeparationEvent::Apogee | SeparationEvent::AltitudeDescending => {
607 return Err(format!(
608 "stage {stage} separates at or after apogee, beside another separation"
609 ));
610 }
611 SeparationEvent::Launch => StagingTrigger::Time { time_s: delay_s },
612 SeparationEvent::Ignition => lit_at(motors, lit, stage, delay_s, "its own motor")?,
613 SeparationEvent::UpperIgnition => {
614 lit_at(motors, lit, stage - 1, delay_s, "the stage above")?
615 }
616 SeparationEvent::Burnout => StagingTrigger::Burnout {
617 mount: first_out()?.mount.clone(),
618 delay_s,
619 },
620 SeparationEvent::Ejection => {
621 let own = own()?;
622 match own.delay {
623 Some(Delay::Seconds(charge_s)) => StagingTrigger::Burnout {
624 mount: own.mount.clone(),
625 delay_s: delay(charge_s, "ejection")? + delay_s,
626 },
627 _ => {
628 return Err(format!(
629 "stage {stage} separates at the ejection charge of {}, which has none \
630 (plugged, or no delay given)",
631 own.designation
632 ));
633 }
634 }
635 }
636 SeparationEvent::AltitudeAscending => {
637 return Err(format!(
638 "stage {stage} separates at a height on the way up, which hpr has no trigger for"
639 ));
640 }
641 _ => {
642 return Err(format!(
643 "stage {stage} separates at `{}`, which hpr has no trigger for",
644 event.as_str()
645 ));
646 }
647 };
648 let time_s = match &trigger {
649 StagingTrigger::Time { time_s } => *time_s,
650 StagingTrigger::Burnout { mount, delay_s } => {
651 let at = motors
652 .iter()
653 .position(|m| m.mount == *mount)
654 .ok_or_else(|| format!("no motor sits in mount `{mount}`"))?;
655 // The stage's motors light (`unlit` let through no other), so the time is known.
656 let lit_s = times_s[at]
657 .ok_or_else(|| format!("stage {stage} separates at a motor that never lights"))?;
658 lit_s + burns_s[at] + delay_s
659 }
660 };
661 // hpr's test, when the separation fires: the part ahead has a motor burning or still to
662 // light, and the part behind has none (flight.rs). A motor that never lights does neither.
663 let burning =
664 |index: usize| times_s[index].is_some_and(|lit_s| lit_s + burns_s[index] > time_s);
665 // With nothing ahead of it left to burn, the flight flies every part as a point with its
666 // devices' drag alone from the split (ADR-014), which hands no sustainer on to another.
667 let powered = (0..motors.len()).any(|i| motors[i].stage < stage && burning(i));
668 if !alone && !powered {
669 return Err(format!(
670 "stage {stage} separates at {time_s} s with no motor ahead of it left to burn, beside \
671 another separation, and hpr flies more than one separation only under power"
672 ));
673 }
674 // At its stage's first burnout under power, the stage's other motors may still burn, as
675 // OpenRocket's stage drops them there: the flight drops them burning, their thrust left out
676 // of the part's descent (ADR-172). Each must have lit before the split; none still to light,
677 // none in another stage, and none at any other split.
678 let at_first_burnout = powered && matches!(event, SeparationEvent::Burnout);
679 let dropped_burning =
680 |i: usize, lit_s: f64| at_first_burnout && motors[i].stage == stage && lit_s < time_s;
681 if let Some((behind, lit_s)) = (0..motors.len())
682 .filter(|&i| motors[i].stage >= stage && burning(i))
683 .filter_map(|i| times_s[i].map(|lit_s| (i, lit_s)))
684 .find(|&(i, lit_s)| !dropped_burning(i, lit_s))
685 {
686 return Err(format!(
687 "stage {stage} separates at {time_s} s, while {} behind it burns until {} s",
688 motors[behind].designation,
689 lit_s + burns_s[behind]
690 ));
691 }
692 // hpr's flight fires a separation only once the rocket is off the rod (flight.rs), so one at
693 // launch would come late.
694 if time_s <= 0.0 {
695 return Err(format!(
696 "stage {stage} separates at launch, on the pad, where hpr's flight fires no separation"
697 ));
698 }
699 let drops_burning = (0..motors.len()).any(|i| motors[i].stage >= stage && burning(i));
700 Ok(Staging {
701 after_stage: stage - 1,
702 trigger,
703 time_s,
704 drops_burning,
705 })
706}