Skip to main content

hpr_io/ork/
simulations.rs

1//! The simulations OpenRocket last ran on a `.ork` design: the launch conditions each was flown in
2//! and the results it stored.
3//!
4//! **Where a `.ork` keeps them.** `<simulations>` holds one `<simulation>` per run, with a name, a
5//! `status`, the `<conditions>` it was flown in, and, when OpenRocket saved them, its results in
6//! `<flightdata>`: ten summary figures as attributes, and a `<databranch>` per stage, each a time
7//! series (`<datapoint>` rows, comma-separated, in the order its `types` attribute names) with the
8//! flight's `<event>`s ([the file specification][spec], *Simulation Data*).
9//!
10//! **What the numbers mean.** The file-format page gives units only for the multilevel wind
11//! (meters, m/s and radians), and its own example writes the launch rod's direction as `90.0` and
12//! the wind's as `1.5707963267948966`. A committed probe
13//! (`validation/oracles/openrocket/conditions.py`, results in
14//! `validation/fixtures/ork/openrocket-conditions.json`) runs OpenRocket 24.12 and settles it:
15//!
16//! - `launchrodangle` and `launchroddirection` are **degrees**; this reader gives them in radians.
17//!   The direction is a compass bearing, clockwise from north: a rod tilted toward 90 lands the
18//!   rocket to the east. With `launchintowind`, OpenRocket writes the wind's bearing, in degrees,
19//!   as the rod's.
20//! - `winddirection` is **radians**, the bearing the wind blows **from**: in a wind from the east a
21//!   rocket drifts west. It is never the rod's direction ([Loft lesson L64][l64]: Loft read it from
22//!   `launchroddirection`).
23//! - In the eight columns the probe compared, the time series is SI with angles in radians and
24//!   latitude in degrees, rounded when stored: to three decimal places (287.857 K), and a large
25//!   value to four significant figures (100,796.6 Pa is stored as 100,800). `NaN` marks a quantity
26//!   OpenRocket did not compute at that step, and this reader keeps it as `None`.
27//!
28//! All of this is measured on OpenRocket 24.12; a file written by a much older version may have
29//! meant the rod's direction otherwise, which is not measured.
30//!
31//! [spec]: https://openrocket.readthedocs.io/en/latest/dev_guide/file_specification.html
32//! [l64]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l64
33
34use serde::{Deserialize, Serialize};
35
36use super::document::{Document, Element};
37use super::value::Values;
38use super::warning::{Warning, WarningKind};
39
40/// A simulation stored in a `.ork`.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[non_exhaustive]
43#[serde(deny_unknown_fields)]
44pub struct StoredSimulation {
45    /// `<name>`.
46    pub name: String,
47    /// The `status` attribute as written: `uptodate`, `outdated`, `loaded`, `external`,
48    /// `notsimulated`, `cantrun` or `aborted` in the files OpenRocket 24.12 writes.
49    pub status: Option<String>,
50    /// `<simulator>`, such as `RK4Simulator`.
51    pub simulator: Option<String>,
52    /// `<calculator>`, such as `BarrowmanCalculator`.
53    pub calculator: Option<String>,
54    /// `<conditions>`: what the run was flown in.
55    pub conditions: Option<LaunchConditions>,
56    /// `<flightdata>`: what it gave, when OpenRocket saved it.
57    pub results: Option<StoredResults>,
58    /// Whether reading this simulation required dropping or reinterpreting stored data.
59    #[serde(default)]
60    pub parser_warnings: bool,
61}
62
63/// Why a stored simulation cannot be used as a validation reference.
64///
65/// Reading a result and accepting it as a reference are separate operations. This enum covers
66/// status, summary and internal-consistency checks that can be made from the stored simulation
67/// alone. A caller that has the containing design must additionally reject reduced designs and
68/// unknown motor configurations.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum StoredReferenceExclusion {
73    /// The simulation has no status, so it cannot be established as current.
74    MissingStatus,
75    /// The simulation was recorded before the design changed.
76    Outdated,
77    /// The file says that the simulation was not run.
78    NotSimulated,
79    /// The result came from an external simulator, not OpenRocket's own run.
80    External,
81    /// The result was loaded rather than produced by a current run.
82    Loaded,
83    /// The simulation could not be run.
84    CannotRun,
85    /// The simulation was aborted.
86    Aborted,
87    /// The file contains a status not recognized by this reader.
88    UnknownStatus,
89    /// The simulation has no `<simulator>`, so its producer cannot be established.
90    MissingSimulator,
91    /// The simulation names a producer other than the pinned OpenRocket RK4 simulator.
92    UnsupportedSimulator,
93    /// The simulation has no `<calculator>`, so its aerodynamic model cannot be established.
94    MissingCalculator,
95    /// The simulation names a calculator other than the pinned OpenRocket Barrowman calculator.
96    UnsupportedCalculator,
97    /// The simulation has no `<flightdata>`.
98    MissingResults,
99    /// The flightdata lacks one of the required ascent summary values.
100    MissingSummary,
101    /// A summary value is non-finite, negative, or otherwise impossible as a maximum.
102    ImpossibleSummary,
103    /// The summary and stored time series contradict one another.
104    InconsistentResults,
105    /// The parser had to drop or reinterpret data belonging to this simulation.
106    ParserWarning,
107    /// The stored flight ended with a fatal simulation event.
108    FatalEvent,
109    /// A stored time series has rows but no recognized time or altitude column.
110    UninspectableSeries,
111    /// The containing design is reduced, so hpr cannot reproduce its full geometry.
112    ReducedDesign,
113    /// The stored run does not name a motor configuration.
114    MissingConfiguration,
115    /// The stored run names a configuration absent from the design.
116    UnknownConfiguration,
117    /// The named configuration cannot be flown by hpr as read.
118    UnflyableConfiguration,
119}
120
121impl StoredReferenceExclusion {
122    /// The stable reason used by survey reports.
123    #[must_use]
124    pub const fn reason(self) -> &'static str {
125        match self {
126            Self::MissingStatus => "status-missing",
127            Self::Outdated => "status-outdated",
128            Self::NotSimulated => "status-not-simulated",
129            Self::External => "status-external",
130            Self::Loaded => "status-loaded",
131            Self::CannotRun => "status-cannot-run",
132            Self::Aborted => "status-aborted",
133            Self::UnknownStatus => "status-unknown",
134            Self::MissingSimulator => "simulator-missing",
135            Self::UnsupportedSimulator => "simulator-unsupported",
136            Self::MissingCalculator => "calculator-missing",
137            Self::UnsupportedCalculator => "calculator-unsupported",
138            Self::MissingResults => "missing-results",
139            Self::MissingSummary => "missing-summary",
140            Self::ImpossibleSummary => "impossible-summary",
141            Self::InconsistentResults => "inconsistent-results",
142            Self::ParserWarning => "parser-warning",
143            Self::FatalEvent => "fatal-event",
144            Self::UninspectableSeries => "uninspectable-series",
145            Self::ReducedDesign => "reduced-design",
146            Self::MissingConfiguration => "configuration-missing",
147            Self::UnknownConfiguration => "configuration-unknown",
148            Self::UnflyableConfiguration => "configuration-unflyable",
149        }
150    }
151}
152
153/// The simulator identifier accepted for an OpenRocket reference.
154///
155/// The public `.ork` file specification's Simulation Data example names this simulator. The
156/// identifier is a provenance check, not a claim that every OpenRocket release uses this integrator.
157pub const OPENROCKET_SIMULATOR: &str = "RK4Simulator";
158
159/// The aerodynamic calculator identifier accepted for an OpenRocket reference.
160///
161/// The public `.ork` file specification's Simulation Data example names this calculator. A stored
162/// result from another calculator is not interchangeable with the pinned reference provenance.
163pub const OPENROCKET_CALCULATOR: &str = "BarrowmanCalculator";
164
165impl StoredSimulation {
166    /// Returns why this stored result must not become a validation reference.
167    ///
168    /// Only an explicitly `uptodate` simulation with the accepted [`OPENROCKET_SIMULATOR`] and
169    /// [`OPENROCKET_CALCULATOR`] provenance markers and finite, positive ascent summary values is
170    /// eligible. The two identifiers establish the recorded producer names, not a complete identity
171    /// fingerprint for the OpenRocket version, settings or design. Optional summary values
172    /// are checked when present. A time series is not required: OpenRocket can save a useful
173    /// summary without one. When a time series includes time and altitude, its finite values must
174    /// be non-negative apart from a 1 mm ground-contact rounding allowance, and time must not run
175    /// backwards. The selected summary branch's maximum altitude must agree with the stored apogee
176    /// in both directions within `max(0.1% of apogee, 1 mm)`. When multiple branches record an
177    /// apogee, the first such branch in file order is selected; the reader does not infer branch
178    /// identity from a matching maximum. A single altitude-bearing branch is selected even without
179    /// an apogee event; multiple such
180    /// branches without an apogee event are uninspectable because the summary branch cannot be
181    /// identified. A branch with rows but no
182    /// recognized time or altitude column is not inspectable. No arbitrary lower altitude threshold
183    /// is used: a small but internally consistent flight is not impossible merely because it is
184    /// small. Fatal events and event times outside the stored flight are also rejected.
185    #[must_use]
186    pub fn reference_exclusion(&self) -> Option<StoredReferenceExclusion> {
187        let exclusion = match self.status.as_deref() {
188            Some("uptodate") => None,
189            None => Some(StoredReferenceExclusion::MissingStatus),
190            Some("outdated") => Some(StoredReferenceExclusion::Outdated),
191            Some("notsimulated") => Some(StoredReferenceExclusion::NotSimulated),
192            Some("external") => Some(StoredReferenceExclusion::External),
193            Some("loaded") => Some(StoredReferenceExclusion::Loaded),
194            Some("cantrun") => Some(StoredReferenceExclusion::CannotRun),
195            Some("aborted") => Some(StoredReferenceExclusion::Aborted),
196            Some(_) => Some(StoredReferenceExclusion::UnknownStatus),
197        };
198        if exclusion.is_some() {
199            return exclusion;
200        }
201        let Some(simulator) = self.simulator.as_deref() else {
202            return Some(StoredReferenceExclusion::MissingSimulator);
203        };
204        if simulator != OPENROCKET_SIMULATOR {
205            return Some(StoredReferenceExclusion::UnsupportedSimulator);
206        }
207        let Some(calculator) = self.calculator.as_deref() else {
208            return Some(StoredReferenceExclusion::MissingCalculator);
209        };
210        if calculator != OPENROCKET_CALCULATOR {
211            return Some(StoredReferenceExclusion::UnsupportedCalculator);
212        }
213        let Some(results) = self.results.as_ref() else {
214            return Some(StoredReferenceExclusion::MissingResults);
215        };
216        if let Some(exclusion) = results.reference_exclusion() {
217            return Some(exclusion);
218        }
219        self.parser_warnings
220            .then_some(StoredReferenceExclusion::ParserWarning)
221    }
222}
223
224/// The launch conditions a stored simulation was flown in. Each is `None` where the file does not
225/// say.
226#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
227#[non_exhaustive]
228#[serde(deny_unknown_fields)]
229pub struct LaunchConditions {
230    /// `<configid>`: the motor configuration flown.
231    pub configuration: Option<String>,
232    /// `<launchrodlength>`, m.
233    pub rod_length_m: Option<f64>,
234    /// `<launchrodangle>`: the rod's tilt from vertical, rad (the file writes degrees).
235    pub rod_angle_rad: Option<f64>,
236    /// `<launchroddirection>`: the compass bearing the rod tilts toward, clockwise from north, rad
237    /// (the file writes degrees).
238    pub rod_direction_rad: Option<f64>,
239    /// `<launchintowind>`: whether the rod is pointed into the wind, which OpenRocket then writes
240    /// as the rod's direction.
241    pub into_wind: Option<bool>,
242    /// `<windaverage>`, or the average wind's `<speed>`: the mean wind speed, m/s.
243    pub wind_speed_m_s: Option<f64>,
244    /// `<windturbulence>`: the turbulence intensity, the standard deviation of the wind speed over
245    /// its mean.
246    pub wind_turbulence: Option<f64>,
247    /// `<winddirection>`, or the average wind's `<direction>`: the compass bearing the wind blows
248    /// from, rad (the file writes radians).
249    pub wind_from_rad: Option<f64>,
250    /// `<windmodeltype>`: which wind the run flew, `Average` or the multilevel one. OpenRocket
251    /// writes both winds whichever it flew.
252    pub wind_model: Option<String>,
253    /// `<wind model="multilevel">`'s levels, lowest first as written.
254    pub wind_levels: Vec<WindLevel>,
255    /// The multilevel wind's `altituderef`: whether its altitudes are above the ground (`agl`) or
256    /// the sea (`msl`).
257    pub wind_levels_above: Option<String>,
258    /// `<launchaltitude>`: the launch site's height above sea level, m.
259    pub launch_altitude_m: Option<f64>,
260    /// `<launchlatitude>`, degrees north.
261    pub latitude_deg: Option<f64>,
262    /// `<launchlongitude>`, degrees east.
263    pub longitude_deg: Option<f64>,
264    /// `<geodeticmethod>`: `flat`, `spherical` or `wgs84`.
265    pub geodetic_method: Option<String>,
266    /// `<atmosphere>`.
267    pub atmosphere: Option<Atmosphere>,
268    /// `<timestep>`, s.
269    pub time_step_s: Option<f64>,
270    /// `<maxtime>`, s.
271    pub max_time_s: Option<f64>,
272}
273
274/// One level of a multilevel wind.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
276#[non_exhaustive]
277#[serde(deny_unknown_fields)]
278pub struct WindLevel {
279    /// Its altitude, m, above the ground or the sea as [`LaunchConditions::wind_levels_above`]
280    /// says.
281    pub altitude_m: Option<f64>,
282    /// Its mean speed, m/s.
283    pub speed_m_s: Option<f64>,
284    /// The bearing it blows from, rad.
285    pub from_rad: Option<f64>,
286    /// The standard deviation of its speed, m/s.
287    pub standard_deviation_m_s: Option<f64>,
288}
289
290/// The atmosphere a stored simulation was flown in.
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
292#[serde(rename_all = "snake_case", tag = "model")]
293#[non_exhaustive]
294#[serde(deny_unknown_fields)]
295pub enum Atmosphere {
296    /// `isa`: the International Standard Atmosphere.
297    // A struct variant, not a unit one: serde lets a unit variant of an internally tagged enum
298    // take any other keys, despite `deny_unknown_fields`, which the schema refuses.
299    Isa {},
300    /// `extendedisa`: the standard atmosphere from a temperature and pressure at the launch site.
301    Extended {
302        /// `<basetemperature>`, K.
303        temperature_k: Option<f64>,
304        /// `<basepressure>`, Pa.
305        pressure_pa: Option<f64>,
306    },
307    /// A model not listed here, or none, kept by name only.
308    Other {
309        /// The `model` attribute; empty when there is none.
310        name: String,
311    },
312}
313
314/// What a stored simulation gave: its summary and its time series.
315#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
316#[non_exhaustive]
317#[serde(deny_unknown_fields)]
318pub struct StoredResults {
319    /// `maxaltitude`, m.
320    pub max_altitude_m: Option<f64>,
321    /// `maxvelocity`, m/s.
322    pub max_speed_m_s: Option<f64>,
323    /// `maxacceleration`, m/s².
324    pub max_acceleration_m_s2: Option<f64>,
325    /// `maxmach`.
326    pub max_mach: Option<f64>,
327    /// `timetoapogee`, s.
328    pub time_to_apogee_s: Option<f64>,
329    /// `flighttime`, s.
330    pub flight_time_s: Option<f64>,
331    /// `groundhitvelocity`, m/s.
332    pub ground_hit_speed_m_s: Option<f64>,
333    /// `launchrodvelocity`: the speed leaving the rod, m/s.
334    pub rod_exit_speed_m_s: Option<f64>,
335    /// `deploymentvelocity`: the speed at the first deployment, m/s.
336    pub deployment_speed_m_s: Option<f64>,
337    /// `optimumdelay`: the ejection delay that would have fired at apogee, s.
338    pub optimum_delay_s: Option<f64>,
339    /// The time series, one per stage, in file order.
340    pub branches: Vec<StoredBranch>,
341    /// The `<warning>`s OpenRocket stored with the results, each as its text.
342    pub warnings: Vec<String>,
343}
344
345/// Small negative AGL values at the numerically interpolated ground event are accepted as
346/// rounding around zero, not as a negative flight. This is a data-integrity allowance for stored
347/// values, not a physics tolerance; materially negative altitude remains impossible.
348const STORED_GROUND_ALTITUDE_TOLERANCE_M: f64 = 1e-3;
349/// Stored times are rounded to milliseconds; allow one last-digit unit when reconciling them.
350const STORED_TIME_TOLERANCE_S: f64 = 1e-3;
351
352impl StoredResults {
353    fn reference_exclusion(&self) -> Option<StoredReferenceExclusion> {
354        let required = [
355            self.max_altitude_m,
356            self.max_speed_m_s,
357            self.time_to_apogee_s,
358        ];
359        if required.iter().any(Option::is_none) {
360            return Some(StoredReferenceExclusion::MissingSummary);
361        }
362        let all = [
363            self.max_altitude_m,
364            self.max_speed_m_s,
365            self.max_acceleration_m_s2,
366            self.max_mach,
367            self.time_to_apogee_s,
368            self.flight_time_s,
369            self.ground_hit_speed_m_s,
370            self.rod_exit_speed_m_s,
371            self.deployment_speed_m_s,
372            self.optimum_delay_s,
373        ];
374        if all
375            .iter()
376            .flatten()
377            .any(|value| !value.is_finite() || *value < 0.0)
378            || self.max_altitude_m == Some(0.0)
379            || self.max_speed_m_s == Some(0.0)
380            || self.time_to_apogee_s == Some(0.0)
381        {
382            return Some(StoredReferenceExclusion::ImpossibleSummary);
383        }
384        if let (Some(apogee_time), Some(flight_time)) = (self.time_to_apogee_s, self.flight_time_s)
385            && apogee_time > flight_time
386        {
387            return Some(StoredReferenceExclusion::InconsistentResults);
388        }
389        let Some(apogee) = self.max_altitude_m else {
390            return Some(StoredReferenceExclusion::MissingSummary);
391        };
392        // A multi-stage result has one branch per stage. The summary belongs to the first branch
393        // that records an apogee event: the surveyed cases put the primary flight branch first,
394        // while a separated booster can have a different height. For a single altitude-bearing branch, the
395        // series itself identifies the summary even when an old or hand-written file omitted the
396        // apogee event. Multiple branches without an apogee event cannot be mapped to the summary
397        // without guessing.
398        let mut altitude_branches = Vec::new();
399        let mut series_maxes = vec![None; self.branches.len()];
400        for (index, branch) in self.branches.iter().enumerate() {
401            let times = branch.column("Time");
402            let altitudes = branch.column("Altitude");
403            if branch
404                .rows
405                .iter()
406                .any(|row| row.iter().all(Option::is_none))
407                || (!branch.rows.is_empty() && times.is_none() && altitudes.is_none())
408            {
409                return Some(StoredReferenceExclusion::UninspectableSeries);
410            }
411            if let Some(times) = times {
412                let mut previous = None;
413                let mut observed = false;
414                for time in times.into_iter().flatten() {
415                    observed = true;
416                    if !time.is_finite() || time < 0.0 {
417                        return Some(StoredReferenceExclusion::ImpossibleSummary);
418                    }
419                    if previous.is_some_and(|old| time < old) {
420                        return Some(StoredReferenceExclusion::InconsistentResults);
421                    }
422                    if self.flight_time_s.is_some_and(|flight_time| {
423                        times_differ_more_than_allowed(time, flight_time) && time > flight_time
424                    }) {
425                        return Some(StoredReferenceExclusion::InconsistentResults);
426                    }
427                    previous = Some(time);
428                }
429                if !observed && !branch.rows.is_empty() {
430                    return Some(StoredReferenceExclusion::UninspectableSeries);
431                }
432            }
433            let Some(altitudes) = altitudes else {
434                continue;
435            };
436            if !altitudes.is_empty() {
437                altitude_branches.push(index);
438            }
439            let mut series_max = None;
440            for altitude in altitudes.into_iter().flatten() {
441                if !altitude.is_finite() || altitude < -STORED_GROUND_ALTITUDE_TOLERANCE_M {
442                    return Some(StoredReferenceExclusion::ImpossibleSummary);
443                }
444                if altitude >= 0.0 {
445                    series_max = Some(series_max.map_or(altitude, |old: f64| old.max(altitude)));
446                }
447            }
448            if series_max.is_none() && !branch.rows.is_empty() {
449                return Some(StoredReferenceExclusion::UninspectableSeries);
450            }
451            series_maxes[index] = series_max;
452        }
453        let apogee_branches: Vec<usize> = self
454            .branches
455            .iter()
456            .enumerate()
457            .filter(|(_, branch)| branch.events.iter().any(|event| event.kind == "apogee"))
458            .map(|(index, _)| index)
459            .collect();
460        let summary_branch = match apogee_branches.as_slice() {
461            [] => altitude_branches.first().copied(),
462            [index, ..] => Some(*index),
463        };
464        if let Some(index) = apogee_branches.first().copied()
465            && let Some(event) = self.branches[index]
466                .events
467                .iter()
468                .find(|event| event.kind == "apogee")
469            && times_differ_more_than_allowed(
470                event.time_s,
471                self.time_to_apogee_s.unwrap_or_default(),
472            )
473        {
474            return Some(StoredReferenceExclusion::InconsistentResults);
475        }
476        if apogee_branches.is_empty() && altitude_branches.len() > 1 {
477            return Some(StoredReferenceExclusion::UninspectableSeries);
478        }
479        for (index, series_max) in series_maxes.into_iter().enumerate() {
480            // Compare the first branch whose apogee event defines the summary. For a single branch
481            // without that event, the branch is the only available summary evidence. Stored values
482            // are rounded, so allow a small relative error in either direction, but not a series
483            // that demonstrably disagrees with the claimed apogee.
484            if summary_branch == Some(index)
485                && let Some(series_max) = series_max
486                && (series_max - apogee).abs() > 1e-3_f64.max(apogee * 1e-3)
487            {
488                return Some(StoredReferenceExclusion::InconsistentResults);
489            }
490        }
491        for event in self.branches.iter().flat_map(|branch| &branch.events) {
492            if !event.time_s.is_finite() || event.time_s < 0.0 {
493                return Some(StoredReferenceExclusion::ImpossibleSummary);
494            }
495            if self
496                .flight_time_s
497                .is_some_and(|flight_time| event.time_s > flight_time)
498            {
499                return Some(StoredReferenceExclusion::InconsistentResults);
500            }
501            if is_fatal_event(&event.kind) {
502                return Some(StoredReferenceExclusion::FatalEvent);
503            }
504        }
505        None
506    }
507}
508
509fn times_differ_more_than_allowed(first: f64, second: f64) -> bool {
510    let rounding = f64::EPSILON * first.abs().max(second.abs()).max(1.0) * 4.0;
511    (first - second).abs() > STORED_TIME_TOLERANCE_S + rounding
512}
513
514fn is_fatal_event(kind: &str) -> bool {
515    let normalized: String = kind
516        .chars()
517        .filter(|character| !matches!(character, '_' | '-'))
518        .flat_map(char::to_lowercase)
519        .collect();
520    matches!(
521        normalized.as_str(),
522        "exception" | "simabort" | "simulationabort"
523    )
524}
525
526/// One stage's stored time series.
527#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
528#[non_exhaustive]
529#[serde(deny_unknown_fields)]
530pub struct StoredBranch {
531    /// `name`: the stage's name.
532    pub name: String,
533    /// `types`: each column's name as OpenRocket shows it, such as `Time` or `Altitude`.
534    pub types: Vec<String>,
535    /// The `<datapoint>` rows, each a value per column, as written: SI, angles in radians, latitude
536    /// and longitude in degrees. `None` where the file says `NaN`, a quantity OpenRocket did not
537    /// compute at that step.
538    pub rows: Vec<Vec<Option<f64>>>,
539    /// The `<event>`s, in file order.
540    pub events: Vec<StoredEvent>,
541}
542
543impl StoredBranch {
544    /// The column called `name`, one value per row; `None` where OpenRocket did not compute it.
545    pub fn column(&self, name: &str) -> Option<Vec<Option<f64>>> {
546        let index = self.types.iter().position(|column| column == name)?;
547        Some(
548            self.rows
549                .iter()
550                .map(|row| row.get(index).copied().flatten())
551                .collect(),
552        )
553    }
554}
555
556/// An event a stored simulation logged.
557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
558#[non_exhaustive]
559#[serde(deny_unknown_fields)]
560pub struct StoredEvent {
561    /// `time`, s.
562    pub time_s: f64,
563    /// `type`, such as `apogee` or `recoverydevicedeployment`.
564    pub kind: String,
565    /// `source`: the id of the component it came from, if any.
566    pub source: Option<String>,
567}
568
569/// Reads every stored simulation in `document`.
570pub(super) fn read(document: &Document, warnings: &mut Vec<Warning>) -> Vec<StoredSimulation> {
571    let Some(simulations) = document.root.child("simulations") else {
572        return Vec::new();
573    };
574    simulations
575        .children_named("simulation")
576        .enumerate()
577        .map(|(index, element)| {
578            let at = format!("openrocket/simulations/simulation[{index}]");
579            simulation(element, &at, warnings)
580        })
581        .collect()
582}
583
584fn simulation(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> StoredSimulation {
585    let warning_count = warnings.len();
586    // Looked up directly below, not through `Values`.
587    super::reads::note(element, "conditions");
588    super::reads::note(element, "flightdata");
589    let mut values = Values::new(element, at, warnings);
590    let name = values.word(&["name"]).unwrap_or_default();
591    let simulator = values.word(&["simulator"]);
592    let calculator = values.word(&["calculator"]);
593    let conditions = element
594        .child("conditions")
595        .map(|c| conditions(c, &format!("{at}/conditions"), warnings));
596    let results = element
597        .child("flightdata")
598        .map(|f| results(f, &format!("{at}/flightdata"), warnings));
599    StoredSimulation {
600        name,
601        status: element.attribute("status").map(str::to_owned),
602        simulator,
603        calculator,
604        conditions,
605        results,
606        parser_warnings: warnings.len() > warning_count,
607    }
608}
609
610fn conditions(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> LaunchConditions {
611    // Looked up directly below, not through `Values`.
612    super::reads::note(element, "wind");
613    super::reads::note(element, "atmosphere");
614    let average = element
615        .children_named("wind")
616        .find(|wind| wind.attribute("model") == Some("average"));
617    let multilevel = element
618        .children_named("wind")
619        .find(|wind| wind.attribute("model") == Some("multilevel"));
620    let (average_speed, average_from) = match average {
621        Some(wind) => {
622            let mut values = Values::new(wind, at, warnings);
623            (values.number(&["speed"]), values.number(&["direction"]))
624        }
625        None => (None, None),
626    };
627    let wind_levels = multilevel.map_or_else(Vec::new, |wind| {
628        super::reads::note(wind, "windlevel");
629        wind.children_named("windlevel")
630            .map(|level| {
631                let mut number = |name: &str| kept_number(level, name, at, warnings);
632                WindLevel {
633                    altitude_m: number("altitude"),
634                    speed_m_s: number("speed"),
635                    from_rad: number("direction"),
636                    standard_deviation_m_s: number("standarddeviation"),
637                }
638            })
639            .collect()
640    });
641    let atmosphere = element.child("atmosphere").map(|atmosphere| {
642        let model = atmosphere.attribute("model").unwrap_or_default();
643        let unread = match model {
644            "isa" | "extendedisa" => None,
645            "" => Some(
646                "an atmosphere with no `model`, as older files write their own table; it was not \
647                 read"
648                    .to_owned(),
649            ),
650            other => Some(format!(
651                "an atmosphere whose model is `{other}`, which OpenRocket 24.12 does not write; it \
652                 was kept by name only"
653            )),
654        };
655        if let Some(message) = unread {
656            warnings.push(Warning::new(
657                format!("{at}/atmosphere"),
658                WarningKind::Unusual,
659                message,
660            ));
661        }
662        match model {
663            "isa" => Atmosphere::Isa {},
664            "extendedisa" => {
665                let mut values = Values::new(atmosphere, at, warnings);
666                Atmosphere::Extended {
667                    temperature_k: values.number(&["basetemperature"]),
668                    pressure_pa: values.number(&["basepressure"]),
669                }
670            }
671            other => Atmosphere::Other {
672                name: other.to_owned(),
673            },
674        }
675    });
676    let mut values = Values::new(element, at, warnings);
677    let legacy_speed = values.number(&["windaverage"]);
678    let legacy_from = values.number(&["winddirection"]);
679    let average_wind = average;
680    for (what, legacy, average) in [
681        ("speed", legacy_speed, average_speed),
682        ("direction", legacy_from, average_from),
683    ] {
684        if let (Some(legacy), Some(average)) = (legacy, average)
685            && legacy != average
686        {
687            // The average `wind`'s tag, named as the quantity is, says what the design does not
688            // hold: it is kept as written.
689            if let Some(wind) = average_wind {
690                super::reads::forget(wind, what);
691            }
692            values.warn_at(
693                WarningKind::Dropped,
694                format!(
695                    "the wind's {what} is {legacy} in the legacy tag and {average} in the average \
696                     `wind`; the legacy tag's was taken"
697                ),
698            );
699        }
700    }
701    LaunchConditions {
702        configuration: values.word(&["configid"]).filter(|id| !id.is_empty()),
703        rod_length_m: values.number(&["launchrodlength"]),
704        rod_angle_rad: values.number(&["launchrodangle"]).map(f64::to_radians),
705        rod_direction_rad: values.number(&["launchroddirection"]).map(f64::to_radians),
706        into_wind: values.flag(&["launchintowind"]),
707        wind_speed_m_s: legacy_speed.or(average_speed),
708        wind_turbulence: values.number(&["windturbulence"]),
709        wind_from_rad: legacy_from.or(average_from),
710        wind_model: values.word(&["windmodeltype"]),
711        wind_levels,
712        wind_levels_above: multilevel
713            .and_then(|wind| wind.attribute("altituderef"))
714            .map(str::to_owned),
715        launch_altitude_m: values.number(&["launchaltitude"]),
716        latitude_deg: values.number(&["launchlatitude"]),
717        longitude_deg: values.number(&["launchlongitude"]),
718        geodetic_method: values.word(&["geodeticmethod"]),
719        atmosphere,
720        time_step_s: values.number(&["timestep"]),
721        max_time_s: values.number(&["maxtime"]),
722    }
723}
724
725/// An attribute's number, or `None` with a warning when it is there and not a finite number.
726fn attribute_number(
727    element: &Element,
728    name: &str,
729    at: &str,
730    warnings: &mut Vec<Warning>,
731) -> Option<f64> {
732    let text = element.attribute(name)?;
733    match text.trim().parse::<f64>() {
734        Ok(value) if value.is_finite() => Some(value),
735        _ => {
736            warnings.push(Warning::new(
737                at,
738                WarningKind::Dropped,
739                format!("`{name}` says `{text}`, which is not a number; it was ignored"),
740            ));
741            None
742        }
743    }
744}
745
746/// [`attribute_number`], keeping an attribute that is not a number as written
747/// ([`super::reads::forget_attribute`]), for an element that is read whatever its attributes say.
748/// An `event` that is left out is not: an attribute kept on it would go back on the next one.
749fn kept_number(
750    element: &Element,
751    name: &str,
752    at: &str,
753    warnings: &mut Vec<Warning>,
754) -> Option<f64> {
755    let number = attribute_number(element, name, at, warnings);
756    if number.is_none() && element.attribute(name).is_some() {
757        super::reads::forget_attribute(element, name);
758    }
759    number
760}
761
762fn results(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> StoredResults {
763    // Looked up directly below, not through `Values`.
764    super::reads::note(element, "warning");
765    super::reads::note(element, "databranch");
766    let mut number = |name: &str| kept_number(element, name, at, warnings);
767    let mut results = StoredResults {
768        max_altitude_m: number("maxaltitude"),
769        max_speed_m_s: number("maxvelocity"),
770        max_acceleration_m_s2: number("maxacceleration"),
771        max_mach: number("maxmach"),
772        time_to_apogee_s: number("timetoapogee"),
773        flight_time_s: number("flighttime"),
774        ground_hit_speed_m_s: number("groundhitvelocity"),
775        rod_exit_speed_m_s: number("launchrodvelocity"),
776        deployment_speed_m_s: number("deploymentvelocity"),
777        optimum_delay_s: number("optimumdelay"),
778        branches: Vec::new(),
779        warnings: element
780            .children_named("warning")
781            .map(|warning| warning.text().trim().to_owned())
782            .collect(),
783    };
784    for (index, element) in element.children_named("databranch").enumerate() {
785        let at = format!("{at}/databranch[{index}]");
786        results.branches.push(branch(element, &at, warnings));
787    }
788    results
789}
790
791fn branch(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> StoredBranch {
792    let types: Vec<String> = element
793        .attribute("types")
794        .map(|types| types.split(',').map(|t| t.trim().to_owned()).collect())
795        .unwrap_or_default();
796    let mut rows = Vec::new();
797    let mut dropped = 0usize;
798    let mut infinite = 0usize;
799    super::reads::note(element, "datapoint");
800    super::reads::note(element, "event");
801    for point in element.children_named("datapoint") {
802        let text = point.text();
803        // `NaN` is a value OpenRocket did not compute, kept as `None`; a number that does not read
804        // spoils the row. An infinity is kept as `None` too, and counted.
805        let row: Option<Vec<Option<f64>>> = text
806            .split(',')
807            .map(|value| match value.trim().parse::<f64>() {
808                Ok(v) if v.is_nan() => Some(None),
809                Ok(v) if v.is_finite() => Some(Some(v)),
810                Ok(_) => {
811                    infinite += 1;
812                    Some(None)
813                }
814                Err(_) => None,
815            })
816            .collect();
817        match row {
818            Some(row) if row.len() == types.len() => rows.push(row),
819            _ => dropped += 1,
820        }
821    }
822    if infinite > 0 {
823        warnings.push(Warning::new(
824            at,
825            WarningKind::Dropped,
826            format!("{infinite} infinite value(s) in the rows were kept as not computed"),
827        ));
828    }
829    if dropped > 0 {
830        warnings.push(Warning::new(
831            at,
832            WarningKind::Dropped,
833            format!(
834                "{dropped} `datapoint` row(s) are not {} numbers, one per column; they were left \
835                 out",
836                types.len()
837            ),
838        ));
839    }
840    let mut events = Vec::new();
841    for event in element.children_named("event") {
842        let (Some(time_s), Some(kind)) = (
843            attribute_number(event, "time", at, warnings),
844            event
845                .attribute("type")
846                .filter(|kind| !kind.trim().is_empty()),
847        ) else {
848            warnings.push(Warning::new(
849                at,
850                WarningKind::Dropped,
851                "an `event` with no time or no type; it was left out",
852            ));
853            continue;
854        };
855        events.push(StoredEvent {
856            time_s,
857            kind: kind.trim().to_owned(),
858            source: event.attribute("source").map(str::to_owned),
859        });
860    }
861    StoredBranch {
862        name: element.attribute("name").unwrap_or_default().to_owned(),
863        types,
864        rows,
865        events,
866    }
867}