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