Skip to main content

hpr_validate/
run.rs

1//! Running cases: fly what the case says, measure it, and compare against the reference.
2
3use std::path::Path;
4
5use hpr_aero::drag::BUILDUP_MACH_LIMIT;
6use hpr_aero::{AeroError, DragTable, NORMAL_FORCE_MACH_LIMIT};
7use hpr_atmos::AtmosphereModel;
8use hpr_atmos::{LayeredWind, WindInterpolation, WindLevel};
9use hpr_core::DVec3;
10use hpr_core::earth::{Earth, EarthRotation, GravityModel};
11use hpr_core::geodesy::Geodetic;
12use hpr_core::gravity::NormalGravity;
13use hpr_core::interp::{Extrapolation, Interpolation, Table1D};
14use hpr_design::{MassProperties, Rocket};
15use hpr_sim::{
16    Adaptive, Device, DeviceDrag, Direction, Environment, EventKind, FlightSettings, FlightStep,
17    Method, Observer, Phase, Rail, Sample, SimError, Simulation, State, Termination, Trigger,
18    UserEvent,
19};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use crate::case::{Case, CaseLock, DragMode, Flight, cases_dir, committed_cases};
24use crate::metrics::{Measured, Reference};
25use crate::report::{Comparison, Gap, Report, Source};
26
27/// What to do when a case declares a known gap that hpr no longer has: the hint that ends that
28/// refusal's message.
29pub const GAP_CLOSED_HINT: &str = "remove the gap and score it";
30
31/// The hints that end some of [`ValidateError::Case`]'s messages, after `"; "`. A program can
32/// show one apart from the fact ([`ValidateError::help`]), such as on a `help:` line.
33pub const HINTS: [&str; 2] = [crate::committed::CENSUS_MISSING_HINT, GAP_CLOSED_HINT];
34
35/// What can go wrong running a case.
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum ValidateError {
39    /// A file could not be read or written.
40    #[error("{what} {path}: {source}")]
41    Io {
42        /// What was being done.
43        what: &'static str,
44        /// The path.
45        path: String,
46        /// The cause.
47        source: std::io::Error,
48    },
49    /// A case or lock file could not be parsed.
50    #[error("{path} is not a valid case file: {source}")]
51    Toml {
52        /// The path.
53        path: String,
54        /// The cause.
55        source: Box<toml::de::Error>,
56    },
57    /// A reference or design file could not be parsed.
58    #[error("{path} is not valid JSON: {source}")]
59    Json {
60        /// The path.
61        path: String,
62        /// The cause.
63        source: Box<serde_json::Error>,
64    },
65    /// The case, the lock and the references disagree.
66    #[error("{0}")]
67    Case(String),
68    /// The flight itself failed, or was refused before it started.
69    #[error("case {case}: {what}")]
70    Flight {
71        /// The case's id.
72        case: String,
73        /// What went wrong, in words.
74        what: String,
75        /// The cause, where the simulator gave one.
76        source: Option<Box<hpr_sim::SimError>>,
77    },
78}
79
80impl ValidateError {
81    /// What to do about it, when its message ends with one of [`HINTS`].
82    pub fn help(&self) -> Option<&'static str> {
83        let Self::Case(message) = self else {
84            return None;
85        };
86        HINTS
87            .into_iter()
88            .find(|hint| message.ends_with(&format!("; {hint}")))
89    }
90
91    /// Its message without the hint [`help`](Self::help) gives.
92    pub fn fact(&self) -> String {
93        let text = self.to_string();
94        match self.help() {
95            Some(hint) => text
96                .strip_suffix(&format!("; {hint}"))
97                .unwrap_or(&text)
98                .to_owned(),
99            None => text,
100        }
101    }
102}
103
104/// How close hpr's mass has to be to the mass the oracle recorded flying, as a fraction.
105///
106/// They are two builds of one rocket, not two measurements of it: anything above rounding means
107/// the case is comparing different vehicles, which must not read as a difference in the physics
108/// (Loft lesson L75).
109const MASS_AGREEMENT: f64 = 1e-9;
110
111/// How close hpr's reference area has to be to the one the oracle flew its drag table on, as a
112/// fraction. The drag force is `½ρV² A C_D`, so "the same drag" means the same `A` as well as the
113/// same `C_D`; as with the mass, anything above rounding is two different vehicles.
114const AREA_AGREEMENT: f64 = 1e-9;
115
116/// Runs every case the lock names, in its order, and reports the comparisons.
117///
118/// # Errors
119///
120/// [`ValidateError`] if a file is missing or malformed, if the lock names a case that is not
121/// there or a committed case is not locked ([Loft lesson L78][l78]), if a reference has no
122/// provenance ([L77][l77]), if a metric is neither gated nor declared ([L79][l79]), or if a flight
123/// fails.
124///
125/// [l77]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l77
126/// [l78]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l78
127/// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
128pub fn run_lock(root: &Path, fast: bool) -> Result<Report, ValidateError> {
129    let lock_path = cases_dir(root).join("lock.toml");
130    let lock: CaseLock =
131        toml::from_str(&read(&lock_path, "reading the case lock")?).map_err(|source| {
132            ValidateError::Toml {
133                path: lock_path.display().to_string(),
134                source: Box::new(source),
135            }
136        })?;
137    let unknown = lock.unknown_slow();
138    if !unknown.is_empty() {
139        return Err(ValidateError::Case(format!(
140            "the case lock names {unknown:?} as slow, which are not cases"
141        )));
142    }
143    // Loft lesson L78's other half: a case nobody locked would never run, and a suite that does
144    // not notice is one edit away from reporting green over half of itself.
145    let committed = committed_cases(root).map_err(ValidateError::Case)?;
146    let unlocked: Vec<&String> = committed
147        .iter()
148        .filter(|id| !lock.cases.contains(id))
149        .collect();
150    if !unlocked.is_empty() {
151        return Err(ValidateError::Case(format!(
152            "{unlocked:?} are committed under validation/cases/ but the lock does not name them, \
153             so they would never run"
154        )));
155    }
156
157    let wanted = lock.wanted(fast);
158    let mut cases = Vec::new();
159    let mut comparisons = Vec::new();
160    let mut sources = Vec::new();
161    let mut gaps = Vec::new();
162    for id in &wanted {
163        let path = cases_dir(root).join(format!("{id}.toml"));
164        if !path.is_file() {
165            // Loft lesson L78: a case that isn't there is a failure, not a quiet skip.
166            return Err(ValidateError::Case(format!(
167                "the case lock names {id}, but {} is not there",
168                path.display()
169            )));
170        }
171        let case: Case = toml::from_str(&read(&path, "reading a case")?).map_err(|source| {
172            ValidateError::Toml {
173                path: path.display().to_string(),
174                source: Box::new(source),
175            }
176        })?;
177        if case.id != *id {
178            return Err(ValidateError::Case(format!(
179                "{} calls itself {}, not {id}",
180                path.display(),
181                case.id
182            )));
183        }
184        let run = run_case(root, &case)?;
185        // The report records the cases that were compared, not the ones a lock hoped for.
186        cases.push(case.id.clone());
187        comparisons.extend(run.comparisons);
188        sources.push(run.source);
189        gaps.extend(run.gap);
190    }
191    if comparisons.is_empty() {
192        // L78 once more: "ok" over nothing is the report Loft's suites produced when their
193        // fixtures were missing. A run that scored no metric has not validated anything.
194        return Err(ValidateError::Case(format!(
195            "the run covered {} case(s) and compared nothing; a suite that checks nothing is not \
196             a suite that passes",
197            cases.len()
198        )));
199    }
200    Ok(Report {
201        harness_version: env!("CARGO_PKG_VERSION").to_owned(),
202        fast,
203        cases,
204        skipped: lock.skipped(fast),
205        comparisons,
206        gaps,
207        sources,
208    })
209}
210
211/// What running one case gives.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct CaseRun {
215    /// One comparison per metric, or none for a known gap.
216    pub comparisons: Vec<Comparison>,
217    /// Where the reference came from.
218    pub source: Source,
219    /// The gap, when the case declares one and hpr refused the flight as it said.
220    pub gap: Option<Gap>,
221}
222
223/// Runs one case: flies it, measures it, and compares every metric it names.
224///
225/// # Errors
226///
227/// As [`run_lock`].
228pub fn run_case(root: &Path, case: &Case) -> Result<CaseRun, ValidateError> {
229    // A name the flight cannot measure is a typo, and it costs nothing to say so before flying.
230    let known: &[&str] = match &case.flight {
231        Flight::RecoveryDescent { .. } => &DESCENT_METRICS,
232        Flight::WholeFlight { .. } => &WHOLE_FLIGHT_METRICS,
233    };
234    let unknown: Vec<&String> = case
235        .metrics
236        .keys()
237        .filter(|name| !known.contains(&name.as_str()))
238        .collect();
239    if !unknown.is_empty() {
240        return Err(ValidateError::Case(format!(
241            "case {}: {unknown:?} are not metrics this flight measures; it measures {known:?}",
242            case.id
243        )));
244    }
245    for (name, metric) in &case.metrics {
246        metric.check(&case.id, name).map_err(ValidateError::Case)?;
247    }
248
249    let (reference, setup) = load_reference(root, case)?;
250    let missing = reference.without_provenance();
251    if !missing.is_empty() {
252        // Loft lesson L77: a value with no source is not a reference.
253        return Err(ValidateError::Case(format!(
254            "case {}: the reference's {missing:?} carry no source",
255            case.id
256        )));
257    }
258    // Loft lesson L79 from the reference's side: a number the oracle published that the case says
259    // nothing about is a metric nobody is watching. Gate it, or write down why it is not scored.
260    let ignored: Vec<&String> = reference
261        .values
262        .keys()
263        .filter(|name| !case.metrics.contains_key(*name))
264        .collect();
265    if !ignored.is_empty() {
266        return Err(ValidateError::Case(format!(
267            "case {}: the reference publishes {ignored:?}, which the case neither gates nor \
268             declares not scored",
269            case.id
270        )));
271    }
272
273    let source = Source {
274        case: case.id.clone(),
275        oracle: reference.oracle.clone(),
276        generator: reference.generator.clone(),
277        command: reference.command.clone(),
278        file: reference.file.clone(),
279        sha256: reference.sha256.clone(),
280        model: reference.model.clone(),
281        overrides: reference.overrides.clone(),
282    };
283    let gap = match &case.known_gap {
284        None => None,
285        Some(reason) if reason.trim().is_empty() => {
286            // As with a metric that is not scored: an excuse nobody wrote down is not one.
287            return Err(ValidateError::Case(format!(
288                "case {}: its known gap gives no reason",
289                case.id
290            )));
291        }
292        Some(reason) => {
293            // The only gap the harness accepts is hpr's refusal of a Mach number past its models'
294            // range (the normal force's and the drag buildup's both end at Mach 5), and it checks
295            // the reference agrees that the flight gets there before it lets the case off.
296            let limit = NORMAL_FORCE_MACH_LIMIT.min(BUILDUP_MACH_LIMIT);
297            let peak = reference.values.get("max_mach").map(|value| value.value);
298            if !peak.is_some_and(|mach| mach >= limit) {
299                return Err(ValidateError::Case(format!(
300                    "case {}: it declares a known gap, past hpr's Mach {limit}, but the reference \
301                     peaks at Mach {peak:?}",
302                    case.id
303                )));
304            }
305            Some(reason.trim().to_owned())
306        }
307    };
308    let peak = reference.values.get("max_mach").map(|value| value.value);
309    let measured = match settle(
310        &case.id,
311        case.metrics.len(),
312        measure(root, case, &setup)?,
313        gap,
314        peak,
315    )? {
316        Settled::Measured(measured) => measured,
317        Settled::Gap(gap) => {
318            return Ok(CaseRun {
319                comparisons: Vec::new(),
320                source,
321                gap: Some(gap),
322            });
323        }
324    };
325    let predicted = matches!(
326        case.flight,
327        Flight::WholeFlight {
328            mode: DragMode::Predicted,
329            ..
330        }
331    );
332    let mut comparisons = Vec::new();
333    for (name, metric) in &case.metrics {
334        let value = reference.values.get(name).ok_or_else(|| {
335            ValidateError::Case(format!(
336                "case {}: the reference has no {name}; it has {:?}",
337                case.id,
338                reference.values.keys().collect::<Vec<_>>()
339            ))
340        })?;
341        let got = measured.values.get(name).ok_or_else(|| {
342            ValidateError::Case(format!(
343                "case {}: hpr measured no {name}; it measured {:?}",
344                case.id,
345                measured.values.keys().collect::<Vec<_>>()
346            ))
347        })?;
348        comparisons.push(match (metric.reason(), predicted) {
349            (Some(reason), _) => {
350                Comparison::not_scored(&case.id, name, *got, value.value, &value.source, reason)
351            }
352            (None, false) => Comparison::new(
353                &case.id,
354                name,
355                *got,
356                value.value,
357                &value.source,
358                metric.tolerance(),
359            ),
360            // Predicted mode: the tolerance is a target, reported and never gated (ADR-023).
361            (None, true) => Comparison::targeted(
362                &case.id,
363                name,
364                *got,
365                value.value,
366                &value.source,
367                metric.tolerance(),
368            ),
369        });
370    }
371    // Loft lesson L79: every metric hpr measured for the case is accounted for, so nothing is
372    // reported without a tolerance and nothing silently drifts.
373    let ungated: Vec<&String> = measured
374        .values
375        .keys()
376        .filter(|name| !case.metrics.contains_key(*name))
377        .collect();
378    if !ungated.is_empty() {
379        return Err(ValidateError::Case(format!(
380            "case {}: {ungated:?} are measured but have no tolerance",
381            case.id
382        )));
383    }
384    Ok(CaseRun {
385        comparisons,
386        source,
387        gap: None,
388    })
389}
390
391/// The inputs a reference records for its kind of flight.
392#[derive(Debug, Clone, PartialEq)]
393enum Setup {
394    /// A descent's.
395    Descent(DescentSetup),
396    /// A whole flight's.
397    WholeFlight(WholeFlightSetup),
398}
399
400/// What a case's flight comes to once its declared gap is weighed: metrics to score, or the gap.
401#[derive(Debug, Clone, PartialEq)]
402pub(crate) enum Settled {
403    /// Metrics to score.
404    Measured(Measured),
405    /// The declared gap, which scores nothing.
406    Gap(Gap),
407}
408
409/// Weighs how case `case`'s flight came out against its declared gap (`gap`, its reason) and the
410/// reference's peak Mach number (`peak`): a flight that lands is scored and must declare no gap
411/// (Loft lesson L85); a refusal past a model's range is the gap if declared and the reference
412/// reaches that model's limit too, and a failure otherwise.
413pub(crate) fn settle(
414    case: &str,
415    metric_count: usize,
416    flown: Flown,
417    gap: Option<String>,
418    peak: Option<f64>,
419) -> Result<Settled, ValidateError> {
420    match (flown, gap) {
421        (Flown::Measured(measured), None) => Ok(Settled::Measured(measured)),
422        (Flown::Measured(_), Some(_)) => {
423            // Loft lesson L85: a gap that has closed and still reads as excused is a gate that has
424            // stopped watching. Score the case instead.
425            Err(ValidateError::Case(format!(
426                "case {case}: it declares a known gap, but hpr flew it to the ground; \
427                 {GAP_CLOSED_HINT}"
428            )))
429        }
430        (Flown::RefusedAtMach { limit, model, .. }, Some(_))
431            if !peak.is_some_and(|peak| peak >= limit) =>
432        {
433            Err(ValidateError::Case(format!(
434                "case {case}: hpr refused it at {model}'s limit, Mach {limit}, but the reference \
435                 peaks at Mach {peak:?}, so the gap isn't the one declared"
436            )))
437        }
438        (Flown::RefusedAtMach { mach, limit, model }, Some(reason)) => Ok(Settled::Gap(Gap {
439            case: case.to_owned(),
440            reason,
441            // Rounded: the report is pinned across platforms. The integrator narrows its step
442            // onto the boundary, so this is the same wherever it runs.
443            refusal: format!(
444                "refused the flight at Mach {mach:.3}, outside {model}'s range [0, {limit})"
445            ),
446            mach,
447            metric_count,
448        })),
449        (Flown::RefusedAtMach { mach, limit, model }, None) => {
450            // The error hpr raised; the words are rounded as the gap's are, since the Mach
451            // number's last bits differ by platform.
452            Err(ValidateError::Flight {
453                case: case.to_owned(),
454                what: format!(
455                    "refused the flight at Mach {mach:.3}, outside {model}'s range [0, {limit}), \
456                     and the case declares no known gap"
457                ),
458                source: Some(Box::new(SimError::Aero(AeroError::Mach {
459                    mach,
460                    limit,
461                    model,
462                }))),
463            })
464        }
465    }
466}
467
468/// How a flight came out.
469#[derive(Debug, Clone, PartialEq)]
470pub(crate) enum Flown {
471    /// It reached the ground, and these are its metrics.
472    Measured(Measured),
473    /// hpr refused it at this Mach number, at or past the top of one of its models' ranges.
474    RefusedAtMach {
475        /// The Mach number it refused.
476        mach: f64,
477        /// The top of the refusing model's range.
478        limit: f64,
479        /// The model that refused it, such as the drag buildup.
480        model: &'static str,
481    },
482}
483
484/// The reference a case names, in the harness's shape, with the inputs the oracle flew.
485fn load_reference(root: &Path, case: &Case) -> Result<(Reference, Setup), ValidateError> {
486    let path = root.join(&case.reference);
487    let text = read(&path, "reading a reference")?;
488    let document: serde_json::Value =
489        serde_json::from_str(&text).map_err(|source| ValidateError::Json {
490            path: path.display().to_string(),
491            source: Box::new(source),
492        })?;
493    let sha256: String = Sha256::digest(text.as_bytes())
494        .iter()
495        .map(|byte| format!("{byte:02x}"))
496        .collect();
497    let wanted = case.reference_case.as_deref().unwrap_or(&case.id);
498    let file = case.reference.display().to_string().replace('\\', "/");
499    match &case.flight {
500        Flight::RecoveryDescent { .. } => {
501            crate::rocketpy::descent_case(&document, wanted, &file, &sha256)
502                .map(|(reference, setup)| (reference, Setup::Descent(setup)))
503        }
504        Flight::WholeFlight { .. } => {
505            crate::rocketpy::whole_flight_case(&document, wanted, &file, &sha256)
506                .map(|(reference, setup)| (reference, Setup::WholeFlight(setup)))
507        }
508    }
509    .ok_or_else(|| {
510        ValidateError::Case(format!(
511            "case {}: {} has no case {wanted} that names the run which produced it",
512            case.id,
513            path.display()
514        ))
515    })
516}
517
518/// Flies the case and measures what it reports.
519fn measure(root: &Path, case: &Case, setup: &Setup) -> Result<Flown, ValidateError> {
520    match (&case.flight, setup) {
521        (
522            Flight::RecoveryDescent {
523                design,
524                configuration,
525            },
526            Setup::Descent(setup),
527        ) => fly_descent(root, case, setup, design, configuration).map(Flown::Measured),
528        (
529            Flight::WholeFlight {
530                design,
531                configuration,
532                mode,
533            },
534            Setup::WholeFlight(setup),
535        ) => fly_whole_flight(root, case, setup, design, configuration, *mode),
536        _ => Err(ValidateError::Case(format!(
537            "case {}: its reference was read for another kind of flight",
538            case.id
539        ))),
540    }
541}
542
543/// A descent from the state the reference declares, under the devices it declares: the flight
544/// `hpr_sim::recovery::tests::descent_matches_rocketpy_examples` makes, through the harness.
545fn fly_descent(
546    root: &Path,
547    case: &Case,
548    setup: &DescentSetup,
549    design: &str,
550    configuration: &str,
551) -> Result<Measured, ValidateError> {
552    let refuse = |what: String| ValidateError::Flight {
553        case: case.id.clone(),
554        what,
555        source: None,
556    };
557    // L75: the reference records which rocket the oracle flew. A case that names another one is
558    // comparing two vehicles, and that must not read as a difference in the physics.
559    let named = format!("validation/designs/{design}.json");
560    if named != setup.design {
561        return Err(refuse(format!(
562            "the case flies {named}, but the reference was flown with {}",
563            setup.design
564        )));
565    }
566    // The devices are in the order they open, and the state the reference hands over is the one
567    // at the first deployment: device 0 has already opened, so only later devices may carry a
568    // trigger of their own.
569    match setup.devices.split_first() {
570        None => return Err(refuse("the reference declares no devices".to_owned())),
571        Some((first, rest)) => {
572            if first.height_above_ground_m.is_some() || first.lag_s != 0.0 {
573                return Err(refuse(format!(
574                    "the reference's first device ({}) has to be the one that opens at the \
575                     handover, with no lag left to run",
576                    first.name
577                )));
578            }
579            if let Some(device) = rest
580                .iter()
581                .find(|device| device.height_above_ground_m.is_none())
582            {
583                return Err(refuse(format!(
584                    "device {} opens at apogee, which is behind the state the reference hands \
585                     over; there is no apogee left for the harness to trigger on",
586                    device.name
587                )));
588            }
589        }
590    }
591
592    let path = root.join(&named);
593    let rocket: Rocket =
594        serde_json::from_str(&read(&path, "reading a design")?).map_err(|source| {
595            ValidateError::Json {
596                path: path.display().to_string(),
597                source: Box::new(source),
598            }
599        })?;
600    let flight = || -> Result<Result<Measured, String>, hpr_sim::SimError> {
601        let environment = rocketpy_environment(
602            setup.latitude_deg,
603            setup.longitude_deg,
604            setup.elevation_m,
605            &setup.wind,
606        )?;
607        let devices = setup
608            .devices
609            .iter()
610            .enumerate()
611            .map(|(index, device)| {
612                let drag = DeviceDrag::DragArea {
613                    cd_s_m2: device.cd_s_m2,
614                };
615                let trigger = match device.height_above_ground_m {
616                    Some(height_above_ground_m) => Trigger::Altitude {
617                        height_above_ground_m,
618                    },
619                    None => Trigger::Time {
620                        time_s: setup.start_time_s,
621                    },
622                };
623                let mut built = Device::new(&device.name, drag, trigger).with_lag_s(device.lag_s);
624                if index + 1 < setup.devices.len() {
625                    built = built.with_release_by(index + 1);
626                }
627                built
628            })
629            .collect();
630        let simulation = Simulation::new(
631            &rocket,
632            configuration,
633            environment,
634            Rail::vertical(6.0),
635            FlightSettings {
636                max_time_s: 6000.0,
637                ..FlightSettings::default()
638            },
639        )?
640        .with_recovery(devices)?;
641        let properties = simulation.assembly().mass_properties(setup.start_time_s);
642        // L75 again: the same rocket has to weigh the same in both codes before any difference in
643        // where it lands can be read as physics.
644        let mass_gap = (properties.mass_kg - setup.dry_mass_kg).abs() / setup.dry_mass_kg.abs();
645        if !mass_gap.is_finite() || mass_gap > MASS_AGREEMENT {
646            return Ok(Err(format!(
647                "hpr flies {:.6} kg where the reference recorded {:.6} kg, a relative {mass_gap:.3e}",
648                properties.mass_kg, setup.dry_mass_kg
649            )));
650        }
651        let attitude = Rail::vertical(6.0).attitude();
652        let cg_enu_m = DVec3::new(
653            setup.start_position_m.0,
654            setup.start_position_m.1,
655            setup.start_position_m.2 - setup.elevation_m,
656        );
657        let state = State {
658            position_enu_m: cg_enu_m - attitude.mul_vec3(properties.cg_m),
659            velocity_enu_m_s: DVec3::new(
660                setup.start_velocity_m_s.0,
661                setup.start_velocity_m_s.1,
662                setup.start_velocity_m_s.2,
663            ),
664            attitude,
665            body_rate_rad_s: DVec3::ZERO,
666        };
667        let result = simulation.run_free(setup.start_time_s, state, &mut ())?;
668        if result.termination != Termination::GroundHit {
669            return Ok(Err(format!(
670                "the descent ended as {:?} after {} steps, not at the ground",
671                result.termination, result.stats.accepted_steps
672            )));
673        }
674        let landing = result
675            .event(EventKind::GroundHit)
676            .map_or(result.final_sample, |event| event.sample);
677        let drift = landing.cg_enu_m - cg_enu_m;
678        let descent_time_s = landing.time_s - setup.start_time_s;
679        let mut measured = Measured::default();
680        measured.insert("descent_time_s", descent_time_s);
681        measured.insert("impact_speed_m_s", -landing.vertical_speed_m_s);
682        measured.insert("drift_m", drift.truncate().length());
683        measured.insert("drift_east_m", drift.x);
684        measured.insert("drift_north_m", drift.y);
685        // As the generator defines it: the height above the site it started from, over the time
686        // it took. Measuring the ENU `z` drop instead would carry the curvature of the ground
687        // (`drift²/2R`, 15 cm over Calisto's 1.4 km drift), which is not what the oracle divided.
688        measured.insert(
689            "mean_descent_rate_m_s",
690            (setup.start_height_above_ground_m - landing.height_above_ground_m) / descent_time_s,
691        );
692        Ok(Ok(measured))
693    };
694    match flight() {
695        Ok(Ok(measured)) => Ok(measured),
696        Ok(Err(what)) => Err(refuse(what)),
697        Err(source) => Err(ValidateError::Flight {
698            case: case.id.clone(),
699            what: source.to_string(),
700            source: Some(Box::new(source)),
701        }),
702    }
703}
704
705/// The environment a RocketPy case declares, flown with RocketPy's own models where hpr has them
706/// (ADR-015): its standard atmosphere, the declared wind interpolated by component as RocketPy's
707/// `custom_atmosphere` does, Coriolis, and RocketPy's gravity.
708fn rocketpy_environment(
709    latitude_deg: f64,
710    longitude_deg: f64,
711    elevation_m: f64,
712    wind: &[(f64, f64, f64)],
713) -> Result<Environment, SimError> {
714    let site = Geodetic::from_degrees(latitude_deg, longitude_deg, elevation_m)?;
715    let levels: Vec<WindLevel> = wind
716        .iter()
717        .map(|(height_msl_m, east, north)| WindLevel {
718            height_msl_m: *height_msl_m,
719            speed_m_s: east.hypot(*north),
720            direction_from_rad: (-east).atan2(-north).rem_euclid(std::f64::consts::TAU),
721        })
722        .collect();
723    // L75, on a difference that took two reviews to find: hpr's default gravity is the full
724    // normal-gravity *vector*, which above the ellipsoid leans very slightly north, while RocketPy
725    // applies gravity to the vertical axis alone (`Flight.u_dot_parachute`, `flight.py:2777`, and
726    // `u_dot_generalized` likewise). Over an 800 m descent that difference is 5.2e-4 m of
727    // northward drift, 26 times the Coriolis drift it hides among. hpr ships RocketPy's formula
728    // for exactly this reason, so a RocketPy comparison uses it.
729    let earth = Earth::new(
730        NormalGravity::wgs84(),
731        site,
732        GravityModel::VerticalTaylor,
733        EarthRotation::Coriolis,
734    )?;
735    Ok(Environment::new(
736        earth,
737        AtmosphereModel::default(),
738        LayeredWind::new(levels, WindInterpolation::Components)?,
739    ))
740}
741
742/// A flight from the pad to the ground under the rail, site, wind and devices the reference
743/// declares, and in same-drag mode its drag table (in predicted mode, the design's own drag):
744/// `flight.py`'s flight, through hpr.
745///
746/// The metrics are measured as RocketPy defines them, which is not always as hpr's own events
747/// would ([Loft lesson L80][l80]: the same word can name a different quantity):
748///
749/// - RocketPy's state follows the **center of dry mass** (`flight.py`'s `vx`, `ax`), so speeds
750///   and accelerations are that body point's, not the nose tip's or the burning rocket's center
751///   of mass: `v_P = v_O + ω × p` and `a_P = a_O + ω̇ × p + ω × (ω × p)`, with `p` the dry center of
752///   mass from the nose tip.
753/// - RocketPy's rail exit is the **forward** button reaching the top of the rail
754///   (`effective_1rl`, `flight.py:1716-1730`), where hpr's own rail exit is the aft-most guide's
755///   ([Loft lesson L26][l26]). hpr is still on its rail then, so the harness finds the instant the
756///   forward guide's travel is reached and reads the speed there.
757/// - The maxima are over the solver's steps, both ends of each, as RocketPy's are over its
758///   solution array, which starts each phase at its first instant. Unlike RocketPy's, they are
759///   also over the peaks found inside a step on its dense output (`Peaks::peaks_within`), so that
760///   they do not move with the step sequence (ADR-023). The power-on maximum is over those up to
761///   burnout (`max_acceleration_power_on`).
762///
763/// [l26]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l26
764/// [l80]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
765fn fly_whole_flight(
766    root: &Path,
767    case: &Case,
768    setup: &WholeFlightSetup,
769    design: &str,
770    configuration: &str,
771    mode: DragMode,
772) -> Result<Flown, ValidateError> {
773    let refuse = |what: String| ValidateError::Flight {
774        case: case.id.clone(),
775        what,
776        source: None,
777    };
778    // L75: the reference records which rocket the oracle flew, and with which drag.
779    let named = format!("validation/designs/{design}.json");
780    if named != setup.design {
781        return Err(refuse(format!(
782            "the case flies {named}, but the reference was flown with {}",
783            setup.design
784        )));
785    }
786    // Each mode needs the reference that means something against it: same-drag the declared
787    // table, flown by both codes; predicted RocketPy flying the example's own drag, since hpr's
788    // own drag scored against the declared constant would measure it against an arbitrary number.
789    let declared = match (
790        mode,
791        &setup.cd0_vs_mach,
792        &setup.declared_cd0_vs_mach,
793        &setup.own_drag_source,
794    ) {
795        (DragMode::SameDrag, Some(flown), Some(declared), None) => {
796            if flown != declared {
797                return Err(refuse(format!(
798                    "the reference's case flew C_D0(M) = {flown:?}, not the {declared:?} its \
799                     generator declares"
800                )));
801            }
802            Some(flown.clone())
803        }
804        (DragMode::Predicted, None, None, Some(_)) => None,
805        (DragMode::SameDrag, ..) => {
806            return Err(refuse(
807                "a same-drag case needs a reference that flew its generator's declared C_D0(M), \
808                 and this one did not"
809                    .to_owned(),
810            ));
811        }
812        (DragMode::Predicted, ..) => {
813            return Err(refuse(
814                "a predicted case needs a reference that flew the example's own drag; against a \
815                 declared table it would score hpr's drag against an arbitrary constant"
816                    .to_owned(),
817            ));
818        }
819    };
820    if let Some((first, rest)) = setup.devices.split_first() {
821        if first.height_above_ground_m.is_some() {
822            return Err(refuse(format!(
823                "the reference's first device ({}) has to open at apogee, as RocketPy's first \
824                 parachute does",
825                first.name
826            )));
827        }
828        if let Some(device) = rest.iter().find(|d| d.height_above_ground_m.is_none()) {
829            return Err(refuse(format!(
830                "device {} opens at apogee too; RocketPy's parachutes replace one another in \
831                 order, which hpr flies as each releasing the one before",
832                device.name
833            )));
834        }
835    } else {
836        return Err(refuse("the reference declares no devices".to_owned()));
837    }
838
839    let path = root.join(&named);
840    let rocket: Rocket =
841        serde_json::from_str(&read(&path, "reading a design")?).map_err(|source| {
842            ValidateError::Json {
843                path: path.display().to_string(),
844                source: Box::new(source),
845            }
846        })?;
847    let flight = || -> Result<Result<Flown, String>, SimError> {
848        let environment = rocketpy_environment(
849            setup.latitude_deg,
850            setup.longitude_deg,
851            setup.elevation_m,
852            &setup.wind,
853        )?;
854        let rail = Rail {
855            length_m: setup.rail_length_m,
856            azimuth_rad: setup.heading_deg.to_radians(),
857            elevation_rad: setup.inclination_deg.to_radians(),
858            roll_rad: 0.0,
859            // RocketPy's rail has no friction (ADR-011).
860            friction_coefficient: 0.0,
861        };
862        let simulation = Simulation::new(
863            &rocket,
864            configuration,
865            environment,
866            rail,
867            FlightSettings {
868                max_time_s: 6000.0,
869                method: match mode {
870                    DragMode::Predicted => Method::DormandPrince54(Adaptive {
871                        relative_tolerance: PREDICTED_TOLERANCE,
872                        absolute_tolerance: PREDICTED_TOLERANCE,
873                        ..Adaptive::default()
874                    }),
875                    _ => FlightSettings::default().method,
876                },
877                ..FlightSettings::default()
878            },
879        )?;
880        // Predicted mode flies the design's own aerodynamics: no table.
881        let simulation = match &declared {
882            Some(rows) => {
883                let (machs, coefficients): (Vec<f64>, Vec<f64>) = rows.iter().copied().unzip();
884                let table = Table1D::new(
885                    machs,
886                    coefficients,
887                    Interpolation::Linear,
888                    // Outside the declared Mach range there is no declared drag, so none is
889                    // invented.
890                    Extrapolation::Error,
891                )?;
892                simulation.with_drag_table(
893                    DragTable::new(table.clone(), Some(table))
894                        .with_reference_diameter_m(2.0 * setup.reference_radius_m)?,
895                )
896            }
897            None => simulation,
898        };
899
900        // L75: the same rocket has to weigh the same, fly its drag on the same area and burn the
901        // same motor in both codes before any difference in how it flies can be read as physics.
902        let assembly = simulation.assembly();
903        let dry = assembly
904            .motors
905            .iter()
906            .fold(assembly.layout.structure, |sum, motor| {
907                MassProperties::combine([&sum, &motor.dry_mass_properties()])
908            });
909        let mass_gap = (dry.mass_kg - setup.dry_mass_kg).abs() / setup.dry_mass_kg;
910        if !mass_gap.is_finite() || mass_gap > MASS_AGREEMENT {
911            return Ok(Err(format!(
912                "hpr's dry mass is {:.6} kg where the reference recorded {:.6} kg, a relative \
913                 {mass_gap:.3e}",
914                dry.mass_kg, setup.dry_mass_kg
915            )));
916        }
917        let radius_m = 0.5 * assembly.layout.reference_diameter_m;
918        let area_m2 = std::f64::consts::PI * radius_m * radius_m;
919        let area_gap = (area_m2 - setup.reference_area_m2).abs() / setup.reference_area_m2;
920        let radius_gap = (radius_m - setup.reference_radius_m).abs() / setup.reference_radius_m;
921        if !(area_gap.is_finite() && radius_gap.is_finite())
922            || area_gap > AREA_AGREEMENT
923            || radius_gap > AREA_AGREEMENT
924        {
925            return Ok(Err(format!(
926                "hpr's reference area is {area_m2:.9} m2 (radius {radius_m:.6} m) where the \
927                 reference flew its drag on {:.9} m2 (radius {:.6} m)",
928                setup.reference_area_m2, setup.reference_radius_m
929            )));
930        }
931        let [placed] = assembly.motors.as_slice() else {
932            return Ok(Err(format!(
933                "the design flies {} motors, where RocketPy's examples fly one",
934                assembly.motors.len()
935            )));
936        };
937        let motor = &placed.mounted.motor;
938        for (what, hpr, rocketpy) in [
939            (
940                "total impulse, N s",
941                motor.curve().total_impulse_ns(),
942                setup.motor.total_impulse_ns,
943            ),
944            (
945                "burn-out time, s",
946                motor.burnout_time_s(),
947                setup.motor.burn_out_time_s,
948            ),
949            (
950                "initial propellant mass, kg",
951                motor.propellant_initial_mass_kg(),
952                setup.motor.propellant_initial_mass_kg,
953            ),
954        ] {
955            let gap = (hpr - rocketpy).abs() / rocketpy.abs();
956            if !gap.is_finite() || gap > MASS_AGREEMENT {
957                return Ok(Err(format!(
958                    "hpr's motor has a {what} of {hpr} where the reference flew {rocketpy}, a \
959                     relative {gap:.3e}"
960                )));
961            }
962        }
963        // The input this milestone found transcribed wrong: a correction for ambient pressure
964        // that RocketPy's examples never apply.
965        let reference_pa = motor
966            .nozzle()
967            .and_then(|nozzle| nozzle.reference_pressure_pa);
968        if reference_pa != setup.motor.reference_pressure_pa {
969            return Ok(Err(format!(
970                "hpr's motor corrects its thrust for a reference pressure of {reference_pa:?} Pa, \
971                 where the reference flew {:?}",
972                setup.motor.reference_pressure_pa
973            )));
974        }
975
976        // RocketPy's tracked point, the center of dry mass, starts at the ground
977        // (`z_init = elevation`, flight.py:1553); hpr's starts `h0` above it, with the rocket's
978        // aft end at the rail's foot. So heights are measured from `h0`, the main opens `h0`
979        // higher, and the flight lands when it is back at `h0`, where RocketPy's does.
980        let start = simulation.initial_state();
981        let origin = start.position_enu_m + start.attitude.mul_vec3(dry.cg_m);
982        let h0 = origin.z;
983        let devices = setup
984            .devices
985            .iter()
986            .enumerate()
987            .map(|(index, device)| {
988                let trigger = match device.height_above_ground_m {
989                    Some(height_above_ground_m) => Trigger::Altitude {
990                        height_above_ground_m: height_above_ground_m + h0,
991                    },
992                    None => Trigger::Apogee,
993                };
994                let mut built = Device::new(
995                    &device.name,
996                    DeviceDrag::DragArea {
997                        cd_s_m2: device.cd_s_m2,
998                    },
999                    trigger,
1000                )
1001                .with_lag_s(device.lag_s);
1002                // RocketPy flies one parachute at a time, the last deployed (flight.py:1404-1431);
1003                // hpr sums its open devices, so each one releases the one before as it opens.
1004                if index + 1 < setup.devices.len() {
1005                    built = built.with_release_by(index + 1);
1006                }
1007                built
1008            })
1009            .collect();
1010        let simulation = simulation.with_recovery(devices)?.with_event(UserEvent {
1011            name: "back at the starting height".to_owned(),
1012            direction: Direction::Falling,
1013            function: Box::new(move |sample: &Sample| sample.height_above_ground_m - h0),
1014        });
1015
1016        let mut peaks = Peaks {
1017            dry_cg_m: dry.cg_m,
1018            rail_axis_enu: rail.direction_enu(),
1019            start_enu_m: start.position_enu_m,
1020            forward_guide_travel_m: setup.effective_1rl_m,
1021            forward_guide_exit: None,
1022            rows: Vec::new(),
1023            start_height_m: h0,
1024            grid_s: setup.series.iter().map(|&(t_s, _, _)| t_s).collect(),
1025            on_grid: Vec::new(),
1026        };
1027        let result = match simulation.run(&mut peaks) {
1028            Ok(result) => result,
1029            // Only a refusal of a real Mach number at or past the top of the normal force's or
1030            // the drag buildup's range: the aerodynamics raise the same error for a NaN or a
1031            // negative Mach number, and other models (the subsonic fin slope) for lower limits,
1032            // and those are failures, not a known gap.
1033            Err(SimError::Aero(AeroError::Mach { mach, limit, model }))
1034                if mach.is_finite()
1035                    && mach >= limit
1036                    && (limit == NORMAL_FORCE_MACH_LIMIT || limit == BUILDUP_MACH_LIMIT) =>
1037            {
1038                return Ok(Ok(Flown::RefusedAtMach { mach, limit, model }));
1039            }
1040            Err(error) => return Err(error),
1041        };
1042        if result.termination != Termination::GroundHit {
1043            return Ok(Err(format!(
1044                "the flight ended as {:?} after {} steps, not at the ground",
1045                result.termination, result.stats.accepted_steps
1046            )));
1047        }
1048        let event = |kind: EventKind| {
1049            result
1050                .event(kind)
1051                .map(|event| event.sample)
1052                .ok_or_else(|| format!("the flight has no {kind:?} event"))
1053        };
1054        let (apogee, burnout, landing) = match (
1055            event(EventKind::Apogee),
1056            event(EventKind::Burnout),
1057            event(EventKind::User(0)),
1058        ) {
1059            (Ok(apogee), Ok(burnout), Ok(landing)) => (apogee, burnout, landing),
1060            (Err(what), _, _) | (_, Err(what), _) | (_, _, Err(what)) => return Ok(Err(what)),
1061        };
1062        let Some((rail_exit_time_s, rail_exit_speed_m_s)) = peaks.forward_guide_exit else {
1063            return Ok(Err(format!(
1064                "the rocket never travelled RocketPy's effective_1rl, {} m, along the rail",
1065                setup.effective_1rl_m
1066            )));
1067        };
1068        // The flight is RocketPy's until it lands, which is where its record ends.
1069        let flown: Vec<&Row> = peaks
1070            .rows
1071            .iter()
1072            .filter(|row| row.time_s <= landing.time_s)
1073            .collect();
1074        // `f64::max` passes over a NaN, so a sample that is not a number would vanish from the
1075        // maxima below rather than fail them. Refuse the flight instead.
1076        if let Some(row) = flown.iter().find(|row| !row.is_finite()) {
1077            return Ok(Err(format!(
1078                "the flight has a sample that is not a number at {} s: {row:?}",
1079                row.time_s
1080            )));
1081        }
1082        let fastest = flown.iter().map(|row| row.speed_m_s).fold(0.0, f64::max);
1083        let max_mach = flown.iter().map(|row| row.mach).fold(0.0, f64::max);
1084        let hardest = |rows: &mut dyn Iterator<Item = &&Row>| {
1085            rows.fold((0.0, 0.0), |(peak, at), row| {
1086                if row.acceleration_m_s2 > peak {
1087                    (row.acceleration_m_s2, row.time_s)
1088                } else {
1089                    (peak, at)
1090                }
1091            })
1092        };
1093        let (max_acceleration, max_acceleration_time_s) = hardest(&mut flown.iter());
1094        let (max_acceleration_power_on, _) =
1095            hardest(&mut flown.iter().filter(|row| row.time_s <= burnout.time_s));
1096        // Where it went, from where the dry center of mass started, as RocketPy's x and y are.
1097        let drift = |state: &State| {
1098            (state.position_enu_m + state.attitude.mul_vec3(dry.cg_m) - origin)
1099                .truncate()
1100                .length()
1101        };
1102
1103        let mut measured = Measured::default();
1104        measured.insert("apogee_agl_m", apogee.height_above_ground_m - h0);
1105        measured.insert("apogee_time_s", apogee.time_s);
1106        measured.insert("max_speed_m_s", fastest);
1107        measured.insert("max_mach", max_mach);
1108        measured.insert("max_acceleration_m_s2", max_acceleration);
1109        measured.insert("max_acceleration_time_s", max_acceleration_time_s);
1110        measured.insert("max_acceleration_power_on_m_s2", max_acceleration_power_on);
1111        measured.insert("rail_exit_speed_m_s", rail_exit_speed_m_s);
1112        measured.insert("rail_exit_time_s", rail_exit_time_s);
1113        // At burnout the propellant is gone, so the center of mass is the dry one.
1114        measured.insert("burnout_altitude_agl_m", burnout.height_above_ground_m - h0);
1115        measured.insert(
1116            "burnout_speed_m_s",
1117            point_velocity(&burnout.state, dry.cg_m).length(),
1118        );
1119        measured.insert("flight_time_s", landing.time_s);
1120        measured.insert("apogee_drift_m", drift(&apogee.state));
1121        measured.insert("landing_drift_m", drift(&landing.state));
1122        measured.insert("impact_speed_m_s", -landing.vertical_speed_m_s);
1123        // The two trajectories at the reference's times: both clocks start at ignition with the
1124        // rocket on the rail, so the times align as they are, and no shift is fitted, which would
1125        // hide a difference in the burn. The comparison runs while both fly: the reference's series
1126        // ends at RocketPy's impact and this one stops at hpr's landing, so whichever lands first
1127        // ends it, and the landing-time difference is left to `flight_time_s`.
1128        let both_fly: Vec<(&SeriesRow, &SeriesRow)> = setup
1129            .series
1130            .iter()
1131            .zip(&peaks.on_grid)
1132            .filter(|(_, ours)| ours.0 <= landing.time_s)
1133            .collect();
1134        if let Some((_, ours)) = both_fly
1135            .iter()
1136            .find(|(_, ours)| !(ours.1.is_finite() && ours.2.is_finite()))
1137        {
1138            return Ok(Err(format!(
1139                "the flight's trajectory is not a number at {} s: {ours:?}",
1140                ours.0
1141            )));
1142        }
1143        if both_fly.is_empty() {
1144            return Ok(Err(
1145                "the flight reached none of the reference's series times".into(),
1146            ));
1147        }
1148        let rms = |difference: fn(&SeriesRow, &SeriesRow) -> f64| {
1149            let sum: f64 = both_fly
1150                .iter()
1151                .map(|(theirs, ours)| difference(theirs, ours).powi(2))
1152                .sum();
1153            #[allow(
1154                clippy::cast_precision_loss,
1155                reason = "a count of rows is exact in an f64 below 2^53; the fixtures have 120"
1156            )]
1157            let count = both_fly.len() as f64;
1158            (sum / count).sqrt()
1159        };
1160        measured.insert("series_height_rms_m", rms(|theirs, ours| ours.1 - theirs.1));
1161        measured.insert(
1162            "series_speed_rms_m_s",
1163            rms(|theirs, ours| ours.2 - theirs.2),
1164        );
1165        Ok(Ok(Flown::Measured(measured)))
1166    };
1167    match flight() {
1168        Ok(Ok(flown)) => Ok(flown),
1169        Ok(Err(what)) => Err(refuse(what)),
1170        Err(source) => Err(ValidateError::Flight {
1171            case: case.id.clone(),
1172            what: source.to_string(),
1173            source: Some(Box::new(source)),
1174        }),
1175    }
1176}
1177
1178/// The velocity of the body point `p` (body axes, from the nose tip), m/s: `v_O + R (ω × p)`.
1179fn point_velocity(state: &State, p: DVec3) -> DVec3 {
1180    state.velocity_enu_m_s + state.attitude.mul_vec3(state.body_rate_rad_s.cross(p))
1181}
1182
1183/// One instant of the flight, as the whole-flight metrics need it.
1184#[derive(Debug, Clone, Copy)]
1185struct Row {
1186    time_s: f64,
1187    speed_m_s: f64,
1188    mach: f64,
1189    acceleration_m_s2: f64,
1190}
1191
1192impl Row {
1193    /// Whether every quantity in the row is a number.
1194    fn is_finite(&self) -> bool {
1195        self.speed_m_s.is_finite() && self.mach.is_finite() && self.acceleration_m_s2.is_finite()
1196    }
1197
1198    /// The quantities whose peaks the metrics report, each as a function of a row.
1199    const PEAKED: [fn(&Row) -> f64; 3] = [
1200        |row| row.speed_m_s,
1201        |row| row.mach,
1202        |row| row.acceleration_m_s2,
1203    ];
1204}
1205
1206/// Golden-section iterations that narrow onto a peak inside one step: each keeps 0.618 of the
1207/// bracket, so 50 leave 3.5e-11 of the step's length.
1208const PEAK_ITERATIONS: usize = 50;
1209
1210/// Where `value` peaks between `low` and `high`, by golden-section search (Kiefer 1953, "Sequential
1211/// minimax search for a maximum", Proc. AMS 4(3), 502-506): each iteration keeps 0.618 of the
1212/// bracket, for [`PEAK_ITERATIONS`] iterations. For a `value` that rises to one peak and then
1213/// falls, the answer is within 3.5e-11 of the bracket's length of a kinked peak. A smooth peak's
1214/// top is flat to the value's rounding over a wider span, so the answer is somewhere on it and
1215/// the value there is the peak's to rounding.
1216///
1217/// # Errors
1218///
1219/// The first error `value` returns.
1220pub(crate) fn peak_between<E>(
1221    low: f64,
1222    high: f64,
1223    mut value: impl FnMut(f64) -> Result<f64, E>,
1224) -> Result<f64, E> {
1225    let keep = 0.5 * (5.0_f64.sqrt() - 1.0);
1226    let (mut low, mut high) = (low, high);
1227    let (mut left, mut right) = (high - keep * (high - low), low + keep * (high - low));
1228    let (mut at_left, mut at_right) = (value(left)?, value(right)?);
1229    for _ in 0..PEAK_ITERATIONS {
1230        if at_left > at_right {
1231            (high, right, at_right) = (right, left, at_left);
1232            left = high - keep * (high - low);
1233            at_left = value(left)?;
1234        } else {
1235            (low, left, at_left) = (left, right, at_right);
1236            right = low + keep * (high - low);
1237            at_right = value(right)?;
1238        }
1239    }
1240    Ok(if at_left > at_right { left } else { right })
1241}
1242
1243/// Watches a whole flight for what its metrics need: speed, Mach and acceleration at the dry center
1244/// of mass at every step's ends and at each of their peaks inside a step, and the instant the
1245/// forward guide leaves the rail.
1246struct Peaks {
1247    /// The dry center of mass, body axes from the nose tip, m.
1248    dry_cg_m: DVec3,
1249    /// The rail's axis, up the rail.
1250    rail_axis_enu: DVec3,
1251    /// The nose tip's position at ignition, m.
1252    start_enu_m: DVec3,
1253    /// How far the rocket travels along the rail before RocketPy's forward button leaves its top,
1254    /// m (`effective_1rl`).
1255    forward_guide_travel_m: f64,
1256    /// When that happened and the speed then, once it has.
1257    forward_guide_exit: Option<(f64, f64)>,
1258    /// A row at every step's start and end, and at every peak inside a step.
1259    rows: Vec<Row>,
1260    /// The height the dry center of mass started at, m: RocketPy's zero.
1261    start_height_m: f64,
1262    /// The reference's series times, s since ignition.
1263    grid_s: Vec<f64>,
1264    /// `(time, height from the start, speed)` of the dry center of mass at each of those times the
1265    /// flight has reached, from the dense output of the step that holds it, s, m and m/s.
1266    on_grid: Vec<SeriesRow>,
1267}
1268
1269impl Peaks {
1270    /// The row at `t_s` within `step`.
1271    fn row(&self, step: &dyn FlightStep, t_s: f64) -> Result<Option<Row>, SimError> {
1272        let sample: Sample = step.sample(t_s)?;
1273        let state = sample.state;
1274        let omega = state.body_rate_rad_s;
1275        let p = self.dry_cg_m;
1276        // ω̇ from the step's own interpolant, whose derivative is the integrator's to the order
1277        // of its dense output: a backward difference over a microsecond, or over the step's first
1278        // quarter if it is shorter. Off the free phase nothing rotates.
1279        let omega_dot = if step.phase() == Phase::Free {
1280            let back = (0.25 * (t_s - step.start_s())).min(1e-6);
1281            let ahead = (0.25 * (step.end_s() - t_s)).min(1e-6);
1282            if back > 0.0 {
1283                (omega - step.state_at(t_s - back).body_rate_rad_s) / back
1284            } else if ahead > 0.0 {
1285                (step.state_at(t_s + ahead).body_rate_rad_s - omega) / ahead
1286            } else {
1287                // A step of no length has no interpolant to differentiate, and its instant is
1288                // the end of the step before it, which is already a row.
1289                return Ok(None);
1290            }
1291        } else {
1292            DVec3::ZERO
1293        };
1294        let acceleration = sample.acceleration_enu_m_s2
1295            + state
1296                .attitude
1297                .mul_vec3(omega_dot.cross(p) + omega.cross(omega.cross(p)));
1298        Ok(Some(Row {
1299            time_s: t_s,
1300            speed_m_s: point_velocity(&state, p).length(),
1301            mach: sample.mach,
1302            acceleration_m_s2: acceleration.length(),
1303        }))
1304    }
1305
1306    /// The rows at the peaks of speed, Mach and acceleration that fall inside `step`.
1307    ///
1308    /// A peak read only where steps end is off by wherever the step control put them, by O(h²) of
1309    /// the step length h at a smooth peak. (A thrust curve's points are stop times, so a peak there
1310    /// is a step's end and read exactly.) The step control is not the same on every platform
1311    /// (predicted mode's drag calls `ln` and `powf`), so neither is such a peak: moving predicted
1312    /// mode's solver tolerance by 1e-7 of itself moved NDRT 2020's max speed by 2.4e-6 of itself,
1313    /// where the event-located apogee moved by 1.3e-9. So where a quantity rises out of the step's start and falls into its end,
1314    /// as read one microsecond (or a quarter of the step) inside each, a golden-section search on
1315    /// the step's dense output narrows onto the peak between them. What it finds is the
1316    /// interpolant's peak, which the tolerance controls, wherever the steps fall. A second pair of
1317    /// samples, a millisecond (or a quarter of the step) inside each end, catches a peak too flat
1318    /// to rise above the acceleration's noise over a microsecond.
1319    ///
1320    /// Its limits, measured by sampling every step at 400 points: a step whose quantity turns more
1321    /// than once, or jumps (the skin friction at the critical Reynolds number), is not searched;
1322    /// none of those is a flight's maximum today. And the acceleration an evaluation of the
1323    /// equations of motion gives is smooth only to about 1e-7 m/s², so a smooth acceleration peak
1324    /// is found to about 1e-8 of itself and its time only to about 1e-4 s (issue #53).
1325    ///
1326    /// `first` and `last` are the rows at the step's start and end. A sample inside the step that
1327    /// is not a number comes back as a row of its own, so that the flight is refused for it rather
1328    /// than the comparisons passing over it.
1329    fn peaks_within(
1330        &self,
1331        step: &dyn FlightStep,
1332        first: &Row,
1333        last: &Row,
1334    ) -> Result<Vec<Row>, SimError> {
1335        let (start_s, end_s) = (step.start_s(), step.end_s());
1336        // Two probes inside each end: one microsecond, for a peak close to an end, and a
1337        // millisecond, for a flat peak whose rise over a microsecond is below the acceleration's
1338        // noise of about 1e-7 m/s² (issue #53), where the near probe alone found it at random
1339        // (the validation audit on M1.8c: predicted NDRT 2020's powered peak).
1340        let (near, far) = (
1341            (0.25 * (end_s - start_s)).min(1e-6),
1342            (0.25 * (end_s - start_s)).min(1e-3),
1343        );
1344        // A step of no length has no inside: in free flight `row` gives none there, and elsewhere
1345        // every sample of it is one instant, so no quantity rises out of its start.
1346        let (Some(second), Some(penultimate), Some(inner), Some(inner_end)) = (
1347            self.row(step, start_s + near)?,
1348            self.row(step, end_s - near)?,
1349            self.row(step, start_s + far)?,
1350            self.row(step, end_s - far)?,
1351        ) else {
1352            return Ok(Vec::new());
1353        };
1354        let mut not_a_number = [second, penultimate, inner, inner_end]
1355            .into_iter()
1356            .find(|row| !row.is_finite());
1357        let mut peaks = Vec::new();
1358        for quantity in Row::PEAKED {
1359            let brackets =
1360                |a: &Row, b: &Row| quantity(a) > quantity(first) && quantity(b) > quantity(last);
1361            if !(brackets(&second, &penultimate) || brackets(&inner, &inner_end)) {
1362                continue;
1363            }
1364            let peak_s = peak_between(start_s, end_s, |t_s| -> Result<f64, SimError> {
1365                Ok(match self.row(step, t_s)? {
1366                    Some(row) if row.is_finite() => quantity(&row),
1367                    Some(row) => {
1368                        not_a_number.get_or_insert(row);
1369                        f64::NAN
1370                    }
1371                    // Only a step of no length, returned from above.
1372                    None => f64::NEG_INFINITY,
1373                })
1374            })?;
1375            peaks.extend(self.row(step, peak_s)?);
1376        }
1377        peaks.extend(not_a_number);
1378        Ok(peaks)
1379    }
1380
1381    /// How far past the forward guide's exit the rocket has travelled at `t_s`, m.
1382    fn past_forward_guide(&self, step: &dyn FlightStep, t_s: f64) -> f64 {
1383        (step.state_at(t_s).position_enu_m - self.start_enu_m).dot(self.rail_axis_enu)
1384            - self.forward_guide_travel_m
1385    }
1386}
1387
1388impl Observer for Peaks {
1389    fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
1390        let (start_s, end_s) = (step.start_s(), step.end_s());
1391        // Each step's start as well as its end: after an event the start is the new phase's first
1392        // instant, such as a canopy fully open, where the deceleration peaks. Taking only the ends
1393        // would read that peak one step late, wherever the step control happens to put it, which
1394        // differs across platforms in the sixth figure where the peak falls steeply.
1395        let (first, last) = (self.row(step, start_s)?, self.row(step, end_s)?);
1396        let peaks = match (&first, &last) {
1397            (Some(first), Some(last)) => self.peaks_within(step, first, last)?,
1398            _ => Vec::new(),
1399        };
1400        self.rows.extend(first);
1401        self.rows.extend(last);
1402        self.rows.extend(peaks);
1403        // Each series time in the first step that reaches it: the one before ended short of it,
1404        // so it lies inside this step.
1405        while let Some(&t_s) = self.grid_s.get(self.on_grid.len()) {
1406            if t_s > end_s {
1407                break;
1408            }
1409            let state = step.state_at(t_s);
1410            let height_m = (state.position_enu_m + state.attitude.mul_vec3(self.dry_cg_m)).z
1411                - self.start_height_m;
1412            let speed_m_s = point_velocity(&state, self.dry_cg_m).length();
1413            self.on_grid.push((t_s, height_m, speed_m_s));
1414        }
1415        if self.forward_guide_exit.is_none()
1416            && step.phase() == Phase::Rail
1417            && self.past_forward_guide(step, end_s) >= 0.0
1418        {
1419            // Bisect the step's interpolant for the crossing. On the rail nothing rotates, so
1420            // every point of the rocket moves at the speed the state carries.
1421            let (mut before, mut after) = (start_s, end_s);
1422            for _ in 0..80 {
1423                let middle = 0.5 * (before + after);
1424                if self.past_forward_guide(step, middle) >= 0.0 {
1425                    after = middle;
1426                } else {
1427                    before = middle;
1428                }
1429            }
1430            let speed = step.state_at(after).velocity_enu_m_s.length();
1431            self.forward_guide_exit = Some((after, speed));
1432        }
1433        Ok(())
1434    }
1435}
1436
1437/// Reads a file, naming what was being done.
1438fn read(path: &Path, what: &'static str) -> Result<String, ValidateError> {
1439    std::fs::read_to_string(path).map_err(|source| ValidateError::Io {
1440        what,
1441        path: path.display().to_string(),
1442        source,
1443    })
1444}
1445
1446/// The metric names a descent case can report.
1447pub const DESCENT_METRICS: [&str; 6] = [
1448    "descent_time_s",
1449    "impact_speed_m_s",
1450    "drift_m",
1451    "drift_east_m",
1452    "drift_north_m",
1453    "mean_descent_rate_m_s",
1454];
1455
1456/// The metric names a whole-flight case can report: `flight.py`'s, as RocketPy defines them, and
1457/// the root mean square of hpr's height and speed less the reference's `series`, whose
1458/// reference is 0, exact agreement.
1459pub const WHOLE_FLIGHT_METRICS: [&str; 17] = [
1460    "apogee_agl_m",
1461    "apogee_time_s",
1462    "max_speed_m_s",
1463    "max_mach",
1464    "max_acceleration_m_s2",
1465    "max_acceleration_time_s",
1466    "max_acceleration_power_on_m_s2",
1467    "rail_exit_speed_m_s",
1468    "rail_exit_time_s",
1469    "burnout_altitude_agl_m",
1470    "burnout_speed_m_s",
1471    "flight_time_s",
1472    "apogee_drift_m",
1473    "landing_drift_m",
1474    "impact_speed_m_s",
1475    "series_height_rms_m",
1476    "series_speed_rms_m_s",
1477];
1478
1479/// The solver tolerance, `rtol` and `atol` alike, predicted mode flies at (ADR-023).
1480///
1481/// Its aerodynamics call `ln` and `powf` (the skin friction), whose last bits differ between the
1482/// platforms' maths libraries, so the adaptive step sequence can differ between them, and the answer
1483/// then differs by the solver's global error. At the default 1e-8, NDRT 2020's predicted apogee was
1484/// 1404.058522 m on macOS and 1404.058761 m on Linux, 1.7e-7 apart, past the committed report's
1485/// 1e-7 reproduction bound (ADR-022). Measured on macOS, that apogee is 1404.058522, .057883,
1486/// .058122 and .058145 m at 1e-8, 1e-9, 1e-10 and 1e-11: converged at 1e-11 to about 1e-5 m, far
1487/// inside the bound, for 0.5 s more over the whole suite. Same-drag mode's table interpolation
1488/// reproduces at the default and keeps it.
1489const PREDICTED_TOLERANCE: f64 = 1e-11;
1490
1491/// The parts of a reference a whole-flight case needs, read from the generator's own JSON.
1492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1493pub struct WholeFlightSetup {
1494    /// The design the oracle flew, from the repository root.
1495    pub design: String,
1496    /// What it weighed with its propellant gone, kg.
1497    pub dry_mass_kg: f64,
1498    /// The site's latitude, degrees.
1499    pub latitude_deg: f64,
1500    /// Its longitude, degrees.
1501    pub longitude_deg: f64,
1502    /// Its elevation above sea level, m.
1503    pub elevation_m: f64,
1504    /// The wind as `(height above sea level, east, north)` levels, m and m/s.
1505    pub wind: Vec<(f64, f64, f64)>,
1506    /// The rail's length, m.
1507    pub rail_length_m: f64,
1508    /// The rail's angle above the horizon, degrees (RocketPy's `inclination`).
1509    pub inclination_deg: f64,
1510    /// Its heading, clockwise from north, degrees.
1511    pub heading_deg: f64,
1512    /// How far RocketPy's rocket travels along the rail before its forward button leaves the top,
1513    /// m (`effective_1rl`): where the rail-exit metrics are taken.
1514    pub effective_1rl_m: f64,
1515    /// The motor as RocketPy flew it, which hpr's must match.
1516    pub motor: FlightMotor,
1517    /// The `(Mach, C_D0)` rows the case flew, power on and off alike, in a same-drag reference;
1518    /// `None` in a reference that flew the example's own drag.
1519    pub cd0_vs_mach: Option<Vec<(f64, f64)>>,
1520    /// The rows the generator declares for every case, which the case's must equal; `None` in a
1521    /// reference that flew the example's own drag.
1522    pub declared_cd0_vs_mach: Option<Vec<(f64, f64)>>,
1523    /// Where the example's own drag came from, in a reference that flew it: recorded by its
1524    /// source and hash, never its values, which carry their own terms
1525    /// ([ADR-009](https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-009-subsonic-drag-buildup-surface-finishes-and-drag-override-tables-2026-09-17),
1526    /// the drag decisions).
1527    pub own_drag_source: Option<String>,
1528    /// The radius the drag coefficients are on, m (RocketPy's `Rocket(radius)`).
1529    pub reference_radius_m: f64,
1530    /// The area they are on, m².
1531    pub reference_area_m2: f64,
1532    /// The recovery devices, in the order they open.
1533    pub devices: Vec<FlightDevice>,
1534    /// The reference's trajectory as `(time since ignition s, height above the ground m, speed
1535    /// m/s)` rows, of the center of dry mass, from ignition to its impact: what the RMS metrics
1536    /// compare hpr's against.
1537    pub series: Vec<SeriesRow>,
1538}
1539
1540/// One instant of a trajectory: time since ignition, height of the center of dry mass above where
1541/// it started, and its speed, s, m and m/s.
1542pub type SeriesRow = (f64, f64, f64);
1543
1544/// The motor of a whole flight, as the reference records RocketPy flying it.
1545#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1546pub struct FlightMotor {
1547    /// The thrust curve's total impulse, N s.
1548    pub total_impulse_ns: f64,
1549    /// When the curve ends, s after ignition.
1550    pub burn_out_time_s: f64,
1551    /// The propellant's mass at ignition, kg.
1552    pub propellant_initial_mass_kg: f64,
1553    /// The pressure the curve is corrected from, Pa; `None` for no correction.
1554    pub reference_pressure_pa: Option<f64>,
1555}
1556
1557/// One recovery device of a whole flight, as the reference declares it.
1558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1559pub struct FlightDevice {
1560    /// Its name, for reports.
1561    pub name: String,
1562    /// Its drag area `C_D S`, m².
1563    pub cd_s_m2: f64,
1564    /// Its lag from the trigger to line stretch, s.
1565    pub lag_s: f64,
1566    /// The height above the site it opens at on the way down, m; `None` opens it at apogee.
1567    pub height_above_ground_m: Option<f64>,
1568}
1569
1570/// The parts of a reference a descent case needs, read from the generator's own JSON.
1571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1572pub struct DescentSetup {
1573    /// The design the oracle flew, from the repository root.
1574    pub design: String,
1575    /// What it weighed as it descended, kg.
1576    pub dry_mass_kg: f64,
1577    /// How far above the site the descent starts, m: the generator's own numerator for the mean
1578    /// descent rate.
1579    pub start_height_above_ground_m: f64,
1580    /// The site's latitude, degrees.
1581    pub latitude_deg: f64,
1582    /// Its longitude, degrees.
1583    pub longitude_deg: f64,
1584    /// Its elevation above sea level, m.
1585    pub elevation_m: f64,
1586    /// The wind as `(height above sea level, east, north)` levels, m and m/s.
1587    pub wind: Vec<(f64, f64, f64)>,
1588    /// When the descent starts, s after ignition.
1589    pub start_time_s: f64,
1590    /// Where its center of mass starts, m (east, north, height above sea level).
1591    pub start_position_m: (f64, f64, f64),
1592    /// That point's velocity, m/s.
1593    pub start_velocity_m_s: (f64, f64, f64),
1594    /// The devices, in the order they open.
1595    pub devices: Vec<DescentDevice>,
1596}
1597
1598/// One device as the reference declares it.
1599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1600pub struct DescentDevice {
1601    /// Its name, for reports.
1602    pub name: String,
1603    /// Its drag area `C_D S`, m2.
1604    pub cd_s_m2: f64,
1605    /// Its lag from the trigger to line stretch, s.
1606    pub lag_s: f64,
1607    /// The height above the site it opens at, m; `None` opens it at the start.
1608    pub height_above_ground_m: Option<f64>,
1609}
1610
1611#[cfg(test)]
1612mod peak_tests {
1613    use hpr_core::DQuat;
1614
1615    use super::*;
1616
1617    /// A free-flight step whose rocket climbs straight up at `speed(t)`, at a steady 9 m/s².
1618    struct Climb {
1619        start_s: f64,
1620        end_s: f64,
1621        speed: fn(f64) -> f64,
1622    }
1623
1624    impl FlightStep for Climb {
1625        fn phase(&self) -> Phase {
1626            Phase::Free
1627        }
1628        fn start_s(&self) -> f64 {
1629            self.start_s
1630        }
1631        fn end_s(&self) -> f64 {
1632            self.end_s
1633        }
1634        fn state_at(&self, t_s: f64) -> State {
1635            // Nothing outside the step: a time read from a step that does not hold it is NaN.
1636            let t_s = if (self.start_s..=self.end_s).contains(&t_s) {
1637                t_s
1638            } else {
1639                f64::NAN
1640            };
1641            State {
1642                // Rising a meter a second, for the series samples' heights.
1643                position_enu_m: DVec3::new(0.0, 0.0, t_s),
1644                velocity_enu_m_s: DVec3::new(0.0, 0.0, (self.speed)(t_s)),
1645                attitude: DQuat::IDENTITY,
1646                body_rate_rad_s: DVec3::ZERO,
1647            }
1648        }
1649        fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
1650            let state = self.state_at(t_s);
1651            let speed = state.velocity_enu_m_s.z;
1652            Ok(Sample {
1653                time_s: t_s,
1654                phase: Phase::Free,
1655                state,
1656                cg_enu_m: DVec3::ZERO,
1657                cg_velocity_enu_m_s: state.velocity_enu_m_s,
1658                height_above_ground_m: 0.0,
1659                vertical_speed_m_s: speed,
1660                acceleration_enu_m_s2: DVec3::new(0.0, 0.0, 9.0),
1661                airspeed_m_s: speed,
1662                mach: speed / 340.0,
1663                angle_of_attack_rad: 0.0,
1664                dynamic_pressure_pa: 0.0,
1665                axial_coefficient: 0.0,
1666                thrust_n: 0.0,
1667                mass_kg: 1.0,
1668                recovery_drag_area_m2: 0.0,
1669            })
1670        }
1671        fn stability(&self, t_s: f64) -> Result<hpr_sim::metrics::Stability, SimError> {
1672            // A made-up climb has no airframe to be stable.
1673            Err(SimError::Domain {
1674                what: "stability of a test step with no aerodynamic model",
1675                value: t_s,
1676            })
1677        }
1678    }
1679
1680    #[test]
1681    fn each_series_time_is_sampled_once_from_the_step_that_holds_it() {
1682        // The dry center of mass half a meter below the stub's origin, which rises at 1 m/s from
1683        // 0, so the center of mass starts at -0.5 m and its height from there is the time.
1684        let mut peaks = Peaks {
1685            dry_cg_m: DVec3::new(0.0, 0.0, -0.5),
1686            rail_axis_enu: DVec3::Z,
1687            start_enu_m: DVec3::ZERO,
1688            forward_guide_travel_m: 1.0,
1689            forward_guide_exit: None,
1690            rows: Vec::new(),
1691            start_height_m: -0.5,
1692            grid_s: vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5],
1693            on_grid: Vec::new(),
1694        };
1695        let speed = |t_s: f64| 10.0 + t_s;
1696        // Two steps back to back, one of no length at their shared end, and none past 2 s.
1697        for (start_s, end_s) in [(0.0, 1.0), (1.0, 2.0), (2.0, 2.0)] {
1698            let step = Climb {
1699                start_s,
1700                end_s,
1701                speed,
1702            };
1703            peaks.step(&step).expect("the stub's samples never fail");
1704        }
1705        let expected: Vec<SeriesRow> = [0.0, 0.5, 1.0, 1.5, 2.0]
1706            .into_iter()
1707            .map(|t_s| (t_s, t_s, speed(t_s)))
1708            .collect();
1709        assert_eq!(peaks.on_grid, expected);
1710    }
1711
1712    /// The rows the observer keeps for one step.
1713    fn rows(start_s: f64, end_s: f64, speed: fn(f64) -> f64) -> Vec<Row> {
1714        let mut peaks = Peaks {
1715            dry_cg_m: DVec3::ZERO,
1716            rail_axis_enu: DVec3::Z,
1717            start_enu_m: DVec3::ZERO,
1718            forward_guide_travel_m: 1.0,
1719            forward_guide_exit: None,
1720            rows: Vec::new(),
1721            start_height_m: 0.0,
1722            grid_s: Vec::new(),
1723            on_grid: Vec::new(),
1724        };
1725        let step = Climb {
1726            start_s,
1727            end_s,
1728            speed,
1729        };
1730        peaks.step(&step).expect("the stub's samples never fail");
1731        peaks.rows
1732    }
1733
1734    #[test]
1735    fn a_peak_inside_a_step_gets_a_row_and_a_rise_does_not() {
1736        // Speed, and so Mach, peak at 0.013 s inside the step; the acceleration is steady. The
1737        // ends and the two peaks give four rows, the peaks at the top to rounding.
1738        let peaked = rows(0.0, 0.05, |t| 100.0 - 1e4 * (t - 0.013).powi(2));
1739        assert_eq!(peaked.len(), 4, "{peaked:?}");
1740        for row in &peaked[2..] {
1741            assert!((row.time_s - 0.013).abs() < 1e-8, "{row:?}");
1742            assert!((row.speed_m_s - 100.0).abs() < 1e-12, "{row:?}");
1743        }
1744        // A step that only rises has its peak at its end, which is a row already.
1745        assert_eq!(rows(0.0, 0.05, |t| 100.0 + t).len(), 2);
1746        // A step of no length has no row at all in free flight: its instant is the end of the
1747        // step before it.
1748        assert!(rows(0.03, 0.03, |t| 100.0 + t).is_empty());
1749    }
1750
1751    #[test]
1752    fn a_sample_inside_a_step_that_is_not_a_number_is_kept_to_be_refused() {
1753        // Finite at the ends and a microsecond inside them, not a number around the peak.
1754        let rows = rows(0.0, 0.05, |t| {
1755            if (t - 0.013).abs() < 1e-3 {
1756                f64::NAN
1757            } else {
1758                100.0 - 1e4 * (t - 0.013).powi(2)
1759            }
1760        });
1761        assert!(rows.iter().any(|row| !row.is_finite()), "{rows:?}");
1762    }
1763}