Skip to main content

hpr_validate/
census.rs

1//! The accuracy census: the numbers the committed reports hold hpr to, counted once, classed by
2//! what they were compared with and how fast the flight went, and held to the census last
3//! accepted.
4//!
5//! The reports say how each case came out. The census says what they add up to, and it is the
6//! gate against a regression that a regenerated report would otherwise carry in unnoticed. Four
7//! reports feed it:
8//!
9//! - the harness's (`validation/reports/latest.json`): every metric of every case, and each known
10//!   gap;
11//! - the real flights' (`real-flights.json`): each flight's apogee and the RMS of its climb;
12//! - OpenRocket's flights of its examples and of the private designs (`openrocket-flights.json`,
13//!   `openrocket-library-flights.json`): apogee, largest speed, stability margin, mass at launch
14//!   and at rod clearance, and center of mass there, and each configuration hpr doesn't fly.
15//!
16//! Each becomes a [`Row`], keyed by its report's [`Group`], its case and its metric, and a key seen
17//! twice is refused, so a case counts once ([Loft lesson L84][l84]).
18//!
19//! The census accepted last is committed (`validation/reports/census.json`). A run is held to it
20//! ([`compare`]): a row that moves by more than its slack in either direction, that changes its
21//! standing (a miss that starts passing too, [L85][l85]), or that comes or goes, a group whose
22//! reference changes, or a changed slack, is a [`Change`], and any change fails the check until the
23//! census is accepted again with a written reason. A predicted-mode row, whose 3% is only a target
24//! ([ADR-023][adr-023], predicted mode), is held the same way, so hpr's own aerodynamics can't
25//! drift unnoticed inside its target ([L88][l88]). Each group's headline names what it was
26//! compared with, the kind of comparison, how many flights and their speeds ([L86][l86]).
27//!
28//! A row's slack is [`SLACK_SHARE`] of its scale: the tolerance it is held to, or for a row held
29//! to none, the bar its kind is judged by. It is never less than the harness's own reproduction
30//! bound, [`reproduction_bound`].
31//!
32//! [l84]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l84
33//! [l85]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l85
34//! [l86]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l86
35//! [l88]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l88
36//! [adr-023]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-023-predicted-mode-each-codes-own-drag-reported-against-a-target-2026-09-18
37
38use std::collections::{BTreeMap, BTreeSet};
39use std::fmt::Write as _;
40
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43
44use crate::real_flight::{APOGEE_TARGET_PERCENT, RealFlightReport};
45use crate::report::{Report, Verdict};
46
47/// The share of a row's scale it may move by before the census calls it a change: 0.1%.
48///
49/// On a 3% tolerance that is 3e-5 of the reference. The committed reports are regenerated on one
50/// machine and compared as files, so the noise that matters is a regeneration's: within
51/// [`reproduction_bound`] for the harness, and at most 5e-7 per cent on an OpenRocket corpus rerun.
52pub const SLACK_SHARE: f64 = 1e-3;
53
54/// How far a harness value may move between runs and platforms and still reproduce: `2e-6` or
55/// `1e-7` of the value, whichever is larger (`Report::reproduces`). A harness row's slack is never
56/// less than this.
57#[must_use]
58pub fn reproduction_bound(value: f64) -> f64 {
59    2e-6_f64.max(1e-7 * value.abs())
60}
61
62/// The scale of a harness row held to no tolerance: 3% of its reference, the bound
63/// [M2.1][m2-1] set on each metric and that no committed gate or target exceeds
64/// (`tests::no_committed_gate_is_looser_than_the_milestone_says`).
65///
66/// [m2-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1
67pub const NOT_SCORED_SCALE: f64 = 0.03;
68
69/// The bar an OpenRocket apogee or largest speed is judged by, per cent: the apogee difference
70/// that needs a written cause ([ADR-070][adr-070], the corpus's rules), and the bar the staging
71/// milestone set on both ([M1.9c][m1-9c]).
72///
73/// [m1-9c]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-9c
74/// [adr-070]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-070-m22e-split-mass-and-centre-of-mass-first-then-the-corpus-2026-09-25
75pub const OPENROCKET_BAR_PERCENT: f64 = 5.0;
76
77/// The bar an OpenRocket mass difference is judged by, per cent: the 1% the mass comparison was
78/// measured against when it began ([M2.2a][m2-2a]).
79///
80/// [m2-2a]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-2a
81pub const MASS_BAR_PERCENT: f64 = 1.0;
82
83/// The bar a stability margin or a center of mass is judged by, calibres: the center-of-pressure
84/// target set before the transonic normal force was measured ([M1.8a][m1-8a]). A difference in
85/// either moves the margin by as many calibres.
86///
87/// [m1-8a]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8a
88pub const MARGIN_BAR_CAL: f64 = 0.5;
89
90/// The bar a real flight's climb is judged by, per cent of its apogee: the bound the harness holds
91/// a whole flight's height series to ([ADR-024][adr-024], the series RMS).
92///
93/// [adr-024]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-024-the-time-series-rms-aligned-at-ignition-held-to-3-of-its-traces-scale-2026-09-18
94pub const TRACE_BAR_PERCENT: f64 = 3.0;
95
96/// The largest Mach number of a subsonic flight, as the OpenRocket library report classes them.
97pub const SUBSONIC_BELOW: f64 = 0.8;
98/// The largest Mach number of a transonic flight; past it a flight is supersonic.
99pub const SUPERSONIC_ABOVE: f64 = 1.2;
100
101/// Which report a row comes from, and so what it was compared with.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104#[non_exhaustive]
105pub enum Group {
106    /// The harness's descents under a parachute against RocketPy, on the same inputs: gated.
107    Descent,
108    /// The harness's whole flights against RocketPy on the same drag: gated.
109    SameDrag,
110    /// The harness's whole flights, each code on its own drag: a target, not a gate.
111    Predicted,
112    /// OpenRocket's calm flights of its own examples.
113    OpenRocketExamples,
114    /// OpenRocket's calm flights of the private designs, under anonymised ids.
115    OpenRocketLibrary,
116    /// Real flights: the teams' altimeter logs.
117    FlightLogs,
118}
119
120impl Group {
121    /// Every group, in the census's order.
122    pub const ALL: [Self; 6] = [
123        Self::Descent,
124        Self::SameDrag,
125        Self::Predicted,
126        Self::OpenRocketExamples,
127        Self::OpenRocketLibrary,
128        Self::FlightLogs,
129    ];
130
131    /// What was flown, in a few words.
132    #[must_use]
133    pub const fn what(self) -> &'static str {
134        match self {
135            Self::Descent => "descents under a parachute",
136            Self::SameDrag => "whole flights on the same drag",
137            Self::Predicted => "whole flights, each code on its own drag",
138            Self::OpenRocketExamples => "calm flights of OpenRocket's examples",
139            Self::OpenRocketLibrary => "calm flights of the private designs",
140            Self::FlightLogs => "real flights",
141        }
142    }
143
144    /// The kind of evidence: another program's answer or a measurement.
145    #[must_use]
146    pub const fn kind(self) -> &'static str {
147        match self {
148            Self::Descent | Self::SameDrag => "code-to-code, same inputs",
149            Self::Predicted => "code-to-code, each code's own drag",
150            Self::OpenRocketExamples | Self::OpenRocketLibrary => {
151                "code-to-code, each code's own model"
152            }
153            Self::FlightLogs => "measured",
154        }
155    }
156
157    /// What its numbers are held to in their own report. A gate fails the run, a target is
158    /// reported and not enforced, and where there is neither, a bar marks a difference that needs
159    /// a written cause. The census holds every number to where it was, whatever this says.
160    #[must_use]
161    pub const fn bar(self) -> &'static str {
162        match self {
163            Self::Descent | Self::SameDrag => "gate: each metric's tolerance, at most 3%",
164            Self::Predicted => "target: 3% on each metric, reported, not enforced",
165            Self::OpenRocketExamples | Self::OpenRocketLibrary => {
166                "no target; an apogee more than 5% off needs a written cause"
167            }
168            Self::FlightLogs => "target: mean absolute apogee error 5%",
169        }
170    }
171
172    /// The noun for its cases.
173    const fn noun(self, count: usize) -> &'static str {
174        match (self, count) {
175            (Self::Descent, 1) => "descent",
176            (Self::Descent, _) => "descents",
177            (_, 1) => "flight",
178            _ => "flights",
179        }
180    }
181
182    const fn report(self) -> &'static str {
183        match self {
184            Self::Descent | Self::SameDrag | Self::Predicted => HARNESS,
185            Self::FlightLogs => REAL,
186            Self::OpenRocketExamples => EXAMPLES,
187            Self::OpenRocketLibrary => LIBRARY,
188        }
189    }
190}
191
192/// How fast a case flew: the class of its largest Mach number.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum Regime {
197    /// A descent under a parachute, slow by construction.
198    Descent,
199    /// Below Mach [`SUBSONIC_BELOW`].
200    Subsonic,
201    /// From Mach [`SUBSONIC_BELOW`] to [`SUPERSONIC_ABOVE`].
202    Transonic,
203    /// Above Mach [`SUPERSONIC_ABOVE`].
204    Supersonic,
205    /// Not known: a flight hpr doesn't fly, or one its report gives no Mach number for.
206    Unknown,
207}
208
209impl Regime {
210    /// The class of a flight whose largest Mach number is `mach`; [`Regime::Unknown`] for a
211    /// number that isn't finite.
212    #[must_use]
213    pub fn of_mach(mach: f64) -> Self {
214        if !mach.is_finite() {
215            Self::Unknown
216        } else if mach < SUBSONIC_BELOW {
217            Self::Subsonic
218        } else if mach <= SUPERSONIC_ABOVE {
219            Self::Transonic
220        } else {
221            Self::Supersonic
222        }
223    }
224
225    /// Its name, as the census writes it.
226    #[must_use]
227    pub const fn name(self) -> &'static str {
228        match self {
229            Self::Descent => "under a parachute",
230            Self::Subsonic => "subsonic",
231            Self::Transonic => "transonic",
232            Self::Supersonic => "supersonic",
233            Self::Unknown => "of unknown speed",
234        }
235    }
236}
237
238/// Where a row stands against what it is held to.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241#[non_exhaustive]
242pub enum Standing {
243    /// Inside its gate.
244    Pass,
245    /// Outside its gate.
246    Fail,
247    /// Inside its target.
248    WithinTarget,
249    /// Outside its target.
250    OutsideTarget,
251    /// Measured and held to nothing, for a written reason.
252    NotScored,
253    /// Inside the bar its kind is judged by, where there is no gate or target.
254    WithinBar,
255    /// Over that bar.
256    OverBar,
257    /// Not compared: the report withholds it, and says why.
258    Withheld,
259    /// A known gap: a harness flight hpr refuses, compared with nothing.
260    Gap,
261    /// A configuration an OpenRocket report lists and hpr doesn't fly, with the report's reason.
262    NotFlown,
263}
264
265impl Standing {
266    /// Its name, as the census writes it.
267    #[must_use]
268    pub const fn name(self) -> &'static str {
269        match self {
270            Self::Pass => "pass",
271            Self::Fail => "fail",
272            Self::WithinTarget => "within target",
273            Self::OutsideTarget => "outside target",
274            Self::NotScored => "not scored",
275            Self::WithinBar => "within the bar",
276            Self::OverBar => "over the bar",
277            Self::Withheld => "withheld",
278            Self::Gap => "known gap",
279            Self::NotFlown => "not flown",
280        }
281    }
282
283    /// Whether it stands for a case hpr doesn't fly, rather than a compared number.
284    const fn unflown(self) -> bool {
285        matches!(self, Self::Gap | Self::NotFlown)
286    }
287
288    /// How good it is, higher better, for telling a fall from a rise.
289    const fn rank(self) -> u8 {
290        match self {
291            Self::Fail => 0,
292            Self::Gap | Self::NotFlown => 1,
293            Self::Withheld | Self::NotScored => 2,
294            Self::OutsideTarget | Self::OverBar => 3,
295            Self::WithinTarget | Self::WithinBar => 4,
296            Self::Pass => 5,
297        }
298    }
299}
300
301/// The unit a row's difference is in.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304#[non_exhaustive]
305pub enum Unit {
306    /// The metric's own unit, as its name says (`_m`, `_s`, `_m_s`, or none for a Mach number).
307    Metric,
308    /// Per cent of the reference.
309    Percent,
310    /// Calibres.
311    Calibre,
312}
313
314impl Unit {
315    const fn suffix(self) -> &'static str {
316        match self {
317            Self::Metric => "",
318            Self::Percent => "%",
319            Self::Calibre => " cal",
320        }
321    }
322}
323
324/// One compared number, or one case that isn't flown.
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct Row {
327    /// The report it comes from.
328    pub group: Group,
329    /// The case: a harness case id, an OpenRocket design and configuration, an anonymised id, or
330    /// a real flight's id.
331    pub case: String,
332    /// The metric; empty for a case that isn't flown.
333    pub metric: String,
334    /// hpr less the reference, in [`Row::unit`]; for a climb, the RMS itself.
335    pub difference: f64,
336    /// The difference as a percentage of the reference, where it is one.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub percent: Option<f64>,
339    /// The unit of the difference, the scale and the slack.
340    pub unit: Unit,
341    /// What the difference is measured against: the row's tolerance, or its kind's bar.
342    pub scale: f64,
343    /// How far the difference may move before the census calls it a change.
344    pub slack: f64,
345    /// Where it stands.
346    pub standing: Standing,
347    /// How fast its case flew.
348    pub regime: Regime,
349}
350
351impl Row {
352    fn key(&self) -> (Group, &str, &str) {
353        (self.group, &self.case, &self.metric)
354    }
355
356    /// Its difference as a share of its scale; not a number for a row with no scale.
357    #[must_use]
358    pub fn share(&self) -> f64 {
359        if self.scale > 0.0 {
360            self.difference.abs() / self.scale
361        } else {
362            f64::NAN
363        }
364    }
365
366    fn describe(&self) -> String {
367        format!(
368            "{}: {} {}",
369            self.group.what(),
370            self.case,
371            if self.metric.is_empty() {
372                "(the case)"
373            } else {
374                &self.metric
375            }
376        )
377    }
378
379    /// A row that isn't a compared number: a case that isn't flown.
380    fn unflown(group: Group, case: String, standing: Standing, regime: Regime) -> Self {
381        Self {
382            group,
383            case,
384            metric: String::new(),
385            difference: 0.0,
386            percent: None,
387            unit: Unit::Metric,
388            scale: 0.0,
389            slack: 0.0,
390            standing,
391            regime,
392        }
393    }
394}
395
396/// What the census counts: every row, and what each group was compared with.
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
398pub struct Census {
399    /// [`SLACK_SHARE`] when it was taken.
400    pub slack_share: f64,
401    /// Each group's reference, as its report names it (a program and its version, or the logs).
402    pub references: BTreeMap<Group, String>,
403    /// Every row, sorted by group, case and metric.
404    pub rows: Vec<Row>,
405}
406
407/// Why a census could not be taken.
408#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
409#[non_exhaustive]
410pub enum CensusError {
411    /// A report said something the census can't read.
412    #[error("{report}: {what}")]
413    Report {
414        /// The report.
415        report: &'static str,
416        /// What is wrong.
417        what: String,
418    },
419    /// A row's key came twice: a case would count twice.
420    #[error("{report}: {case} {metric} is compared twice; a case counts once (L84)")]
421    Duplicate {
422        /// The report.
423        report: &'static str,
424        /// The case.
425        case: String,
426        /// The metric.
427        metric: String,
428    },
429}
430
431/// The reports the census reads, as parsed from their committed files.
432#[derive(Debug, Clone, Copy)]
433pub struct Reports<'a> {
434    /// `validation/reports/latest.json`.
435    pub harness: &'a Report,
436    /// `validation/reports/real-flights.json`.
437    pub real_flights: &'a RealFlightReport,
438    /// `validation/reports/openrocket-flights.json`.
439    pub openrocket_examples: &'a Value,
440    /// `validation/reports/openrocket-library-flights.json`.
441    pub openrocket_library: &'a Value,
442}
443
444const HARNESS: &str = "latest.json";
445const REAL: &str = "real-flights.json";
446const EXAMPLES: &str = "openrocket-flights.json";
447const LIBRARY: &str = "openrocket-library-flights.json";
448
449impl Census {
450    /// Takes the census of `reports`.
451    ///
452    /// # Errors
453    ///
454    /// [`CensusError::Duplicate`] when a case's metric comes twice, and [`CensusError::Report`]
455    /// when a report is partial or holds something the census has no class for.
456    pub fn take(reports: Reports<'_>) -> Result<Self, CensusError> {
457        let mut rows = Vec::new();
458        let mut references = BTreeMap::new();
459        harness_rows(reports.harness, &mut rows, &mut references)?;
460        real_flight_rows(reports.real_flights, &mut rows, &mut references);
461        openrocket_example_rows(reports.openrocket_examples, &mut rows, &mut references)?;
462        openrocket_library_rows(reports.openrocket_library, &mut rows, &mut references)?;
463        let mut seen = BTreeSet::new();
464        for row in &rows {
465            if !seen.insert(row.key()) {
466                return Err(CensusError::Duplicate {
467                    report: row.group.report(),
468                    case: row.case.clone(),
469                    metric: row.metric.clone(),
470                });
471            }
472        }
473        let unflown: BTreeSet<(Group, &str)> = rows
474            .iter()
475            .filter(|row| row.standing.unflown())
476            .map(|row| (row.group, row.case.as_str()))
477            .collect();
478        if let Some(row) = rows.iter().find(|row| {
479            !row.standing.unflown() && unflown.contains(&(row.group, row.case.as_str()))
480        }) {
481            return Err(CensusError::Report {
482                report: row.group.report(),
483                what: format!("{} is both compared and not compared", row.case),
484            });
485        }
486        rows.sort_by(|a, b| a.key().cmp(&b.key()));
487        Ok(Self {
488            slack_share: SLACK_SHARE,
489            references,
490            rows,
491        })
492    }
493
494    /// What each group adds up to, in [`Group::ALL`]'s order, leaving out a group with no rows.
495    #[must_use]
496    pub fn summaries(&self) -> Vec<Summary> {
497        Group::ALL
498            .into_iter()
499            .filter_map(|group| Summary::of(self, group))
500            .collect()
501    }
502}
503
504pub(crate) fn harness_group(case: &str) -> Result<Group, CensusError> {
505    if case.starts_with("descent-") {
506        Ok(Group::Descent)
507    } else if case.starts_with("flight-") {
508        Ok(Group::SameDrag)
509    } else if case.starts_with("predicted-") {
510        Ok(Group::Predicted)
511    } else {
512        Err(CensusError::Report {
513            report: HARNESS,
514            what: format!(
515                "case {case} is neither a descent, a same-drag flight nor a predicted one, by its id"
516            ),
517        })
518    }
519}
520
521fn harness_rows(
522    report: &Report,
523    rows: &mut Vec<Row>,
524    references: &mut BTreeMap<Group, String>,
525) -> Result<(), CensusError> {
526    if report.fast || !report.skipped.is_empty() {
527        return Err(CensusError::Report {
528            report: HARNESS,
529            what: "a partial run: the census counts the whole suite".to_owned(),
530        });
531    }
532    let mut oracles: BTreeMap<Group, BTreeSet<&str>> = BTreeMap::new();
533    for source in &report.sources {
534        oracles
535            .entry(harness_group(&source.case)?)
536            .or_default()
537            .insert(&source.oracle);
538    }
539    for (group, names) in oracles {
540        references.insert(group, names.into_iter().collect::<Vec<_>>().join("; "));
541    }
542    let regime = |case: &str| -> Result<Regime, CensusError> {
543        if harness_group(case)? == Group::Descent {
544            return Ok(Regime::Descent);
545        }
546        if let Some(gap) = report.gaps.iter().find(|gap| gap.case == case) {
547            return Ok(Regime::of_mach(gap.mach));
548        }
549        report
550            .comparisons
551            .iter()
552            .find(|row| row.case == case && row.metric == "max_mach")
553            .map(|row| Regime::of_mach(row.reference))
554            .ok_or_else(|| CensusError::Report {
555                report: HARNESS,
556                what: format!("case {case} has no max_mach row to class its speed by"),
557            })
558    };
559    for comparison in &report.comparisons {
560        let scale = if comparison.tolerance.is_set() {
561            comparison.tolerance.allowed(comparison.reference)
562        } else {
563            NOT_SCORED_SCALE * comparison.reference.abs()
564        };
565        rows.push(Row {
566            group: harness_group(&comparison.case)?,
567            case: comparison.case.clone(),
568            metric: comparison.metric.clone(),
569            difference: comparison.difference,
570            percent: comparison.relative.map(|relative| 100.0 * relative),
571            unit: Unit::Metric,
572            scale,
573            slack: (SLACK_SHARE * scale).max(reproduction_bound(comparison.reference)),
574            standing: match comparison.verdict {
575                Verdict::Pass => Standing::Pass,
576                Verdict::Fail => Standing::Fail,
577                Verdict::NotScored => Standing::NotScored,
578                Verdict::WithinTarget => Standing::WithinTarget,
579                Verdict::OutsideTarget => Standing::OutsideTarget,
580            },
581            regime: regime(&comparison.case)?,
582        });
583    }
584    for gap in &report.gaps {
585        rows.push(Row::unflown(
586            harness_group(&gap.case)?,
587            gap.case.clone(),
588            Standing::Gap,
589            Regime::of_mach(gap.mach),
590        ));
591    }
592    Ok(())
593}
594
595/// A row judged by a bar: within it or over it, by the difference's magnitude; withheld where the
596/// report gives no difference.
597fn barred(
598    group: Group,
599    case: &str,
600    metric: &str,
601    difference: Option<f64>,
602    (unit, scale): (Unit, f64),
603    regime: Regime,
604) -> Row {
605    let (difference, standing) = match difference.filter(|value| value.is_finite()) {
606        Some(value) if value.abs() <= scale => (value, Standing::WithinBar),
607        Some(value) => (value, Standing::OverBar),
608        None => (0.0, Standing::Withheld),
609    };
610    Row {
611        group,
612        case: case.to_owned(),
613        metric: metric.to_owned(),
614        difference,
615        percent: (unit == Unit::Percent && standing != Standing::Withheld).then_some(difference),
616        unit,
617        scale,
618        slack: SLACK_SHARE * scale,
619        standing,
620        regime,
621    }
622}
623
624fn real_flight_rows(
625    report: &RealFlightReport,
626    rows: &mut Vec<Row>,
627    references: &mut BTreeMap<Group, String>,
628) {
629    references.insert(Group::FlightLogs, "the teams' altimeter logs".to_owned());
630    for flight in &report.flights {
631        let regime = Regime::of_mach(flight.hpr_max_mach);
632        // The 5% target is on the mean; one flight's apogee is judged by it as a bar.
633        rows.push(barred(
634            Group::FlightLogs,
635            &flight.id,
636            "apogee",
637            Some(flight.apogee_error_percent),
638            (Unit::Percent, APOGEE_TARGET_PERCENT),
639            regime,
640        ));
641        rows.push(barred(
642            Group::FlightLogs,
643            &flight.id,
644            "climb",
645            Some(flight.trace_rms_percent),
646            (Unit::Percent, TRACE_BAR_PERCENT),
647            regime,
648        ));
649    }
650}
651
652fn openrocket_reference(report: &Value, name: &'static str) -> Result<String, CensusError> {
653    match (
654        report["reference"]["tool"].as_str(),
655        report["reference"]["version"].as_str(),
656    ) {
657        (Some(tool), Some(version)) => Ok(format!("{tool} {version}")),
658        _ => Err(CensusError::Report {
659            report: name,
660            what: "no reference tool and version".to_owned(),
661        }),
662    }
663}
664
665fn array<'a>(
666    report: &'a Value,
667    key: &str,
668    name: &'static str,
669) -> Result<&'a Vec<Value>, CensusError> {
670    report[key].as_array().ok_or_else(|| CensusError::Report {
671        report: name,
672        what: format!("no `{key}` list"),
673    })
674}
675
676/// A difference an OpenRocket report scored, or `None` where it withheld it.
677fn scored(
678    outcome: Option<&str>,
679    value: Option<f64>,
680    case: &str,
681    metric: &str,
682    name: &'static str,
683) -> Result<Option<f64>, CensusError> {
684    match (outcome, value) {
685        (Some("scored"), Some(value)) if value.is_finite() => Ok(Some(value)),
686        (Some("scored"), _) => Err(CensusError::Report {
687            report: name,
688            what: format!("{case} {metric} is scored with no finite difference"),
689        }),
690        (Some(_), _) => Ok(None),
691        (None, _) => Err(CensusError::Report {
692            report: name,
693            what: format!("{case} {metric} has no outcome"),
694        }),
695    }
696}
697
698/// A mass or center-of-mass difference: `None` only for an aborted flight, which the reports
699/// withhold them from. An absent or null value on a flight that ran is a partial report.
700fn measured(
701    value: Option<f64>,
702    flight: &Value,
703    case: &str,
704    metric: &str,
705    name: &'static str,
706) -> Result<Option<f64>, CensusError> {
707    let aborted = flight["aborted"]
708        .as_bool()
709        .ok_or_else(|| CensusError::Report {
710            report: name,
711            what: format!("{case} doesn't say whether it was aborted"),
712        })?;
713    match value.filter(|value| value.is_finite()) {
714        Some(value) => Ok(Some(value)),
715        None if aborted => Ok(None),
716        None => Err(CensusError::Report {
717            report: name,
718            what: format!("{case} {metric} is missing from a flight that ran"),
719        }),
720    }
721}
722
723/// Per cent of `openrocket`'s by which `hpr` differs, as `cargo xtask ork-flights` computes it.
724fn percent_of(hpr: &Value, openrocket: &Value) -> Option<f64> {
725    hpr.as_f64()
726        .zip(openrocket.as_f64())
727        .map(|(h, o)| 100.0 * (h - o) / o)
728        .filter(|p| p.is_finite())
729}
730
731const APOGEE_BAR: (Unit, f64) = (Unit::Percent, OPENROCKET_BAR_PERCENT);
732const MASS_BAR: (Unit, f64) = (Unit::Percent, MASS_BAR_PERCENT);
733const CALIBRE_BAR: (Unit, f64) = (Unit::Calibre, MARGIN_BAR_CAL);
734
735fn openrocket_example_rows(
736    report: &Value,
737    rows: &mut Vec<Row>,
738    references: &mut BTreeMap<Group, String>,
739) -> Result<(), CensusError> {
740    let group = Group::OpenRocketExamples;
741    references.insert(group, openrocket_reference(report, EXAMPLES)?);
742    let case_of = |flight: &Value| -> Result<String, CensusError> {
743        match (flight["design"].as_str(), flight["configuration"].as_str()) {
744            (Some(design), Some(configuration)) => Ok(format!("{design} / {configuration}")),
745            _ => Err(CensusError::Report {
746                report: EXAMPLES,
747                what: "a flight has no design or configuration".to_owned(),
748            }),
749        }
750    };
751    for flight in array(report, "flights", EXAMPLES)? {
752        let case = case_of(flight)?;
753        let regime = Regime::of_mach(flight["max_mach_openrocket"].as_f64().unwrap_or(f64::NAN));
754        let metrics = &flight["metrics"];
755        for key in metrics.as_object().into_iter().flat_map(|map| map.keys()) {
756            if !matches!(
757                key.as_str(),
758                "apogee_m" | "max_speed_m_s" | "rod_clearance_margin_cal"
759            ) {
760                return Err(CensusError::Report {
761                    report: EXAMPLES,
762                    what: format!("{case}: the census has no bar for the metric {key}"),
763                });
764            }
765        }
766        for (key, metric, field, bar) in [
767            ("apogee_m", "apogee", "relative_percent", APOGEE_BAR),
768            ("max_speed_m_s", "max_speed", "relative_percent", APOGEE_BAR),
769            (
770                "rod_clearance_margin_cal",
771                "margin",
772                "difference",
773                CALIBRE_BAR,
774            ),
775        ] {
776            let entry = &metrics[key];
777            let value = scored(
778                entry["outcome"]["outcome"].as_str(),
779                entry[field].as_f64(),
780                &case,
781                metric,
782                EXAMPLES,
783            )?;
784            rows.push(barred(group, &case, metric, value, bar, regime));
785        }
786        let at = &flight["at_rod_clearance"];
787        let (hpr, openrocket) = (&at["hpr"], &at["openrocket"]);
788        let cg = hpr["cg_from_nose_m"]
789            .as_f64()
790            .zip(openrocket["cg_from_nose_m"].as_f64())
791            .zip(openrocket["reference_length_m"].as_f64())
792            .map(|((h, o), reference)| (h - o) / reference)
793            .filter(|cal| cal.is_finite());
794        let launch = &flight["launch_mass_kg"];
795        for (metric, value, bar) in [
796            (
797                "launch_mass",
798                percent_of(&launch["hpr"], &launch["openrocket"]),
799                MASS_BAR,
800            ),
801            (
802                "rod_clearance_mass",
803                percent_of(&hpr["mass_kg"], &openrocket["mass_kg"]),
804                MASS_BAR,
805            ),
806            ("rod_clearance_cg", cg, CALIBRE_BAR),
807        ] {
808            let value = measured(value, flight, &case, metric, EXAMPLES)?;
809            rows.push(barred(group, &case, metric, value, bar, regime));
810        }
811    }
812    for flight in array(report, "not_flown", EXAMPLES)? {
813        rows.push(Row::unflown(
814            group,
815            case_of(flight)?,
816            Standing::NotFlown,
817            Regime::Unknown,
818        ));
819    }
820    Ok(())
821}
822
823fn openrocket_library_rows(
824    report: &Value,
825    rows: &mut Vec<Row>,
826    references: &mut BTreeMap<Group, String>,
827) -> Result<(), CensusError> {
828    let group = Group::OpenRocketLibrary;
829    references.insert(group, openrocket_reference(report, LIBRARY)?);
830    let case_of = |flight: &Value| -> Result<String, CensusError> {
831        flight["flight"]
832            .as_str()
833            .map(str::to_owned)
834            .ok_or_else(|| CensusError::Report {
835                report: LIBRARY,
836                what: "a flight has no id".to_owned(),
837            })
838    };
839    for flight in array(report, "flights", LIBRARY)? {
840        let case = case_of(flight)?;
841        let regime = match flight["mach"].as_str() {
842            Some("subsonic") => Regime::Subsonic,
843            Some("transonic") => Regime::Transonic,
844            Some("supersonic") => Regime::Supersonic,
845            Some("unknown") => Regime::Unknown,
846            other => {
847                return Err(CensusError::Report {
848                    report: LIBRARY,
849                    what: format!("{case}: no Mach class the census knows ({other:?})"),
850                });
851            }
852        };
853        for (metric, field, outcome, bar) in [
854            ("apogee", "apogee_percent", "apogee_outcome", APOGEE_BAR),
855            (
856                "max_speed",
857                "max_speed_percent",
858                "max_speed_outcome",
859                APOGEE_BAR,
860            ),
861            ("margin", "margin_cal", "margin_outcome", CALIBRE_BAR),
862        ] {
863            let value = scored(
864                flight[outcome].as_str(),
865                flight[field].as_f64(),
866                &case,
867                metric,
868                LIBRARY,
869            )?;
870            rows.push(barred(group, &case, metric, value, bar, regime));
871        }
872        for (metric, field, bar) in [
873            ("launch_mass", "launch_mass_percent", MASS_BAR),
874            ("rod_clearance_mass", "rod_clearance_mass_percent", MASS_BAR),
875            ("rod_clearance_cg", "rod_clearance_cg_cal", CALIBRE_BAR),
876        ] {
877            let value = measured(flight[field].as_f64(), flight, &case, metric, LIBRARY)?;
878            rows.push(barred(group, &case, metric, value, bar, regime));
879        }
880    }
881    for flight in array(report, "not_flown", LIBRARY)? {
882        rows.push(Row::unflown(
883            group,
884            case_of(flight)?,
885            Standing::NotFlown,
886            Regime::Unknown,
887        ));
888    }
889    Ok(())
890}
891
892/// A spread of per-cent differences: the least, the largest and the mean magnitude.
893#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
894pub struct Spread {
895    /// How many.
896    pub count: usize,
897    /// The least.
898    pub min: f64,
899    /// The largest.
900    pub max: f64,
901    /// The mean of their magnitudes.
902    pub mean_absolute: f64,
903    /// How many are within the scale they are judged by.
904    pub within: usize,
905}
906
907impl Spread {
908    fn of(rows: &[&Row]) -> Option<Self> {
909        let values: Vec<(f64, bool)> = rows
910            .iter()
911            .filter_map(|row| {
912                row.percent
913                    .map(|percent| (percent, row.difference.abs() <= row.scale))
914            })
915            .collect();
916        if values.is_empty() {
917            return None;
918        }
919        #[allow(
920            clippy::cast_precision_loss,
921            reason = "a count of rows, far below 2^52"
922        )]
923        let count = values.len() as f64;
924        Some(Self {
925            count: values.len(),
926            min: values.iter().map(|v| v.0).fold(f64::INFINITY, f64::min),
927            max: values.iter().map(|v| v.0).fold(f64::NEG_INFINITY, f64::max),
928            mean_absolute: values.iter().map(|v| v.0.abs()).sum::<f64>() / count,
929            within: values.iter().filter(|v| v.1).count(),
930        })
931    }
932}
933
934/// How many of one metric's numbers are within the bar they are judged by.
935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
936pub struct BarCount {
937    /// The metric.
938    pub metric: String,
939    /// Its bar.
940    pub scale: f64,
941    /// The bar's unit.
942    pub unit: Unit,
943    /// How many are within it.
944    pub within: usize,
945    /// How many were compared.
946    pub compared: usize,
947}
948
949/// The metrics judged by a bar, in the order the census reads them out, with their names and
950/// what their bar is a share of, where that isn't the reference's own value.
951const BARRED: [(&str, &str, &str); 7] = [
952    ("apogee", "apogee", ""),
953    ("max_speed", "largest speed", ""),
954    ("margin", "margin", ""),
955    ("launch_mass", "launch mass", ""),
956    ("rod_clearance_mass", "mass at rod clearance", ""),
957    ("rod_clearance_cg", "center of mass at rod clearance", ""),
958    ("climb", "climb's RMS height error", " of apogee"),
959];
960
961/// What one group adds up to.
962#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
963pub struct Summary {
964    /// The group.
965    pub group: Group,
966    /// What it was compared with.
967    pub reference: String,
968    /// Its cases compared, or refused as known gaps: flights, or descents.
969    pub cases: usize,
970    /// Its cases the report lists as not flown.
971    pub not_flown: usize,
972    /// Its cases by speed, not-flown ones left out.
973    pub regimes: BTreeMap<Regime, usize>,
974    /// Its rows.
975    pub rows: usize,
976    /// Its rows by standing.
977    pub standings: BTreeMap<Standing, usize>,
978    /// Each metric judged by a bar, in the census's order.
979    pub bars: Vec<BarCount>,
980    /// The apogee differences, per cent: the harness's relative to RocketPy's, OpenRocket's and the
981    /// logs' as their reports give them. `None` for descents.
982    pub apogee_percent: Option<Spread>,
983}
984
985impl Summary {
986    fn of(census: &Census, group: Group) -> Option<Self> {
987        let rows: Vec<&Row> = census
988            .rows
989            .iter()
990            .filter(|row| row.group == group)
991            .collect();
992        if rows.is_empty() {
993            return None;
994        }
995        let mut cases: BTreeMap<&str, Regime> = BTreeMap::new();
996        let mut not_flown = BTreeSet::new();
997        let mut standings = BTreeMap::new();
998        for row in &rows {
999            if row.standing == Standing::NotFlown {
1000                not_flown.insert(row.case.as_str());
1001            } else {
1002                cases.insert(&row.case, row.regime);
1003            }
1004            *standings.entry(row.standing).or_insert(0) += 1;
1005        }
1006        let mut regimes = BTreeMap::new();
1007        for regime in cases.values() {
1008            *regimes.entry(*regime).or_insert(0) += 1;
1009        }
1010        let bars = BARRED
1011            .iter()
1012            .filter_map(|(metric, ..)| {
1013                let judged: Vec<&Row> = rows
1014                    .iter()
1015                    .copied()
1016                    .filter(|row| {
1017                        row.metric == *metric
1018                            && matches!(row.standing, Standing::WithinBar | Standing::OverBar)
1019                    })
1020                    .collect();
1021                let first = judged.first()?;
1022                Some(BarCount {
1023                    metric: (*metric).to_owned(),
1024                    scale: first.scale,
1025                    unit: first.unit,
1026                    within: judged
1027                        .iter()
1028                        .filter(|row| row.standing == Standing::WithinBar)
1029                        .count(),
1030                    compared: judged.len(),
1031                })
1032            })
1033            .collect();
1034        let apogees: Vec<&Row> = rows
1035            .iter()
1036            .copied()
1037            .filter(|row| matches!(row.metric.as_str(), "apogee" | "apogee_agl_m"))
1038            .collect();
1039        Some(Self {
1040            group,
1041            reference: census.references.get(&group).cloned().unwrap_or_default(),
1042            cases: cases.len(),
1043            not_flown: not_flown.len(),
1044            regimes,
1045            rows: rows.len(),
1046            standings,
1047            bars,
1048            apogee_percent: Spread::of(&apogees),
1049        })
1050    }
1051
1052    fn count(&self, standing: Standing) -> usize {
1053        self.standings.get(&standing).copied().unwrap_or(0)
1054    }
1055
1056    /// Its cases by speed, in words: "7 subsonic, 1 transonic". Descents are left out: their noun
1057    /// already says it.
1058    #[must_use]
1059    pub fn regimes_in_words(&self) -> String {
1060        self.regimes
1061            .iter()
1062            .filter(|(regime, _)| **regime != Regime::Descent)
1063            .map(|(regime, count)| format!("{count} {}", regime.name()))
1064            .collect::<Vec<_>>()
1065            .join(", ")
1066    }
1067
1068    /// Its population in words: "33 flights (32 subsonic, 1 transonic); 24 more not flown".
1069    #[must_use]
1070    pub fn population(&self) -> String {
1071        let mut out = format!("{} {}", self.cases, self.group.noun(self.cases));
1072        let regimes = self.regimes_in_words();
1073        if !regimes.is_empty() {
1074            let _ = write!(out, " ({regimes})");
1075        }
1076        if self.not_flown > 0 {
1077            let _ = write!(out, "; {} more not flown", self.not_flown);
1078        }
1079        out
1080    }
1081
1082    /// Its result in words: the counts against its gate, target or bars, and its apogees.
1083    #[must_use]
1084    pub fn result(&self) -> String {
1085        let mut parts = Vec::new();
1086        let gated = self.count(Standing::Pass) + self.count(Standing::Fail);
1087        let targeted = self.count(Standing::WithinTarget) + self.count(Standing::OutsideTarget);
1088        if gated > 0 {
1089            parts.push(format!(
1090                "{} of {gated} gated metrics pass",
1091                self.count(Standing::Pass)
1092            ));
1093        }
1094        if targeted > 0 {
1095            parts.push(format!(
1096                "{} of {targeted} metrics within target",
1097                self.count(Standing::WithinTarget)
1098            ));
1099        }
1100        if let (Group::FlightLogs, Some(apogee)) = (self.group, self.apogee_percent) {
1101            parts.push(format!(
1102                "mean absolute apogee error {:.2}% (target {APOGEE_TARGET_PERCENT}%, {}); apogee \
1103                 {:+.2}% to {:+.2}%",
1104                apogee.mean_absolute,
1105                if apogee.mean_absolute <= APOGEE_TARGET_PERCENT {
1106                    "met"
1107                } else {
1108                    "missed"
1109                },
1110                apogee.min,
1111                apogee.max,
1112            ));
1113        } else if let Some(apogee) = self.apogee_percent {
1114            parts.push(format!("apogee {:+.2}% to {:+.2}%", apogee.min, apogee.max));
1115        }
1116        let bars: Vec<String> = self
1117            .bars
1118            .iter()
1119            .map(|bar| {
1120                let (name, of) = BARRED
1121                    .iter()
1122                    .find(|(metric, ..)| *metric == bar.metric)
1123                    .map_or((bar.metric.as_str(), ""), |(_, name, of)| (name, of));
1124                format!(
1125                    "{name} within {}{}{of} on {} of {}",
1126                    bar.scale,
1127                    match bar.unit {
1128                        Unit::Calibre => " calibres",
1129                        Unit::Percent => "%",
1130                        Unit::Metric => "",
1131                    },
1132                    bar.within,
1133                    bar.compared
1134                )
1135            })
1136            .collect();
1137        if !bars.is_empty() {
1138            parts.push(bars.join(", "));
1139        }
1140        for (standing, words) in [
1141            (Standing::NotScored, "not scored, each for a written reason"),
1142            (Standing::Withheld, "values withheld by the report"),
1143            (Standing::Gap, "known gaps"),
1144        ] {
1145            if self.count(standing) > 0 {
1146                parts.push(format!("{} {words}", self.count(standing)));
1147            }
1148        }
1149        parts.join("; ")
1150    }
1151
1152    /// Its headline: what was flown, against what and of which kind, how many and how fast, and
1153    /// the result ([Loft lesson L86][l86]).
1154    ///
1155    /// [l86]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l86
1156    #[must_use]
1157    pub fn headline(&self) -> String {
1158        format!(
1159            "{} against {} ({}; {}): {}; {}.",
1160            capitalised(self.group.what()),
1161            self.reference,
1162            self.group.kind(),
1163            self.group.bar(),
1164            self.population(),
1165            self.result()
1166        )
1167    }
1168}
1169
1170fn capitalised(text: &str) -> String {
1171    let mut chars = text.chars();
1172    chars.next().map_or_else(String::new, |first| {
1173        first.to_uppercase().chain(chars).collect()
1174    })
1175}
1176
1177/// A reference as the table shows it: RocketPy's name and version, marked when patched, and
1178/// every other reference as its report names it. The census page gives each in full.
1179#[must_use]
1180pub fn short_reference(reference: &str) -> String {
1181    reference
1182        .split("; ")
1183        .map(|one| match one.strip_prefix("rocketpy ") {
1184            Some(rest) => {
1185                let (version, more) = rest.split_once(' ').unwrap_or((rest, ""));
1186                if more.is_empty() {
1187                    format!("RocketPy {version}")
1188                } else {
1189                    format!("RocketPy {version}, patched")
1190                }
1191            }
1192            None => one.to_owned(),
1193        })
1194        .collect::<Vec<_>>()
1195        .join("; ")
1196}
1197
1198/// How the committed reports differ from the census accepted last.
1199#[derive(Debug, Clone, PartialEq)]
1200#[non_exhaustive]
1201pub enum Change {
1202    /// A row the accepted census doesn't have.
1203    Added(Row),
1204    /// A row the accepted census has and this one doesn't.
1205    Removed(Row),
1206    /// A known gap, or a configuration not flown, that hpr flies now.
1207    Flown {
1208        /// The case's row as accepted.
1209        accepted: Row,
1210        /// Its rows now.
1211        now: Vec<Row>,
1212    },
1213    /// A row whose standing changed, better or worse.
1214    Standing {
1215        /// As accepted.
1216        accepted: Row,
1217        /// Now.
1218        now: Row,
1219    },
1220    /// A row that moved by more than its slack, further from its reference.
1221    Regressed {
1222        /// As accepted.
1223        accepted: Row,
1224        /// Now.
1225        now: Row,
1226    },
1227    /// A row that moved by more than its slack, closer to its reference or across it.
1228    Improved {
1229        /// As accepted.
1230        accepted: Row,
1231        /// Now.
1232        now: Row,
1233    },
1234    /// A row whose unit, scale, slack or speed class changed, or whose percentage no longer
1235    /// follows its difference (its reference moved): it is not the same comparison.
1236    Redefined {
1237        /// As accepted.
1238        accepted: Row,
1239        /// Now.
1240        now: Row,
1241    },
1242    /// A group compared with another reference, or another version of it.
1243    Reference {
1244        /// The group.
1245        group: Group,
1246        /// As accepted; empty if it had none.
1247        accepted: String,
1248        /// Now; empty if it has none.
1249        now: String,
1250    },
1251    /// The census's slack rule changed.
1252    Rule {
1253        /// [`Census::slack_share`] as accepted.
1254        accepted: f64,
1255        /// Now.
1256        now: f64,
1257    },
1258}
1259
1260impl Change {
1261    /// One line saying what changed.
1262    #[must_use]
1263    pub fn describe(&self) -> String {
1264        let moved = |accepted: &Row, now: &Row| {
1265            format!(
1266                "{:+.6}{unit} ({:.2}% of its scale) to {:+.6}{unit} ({:.2}%), slack {:.2e}",
1267                accepted.difference,
1268                100.0 * accepted.share(),
1269                now.difference,
1270                100.0 * now.share(),
1271                accepted.slack,
1272                unit = now.unit.suffix()
1273            )
1274        };
1275        match self {
1276            Self::Added(row) => format!("added: {} ({})", row.describe(), row.standing.name()),
1277            Self::Removed(row) => format!("removed: {} ({})", row.describe(), row.standing.name()),
1278            Self::Flown { accepted, now } => format!(
1279                "flown: {}, {} before: hpr flies it now (L85); {}",
1280                accepted.describe(),
1281                accepted.standing.name(),
1282                now.iter()
1283                    .map(|row| format!(
1284                        "{} {:+.6}{} ({})",
1285                        row.metric,
1286                        row.difference,
1287                        row.unit.suffix(),
1288                        row.standing.name()
1289                    ))
1290                    .collect::<Vec<_>>()
1291                    .join(", ")
1292            ),
1293            Self::Standing { accepted, now } => format!(
1294                "{}: {} to {}; {}",
1295                now.describe(),
1296                accepted.standing.name(),
1297                now.standing.name(),
1298                moved(accepted, now)
1299            ),
1300            Self::Regressed { accepted, now } => {
1301                format!("regressed: {}: {}", now.describe(), moved(accepted, now))
1302            }
1303            Self::Improved { accepted, now } => {
1304                format!("improved: {}: {}", now.describe(), moved(accepted, now))
1305            }
1306            Self::Redefined { accepted, now } => format!(
1307                "redefined: {}: unit, scale, slack, speed class or percentage changed ({:?}, {}, \
1308                 {:.2e}, {}, {:?} to {:?}, {}, {:.2e}, {}, {:?})",
1309                now.describe(),
1310                accepted.unit,
1311                accepted.scale,
1312                accepted.slack,
1313                accepted.regime.name(),
1314                accepted.percent,
1315                now.unit,
1316                now.scale,
1317                now.slack,
1318                now.regime.name(),
1319                now.percent
1320            ),
1321            Self::Reference {
1322                group,
1323                accepted,
1324                now,
1325            } => format!(
1326                "reference: {} compared with `{accepted}` before and `{now}` now",
1327                group.what()
1328            ),
1329            Self::Rule { accepted, now } => {
1330                format!("rule: the slack's share of a row's scale was {accepted} and is {now}")
1331            }
1332        }
1333    }
1334
1335    /// Whether it makes the record worse: a regression, a row lost, a standing that fell, a
1336    /// wider scale or slack, a changed unit, or a case flown now that fails or misses a bar.
1337    #[must_use]
1338    pub fn is_worse(&self) -> bool {
1339        match self {
1340            Self::Regressed { .. } | Self::Removed(_) => true,
1341            Self::Standing { accepted, now } => now.standing.rank() < accepted.standing.rank(),
1342            Self::Rule { accepted, now } => now > accepted,
1343            Self::Redefined { accepted, now } => {
1344                now.unit != accepted.unit
1345                    || now.scale > accepted.scale
1346                    || now.slack > accepted.slack
1347            }
1348            Self::Flown { now, .. } => now.iter().any(|row| {
1349                matches!(
1350                    row.standing,
1351                    Standing::Fail | Standing::OverBar | Standing::OutsideTarget
1352                )
1353            }),
1354            Self::Added(_) | Self::Improved { .. } | Self::Reference { .. } => false,
1355        }
1356    }
1357}
1358
1359/// Every way `now` differs from `accepted`. Empty when the census holds.
1360///
1361/// A row is a change when its unit, scale, slack, speed class or percentage's reference differs
1362/// ([`Change::Redefined`], listed as well as what follows), when its standing differs, or when its
1363/// difference moved by more than the accepted slack either way: a ratchet that only watched for
1364/// regressions would let an improvement slip back unseen. A case that was a known gap or not
1365/// flown, and is compared now, is one [`Change::Flown`] carrying its new rows.
1366#[must_use]
1367pub fn compare(accepted: &Census, now: &Census) -> Vec<Change> {
1368    let mut changes = Vec::new();
1369    if accepted.slack_share.to_bits() != now.slack_share.to_bits() {
1370        changes.push(Change::Rule {
1371            accepted: accepted.slack_share,
1372            now: now.slack_share,
1373        });
1374    }
1375    let groups: BTreeSet<&Group> = accepted
1376        .references
1377        .keys()
1378        .chain(now.references.keys())
1379        .collect();
1380    for group in groups {
1381        let (was, is) = (
1382            accepted.references.get(group).cloned().unwrap_or_default(),
1383            now.references.get(group).cloned().unwrap_or_default(),
1384        );
1385        if was != is {
1386            changes.push(Change::Reference {
1387                group: *group,
1388                accepted: was,
1389                now: is,
1390            });
1391        }
1392    }
1393    let before: BTreeMap<_, &Row> = accepted.rows.iter().map(|row| (row.key(), row)).collect();
1394    let after: BTreeMap<_, &Row> = now.rows.iter().map(|row| (row.key(), row)).collect();
1395    // The cases compared now, and those among them that were known gaps or not flown before:
1396    // their new rows come with them.
1397    let compared_now: BTreeSet<(Group, &str)> = now
1398        .rows
1399        .iter()
1400        .filter(|row| !row.standing.unflown())
1401        .map(|row| (row.group, row.case.as_str()))
1402        .collect();
1403    let newly_flown: BTreeSet<(Group, &str)> = accepted
1404        .rows
1405        .iter()
1406        .filter(|row| row.standing.unflown() && !after.contains_key(&row.key()))
1407        .map(|row| (row.group, row.case.as_str()))
1408        .filter(|case| compared_now.contains(case))
1409        .collect();
1410    let keys: BTreeSet<_> = before.keys().chain(after.keys()).copied().collect();
1411    let differs = |a: f64, b: f64| (a - b).abs() > 1e-9 * a.abs().max(b.abs());
1412    for key in keys {
1413        let flown = newly_flown.contains(&(key.0, key.1));
1414        match (before.get(&key), after.get(&key)) {
1415            (Some(was), None) if flown => changes.push(Change::Flown {
1416                accepted: (*was).clone(),
1417                now: now
1418                    .rows
1419                    .iter()
1420                    .filter(|row| row.group == key.0 && row.case == key.1)
1421                    .cloned()
1422                    .collect(),
1423            }),
1424            (Some(was), None) => changes.push(Change::Removed((*was).clone())),
1425            (None, Some(_)) if flown => {}
1426            (None, Some(is)) => changes.push(Change::Added((*is).clone())),
1427            (Some(was), Some(is)) => {
1428                let (was, is) = ((*was).clone(), (*is).clone());
1429                if was.unit != is.unit
1430                    || was.regime != is.regime
1431                    || differs(was.scale, is.scale)
1432                    || differs(was.slack, is.slack)
1433                    || !percent_follows(&was, &is)
1434                {
1435                    changes.push(Change::Redefined {
1436                        accepted: was.clone(),
1437                        now: is.clone(),
1438                    });
1439                }
1440                if was.standing != is.standing {
1441                    changes.push(Change::Standing {
1442                        accepted: was,
1443                        now: is,
1444                    });
1445                } else if (is.difference - was.difference).abs() > was.slack {
1446                    if is.difference.abs() > was.difference.abs() {
1447                        changes.push(Change::Regressed {
1448                            accepted: was,
1449                            now: is,
1450                        });
1451                    } else {
1452                        changes.push(Change::Improved {
1453                            accepted: was,
1454                            now: is,
1455                        });
1456                    }
1457                }
1458            }
1459            (None, None) => {}
1460        }
1461    }
1462    changes
1463}
1464
1465/// Whether `now`'s percentage is still its difference over the reference `accepted`'s was: a
1466/// percentage that moved on its own means the reference moved, which the difference can't show.
1467/// Held to the accepted slack, carried into per cent.
1468fn percent_follows(accepted: &Row, now: &Row) -> bool {
1469    match (accepted.percent, now.percent) {
1470        (None, None) => true,
1471        // A zero difference has a zero percentage, and says nothing of its reference.
1472        (Some(was), Some(_)) if accepted.difference == 0.0 => was == 0.0,
1473        (Some(was), Some(is)) => {
1474            // Per cent per unit of difference: 100 over the reference, as accepted.
1475            let per_unit = was / accepted.difference;
1476            let expected = now.difference * per_unit;
1477            (is - expected).abs()
1478                <= accepted.slack * per_unit.abs() + 1e-9 * is.abs().max(expected.abs())
1479        }
1480        // A difference withheld, or no longer withheld, is a change of standing, not of definition.
1481        _ => accepted.standing == Standing::Withheld || now.standing == Standing::Withheld,
1482    }
1483}
1484
1485/// The census as accepted: the rows, what they add up to, and why it was last accepted.
1486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1487pub struct Accepted {
1488    /// The command that writes it.
1489    pub generated_by: String,
1490    /// Why the census was last accepted, in the words of whoever accepted it.
1491    pub reason: String,
1492    /// What changed then, one line each.
1493    pub changes: Vec<String>,
1494    /// What each group added up to: [`Census::summaries`], written out for a reader, and
1495    /// never read back: [`Accepted::refreshed`] computes them again.
1496    #[serde(default, skip_deserializing)]
1497    pub summaries: Vec<Summary>,
1498    /// The census.
1499    pub census: Census,
1500}
1501
1502impl Accepted {
1503    /// The census `census`, accepted over `previous` for `reason`.
1504    #[must_use]
1505    pub fn new(census: Census, previous: Option<&Census>, reason: &str) -> Self {
1506        let changes = previous.map_or_else(
1507            || vec![format!("the first census: {} rows", census.rows.len())],
1508            |previous| {
1509                compare(previous, &census)
1510                    .iter()
1511                    .map(Change::describe)
1512                    .collect()
1513            },
1514        );
1515        Self {
1516            generated_by: "cargo xtask census --accept".to_owned(),
1517            reason: reason.trim().to_owned(),
1518            changes,
1519            summaries: census.summaries(),
1520            census,
1521        }
1522    }
1523
1524    /// The same, with its summaries computed again from its rows.
1525    #[must_use]
1526    pub fn refreshed(&self) -> Self {
1527        Self {
1528            summaries: self.census.summaries(),
1529            ..self.clone()
1530        }
1531    }
1532
1533    /// The census page, `validation/reports/census.md`.
1534    #[must_use]
1535    pub fn to_markdown(&self) -> String {
1536        let summaries = self.census.summaries();
1537        let mut out = String::new();
1538        let _ = writeln!(out, "# Accuracy census\n");
1539        let _ = writeln!(
1540            out,
1541            "Generated by `{}` from the committed reports; do not edit. `cargo xtask validate \
1542             --check` holds those reports to this census, row by row: a row that moves by more \
1543             than its slack, changes its standing, or comes or goes, a reference that changes, or \
1544             a changed slack rule fails it until the census is accepted again with a reason. It \
1545             holds {} rows.\n",
1546            self.generated_by,
1547            self.census.rows.len()
1548        );
1549        let _ = writeln!(out, "## Headlines\n");
1550        for summary in &summaries {
1551            let _ = writeln!(out, "- {}", summary.headline());
1552        }
1553        let _ = writeln!(out);
1554        let _ = writeln!(out, "{}", table(&summaries, None));
1555        let _ = writeln!(out, "{}", reading(self.census.slack_share));
1556        let _ = writeln!(out, "## Rows by standing\n");
1557        let _ = writeln!(out, "| group | metric | standing | rows |");
1558        let _ = writeln!(out, "|---|---|---|---:|");
1559        let mut counts: BTreeMap<(Group, &str, Standing), usize> = BTreeMap::new();
1560        for row in &self.census.rows {
1561            let metric = if row.metric.is_empty() {
1562                "(the case)"
1563            } else {
1564                row.metric.as_str()
1565            };
1566            *counts.entry((row.group, metric, row.standing)).or_insert(0) += 1;
1567        }
1568        for ((group, metric, standing), count) in counts {
1569            let _ = writeln!(
1570                out,
1571                "| {} | {metric} | {} | {count} |",
1572                group.what(),
1573                standing.name()
1574            );
1575        }
1576        let _ = writeln!(out);
1577        let _ = writeln!(out, "## Last accepted\n");
1578        let _ = writeln!(out, "Reason: {}\n", self.reason);
1579        const SHOWN: usize = 50;
1580        for change in self.changes.iter().take(SHOWN) {
1581            let _ = writeln!(out, "- {change}");
1582        }
1583        if self.changes.len() > SHOWN {
1584            let _ = writeln!(
1585                out,
1586                "- and {} more, in `census.json`",
1587                self.changes.len() - SHOWN
1588            );
1589        }
1590        out
1591    }
1592}
1593
1594/// How to read the census: its speed classes, its slack and each kind of row's scale, from the
1595/// constants that set them.
1596fn reading(slack_share: f64) -> String {
1597    // A worked example: a relative tolerance on a round apogee.
1598    let (tolerance, apogee_m) = (NOT_SCORED_SCALE, 1000.0);
1599    let allowed_m = tolerance * apogee_m;
1600    let mut out = String::new();
1601    let _ = writeln!(out, "## How to read it\n");
1602    let _ = writeln!(
1603        out,
1604        "Each flight is classed by its largest Mach number: subsonic below {SUBSONIC_BELOW}, \
1605         transonic from {SUBSONIC_BELOW} to {SUPERSONIC_ABOVE}, supersonic above \
1606         {SUPERSONIC_ABOVE}. The harness's flights are classed by RocketPy's largest Mach \
1607         number, OpenRocket's by OpenRocket's, and the logged flights by hpr's, since a log has \
1608         none.\n"
1609    );
1610    let _ = writeln!(
1611        out,
1612        "A row's slack is {}% of its scale, and for a harness row never less than the harness's \
1613         reproduction bound, 2e-6 or 1e-7 of the value, whichever is larger. The scale is the \
1614         row's own tolerance where it has one, and otherwise the bar of its kind:\n",
1615        100.0 * slack_share
1616    );
1617    let _ = writeln!(out, "| row | scale |");
1618    let _ = writeln!(out, "|---|---|");
1619    for (row, scale) in [
1620        (
1621            "harness metric, held to a tolerance",
1622            "that tolerance".to_owned(),
1623        ),
1624        (
1625            "harness metric, not scored",
1626            format!("{}% of its reference", 100.0 * NOT_SCORED_SCALE),
1627        ),
1628        (
1629            "OpenRocket apogee or largest speed",
1630            format!("{OPENROCKET_BAR_PERCENT}%"),
1631        ),
1632        (
1633            "OpenRocket mass, at launch or at rod clearance",
1634            format!("{MASS_BAR_PERCENT}%"),
1635        ),
1636        (
1637            "OpenRocket stability margin, or center of mass",
1638            format!("{MARGIN_BAR_CAL} calibres"),
1639        ),
1640        ("logged apogee", format!("{APOGEE_TARGET_PERCENT}%")),
1641        (
1642            "logged climb (the RMS of the climb's heights, as a share of the apogee)",
1643            format!("{TRACE_BAR_PERCENT}%"),
1644        ),
1645    ] {
1646        let _ = writeln!(out, "| {row} | {scale} |");
1647    }
1648    let _ = writeln!(
1649        out,
1650        "\nFor example, a {}% tolerance on a {apogee_m} m apogee allows {allowed_m} m, and the \
1651         census lets the difference move by {} m before it fails.",
1652        100.0 * tolerance,
1653        slack_share * allowed_m
1654    );
1655    out
1656}
1657
1658/// The census table, as the README and the accuracy page show it, each row's reference linked to
1659/// `link` (the census page) where one is given.
1660#[must_use]
1661pub fn table(summaries: &[Summary], link: Option<&str>) -> String {
1662    let mut out = String::new();
1663    let _ = writeln!(
1664        out,
1665        "| compared with | kind | held to | flights (speed) | result |"
1666    );
1667    let _ = writeln!(out, "|---|---|---|---|---|");
1668    for summary in summaries {
1669        let reference = short_reference(&summary.reference);
1670        let _ = writeln!(
1671            out,
1672            "| {}: {} | {} | {} | {} | {} |",
1673            link.map_or_else(
1674                || reference.clone(),
1675                |link| format!("[{reference}]({link})")
1676            ),
1677            summary.group.what(),
1678            summary.group.kind(),
1679            summary.group.bar(),
1680            summary.population(),
1681            summary.result()
1682        );
1683    }
1684    out
1685}
1686
1687/// One badge: a flat SVG, grey label on the left, colored message on the right.
1688fn badge(label: &str, message: &str, color: &str) -> String {
1689    // Verdana at 11 px averages about 6.5 px a character; 10 px of padding each side.
1690    let width = |text: &str| 20 + (13 * text.chars().count()).div_ceil(2);
1691    let (left, right) = (width(label), width(message));
1692    let total = left + right;
1693    format!(
1694        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total}\" height=\"20\" role=\"img\" \
1695         aria-label=\"{label}: {message}\">\n\
1696         <title>{label}: {message}</title>\n\
1697         <rect width=\"{left}\" height=\"20\" fill=\"#555\"/>\n\
1698         <rect x=\"{left}\" width=\"{right}\" height=\"20\" fill=\"{color}\"/>\n\
1699         <g fill=\"#fff\" font-family=\"Verdana,DejaVu Sans,sans-serif\" font-size=\"11\" \
1700         text-anchor=\"middle\">\n\
1701         <text x=\"{}\" y=\"14\">{label}</text>\n\
1702         <text x=\"{}\" y=\"14\">{message}</text>\n\
1703         </g>\n\
1704         </svg>\n",
1705        left / 2,
1706        left + right / 2
1707    )
1708}
1709
1710/// The badges, by file name: the gated code-to-code metrics that pass, of all of them, and the
1711/// real flights' mean apogee error against its target. Two, so the one measurement stands beside
1712/// the agreement with another program.
1713#[must_use]
1714pub fn badges(summaries: &[Summary]) -> Vec<(&'static str, String)> {
1715    let (pass, gated) = summaries
1716        .iter()
1717        .filter(|summary| matches!(summary.group, Group::Descent | Group::SameDrag))
1718        .fold((0, 0), |(pass, gated), summary| {
1719            (
1720                pass + summary.count(Standing::Pass),
1721                gated + summary.count(Standing::Pass) + summary.count(Standing::Fail),
1722            )
1723        });
1724    let mut out = vec![(
1725        "census-badge.svg",
1726        badge(
1727            "vs RocketPy, same inputs",
1728            &format!("{pass} of {gated} gated metrics pass"),
1729            if pass == gated && gated > 0 {
1730                "#2e7d32"
1731            } else {
1732                "#c62828"
1733            },
1734        ),
1735    )];
1736    let flights = summaries
1737        .iter()
1738        .find(|summary| summary.group == Group::FlightLogs)
1739        .and_then(|summary| summary.apogee_percent);
1740    // Always written, so a badge from an earlier census can't linger unchecked.
1741    out.push((
1742        "real-flights-badge.svg",
1743        flights.map_or_else(
1744            || badge("real flights", "none compared", "#6e6e6e"),
1745            |apogee| {
1746                let met = apogee.mean_absolute <= APOGEE_TARGET_PERCENT;
1747                badge(
1748                    &format!("{} real flights", apogee.count),
1749                    &format!(
1750                        "mean absolute apogee error {:.2}% (target {APOGEE_TARGET_PERCENT}%, {})",
1751                        apogee.mean_absolute,
1752                        if met { "met" } else { "missed" }
1753                    ),
1754                    if met { "#2e7d32" } else { "#b35c00" },
1755                )
1756            },
1757        ),
1758    ));
1759    out
1760}
1761
1762#[cfg(test)]
1763mod tests;