Skip to main content

hpr/
ork.rs

1//! Flying what an OpenRocket `.ork` file says about staging and recovery.
2//!
3//! `hpr_io` reads each of a `.ork` file's powered separations as an [`hpr_io::ork::Staging`],
4//! naming the motor it is timed from by its mount, since `hpr_io` does not depend on `hpr_sim`.
5//! [`separation`] turns one into the flight's [`hpr_sim::Separation`], naming that motor by its
6//! index among the assembled motors, and [`separations`] a configuration's list. The flight also
7//! needs a recovery device on each of the bodies the separations make (the decision record on
8//! staging, [ADR-074][adr-074]):
9//! [`separated_recovery`] puts the file's devices on their parts and adds the tumbles, as
10//! `hpr sim` flies it; the example `ork_two_stage` in this crate shows the pieces. The decision record for reading a `.ork` file's
11//! staging is [ADR-076][adr-076].
12//!
13//! [adr-074]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-074-ignition-times-and-powered-staging-the-sustainer-flies-on-as-a-rigid-body-2026-09-25
14//! [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
15
16use hpr_design::{Assembly, DesignError, Part};
17use hpr_io::ork::{DeployEvent, DeviceKind, Dimension, Recovery, Staging, StagingTrigger};
18use hpr_motor::Delay;
19use hpr_sim::recovery::{MIN_STREAMER_ASPECT_RATIO, StreamerModel};
20use hpr_sim::{Device, DeviceDrag, Separation, SimError, Trigger};
21
22/// The flight's separation for `staging`, with the motors of `assembly`, the configuration the
23/// `.ork` file's staging belongs to, assembled.
24///
25/// # Errors
26///
27/// [`SimError::Domain`] if the mount a separation is timed from places no lit motor in `assembly`
28/// (it is not that configuration's, or every tube of it is set to fail), or if `staging` holds a trigger this function does not know.
29pub fn separation(staging: &Staging, assembly: &Assembly) -> Result<Separation, SimError> {
30    let trigger = match &staging.trigger {
31        StagingTrigger::Time { time_s } => Trigger::Time { time_s: *time_s },
32        StagingTrigger::Burnout { mount, delay_s } => Trigger::Burnout {
33            // A cluster's tubes light together, so its first lit tube's burnout is the mount's, as
34            // `Assembly::ignition_times_s` takes it.
35            motor: assembly
36                .motors
37                .iter()
38                .position(|motor| motor.mounted.mount == *mount && !motor.fails)
39                .ok_or(SimError::Domain {
40                    what: "count of lit motors in the mount a separation is timed from",
41                    value: 0.0,
42                })?,
43            delay_s: *delay_s,
44        },
45        _ => {
46            return Err(SimError::Domain {
47                what: "kind of `.ork` separation trigger (one this version does not know)",
48                value: f64::NAN,
49            });
50        }
51    };
52    let separation = Separation::new(trigger, staging.after_stage);
53    // A split at a stage's first burnout drops the stage's other motors still burning, as
54    // OpenRocket's does (ADR-172); no other split may.
55    Ok(if staging.drops_burning {
56        separation.dropping_burning()
57    } else {
58        separation
59    })
60}
61
62/// The flight's separations for `stagings`, in the order they fire
63/// ([`hpr_io::ork::MotorConfiguration::stagings`]), each as [`separation`] maps it; give them to
64/// [`hpr_sim::Simulation::with_separations`].
65///
66/// # Errors
67///
68/// As [`separation`], for each.
69pub fn separations<'a>(
70    stagings: impl IntoIterator<Item = &'a Staging>,
71    assembly: &Assembly,
72) -> Result<Vec<Separation>, SimError> {
73    stagings
74        .into_iter()
75        .map(|staging| separation(staging, assembly))
76        .collect()
77}
78
79/// OpenRocket's drag coefficient for a parachute whose `<cd>` is `auto`, on the canopy's area
80/// `π D²/4`, `D` its diameter: the OpenRocket technical documentation v13.05, §4.2.5, citing
81/// S. F. Hoerner, *Fluid-Dynamic Drag* (1965), p. 13-23. OpenRocket 24.12 reads it back
82/// (`validation/fixtures/ork/openrocket-events.json`).
83pub const OPENROCKET_PARACHUTE_DRAG_COEFFICIENT: f64 = 0.8;
84
85/// A `.ork` configuration's recovery devices as the flight flies them ([`recovery`]).
86#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
87#[non_exhaustive]
88pub struct Recovered {
89    /// The devices that deploy, in file order, each named by its part's name (its id where the
90    /// name is empty).
91    pub devices: Vec<Device>,
92    /// The devices that never deploy in this configuration, by name, with why.
93    pub not_deployed: Vec<(String, NeverDeploys)>,
94    /// How many devices at the end of `devices` hpr added rather than read from the file: the
95    /// tumbles [`separated_recovery`] adds ([`tumbling`]). Zero from [`recovery`].
96    #[serde(default)]
97    pub added: usize,
98}
99
100/// Why a `.ork`'s recovery device never deploys in a configuration.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102#[serde(rename_all = "snake_case")]
103#[non_exhaustive]
104pub enum NeverDeploys {
105    /// Its event is `never`.
106    SetToNever,
107    /// It deploys at its stage's ejection charge, and no motor of that stage has one: each is
108    /// plugged, or the file states no delay.
109    NoEjectionCharge,
110    /// It deploys at its stage's ejection charge, and no motor of that stage lights. OpenRocket
111    /// 24.12 opens such a device on its own stage's charges alone, never another stage's, joined
112    /// or apart, so it never opens there either
113    /// ([ADR-168](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0168-pods-powered-flies.md)).
114    NoLitMotor,
115}
116
117impl NeverDeploys {
118    /// Why, in words for a note.
119    #[must_use]
120    pub const fn reason(self) -> &'static str {
121        match self {
122            Self::SetToNever => "the file sets it to never",
123            Self::NoEjectionCharge => {
124                "no motor of its stage has an ejection charge (plugged, or no delay given)"
125            }
126            Self::NoLitMotor => {
127                "it opens at its stage's ejection charge, and no motor of its stage lights"
128            }
129        }
130    }
131}
132
133/// A `.ork` configuration's recovery that [`recovery`] does not fly.
134#[derive(Debug, Clone, PartialEq, thiserror::Error)]
135#[non_exhaustive]
136pub enum RecoveryRefused {
137    /// A parachute or streamer inside a part hpr does not read, such as a pod.
138    #[error("a {tag} at {at} is inside a `{inside}`, which hpr does not read")]
139    Unread {
140        /// `parachute` or `streamer`.
141        tag: String,
142        /// Where it is in the file.
143        at: String,
144        /// The tag of the outermost part not read.
145        inside: String,
146    },
147    /// A device that deploys at the separation of the stage below, where the flight has no such
148    /// separation with nothing ahead of it left to burn: [`separated_recovery`] opens one at that
149    /// split alone ([ADR-165](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0165-an-unpowered-separation-in-hpr-sim.md)).
150    #[error(
151        "`{name}` deploys at the lower stage's separation, which hpr flies only when that stage \
152         drops away with nothing ahead of it left to burn"
153    )]
154    AtSeparation {
155        /// The device's name.
156        name: String,
157    },
158    /// A deployment event hpr does not know, or none.
159    #[error("`{name}` deploys at `{word}`, an event hpr does not know")]
160    UnknownEvent {
161        /// The device's name.
162        name: String,
163        /// The word the file wrote, empty where it wrote none.
164        word: String,
165    },
166    /// A device the assembled rocket does not hold as a parachute or a streamer.
167    #[error("the recovery device `{id}` is not a parachute or streamer of the assembled rocket")]
168    NoPart {
169        /// The device's id.
170        id: String,
171    },
172    /// A number outside its domain, or one the file leaves out that the device needs.
173    #[error("`{name}`'s {what} is {value}, outside its domain")]
174    Domain {
175        /// The device's name.
176        name: String,
177        /// What the number is.
178        what: &'static str,
179        /// The number, `NaN` where the file states none.
180        value: f64,
181    },
182    /// A part of a separated rocket that can't tumble, which the flight needs it to
183    /// ([`tumbling`]).
184    #[error("`{name}` can't be flown: {why}")]
185    Tumbling {
186        /// The tumble's name, after its part's stage.
187        name: String,
188        /// Why the tumble's drag was refused, from the flight's error.
189        why: String,
190    },
191    /// The part that keeps the nose, after a separation with nothing ahead of it left to burn,
192    /// has no device of its own open by the split: the flight flies it on as a point with its
193    /// devices' drag alone, which would be a coast with no drag at all ([`tumbling`],
194    /// [ADR-165](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0165-an-unpowered-separation-in-hpr-sim.md)).
195    #[error(
196        "{name}, the part that keeps the nose, would coast with no drag from the separation at \
197         {time_s:.3} s: with nothing ahead of it left to burn, hpr flies it as a point with only \
198         its own devices' drag, and none of them is open by then; set one of them to open at \
199         the lower stage's separation, with no delay, for hpr to fly it"
200    )]
201    Coasts {
202        /// The part, after its first stage.
203        name: String,
204        /// When the separation fires, s after launch.
205        time_s: f64,
206    },
207    /// The part's own numbers, such as a material with no surface density.
208    #[error("`{name}`: {source}")]
209    Design {
210        /// The device's name.
211        name: String,
212        /// The error.
213        source: DesignError,
214    },
215}
216
217/// The flight's devices for the parachutes and streamers of `rocket`, as the `.ork` file's
218/// `recovery` states them, in the configuration `assembly` holds assembled: each as OpenRocket
219/// flies it (the OpenRocket technical documentation v13.05,
220/// §4.2.5; [ADR-153](https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-153-a-orks-recovery-flown-as-openrocket-flies-it-held-to-its-descents-2026-10-04), the decision to fly them so).
221///
222/// - **Drag.** A parachute's drag area is `C_D · π D²/4`, `D` its canopy diameter and `C_D` the
223///   file's, or [`OPENROCKET_PARACHUTE_DRAG_COEFFICIENT`] for `auto`. A streamer's is a stated
224///   `C_D` on its area `l · w`, or for `auto` OpenRocket's appendix C correlation
225///   ([`StreamerModel::OpenRocket`]) from its length, width and fabric, for a strip no wider than
226///   it is long ([`hpr_sim::recovery::MIN_STREAMER_ASPECT_RATIO`]).
227/// - **Opening.** At once ([`hpr_sim::Inflation::Instant`]), the file's delay after its event.
228/// - **Events.** `apogee` is [`Trigger::Apogee`]; `altitude` is [`Trigger::Altitude`], which
229///   opens at apogee a device set above it, where OpenRocket's one probed run never opened it
230///   ([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) §5, the `.ork` reader's probe); `ejection` is [`Trigger::MotorDelay`] of the motor of the device's stage whose
231///   charge fires first (OpenRocket's "first ejection charge" of the stage); `launch` is [`Trigger::Time`] at the delay. `never`, and `ejection` with a
232///   motor that has no charge, give no device but a [`Recovered::not_deployed`] entry.
233///
234/// Every device rides the first body. For a configuration that separates under power, use
235/// [`separated_recovery`], which puts each on the part that carries it.
236///
237/// # Errors
238///
239/// [`RecoveryRefused`]: a device in a part hpr does not read, one deployed at a separation or
240/// by an unknown event, at the charge of a stage with no lit motor, a part not in `assembly`,
241/// or a number outside its domain.
242pub fn recovery(
243    recovery: &Recovery,
244    rocket: &hpr_design::Rocket,
245    assembly: &Assembly,
246) -> Result<Recovered, RecoveryRefused> {
247    map_devices(recovery, rocket, assembly, &[])
248}
249
250/// The flight's devices for a `.ork` configuration that separates at `separations`, in the order
251/// they fire ([`separations`] makes them): [`recovery`]'s mapping, each device on the part that
252/// carries it,
253/// with the tumbles [`tumbling`] adds so that each part has drag from the moment it flies alone
254/// (the decision record on a powered separation in `hpr sim`,
255/// [ADR-159](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0159-a-powered-separation-in-hpr-sim.md)).
256///
257/// A device in a stage aft of the first split rides the booster, body 1; one between the first
258/// split and the second rides the part the second drops, body 2, and so on
259/// ([`Separation::body_of`]); the rest ride the sustainer, body 0, which keeps the nose. A
260/// device's event is its own part's: `apogee` is that part's own apogee, and `ejection` the charge
261/// of a motor in the device's stage. `lowerstageseparation`, OpenRocket's "Lower stage
262/// separation", opens on the trigger of the split right behind the device's stage, plus its
263/// delay, when that split comes with nothing ahead of it left to burn
264/// ([ADR-165](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0165-an-unpowered-separation-in-hpr-sim.md)).
265///
266/// # Errors
267///
268/// As [`recovery`], but for `lowerstageseparation` on such a split; [`tumbling`]'s.
269pub fn separated_recovery(
270    recovery: &Recovery,
271    rocket: &hpr_design::Rocket,
272    assembly: &Assembly,
273    separations: &[Separation],
274) -> Result<Recovered, RecoveryRefused> {
275    let mut out = map_devices(recovery, rocket, assembly, separations)?;
276    let read = out.devices.len();
277    out.devices = tumbling(out.devices, rocket, assembly, separations)?;
278    out.added = out.devices.len() - read;
279    Ok(out)
280}
281
282/// `devices` with a tumble added for each part of `separations` that needs one: the flight flies
283/// a separated part as a point with no airframe drag of its own, so each part must have a device
284/// open from the moment it flies alone ([`hpr_sim::Simulation::with_separations`]).
285///
286/// - **Each dropped part,** body `k + 1` for separation `k` (the booster first), tumbles from its
287///   split ([`DeviceDrag::tumbling_stages`] over its stages), until the first of its own devices,
288///   in list order, opens: that device releases the tumble, so a part under its parachute has the
289///   parachute's drag alone, as OpenRocket flies a descent (its technical documentation v13.05,
290///   §4.2.5).
291/// - **The sustainer,** body 0, flies on as a rigid body with its own aerodynamics. Only when no
292///   device rides it is a tumble from its apogee added, so that it has a descent.
293/// - **The part that keeps the nose after a lone separation with nothing ahead of it left to
294///   burn** ([`Separation::powered_at`]) flies on as a point too, from the split. A device of its
295///   own must be open by then, one whose trigger's time, known before the flight, is at or before
296///   the split's: else it would coast with no drag, and it is refused rather than tumbled, as
297///   nothing says how a part with no fins flies on its own ([ADR-165](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0165-an-unpowered-separation-in-hpr-sim.md)).
298///
299/// The tumbles go after `devices`, so each device keeps its index. Each is named after its part's
300/// first stage, such as "Booster, tumbling", or, for a stage with no name, "sustainer",
301/// "booster" or "stage 2" (counted from the nose's, 1).
302///
303/// # Errors
304///
305/// [`RecoveryRefused::Tumbling`] when [`DeviceDrag::tumbling_stages`] refuses a part's stages,
306/// such as a part with tube fins, or more than eight fins in a set; [`RecoveryRefused::Coasts`]
307/// when the part that keeps the nose would coast.
308pub fn tumbling(
309    mut devices: Vec<Device>,
310    rocket: &hpr_design::Rocket,
311    assembly: &Assembly,
312    separations: &[Separation],
313) -> Result<Vec<Device>, RecoveryRefused> {
314    let stage_count = assembly.layout.stages.len();
315    if let [separation] = separations
316        && let Some(time_s) = separation.trigger.known_time_s(assembly).ok().flatten()
317        && !separation.powered_at(assembly, time_s)
318    {
319        let open = devices
320            .iter()
321            .filter(|device| device.body == 0)
322            .any(|device| {
323                device
324                    .trigger
325                    .known_time_s(assembly)
326                    .ok()
327                    .flatten()
328                    .is_some_and(|known_s| known_s + device.lag_s <= time_s)
329            });
330        if !open {
331            let name = rocket
332                .stages
333                .first()
334                .map(|stage| stage.name.trim())
335                .filter(|name| !name.is_empty())
336                .map_or_else(|| "stage 1".to_owned(), |name| format!("`{name}`"));
337            return Err(RecoveryRefused::Coasts { name, time_s });
338        }
339    }
340    for body in 0..=separations.len() {
341        let Some(stages) = Separation::stages_of_body(separations, body, stage_count) else {
342            continue;
343        };
344        let first = devices.iter().position(|device| device.body == body);
345        if body == 0 && first.is_some() {
346            continue;
347        }
348        let name = rocket
349            .stages
350            .get(stages.0)
351            .map(|stage| stage.name.as_str())
352            .filter(|name| !name.is_empty())
353            .map_or_else(
354                || match body {
355                    0 => "sustainer".to_owned(),
356                    1 => "booster".to_owned(),
357                    _ => format!("stage {}", stages.0 + 1),
358                },
359                str::to_owned,
360            );
361        let name = format!("{name}, tumbling");
362        let drag = DeviceDrag::tumbling_stages(assembly, stages).map_err(|error| {
363            RecoveryRefused::Tumbling {
364                name: name.clone(),
365                why: error.to_string(),
366            }
367        })?;
368        let device = if body == 0 {
369            Device::new(name, drag, Trigger::Apogee)
370        } else {
371            // A dropped part's devices act only once it flies on its own, so a time of zero is
372            // its split.
373            let tumble = Device::new(name, drag, Trigger::Time { time_s: 0.0 }).on_body(body);
374            match first {
375                Some(first) => tumble.with_release_by(first),
376                None => tumble,
377            }
378        };
379        devices.push(device);
380    }
381    Ok(devices)
382}
383
384/// [`recovery`]'s mapping, each device on the part that carries it after `separations`, if any.
385fn map_devices(
386    recovery: &Recovery,
387    rocket: &hpr_design::Rocket,
388    assembly: &Assembly,
389    separations: &[Separation],
390) -> Result<Recovered, RecoveryRefused> {
391    let configuration = assembly.configuration.as_str();
392    // The `.ork` reader lights no motor at a separation (each ignition it reads is a launch, a
393    // time or another motor's burnout), so none waits on one here.
394    let ignitions_s = assembly.ignition_times_s(|_| None);
395    if let Some(unread) = recovery.unread.first() {
396        return Err(RecoveryRefused::Unread {
397            tag: unread.tag.clone(),
398            at: unread.at.clone(),
399            inside: unread.inside.clone(),
400        });
401    }
402    let mut out = Recovered {
403        devices: Vec::new(),
404        not_deployed: Vec::new(),
405        added: 0,
406    };
407    for device in &recovery.devices {
408        let (stage, part) = assembly
409            .layout
410            .find(&device.id)
411            .map(|(_, placed)| (placed.stage, &placed.part))
412            .ok_or_else(|| RecoveryRefused::NoPart {
413                id: device.id.clone(),
414            })?;
415        let name = component_name(rocket, &device.id)
416            .filter(|name| !name.is_empty())
417            .unwrap_or(&device.id)
418            .to_owned();
419        let domain = |what: &'static str, value: f64| RecoveryRefused::Domain {
420            name: name.clone(),
421            what,
422            value,
423        };
424        let deployment = device.deployment_in(configuration);
425        let delay_s = deployment.delay_s.unwrap_or(0.0);
426        if !(delay_s.is_finite() && delay_s >= 0.0) {
427            return Err(domain("deployment delay, s", delay_s));
428        }
429        // Set for `lowerstageseparation`, whose trigger is a split's.
430        let mut at_split = false;
431        let trigger = match &deployment.event {
432            Some(DeployEvent::Apogee) => Trigger::Apogee,
433            Some(DeployEvent::Altitude) => {
434                let height = deployment.altitude_m.unwrap_or(f64::NAN);
435                if !(height.is_finite() && height > 0.0) {
436                    return Err(domain("deployment height above the ground, m", height));
437                }
438                Trigger::Altitude {
439                    height_above_ground_m: height,
440                }
441            }
442            Some(DeployEvent::Launch) => Trigger::Time { time_s: delay_s },
443            Some(DeployEvent::Ejection) => {
444                // The stage's motors that light at a known time, each with when its charge
445                // fires, if it has one: the first charge opens the device.
446                let lit: Vec<(usize, Option<f64>)> = assembly
447                    .motors
448                    .iter()
449                    .zip(&ignitions_s)
450                    .enumerate()
451                    .filter(|(_, (motor, _))| motor.stage == stage)
452                    .filter_map(|(index, (motor, ignition_s))| {
453                        let charge_s = match motor.mounted.delay {
454                            Some(Delay::Seconds(delay_s)) => ignition_s.map(|ignition_s| {
455                                ignition_s + motor.mounted.motor.burnout_time_s() + delay_s
456                            }),
457                            _ => None,
458                        };
459                        ignition_s.map(|_| (index, charge_s))
460                    })
461                    .collect();
462                if lit.is_empty() {
463                    out.not_deployed.push((name, NeverDeploys::NoLitMotor));
464                    continue;
465                }
466                let first = lit
467                    .iter()
468                    .filter_map(|&(index, charge_s)| Some((index, charge_s?)))
469                    .min_by(|a, b| a.1.total_cmp(&b.1));
470                match first {
471                    Some((motor, _)) => Trigger::MotorDelay { motor },
472                    None => {
473                        out.not_deployed
474                            .push((name, NeverDeploys::NoEjectionCharge));
475                        continue;
476                    }
477                }
478            }
479            Some(DeployEvent::Never) => {
480                out.not_deployed.push((name, NeverDeploys::SetToNever));
481                continue;
482            }
483            Some(DeployEvent::LowerStageSeparation) => {
484                // OpenRocket's "Lower stage separation": the stage behind the device's comes
485                // away. Its record opens a payload's parachute in the step after that split, so
486                // the device takes the split's own trigger, and its delay as its lag. A
487                // sustainer's split is left out: a device opening under power is not something
488                // the flight flies.
489                let split = separations
490                    .iter()
491                    .find(|separation| separation.after_stage == stage)
492                    .filter(|separation| {
493                        separation
494                            .trigger
495                            .known_time_s(assembly)
496                            .ok()
497                            .flatten()
498                            .is_some_and(|time_s| !separation.powered_at(assembly, time_s))
499                    });
500                match split.map(|separation| separation.trigger) {
501                    Some(trigger @ (Trigger::Time { .. } | Trigger::Burnout { .. })) => {
502                        at_split = true;
503                        trigger
504                    }
505                    _ => return Err(RecoveryRefused::AtSeparation { name }),
506                }
507            }
508            Some(other) => {
509                return Err(RecoveryRefused::UnknownEvent {
510                    name,
511                    word: other.as_str().to_owned(),
512                });
513            }
514            None => {
515                return Err(RecoveryRefused::UnknownEvent {
516                    name,
517                    word: String::new(),
518                });
519            }
520        };
521        let stated = match &device.cd {
522            Some(Dimension::Stated { value }) => {
523                if !(value.is_finite() && *value > 0.0) {
524                    return Err(domain("drag coefficient", *value));
525                }
526                Some(*value)
527            }
528            // OpenRocket's own, which a file that states nothing gets too.
529            Some(Dimension::Automatic { .. }) | None => None,
530            Some(_) => {
531                return Err(domain(
532                    "drag coefficient (a kind hpr does not know)",
533                    f64::NAN,
534                ));
535            }
536        };
537        let drag = match (device.kind, part) {
538            (DeviceKind::Parachute, Part::Parachute(parachute)) => {
539                let diameter_m = parachute.diameter_m;
540                if !(diameter_m.is_finite() && diameter_m > 0.0) {
541                    return Err(domain("canopy diameter, m", diameter_m));
542                }
543                // A drag area, not a `DeviceDrag::Canopy`: OpenRocket flies any positive
544                // coefficient the file states, where a canopy's stays within Knacke's range.
545                let drag_coefficient = stated.unwrap_or(OPENROCKET_PARACHUTE_DRAG_COEFFICIENT);
546                DeviceDrag::DragArea {
547                    cd_s_m2: drag_coefficient * std::f64::consts::PI * diameter_m * diameter_m
548                        / 4.0,
549                }
550            }
551            (DeviceKind::Streamer, Part::Streamer(streamer)) => {
552                let (length_m, width_m) = (streamer.length_m, streamer.width_m);
553                for (what, value) in [
554                    ("streamer length, m", length_m),
555                    ("streamer width, m", width_m),
556                ] {
557                    if !(value.is_finite() && value > 0.0) {
558                        return Err(domain(what, value));
559                    }
560                }
561                match stated {
562                    Some(cd) => DeviceDrag::DragArea {
563                        cd_s_m2: cd * length_m * width_m,
564                    },
565                    // The correlation needs a strip, as the flight checks.
566                    None if length_m / width_m < MIN_STREAMER_ASPECT_RATIO => {
567                        return Err(domain(
568                            "streamer aspect ratio, length over width",
569                            length_m / width_m,
570                        ));
571                    }
572                    None => DeviceDrag::Streamer {
573                        length_m,
574                        width_m,
575                        surface_density_kg_m2: streamer
576                            .material
577                            .surface_kg_m2("streamer")
578                            .map_err(|source| RecoveryRefused::Design {
579                                name: name.clone(),
580                                source,
581                            })?,
582                        model: StreamerModel::OpenRocket,
583                    },
584                }
585            }
586            _ => {
587                return Err(RecoveryRefused::NoPart {
588                    id: device.id.clone(),
589                });
590            }
591        };
592        // A time from launch holds its delay already; any other trigger, a split's included,
593        // waits its delay after.
594        let lag_s = if matches!(trigger, Trigger::Time { .. }) && !at_split {
595            0.0
596        } else {
597            delay_s
598        };
599        // The part that carries it: the booster for a stage aft of the first split, and so on.
600        let body = Separation::body_of(separations, stage);
601        out.devices.push(
602            Device::new(name, drag, trigger)
603                .with_lag_s(lag_s)
604                .on_body(body),
605        );
606    }
607    Ok(out)
608}
609
610/// The name of the component with id `id` anywhere in `rocket`.
611fn component_name<'a>(rocket: &'a hpr_design::Rocket, id: &str) -> Option<&'a str> {
612    fn search<'a>(components: &'a [hpr_design::tree::Component], id: &str) -> Option<&'a str> {
613        components.iter().find_map(|component| {
614            if component.id == id {
615                Some(component.name.as_str())
616            } else {
617                search(&component.children, id)
618            }
619        })
620    }
621    rocket
622        .stages
623        .iter()
624        .find_map(|stage| search(&stage.components, id))
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    /// Two stages, an Estes F15 from the bundled catalog in each: configuration `burn` drops the
632    /// booster at its burnout and lights the sustainer there; `time` drops it 4 s after launch
633    /// and lights the sustainer at 6 s.
634    const ORK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
635<openrocket version="1.10" creator="OpenRocket 24.12">
636  <rocket><name>Two-stage</name>
637    <motorconfiguration configid="burn"/><motorconfiguration configid="time"/>
638    <subcomponents>
639      <stage><name>Sustainer</name><id>upper</id><subcomponents>
640        <nosecone><name>Nose</name><id>nose</id><length>0.15</length><thickness>0.002</thickness>
641          <shape>ogive</shape><aftradius>0.0165</aftradius></nosecone>
642        <bodytube><name>Sustainer</name><id>sustainer</id><length>0.4</length>
643          <thickness>0.001</thickness><radius>0.0165</radius>
644          <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
645            <overhang>0.0</overhang>
646            <motor configid="burn"><type>single</type><manufacturer>Estes</manufacturer>
647              <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
648              <delay>6.0</delay></motor>
649            <motor configid="time"><type>single</type><manufacturer>Estes</manufacturer>
650              <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
651              <delay>6.0</delay></motor>
652            <ignitionconfiguration configid="time"><ignitionevent>launch</ignitionevent>
653              <ignitiondelay>6.0</ignitiondelay></ignitionconfiguration>
654          </motormount></bodytube></subcomponents></stage>
655      <stage><name>Booster</name><id>lower</id>
656        <separationevent>burnout</separationevent><separationdelay>0.0</separationdelay>
657        <separationconfiguration configid="time"><separationevent>launch</separationevent>
658          <separationdelay>4.0</separationdelay></separationconfiguration>
659        <subcomponents>
660        <bodytube><name>Booster</name><id>booster</id><length>0.3</length>
661          <thickness>0.001</thickness><radius>0.0165</radius>
662          <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
663            <overhang>0.0</overhang>
664            <motor configid="burn"><type>single</type><manufacturer>Estes</manufacturer>
665              <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
666              <delay>0.0</delay></motor>
667            <motor configid="time"><type>single</type><manufacturer>Estes</manufacturer>
668              <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
669              <delay>0.0</delay></motor>
670          </motormount></bodytube></subcomponents></stage>
671    </subcomponents></rocket>
672</openrocket>"#;
673
674    /// The configuration's staging and its assembly.
675    fn read(id: &str) -> (Staging, Assembly) {
676        let file = hpr_io::ork::read(ORK.as_bytes()).expect("a readable design");
677        let design = hpr_io::ork::design(&file.value).value;
678        let configuration = design
679            .motors
680            .configurations
681            .iter()
682            .find(|configuration| configuration.id == id)
683            .expect("the configuration");
684        let staging = configuration.staging.clone().expect("a separation");
685        (staging, design.rocket.assemble(id).expect("flown"))
686    }
687
688    #[test]
689    fn a_burnout_separation_names_the_booster_motor_by_its_index() {
690        let (staging, assembly) = read("burn");
691        let booster = assembly
692            .motors
693            .iter()
694            .position(|motor| motor.mount == "booster")
695            .expect("the booster's motor");
696        assert_eq!(
697            separation(&staging, &assembly).expect("maps"),
698            Separation::new(
699                Trigger::Burnout {
700                    motor: booster,
701                    delay_s: 0.0
702                },
703                0
704            )
705        );
706
707        // A tube set to fail is passed over for the first that lights, as the design's own
708        // ignition times take it.
709        let mut failing = assembly.clone();
710        let mut dud = failing.motors[booster].clone();
711        dud.fails = true;
712        failing.motors.insert(booster, dud);
713        assert_eq!(
714            separation(&staging, &failing).expect("maps").trigger,
715            Trigger::Burnout {
716                motor: booster + 1,
717                delay_s: 0.0
718            }
719        );
720
721        // A mount whose every tube fails, or another configuration's assembly with no motor in
722        // that mount, is refused.
723        let mut dead = assembly.clone();
724        dead.motors[booster].fails = true;
725        assert!(matches!(
726            separation(&staging, &dead),
727            Err(SimError::Domain {
728                what: "count of lit motors in the mount a separation is timed from",
729                ..
730            })
731        ));
732        let mut other = assembly;
733        other.motors.retain(|motor| motor.mount != "booster");
734        let error = separation(&staging, &other).expect_err("no booster motor");
735        assert!(
736            matches!(
737                error,
738                SimError::Domain {
739                    what: "count of lit motors in the mount a separation is timed from",
740                    ..
741                }
742            ),
743            "{error}"
744        );
745    }
746
747    /// [`ORK`] with a parachute opening at apogee in each stage's tube where `chutes` says, read:
748    /// configuration `id`'s recovery, rocket, assembly and separation.
749    fn staged(
750        id: &str,
751        chutes: (bool, bool),
752    ) -> (Recovery, hpr_design::Rocket, Assembly, Separation) {
753        let chute = |name: &str| {
754            format!(
755                r#"<subcomponents><parachute><name>{name}</name><id>{name}</id>
756                  <axialoffset method="top">0.05</axialoffset><packedlength>0.03</packedlength>
757                  <packedradius>0.012</packedradius><cd>auto</cd>
758                  <material type="surface" density="0.067">Ripstop nylon</material>
759                  <deployevent>apogee</deployevent><deploydelay>0.0</deploydelay>
760                  <diameter>0.3</diameter></parachute></subcomponents>"#
761            )
762        };
763        let mut text = ORK.to_owned();
764        // A tube's parts may come before its motor mount: the reader takes them in any order.
765        for (wanted, tube, name) in [
766            (chutes.0, "<id>sustainer</id>", "Upper chute"),
767            (chutes.1, "<id>booster</id>", "Lower chute"),
768        ] {
769            if wanted {
770                let at = text.find(tube).expect("the tube's text") + tube.len();
771                text.insert_str(at, &chute(name));
772            }
773        }
774        assert_eq!(
775            text.matches("<parachute>").count(),
776            usize::from(chutes.0) + usize::from(chutes.1)
777        );
778        let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
779        let design = hpr_io::ork::design(&file.value).value;
780        let configuration = design
781            .motors
782            .configurations
783            .iter()
784            .find(|configuration| configuration.id == id)
785            .expect("the configuration");
786        let assembly = design.rocket.assemble(id).expect("flown");
787        let split = separation(configuration.staging.as_ref().expect("staged"), &assembly)
788            .expect("a separation");
789        (design.recovery, design.rocket, assembly, split)
790    }
791
792    /// M4.5g1: each device rides the part its stage is in after the split, and the booster
793    /// tumbles from the split until its own device opens; a sustainer with a device of its own
794    /// gets no tumble.
795    #[test]
796    fn each_device_rides_its_part_and_the_booster_tumbles_until_its_own_opens() {
797        let (recovery_, rocket, assembly, split) = staged("burn", (true, true));
798        let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
799        let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).expect("tumbles");
800        let chute = DeviceDrag::DragArea {
801            cd_s_m2: 0.8 * std::f64::consts::PI * 0.3 * 0.3 / 4.0,
802        };
803        assert_eq!(
804            flown.devices,
805            vec![
806                Device::new("Upper chute", chute, Trigger::Apogee),
807                Device::new("Lower chute", chute, Trigger::Apogee).on_body(1),
808                Device::new("Booster, tumbling", tumble, Trigger::Time { time_s: 0.0 })
809                    .on_body(1)
810                    .with_release_by(1),
811            ]
812        );
813        assert_eq!(flown.added, 1);
814        // Without a separation every device rides the first body, as before, and hpr adds none.
815        let whole = recovery(&recovery_, &rocket, &assembly).expect("maps");
816        assert!(whole.devices.iter().all(|device| device.body == 0));
817        assert_eq!((whole.devices.len(), whole.added), (2, 0));
818    }
819
820    /// A part with no device of the file's: the booster tumbles from the split to the ground, and
821    /// the sustainer from its apogee. (`hpr sim`'s tests fly both.)
822    #[test]
823    fn a_part_with_no_device_tumbles() {
824        let (recovery_, rocket, assembly, split) = staged("burn", (false, false));
825        let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
826        assert_eq!(flown.added, 2);
827        assert_eq!(
828            flown.devices,
829            vec![
830                Device::new(
831                    "Sustainer, tumbling",
832                    DeviceDrag::tumbling_stages(&assembly, (0, 0)).expect("tumbles"),
833                    Trigger::Apogee,
834                ),
835                Device::new(
836                    "Booster, tumbling",
837                    DeviceDrag::tumbling_stages(&assembly, (1, 1)).expect("tumbles"),
838                    Trigger::Time { time_s: 0.0 },
839                )
840                .on_body(1),
841            ]
842        );
843        // A booster chute alone: the sustainer still tumbles from its apogee, and the booster's
844        // tumble is released by its chute, the first device in the list.
845        let (recovery_, rocket, assembly, split) = staged("burn", (false, true));
846        let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
847        assert_eq!(flown.added, 2);
848        let described: Vec<(&str, usize, Option<usize>)> = flown
849            .devices
850            .iter()
851            .map(|d| (d.name.as_str(), d.body, d.released_by))
852            .collect();
853        assert_eq!(
854            described,
855            [
856                ("Lower chute", 1, None),
857                ("Sustainer, tumbling", 0, None),
858                ("Booster, tumbling", 1, Some(0)),
859            ]
860        );
861    }
862
863    /// [`ORK`] made a payload rocket: the sustainer holds no motor, the booster drops 2 s after its
864    /// burnout in `burn` (4 s after launch in `time`), and the top stage's parachute opens at
865    /// `upper`, `upper_delay_s` after, the booster's at apogee. Configuration `id`'s recovery,
866    /// rocket, assembly and separation.
867    fn payload(
868        id: &str,
869        (upper, upper_delay_s): (&str, f64),
870    ) -> (Recovery, hpr_design::Rocket, Assembly, Separation) {
871        let mut text = ORK.to_owned();
872        let start = text.find("<motormount>").expect("the sustainer's mount");
873        let end = text[start..].find("</motormount>").expect("its end") + start;
874        text.replace_range(start..end + "</motormount>".len(), "");
875        let text = text.replacen(
876            "<separationdelay>0.0</separationdelay>",
877            "<separationdelay>2.0</separationdelay>",
878            1,
879        );
880        let chute = |name: &str, event: &str, delay_s: f64| {
881            format!(
882                r#"<subcomponents><parachute><name>{name}</name><id>{name}</id>
883                  <axialoffset method="top">0.05</axialoffset><packedlength>0.03</packedlength>
884                  <packedradius>0.012</packedradius><cd>auto</cd>
885                  <material type="surface" density="0.067">Ripstop nylon</material>
886                  <deployevent>{event}</deployevent><deploydelay>{delay_s}</deploydelay>
887                  <diameter>0.3</diameter></parachute></subcomponents>"#
888            )
889        };
890        let text = text
891            .replacen(
892                "<id>sustainer</id>",
893                &format!(
894                    "<id>sustainer</id>{}",
895                    chute("Upper chute", upper, upper_delay_s)
896                ),
897                1,
898            )
899            .replacen(
900                "<id>booster</id>",
901                &format!("<id>booster</id>{}", chute("Lower chute", "apogee", 0.0)),
902                1,
903            );
904        let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
905        let design = hpr_io::ork::design(&file.value).value;
906        let configuration = design
907            .motors
908            .configurations
909            .iter()
910            .find(|configuration| configuration.id == id)
911            .expect("the configuration");
912        let assembly = design.rocket.assemble(id).expect("flown");
913        assert_eq!(assembly.motors.len(), 1);
914        let split = separation(configuration.staging.as_ref().expect("staged"), &assembly)
915            .expect("a separation");
916        (design.recovery, design.rocket, assembly, split)
917    }
918
919    /// M4.5g3: a separation with nothing ahead of it left to burn opens a parachute set to
920    /// `lowerstageseparation` on the split's own trigger, its delay after, on the part that keeps
921    /// the nose; that part must have a device open by the split, or it is refused, as it would
922    /// coast with no drag (ADR-165). A sustainer's powered split, or none, still refuses the word.
923    #[test]
924    fn a_payload_opens_at_its_split_or_is_refused_as_coasting() {
925        let (recovery_, rocket, assembly, split) = payload("burn", ("lowerstageseparation", 0.0));
926        let booster = 0;
927        assert_eq!(
928            split.trigger,
929            Trigger::Burnout {
930                motor: booster,
931                delay_s: 2.0
932            }
933        );
934        let split_s = split.trigger.known_time_s(&assembly).unwrap().unwrap();
935        assert!(!split.powered_at(&assembly, split_s));
936        let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
937        let described: Vec<(&str, usize, Trigger, Option<usize>)> = flown
938            .devices
939            .iter()
940            .map(|d| (d.name.as_str(), d.body, d.trigger, d.released_by))
941            .collect();
942        assert_eq!(
943            described,
944            [
945                ("Upper chute", 0, split.trigger, None),
946                ("Lower chute", 1, Trigger::Apogee, None),
947                (
948                    "Booster, tumbling",
949                    1,
950                    Trigger::Time { time_s: 0.0 },
951                    Some(1)
952                ),
953            ]
954        );
955        assert_eq!(flown.added, 1);
956        assert_eq!(flown.devices[0].lag_s, 0.0);
957        // A split at a time: the parachute opens at that time.
958        let (recovery_, rocket, assembly, split) = payload("time", ("lowerstageseparation", 0.0));
959        assert_eq!(split.trigger, Trigger::Time { time_s: 4.0 });
960        let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
961        assert_eq!(flown.devices[0].trigger, Trigger::Time { time_s: 4.0 });
962        assert_eq!(flown.devices[0].lag_s, 0.0);
963
964        // With a delay, on either split, the device takes the split's trigger and waits the delay
965        // once, as its lag: flown, it opens the delay after the split, not twice that.
966        for id in ["burn", "time"] {
967            let (recovery_, rocket, assembly, split) = payload(id, ("lowerstageseparation", 1.0));
968            let mapped = map_devices(&recovery_, &rocket, &assembly, &[split]).expect("maps");
969            let upper = &mapped.devices[0];
970            assert_eq!((upper.trigger, upper.lag_s), (split.trigger, 1.0), "{id}");
971            // A streamer open from launch gives the payload drag from the split, so it flies.
972            let mut devices = mapped.devices;
973            devices.push(Device::new(
974                "streamer",
975                DeviceDrag::DragArea { cd_s_m2: 0.001 },
976                Trigger::Time { time_s: 0.0 },
977            ));
978            let devices = tumbling(devices, &rocket, &assembly, &[split]).expect("flies");
979            let result = hpr_sim::Simulation::new(
980                &rocket,
981                id,
982                hpr_sim::Environment::standard(
983                    hpr_core::geodesy::Geodetic::from_degrees(45.0, 0.0, 0.0).expect("a site"),
984                )
985                .expect("standard air"),
986                hpr_sim::Rail::vertical(1.0),
987                hpr_sim::FlightSettings::default(),
988            )
989            .expect("a simulation")
990            .with_recovery(devices)
991            .expect("devices it takes")
992            .with_separations(vec![split])
993            .expect("a split it takes")
994            .run(&mut ())
995            .expect("a flight");
996            let split_s = result
997                .event(hpr_sim::EventKind::Separation)
998                .expect("split")
999                .sample
1000                .time_s;
1001            let opened_s = result
1002                .bodies
1003                .iter()
1004                .find(|body| body.body == 0)
1005                .and_then(|body| body.event(hpr_sim::EventKind::Deployment(0)))
1006                .expect("opened on the payload")
1007                .sample
1008                .time_s;
1009            assert!(
1010                (opened_s - (split_s + 1.0)).abs() < 1e-9,
1011                "{id}: {opened_s} vs {split_s}"
1012            );
1013        }
1014
1015        // Opening after the split, by its delay or at apogee, or not at all, leaves a coast with
1016        // no drag: refused, naming the part and when it would start.
1017        for upper in [
1018            ("lowerstageseparation", 0.25),
1019            ("apogee", 0.0),
1020            ("never", 0.0),
1021        ] {
1022            let (recovery_, rocket, assembly, split) = payload("burn", upper);
1023            let refused =
1024                separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect_err("coasts");
1025            assert_eq!(
1026                refused,
1027                RecoveryRefused::Coasts {
1028                    name: "`Sustainer`".to_owned(),
1029                    time_s: split_s,
1030                },
1031                "{upper:?}"
1032            );
1033            assert!(
1034                refused
1035                    .to_string()
1036                    .starts_with("`Sustainer`, the part that keeps the nose, would coast"),
1037                "{refused}"
1038            );
1039        }
1040        // Open before the split, at a time on the stack, it has drag from the split.
1041        let (recovery_, rocket, assembly, split) = payload("burn", ("launch", split_s - 0.5));
1042        let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
1043        assert_eq!(
1044            flown.devices[0].trigger,
1045            Trigger::Time {
1046                time_s: split_s - 0.5
1047            }
1048        );
1049
1050        // With no separation the word is refused, as before.
1051        let (recovery_, rocket, assembly, _) = payload("burn", ("lowerstageseparation", 0.0));
1052        assert_eq!(
1053            recovery(&recovery_, &rocket, &assembly).expect_err("refused"),
1054            RecoveryRefused::AtSeparation {
1055                name: "Upper chute".to_owned()
1056            }
1057        );
1058        // A sustainer's split, under power: refused too.
1059        let text = ORK.replacen(
1060            "<id>sustainer</id>",
1061            r#"<id>sustainer</id><subcomponents><parachute><name>Upper chute</name>
1062              <id>Upper chute</id><axialoffset method="top">0.05</axialoffset>
1063              <packedlength>0.03</packedlength><packedradius>0.012</packedradius><cd>auto</cd>
1064              <deployevent>lowerstageseparation</deployevent><deploydelay>0.0</deploydelay>
1065              <diameter>0.3</diameter></parachute></subcomponents>"#,
1066            1,
1067        );
1068        let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
1069        let design = hpr_io::ork::design(&file.value).value;
1070        let staging = design.motors.configurations[0]
1071            .staging
1072            .clone()
1073            .expect("staged");
1074        let assembly = design.rocket.assemble("burn").expect("flown");
1075        let split = separation(&staging, &assembly).expect("a separation");
1076        assert!(split.powered_at(&assembly, staging.time_s));
1077        assert_eq!(
1078            separated_recovery(&design.recovery, &design.rocket, &assembly, &[split])
1079                .expect_err("refused"),
1080            RecoveryRefused::AtSeparation {
1081                name: "Upper chute".to_owned()
1082            }
1083        );
1084    }
1085
1086    /// ADR-172: only a staging the `.ork` reader marks as dropping its stage's motors burning,
1087    /// at the stage's first burnout, lets the flight drop a burning motor; any other, such as one
1088    /// written by hand in a `.hpr`, keeps the flight's refusal.
1089    #[test]
1090    fn only_a_staging_marked_so_drops_a_burning_motor() {
1091        let (_, assembly) = read("time");
1092        let staging = |drops: bool| -> Staging {
1093            serde_json::from_value(serde_json::json!({
1094                "after_stage": 0,
1095                "trigger": { "time": { "time_s": 1.0 } },
1096                "time_s": 1.0,
1097                "drops_burning": drops,
1098            }))
1099            .expect("a staging")
1100        };
1101        let plain = Separation::new(Trigger::Time { time_s: 1.0 }, 0);
1102        assert_eq!(separation(&staging(false), &assembly).unwrap(), plain);
1103        assert_eq!(
1104            separation(&staging(true), &assembly).unwrap(),
1105            plain.dropping_burning()
1106        );
1107    }
1108
1109    #[test]
1110    fn a_separation_at_a_time_stays_a_time() {
1111        let (staging, assembly) = read("time");
1112        assert_eq!(
1113            separation(&staging, &assembly).expect("maps"),
1114            Separation::new(Trigger::Time { time_s: 4.0 }, 0)
1115        );
1116    }
1117
1118    /// One stage and an Estes F15 from the bundled catalog: configurations `charge` (a 6 s
1119    /// delay) and `plugged`. Its devices: a drogue `auto` at apogee 1 s late, a main of `C_D`
1120    /// 0.9 at 100 m, a streamer `auto` at the ejection charge, which `plugged` sets never to
1121    /// open by having no charge, a streamer of `C_D` 0.6 at launch plus 2 s, and a parachute set
1122    /// to `never`.
1123    const RECOVERY_ORK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1124<openrocket version="1.10" creator="OpenRocket 24.12">
1125  <rocket><name>Recovery</name>
1126    <motorconfiguration configid="charge"/><motorconfiguration configid="plugged"/>
1127    <subcomponents>
1128      <stage><name>Sustainer</name><id>stage</id><subcomponents>
1129        <nosecone><name>Nose</name><id>nose</id><length>0.15</length><thickness>0.002</thickness>
1130          <shape>ogive</shape><aftradius>0.0165</aftradius></nosecone>
1131        <bodytube><name>Body</name><id>body</id><length>0.6</length>
1132          <thickness>0.001</thickness><radius>0.0165</radius>
1133          <subcomponents>
1134            <parachute><name>Drogue</name><id>drogue</id><axialoffset method="top">0.05</axialoffset><packedlength>0.03</packedlength>
1135              <packedradius>0.012</packedradius><cd>auto</cd>
1136              <material type="surface" density="0.067">Ripstop nylon</material>
1137              <deployevent>apogee</deployevent><deploydelay>1.0</deploydelay>
1138              <diameter>0.3</diameter><linecount>6</linecount><linelength>0.3</linelength>
1139              <linematerial type="line" density="0.0025">Nylon</linematerial></parachute>
1140            <parachute><name>Main</name><id>main</id><axialoffset method="top">0.05</axialoffset><packedlength>0.04</packedlength>
1141              <packedradius>0.012</packedradius><cd>0.9</cd>
1142              <material type="surface" density="0.067">Ripstop nylon</material>
1143              <deployevent>altitude</deployevent><deployaltitude>100.0</deployaltitude>
1144              <deploydelay>0.0</deploydelay>
1145              <diameter>0.6</diameter><linecount>8</linecount><linelength>0.5</linelength>
1146              <linematerial type="line" density="0.0025">Nylon</linematerial></parachute>
1147            <streamer><name>Charge streamer</name><id>charged</id><axialoffset method="top">0.05</axialoffset><packedlength>0.02</packedlength>
1148              <packedradius>0.01</packedradius><cd>auto</cd>
1149              <material type="surface" density="0.09">Mylar</material>
1150              <deployevent>ejection</deployevent><deploydelay>0.5</deploydelay>
1151              <striplength>1.2</striplength><stripwidth>0.1</stripwidth></streamer>
1152            <streamer><name></name><id>timed</id><axialoffset method="top">0.05</axialoffset><packedlength>0.02</packedlength>
1153              <packedradius>0.01</packedradius><cd>0.6</cd>
1154              <material type="surface" density="0.09">Mylar</material>
1155              <deployevent>launch</deployevent><deploydelay>2.0</deploydelay>
1156              <striplength>1.0</striplength><stripwidth>0.05</stripwidth></streamer>
1157            <parachute><name>Spare</name><id>spare</id><axialoffset method="top">0.05</axialoffset><packedlength>0.02</packedlength>
1158              <packedradius>0.01</packedradius><cd>auto</cd>
1159              <material type="surface" density="0.067">Ripstop nylon</material>
1160              <deployevent>never</deployevent><deploydelay>0.0</deploydelay>
1161              <diameter>0.3</diameter><linecount>6</linecount><linelength>0.3</linelength>
1162              <linematerial type="line" density="0.0025">Nylon</linematerial></parachute>
1163          </subcomponents>
1164          <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
1165            <overhang>0.0</overhang>
1166            <motor configid="charge"><type>single</type><manufacturer>Estes</manufacturer>
1167              <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
1168              <delay>6.0</delay></motor>
1169            <motor configid="plugged"><type>single</type><manufacturer>Estes</manufacturer>
1170              <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
1171              <delay>none</delay></motor>
1172          </motormount></bodytube></subcomponents></stage>
1173    </subcomponents></rocket>
1174</openrocket>"#;
1175
1176    /// `RECOVERY_ORK` with `edit` made to its text, read: its recovery, rocket and the assembly
1177    /// of configuration `id`.
1178    fn recovery_read(
1179        id: &str,
1180        edit: impl Fn(String) -> String,
1181    ) -> (Recovery, hpr_design::Rocket, Assembly) {
1182        let text = edit(RECOVERY_ORK.to_owned());
1183        let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
1184        let design = hpr_io::ork::design(&file.value).value;
1185        let assembly = design.rocket.assemble(id).expect("flown");
1186        (design.recovery, design.rocket, assembly)
1187    }
1188
1189    fn mapped(id: &str, edit: impl Fn(String) -> String) -> Result<Recovered, RecoveryRefused> {
1190        let (recovery_, rocket, assembly) = recovery_read(id, edit);
1191        recovery(&recovery_, &rocket, &assembly)
1192    }
1193
1194    #[test]
1195    fn each_device_flies_as_openrocket_documents_it() {
1196        let flown = mapped("charge", |text| text).expect("maps");
1197        let motor = 0;
1198        let expected = vec![
1199            // `auto` on a parachute is 0.8 on the canopy's area, its delay a lag.
1200            Device::new(
1201                "Drogue",
1202                DeviceDrag::DragArea {
1203                    cd_s_m2: 0.8 * std::f64::consts::PI * 0.3 * 0.3 / 4.0,
1204                },
1205                Trigger::Apogee,
1206            )
1207            .with_lag_s(1.0),
1208            Device::new(
1209                "Main",
1210                DeviceDrag::DragArea {
1211                    cd_s_m2: 0.9 * std::f64::consts::PI * 0.6 * 0.6 / 4.0,
1212                },
1213                Trigger::Altitude {
1214                    height_above_ground_m: 100.0,
1215                },
1216            ),
1217            // `auto` on a streamer is appendix C's, from the strip and its fabric.
1218            Device::new(
1219                "Charge streamer",
1220                DeviceDrag::Streamer {
1221                    length_m: 1.2,
1222                    width_m: 0.1,
1223                    surface_density_kg_m2: 0.09,
1224                    model: StreamerModel::OpenRocket,
1225                },
1226                Trigger::MotorDelay { motor },
1227            )
1228            .with_lag_s(0.5),
1229            // A stated coefficient on the strip's area; a nameless part goes by its id; the
1230            // launch's delay is the trigger's time, not a lag.
1231            Device::new(
1232                "timed",
1233                DeviceDrag::DragArea {
1234                    cd_s_m2: 0.6 * 1.0 * 0.05,
1235                },
1236                Trigger::Time { time_s: 2.0 },
1237            ),
1238        ];
1239        assert_eq!(flown.devices, expected);
1240        assert_eq!(
1241            flown.not_deployed,
1242            vec![("Spare".to_owned(), NeverDeploys::SetToNever)]
1243        );
1244    }
1245
1246    #[test]
1247    fn a_coefficient_above_knackes_range_flies_as_stated() {
1248        let flown = mapped("charge", |text| {
1249            text.replace("<cd>0.9</cd>", "<cd>2.5</cd>")
1250        })
1251        .expect("maps");
1252        assert_eq!(
1253            flown.devices[1].drag,
1254            DeviceDrag::DragArea {
1255                cd_s_m2: 2.5 * std::f64::consts::PI * 0.6 * 0.6 / 4.0
1256            }
1257        );
1258    }
1259
1260    #[test]
1261    fn the_stages_first_charge_opens_the_device() {
1262        // A second motor in another mount of the same stage, its charge 2 s sooner: it opens
1263        // the streamer, though it is listed second.
1264        let (recovery_, rocket, mut assembly) = recovery_read("charge", |text| text);
1265        let mut sooner = assembly.motors[0].clone();
1266        sooner.mount = "second mount".to_owned();
1267        sooner.mounted.delay = Some(Delay::Seconds(4.0));
1268        assembly.motors.push(sooner);
1269        let streamer = |assembly: &Assembly| {
1270            recovery(&recovery_, &rocket, assembly)
1271                .expect("maps")
1272                .devices[2]
1273                .trigger
1274        };
1275        assert_eq!(streamer(&assembly), Trigger::MotorDelay { motor: 1 });
1276        // The listed-first motor plugged: the other's charge still opens it.
1277        assembly.motors[1].mounted.delay = Some(Delay::Seconds(8.0));
1278        assembly.motors[0].mounted.delay = Some(Delay::Plugged);
1279        assert_eq!(streamer(&assembly), Trigger::MotorDelay { motor: 1 });
1280        // A motor that never lights has no charge to give.
1281        assembly.motors[0].mounted.delay = Some(Delay::Seconds(1.0));
1282        assembly.motors[0].fails = true;
1283        assert_eq!(streamer(&assembly), Trigger::MotorDelay { motor: 1 });
1284    }
1285
1286    #[test]
1287    fn a_pod_s_device_a_wide_streamer_and_a_missing_part_are_refused() {
1288        let (mut recovery_, rocket, assembly) = recovery_read("charge", |text| text);
1289        let mut unread = recovery_.clone();
1290        // The type is non-exhaustive, so it is built as a document would give it.
1291        unread.unread.push(
1292            serde_json::from_str(
1293                r#"{"at": "/rocket/podset/parachute", "tag": "parachute", "inside": "podset"}"#,
1294            )
1295            .expect("an unread device"),
1296        );
1297        assert!(matches!(
1298            recovery(&unread, &rocket, &assembly),
1299            Err(RecoveryRefused::Unread { inside, .. }) if inside == "podset"
1300        ));
1301        // A parachute the file calls a streamer is no part of the rocket's.
1302        recovery_.devices[0].kind = DeviceKind::Streamer;
1303        assert_eq!(
1304            recovery(&recovery_, &rocket, &assembly),
1305            Err(RecoveryRefused::NoPart {
1306                id: "drogue".to_owned()
1307            })
1308        );
1309        let wide = mapped("charge", |text| {
1310            text.replace(
1311                "<stripwidth>0.1</stripwidth>",
1312                "<stripwidth>1.5</stripwidth>",
1313            )
1314        });
1315        assert!(matches!(
1316            wide,
1317            Err(RecoveryRefused::Domain { name, what: "streamer aspect ratio, length over width", .. })
1318                if name == "Charge streamer"
1319        ));
1320    }
1321
1322    #[test]
1323    fn a_plugged_motor_opens_nothing_at_its_charge() {
1324        let flown = mapped("plugged", |text| text).expect("maps");
1325        assert_eq!(
1326            flown
1327                .devices
1328                .iter()
1329                .map(|device| device.name.as_str())
1330                .collect::<Vec<_>>(),
1331            ["Drogue", "Main", "timed"]
1332        );
1333        assert_eq!(
1334            flown.not_deployed,
1335            vec![
1336                ("Charge streamer".to_owned(), NeverDeploys::NoEjectionCharge),
1337                ("Spare".to_owned(), NeverDeploys::SetToNever),
1338            ]
1339        );
1340    }
1341
1342    #[test]
1343    fn a_configurations_own_deployment_wins() {
1344        let flown = mapped("charge", |text| {
1345            text.replace(
1346                "<deployevent>apogee</deployevent><deploydelay>1.0</deploydelay>",
1347                "<deployevent>apogee</deployevent><deploydelay>1.0</deploydelay>\
1348                 <deploymentconfiguration configid=\"charge\"><deployevent>altitude</deployevent>\
1349                 <deployaltitude>50.0</deployaltitude></deploymentconfiguration>",
1350            )
1351        })
1352        .expect("maps");
1353        // The event and height are the configuration's; the delay, which it leaves out, the
1354        // device's own.
1355        assert_eq!(
1356            flown.devices[0].trigger,
1357            Trigger::Altitude {
1358                height_above_ground_m: 50.0
1359            }
1360        );
1361        assert_eq!(flown.devices[0].lag_s, 1.0);
1362    }
1363
1364    #[test]
1365    fn what_is_not_flown_is_refused_by_name() {
1366        let event = |word: &'static str| {
1367            move |text: String| {
1368                text.replace(
1369                    "<deployevent>apogee</deployevent>",
1370                    &format!("<deployevent>{word}</deployevent>"),
1371                )
1372            }
1373        };
1374        assert_eq!(
1375            mapped("charge", event("lowerstageseparation")),
1376            Err(RecoveryRefused::AtSeparation {
1377                name: "Drogue".to_owned()
1378            })
1379        );
1380        assert_eq!(
1381            mapped("charge", event("burnout")),
1382            Err(RecoveryRefused::UnknownEvent {
1383                name: "Drogue".to_owned(),
1384                word: "burnout".to_owned()
1385            })
1386        );
1387        // An altitude with no height, a negative delay, a coefficient of zero.
1388        let refused = |edit: &dyn Fn(String) -> String| match mapped("charge", edit) {
1389            Err(RecoveryRefused::Domain { name, what, .. }) => (name, what),
1390            other => panic!("not a domain error: {other:?}"),
1391        };
1392        assert_eq!(
1393            refused(&event("altitude")),
1394            ("Drogue".to_owned(), "deployment height above the ground, m")
1395        );
1396        assert_eq!(
1397            refused(&|text: String| text.replace(
1398                "<deploydelay>1.0</deploydelay>",
1399                "<deploydelay>-1.0</deploydelay>"
1400            )),
1401            ("Drogue".to_owned(), "deployment delay, s")
1402        );
1403        assert_eq!(
1404            refused(&|text: String| text.replace("<cd>0.9</cd>", "<cd>0.0</cd>")),
1405            ("Main".to_owned(), "drag coefficient")
1406        );
1407    }
1408
1409    /// A device at its stage's charge, in a stage with no motor that lights, never opens, as in
1410    /// OpenRocket 24.12, which opens it on its own stage's charges alone; the others still fly
1411    /// (ADR-168).
1412    #[test]
1413    fn a_charge_with_no_motor_in_its_stage_never_opens() {
1414        let (recovery_, rocket, mut assembly) = recovery_read("charge", |text| text);
1415        let lit = recovery(&recovery_, &rocket, &assembly).unwrap();
1416        for motor in &mut assembly.motors {
1417            motor.fails = true;
1418        }
1419        let unlit = recovery(&recovery_, &rocket, &assembly).unwrap();
1420        let spare = ("Spare".to_owned(), NeverDeploys::SetToNever);
1421        assert_eq!(lit.not_deployed, std::slice::from_ref(&spare));
1422        assert_eq!(
1423            unlit.not_deployed,
1424            [
1425                ("Charge streamer".to_owned(), NeverDeploys::NoLitMotor),
1426                spare
1427            ]
1428        );
1429        assert_eq!(unlit.devices.len() + 1, lit.devices.len());
1430    }
1431
1432    #[test]
1433    fn the_devices_fly() {
1434        // The mapping's devices are ones the flight takes, the charge's motor among them.
1435        let (recovery_, rocket, _) = recovery_read("charge", |text| text);
1436        let flown = recovery(
1437            &recovery_,
1438            &rocket,
1439            &rocket.assemble("charge").expect("flown"),
1440        )
1441        .expect("maps");
1442        let simulation = hpr_sim::Simulation::new(
1443            &rocket,
1444            "charge",
1445            hpr_sim::Environment::standard(
1446                hpr_core::geodesy::Geodetic::from_degrees(45.0, 0.0, 0.0).expect("a site"),
1447            )
1448            .expect("standard air"),
1449            hpr_sim::Rail::vertical(1.0),
1450            hpr_sim::FlightSettings::default(),
1451        )
1452        .expect("a simulation")
1453        .with_recovery(flown.devices)
1454        .expect("devices it takes");
1455        let result = simulation.run(&mut ()).expect("a flight");
1456        let deployed = result
1457            .events
1458            .iter()
1459            .filter(|event| matches!(event.kind, hpr_sim::EventKind::Deployment(_)))
1460            .count();
1461        assert_eq!(deployed, 4);
1462    }
1463}