Skip to main content

hpr_validate/
real_flight.rs

1//! Real flights: hpr against the altitude logs of RocketPy's example rockets, flown in the ERA5
2//! weather of their day ([M2.3b][m2-3b], decision [ADR-082][adr-082]).
3//!
4//! Each [`RealFlight`] is a rocket RocketPy's documentation flies against its team's log. hpr flies
5//! the same rocket (its design under `validation/designs/`, built from the example's inputs), on the
6//! example's own thrust file with the example's burn options, from the example's rail and site, in
7//! the ERA5 file the example reads, with hpr's own aerodynamics: the way a user would fly it. The
8//! log is the reference.
9//!
10//! The logs, most of the thrust files and the full weather files carry their own terms, so they are
11//! read from the pinned RocketPy checkout under the gitignored `refs/` and never committed. What is
12//! committed is [`REPORT_JSON`] and [`REPORT_MD`]: per flight, the log's apogee and hpr's, the
13//! apogee error, the altitude-trace RMS, and the SHA-256 of every file read. `cargo xtask
14//! real-flights` writes them and `--check` flies them again; CI, which has no `refs/`, holds the
15//! summary to the rows and the page to the data ([`RealFlightReport::check_consistent`]).
16//!
17//! What is compared, for each flight:
18//!
19//! - **Heights:** a barometric altimeter reads the standard atmosphere's altitude of the pressure
20//!   it measures, less the pad's, not the height it climbed: on a warm day, less. So hpr's height
21//!   is read the same way, from the ERA5 pressure at its center of mass, for a log whose
22//!   [`Altimeter`] is barometric ([`Barometer`]), and taken as it is otherwise. Each
23//!   log's height column is used as recorded.
24//! - **Apogee:** the highest height in the log, read up to [`Log::until_s`] where the recovery's
25//!   pressure transients would otherwise set it, against hpr's apogee read the same way. The error
26//!   is hpr's less the log's, as a percentage of the log's.
27//! - **Altitude-trace RMS:** the log's heights against hpr's over the ascent. The two clocks are
28//!   aligned where each trace first reaches [`ALIGN_HEIGHT_M`], since a log's zero is its own
29//!   (armed, launch detected, or power on) and not hpr's ignition. The RMS is over every log row from
30//!   there until the first of the two apogees, hpr's heights linearly interpolated from a
31//!   [`GRID_S`] grid of its dense output. The descent is not compared: its events (which parachute
32//!   opened, when) are the team's, and one of these flights lost its main.
33//!
34//! Two diagnostic flights, not predictions, say where a miss may come from: the same flight on the
35//! example's own drag, and where the example reshapes its thrust file, on the file as recorded.
36//! Where a log keeps its pressure or the flight a satellite altitude, the report holds the
37//! barometric reading against them. Each row carries the SHA-256 of its flight's entry in
38//! [`FLIGHTS`], so CI sees an input change the report hasn't caught up with.
39//!
40//! The mean absolute apogee error is reported against the [`APOGEE_TARGET_PERCENT`] target of
41//! `docs/VALIDATION.md`; it is a target, not a gate. A flight outside it is an outlier, and each
42//! outlier carries its explanation in [`RealFlight::explanation`].
43//!
44//! [m2-3b]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-3b
45//! [adr-082]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-082-real-flights-read-from-refs-compared-over-the-ascent-with-checked-explanations-2026-09-26
46
47use std::fmt::Write as _;
48use std::fs;
49use std::path::{Path, PathBuf};
50
51use hpr_aero::DragTable;
52use hpr_atmos::{Atmosphere, Ussa76, WindInterpolation};
53use hpr_core::earth::Earth;
54use hpr_core::geodesy::Geodetic;
55use hpr_core::interp::{Extrapolation, Interpolation, Table1D};
56use hpr_design::Rocket;
57use hpr_io::era5::{Era5Profile, Era5Request, UtcTime};
58use hpr_io::netcdf::NetCdf;
59use hpr_sim::{
60    Environment, EventKind, FlightMetrics, FlightSettings, FlightStep, Observer, Rail, SimError,
61    Simulation,
62};
63use serde::{Deserialize, Serialize};
64use serde_json::Value;
65use sha2::{Digest, Sha256};
66
67/// The report, as data.
68pub const REPORT_JSON: &str = "validation/reports/real-flights.json";
69
70/// The report, as a page.
71pub const REPORT_MD: &str = "validation/reports/real-flights.md";
72
73/// The pinned RocketPy checkout the logs, thrust files and weather files are read from.
74pub const ROCKETPY: &str = "refs/rocketpy";
75
76/// The design fixture that records each example's own thrust file and burn options.
77pub const MASS_FIXTURE: &str = "validation/fixtures/design/rocketpy-rocket-mass.json";
78
79/// The target for the mean absolute apogee error, per cent (`docs/VALIDATION.md`, "Principles").
80pub const APOGEE_TARGET_PERCENT: f64 = 5.0;
81
82/// The height each trace's clock is aligned at, m above its start.
83///
84/// High enough to be past the pad's noise and the rail in every log here (the longest rail is
85/// 12 m), low enough to be inside the first second of flight.
86pub const ALIGN_HEIGHT_M: f64 = 30.0;
87
88/// The spacing of hpr's heights the trace is compared against, s.
89pub const GRID_S: f64 = 0.01;
90
91/// How long hpr flies past the log's apogee before it stops, s: past any apogee within reach.
92const PAST_APOGEE_S: f64 = 30.0;
93
94/// Where a flight's altitude log is and how to read it.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct Log {
97    /// The CSV file, under `refs/rocketpy/data/rockets/`.
98    pub file: &'static str,
99    /// Header lines before the first row.
100    pub header_lines: usize,
101    /// The column of time, s (zero-based).
102    pub time_column: usize,
103    /// The column of height above the pad (zero-based), used as recorded.
104    pub height_column: usize,
105    /// Meters per unit of the height column.
106    pub meters_per_unit: f64,
107    /// Rows after this time are not read, s: where the log stops recording the rocket's height,
108    /// at an ejection charge's pressure transient past apogee or a corrupted end. The flight's
109    /// [`RealFlight::note`] says which.
110    pub until_s: Option<f64>,
111    /// What measured the height, and so what of hpr's flight it is compared with.
112    pub altimeter: Altimeter,
113    /// The column of the pressure the height was read from, and pascals per unit, where the log
114    /// keeps it: the report gives how far the height column is from the standard atmosphere's
115    /// reading of it.
116    pub pressure: Option<(usize, f64)>,
117    /// The flight's satellite (GNSS) altitude, where it logged one: a geometric height to hold the
118    /// barometric reading against.
119    pub gnss: Option<Gnss>,
120}
121
122/// Where a flight's satellite altitude is and how to read it.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct Gnss {
125    /// The CSV file, under `refs/rocketpy/data/rockets/`.
126    pub file: &'static str,
127    /// Header lines before the first row.
128    pub header_lines: usize,
129    /// The column of time, s (zero-based).
130    pub time_column: usize,
131    /// The column of altitude (zero-based); its first row is the pad's.
132    pub altitude_column: usize,
133    /// Meters per unit of the altitude column.
134    pub meters_per_unit: f64,
135}
136
137/// What a log's height is.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum Altimeter {
140    /// A barometric altimeter's: the pressure's altitude in the standard atmosphere less the pad's
141    /// ([`hpr_atmos::Ussa76::pressure_altitude_m`]). On a day warmer than the standard it reads
142    /// less than the height climbed. hpr's is read the same way, from the ERA5 pressure at its
143    /// center of mass.
144    Barometric(&'static str),
145    /// Taken as barometric, read as [`Altimeter::Barometric`], without evidence of how its height
146    /// was made.
147    AssumedBarometric(&'static str),
148    /// A height above the pad in meters, compared with hpr's as it is.
149    Height(&'static str),
150}
151
152impl Altimeter {
153    /// The kind's name in the report.
154    #[must_use]
155    pub fn kind(self) -> &'static str {
156        match self {
157            Self::Barometric(_) => "barometric",
158            Self::AssumedBarometric(_) => "barometric, assumed",
159            Self::Height(_) => "height",
160        }
161    }
162
163    /// Where the kind comes from, in words.
164    #[must_use]
165    pub fn evidence(self) -> &'static str {
166        match self {
167            Self::Barometric(text) | Self::AssumedBarometric(text) | Self::Height(text) => text,
168        }
169    }
170}
171
172/// The drag the example flies, as its notebook gives it: the second, diagnostic flight's.
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum ExampleDrag {
175    /// One `C_D`, power off and on.
176    Constant(f64),
177    /// `(Mach, C_D)` knots, linear between and held outside; power on is `power_on_factor` times.
178    Knots {
179        /// The knots, in increasing Mach number.
180        points: &'static [(f64, f64)],
181        /// Power on, as a multiple of power off.
182        power_on_factor: f64,
183    },
184    /// `(Mach, C_D)` CSV files under `refs/rocketpy/data/rockets/`, linear and held outside,
185    /// optionally scaled so power off reads `C_D` at a Mach number.
186    Files {
187        /// The power-off curve.
188        power_off: &'static str,
189        /// The power-on curve.
190        power_on: &'static str,
191        /// `(Mach, C_D)` the power-off curve is scaled to pass through, with the power-on
192        /// curve scaled by the same factor.
193        scaled_to: Option<(f64, f64)>,
194    },
195}
196
197/// Why a flight's apogee misses the log's by more than [`APOGEE_TARGET_PERCENT`]: a claim the
198/// report's own numbers are checked against ([`RealFlightReport::check_consistent`]), so a change
199/// that makes it false fails the check, with the words that argue it.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Explanation {
202    /// Within the target: nothing to explain.
203    None,
204    /// Consistent with hpr's drag: on the example's own drag the apogee is within the target, and
205    /// on the thrust file as recorded, where there is that flight, it is not.
206    Drag(&'static str),
207    /// Consistent with the motor's impulse: on the thrust file as recorded, without the reshape
208    /// the example applies, the apogee is within the target, and on the example's own drag it is
209    /// not.
210    Thrust(&'static str),
211}
212
213impl Explanation {
214    /// The claim's name in the report, empty for none.
215    #[must_use]
216    pub fn kind(self) -> &'static str {
217        match self {
218            Self::None => "",
219            Self::Drag(_) => "drag",
220            Self::Thrust(_) => "thrust",
221        }
222    }
223
224    /// The words that argue it, empty for none.
225    #[must_use]
226    pub fn text(self) -> &'static str {
227        match self {
228            Self::None => "",
229            Self::Drag(text) | Self::Thrust(text) => text,
230        }
231    }
232}
233
234/// One logged flight.
235#[derive(Debug, Clone, Copy, PartialEq)]
236pub struct RealFlight {
237    /// The flight's id in the report.
238    pub id: &'static str,
239    /// The rocket, the team and the event, in words.
240    pub title: &'static str,
241    /// The design flown, by file stem under `validation/designs/`, which is also its case name in
242    /// [`MASS_FIXTURE`].
243    pub design: &'static str,
244    /// Where the example's site, weather, rail and log come from, in RocketPy 1.13.0's notebook.
245    pub source: &'static str,
246    /// The site: latitude and longitude, degrees, and elevation above sea level, m, as the example
247    /// gives them.
248    pub site: (f64, f64, f64),
249    /// The ERA5 file, under `refs/rocketpy/data/weather/`.
250    pub weather: &'static str,
251    /// The launch time the example reads the weather at, UTC: year, month, day, hour.
252    pub utc: (i32, u32, u32, u32),
253    /// The rail: length, m; inclination from the horizontal and heading from north, degrees.
254    pub rail: (f64, f64, f64),
255    /// The log.
256    pub log: Log,
257    /// The example's own drag, flown on the example's radius as a diagnostic.
258    pub example_drag: ExampleDrag,
259    /// Where the example's drag comes from, in the notebook.
260    pub example_drag_source: &'static str,
261    /// What about this flight's inputs departs from its day, in words; empty when nothing does.
262    pub note: &'static str,
263    /// Why hpr's apogee misses the log's by more than [`APOGEE_TARGET_PERCENT`], when it does.
264    pub explanation: Explanation,
265}
266
267/// The flights, in the order the report lists them.
268///
269/// Each site, time, rail and log is the example notebook's (`docs/examples/<name>_flight_sim.ipynb`
270/// in RocketPy 1.13.0, cited per flight). A notebook that gives a time zone gives local time; the
271/// UTC hour here is that time converted (Mountain Daylight Time is UTC−6, Western European Summer
272/// Time UTC+1).
273pub const FLIGHTS: [RealFlight; 7] = [
274    RealFlight {
275        id: "bella-lui",
276        title: "Bella Lui, EPFL Rocket Team, 2020 (K828FJ)",
277        design: "rocketpy-bella-lui",
278        source: "bella_lui_flight_sim.ipynb:109-111 (rail), :139-153 (site, date), :761-765 (log)",
279        site: (47.213476, 9.003336, 407.0),
280        weather: "bella_lui_weather_data_ERA5.nc",
281        utc: (2020, 2, 22, 13),
282        rail: (4.2, 89.0, 45.0),
283        log: Log {
284            file: "EPFL_Bella_Lui/bella_lui_flight_data_filtered.csv",
285            header_lines: 1,
286            time_column: 2,
287            height_column: 3,
288            meters_per_unit: 1.0,
289            until_s: None,
290            altimeter: Altimeter::AssumedBarometric(
291                "the team's own avionics, filtered by a method the example doesn't record: \
292                 barometric assumed, as hobby altimeters are",
293            ),
294            pressure: None,
295            gnss: None,
296        },
297        example_drag: ExampleDrag::Knots {
298            points: &[
299                (0.01, 0.51),
300                (0.02, 0.46),
301                (0.04, 0.43),
302                (0.28, 0.43),
303                (0.29, 0.44),
304                (0.45, 0.44),
305                (0.49, 0.46),
306            ],
307            power_on_factor: 1.0,
308        },
309        example_drag_source: "bella_lui_flight_sim.ipynb:94-95, :453-484 (power off and on, times 1)",
310        note: "",
311        explanation: Explanation::None,
312    },
313    RealFlight {
314        id: "ndrt-2020",
315        title: "NDRT 2020, Notre Dame Rocketry Team (L1395)",
316        design: "rocketpy-ndrt-2020-nose-to-tail",
317        source: "ndrt_2020_flight_sim.ipynb:115-117 (rail), :148-153 (site, date), :679-683 (log)",
318        site: (41.775447, -86.572467, 206.0),
319        weather: "ndrt_2020_weather_data_ERA5.nc",
320        utc: (2020, 2, 23, 16),
321        rail: (3.353, 90.0, 181.0),
322        log: Log {
323            file: "NDRT_2020/ndrt_2020_flight_data.csv",
324            header_lines: 1,
325            time_column: 3,
326            height_column: 4,
327            meters_per_unit: 0.3048,
328            until_s: None,
329            altimeter: Altimeter::Barometric(
330                "a Featherweight Raven (RocketPy's tests/acceptance/test_ndrt_2020_rocket.py:190), \
331                 a barometric altimeter, in feet above the pad",
332            ),
333            pressure: None,
334            gnss: None,
335        },
336        example_drag: ExampleDrag::Constant(0.44),
337        example_drag_source: "ndrt_2020_flight_sim.ipynb:91 and :316-317 (the drag coefficient, power off and on)",
338        note: "",
339        explanation: Explanation::Drag(
340            "consistent with hpr's drag. On the example's own drag, a constant 0.44 the notebook \
341             gives no source for, the apogee is within the target. hpr's own drag is lower, as the \
342             predicted-mode comparison with RocketPy flying the same constant found (+10.3% in \
343             apogee), and the design's fin edges and finish are placeholders, since the example \
344             records none.",
345        ),
346    },
347    RealFlight {
348        id: "prometheus-2022",
349        title: "Prometheus, Western Engineering, Spaceport America Cup 2022 (M1520)",
350        design: "rocketpy-prometheus-2022-generic-motor",
351        source: "prometheus_2022_flight_sim.ipynb:65-70 (site, date), :401-406 (rail), :508-526 \
352                 (log)",
353        site: (32.939377, -106.911986, 1401.0),
354        weather: "spaceport_america_pressure_levels_2023_hourly.nc",
355        utc: (2023, 6, 24, 15),
356        rail: (5.18, 80.0, 75.0),
357        log: Log {
358            file: "prometheus/2022-06-24-serial-5115-flight-0001-TeleMetrum.csv",
359            header_lines: 1,
360            time_column: 4,
361            height_column: 10,
362            meters_per_unit: 1.0,
363            until_s: Some(29.58),
364            altimeter: Altimeter::Barometric(
365                "an Altus Metrum TeleMetrum: its height column is the standard atmosphere's \
366                 altitude of its pressure column less the first row's (the gap is below)",
367            ),
368            pressure: Some((8, 1.0)),
369            gnss: Some(Gnss {
370                file: "prometheus/2022-06-24-serial-5115-flight-0001-TeleMetrum.csv",
371                header_lines: 1,
372                time_column: 4,
373                altitude_column: 21,
374                meters_per_unit: 1.0,
375            }),
376        },
377        example_drag: ExampleDrag::Knots {
378            points: &[
379                (0.15, 0.422),
380                (0.45, 0.38),
381                (0.77, 0.32),
382                (0.82, 0.3),
383                (0.88, 0.3),
384                (0.94, 0.32),
385                (0.99, 0.37),
386                (1.04, 0.44),
387                (1.24, 0.43),
388                (1.33, 0.42),
389                (1.49, 0.39),
390            ],
391            power_on_factor: 1.02,
392        },
393        example_drag_source: "prometheus_2022_flight_sim.ipynb:224-261 (`prometheus_cd_at_ma` from Mach 0.15, where it \
394                              starts to change, and power on 1.02 times it)",
395        note: "flown in the weather of 24 June 2023, a year after the flight, as RocketPy's example \
396               flies it: RocketPy has no ERA5 file of the day. The log is read to 29.58 s: a \
397               pressure transient then drops the reading 600 m and returns it 8 m above the \
398               highest reading before",
399        explanation: Explanation::Drag(
400            "consistent with hpr's drag. On the example's own drag, the team's table from Mach \
401             0.15, the apogee is within the target. The reading is built on weather a year off \
402             the flight's day (the note). Against the satellite heights, hpr's conversion reads \
403             1 to 2 points below the altimeter here and on Juno III, which flew in its own day's \
404             weather, so the wrong day's share of the miss can't be told apart.",
405        ),
406    },
407    RealFlight {
408        id: "juno-iii",
409        title: "Juno III, Projeto Jupiter, Spaceport America Cup 2023 (the team's motor)",
410        design: "rocketpy-juno-iii",
411        source: "juno3_flight_sim.ipynb:54-67 (site, date), :448-456 (rail), :543-553 (log)",
412        site: (32.939377, -106.911986, 1480.0),
413        weather: "spaceport_america_pressure_levels_2023_hourly.nc",
414        utc: (2023, 6, 23, 23),
415        rail: (5.2, 85.0, 105.0),
416        log: Log {
417            file: "juno3/cots_altimeter.csv",
418            header_lines: 1,
419            time_column: 0,
420            height_column: 1,
421            meters_per_unit: 1.0,
422            until_s: Some(24.60),
423            altimeter: Altimeter::Barometric(
424                "a Missile Works RRC3 (juno3/README.txt:21): its height column is the standard \
425                 atmosphere's altitude of its pressure column less the first row's (the gap is \
426                 below)",
427            ),
428            pressure: Some((2, 100.0)),
429            gnss: Some(Gnss {
430                file: "juno3/cots_GNSS.csv",
431                header_lines: 1,
432                time_column: 0,
433                altitude_column: 1,
434                meters_per_unit: 0.3048,
435            }),
436        },
437        example_drag: ExampleDrag::Files {
438            power_off: "juno3/drag_curve.csv",
439            power_on: "juno3/drag_curve.csv",
440            scaled_to: Some((0.6, 0.38)),
441        },
442        example_drag_source: "juno3_flight_sim.ipynb:244-251, :356-359 (`drag_curve.csv`, scaled to 0.38 at Mach 0.6 \
443                              \"from CFD analysis\")",
444        note: "the log is read to 24.60 s, as it levels off: a pressure transient there dips the \
445               reading by 94 m, then lifts it 62 m above the level within 0.3 s, to the 3213.4 m \
446               the team reports as its apogee, and the record's last two rows are corrupt. At the cut the \
447               log's own velocity column still reads 17.5 m/s up, so its apogee may be 10 m to \
448               20 m low. The thrust file's last five points are negative (-6.8 to -47.3 N); hpr, \
449               which refuses a negative thrust, reads them as zero, and RocketPy's flight holds \
450               its thrust at zero too",
451        explanation: Explanation::Thrust(
452            "consistent with the motor's impulse. The notebook reshapes the team's own motor \
453             curve (`mandioca_thrust_curve.csv`) to 5.8 s and 8800 N s, 4.9% less than the file; \
454             on the file as recorded the apogee is within the target, and on the team's drag it \
455             is not.",
456        ),
457    },
458    RealFlight {
459        id: "cavour",
460        title: "Cavour, Politecnico di Torino, EuRoC 2023 (L995)",
461        design: "rocketpy-cavour",
462        source: "cavour_flight_sim.ipynb:125-132 (site, date), :314-315 (rail), :379-389 (log)",
463        site: (39.388692, -8.287814, 150.0),
464        weather: "euroc_2023_all_windows.nc",
465        utc: (2023, 10, 13, 12),
466        rail: (12.0, 84.0, 133.0),
467        log: Log {
468            file: "polito/altimeter_cavour.csv",
469            header_lines: 1,
470            time_column: 0,
471            height_column: 1,
472            meters_per_unit: 1.0,
473            until_s: None,
474            altimeter: Altimeter::AssumedBarometric(
475                "the CATS Vega EuRoC 2023 required, sent by radio in whole meters: a Kalman \
476                 filter's estimate from a barometer and an accelerometer, barometric assumed",
477            ),
478            pressure: None,
479            gnss: None,
480        },
481        example_drag: ExampleDrag::Files {
482            power_off: "polito/drag_coefficient_power_off.csv",
483            power_on: "polito/drag_coefficient_power_on.csv",
484            scaled_to: None,
485        },
486        example_drag_source: "cavour_flight_sim.ipynb:250-251",
487        note: "",
488        explanation: Explanation::Drag(
489            "consistent with hpr's drag. On the example's own curves, labelled RASAero II, the \
490             apogee is within the target. hpr's drag is below them: at Mach 0.3, 8.3% below power \
491             off and 18.3% below power on (the aerodynamics page's comparison; the power-on gap's \
492             cause is open), and the design's fin edges are placeholders, since the example \
493             records none.",
494        ),
495    },
496    RealFlight {
497        id: "genesis",
498        title: "Genesis, EuRoC 2023 (L995)",
499        design: "rocketpy-genesis",
500        source: "genesis_flight_sim.ipynb:124-131 (site, date), :326-327 (rail), :391-406 (log)",
501        site: (39.38895, -8.28837, 160.0),
502        weather: "euroc_2023_all_windows.nc",
503        utc: (2023, 10, 12, 13),
504        rail: (12.0, 84.0, 133.0),
505        log: Log {
506            file: "genesis/flight_data_faraday.csv",
507            header_lines: 1,
508            time_column: 0,
509            height_column: 1,
510            meters_per_unit: 1.0,
511            until_s: None,
512            altimeter: Altimeter::AssumedBarometric(
513                "a filtered estimate (`filtered_altitude_AGL`), probably the CATS Vega's \
514                 barometer and accelerometer Kalman filter: barometric assumed",
515            ),
516            pressure: None,
517            gnss: None,
518        },
519        example_drag: ExampleDrag::Files {
520            power_off: "genesis/drag_coefficient_power_off.csv",
521            power_on: "genesis/drag_coefficient_power_on.csv",
522            scaled_to: None,
523        },
524        example_drag_source: "genesis_flight_sim.ipynb:241-242",
525        note: "",
526        explanation: Explanation::Drag(
527            "consistent with hpr's drag. On the example's own curves the apogee is within the \
528             target; the design's fin edges and finish are placeholders, since the example \
529             records none.",
530        ),
531    },
532    RealFlight {
533        id: "lince",
534        title: "Lince, EuRoC 2023 (M1101)",
535        design: "rocketpy-lince",
536        source: "lince_flight_sim.ipynb:123-129 (site, date), :432-437 (rail), :609-619 (log)",
537        site: (39.3897, -8.288964, 158.0),
538        weather: "euroc_2023_all_windows.nc",
539        utc: (2023, 10, 12, 10),
540        rail: (12.0, 84.0, 133.0),
541        log: Log {
542            file: "lince/main_data.csv",
543            header_lines: 1,
544            time_column: 0,
545            height_column: 1,
546            meters_per_unit: 1.0,
547            until_s: Some(26.80),
548            altimeter: Altimeter::AssumedBarometric(
549                "a filtered estimate (`filtered_altitude_AGL`, as Genesis's) from a computer \
550                 the example doesn't name: barometric assumed",
551            ),
552            pressure: None,
553            gnss: None,
554        },
555        example_drag: ExampleDrag::Files {
556            power_off: "lince/drag_coefficient_power_off.csv",
557            power_on: "lince/drag_coefficient_power_on.csv",
558            scaled_to: None,
559        },
560        example_drag_source: "lince_flight_sim.ipynb:240-241",
561        note: "the log is read to 26.80 s: past it, as the recovery fires, the filtered height swings \
562               by hundreds of meters, up to 3668.5 m; its highest reading before is the 3587 m \
563               the team reports as its apogee",
564        explanation: Explanation::None,
565    },
566];
567
568/// Why a real flight could not be read, flown or reported.
569#[derive(Debug, thiserror::Error)]
570#[non_exhaustive]
571pub enum RealFlightError {
572    /// A file could not be read.
573    #[error("{what} {path}: {source}")]
574    Io {
575        /// What was being read.
576        what: &'static str,
577        /// The file.
578        path: String,
579        /// The error.
580        #[source]
581        source: std::io::Error,
582    },
583    /// A file's content is not what the flight needs.
584    #[error("{flight}: {what}")]
585    Input {
586        /// The flight.
587        flight: String,
588        /// What is wrong, in words.
589        what: String,
590    },
591    /// hpr could not fly it.
592    #[error("{flight}: {source}")]
593    Sim {
594        /// The flight.
595        flight: String,
596        /// The error.
597        #[source]
598        source: SimError,
599    },
600}
601
602/// A file read and its SHA-256.
603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
604pub struct FileRead {
605    /// Its path, relative to the repository root.
606    pub path: String,
607    /// Its SHA-256, in hex.
608    pub sha256: String,
609}
610
611/// One flight's result, as the report keeps it.
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
613pub struct FlightRow {
614    /// The flight's id.
615    pub id: String,
616    /// The rocket, team and event.
617    pub title: String,
618    /// The design flown.
619    pub design: String,
620    /// The notebook lines the inputs come from.
621    pub source: String,
622    /// The log, the thrust file, the weather file and the design, with their digests.
623    pub files: Vec<FileRead>,
624    /// The launch time the weather is read at, UTC, ISO 8601.
625    pub weather_time_utc: String,
626    /// The log's rows read.
627    pub log_rows: usize,
628    /// The time past which the log is not read, s ([`Log::until_s`]).
629    pub log_until_s: Option<f64>,
630    /// What measured the log's height ([`Altimeter::kind`]).
631    pub altimeter: String,
632    /// Where that comes from.
633    pub altimeter_evidence: String,
634    /// The log's apogee: its highest reading, m.
635    pub log_apogee_m: f64,
636    /// hpr's apogee as the log's altimeter would read it, m: above its center of mass's starting
637    /// height, or for a barometric log, the pressure altitude of the ERA5 pressure there less the
638    /// start's.
639    pub hpr_apogee_m: f64,
640    /// hpr's apogee above its center of mass's starting height, m, whatever the altimeter.
641    pub hpr_height_apogee_m: f64,
642    /// hpr's less the log's, per cent of the log's.
643    pub apogee_error_percent: f64,
644    /// The time from the alignment height to apogee in the log, s.
645    pub log_time_to_apogee_s: f64,
646    /// The same in hpr, s.
647    pub hpr_time_to_apogee_s: f64,
648    /// The RMS of hpr's heights less the log's over the ascent, m.
649    pub trace_rms_m: f64,
650    /// That RMS as a percentage of the log's apogee.
651    pub trace_rms_percent: f64,
652    /// hpr's largest Mach number over the flight compared, in the ERA5 air: what the accuracy
653    /// census classes the flight's speed by (subsonic below 0.8, supersonic above 1.2).
654    pub hpr_max_mach: f64,
655    /// The log rows the RMS is over.
656    pub trace_rows: usize,
657    /// The thrust hpr flew, as a total impulse, N s.
658    pub total_impulse_ns: f64,
659    /// The impulse added by reading the thrust file's negative points as zero, N s.
660    pub negative_thrust_zeroed_ns: f64,
661    /// Where the example's drag comes from.
662    pub example_drag_source: String,
663    /// hpr's apogee on the example's drag, m.
664    pub example_drag_apogee_m: f64,
665    /// Its error against the log's, per cent.
666    pub example_drag_apogee_error_percent: f64,
667    /// Its trace RMS against the log's, m.
668    pub example_drag_trace_rms_m: f64,
669    /// For an example that reshapes its thrust file, the second diagnostic: the file flown as
670    /// recorded.
671    pub recorded_thrust: Option<RecordedThrust>,
672    /// For a log that keeps its pressure: the largest gap between its height column and the
673    /// standard atmosphere's reading of that pressure less the first row's, over the rows read, m.
674    pub pressure_reading_max_m: Option<f64>,
675    /// For a flight that logged a satellite altitude: its highest above its first row, m.
676    pub gnss_apogee_m: Option<f64>,
677    /// The SHA-256 of the flight's entry in [`FLIGHTS`] as Rust's `Debug` prints it: every input
678    /// the code gives it, so CI sees a changed one. `Debug` output may change with a Rust
679    /// release; CI then flags every row, and `cargo xtask real-flights` rewrites them.
680    pub inputs_sha256: String,
681    /// What departs from the day, in words.
682    pub note: String,
683    /// What the explanation claims ([`Explanation::kind`]), empty when there is none.
684    pub explanation_kind: String,
685    /// Why the apogee misses by more than the target, when it does.
686    pub explanation: String,
687}
688
689/// The flight on the thrust file as recorded, without the example's reshape.
690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
691pub struct RecordedThrust {
692    /// The total impulse flown, N s: the file's, negative points read as zero, clipped at the
693    /// example's burn time where it gives one.
694    pub impulse_ns: f64,
695    /// hpr's apogee, read as the log's altimeter reads, m.
696    pub apogee_m: f64,
697    /// Its error against the log's, per cent.
698    pub apogee_error_percent: f64,
699}
700
701/// What the report sums up.
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
703pub struct Summary {
704    /// Flights compared.
705    pub flights: usize,
706    /// The mean of the absolute apogee errors, per cent.
707    pub mean_absolute_apogee_error_percent: f64,
708    /// The target it is reported against, per cent.
709    pub target_percent: f64,
710    /// Whether the mean is at or below the target.
711    pub within_target: bool,
712    /// The mean apogee error with its sign, per cent: a bias shows here.
713    pub mean_apogee_error_percent: f64,
714    /// The mean absolute apogee error were every log a height, not a barometric reading, per cent:
715    /// how much the altimeters' readings matter.
716    pub mean_absolute_height_apogee_error_percent: f64,
717    /// The mean absolute apogee error with only the logs known to be barometric read so and the
718    /// assumed ones as heights, per cent.
719    pub mean_absolute_known_barometric_apogee_error_percent: f64,
720    /// The flights whose apogee misses by more than the target.
721    pub outliers: Vec<String>,
722    /// The largest trace RMS as a percentage of its log's apogee.
723    pub max_trace_rms_percent: f64,
724    /// The mean absolute apogee error on each example's own drag, per cent.
725    pub example_drag_mean_absolute_apogee_error_percent: f64,
726}
727
728/// The real-flight report.
729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
730pub struct RealFlightReport {
731    /// What wrote it.
732    pub generated_by: String,
733    /// The alignment height, m.
734    pub align_height_m: f64,
735    /// The grid hpr's heights are kept on, s.
736    pub grid_s: f64,
737    /// Each flight.
738    pub flights: Vec<FlightRow>,
739    /// The summary, computed from the rows.
740    pub summary: Summary,
741}
742
743fn read_bytes(root: &Path, relative: &str, what: &'static str) -> Result<Vec<u8>, RealFlightError> {
744    let path = root.join(relative);
745    fs::read(&path).map_err(|source| RealFlightError::Io {
746        what,
747        path: path.display().to_string(),
748        source,
749    })
750}
751
752/// The SHA-256 of `bytes`, in lower-case hex.
753pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
754    Sha256::digest(bytes)
755        .iter()
756        .fold(String::with_capacity(64), |mut out, byte| {
757            let _ = write!(out, "{byte:02x}");
758            out
759        })
760}
761
762/// Each row's `columns`, in file order: a row is split at commas and each column trimmed and
763/// parsed, and a row where one doesn't parse as a finite number is skipped.
764fn read_rows(text: &str, header_lines: usize, columns: &[usize]) -> Vec<Vec<f64>> {
765    text.lines()
766        .skip(header_lines)
767        .filter_map(|line| {
768            let fields: Vec<&str> = line.split(',').map(str::trim).collect();
769            columns
770                .iter()
771                .map(|&column| {
772                    fields
773                        .get(column)
774                        .and_then(|field| field.parse::<f64>().ok())
775                        .filter(|value| value.is_finite())
776                })
777                .collect::<Option<Vec<f64>>>()
778        })
779        .collect()
780}
781
782/// A log's rows as (time, height in meters), in file order.
783///
784/// Each row is split at commas and the two columns trimmed and parsed; a row where either doesn't
785/// parse as a number is skipped (NDRT 2020's altitude columns end before its acceleration columns
786/// do). Rows past [`Log::until_s`] are not read.
787///
788/// # Errors
789///
790/// [`RealFlightError::Input`] if fewer than two rows are read, or times decrease.
791pub fn parse_log(id: &str, log: &Log, text: &str) -> Result<Vec<(f64, f64)>, RealFlightError> {
792    let rows: Vec<(f64, f64)> = read_rows(
793        text,
794        log.header_lines,
795        &[log.time_column, log.height_column],
796    )
797    .into_iter()
798    .take_while(|row| log.until_s.is_none_or(|until| row[0] <= until))
799    .map(|row| (row[0], row[1] * log.meters_per_unit))
800    .collect();
801    let input = |what: String| RealFlightError::Input {
802        flight: id.to_owned(),
803        what,
804    };
805    if rows.len() < 2 {
806        return Err(input(format!(
807            "the log {} has {} rows",
808            log.file,
809            rows.len()
810        )));
811    }
812    if let Some(i) = rows.windows(2).position(|w| w[1].0 < w[0].0) {
813        return Err(input(format!(
814            "the log {}'s time goes back at row {}",
815            log.file,
816            i + 1
817        )));
818    }
819    Ok(rows)
820}
821
822/// A thrust file as RocketPy 1.13.0 reads it, with the example's burn options.
823///
824/// - An `.eng` file (`Motor.import_eng`, rocketpy/motors/motor.py:1130-1150): `;` starts a
825///   comment, the first line left is the description, and each later one gives a time and a
826///   thrust; a `(0, 0)` point is put first. So a file whose first data line is `0 0` has that
827///   line read as its description.
828/// - A `.csv` file: every row a time and a thrust, nothing put first.
829/// - `reshape` (`Motor.reshape_thrust_curve`, motor.py:937-960): times scaled to the burn time and
830///   moved to start at zero, then thrusts scaled so the trapezoidal integral is the impulse.
831/// - The burn time (`Motor.clip_thrust`, motor.py:985-1024): at most the last time; the points
832///   strictly inside `(0, burn time)` kept, with the curve's linear value added at each end.
833///
834/// hpr's curve refuses a negative thrust, so any is read as zero, and the impulse this removes is
835/// returned with the curve.
836///
837/// # Errors
838///
839/// [`RealFlightError::Input`] for a file with no points, a line that doesn't read, or times that
840/// decrease.
841pub fn parse_thrust(
842    id: &str,
843    name: &str,
844    text: &str,
845    burn_time_s: Option<f64>,
846    reshape: Option<(f64, f64)>,
847) -> Result<(Vec<(f64, f64)>, f64), RealFlightError> {
848    let input = |what: String| RealFlightError::Input {
849        flight: id.to_owned(),
850        what,
851    };
852    let number = |field: &str| field.trim().parse::<f64>().ok();
853    let mut points: Vec<(f64, f64)> = Vec::new();
854    if name.ends_with(".eng") {
855        points.push((0.0, 0.0));
856        let mut described = false;
857        for line in text.lines() {
858            let line = line.split(';').next().unwrap_or_default();
859            if line.trim().is_empty() {
860                continue;
861            }
862            if !described {
863                described = true;
864                continue;
865            }
866            let mut fields = line.split_whitespace();
867            match (
868                fields.next().and_then(number),
869                fields.next().and_then(number),
870            ) {
871                (Some(t), Some(f)) => points.push((t, f)),
872                _ => return Err(input(format!("{name}: the line {line:?} doesn't read"))),
873            }
874        }
875    } else {
876        for line in text.lines().filter(|line| !line.trim().is_empty()) {
877            let mut fields = line.split(',');
878            match (
879                fields.next().and_then(number),
880                fields.next().and_then(number),
881            ) {
882                (Some(t), Some(f)) => points.push((t, f)),
883                _ => return Err(input(format!("{name}: the line {line:?} doesn't read"))),
884            }
885        }
886    }
887    if points.len() < 2 || points.windows(2).any(|w| w[1].0 < w[0].0) {
888        return Err(input(format!(
889            "{name} has {} points or times that go back",
890            points.len()
891        )));
892    }
893    let integral = |points: &[(f64, f64)]| -> f64 {
894        points
895            .windows(2)
896            .map(|w| 0.5 * (w[1].0 - w[0].0) * (w[0].1 + w[1].1))
897            .sum()
898    };
899    let mut burn = burn_time_s;
900    if let Some((burn_s, impulse_ns)) = reshape {
901        let (first, last) = (points[0].0, points[points.len() - 1].0);
902        let scale = burn_s / (last - first);
903        let start = scale * first;
904        let moved: Vec<(f64, f64)> = points
905            .iter()
906            .map(|&(t, f)| (scale * t - start, f))
907            .collect();
908        let factor = impulse_ns / integral(&moved);
909        points = moved.into_iter().map(|(t, f)| (t, factor * f)).collect();
910        burn = Some(burn_s);
911    }
912    let last = points[points.len() - 1].0;
913    let first = points[0].0;
914    let end = burn.map_or(last, |b| b.min(last));
915    let at = |t: f64| -> f64 {
916        // Linear, and zero outside, as RocketPy's Function with "zero" extrapolation.
917        if t < first || t > last {
918            return 0.0;
919        }
920        let i = points.partition_point(|p| p.0 <= t).max(1) - 1;
921        let (t0, f0) = points[i];
922        match points.get(i + 1) {
923            Some(&(t1, f1)) if t1 > t0 => f0 + (f1 - f0) * (t - t0) / (t1 - t0),
924            _ => f0,
925        }
926    };
927    let begin = first.max(0.0);
928    let mut clipped = vec![(begin, at(begin))];
929    clipped.extend(points.iter().copied().filter(|p| p.0 > begin && p.0 < end));
930    clipped.push((end, at(end)));
931    let before = integral(&clipped);
932    for point in &mut clipped {
933        point.1 = point.1.max(0.0);
934    }
935    let removed = integral(&clipped) - before;
936    Ok((clipped, removed))
937}
938
939/// The first time a trace reaches `height`, linearly interpolated between its rows.
940fn crossing(rows: &[(f64, f64)], height: f64) -> Option<f64> {
941    let i = rows.iter().position(|row| row.1 >= height)?;
942    if i == 0 {
943        return None;
944    }
945    let ((t0, h0), (t1, h1)) = (rows[i - 1], rows[i]);
946    Some(t0 + (t1 - t0) * (height - h0) / (h1 - h0))
947}
948
949/// The trace's value at `t`, linearly interpolated; `None` outside it.
950fn value_at(rows: &[(f64, f64)], t: f64) -> Option<f64> {
951    let i = rows.partition_point(|row| row.0 <= t);
952    if i == 0 || i > rows.len() {
953        return None;
954    }
955    let (t0, h0) = rows[i - 1];
956    match rows.get(i) {
957        Some(&(t1, h1)) => Some(h0 + (h1 - h0) * (t - t0) / (t1 - t0)),
958        None => (t == t0).then_some(h0),
959    }
960}
961
962/// The highest row of a trace: its time and height.
963fn highest(rows: &[(f64, f64)]) -> (f64, f64) {
964    rows.iter()
965        .copied()
966        .fold((f64::NAN, f64::NEG_INFINITY), |best, row| {
967            if row.1 > best.1 { row } else { best }
968        })
969}
970
971/// What the trace comparison gives.
972#[derive(Debug, Clone, Copy, PartialEq)]
973pub struct TraceComparison {
974    /// The log's time from the alignment height to its apogee, s.
975    pub log_time_to_apogee_s: f64,
976    /// hpr's, s.
977    pub hpr_time_to_apogee_s: f64,
978    /// The RMS of hpr's heights less the log's, m.
979    pub rms_m: f64,
980    /// The log rows it is over.
981    pub rows: usize,
982}
983
984/// The ascent of `hpr` (time since ignition, height) against the log's, each aligned where it
985/// first reaches [`ALIGN_HEIGHT_M`], over the log's rows from there to the first of the two
986/// apogees.
987///
988/// `hpr_apogee_s` is hpr's apogee time from its own event, since its grid may miss the top.
989///
990/// # Errors
991///
992/// A message when a trace never reaches the alignment height or no row falls in the window.
993pub fn compare_traces(
994    log: &[(f64, f64)],
995    hpr: &[(f64, f64)],
996    hpr_apogee_s: f64,
997) -> Result<TraceComparison, String> {
998    let log_align = crossing(log, ALIGN_HEIGHT_M)
999        .ok_or_else(|| format!("the log never rises through {ALIGN_HEIGHT_M} m"))?;
1000    let hpr_align = crossing(hpr, ALIGN_HEIGHT_M)
1001        .ok_or_else(|| format!("hpr never rises through {ALIGN_HEIGHT_M} m"))?;
1002    let (log_apogee_s, _) = highest(log);
1003    let log_time = log_apogee_s - log_align;
1004    let hpr_time = hpr_apogee_s - hpr_align;
1005    let window = log_time.min(hpr_time);
1006    let mut sum = 0.0;
1007    let mut rows = 0;
1008    for &(t, h) in log {
1009        let since = t - log_align;
1010        if !(0.0..=window).contains(&since) {
1011            continue;
1012        }
1013        let Some(ours) = value_at(hpr, hpr_align + since) else {
1014            return Err(format!("hpr has no height {since} s after its alignment"));
1015        };
1016        sum += (ours - h).powi(2);
1017        rows += 1;
1018    }
1019    if rows == 0 {
1020        return Err("no log row falls between the alignment and the first apogee".to_owned());
1021    }
1022    Ok(TraceComparison {
1023        log_time_to_apogee_s: log_time,
1024        hpr_time_to_apogee_s: hpr_time,
1025        rms_m: (sum / rows as f64).sqrt(),
1026        rows,
1027    })
1028}
1029
1030/// A barometric altimeter on a pad, in the air of a day: it reads the standard atmosphere's
1031/// altitude of the day's pressure where it is, less that at the pad, `H(p(z)) − H(p(z₀))`
1032/// ([`Ussa76::pressure_altitude_m`]).
1033///
1034/// In the standard atmosphere itself it reads the geopotential height climbed. On a warmer day
1035/// the air is thinner, pressure falls more slowly with height, and it reads less than that: in
1036/// the troposphere, with the sea-level pressure unchanged, by the ratio of the standard's
1037/// sea-level temperature to the day's.
1038#[derive(Debug)]
1039pub struct Barometer<'a> {
1040    day: &'a dyn Atmosphere,
1041    site_msl_m: f64,
1042    standard: Ussa76,
1043    pad_altitude_m: f64,
1044}
1045
1046impl<'a> Barometer<'a> {
1047    /// A barometer on a pad `pad_m` above a site `site_msl_m` above sea level, in `day`'s air.
1048    ///
1049    /// # Errors
1050    ///
1051    /// [`hpr_atmos::AtmosError`] if the day has no air at the pad.
1052    pub fn on_pad(
1053        day: &'a dyn Atmosphere,
1054        site_msl_m: f64,
1055        pad_m: f64,
1056    ) -> Result<Self, hpr_atmos::AtmosError> {
1057        let standard = Ussa76::standard();
1058        let pad_altitude_m =
1059            standard.pressure_altitude_m(day.air(site_msl_m + pad_m)?.air.pressure_pa)?;
1060        Ok(Self {
1061            day,
1062            site_msl_m,
1063            standard,
1064            pad_altitude_m,
1065        })
1066    }
1067
1068    /// What it reads at `height_m` above the site, m.
1069    ///
1070    /// # Errors
1071    ///
1072    /// [`hpr_atmos::AtmosError`] if the day has no air there.
1073    pub fn reading_m(&self, height_m: f64) -> Result<f64, hpr_atmos::AtmosError> {
1074        let pressure_pa = self.day.air(self.site_msl_m + height_m)?.air.pressure_pa;
1075        Ok(self.standard.pressure_altitude_m(pressure_pa)? - self.pad_altitude_m)
1076    }
1077
1078    /// The height above the site at which it reads `reading_m`, m: the inverse of
1079    /// [`Self::reading_m`], which turns a logged barometric altitude into the height the rocket
1080    /// climbed in the day's air ([the private collection's logged apogees, ADR-184][adr-184]).
1081    ///
1082    /// [adr-184]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0184-logged-apogees-of-the-private-collection.md
1083    ///
1084    /// The reading rises with height wherever the day's pressure falls with height, so the
1085    /// height is bisected between the site and the first doubling of the reading's size that
1086    /// reads past it, to the resolution of `f64`.
1087    ///
1088    /// # Errors
1089    ///
1090    /// [`hpr_atmos::AtmosError::Domain`] for a reading that is not finite, below what the site
1091    /// itself reads, or above what the day reads 100 km up; and what [`Self::reading_m`] raises.
1092    pub fn height_m(&self, reading_m: f64) -> Result<f64, hpr_atmos::AtmosError> {
1093        const TOP_M: f64 = 100_000.0;
1094        let domain = || hpr_atmos::AtmosError::Domain {
1095            what: "barometric reading",
1096            value: reading_m,
1097        };
1098        if !reading_m.is_finite() {
1099            return Err(domain());
1100        }
1101        let mut low = 0.0;
1102        if self.reading_m(low)? > reading_m {
1103            return Err(domain());
1104        }
1105        let mut high = (2.0 * reading_m.abs()).clamp(100.0, TOP_M);
1106        while self.reading_m(high)? < reading_m {
1107            if high >= TOP_M {
1108                return Err(domain());
1109            }
1110            low = high;
1111            high = (2.0 * high).min(TOP_M);
1112        }
1113        loop {
1114            let middle = 0.5 * (low + high);
1115            if middle <= low || middle >= high {
1116                return Ok(middle);
1117            }
1118            if self.reading_m(middle)? < reading_m {
1119                low = middle;
1120            } else {
1121                high = middle;
1122            }
1123        }
1124    }
1125}
1126
1127/// Keeps hpr's height above its start on a fixed grid of the dense output.
1128struct Heights {
1129    /// The next grid point's index: its time is `GRID_S` times it.
1130    next: u32,
1131    rows: Vec<(f64, f64)>,
1132}
1133
1134impl Observer for Heights {
1135    fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
1136        loop {
1137            let t = GRID_S * f64::from(self.next);
1138            if t > step.end_s() {
1139                return Ok(());
1140            }
1141            // Steps follow one another, so a grid time before this step's start was the last
1142            // step's end, already kept.
1143            if t >= step.start_s() {
1144                let sample = step.sample(t)?;
1145                self.rows.push((t, sample.height_above_ground_m));
1146            }
1147            self.next += 1;
1148        }
1149    }
1150}
1151
1152/// The flight's case in [`MASS_FIXTURE`], the design fixture's record of the example.
1153fn fixture_case(root: &Path, flight: &RealFlight) -> Result<Value, RealFlightError> {
1154    let input = |what: String| RealFlightError::Input {
1155        flight: flight.id.to_owned(),
1156        what,
1157    };
1158    let bytes = read_bytes(root, MASS_FIXTURE, "reading the design fixture")?;
1159    let mut fixture: Value = serde_json::from_slice(&bytes)
1160        .map_err(|error| input(format!("{MASS_FIXTURE}: {error}")))?;
1161    let name = flight.design.trim_start_matches("rocketpy-");
1162    fixture
1163        .get_mut("cases")
1164        .and_then(Value::as_array_mut)
1165        .and_then(|cases| {
1166            cases
1167                .iter_mut()
1168                .find(|case| case["name"] == name)
1169                .map(Value::take)
1170        })
1171        .ok_or_else(|| input(format!("{MASS_FIXTURE} has no case {name}")))
1172}
1173
1174/// The example's own thrust file, by its path from the repository root, with its burn time and
1175/// its reshape (a burn time and an impulse), each when the example gives one.
1176type ExampleThrust = (String, Option<f64>, Option<(f64, f64)>);
1177
1178/// A file read, by its path from the repository root, and its bytes.
1179type ReadFile = (String, Vec<u8>);
1180
1181/// The example's own thrust file and burn options, from [`MASS_FIXTURE`]'s record of the case.
1182fn example_thrust(root: &Path, flight: &RealFlight) -> Result<ExampleThrust, RealFlightError> {
1183    let input = |what: String| RealFlightError::Input {
1184        flight: flight.id.to_owned(),
1185        what,
1186    };
1187    let case = fixture_case(root, flight)?;
1188    let name = flight.design.trim_start_matches("rocketpy-");
1189    let file = case["original_thrust_source"]
1190        .as_str()
1191        .ok_or_else(|| input(format!("{name} records no thrust file")))?;
1192    let options = &case["original_thrust_options"];
1193    let burn = options["burn_time"].as_f64();
1194    let reshape = match options["reshape_thrust_curve"].as_array() {
1195        Some(pair) => match (
1196            pair.first().and_then(Value::as_f64),
1197            pair.get(1).and_then(Value::as_f64),
1198        ) {
1199            (Some(time), Some(impulse)) => Some((time, impulse)),
1200            _ => {
1201                return Err(input(format!(
1202                    "{name}'s reshape is not a burn time and an impulse"
1203                )));
1204            }
1205        },
1206        None => None,
1207    };
1208    // The fixture records the file's name; RocketPy keeps motors by maker.
1209    let motors = root.join(ROCKETPY).join("data/motors");
1210    let entries = fs::read_dir(&motors).map_err(|source| RealFlightError::Io {
1211        what: "listing RocketPy's motors",
1212        path: motors.display().to_string(),
1213        source,
1214    })?;
1215    let mut found: Vec<PathBuf> = entries
1216        .filter_map(Result::ok)
1217        .map(|entry| entry.path().join(file))
1218        .filter(|path| path.is_file())
1219        .collect();
1220    found.sort();
1221    match found.as_slice() {
1222        [path] => {
1223            let relative = path
1224                .strip_prefix(root)
1225                .unwrap_or(path)
1226                .to_string_lossy()
1227                .replace('\\', "/");
1228            Ok((relative, burn, reshape))
1229        }
1230        _ => Err(input(format!(
1231            "{file} is in {} of RocketPy's motor folders, not one",
1232            found.len()
1233        ))),
1234    }
1235}
1236
1237/// A satellite log's apogee: its highest altitude above its first row, the pad's, m.
1238///
1239/// The first row must be within [`GNSS_PAD_TOLERANCE_M`] of the site's elevation (a receiver
1240/// without a fix reads far from it), and the apogee must be above the pad.
1241fn satellite_apogee_m(gnss: &Gnss, text: &str, site_elevation_m: f64) -> Result<f64, String> {
1242    let rows = read_rows(
1243        text,
1244        gnss.header_lines,
1245        &[gnss.time_column, gnss.altitude_column],
1246    );
1247    let pad = rows
1248        .first()
1249        .map(|row| row[1])
1250        .ok_or_else(|| format!("{} has no altitude", gnss.file))?;
1251    if pad == 0.0 || (pad * gnss.meters_per_unit - site_elevation_m).abs() > GNSS_PAD_TOLERANCE_M {
1252        return Err(format!(
1253            "{}'s first altitude, {} m, is not the site's {site_elevation_m} m",
1254            gnss.file,
1255            pad * gnss.meters_per_unit
1256        ));
1257    }
1258    let apogee_m = rows
1259        .iter()
1260        .map(|row| (row[1] - pad) * gnss.meters_per_unit)
1261        .fold(f64::NEG_INFINITY, f64::max);
1262    if apogee_m > 0.0 {
1263        Ok(apogee_m)
1264    } else {
1265        Err(format!("{} never rises above its first row", gnss.file))
1266    }
1267}
1268
1269/// How far a satellite log's first altitude may be from the site's elevation, m. Juno III's pad
1270/// reads 83 m below the 1480 m its example gives, which Prometheus's example gives as 1401 m at
1271/// the same coordinates, so hpr flies Juno III from about 80 m above its real pad. A first
1272/// altitude of exactly zero, a receiver without a fix, is refused wherever the site is.
1273const GNSS_PAD_TOLERANCE_M: f64 = 150.0;
1274
1275/// The largest gap between a log's height column and the standard atmosphere's reading of its
1276/// pressure column less that of the first row with one, over the rows up to [`Log::until_s`]
1277/// whose time, height and pressure all read, m.
1278fn pressure_reading_gap_m(
1279    log: &Log,
1280    column: usize,
1281    pascals_per_unit: f64,
1282    text: &str,
1283) -> Result<f64, String> {
1284    let standard = Ussa76::standard();
1285    let rows: Vec<Vec<f64>> = read_rows(
1286        text,
1287        log.header_lines,
1288        &[log.time_column, log.height_column, column],
1289    )
1290    .into_iter()
1291    .take_while(|row| log.until_s.is_none_or(|until| row[0] <= until))
1292    .collect();
1293    let altitude = |row: &[f64]| {
1294        standard
1295            .pressure_altitude_m(row[2] * pascals_per_unit)
1296            .map_err(|error| format!("{}: {error}", log.file))
1297    };
1298    let pad = altitude(
1299        rows.first()
1300            .ok_or_else(|| format!("{} has no pressure", log.file))?,
1301    )?;
1302    rows.iter().try_fold(0.0_f64, |gap, row| {
1303        Ok(gap.max((row[1] * log.meters_per_unit - (altitude(row)? - pad)).abs()))
1304    })
1305}
1306
1307/// One flight of hpr, as the log's altimeter would read it.
1308struct Flown {
1309    apogee_m: f64,
1310    height_apogee_m: f64,
1311    max_mach: f64,
1312    trace: TraceComparison,
1313    total_impulse_ns: f64,
1314}
1315
1316/// Flies one flight and compares it with its log.
1317///
1318/// # Errors
1319///
1320/// [`RealFlightError`] if a file is missing or doesn't read, or hpr can't fly it.
1321pub fn fly(root: &Path, flight: &RealFlight) -> Result<FlightRow, RealFlightError> {
1322    let input = |what: String| RealFlightError::Input {
1323        flight: flight.id.to_owned(),
1324        what,
1325    };
1326    let sim = |source: SimError| RealFlightError::Sim {
1327        flight: flight.id.to_owned(),
1328        source,
1329    };
1330    let mut files = Vec::new();
1331    let mut read = |relative: String, what: &'static str| -> Result<Vec<u8>, RealFlightError> {
1332        let bytes = read_bytes(root, &relative, what)?;
1333        files.push(FileRead {
1334            sha256: sha256_hex(&bytes),
1335            path: relative,
1336        });
1337        Ok(bytes)
1338    };
1339
1340    // The log.
1341    let log_path = format!("{ROCKETPY}/data/rockets/{}", flight.log.file);
1342    let log_bytes = read(log_path, "reading a flight log")?;
1343    let log_text = String::from_utf8_lossy(&log_bytes);
1344    let log = parse_log(flight.id, &flight.log, &log_text)?;
1345    let (log_apogee_s, log_apogee_m) = highest(&log);
1346    let pressure_reading_max_m = match flight.log.pressure {
1347        Some((column, pascals_per_unit)) => Some(
1348            pressure_reading_gap_m(&flight.log, column, pascals_per_unit, &log_text)
1349                .map_err(input)?,
1350        ),
1351        None => None,
1352    };
1353    let gnss_apogee_m = match flight.log.gnss {
1354        Some(gnss) => {
1355            let apogee_m = if gnss.file == flight.log.file {
1356                satellite_apogee_m(&gnss, &log_text, flight.site.2)
1357            } else {
1358                let path = format!("{ROCKETPY}/data/rockets/{}", gnss.file);
1359                let bytes = read(path, "reading a satellite log")?;
1360                satellite_apogee_m(&gnss, &String::from_utf8_lossy(&bytes), flight.site.2)
1361            };
1362            Some(apogee_m.map_err(input)?)
1363        }
1364        None => None,
1365    };
1366
1367    // The design, with the example's own thrust, burn options and radius from the fixture.
1368    read(MASS_FIXTURE.to_owned(), "reading the design fixture")?;
1369    let (thrust_path, burn, reshape) = example_thrust(root, flight)?;
1370    let thrust_bytes = read(thrust_path.clone(), "reading a thrust file")?;
1371    let (curve, zeroed_ns) = parse_thrust(
1372        flight.id,
1373        &thrust_path,
1374        &String::from_utf8_lossy(&thrust_bytes),
1375        burn,
1376        reshape,
1377    )?;
1378    let design_path = format!("validation/designs/{}.json", flight.design);
1379    let design_bytes = read(design_path.clone(), "reading a design")?;
1380    let design: Value = serde_json::from_slice(&design_bytes)
1381        .map_err(|error| input(format!("{design_path}: {error}")))?;
1382    let with_thrust = |curve: &[(f64, f64)]| -> Result<Rocket, RealFlightError> {
1383        let mut design = design.clone();
1384        let motor = design
1385            .pointer_mut("/configurations/0/motors/0")
1386            .and_then(Value::as_object_mut)
1387            .ok_or_else(|| input(format!("{design_path} has no motor")))?;
1388        let (times, thrusts): (Vec<f64>, Vec<f64>) = curve.iter().copied().unzip();
1389        motor.insert(
1390            "designation".to_owned(),
1391            Value::String(format!(
1392                "RocketPy's {}",
1393                thrust_path.rsplit('/').next().unwrap_or("")
1394            )),
1395        );
1396        motor
1397            .get_mut("motor")
1398            .and_then(Value::as_object_mut)
1399            .ok_or_else(|| input(format!("{design_path}'s motor has no inputs")))?
1400            .insert(
1401                "curve".to_owned(),
1402                serde_json::json!({ "times_s": times, "thrusts_n": thrusts }),
1403            );
1404        serde_json::from_value(design)
1405            .map_err(|error| input(format!("{design_path} with a thrust file's curve: {error}")))
1406    };
1407    let rocket = with_thrust(&curve)?;
1408    // Where the example reshapes its thrust file, the file as recorded, for the diagnostic.
1409    let recorded = match reshape {
1410        Some(_) => Some(with_thrust(
1411            &parse_thrust(
1412                flight.id,
1413                &thrust_path,
1414                &String::from_utf8_lossy(&thrust_bytes),
1415                burn,
1416                None,
1417            )?
1418            .0,
1419        )?),
1420        None => None,
1421    };
1422
1423    // The weather.
1424    let weather_path = format!("{ROCKETPY}/data/weather/{}", flight.weather);
1425    let weather_bytes = read(weather_path.clone(), "reading an ERA5 file")?;
1426    let file =
1427        NetCdf::parse(&weather_bytes).map_err(|error| input(format!("{weather_path}: {error}")))?;
1428    let (year, month, day, hour) = flight.utc;
1429    let time = UtcTime::from_civil(year, month, day, hour, 0, 0.0)
1430        .map_err(|error| input(error.to_string()))?;
1431    let (latitude_deg, longitude_deg, elevation_m) = flight.site;
1432    let profile = Era5Profile::read(
1433        &file,
1434        Era5Request {
1435            latitude_deg,
1436            longitude_deg,
1437            time,
1438        },
1439    )
1440    .map_err(|error| input(format!("{weather_path}: {error}")))?;
1441    let sounding = profile
1442        .sounding(WindInterpolation::Components)
1443        .map_err(|error| input(format!("{weather_path}: {error}")))?;
1444    let wind = sounding
1445        .wind()
1446        .ok_or_else(|| input(format!("{weather_path} gives no wind")))?
1447        .clone();
1448
1449    let site = Geodetic::from_degrees(latitude_deg, longitude_deg, elevation_m)
1450        .map_err(|error| sim(error.into()))?;
1451    let earth = Earth::wgs84(site).map_err(|error| sim(error.into()))?;
1452    let weather = sounding.clone();
1453    let environment = Environment::new(earth, sounding, wind);
1454    let (length_m, inclination_deg, heading_deg) = flight.rail;
1455    let rail = Rail {
1456        length_m,
1457        azimuth_rad: heading_deg.to_radians(),
1458        elevation_rad: inclination_deg.to_radians(),
1459        roll_rad: 0.0,
1460        friction_coefficient: 0.0,
1461    };
1462    let settings = FlightSettings {
1463        max_time_s: log_apogee_s + PAST_APOGEE_S,
1464        ..FlightSettings::default()
1465    };
1466
1467    // The example's drag, on the example's radius (the design fixture's record of the rocket).
1468    let (table, drag_files) = example_drag_table(root, flight)?;
1469    for (relative, bytes) in drag_files {
1470        files.push(FileRead {
1471            sha256: sha256_hex(&bytes),
1472            path: relative,
1473        });
1474    }
1475
1476    let fly_once = |rocket: &Rocket, table: Option<DragTable>| -> Result<Flown, RealFlightError> {
1477        let simulation =
1478            Simulation::new(rocket, "example", environment.clone(), rail, settings).map_err(sim)?;
1479        let simulation = match table {
1480            Some(table) => simulation.with_drag_table(table),
1481            None => simulation,
1482        };
1483        let total_impulse_ns = simulation
1484            .assembly()
1485            .motors
1486            .iter()
1487            .map(|motor| motor.mounted.motor.curve().total_impulse_ns())
1488            .sum();
1489        let mut heights = Heights {
1490            next: 0,
1491            rows: Vec::new(),
1492        };
1493        let mut metrics = FlightMetrics::new();
1494        let result = simulation
1495            .run(&mut (&mut heights, &mut metrics))
1496            .map_err(sim)?;
1497        let max_mach = metrics
1498            .summary(&result, &environment)
1499            .map_err(sim)?
1500            .max_mach
1501            .ok_or_else(|| input("hpr's flight has no Mach number".to_owned()))?
1502            .value;
1503        let apogee = result
1504            .event(EventKind::Apogee)
1505            .ok_or_else(|| input("hpr's flight has no apogee".to_owned()))?
1506            .sample;
1507        let start_m = heights
1508            .rows
1509            .first()
1510            .map(|row| row.1)
1511            .ok_or_else(|| input("hpr's flight has no steps".to_owned()))?;
1512        // What the log's altimeter would read at a height above the site, above its start.
1513        let atmos = |error: hpr_atmos::AtmosError| input(format!("{weather_path}: {error}"));
1514        let barometer = Barometer::on_pad(&weather, elevation_m, start_m).map_err(atmos)?;
1515        let reading = |height_m: f64| -> Result<f64, RealFlightError> {
1516            match flight.log.altimeter {
1517                Altimeter::Height(_) => Ok(height_m - start_m),
1518                Altimeter::Barometric(_) | Altimeter::AssumedBarometric(_) => {
1519                    barometer.reading_m(height_m).map_err(atmos)
1520                }
1521            }
1522        };
1523        let hpr = heights
1524            .rows
1525            .iter()
1526            .map(|&(t, h)| Ok((t, reading(h)?)))
1527            .collect::<Result<Vec<(f64, f64)>, RealFlightError>>()?;
1528        let trace = compare_traces(&log, &hpr, apogee.time_s).map_err(input)?;
1529        Ok(Flown {
1530            apogee_m: reading(apogee.height_above_ground_m)?,
1531            height_apogee_m: apogee.height_above_ground_m - start_m,
1532            max_mach,
1533            trace,
1534            total_impulse_ns,
1535        })
1536    };
1537    let own = fly_once(&rocket, None)?;
1538    let on_example_drag = fly_once(&rocket, Some(table))?;
1539    let on_recorded_thrust = recorded
1540        .as_ref()
1541        .map(|rocket| fly_once(rocket, None))
1542        .transpose()?;
1543    let (hpr_apogee_m, trace, total_impulse_ns) = (own.apogee_m, own.trace, own.total_impulse_ns);
1544    let (drag_apogee_m, drag_trace) = (on_example_drag.apogee_m, on_example_drag.trace);
1545    let error = |apogee_m: f64| percent_of(apogee_m, log_apogee_m);
1546
1547    Ok(FlightRow {
1548        id: flight.id.to_owned(),
1549        title: flight.title.to_owned(),
1550        design: flight.design.to_owned(),
1551        source: flight.source.to_owned(),
1552        files,
1553        weather_time_utc: format!("{year:04}-{month:02}-{day:02}T{hour:02}:00Z"),
1554        log_rows: log.len(),
1555        log_until_s: flight.log.until_s,
1556        altimeter: flight.log.altimeter.kind().to_owned(),
1557        altimeter_evidence: flight.log.altimeter.evidence().to_owned(),
1558        log_apogee_m,
1559        hpr_apogee_m,
1560        hpr_height_apogee_m: own.height_apogee_m,
1561        apogee_error_percent: error(hpr_apogee_m),
1562        log_time_to_apogee_s: trace.log_time_to_apogee_s,
1563        hpr_time_to_apogee_s: trace.hpr_time_to_apogee_s,
1564        trace_rms_m: trace.rms_m,
1565        trace_rms_percent: 100.0 * trace.rms_m / log_apogee_m,
1566        hpr_max_mach: own.max_mach,
1567        trace_rows: trace.rows,
1568        total_impulse_ns,
1569        negative_thrust_zeroed_ns: zeroed_ns,
1570        example_drag_source: flight.example_drag_source.to_owned(),
1571        example_drag_apogee_m: drag_apogee_m,
1572        example_drag_apogee_error_percent: error(drag_apogee_m),
1573        example_drag_trace_rms_m: drag_trace.rms_m,
1574        recorded_thrust: on_recorded_thrust.map(|flown| RecordedThrust {
1575            impulse_ns: flown.total_impulse_ns,
1576            apogee_m: flown.apogee_m,
1577            apogee_error_percent: error(flown.apogee_m),
1578        }),
1579        pressure_reading_max_m,
1580        gnss_apogee_m,
1581        inputs_sha256: sha256_hex(format!("{flight:?}").as_bytes()),
1582        note: flight.note.to_owned(),
1583        explanation_kind: flight.explanation.kind().to_owned(),
1584        explanation: flight.explanation.text().to_owned(),
1585    })
1586}
1587
1588/// The first row of each run of rows with the same Mach number, as `cargo xtask aero` reads
1589/// RocketPy's curves: Cavour's repeats 13 Mach numbers below 0.107, some with values 0.001 apart.
1590fn first_row_per_mach(rows: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
1591    let mut kept: Vec<(f64, f64)> = Vec::with_capacity(rows.len());
1592    for row in rows {
1593        if kept.last().is_none_or(|last| last.0 != row.0) {
1594            kept.push(row);
1595        }
1596    }
1597    kept
1598}
1599
1600/// The example's drag as a table on its radius, and the files it was read from.
1601fn example_drag_table(
1602    root: &Path,
1603    flight: &RealFlight,
1604) -> Result<(DragTable, Vec<ReadFile>), RealFlightError> {
1605    let input = |what: String| RealFlightError::Input {
1606        flight: flight.id.to_owned(),
1607        what,
1608    };
1609    let table = |points: Vec<(f64, f64)>| -> Result<Table1D, RealFlightError> {
1610        let (machs, coefficients): (Vec<f64>, Vec<f64>) = points.into_iter().unzip();
1611        Table1D::new(
1612            machs,
1613            coefficients,
1614            Interpolation::Linear,
1615            Extrapolation::Clamp,
1616        )
1617        .map_err(|error| input(format!("the example's drag: {error}")))
1618    };
1619    let mut files = Vec::new();
1620    let (power_off, power_on) = match flight.example_drag {
1621        ExampleDrag::Constant(cd) => (table(vec![(0.0, cd), (1.0, cd)])?, None),
1622        ExampleDrag::Knots {
1623            points,
1624            power_on_factor,
1625        } => (
1626            table(points.to_vec())?,
1627            Some(table(
1628                points
1629                    .iter()
1630                    .map(|&(mach, cd)| (mach, power_on_factor * cd))
1631                    .collect(),
1632            )?),
1633        ),
1634        ExampleDrag::Files {
1635            power_off,
1636            power_on,
1637            scaled_to,
1638        } => {
1639            let mut curves = Vec::new();
1640            for file in [power_off, power_on] {
1641                let relative = format!("{ROCKETPY}/data/rockets/{file}");
1642                let bytes = read_bytes(root, &relative, "reading a drag curve")?;
1643                let mut rows = Vec::new();
1644                for line in String::from_utf8_lossy(&bytes).lines() {
1645                    let mut fields = line.split(',').map(str::trim);
1646                    match (
1647                        fields.next().map(str::parse::<f64>),
1648                        fields.next().map(str::parse::<f64>),
1649                    ) {
1650                        (Some(Ok(mach)), Some(Ok(cd))) => rows.push((mach, cd)),
1651                        _ if line.trim().is_empty() => {}
1652                        _ => return Err(input(format!("{file}: the line {line:?} doesn't read"))),
1653                    }
1654                }
1655                curves.push(first_row_per_mach(rows));
1656                if power_on != power_off || files.is_empty() {
1657                    files.push((relative, bytes));
1658                }
1659            }
1660            let [off, on] = <[Vec<(f64, f64)>; 2]>::try_from(curves)
1661                .map_err(|_| input("two drag curves".to_owned()))?;
1662            let off_table = table(off.clone())?;
1663            let factor = match scaled_to {
1664                Some((mach, cd)) => {
1665                    cd / off_table
1666                        .eval(mach)
1667                        .map_err(|error| input(error.to_string()))?
1668                }
1669                None => 1.0,
1670            };
1671            let scale =
1672                |rows: Vec<(f64, f64)>| rows.into_iter().map(|(m, c)| (m, factor * c)).collect();
1673            (table(scale(off))?, Some(table(scale(on))?))
1674        }
1675    };
1676    let radius_m = example_radius_m(root, flight)?;
1677    Ok((
1678        DragTable::new(power_off, power_on)
1679            .with_reference_diameter_m(2.0 * radius_m)
1680            .map_err(|error| input(format!("the example's drag: {error}")))?,
1681        files,
1682    ))
1683}
1684
1685/// The example rocket's radius, from [`MASS_FIXTURE`]'s record of it.
1686fn example_radius_m(root: &Path, flight: &RealFlight) -> Result<f64, RealFlightError> {
1687    let case = fixture_case(root, flight)?;
1688    case["rocket"]["radius"]
1689        .as_f64()
1690        .ok_or_else(|| RealFlightError::Input {
1691            flight: flight.id.to_owned(),
1692            what: format!("{MASS_FIXTURE} records no radius"),
1693        })
1694}
1695
1696/// `value` less `reference`, per cent of `reference`.
1697fn percent_of(value: f64, reference: f64) -> f64 {
1698    100.0 * (value - reference) / reference
1699}
1700
1701/// The summary of `rows`.
1702#[must_use]
1703pub fn summarise(rows: &[FlightRow]) -> Summary {
1704    let n = rows.len().max(1) as f64;
1705    let mean_absolute = rows
1706        .iter()
1707        .map(|row| row.apogee_error_percent.abs())
1708        .sum::<f64>()
1709        / n;
1710    Summary {
1711        flights: rows.len(),
1712        mean_absolute_apogee_error_percent: mean_absolute,
1713        target_percent: APOGEE_TARGET_PERCENT,
1714        within_target: mean_absolute <= APOGEE_TARGET_PERCENT,
1715        mean_apogee_error_percent: rows.iter().map(|row| row.apogee_error_percent).sum::<f64>() / n,
1716        mean_absolute_height_apogee_error_percent: rows
1717            .iter()
1718            .map(|row| percent_of(row.hpr_height_apogee_m, row.log_apogee_m).abs())
1719            .sum::<f64>()
1720            / n,
1721        mean_absolute_known_barometric_apogee_error_percent: rows
1722            .iter()
1723            .map(|row| {
1724                let apogee_m = if row.altimeter == Altimeter::AssumedBarometric("").kind() {
1725                    row.hpr_height_apogee_m
1726                } else {
1727                    row.hpr_apogee_m
1728                };
1729                percent_of(apogee_m, row.log_apogee_m).abs()
1730            })
1731            .sum::<f64>()
1732            / n,
1733        outliers: rows
1734            .iter()
1735            .filter(|row| row.apogee_error_percent.abs() > APOGEE_TARGET_PERCENT)
1736            .map(|row| row.id.clone())
1737            .collect(),
1738        max_trace_rms_percent: rows
1739            .iter()
1740            .map(|row| row.trace_rms_percent)
1741            .fold(0.0, f64::max),
1742        example_drag_mean_absolute_apogee_error_percent: rows
1743            .iter()
1744            .map(|row| row.example_drag_apogee_error_percent.abs())
1745            .sum::<f64>()
1746            / n,
1747    }
1748}
1749
1750/// Flies every flight in [`FLIGHTS`] and builds the report.
1751///
1752/// # Errors
1753///
1754/// The first flight's [`RealFlightError`].
1755pub fn run(root: &Path) -> Result<RealFlightReport, RealFlightError> {
1756    let flights = FLIGHTS
1757        .iter()
1758        .map(|flight| fly(root, flight))
1759        .collect::<Result<Vec<_>, _>>()?;
1760    let summary = summarise(&flights);
1761    Ok(RealFlightReport {
1762        generated_by: "cargo xtask real-flights".to_owned(),
1763        align_height_m: ALIGN_HEIGHT_M,
1764        grid_s: GRID_S,
1765        flights,
1766        summary,
1767    })
1768}
1769
1770impl RealFlightReport {
1771    /// Whether the report holds together without the files it was flown from, as CI checks it:
1772    /// each row's percentages are its meters', the summary is the rows', every outlier has an
1773    /// explanation that holds and no other flight has one, and the page is this report's
1774    /// rendering.
1775    ///
1776    /// # Errors
1777    ///
1778    /// The first thing that doesn't hold, in words.
1779    pub fn check_consistent(&self, markdown: &str) -> Result<(), String> {
1780        if self.summary != summarise(&self.flights) {
1781            return Err("the summary is not the rows'".to_owned());
1782        }
1783        let same = crate::report::same_but_for_platform_rounding;
1784        for row in &self.flights {
1785            for (what, kept, derived) in [
1786                (
1787                    "apogee error",
1788                    row.apogee_error_percent,
1789                    percent_of(row.hpr_apogee_m, row.log_apogee_m),
1790                ),
1791                (
1792                    "apogee error on the example's drag",
1793                    row.example_drag_apogee_error_percent,
1794                    percent_of(row.example_drag_apogee_m, row.log_apogee_m),
1795                ),
1796                (
1797                    "trace RMS share",
1798                    row.trace_rms_percent,
1799                    100.0 * row.trace_rms_m / row.log_apogee_m,
1800                ),
1801                (
1802                    "apogee error on the recorded thrust",
1803                    row.recorded_thrust
1804                        .as_ref()
1805                        .map_or(0.0, |flown| flown.apogee_error_percent),
1806                    row.recorded_thrust
1807                        .as_ref()
1808                        .map_or(0.0, |flown| percent_of(flown.apogee_m, row.log_apogee_m)),
1809                ),
1810            ] {
1811                if !same(kept, derived) {
1812                    return Err(format!(
1813                        "{}: the {what} is {kept}, its meters give {derived}",
1814                        row.id
1815                    ));
1816                }
1817            }
1818            let outside = row.apogee_error_percent.abs() > APOGEE_TARGET_PERCENT;
1819            if outside == row.explanation.trim().is_empty() {
1820                return Err(format!(
1821                    "{}: {}",
1822                    row.id,
1823                    if outside {
1824                        "an outlier with no explanation"
1825                    } else {
1826                        "an explanation on a flight within the target"
1827                    }
1828                ));
1829            }
1830            let (own, example) = (
1831                row.apogee_error_percent,
1832                row.example_drag_apogee_error_percent,
1833            );
1834            let recorded = row
1835                .recorded_thrust
1836                .as_ref()
1837                .map(|flown| flown.apogee_error_percent);
1838            let holds = match row.explanation_kind.as_str() {
1839                "" => !outside,
1840                "drag" => {
1841                    example.abs() <= APOGEE_TARGET_PERCENT
1842                        && recorded.is_none_or(|error| error.abs() > APOGEE_TARGET_PERCENT)
1843                }
1844                "thrust" => {
1845                    recorded.is_some_and(|error| error.abs() <= APOGEE_TARGET_PERCENT)
1846                        && example.abs() > APOGEE_TARGET_PERCENT
1847                }
1848                kind => return Err(format!("{}: no explanation is called {kind:?}", row.id)),
1849            };
1850            if !holds {
1851                return Err(format!(
1852                    "{}: the explanation {:?} doesn't hold: {own:+.3}% on hpr's drag, \
1853                     {example:+.3}% on the example's, {recorded:?}% on the recorded thrust",
1854                    row.id, row.explanation_kind
1855                ));
1856            }
1857        }
1858        if markdown != self.to_markdown() {
1859            return Err(format!("{REPORT_MD} is not {REPORT_JSON}'s rendering"));
1860        }
1861        Ok(())
1862    }
1863
1864    /// Whether `other`, a run of today, reproduces this committed report: the same flights, files,
1865    /// words and counts, and numbers the same but for the platform's last digits.
1866    ///
1867    /// # Errors
1868    ///
1869    /// The first difference, in words.
1870    pub fn reproduces(&self, other: &RealFlightReport) -> Result<(), String> {
1871        let same = crate::report::same_but_for_platform_rounding;
1872        let recorded = |row: &FlightRow, value: fn(&RecordedThrust) -> f64| {
1873            row.recorded_thrust.as_ref().map_or(0.0, value)
1874        };
1875        if self.flights.len() != other.flights.len() {
1876            return Err(format!(
1877                "{} flights committed, {} flown",
1878                self.flights.len(),
1879                other.flights.len()
1880            ));
1881        }
1882        for (a, b) in self.flights.iter().zip(&other.flights) {
1883            let differs = [
1884                ("id", a.id != b.id),
1885                ("title", a.title != b.title),
1886                ("design", a.design != b.design),
1887                ("source", a.source != b.source),
1888                ("files read or their digests", a.files != b.files),
1889                ("weather time", a.weather_time_utc != b.weather_time_utc),
1890                ("log rows", a.log_rows != b.log_rows),
1891                ("log cut", a.log_until_s != b.log_until_s),
1892                ("altimeter", a.altimeter != b.altimeter),
1893                (
1894                    "altimeter's evidence",
1895                    a.altimeter_evidence != b.altimeter_evidence,
1896                ),
1897                ("trace rows", a.trace_rows != b.trace_rows),
1898                ("note", a.note != b.note),
1899                (
1900                    "explanation's kind",
1901                    a.explanation_kind != b.explanation_kind,
1902                ),
1903                ("explanation", a.explanation != b.explanation),
1904                (
1905                    "example drag's source",
1906                    a.example_drag_source != b.example_drag_source,
1907                ),
1908                ("inputs", a.inputs_sha256 != b.inputs_sha256),
1909                (
1910                    "recorded-thrust flight",
1911                    a.recorded_thrust.is_some() != b.recorded_thrust.is_some(),
1912                ),
1913                (
1914                    "pressure column",
1915                    a.pressure_reading_max_m.is_some() != b.pressure_reading_max_m.is_some(),
1916                ),
1917                (
1918                    "satellite log",
1919                    a.gnss_apogee_m.is_some() != b.gnss_apogee_m.is_some(),
1920                ),
1921            ];
1922            if let Some((what, _)) = differs.iter().find(|(_, differ)| *differ) {
1923                return Err(format!("{}: the {what} differs", a.id));
1924            }
1925            for (what, x, y) in [
1926                ("log apogee", a.log_apogee_m, b.log_apogee_m),
1927                ("hpr apogee", a.hpr_apogee_m, b.hpr_apogee_m),
1928                (
1929                    "hpr apogee as a height",
1930                    a.hpr_height_apogee_m,
1931                    b.hpr_height_apogee_m,
1932                ),
1933                (
1934                    "apogee error",
1935                    a.apogee_error_percent,
1936                    b.apogee_error_percent,
1937                ),
1938                (
1939                    "log time to apogee",
1940                    a.log_time_to_apogee_s,
1941                    b.log_time_to_apogee_s,
1942                ),
1943                (
1944                    "hpr time to apogee",
1945                    a.hpr_time_to_apogee_s,
1946                    b.hpr_time_to_apogee_s,
1947                ),
1948                ("trace RMS", a.trace_rms_m, b.trace_rms_m),
1949                ("trace RMS share", a.trace_rms_percent, b.trace_rms_percent),
1950                ("hpr's largest Mach number", a.hpr_max_mach, b.hpr_max_mach),
1951                ("total impulse", a.total_impulse_ns, b.total_impulse_ns),
1952                (
1953                    "negative thrust zeroed",
1954                    a.negative_thrust_zeroed_ns,
1955                    b.negative_thrust_zeroed_ns,
1956                ),
1957                (
1958                    "apogee on the example's drag",
1959                    a.example_drag_apogee_m,
1960                    b.example_drag_apogee_m,
1961                ),
1962                (
1963                    "error on the example's drag",
1964                    a.example_drag_apogee_error_percent,
1965                    b.example_drag_apogee_error_percent,
1966                ),
1967                (
1968                    "trace RMS on the example's drag",
1969                    a.example_drag_trace_rms_m,
1970                    b.example_drag_trace_rms_m,
1971                ),
1972                (
1973                    "recorded thrust's impulse",
1974                    recorded(a, |flown| flown.impulse_ns),
1975                    recorded(b, |flown| flown.impulse_ns),
1976                ),
1977                (
1978                    "apogee on the recorded thrust",
1979                    recorded(a, |flown| flown.apogee_m),
1980                    recorded(b, |flown| flown.apogee_m),
1981                ),
1982                (
1983                    "error on the recorded thrust",
1984                    recorded(a, |flown| flown.apogee_error_percent),
1985                    recorded(b, |flown| flown.apogee_error_percent),
1986                ),
1987                (
1988                    "height column's gap from its pressure",
1989                    a.pressure_reading_max_m.unwrap_or(0.0),
1990                    b.pressure_reading_max_m.unwrap_or(0.0),
1991                ),
1992                (
1993                    "satellite apogee",
1994                    a.gnss_apogee_m.unwrap_or(0.0),
1995                    b.gnss_apogee_m.unwrap_or(0.0),
1996                ),
1997            ] {
1998                if !same(x, y) {
1999                    return Err(format!("{}: the {what} is {x} committed, {y} flown", a.id));
2000                }
2001            }
2002        }
2003        if (self.align_height_m, self.grid_s) != (other.align_height_m, other.grid_s)
2004            || self.generated_by != other.generated_by
2005        {
2006            return Err("the method differs".to_owned());
2007        }
2008        Ok(())
2009    }
2010
2011    /// The report as a page.
2012    #[must_use]
2013    pub fn to_markdown(&self) -> String {
2014        let mut out = String::new();
2015        let s = &self.summary;
2016        let _ = writeln!(out, "# hpr against the logs of real flights\n");
2017        let _ = writeln!(
2018            out,
2019            "Written by `{}` ([M2.3b][m2-3b], decision [ADR-082][adr-082]). How it is done, \
2020             and what it means, is on the [documentation site][site].\n\n\
2021             - **What is flown:** {} of the rockets RocketPy's documentation flies against their \
2022             teams' altitude logs. hpr flies each with its own aerodynamics, on the example's \
2023             own thrust file, from the example's rail and site, in the ERA5 weather the example \
2024             reads. The log is the reference.\n\
2025             - **Heights:** a barometric altimeter reads the standard atmosphere's altitude of \
2026             the pressure it measures, less the pad's. hpr's height is read the same way, from \
2027             the ERA5 pressure at its center of mass, less the start's. Each log is read up to \
2028             its apogee, stopping before the recovery's pressure transients.\n\
2029             - **Trace RMS:** over the ascent, both clocks aligned where the trace first \
2030             reaches {} m, until the first of the two apogees.\n\
2031             - **Files:** the logs, thrust files and weather files are read from the pinned \
2032             RocketPy checkout and never committed; each one's SHA-256 is in the JSON.\n",
2033            self.generated_by,
2034            self.flights.len(),
2035            fmt_number(self.align_height_m, 0)
2036        );
2037        let _ = writeln!(
2038            out,
2039            "[m2-3b]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-3b\n\
2040             [adr-082]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-082-real-flights-read-from-refs-compared-over-the-ascent-with-checked-explanations-2026-09-26\n\
2041             [site]: https://nrdptel.github.io/hpr-sim/accuracy.html#real-flights\n"
2042        );
2043        let _ = writeln!(out, "## Summary\n");
2044        let _ = writeln!(
2045            out,
2046            "- Flights: {}.\n- Mean absolute apogee error: {}% against a target of {}%: {}.\n\
2047             - Mean apogee error with its sign: {}%.\n- Mean absolute apogee error were every \
2048             log a height, not a barometric reading: {}%.\n- Mean absolute apogee error with \
2049             only the logs known to be barometric read so, the assumed ones as heights: {}%.\n\
2050             - Largest trace RMS: {}% of its log's apogee.\n- Outside the target: {}.\n- On \
2051             each example's own drag, the diagnostic flight: mean absolute apogee error {}%.\n",
2052            s.flights,
2053            fmt_number(s.mean_absolute_apogee_error_percent, 2),
2054            fmt_number(s.target_percent, 0),
2055            if s.within_target {
2056                "within target"
2057            } else {
2058                "outside target"
2059            },
2060            fmt_signed(s.mean_apogee_error_percent, 2),
2061            fmt_number(s.mean_absolute_height_apogee_error_percent, 2),
2062            fmt_number(s.mean_absolute_known_barometric_apogee_error_percent, 2),
2063            fmt_number(s.max_trace_rms_percent, 2),
2064            if s.outliers.is_empty() {
2065                "none".to_owned()
2066            } else {
2067                s.outliers.join(", ")
2068            },
2069            fmt_number(s.example_drag_mean_absolute_apogee_error_percent, 2)
2070        );
2071        let _ = writeln!(out, "## Flights\n");
2072        let _ = writeln!(
2073            out,
2074            "| flight | altimeter | log apogee (m) | hpr apogee (m) | apogee error | hpr apogee \
2075             as a height (m) | log time to apogee (s) | hpr time to apogee (s) | trace RMS (m) | \
2076             trace RMS (% of apogee) | rows | hpr's largest Mach |"
2077        );
2078        let _ = writeln!(
2079            out,
2080            "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"
2081        );
2082        for row in &self.flights {
2083            let _ = writeln!(
2084                out,
2085                "| {} | {} | {} | {} | {}% | {} | {} | {} | {} | {}% | {} | {} |",
2086                row.title,
2087                row.altimeter,
2088                fmt_number(row.log_apogee_m, 1),
2089                fmt_number(row.hpr_apogee_m, 1),
2090                fmt_signed(row.apogee_error_percent, 2),
2091                fmt_number(row.hpr_height_apogee_m, 1),
2092                fmt_number(row.log_time_to_apogee_s, 2),
2093                fmt_number(row.hpr_time_to_apogee_s, 2),
2094                fmt_number(row.trace_rms_m, 1),
2095                fmt_number(row.trace_rms_percent, 2),
2096                row.trace_rows,
2097                fmt_number(row.hpr_max_mach, 3)
2098            );
2099        }
2100        let _ = writeln!(out);
2101        let _ = writeln!(out, "## On each example's own drag\n");
2102        let _ = writeln!(
2103            out,
2104            "A diagnostic, not a prediction: the same flight with hpr's zero-lift drag replaced \
2105             by the drag the example's notebook specifies (a team's estimate: a table, a curve \
2106             from RASAero II or CFD, or a constant), on the example's radius. hpr's normal force \
2107             is its own in both. Where the error falls within the target, the miss is consistent \
2108             with hpr's drag; where it doesn't, it is elsewhere. The teams' drags are estimates \
2109             too, and one may have been tuned to its flight.\n"
2110        );
2111        let _ = writeln!(
2112            out,
2113            "| flight | example's drag | apogee (m) | apogee error | trace RMS (m) |"
2114        );
2115        let _ = writeln!(out, "|---|---|---:|---:|---:|");
2116        for row in &self.flights {
2117            let _ = writeln!(
2118                out,
2119                "| {} | {} | {} | {}% | {} |",
2120                row.title,
2121                row.example_drag_source,
2122                fmt_number(row.example_drag_apogee_m, 1),
2123                fmt_signed(row.example_drag_apogee_error_percent, 2),
2124                fmt_number(row.example_drag_trace_rms_m, 1)
2125            );
2126        }
2127        let _ = writeln!(out);
2128        if self.flights.iter().any(|row| row.recorded_thrust.is_some()) {
2129            let _ = writeln!(out, "## On the thrust file as recorded\n");
2130            let _ = writeln!(
2131                out,
2132                "A second diagnostic, where an example reshapes its thrust file to a burn time and \
2133                 a total impulse: the same flight on the file as recorded (negative points read as \
2134                 zero), on hpr's own drag. Where the error falls within the target and the \
2135                 example's drag doesn't bring it there, the miss is consistent with the impulse \
2136                 the example sets.\n"
2137            );
2138            let _ = writeln!(
2139                out,
2140                "| flight | impulse as recorded (N s) | impulse reshaped (N s) | apogee (m) | apogee \
2141                 error |"
2142            );
2143            let _ = writeln!(out, "|---|---:|---:|---:|---:|");
2144            for row in &self.flights {
2145                if let Some(flown) = &row.recorded_thrust {
2146                    let _ = writeln!(
2147                        out,
2148                        "| {} | {} | {} | {} | {}% |",
2149                        row.title,
2150                        fmt_number(flown.impulse_ns, 1),
2151                        fmt_number(row.total_impulse_ns, 1),
2152                        fmt_number(flown.apogee_m, 1),
2153                        fmt_signed(flown.apogee_error_percent, 2)
2154                    );
2155                }
2156            }
2157            let _ = writeln!(out);
2158        }
2159        if self
2160            .flights
2161            .iter()
2162            .any(|row| row.pressure_reading_max_m.is_some() || row.gnss_apogee_m.is_some())
2163        {
2164            let _ = writeln!(
2165                out,
2166                "## Against the logs' own pressure and satellite heights\n"
2167            );
2168            let _ = writeln!(
2169                out,
2170                "Where a log keeps the pressure its height was read from, the height column's \
2171                 largest gap from the standard atmosphere's reading of that pressure shows how the \
2172                 altimeter reads. Where the flight logged a satellite (GNSS) altitude, its apogee \
2173                 above the pad is a geometric height: the log's barometric apogee over it is what \
2174                 the day's air did to the reading, beside what hpr's conversion does to its own \
2175                 apogee.\n"
2176            );
2177            let _ = writeln!(
2178                out,
2179                "| flight | height against its pressure (largest gap, m) | satellite apogee (m) | \
2180                 log apogee over it | hpr's reading over its height |"
2181            );
2182            let _ = writeln!(out, "|---|---:|---:|---:|---:|");
2183            let or_none = |value: Option<String>| value.unwrap_or_else(|| "n/a".to_owned());
2184            for row in self
2185                .flights
2186                .iter()
2187                .filter(|row| row.pressure_reading_max_m.is_some() || row.gnss_apogee_m.is_some())
2188            {
2189                let _ = writeln!(
2190                    out,
2191                    "| {} | {} | {} | {} | {} |",
2192                    row.title,
2193                    or_none(row.pressure_reading_max_m.map(|gap| fmt_number(gap, 3))),
2194                    or_none(row.gnss_apogee_m.map(|apogee| fmt_number(apogee, 1))),
2195                    or_none(
2196                        row.gnss_apogee_m
2197                            .map(|apogee| fmt_number(row.log_apogee_m / apogee, 3))
2198                    ),
2199                    fmt_number(row.hpr_apogee_m / row.hpr_height_apogee_m, 3)
2200                );
2201            }
2202            let _ = writeln!(out);
2203        }
2204        let _ = writeln!(out, "## Each flight's inputs\n");
2205        for row in &self.flights {
2206            let _ = writeln!(
2207                out,
2208                "- **{}** (`{}`): weather at {}; inputs from RocketPy 1.13.0's {}; altimeter: \
2209                 {}; total impulse flown {} N s{}.{}{}",
2210                row.title,
2211                row.design,
2212                row.weather_time_utc,
2213                row.source,
2214                row.altimeter_evidence,
2215                fmt_number(row.total_impulse_ns, 1),
2216                if row.negative_thrust_zeroed_ns == 0.0 {
2217                    String::new()
2218                } else {
2219                    format!(
2220                        ", {} N s of it from reading the file's negative thrust as zero",
2221                        fmt_number(row.negative_thrust_zeroed_ns, 2)
2222                    )
2223                },
2224                if row.note.is_empty() {
2225                    String::new()
2226                } else {
2227                    format!(" Note: {}.", row.note)
2228                },
2229                if row.explanation.is_empty() {
2230                    String::new()
2231                } else {
2232                    format!(
2233                        " Outside the target ({}): {}",
2234                        row.explanation_kind, row.explanation
2235                    )
2236                }
2237            );
2238        }
2239        out
2240    }
2241}
2242
2243fn fmt_number(value: f64, decimals: usize) -> String {
2244    format!("{value:.decimals$}")
2245}
2246
2247fn fmt_signed(value: f64, decimals: usize) -> String {
2248    format!("{value:+.decimals$}")
2249}
2250
2251#[cfg(test)]
2252mod tests {
2253    use super::*;
2254
2255    const ENG: &str = "; a comment\nK1 54 579 6 1.4 2.2 AT\n 0.1 100 ; peak\n 0.5 50\n 1.0 0\n";
2256
2257    /// In the standard atmosphere a barometric altimeter reads the geopotential height climbed.
2258    /// On a day 20 K warmer at every height, with the same sea-level pressure, each pressure's
2259    /// geopotential height in the troposphere is `(T₀ + ΔT) / T₀` times the standard's, so the
2260    /// altimeter reads exactly `T₀ / (T₀ + ΔT)` = 288.15 / 308.15 of the height climbed.
2261    #[test]
2262    fn a_barometer_reads_the_standard_atmospheres_altitude() {
2263        let climbed = hpr_atmos::ussa76::geopotential_from_geometric_m(4400.0).unwrap()
2264            - hpr_atmos::ussa76::geopotential_from_geometric_m(1401.0).unwrap();
2265        let standard = Ussa76::standard();
2266        let barometer = Barometer::on_pad(&standard, 1400.0, 1.0).unwrap();
2267        let reading = barometer.reading_m(3000.0).unwrap();
2268        assert!((reading - climbed).abs() < 1e-6, "{reading} vs {climbed}");
2269        assert_eq!(barometer.reading_m(1.0).unwrap(), 0.0);
2270
2271        let warm = Ussa76::with_offset(20.0, 101_325.0).unwrap();
2272        let reading = Barometer::on_pad(&warm, 1400.0, 1.0)
2273            .unwrap()
2274            .reading_m(3000.0)
2275            .unwrap();
2276        let expected = climbed * 288.15 / 308.15;
2277        assert!(
2278            (reading - expected).abs() < 1e-9 * climbed,
2279            "{reading} vs {expected}"
2280        );
2281    }
2282
2283    /// The inverse: on the same warm day, a reading of `288.15 / 308.15` of the geopotential
2284    /// height climbed from a pad 1400 m up is 3000 m climbed, and on the standard day a reading of
2285    /// the geopotential height climbed is the geometric height. A warm day's reading turns into
2286    /// more height than it says, a cold day's into less; and readings below the site's own, not
2287    /// finite, or past 100 km are refused by name.
2288    #[test]
2289    fn a_barometers_reading_turns_back_into_height() {
2290        let geopotential = |m: f64| hpr_atmos::ussa76::geopotential_from_geometric_m(m).unwrap();
2291        let climbed = geopotential(4400.0) - geopotential(1400.0);
2292        let standard = Ussa76::standard();
2293        let barometer = Barometer::on_pad(&standard, 1400.0, 0.0).unwrap();
2294        let height = barometer.height_m(climbed).unwrap();
2295        assert!((height - 3000.0).abs() < 1e-6, "{height}");
2296        assert_eq!(barometer.height_m(0.0).unwrap(), 0.0);
2297
2298        let warm = Ussa76::with_offset(20.0, 101_325.0).unwrap();
2299        let barometer = Barometer::on_pad(&warm, 1400.0, 0.0).unwrap();
2300        let height = barometer.height_m(climbed * 288.15 / 308.15).unwrap();
2301        assert!((height - 3000.0).abs() < 1e-6, "{height}");
2302        let reading = 2500.0;
2303        assert!(barometer.height_m(reading).unwrap() > reading);
2304        let round_trip = barometer.reading_m(barometer.height_m(reading).unwrap());
2305        assert!((round_trip.unwrap() - reading).abs() < 1e-9 * reading);
2306
2307        let cold = Ussa76::with_offset(-20.0, 101_325.0).unwrap();
2308        let barometer = Barometer::on_pad(&cold, 1400.0, 0.0).unwrap();
2309        assert!(barometer.height_m(reading).unwrap() < reading);
2310
2311        for refused in [-10.0, f64::NAN, f64::INFINITY, 1.0e6] {
2312            match barometer.height_m(refused) {
2313                Err(hpr_atmos::AtmosError::Domain { what, .. }) => {
2314                    assert_eq!(what, "barometric reading", "{refused}");
2315                }
2316                other => panic!("{refused}: {other:?}"),
2317            }
2318        }
2319    }
2320
2321    /// The pressure gap against the standard's troposphere in closed form,
2322    /// `p = 101325 Pa (1 − L H / T₀)^(g₀′ M₀ / (R* L))`, on a pad 1400 m up and a height column
2323    /// in feet: a height 5 m off its pressure's reading less the pad's gives a 5 m gap, a row
2324    /// whose pressure doesn't read is skipped, and rows past the cut don't count.
2325    #[test]
2326    fn a_height_column_is_held_to_its_pressure() {
2327        let pressure_hpa = |h: f64| {
2328            1013.25 * (1.0 - 0.0065 * h / 288.15).powf(9.806_65 * 28.9644 / (8314.32 * 0.0065))
2329        };
2330        let feet = |m: f64| m / 0.3048;
2331        let text = format!(
2332            "t,h,p\n0,0,{}\n1,{},{}\n2,{},{}\n3,9999,x\n4,9999,{}\n",
2333            pressure_hpa(1400.0),
2334            feet(1000.0),
2335            pressure_hpa(2400.0),
2336            feet(2005.0),
2337            pressure_hpa(3400.0),
2338            pressure_hpa(1400.0)
2339        );
2340        let log = Log {
2341            file: "x.csv",
2342            header_lines: 1,
2343            time_column: 0,
2344            height_column: 1,
2345            meters_per_unit: 0.3048,
2346            until_s: Some(3.5),
2347            altimeter: Altimeter::Barometric(""),
2348            pressure: Some((2, 100.0)),
2349            gnss: None,
2350        };
2351        let gap = pressure_reading_gap_m(&log, 2, 100.0, &text).unwrap();
2352        assert!((gap - 5.0).abs() < 1e-6, "{gap}");
2353        assert!(pressure_reading_gap_m(&log, 2, 100.0, "t,h,p\n").is_err());
2354    }
2355
2356    /// A satellite apogee is above the first row, in the file's units; a first row far from the
2357    /// site, or a log that never climbs, is refused.
2358    #[test]
2359    fn a_satellite_apogee_is_above_its_first_row() {
2360        let gnss = Gnss {
2361            file: "g.csv",
2362            header_lines: 1,
2363            time_column: 0,
2364            altitude_column: 1,
2365            meters_per_unit: 0.3048,
2366        };
2367        let text = "t,alt\n0,4600\n1,5100\n2,5600\n3,5400\n";
2368        let apogee = satellite_apogee_m(&gnss, text, 1400.0).unwrap();
2369        assert!((apogee - 304.8).abs() < 1e-9, "{apogee}");
2370        assert!(satellite_apogee_m(&gnss, text, 0.0).is_err());
2371        assert!(satellite_apogee_m(&gnss, "t,alt\n0,4600\n1,4600\n", 1400.0).is_err());
2372        assert!(satellite_apogee_m(&gnss, "t,alt\n", 1400.0).is_err());
2373        assert!(satellite_apogee_m(&gnss, "t,alt\n0,0\n1,500\n", 100.0).is_err());
2374    }
2375
2376    #[test]
2377    fn an_eng_file_reads_as_rocketpy_reads_it() {
2378        let (points, removed) = parse_thrust("t", "a.eng", ENG, None, None).unwrap();
2379        assert_eq!(
2380            points,
2381            vec![(0.0, 0.0), (0.1, 100.0), (0.5, 50.0), (1.0, 0.0)]
2382        );
2383        assert_eq!(removed, 0.0);
2384        // A first data line of `0 0` is the description, as in RocketPy's `import_eng`.
2385        let headless = "0 0\n0.003 281.69\n1.0 0\n";
2386        let (points, _) = parse_thrust("t", "b.eng", headless, None, None).unwrap();
2387        assert_eq!(points, vec![(0.0, 0.0), (0.003, 281.69), (1.0, 0.0)]);
2388    }
2389
2390    #[test]
2391    fn a_burn_time_clips_with_the_curves_value_at_the_end() {
2392        let (points, _) = parse_thrust("t", "a.eng", ENG, Some(0.3), None).unwrap();
2393        assert_eq!(points, vec![(0.0, 0.0), (0.1, 100.0), (0.3, 75.0)]);
2394        // Past the last time, the last time.
2395        let (points, _) = parse_thrust("t", "a.eng", ENG, Some(2.0), None).unwrap();
2396        assert_eq!(points.last(), Some(&(1.0, 0.0)));
2397    }
2398
2399    #[test]
2400    fn a_reshape_scales_time_then_thrust_to_the_impulse() {
2401        let csv = "0,10\n1,10\n2,-2\n";
2402        let (points, removed) = parse_thrust("t", "a.csv", csv, None, Some((4.0, 90.0))).unwrap();
2403        // Times double; the trapezoid of (0,10),(2,10),(4,-2) is 20 + 8 = 28, so thrusts scale
2404        // by 90/28, and the negative end is read as zero, which adds its negative share back.
2405        let k = 90.0 / 28.0;
2406        assert_eq!(points[0], (0.0, 10.0 * k));
2407        assert_eq!(points[1], (2.0, 10.0 * k));
2408        assert_eq!(points[2], (4.0, 0.0));
2409        assert!((removed - (0.5 * 2.0 * 10.0 * k - 0.5 * 2.0 * (10.0 - 2.0) * k)).abs() < 1e-12);
2410    }
2411
2412    #[test]
2413    fn a_log_skips_rows_that_do_not_read_and_stops_at_its_end() {
2414        let log = Log {
2415            file: "x.csv",
2416            header_lines: 1,
2417            time_column: 0,
2418            height_column: 1,
2419            meters_per_unit: 0.3048,
2420            until_s: Some(2.0),
2421            altimeter: Altimeter::Barometric(""),
2422            pressure: None,
2423            gnss: None,
2424        };
2425        let text = "t,h\n0,0\n1, 100\n1.5,\n2,200\n3,9999\n";
2426        let rows = parse_log("t", &log, text).unwrap();
2427        assert_eq!(rows, vec![(0.0, 0.0), (1.0, 30.48), (2.0, 60.96)]);
2428    }
2429
2430    #[test]
2431    fn traces_align_at_the_height_and_compare_to_the_first_apogee() {
2432        // The log is hpr's trace three seconds later: aligned at 30 m, the RMS is zero whatever
2433        // the clocks, and it runs to the log's apogee, hpr's being later.
2434        let rise = |t: f64| 100.0 * t * t;
2435        let hpr: Vec<(f64, f64)> = (0..=1200)
2436            .map(|i| 0.01 * f64::from(i))
2437            .map(|t| (t, rise(t)))
2438            .collect();
2439        let log: Vec<(f64, f64)> = (0..=1000)
2440            .map(|i| 0.01 * f64::from(i))
2441            .map(|t| (t + 3.0, rise(t)))
2442            .collect();
2443        let got = compare_traces(&log, &hpr, 12.0).unwrap();
2444        assert!(got.rms_m < 1e-9, "{got:?}");
2445        let align = 30.0_f64.sqrt() / 10.0;
2446        assert!(
2447            (got.log_time_to_apogee_s - (10.0 - align)).abs() < 1e-3,
2448            "{got:?}"
2449        );
2450        assert!(
2451            (got.hpr_time_to_apogee_s - (12.0 - align)).abs() < 1e-3,
2452            "{got:?}"
2453        );
2454        // Every log row from the crossing (between 0.54 and 0.55 s) to its apogee.
2455        assert_eq!(got.rows, 1000 - 55 + 1);
2456        // Heights 10 m higher after the crossing give an RMS of 10 m.
2457        let higher: Vec<(f64, f64)> = log
2458            .iter()
2459            .map(|&(t, h)| (t, if h > 40.0 { h + 10.0 } else { h }))
2460            .collect();
2461        let got = compare_traces(&higher, &hpr, 12.0).unwrap();
2462        assert!(got.rms_m > 9.9 && got.rms_m <= 10.0, "{got:?}");
2463    }
2464
2465    #[test]
2466    fn the_flights_take_their_thrust_from_the_design_fixtures_record() {
2467        let fixture: Value = serde_json::from_str(include_str!(
2468            "../../../validation/fixtures/design/rocketpy-rocket-mass.json"
2469        ))
2470        .unwrap();
2471        for flight in &FLIGHTS {
2472            let name = flight.design.trim_start_matches("rocketpy-");
2473            let case = fixture["cases"]
2474                .as_array()
2475                .unwrap()
2476                .iter()
2477                .find(|case| case["name"] == name)
2478                .unwrap_or_else(|| panic!("{} has no fixture case", flight.id));
2479            assert!(case["original_thrust_source"].is_string(), "{}", flight.id);
2480        }
2481    }
2482}