Skip to main content

hpr_io/ork/
recovery.rs

1//! When a `.ork` design's parachutes and streamers open, how much drag each states, and when each
2//! stage separates.
3//!
4//! **Where a `.ork` keeps them.** A `<parachute>` or `<streamer>` names the event that deploys it
5//! (`<deployevent>`), a height for the event that needs one (`<deployaltitude>`) and a delay after
6//! it (`<deploydelay>`), and may change any of the three for one configuration in a
7//! `<deploymentconfiguration configid="…">`. A stage says when it separates the same way, with
8//! `<separationevent>`, `<separationaltitude>`, `<separationdelay>` and
9//! `<separationconfiguration configid="…">` ([the file specification][spec]). A per-configuration
10//! setting replaces the three one at a time: whichever it leaves out, the device's or stage's own
11//! stands.
12//!
13//! **What the words mean.** OpenRocket's documentation lists none of them. The words here are the
14//! ones OpenRocket 24.12 writes, each measured by setting it through the program's public setters
15//! and saving (`validation/oracles/openrocket/events.py`, whose results
16//! `validation/fixtures/ork/openrocket-events.json` holds and a test reads). The same run shows a
17//! deploy height is above the ground, not the sea, and that in its run a parachute set to open
18//! above apogee did not open at all.
19//!
20//! A per-configuration setting replacing the three one at a time is hpr's reading: OpenRocket was
21//! not probed on a file that leaves one out.
22//!
23//! **Drag.** `<cd>auto</cd>` leaves the drag coefficient to OpenRocket: 0.8 for a parachute, on the
24//! canopy's area, which its technical documentation gives as the default (section 4.2.5) and the
25//! same run reads back, and for a streamer a value from the strip's length and material, on the
26//! strip's area (the documentation's appendix C). This reader keeps the word, and the number when
27//! one is stated; choosing the model is the flight's business.
28//!
29//! Nothing here flies a device: `hpr::ork::recovery` maps these settings onto `hpr-sim`'s devices
30//! ([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 as OpenRocket does), since `hpr-io` does not depend on `hpr-sim`.
31//!
32//! [spec]: https://openrocket.readthedocs.io/en/latest/dev_guide/file_specification.html
33
34use std::collections::{BTreeMap, BTreeSet};
35
36use hpr_design::Rocket;
37use serde::{Deserialize, Serialize};
38
39use super::component::subcomponents;
40use super::document::Element;
41use super::motors::stage_of;
42use super::value::{Dimension, Values};
43use super::warning::{Warning, WarningKind};
44
45/// The path segment every warning about when a device deploys carries, so that
46/// [`super::design`] can tell it from one about the airframe: recovery is read here, and flown by
47/// `hpr::ork::recovery`.
48pub(super) const DEPLOYMENT: &str = "/deployment";
49/// The same, for a device's drag coefficient.
50pub(super) const DRAG: &str = "/drag";
51/// The same, for a stage's separation.
52pub(super) const SEPARATION: &str = "/separation";
53
54/// What deploys a recovery device, as `<deployevent>` names it.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
56#[serde(rename_all = "snake_case")]
57#[non_exhaustive]
58pub enum DeployEvent {
59    /// `launch`: at launch, plus the delay.
60    Launch,
61    /// `ejection`: the first ejection charge of the device's own stage.
62    Ejection,
63    /// `apogee`.
64    Apogee,
65    /// `altitude`: the height `<deployaltitude>` gives, above the ground, on the way down.
66    Altitude,
67    /// `lowerstageseparation`: the separation of the stage below.
68    LowerStageSeparation,
69    /// `never`.
70    Never,
71    /// A word not listed here, kept as written.
72    Other(String),
73}
74
75impl DeployEvent {
76    /// Reads a `<deployevent>`'s text.
77    pub fn parse(text: &str) -> Self {
78        match text.trim() {
79            "launch" => Self::Launch,
80            "ejection" => Self::Ejection,
81            "apogee" => Self::Apogee,
82            "altitude" => Self::Altitude,
83            "lowerstageseparation" => Self::LowerStageSeparation,
84            "never" => Self::Never,
85            other => Self::Other(other.to_owned()),
86        }
87    }
88
89    /// The word the file wrote.
90    pub fn as_str(&self) -> &str {
91        match self {
92            Self::Launch => "launch",
93            Self::Ejection => "ejection",
94            Self::Apogee => "apogee",
95            Self::Altitude => "altitude",
96            Self::LowerStageSeparation => "lowerstageseparation",
97            Self::Never => "never",
98            Self::Other(text) => text,
99        }
100    }
101}
102
103/// What separates a stage from the one above it, as `<separationevent>` names it. "This stage" is
104/// the stage that carries the setting, the lower one, which drops away.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108pub enum SeparationEvent {
109    /// `launch`: at launch, plus the delay.
110    Launch,
111    /// `ignition`: this stage's motor igniting.
112    Ignition,
113    /// `burnout`: this stage's motor burning out.
114    Burnout,
115    /// `ejection`: this stage's ejection charge.
116    Ejection,
117    /// `upperignition`: the motor of the stage above igniting.
118    UpperIgnition,
119    /// `altitudeascending`: a height on the way up.
120    AltitudeAscending,
121    /// `apogee`.
122    Apogee,
123    /// `altitudedescending`: a height on the way down.
124    AltitudeDescending,
125    /// `never`.
126    Never,
127    /// A word not listed here, kept as written.
128    Other(String),
129}
130
131impl SeparationEvent {
132    /// Reads a `<separationevent>`'s text.
133    pub fn parse(text: &str) -> Self {
134        match text.trim() {
135            "launch" => Self::Launch,
136            "ignition" => Self::Ignition,
137            "burnout" => Self::Burnout,
138            "ejection" => Self::Ejection,
139            "upperignition" => Self::UpperIgnition,
140            "altitudeascending" => Self::AltitudeAscending,
141            "apogee" => Self::Apogee,
142            "altitudedescending" => Self::AltitudeDescending,
143            "never" => Self::Never,
144            other => Self::Other(other.to_owned()),
145        }
146    }
147
148    /// The word the file wrote.
149    pub fn as_str(&self) -> &str {
150        match self {
151            Self::Launch => "launch",
152            Self::Ignition => "ignition",
153            Self::Burnout => "burnout",
154            Self::Ejection => "ejection",
155            Self::UpperIgnition => "upperignition",
156            Self::AltitudeAscending => "altitudeascending",
157            Self::Apogee => "apogee",
158            Self::AltitudeDescending => "altitudedescending",
159            Self::Never => "never",
160            Self::Other(text) => text,
161        }
162    }
163}
164
165/// An event, a height for the events that need one, and a delay after it: when a device deploys
166/// or a stage separates. Each is `None` where the file does not say.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
168#[non_exhaustive]
169#[serde(deny_unknown_fields)]
170// One name per event type in the design format's schema.
171#[schemars(rename = "EventSetting_for_{E}")]
172pub struct EventSetting<E> {
173    /// The event.
174    pub event: Option<E>,
175    /// The height, m: above the ground for a deployment (measured; see the module docs).
176    pub altitude_m: Option<f64>,
177    /// Seconds after the event, s.
178    pub delay_s: Option<f64>,
179}
180
181impl<E: Clone> EventSetting<E> {
182    /// This trigger with each field `over` states put in place of this one's.
183    fn overridden_by(&self, over: &Self) -> Self {
184        Self {
185            event: over.event.clone().or_else(|| self.event.clone()),
186            altitude_m: over.altitude_m.or(self.altitude_m),
187            delay_s: over.delay_s.or(self.delay_s),
188        }
189    }
190}
191
192/// When a recovery device deploys.
193pub type Deployment = EventSetting<DeployEvent>;
194
195/// When a stage separates.
196pub type Separation = EventSetting<SeparationEvent>;
197
198/// Which kind of recovery device.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
200#[serde(rename_all = "snake_case")]
201#[non_exhaustive]
202pub enum DeviceKind {
203    /// A `<parachute>`.
204    Parachute,
205    /// A `<streamer>`.
206    Streamer,
207}
208
209/// A parachute's or streamer's recovery settings.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
211#[non_exhaustive]
212#[serde(deny_unknown_fields)]
213pub struct RecoveryDevice {
214    /// The id of the device's component in the rocket.
215    pub id: String,
216    /// The index of its stage in [`Rocket::stages`].
217    pub stage: usize,
218    /// Parachute or streamer.
219    pub kind: DeviceKind,
220    /// `<cd>`: a stated drag coefficient, or `auto` for OpenRocket's own (0.8 for a parachute,
221    /// from the strip's size for a streamer). `None` where the file says nothing.
222    pub cd: Option<Dimension>,
223    /// When it deploys, as the device states it.
224    pub deployment: Deployment,
225    /// When it deploys in each configuration that changes that, by `configid`, with anything the
226    /// configuration leaves out taken from [`RecoveryDevice::deployment`].
227    pub configurations: BTreeMap<String, Deployment>,
228}
229
230impl RecoveryDevice {
231    /// When the device deploys in configuration `id`.
232    pub fn deployment_in(&self, id: &str) -> &Deployment {
233        self.configurations.get(id).unwrap_or(&self.deployment)
234    }
235}
236
237/// A stage's separation settings.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
239#[non_exhaustive]
240#[serde(deny_unknown_fields)]
241pub struct StageSeparation {
242    /// The stage's id in the rocket.
243    pub id: String,
244    /// The stage's index in [`Rocket::stages`].
245    pub stage: usize,
246    /// When it separates, as the stage states it.
247    pub separation: Separation,
248    /// When it separates in each configuration that changes that, by `configid`, with anything
249    /// the configuration leaves out taken from [`StageSeparation::separation`].
250    pub configurations: BTreeMap<String, Separation>,
251}
252
253impl StageSeparation {
254    /// When the stage separates in configuration `id`.
255    pub fn separation_in(&self, id: &str) -> &Separation {
256        self.configurations.get(id).unwrap_or(&self.separation)
257    }
258}
259
260/// A parachute or streamer inside a part hpr does not read, such as a pod.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
262#[non_exhaustive]
263#[serde(deny_unknown_fields)]
264pub struct UnreadDevice {
265    /// Where it is in the file.
266    pub at: String,
267    /// `parachute` or `streamer`.
268    pub tag: String,
269    /// The tag of the outermost part that was not read: a `podset` or `parallelstage`, or else
270    /// the device's own tag.
271    pub inside: String,
272}
273
274/// Every recovery setting in a `.ork` design.
275#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
276#[non_exhaustive]
277#[serde(deny_unknown_fields)]
278pub struct Recovery {
279    /// The parachutes and streamers read, in file order.
280    pub devices: Vec<RecoveryDevice>,
281    /// Every stage that states when it separates, in file order.
282    pub separations: Vec<StageSeparation>,
283    /// The parachutes and streamers in parts hpr does not read.
284    pub unread: Vec<UnreadDevice>,
285    /// The parallel stages that state a separation, in parts hpr did not read.
286    pub unread_separations: Vec<UnreadDevice>,
287}
288
289/// A device's settings, read while the walk reads its component.
290#[derive(Debug, Clone, PartialEq)]
291pub(super) struct DeviceRead {
292    /// Where the device is in the file.
293    pub at: String,
294    kind: DeviceKind,
295    cd: Option<Dimension>,
296    deployment: Deployment,
297    configurations: BTreeMap<String, Deployment>,
298}
299
300/// A stage's separation, read while the walk reads the stage.
301#[derive(Debug, Clone, PartialEq)]
302pub(super) struct SeparationRead {
303    /// The stage's path.
304    at: String,
305    separation: Separation,
306    configurations: BTreeMap<String, Separation>,
307}
308
309/// Reads the recovery settings of `element`, a `<parachute>` or `<streamer>` at `at`.
310pub(super) fn device(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> DeviceRead {
311    let kind = if element.name == "streamer" {
312        DeviceKind::Streamer
313    } else {
314        DeviceKind::Parachute
315    };
316    // Warnings here are about when the device opens and how much drag it states, not its shape,
317    // and say so in their path.
318    let cd = Values::new(element, &format!("{at}{DRAG}"), warnings).dimension(&["cd"]);
319    let (deployment, configurations) = trigger(
320        element,
321        &format!("{at}{DEPLOYMENT}"),
322        "deploy",
323        "deploymentconfiguration",
324        DeployEvent::parse,
325        |event| !matches!(event, DeployEvent::Other(_)),
326        warnings,
327    );
328    DeviceRead {
329        at: at.to_owned(),
330        kind,
331        cd,
332        deployment,
333        configurations,
334    }
335}
336
337/// Reads a stage's separation settings, or `None` when it states none.
338pub(super) fn separation(
339    element: &Element,
340    at: &str,
341    warnings: &mut Vec<Warning>,
342) -> Option<SeparationRead> {
343    let (separation, configurations) = trigger(
344        element,
345        &format!("{at}{SEPARATION}"),
346        "separation",
347        "separationconfiguration",
348        SeparationEvent::parse,
349        |event| !matches!(event, SeparationEvent::Other(_)),
350        warnings,
351    );
352    let empty = separation.event.is_none()
353        && separation.altitude_m.is_none()
354        && separation.delay_s.is_none()
355        && configurations.is_empty();
356    (!empty).then_some(SeparationRead {
357        at: at.to_owned(),
358        separation,
359        configurations,
360    })
361}
362
363/// An element's `<{prefix}event>`, `<{prefix}altitude>` and `<{prefix}delay>`, and the same in
364/// each `<{per}>` child, merged over them.
365fn trigger<E: Clone>(
366    element: &Element,
367    at: &str,
368    prefix: &str,
369    per: &str,
370    parse: fn(&str) -> E,
371    known: fn(&E) -> bool,
372    warnings: &mut Vec<Warning>,
373) -> (EventSetting<E>, BTreeMap<String, EventSetting<E>>) {
374    let tag = format!("{prefix}event");
375    let own = |element: &Element, at: &str, warnings: &mut Vec<Warning>| {
376        let mut values = Values::new(element, at, warnings);
377        let event = match values.word(&[&tag]) {
378            None => None,
379            Some(text) if text.is_empty() => {
380                values.warn_at(
381                    WarningKind::Dropped,
382                    format!("`{tag}` is empty; it was read as not stated"),
383                );
384                values.forget(&[&tag]);
385                None
386            }
387            Some(text) => {
388                let event = parse(&text);
389                if !known(&event) {
390                    values.warn_at(
391                        WarningKind::Unusual,
392                        format!(
393                            "`{tag}` says `{text}`, a word OpenRocket 24.12 does not write; it \
394                             was kept as written"
395                        ),
396                    );
397                }
398                Some(event)
399            }
400        };
401        EventSetting {
402            event,
403            altitude_m: values.number(&[&format!("{prefix}altitude")]),
404            delay_s: values.number(&[&format!("{prefix}delay")]),
405        }
406    };
407    let default = own(element, at, warnings);
408    let mut configurations = BTreeMap::new();
409    super::reads::note(element, per);
410    for child in element.children_named(per) {
411        let here = format!("{at}/{per}");
412        let Some(id) = child
413            .attribute("configid")
414            .map(str::trim)
415            .filter(|id| !id.is_empty())
416        else {
417            warnings.push(Warning::new(
418                here,
419                WarningKind::Dropped,
420                format!("a `{per}` with no `configid` belongs to no configuration; it was ignored"),
421            ));
422            continue;
423        };
424        let over = own(child, &here, warnings);
425        if configurations
426            .insert(id.to_owned(), default.overridden_by(&over))
427            .is_some()
428        {
429            warnings.push(Warning::new(
430                here,
431                WarningKind::Dropped,
432                format!("configuration `{id}` is set twice here; the last was kept"),
433            ));
434        }
435    }
436    (default, configurations)
437}
438
439/// Every recovery setting: the devices and stages the walk read (each with the id the rocket gave
440/// it), and the devices in parts it did not, found in `rocket_element`.
441pub(super) fn read(
442    rocket_element: &Element,
443    rocket: &Rocket,
444    devices: Vec<(String, DeviceRead)>,
445    separations: Vec<(String, SeparationRead)>,
446) -> Recovery {
447    let mut placed = BTreeSet::new();
448    let mut read_devices = Vec::new();
449    for (id, device) in devices {
450        let Some(stage) = stage_of(rocket, &id) else {
451            continue;
452        };
453        placed.insert(device.at.clone());
454        read_devices.push(RecoveryDevice {
455            id,
456            stage,
457            kind: device.kind,
458            cd: device.cd,
459            deployment: device.deployment,
460            configurations: device.configurations,
461        });
462    }
463    // A parallel stage read with its separation is no unread one.
464    placed.extend(separations.iter().map(|(_, read)| read.at.clone()));
465    let read_separations = separations
466        .into_iter()
467        .filter_map(|(id, read)| {
468            Some(StageSeparation {
469                stage: rocket.stages.iter().position(|stage| stage.id == id)?,
470                id,
471                separation: read.separation,
472                configurations: read.configurations,
473            })
474        })
475        .collect();
476    let mut unread = Vec::new();
477    let mut unread_separations = Vec::new();
478    for (index, child) in subcomponents(rocket_element).enumerate() {
479        let path = format!("openrocket/rocket/{}[{index}]", child.name);
480        unread_devices(
481            child,
482            &path,
483            None,
484            &placed,
485            &mut unread,
486            &mut unread_separations,
487        );
488    }
489    Recovery {
490        devices: read_devices,
491        separations: read_separations,
492        unread,
493        unread_separations,
494    }
495}
496
497/// Every `<parachute>` or `<streamer>` under `element` that the walk did not read, and every
498/// `<parallelstage>` stating a separation that it did not read, by `read`, the paths of the devices
499/// and separations it did. `inside` is the outermost part on the way down that hpr does not read,
500/// if any.
501fn unread_devices(
502    element: &Element,
503    at: &str,
504    inside: Option<&str>,
505    read: &BTreeSet<String>,
506    found: &mut Vec<UnreadDevice>,
507    separations: &mut Vec<UnreadDevice>,
508) {
509    let inside = inside.or(match element.name.as_str() {
510        tag @ ("podset" | "parallelstage") if !read.contains(at) => Some(tag),
511        _ => None,
512    });
513    let states_a_separation = ["separationevent", "separationconfiguration"]
514        .iter()
515        .any(|tag| element.child(tag).is_some());
516    if element.name == "parallelstage" && states_a_separation && !read.contains(at) {
517        separations.push(UnreadDevice {
518            at: at.to_owned(),
519            tag: element.name.clone(),
520            inside: inside.unwrap_or(element.name.as_str()).to_owned(),
521        });
522    }
523    if matches!(element.name.as_str(), "parachute" | "streamer") && !read.contains(at) {
524        found.push(UnreadDevice {
525            at: at.to_owned(),
526            tag: element.name.clone(),
527            inside: inside.unwrap_or(element.name.as_str()).to_owned(),
528        });
529    }
530    for (index, child) in subcomponents(element).enumerate() {
531        let path = format!("{at}/{}[{index}]", child.name);
532        unread_devices(child, &path, inside, read, found, separations);
533    }
534}