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}