Skip to main content

hpr_validate/
flight_metrics.rs

1//! What a flight metric measures, tool by tool and version by version, and what becomes of a
2//! metric whose event never happened ([M2.2d1][m2-2d1]).
3//!
4//! A reference names a quantity with a word: `maxvelocity`, `deploymentvelocity`,
5//! `groundhitvelocity`, `optimumdelay`. The same word can mean a different quantity in another
6//! tool, or in another version of the same tool ([Loft lesson L80][l80]). So a metric is compared
7//! only through its [`Definition`], looked up by [`definition`] for the [`Tool`] and version that
8//! produced the reference. A version whose meaning has not been measured has no definition, and
9//! its value is withheld rather than compared as if it meant what the newest version means.
10//!
11//! OpenRocket 24.12's definitions are measured, not read from its source (which is GPL):
12//! `validation/oracles/openrocket/flights.py` flies every configuration of the public designs and
13//! records each summary word beside the quantities of OpenRocket's own time series it could mean.
14//! The record is `validation/fixtures/ork/openrocket-flights.json`, and a test holds each
15//! definition below to it on every complete flight ([ADR-068][adr-068]).
16//!
17//! A metric taken at an event that did not happen, such as the deployment speed of a flight whose
18//! parachute never opened, has no value. OpenRocket writes `NaN` for it. Loft scored it as 0
19//! ([Loft lesson L81][l81]). [`compare`] withholds it when neither flight had the event, and fails
20//! it when only one did; it is never scored.
21//!
22//! ```
23//! use hpr_validate::flight_metrics::{
24//!     Event, Failure, FlightMetric, MetricOutcome, ReferenceReading, Side, Tool, Withheld, compare,
25//! };
26//!
27//! let openrocket = Tool::OpenRocket { version: "24.12".to_owned() };
28//! // OpenRocket's Chute release example with a G40W-7: its last parachute opens at 14.2306 m/s.
29//! let reading = ReferenceReading::Complete(Some(14.2306));
30//! let outcome = compare(&openrocket, FlightMetric::DeploymentSpeed, reading, Some(14.0));
31//! assert!((outcome.difference().unwrap() + 0.2306).abs() < 1e-9);
32//!
33//! // No parachute opened in OpenRocket's flight (it wrote `NaN`), nor in hpr's: withheld.
34//! let none = ReferenceReading::Complete(Some(f64::NAN));
35//! assert_eq!(
36//!     compare(&openrocket, FlightMetric::DeploymentSpeed, none, None),
37//!     MetricOutcome::Withheld(Withheld::NoEventInEither { event: Event::Deployment })
38//! );
39//! // Only hpr's opened: a disagreement, which fails rather than being scored against 0.
40//! assert_eq!(
41//!     compare(&openrocket, FlightMetric::DeploymentSpeed, none, Some(9.0)),
42//!     MetricOutcome::Failed(Failure::EventOnlyIn { event: Event::Deployment, side: Side::Measured })
43//! );
44//! ```
45//!
46//! [m2-2d1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-2d1
47//! [l80]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l80
48//! [l81]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l81
49//! [adr-068]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-068-openrockets-flights-of-the-public-designs-and-what-its-metric-words-mean-2026-09-25
50
51use serde::{Deserialize, Serialize};
52
53/// A tool that produced a reference flight, with its version as the tool writes it.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(tag = "tool")]
56#[non_exhaustive]
57pub enum Tool {
58    /// OpenRocket, for example version `24.12`.
59    #[serde(rename = "openrocket")]
60    OpenRocket {
61        /// The version, as in the `.ork`'s `creator` attribute after `OpenRocket `.
62        version: String,
63    },
64    /// RocketPy, for example version `1.13.0`.
65    #[serde(rename = "rocketpy")]
66    RocketPy {
67        /// The version, as `rocketpy.__version__` gives it.
68        version: String,
69    },
70}
71
72impl Tool {
73    /// The tool named by a `.ork`'s root `creator` attribute, such as `OpenRocket 24.12`. `None`
74    /// for any other writer.
75    #[must_use]
76    pub fn from_ork_creator(creator: &str) -> Option<Self> {
77        let version = creator.trim().strip_prefix("OpenRocket ")?.trim();
78        (!version.is_empty()).then(|| Self::OpenRocket {
79            version: version.to_owned(),
80        })
81    }
82}
83
84/// A summary quantity of one flight.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum FlightMetric {
89    /// The highest point above the launch site, m.
90    Apogee,
91    /// The largest speed, m/s.
92    MaxSpeed,
93    /// The largest acceleration, m/s².
94    MaxAcceleration,
95    /// The largest Mach number.
96    MaxMach,
97    /// The time from launch to apogee, s.
98    TimeToApogee,
99    /// The time from launch to the end of the flight, s.
100    FlightTime,
101    /// The speed as the rocket leaves the launch rod or rail, m/s.
102    RodClearanceSpeed,
103    /// The static stability margin as the rocket leaves the launch rod or rail, calibres.
104    RodClearanceStability,
105    /// The speed at a recovery device's deployment, m/s.
106    DeploymentSpeed,
107    /// The speed at ground hit, m/s.
108    GroundHitSpeed,
109    /// The motor delay that would fire the ejection charge at apogee, s. OpenRocket 24.12's
110    /// `optimumdelay` is not always apogee less burnout; see [`definition`].
111    OptimumDelay,
112}
113
114impl FlightMetric {
115    /// Every metric, in declaration order.
116    pub const ALL: &'static [Self] = &[
117        Self::Apogee,
118        Self::MaxSpeed,
119        Self::MaxAcceleration,
120        Self::MaxMach,
121        Self::TimeToApogee,
122        Self::FlightTime,
123        Self::RodClearanceSpeed,
124        Self::RodClearanceStability,
125        Self::DeploymentSpeed,
126        Self::GroundHitSpeed,
127        Self::OptimumDelay,
128    ];
129
130    /// The attribute of a `.ork`'s stored `<flightdata>` that carries this metric, or `None` for
131    /// the stability margin, which OpenRocket keeps only in its time series.
132    #[must_use]
133    pub const fn ork_summary_word(self) -> Option<&'static str> {
134        match self {
135            Self::Apogee => Some("maxaltitude"),
136            Self::MaxSpeed => Some("maxvelocity"),
137            Self::MaxAcceleration => Some("maxacceleration"),
138            Self::MaxMach => Some("maxmach"),
139            Self::TimeToApogee => Some("timetoapogee"),
140            Self::FlightTime => Some("flighttime"),
141            Self::RodClearanceSpeed => Some("launchrodvelocity"),
142            Self::RodClearanceStability => None,
143            Self::DeploymentSpeed => Some("deploymentvelocity"),
144            Self::GroundHitSpeed => Some("groundhitvelocity"),
145            Self::OptimumDelay => Some("optimumdelay"),
146        }
147    }
148}
149
150/// The point on the rocket whose height or speed a metric takes.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153#[non_exhaustive]
154pub enum Point {
155    /// The center of dry mass, the point RocketPy's state follows ([ADR-021][adr-021]).
156    ///
157    /// [adr-021]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-021-whole-flights-against-rocketpy-what-is-compared-and-the-gaps-it-may-declare-2026-09-18
158    ///
159    /// Written before the move to US spelling as `centre_of_dry_mass`, which is still read and never written.
160    #[serde(alias = "centre_of_dry_mass")]
161    CenterOfDryMass,
162    /// The point the tool's own state tracks, which its documentation does not name. OpenRocket's
163    /// is described only as "the position and velocity of a rocket", kept in world coordinates
164    /// (Niskanen 2009, §4.2.1, p. 61); no probe here tells it from the center of mass.
165    Unstated,
166}
167
168/// An event in a flight that a metric is taken at.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171#[non_exhaustive]
172pub enum Event {
173    /// The first integration step at which the rocket has travelled past the launch rod's
174    /// length: OpenRocket 24.12's rod clearance. The rod's end falls between that step and the
175    /// one before, so the speed here is above the speed at the rod's end (by 0.06% to 7.50% on
176    /// the record's flights).
177    RodClearance,
178    /// The moment the rocket's travel equals the effective rail length, found between solver
179    /// steps: RocketPy's rail exit ([ADR-021][adr-021]).
180    ///
181    /// [adr-021]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-021-whole-flights-against-rocketpy-what-is-compared-and-the-gaps-it-may-declare-2026-09-18
182    RailExit,
183    /// A recovery device deploys.
184    Deployment,
185    /// The rocket reaches the ground.
186    GroundHit,
187}
188
189/// Which occurrence of an event that can happen more than once.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum Occurrence {
193    /// The first time it happens.
194    First,
195    /// The last time it happens.
196    Last,
197}
198
199/// What a metric measures, precisely enough to take it from a flight's time series.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[serde(tag = "kind", rename_all = "snake_case")]
202#[non_exhaustive]
203pub enum Definition {
204    /// The largest height of `point` above the launch site over the flight.
205    PeakHeight {
206        /// Whose height.
207        point: Point,
208    },
209    /// The time of the stored step at which the height of `point` is largest. This is not
210    /// always the apogee event's time: the event can fall between steps.
211    TimeOfPeakHeight {
212        /// Whose height.
213        point: Point,
214    },
215    /// The largest speed of `point` over the flight.
216    PeakSpeed {
217        /// Whose speed.
218        point: Point,
219    },
220    /// The largest Mach number over the flight.
221    PeakMach,
222    /// The largest total acceleration up to the first occurrence of `event`, or over the whole
223    /// flight if it never happens: nothing after the event counts.
224    PeakAccelerationBefore {
225        /// The event.
226        event: Event,
227    },
228    /// The speed of `point` at `which` occurrence of `event`, interpolated linearly in time
229    /// between the two stored steps either side of it (exact when the event is on a step).
230    SpeedAtEvent {
231        /// Whose speed.
232        point: Point,
233        /// The event.
234        event: Event,
235        /// Which occurrence.
236        which: Occurrence,
237    },
238    /// The time of the first occurrence of `event`.
239    TimeOfEvent {
240        /// The event.
241        event: Event,
242    },
243    /// The static stability margin at the first occurrence of `event`: the distance from the
244    /// center of mass aft to the center of pressure, divided by the tool's reference length, in
245    /// calibres (Niskanen 2009, p. 12). OpenRocket's reference length is by default the largest
246    /// body diameter, and was that on every flight of the record.
247    StabilityAtEvent {
248        /// The event.
249        event: Event,
250    },
251}
252
253impl Definition {
254    /// The event this definition takes its value at, if any.
255    #[must_use]
256    pub const fn event(self) -> Option<Event> {
257        match self {
258            Self::PeakHeight { .. }
259            | Self::TimeOfPeakHeight { .. }
260            | Self::PeakSpeed { .. }
261            | Self::PeakMach
262            | Self::PeakAccelerationBefore { .. } => None,
263            Self::SpeedAtEvent { event, .. }
264            | Self::TimeOfEvent { event }
265            | Self::StabilityAtEvent { event } => Some(event),
266        }
267    }
268}
269
270/// The OpenRocket version whose summary words `flights.py` measured.
271pub const OPENROCKET_MEASURED: &str = "24.12";
272
273/// The RocketPy version whose metrics hpr's whole-flight cases compare against. Its definitions
274/// come from [ADR-021][adr-021], which read what RocketPy computes, not from a probe like
275/// `flights.py`.
276///
277/// [adr-021]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-021-whole-flights-against-rocketpy-what-is-compared-and-the-gaps-it-may-declare-2026-09-18
278pub const ROCKETPY_MEASURED: &str = "1.13.0";
279
280/// What `metric` measures in a reference from `tool`, or `None` where that has not been measured
281/// for the tool's version.
282///
283/// OpenRocket 24.12, measured by `flights.py` on the 56 complete flights of the public designs:
284///
285/// | metric | definition |
286/// |---|---|
287/// | apogee, largest speed, largest Mach | the peak of its altitude, total velocity or Mach column |
288/// | largest acceleration | the peak of total acceleration before the first deployment |
289/// | time to apogee | the time of the highest stored step, not the apogee event (13 flights differ) |
290/// | flight time | the time of ground hit, its last step |
291/// | rod clearance, ground-hit speed | total velocity at the event, which is on a step |
292/// | deployment speed | total velocity interpolated in time between the steps either side |
293/// | which deployment | the **last** (no flight has more than two) |
294/// | stability margin | its stability column at rod clearance |
295/// | optimum delay | none: apogee less burnout misses on 15 flights, and so does the same flight's with nothing deployed |
296///
297/// RocketPy 1.13.0: the apogee, largest speed and rail-exit speed of hpr's whole-flight cases,
298/// taken at the center of dry mass ([ADR-021][adr-021]). Any other version: `None`.
299///
300/// [adr-021]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-021-whole-flights-against-rocketpy-what-is-compared-and-the-gaps-it-may-declare-2026-09-18
301#[must_use]
302pub fn definition(tool: &Tool, metric: FlightMetric) -> Option<Definition> {
303    match tool {
304        Tool::OpenRocket { version } if version == OPENROCKET_MEASURED => {
305            let point = Point::Unstated;
306            let at = |event, which| Definition::SpeedAtEvent {
307                point,
308                event,
309                which,
310            };
311            match metric {
312                FlightMetric::Apogee => Some(Definition::PeakHeight { point }),
313                FlightMetric::MaxSpeed => Some(Definition::PeakSpeed { point }),
314                FlightMetric::MaxMach => Some(Definition::PeakMach),
315                FlightMetric::TimeToApogee => Some(Definition::TimeOfPeakHeight { point }),
316                FlightMetric::FlightTime => Some(Definition::TimeOfEvent {
317                    event: Event::GroundHit,
318                }),
319                FlightMetric::RodClearanceSpeed => Some(at(Event::RodClearance, Occurrence::First)),
320                FlightMetric::RodClearanceStability => Some(Definition::StabilityAtEvent {
321                    event: Event::RodClearance,
322                }),
323                FlightMetric::DeploymentSpeed => Some(at(Event::Deployment, Occurrence::Last)),
324                FlightMetric::GroundHitSpeed => Some(at(Event::GroundHit, Occurrence::First)),
325                FlightMetric::MaxAcceleration => Some(Definition::PeakAccelerationBefore {
326                    event: Event::Deployment,
327                }),
328                FlightMetric::OptimumDelay => None,
329            }
330        }
331        Tool::RocketPy { version } if version == ROCKETPY_MEASURED => {
332            let point = Point::CenterOfDryMass;
333            match metric {
334                FlightMetric::Apogee => Some(Definition::PeakHeight { point }),
335                FlightMetric::MaxSpeed => Some(Definition::PeakSpeed { point }),
336                FlightMetric::RodClearanceSpeed => Some(Definition::SpeedAtEvent {
337                    point,
338                    event: Event::RailExit,
339                    which: Occurrence::First,
340                }),
341                _ => None,
342            }
343        }
344        _ => None,
345    }
346}
347
348/// A reference flight's value for one metric.
349#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
350#[non_exhaustive]
351#[serde(tag = "flight", content = "value", rename_all = "snake_case")]
352pub enum ReferenceReading {
353    /// The flight ran to its end. `None`, or OpenRocket's `NaN`, means it has no value: for a
354    /// metric taken at an event, that the event never happened, which OpenRocket 24.12 writes as
355    /// `NaN` exactly then.
356    Complete(Option<f64>),
357    /// The tool stopped the flight early (OpenRocket's `SIM_ABORT`), so even its peaks are where
358    /// it stopped, not a flight's.
359    Aborted,
360}
361
362/// One of the two flights compared.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum Side {
366    /// The reference's flight.
367    Reference,
368    /// hpr's flight.
369    Measured,
370}
371
372/// Why a metric was not compared, when that is no fault of either flight.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374#[serde(tag = "reason", rename_all = "snake_case")]
375#[non_exhaustive]
376pub enum Withheld {
377    /// What the metric means in the reference's tool and version has not been measured.
378    DefinitionUnmeasured,
379    /// The reference's flight was aborted.
380    ReferenceAborted,
381    /// The event the metric is taken at happened in neither flight.
382    NoEventInEither {
383        /// The event.
384        event: Event,
385    },
386}
387
388/// Why a metric fails without being scored: the two flights disagree about what happened, or a
389/// value is broken.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
391#[serde(tag = "reason", rename_all = "snake_case")]
392#[non_exhaustive]
393pub enum Failure {
394    /// The event happened in one flight only.
395    EventOnlyIn {
396        /// The event.
397        event: Event,
398        /// The flight that had it.
399        side: Side,
400    },
401    /// A value that is not a number: any `NaN` or infinity from hpr, or an infinity from the
402    /// reference (whose `NaN` means its event did not happen).
403    NotANumber {
404        /// The side with the value.
405        side: Side,
406    },
407    /// A metric that every complete flight has, such as the apogee, has no value on one side.
408    NoValue {
409        /// The side without one.
410        side: Side,
411    },
412}
413
414/// A metric compared, withheld, or failed. Never a zero standing in for a missing value.
415#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
416#[serde(tag = "outcome", rename_all = "snake_case")]
417#[non_exhaustive]
418pub enum MetricOutcome {
419    /// Both sides have a value for the same defined quantity.
420    Scored {
421        /// The quantity both values are.
422        definition: Definition,
423        /// The reference's value.
424        reference: f64,
425        /// hpr's value.
426        measured: f64,
427    },
428    /// Not compared, and why.
429    Withheld(Withheld),
430    /// Not scored, and a failure of the comparison.
431    Failed(Failure),
432}
433
434impl MetricOutcome {
435    /// `measured − reference`, for a scored metric.
436    #[must_use]
437    pub fn difference(&self) -> Option<f64> {
438        match *self {
439            Self::Scored {
440                reference,
441                measured,
442                ..
443            } => Some(measured - reference),
444            Self::Withheld(_) | Self::Failed(_) => None,
445        }
446    }
447}
448
449/// Compares hpr's `measured` value of `metric` with a `reference` reading from `tool`.
450///
451/// - A metric whose meaning in `tool`'s version is not measured is withheld ([Loft lesson
452///   L80][l80]), and so is every metric of an aborted reference flight.
453/// - A reference `None` or `NaN` means its flight has no value; hpr's `None` means the same, but
454///   a `NaN` or infinity from hpr, or an infinity from the reference, is a failure.
455/// - For a metric taken at an event, no value on both sides withholds it; on one side only, it
456///   fails, since the flights disagree about what happened ([Loft lesson L81][l81]: never 0).
457/// - For a metric every complete flight has, such as the apogee, a missing value fails.
458///
459/// `measured` must be taken by the [`Definition`] that [`definition`] gives for `tool`: the last
460/// deployment, say, not the first.
461///
462/// [l80]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l80
463/// [l81]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l81
464#[must_use]
465pub fn compare(
466    tool: &Tool,
467    metric: FlightMetric,
468    reference: ReferenceReading,
469    measured: Option<f64>,
470) -> MetricOutcome {
471    let Some(definition) = definition(tool, metric) else {
472        return MetricOutcome::Withheld(Withheld::DefinitionUnmeasured);
473    };
474    let ReferenceReading::Complete(reference) = reference else {
475        return MetricOutcome::Withheld(Withheld::ReferenceAborted);
476    };
477    if measured.is_some_and(|value| !value.is_finite()) {
478        return MetricOutcome::Failed(Failure::NotANumber {
479            side: Side::Measured,
480        });
481    }
482    if reference.is_some_and(f64::is_infinite) {
483        return MetricOutcome::Failed(Failure::NotANumber {
484            side: Side::Reference,
485        });
486    }
487    let reference = reference.filter(|value| value.is_finite());
488    match (reference, measured, definition.event()) {
489        (Some(reference), Some(measured), _) => MetricOutcome::Scored {
490            definition,
491            reference,
492            measured,
493        },
494        (None, None, Some(event)) => MetricOutcome::Withheld(Withheld::NoEventInEither { event }),
495        (None, Some(_), Some(event)) => MetricOutcome::Failed(Failure::EventOnlyIn {
496            event,
497            side: Side::Measured,
498        }),
499        (Some(_), None, Some(event)) => MetricOutcome::Failed(Failure::EventOnlyIn {
500            event,
501            side: Side::Reference,
502        }),
503        (None, _, None) => MetricOutcome::Failed(Failure::NoValue {
504            side: Side::Reference,
505        }),
506        (Some(_), None, None) => MetricOutcome::Failed(Failure::NoValue {
507            side: Side::Measured,
508        }),
509    }
510}