1use 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
47pub const SLACK_SHARE: f64 = 1e-3;
53
54#[must_use]
58pub fn reproduction_bound(value: f64) -> f64 {
59 2e-6_f64.max(1e-7 * value.abs())
60}
61
62pub const NOT_SCORED_SCALE: f64 = 0.03;
68
69pub const OPENROCKET_BAR_PERCENT: f64 = 5.0;
76
77pub const MASS_BAR_PERCENT: f64 = 1.0;
82
83pub const MARGIN_BAR_CAL: f64 = 0.5;
89
90pub const TRACE_BAR_PERCENT: f64 = 3.0;
95
96pub const SUBSONIC_BELOW: f64 = 0.8;
98pub const SUPERSONIC_ABOVE: f64 = 1.2;
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104#[non_exhaustive]
105pub enum Group {
106 Descent,
108 SameDrag,
110 Predicted,
112 OpenRocketExamples,
114 OpenRocketLibrary,
116 FlightLogs,
118}
119
120impl Group {
121 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 #[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 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195#[non_exhaustive]
196pub enum Regime {
197 Descent,
199 Subsonic,
201 Transonic,
203 Supersonic,
205 Unknown,
207}
208
209impl Regime {
210 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241#[non_exhaustive]
242pub enum Standing {
243 Pass,
245 Fail,
247 WithinTarget,
249 OutsideTarget,
251 NotScored,
253 WithinBar,
255 OverBar,
257 Withheld,
259 Gap,
261 NotFlown,
263}
264
265impl Standing {
266 #[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 const fn unflown(self) -> bool {
285 matches!(self, Self::Gap | Self::NotFlown)
286 }
287
288 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304#[non_exhaustive]
305pub enum Unit {
306 Metric,
308 Percent,
310 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct Row {
327 pub group: Group,
329 pub case: String,
332 pub metric: String,
334 pub difference: f64,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub percent: Option<f64>,
339 pub unit: Unit,
341 pub scale: f64,
343 pub slack: f64,
345 pub standing: Standing,
347 pub regime: Regime,
349}
350
351impl Row {
352 fn key(&self) -> (Group, &str, &str) {
353 (self.group, &self.case, &self.metric)
354 }
355
356 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
398pub struct Census {
399 pub slack_share: f64,
401 pub references: BTreeMap<Group, String>,
403 pub rows: Vec<Row>,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
409#[non_exhaustive]
410pub enum CensusError {
411 #[error("{report}: {what}")]
413 Report {
414 report: &'static str,
416 what: String,
418 },
419 #[error("{report}: {case} {metric} is compared twice; a case counts once (L84)")]
421 Duplicate {
422 report: &'static str,
424 case: String,
426 metric: String,
428 },
429}
430
431#[derive(Debug, Clone, Copy)]
433pub struct Reports<'a> {
434 pub harness: &'a Report,
436 pub real_flights: &'a RealFlightReport,
438 pub openrocket_examples: &'a Value,
440 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 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 #[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
595fn 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 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
676fn 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
698fn 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
723fn 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
894pub struct Spread {
895 pub count: usize,
897 pub min: f64,
899 pub max: f64,
901 pub mean_absolute: f64,
903 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
936pub struct BarCount {
937 pub metric: String,
939 pub scale: f64,
941 pub unit: Unit,
943 pub within: usize,
945 pub compared: usize,
947}
948
949const 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
963pub struct Summary {
964 pub group: Group,
966 pub reference: String,
968 pub cases: usize,
970 pub not_flown: usize,
972 pub regimes: BTreeMap<Regime, usize>,
974 pub rows: usize,
976 pub standings: BTreeMap<Standing, usize>,
978 pub bars: Vec<BarCount>,
980 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 #[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 #[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 #[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 #[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#[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#[derive(Debug, Clone, PartialEq)]
1200#[non_exhaustive]
1201pub enum Change {
1202 Added(Row),
1204 Removed(Row),
1206 Flown {
1208 accepted: Row,
1210 now: Vec<Row>,
1212 },
1213 Standing {
1215 accepted: Row,
1217 now: Row,
1219 },
1220 Regressed {
1222 accepted: Row,
1224 now: Row,
1226 },
1227 Improved {
1229 accepted: Row,
1231 now: Row,
1233 },
1234 Redefined {
1237 accepted: Row,
1239 now: Row,
1241 },
1242 Reference {
1244 group: Group,
1246 accepted: String,
1248 now: String,
1250 },
1251 Rule {
1253 accepted: f64,
1255 now: f64,
1257 },
1258}
1259
1260impl Change {
1261 #[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 #[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#[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 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
1465fn percent_follows(accepted: &Row, now: &Row) -> bool {
1469 match (accepted.percent, now.percent) {
1470 (None, None) => true,
1471 (Some(was), Some(_)) if accepted.difference == 0.0 => was == 0.0,
1473 (Some(was), Some(is)) => {
1474 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 _ => accepted.standing == Standing::Withheld || now.standing == Standing::Withheld,
1482 }
1483}
1484
1485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1487pub struct Accepted {
1488 pub generated_by: String,
1490 pub reason: String,
1492 pub changes: Vec<String>,
1494 #[serde(default, skip_deserializing)]
1497 pub summaries: Vec<Summary>,
1498 pub census: Census,
1500}
1501
1502impl Accepted {
1503 #[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 #[must_use]
1526 pub fn refreshed(&self) -> Self {
1527 Self {
1528 summaries: self.census.summaries(),
1529 ..self.clone()
1530 }
1531 }
1532
1533 #[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
1594fn reading(slack_share: f64) -> String {
1597 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#[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
1687fn badge(label: &str, message: &str, color: &str) -> String {
1689 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#[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 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;