1use std::path::Path;
4
5use hpr_aero::drag::BUILDUP_MACH_LIMIT;
6use hpr_aero::{AeroError, DragTable, NORMAL_FORCE_MACH_LIMIT};
7use hpr_atmos::AtmosphereModel;
8use hpr_atmos::{LayeredWind, WindInterpolation, WindLevel};
9use hpr_core::DVec3;
10use hpr_core::earth::{Earth, EarthRotation, GravityModel};
11use hpr_core::geodesy::Geodetic;
12use hpr_core::gravity::NormalGravity;
13use hpr_core::interp::{Extrapolation, Interpolation, Table1D};
14use hpr_design::{MassProperties, Rocket};
15use hpr_sim::{
16 Adaptive, Device, DeviceDrag, Direction, Environment, EventKind, FlightSettings, FlightStep,
17 Method, Observer, Phase, Rail, Sample, SimError, Simulation, State, Termination, Trigger,
18 UserEvent,
19};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use crate::case::{Case, CaseLock, DragMode, Flight, cases_dir, committed_cases};
24use crate::metrics::{Measured, Reference};
25use crate::report::{Comparison, Gap, Report, Source};
26
27pub const GAP_CLOSED_HINT: &str = "remove the gap and score it";
30
31pub const HINTS: [&str; 2] = [crate::committed::CENSUS_MISSING_HINT, GAP_CLOSED_HINT];
34
35#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum ValidateError {
39 #[error("{what} {path}: {source}")]
41 Io {
42 what: &'static str,
44 path: String,
46 source: std::io::Error,
48 },
49 #[error("{path} is not a valid case file: {source}")]
51 Toml {
52 path: String,
54 source: Box<toml::de::Error>,
56 },
57 #[error("{path} is not valid JSON: {source}")]
59 Json {
60 path: String,
62 source: Box<serde_json::Error>,
64 },
65 #[error("{0}")]
67 Case(String),
68 #[error("case {case}: {what}")]
70 Flight {
71 case: String,
73 what: String,
75 source: Option<Box<hpr_sim::SimError>>,
77 },
78}
79
80impl ValidateError {
81 pub fn help(&self) -> Option<&'static str> {
83 let Self::Case(message) = self else {
84 return None;
85 };
86 HINTS
87 .into_iter()
88 .find(|hint| message.ends_with(&format!("; {hint}")))
89 }
90
91 pub fn fact(&self) -> String {
93 let text = self.to_string();
94 match self.help() {
95 Some(hint) => text
96 .strip_suffix(&format!("; {hint}"))
97 .unwrap_or(&text)
98 .to_owned(),
99 None => text,
100 }
101 }
102}
103
104const MASS_AGREEMENT: f64 = 1e-9;
110
111const AREA_AGREEMENT: f64 = 1e-9;
115
116pub fn run_lock(root: &Path, fast: bool) -> Result<Report, ValidateError> {
129 let lock_path = cases_dir(root).join("lock.toml");
130 let lock: CaseLock =
131 toml::from_str(&read(&lock_path, "reading the case lock")?).map_err(|source| {
132 ValidateError::Toml {
133 path: lock_path.display().to_string(),
134 source: Box::new(source),
135 }
136 })?;
137 let unknown = lock.unknown_slow();
138 if !unknown.is_empty() {
139 return Err(ValidateError::Case(format!(
140 "the case lock names {unknown:?} as slow, which are not cases"
141 )));
142 }
143 let committed = committed_cases(root).map_err(ValidateError::Case)?;
146 let unlocked: Vec<&String> = committed
147 .iter()
148 .filter(|id| !lock.cases.contains(id))
149 .collect();
150 if !unlocked.is_empty() {
151 return Err(ValidateError::Case(format!(
152 "{unlocked:?} are committed under validation/cases/ but the lock does not name them, \
153 so they would never run"
154 )));
155 }
156
157 let wanted = lock.wanted(fast);
158 let mut cases = Vec::new();
159 let mut comparisons = Vec::new();
160 let mut sources = Vec::new();
161 let mut gaps = Vec::new();
162 for id in &wanted {
163 let path = cases_dir(root).join(format!("{id}.toml"));
164 if !path.is_file() {
165 return Err(ValidateError::Case(format!(
167 "the case lock names {id}, but {} is not there",
168 path.display()
169 )));
170 }
171 let case: Case = toml::from_str(&read(&path, "reading a case")?).map_err(|source| {
172 ValidateError::Toml {
173 path: path.display().to_string(),
174 source: Box::new(source),
175 }
176 })?;
177 if case.id != *id {
178 return Err(ValidateError::Case(format!(
179 "{} calls itself {}, not {id}",
180 path.display(),
181 case.id
182 )));
183 }
184 let run = run_case(root, &case)?;
185 cases.push(case.id.clone());
187 comparisons.extend(run.comparisons);
188 sources.push(run.source);
189 gaps.extend(run.gap);
190 }
191 if comparisons.is_empty() {
192 return Err(ValidateError::Case(format!(
195 "the run covered {} case(s) and compared nothing; a suite that checks nothing is not \
196 a suite that passes",
197 cases.len()
198 )));
199 }
200 Ok(Report {
201 harness_version: env!("CARGO_PKG_VERSION").to_owned(),
202 fast,
203 cases,
204 skipped: lock.skipped(fast),
205 comparisons,
206 gaps,
207 sources,
208 })
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct CaseRun {
215 pub comparisons: Vec<Comparison>,
217 pub source: Source,
219 pub gap: Option<Gap>,
221}
222
223pub fn run_case(root: &Path, case: &Case) -> Result<CaseRun, ValidateError> {
229 let known: &[&str] = match &case.flight {
231 Flight::RecoveryDescent { .. } => &DESCENT_METRICS,
232 Flight::WholeFlight { .. } => &WHOLE_FLIGHT_METRICS,
233 };
234 let unknown: Vec<&String> = case
235 .metrics
236 .keys()
237 .filter(|name| !known.contains(&name.as_str()))
238 .collect();
239 if !unknown.is_empty() {
240 return Err(ValidateError::Case(format!(
241 "case {}: {unknown:?} are not metrics this flight measures; it measures {known:?}",
242 case.id
243 )));
244 }
245 for (name, metric) in &case.metrics {
246 metric.check(&case.id, name).map_err(ValidateError::Case)?;
247 }
248
249 let (reference, setup) = load_reference(root, case)?;
250 let missing = reference.without_provenance();
251 if !missing.is_empty() {
252 return Err(ValidateError::Case(format!(
254 "case {}: the reference's {missing:?} carry no source",
255 case.id
256 )));
257 }
258 let ignored: Vec<&String> = reference
261 .values
262 .keys()
263 .filter(|name| !case.metrics.contains_key(*name))
264 .collect();
265 if !ignored.is_empty() {
266 return Err(ValidateError::Case(format!(
267 "case {}: the reference publishes {ignored:?}, which the case neither gates nor \
268 declares not scored",
269 case.id
270 )));
271 }
272
273 let source = Source {
274 case: case.id.clone(),
275 oracle: reference.oracle.clone(),
276 generator: reference.generator.clone(),
277 command: reference.command.clone(),
278 file: reference.file.clone(),
279 sha256: reference.sha256.clone(),
280 model: reference.model.clone(),
281 overrides: reference.overrides.clone(),
282 };
283 let gap = match &case.known_gap {
284 None => None,
285 Some(reason) if reason.trim().is_empty() => {
286 return Err(ValidateError::Case(format!(
288 "case {}: its known gap gives no reason",
289 case.id
290 )));
291 }
292 Some(reason) => {
293 let limit = NORMAL_FORCE_MACH_LIMIT.min(BUILDUP_MACH_LIMIT);
297 let peak = reference.values.get("max_mach").map(|value| value.value);
298 if !peak.is_some_and(|mach| mach >= limit) {
299 return Err(ValidateError::Case(format!(
300 "case {}: it declares a known gap, past hpr's Mach {limit}, but the reference \
301 peaks at Mach {peak:?}",
302 case.id
303 )));
304 }
305 Some(reason.trim().to_owned())
306 }
307 };
308 let peak = reference.values.get("max_mach").map(|value| value.value);
309 let measured = match settle(
310 &case.id,
311 case.metrics.len(),
312 measure(root, case, &setup)?,
313 gap,
314 peak,
315 )? {
316 Settled::Measured(measured) => measured,
317 Settled::Gap(gap) => {
318 return Ok(CaseRun {
319 comparisons: Vec::new(),
320 source,
321 gap: Some(gap),
322 });
323 }
324 };
325 let predicted = matches!(
326 case.flight,
327 Flight::WholeFlight {
328 mode: DragMode::Predicted,
329 ..
330 }
331 );
332 let mut comparisons = Vec::new();
333 for (name, metric) in &case.metrics {
334 let value = reference.values.get(name).ok_or_else(|| {
335 ValidateError::Case(format!(
336 "case {}: the reference has no {name}; it has {:?}",
337 case.id,
338 reference.values.keys().collect::<Vec<_>>()
339 ))
340 })?;
341 let got = measured.values.get(name).ok_or_else(|| {
342 ValidateError::Case(format!(
343 "case {}: hpr measured no {name}; it measured {:?}",
344 case.id,
345 measured.values.keys().collect::<Vec<_>>()
346 ))
347 })?;
348 comparisons.push(match (metric.reason(), predicted) {
349 (Some(reason), _) => {
350 Comparison::not_scored(&case.id, name, *got, value.value, &value.source, reason)
351 }
352 (None, false) => Comparison::new(
353 &case.id,
354 name,
355 *got,
356 value.value,
357 &value.source,
358 metric.tolerance(),
359 ),
360 (None, true) => Comparison::targeted(
362 &case.id,
363 name,
364 *got,
365 value.value,
366 &value.source,
367 metric.tolerance(),
368 ),
369 });
370 }
371 let ungated: Vec<&String> = measured
374 .values
375 .keys()
376 .filter(|name| !case.metrics.contains_key(*name))
377 .collect();
378 if !ungated.is_empty() {
379 return Err(ValidateError::Case(format!(
380 "case {}: {ungated:?} are measured but have no tolerance",
381 case.id
382 )));
383 }
384 Ok(CaseRun {
385 comparisons,
386 source,
387 gap: None,
388 })
389}
390
391#[derive(Debug, Clone, PartialEq)]
393enum Setup {
394 Descent(DescentSetup),
396 WholeFlight(WholeFlightSetup),
398}
399
400#[derive(Debug, Clone, PartialEq)]
402pub(crate) enum Settled {
403 Measured(Measured),
405 Gap(Gap),
407}
408
409pub(crate) fn settle(
414 case: &str,
415 metric_count: usize,
416 flown: Flown,
417 gap: Option<String>,
418 peak: Option<f64>,
419) -> Result<Settled, ValidateError> {
420 match (flown, gap) {
421 (Flown::Measured(measured), None) => Ok(Settled::Measured(measured)),
422 (Flown::Measured(_), Some(_)) => {
423 Err(ValidateError::Case(format!(
426 "case {case}: it declares a known gap, but hpr flew it to the ground; \
427 {GAP_CLOSED_HINT}"
428 )))
429 }
430 (Flown::RefusedAtMach { limit, model, .. }, Some(_))
431 if !peak.is_some_and(|peak| peak >= limit) =>
432 {
433 Err(ValidateError::Case(format!(
434 "case {case}: hpr refused it at {model}'s limit, Mach {limit}, but the reference \
435 peaks at Mach {peak:?}, so the gap isn't the one declared"
436 )))
437 }
438 (Flown::RefusedAtMach { mach, limit, model }, Some(reason)) => Ok(Settled::Gap(Gap {
439 case: case.to_owned(),
440 reason,
441 refusal: format!(
444 "refused the flight at Mach {mach:.3}, outside {model}'s range [0, {limit})"
445 ),
446 mach,
447 metric_count,
448 })),
449 (Flown::RefusedAtMach { mach, limit, model }, None) => {
450 Err(ValidateError::Flight {
453 case: case.to_owned(),
454 what: format!(
455 "refused the flight at Mach {mach:.3}, outside {model}'s range [0, {limit}), \
456 and the case declares no known gap"
457 ),
458 source: Some(Box::new(SimError::Aero(AeroError::Mach {
459 mach,
460 limit,
461 model,
462 }))),
463 })
464 }
465 }
466}
467
468#[derive(Debug, Clone, PartialEq)]
470pub(crate) enum Flown {
471 Measured(Measured),
473 RefusedAtMach {
475 mach: f64,
477 limit: f64,
479 model: &'static str,
481 },
482}
483
484fn load_reference(root: &Path, case: &Case) -> Result<(Reference, Setup), ValidateError> {
486 let path = root.join(&case.reference);
487 let text = read(&path, "reading a reference")?;
488 let document: serde_json::Value =
489 serde_json::from_str(&text).map_err(|source| ValidateError::Json {
490 path: path.display().to_string(),
491 source: Box::new(source),
492 })?;
493 let sha256: String = Sha256::digest(text.as_bytes())
494 .iter()
495 .map(|byte| format!("{byte:02x}"))
496 .collect();
497 let wanted = case.reference_case.as_deref().unwrap_or(&case.id);
498 let file = case.reference.display().to_string().replace('\\', "/");
499 match &case.flight {
500 Flight::RecoveryDescent { .. } => {
501 crate::rocketpy::descent_case(&document, wanted, &file, &sha256)
502 .map(|(reference, setup)| (reference, Setup::Descent(setup)))
503 }
504 Flight::WholeFlight { .. } => {
505 crate::rocketpy::whole_flight_case(&document, wanted, &file, &sha256)
506 .map(|(reference, setup)| (reference, Setup::WholeFlight(setup)))
507 }
508 }
509 .ok_or_else(|| {
510 ValidateError::Case(format!(
511 "case {}: {} has no case {wanted} that names the run which produced it",
512 case.id,
513 path.display()
514 ))
515 })
516}
517
518fn measure(root: &Path, case: &Case, setup: &Setup) -> Result<Flown, ValidateError> {
520 match (&case.flight, setup) {
521 (
522 Flight::RecoveryDescent {
523 design,
524 configuration,
525 },
526 Setup::Descent(setup),
527 ) => fly_descent(root, case, setup, design, configuration).map(Flown::Measured),
528 (
529 Flight::WholeFlight {
530 design,
531 configuration,
532 mode,
533 },
534 Setup::WholeFlight(setup),
535 ) => fly_whole_flight(root, case, setup, design, configuration, *mode),
536 _ => Err(ValidateError::Case(format!(
537 "case {}: its reference was read for another kind of flight",
538 case.id
539 ))),
540 }
541}
542
543fn fly_descent(
546 root: &Path,
547 case: &Case,
548 setup: &DescentSetup,
549 design: &str,
550 configuration: &str,
551) -> Result<Measured, ValidateError> {
552 let refuse = |what: String| ValidateError::Flight {
553 case: case.id.clone(),
554 what,
555 source: None,
556 };
557 let named = format!("validation/designs/{design}.json");
560 if named != setup.design {
561 return Err(refuse(format!(
562 "the case flies {named}, but the reference was flown with {}",
563 setup.design
564 )));
565 }
566 match setup.devices.split_first() {
570 None => return Err(refuse("the reference declares no devices".to_owned())),
571 Some((first, rest)) => {
572 if first.height_above_ground_m.is_some() || first.lag_s != 0.0 {
573 return Err(refuse(format!(
574 "the reference's first device ({}) has to be the one that opens at the \
575 handover, with no lag left to run",
576 first.name
577 )));
578 }
579 if let Some(device) = rest
580 .iter()
581 .find(|device| device.height_above_ground_m.is_none())
582 {
583 return Err(refuse(format!(
584 "device {} opens at apogee, which is behind the state the reference hands \
585 over; there is no apogee left for the harness to trigger on",
586 device.name
587 )));
588 }
589 }
590 }
591
592 let path = root.join(&named);
593 let rocket: Rocket =
594 serde_json::from_str(&read(&path, "reading a design")?).map_err(|source| {
595 ValidateError::Json {
596 path: path.display().to_string(),
597 source: Box::new(source),
598 }
599 })?;
600 let flight = || -> Result<Result<Measured, String>, hpr_sim::SimError> {
601 let environment = rocketpy_environment(
602 setup.latitude_deg,
603 setup.longitude_deg,
604 setup.elevation_m,
605 &setup.wind,
606 )?;
607 let devices = setup
608 .devices
609 .iter()
610 .enumerate()
611 .map(|(index, device)| {
612 let drag = DeviceDrag::DragArea {
613 cd_s_m2: device.cd_s_m2,
614 };
615 let trigger = match device.height_above_ground_m {
616 Some(height_above_ground_m) => Trigger::Altitude {
617 height_above_ground_m,
618 },
619 None => Trigger::Time {
620 time_s: setup.start_time_s,
621 },
622 };
623 let mut built = Device::new(&device.name, drag, trigger).with_lag_s(device.lag_s);
624 if index + 1 < setup.devices.len() {
625 built = built.with_release_by(index + 1);
626 }
627 built
628 })
629 .collect();
630 let simulation = Simulation::new(
631 &rocket,
632 configuration,
633 environment,
634 Rail::vertical(6.0),
635 FlightSettings {
636 max_time_s: 6000.0,
637 ..FlightSettings::default()
638 },
639 )?
640 .with_recovery(devices)?;
641 let properties = simulation.assembly().mass_properties(setup.start_time_s);
642 let mass_gap = (properties.mass_kg - setup.dry_mass_kg).abs() / setup.dry_mass_kg.abs();
645 if !mass_gap.is_finite() || mass_gap > MASS_AGREEMENT {
646 return Ok(Err(format!(
647 "hpr flies {:.6} kg where the reference recorded {:.6} kg, a relative {mass_gap:.3e}",
648 properties.mass_kg, setup.dry_mass_kg
649 )));
650 }
651 let attitude = Rail::vertical(6.0).attitude();
652 let cg_enu_m = DVec3::new(
653 setup.start_position_m.0,
654 setup.start_position_m.1,
655 setup.start_position_m.2 - setup.elevation_m,
656 );
657 let state = State {
658 position_enu_m: cg_enu_m - attitude.mul_vec3(properties.cg_m),
659 velocity_enu_m_s: DVec3::new(
660 setup.start_velocity_m_s.0,
661 setup.start_velocity_m_s.1,
662 setup.start_velocity_m_s.2,
663 ),
664 attitude,
665 body_rate_rad_s: DVec3::ZERO,
666 };
667 let result = simulation.run_free(setup.start_time_s, state, &mut ())?;
668 if result.termination != Termination::GroundHit {
669 return Ok(Err(format!(
670 "the descent ended as {:?} after {} steps, not at the ground",
671 result.termination, result.stats.accepted_steps
672 )));
673 }
674 let landing = result
675 .event(EventKind::GroundHit)
676 .map_or(result.final_sample, |event| event.sample);
677 let drift = landing.cg_enu_m - cg_enu_m;
678 let descent_time_s = landing.time_s - setup.start_time_s;
679 let mut measured = Measured::default();
680 measured.insert("descent_time_s", descent_time_s);
681 measured.insert("impact_speed_m_s", -landing.vertical_speed_m_s);
682 measured.insert("drift_m", drift.truncate().length());
683 measured.insert("drift_east_m", drift.x);
684 measured.insert("drift_north_m", drift.y);
685 measured.insert(
689 "mean_descent_rate_m_s",
690 (setup.start_height_above_ground_m - landing.height_above_ground_m) / descent_time_s,
691 );
692 Ok(Ok(measured))
693 };
694 match flight() {
695 Ok(Ok(measured)) => Ok(measured),
696 Ok(Err(what)) => Err(refuse(what)),
697 Err(source) => Err(ValidateError::Flight {
698 case: case.id.clone(),
699 what: source.to_string(),
700 source: Some(Box::new(source)),
701 }),
702 }
703}
704
705fn rocketpy_environment(
709 latitude_deg: f64,
710 longitude_deg: f64,
711 elevation_m: f64,
712 wind: &[(f64, f64, f64)],
713) -> Result<Environment, SimError> {
714 let site = Geodetic::from_degrees(latitude_deg, longitude_deg, elevation_m)?;
715 let levels: Vec<WindLevel> = wind
716 .iter()
717 .map(|(height_msl_m, east, north)| WindLevel {
718 height_msl_m: *height_msl_m,
719 speed_m_s: east.hypot(*north),
720 direction_from_rad: (-east).atan2(-north).rem_euclid(std::f64::consts::TAU),
721 })
722 .collect();
723 let earth = Earth::new(
730 NormalGravity::wgs84(),
731 site,
732 GravityModel::VerticalTaylor,
733 EarthRotation::Coriolis,
734 )?;
735 Ok(Environment::new(
736 earth,
737 AtmosphereModel::default(),
738 LayeredWind::new(levels, WindInterpolation::Components)?,
739 ))
740}
741
742fn fly_whole_flight(
766 root: &Path,
767 case: &Case,
768 setup: &WholeFlightSetup,
769 design: &str,
770 configuration: &str,
771 mode: DragMode,
772) -> Result<Flown, ValidateError> {
773 let refuse = |what: String| ValidateError::Flight {
774 case: case.id.clone(),
775 what,
776 source: None,
777 };
778 let named = format!("validation/designs/{design}.json");
780 if named != setup.design {
781 return Err(refuse(format!(
782 "the case flies {named}, but the reference was flown with {}",
783 setup.design
784 )));
785 }
786 let declared = match (
790 mode,
791 &setup.cd0_vs_mach,
792 &setup.declared_cd0_vs_mach,
793 &setup.own_drag_source,
794 ) {
795 (DragMode::SameDrag, Some(flown), Some(declared), None) => {
796 if flown != declared {
797 return Err(refuse(format!(
798 "the reference's case flew C_D0(M) = {flown:?}, not the {declared:?} its \
799 generator declares"
800 )));
801 }
802 Some(flown.clone())
803 }
804 (DragMode::Predicted, None, None, Some(_)) => None,
805 (DragMode::SameDrag, ..) => {
806 return Err(refuse(
807 "a same-drag case needs a reference that flew its generator's declared C_D0(M), \
808 and this one did not"
809 .to_owned(),
810 ));
811 }
812 (DragMode::Predicted, ..) => {
813 return Err(refuse(
814 "a predicted case needs a reference that flew the example's own drag; against a \
815 declared table it would score hpr's drag against an arbitrary constant"
816 .to_owned(),
817 ));
818 }
819 };
820 if let Some((first, rest)) = setup.devices.split_first() {
821 if first.height_above_ground_m.is_some() {
822 return Err(refuse(format!(
823 "the reference's first device ({}) has to open at apogee, as RocketPy's first \
824 parachute does",
825 first.name
826 )));
827 }
828 if let Some(device) = rest.iter().find(|d| d.height_above_ground_m.is_none()) {
829 return Err(refuse(format!(
830 "device {} opens at apogee too; RocketPy's parachutes replace one another in \
831 order, which hpr flies as each releasing the one before",
832 device.name
833 )));
834 }
835 } else {
836 return Err(refuse("the reference declares no devices".to_owned()));
837 }
838
839 let path = root.join(&named);
840 let rocket: Rocket =
841 serde_json::from_str(&read(&path, "reading a design")?).map_err(|source| {
842 ValidateError::Json {
843 path: path.display().to_string(),
844 source: Box::new(source),
845 }
846 })?;
847 let flight = || -> Result<Result<Flown, String>, SimError> {
848 let environment = rocketpy_environment(
849 setup.latitude_deg,
850 setup.longitude_deg,
851 setup.elevation_m,
852 &setup.wind,
853 )?;
854 let rail = Rail {
855 length_m: setup.rail_length_m,
856 azimuth_rad: setup.heading_deg.to_radians(),
857 elevation_rad: setup.inclination_deg.to_radians(),
858 roll_rad: 0.0,
859 friction_coefficient: 0.0,
861 };
862 let simulation = Simulation::new(
863 &rocket,
864 configuration,
865 environment,
866 rail,
867 FlightSettings {
868 max_time_s: 6000.0,
869 method: match mode {
870 DragMode::Predicted => Method::DormandPrince54(Adaptive {
871 relative_tolerance: PREDICTED_TOLERANCE,
872 absolute_tolerance: PREDICTED_TOLERANCE,
873 ..Adaptive::default()
874 }),
875 _ => FlightSettings::default().method,
876 },
877 ..FlightSettings::default()
878 },
879 )?;
880 let simulation = match &declared {
882 Some(rows) => {
883 let (machs, coefficients): (Vec<f64>, Vec<f64>) = rows.iter().copied().unzip();
884 let table = Table1D::new(
885 machs,
886 coefficients,
887 Interpolation::Linear,
888 Extrapolation::Error,
891 )?;
892 simulation.with_drag_table(
893 DragTable::new(table.clone(), Some(table))
894 .with_reference_diameter_m(2.0 * setup.reference_radius_m)?,
895 )
896 }
897 None => simulation,
898 };
899
900 let assembly = simulation.assembly();
903 let dry = assembly
904 .motors
905 .iter()
906 .fold(assembly.layout.structure, |sum, motor| {
907 MassProperties::combine([&sum, &motor.dry_mass_properties()])
908 });
909 let mass_gap = (dry.mass_kg - setup.dry_mass_kg).abs() / setup.dry_mass_kg;
910 if !mass_gap.is_finite() || mass_gap > MASS_AGREEMENT {
911 return Ok(Err(format!(
912 "hpr's dry mass is {:.6} kg where the reference recorded {:.6} kg, a relative \
913 {mass_gap:.3e}",
914 dry.mass_kg, setup.dry_mass_kg
915 )));
916 }
917 let radius_m = 0.5 * assembly.layout.reference_diameter_m;
918 let area_m2 = std::f64::consts::PI * radius_m * radius_m;
919 let area_gap = (area_m2 - setup.reference_area_m2).abs() / setup.reference_area_m2;
920 let radius_gap = (radius_m - setup.reference_radius_m).abs() / setup.reference_radius_m;
921 if !(area_gap.is_finite() && radius_gap.is_finite())
922 || area_gap > AREA_AGREEMENT
923 || radius_gap > AREA_AGREEMENT
924 {
925 return Ok(Err(format!(
926 "hpr's reference area is {area_m2:.9} m2 (radius {radius_m:.6} m) where the \
927 reference flew its drag on {:.9} m2 (radius {:.6} m)",
928 setup.reference_area_m2, setup.reference_radius_m
929 )));
930 }
931 let [placed] = assembly.motors.as_slice() else {
932 return Ok(Err(format!(
933 "the design flies {} motors, where RocketPy's examples fly one",
934 assembly.motors.len()
935 )));
936 };
937 let motor = &placed.mounted.motor;
938 for (what, hpr, rocketpy) in [
939 (
940 "total impulse, N s",
941 motor.curve().total_impulse_ns(),
942 setup.motor.total_impulse_ns,
943 ),
944 (
945 "burn-out time, s",
946 motor.burnout_time_s(),
947 setup.motor.burn_out_time_s,
948 ),
949 (
950 "initial propellant mass, kg",
951 motor.propellant_initial_mass_kg(),
952 setup.motor.propellant_initial_mass_kg,
953 ),
954 ] {
955 let gap = (hpr - rocketpy).abs() / rocketpy.abs();
956 if !gap.is_finite() || gap > MASS_AGREEMENT {
957 return Ok(Err(format!(
958 "hpr's motor has a {what} of {hpr} where the reference flew {rocketpy}, a \
959 relative {gap:.3e}"
960 )));
961 }
962 }
963 let reference_pa = motor
966 .nozzle()
967 .and_then(|nozzle| nozzle.reference_pressure_pa);
968 if reference_pa != setup.motor.reference_pressure_pa {
969 return Ok(Err(format!(
970 "hpr's motor corrects its thrust for a reference pressure of {reference_pa:?} Pa, \
971 where the reference flew {:?}",
972 setup.motor.reference_pressure_pa
973 )));
974 }
975
976 let start = simulation.initial_state();
981 let origin = start.position_enu_m + start.attitude.mul_vec3(dry.cg_m);
982 let h0 = origin.z;
983 let devices = setup
984 .devices
985 .iter()
986 .enumerate()
987 .map(|(index, device)| {
988 let trigger = match device.height_above_ground_m {
989 Some(height_above_ground_m) => Trigger::Altitude {
990 height_above_ground_m: height_above_ground_m + h0,
991 },
992 None => Trigger::Apogee,
993 };
994 let mut built = Device::new(
995 &device.name,
996 DeviceDrag::DragArea {
997 cd_s_m2: device.cd_s_m2,
998 },
999 trigger,
1000 )
1001 .with_lag_s(device.lag_s);
1002 if index + 1 < setup.devices.len() {
1005 built = built.with_release_by(index + 1);
1006 }
1007 built
1008 })
1009 .collect();
1010 let simulation = simulation.with_recovery(devices)?.with_event(UserEvent {
1011 name: "back at the starting height".to_owned(),
1012 direction: Direction::Falling,
1013 function: Box::new(move |sample: &Sample| sample.height_above_ground_m - h0),
1014 });
1015
1016 let mut peaks = Peaks {
1017 dry_cg_m: dry.cg_m,
1018 rail_axis_enu: rail.direction_enu(),
1019 start_enu_m: start.position_enu_m,
1020 forward_guide_travel_m: setup.effective_1rl_m,
1021 forward_guide_exit: None,
1022 rows: Vec::new(),
1023 start_height_m: h0,
1024 grid_s: setup.series.iter().map(|&(t_s, _, _)| t_s).collect(),
1025 on_grid: Vec::new(),
1026 };
1027 let result = match simulation.run(&mut peaks) {
1028 Ok(result) => result,
1029 Err(SimError::Aero(AeroError::Mach { mach, limit, model }))
1034 if mach.is_finite()
1035 && mach >= limit
1036 && (limit == NORMAL_FORCE_MACH_LIMIT || limit == BUILDUP_MACH_LIMIT) =>
1037 {
1038 return Ok(Ok(Flown::RefusedAtMach { mach, limit, model }));
1039 }
1040 Err(error) => return Err(error),
1041 };
1042 if result.termination != Termination::GroundHit {
1043 return Ok(Err(format!(
1044 "the flight ended as {:?} after {} steps, not at the ground",
1045 result.termination, result.stats.accepted_steps
1046 )));
1047 }
1048 let event = |kind: EventKind| {
1049 result
1050 .event(kind)
1051 .map(|event| event.sample)
1052 .ok_or_else(|| format!("the flight has no {kind:?} event"))
1053 };
1054 let (apogee, burnout, landing) = match (
1055 event(EventKind::Apogee),
1056 event(EventKind::Burnout),
1057 event(EventKind::User(0)),
1058 ) {
1059 (Ok(apogee), Ok(burnout), Ok(landing)) => (apogee, burnout, landing),
1060 (Err(what), _, _) | (_, Err(what), _) | (_, _, Err(what)) => return Ok(Err(what)),
1061 };
1062 let Some((rail_exit_time_s, rail_exit_speed_m_s)) = peaks.forward_guide_exit else {
1063 return Ok(Err(format!(
1064 "the rocket never travelled RocketPy's effective_1rl, {} m, along the rail",
1065 setup.effective_1rl_m
1066 )));
1067 };
1068 let flown: Vec<&Row> = peaks
1070 .rows
1071 .iter()
1072 .filter(|row| row.time_s <= landing.time_s)
1073 .collect();
1074 if let Some(row) = flown.iter().find(|row| !row.is_finite()) {
1077 return Ok(Err(format!(
1078 "the flight has a sample that is not a number at {} s: {row:?}",
1079 row.time_s
1080 )));
1081 }
1082 let fastest = flown.iter().map(|row| row.speed_m_s).fold(0.0, f64::max);
1083 let max_mach = flown.iter().map(|row| row.mach).fold(0.0, f64::max);
1084 let hardest = |rows: &mut dyn Iterator<Item = &&Row>| {
1085 rows.fold((0.0, 0.0), |(peak, at), row| {
1086 if row.acceleration_m_s2 > peak {
1087 (row.acceleration_m_s2, row.time_s)
1088 } else {
1089 (peak, at)
1090 }
1091 })
1092 };
1093 let (max_acceleration, max_acceleration_time_s) = hardest(&mut flown.iter());
1094 let (max_acceleration_power_on, _) =
1095 hardest(&mut flown.iter().filter(|row| row.time_s <= burnout.time_s));
1096 let drift = |state: &State| {
1098 (state.position_enu_m + state.attitude.mul_vec3(dry.cg_m) - origin)
1099 .truncate()
1100 .length()
1101 };
1102
1103 let mut measured = Measured::default();
1104 measured.insert("apogee_agl_m", apogee.height_above_ground_m - h0);
1105 measured.insert("apogee_time_s", apogee.time_s);
1106 measured.insert("max_speed_m_s", fastest);
1107 measured.insert("max_mach", max_mach);
1108 measured.insert("max_acceleration_m_s2", max_acceleration);
1109 measured.insert("max_acceleration_time_s", max_acceleration_time_s);
1110 measured.insert("max_acceleration_power_on_m_s2", max_acceleration_power_on);
1111 measured.insert("rail_exit_speed_m_s", rail_exit_speed_m_s);
1112 measured.insert("rail_exit_time_s", rail_exit_time_s);
1113 measured.insert("burnout_altitude_agl_m", burnout.height_above_ground_m - h0);
1115 measured.insert(
1116 "burnout_speed_m_s",
1117 point_velocity(&burnout.state, dry.cg_m).length(),
1118 );
1119 measured.insert("flight_time_s", landing.time_s);
1120 measured.insert("apogee_drift_m", drift(&apogee.state));
1121 measured.insert("landing_drift_m", drift(&landing.state));
1122 measured.insert("impact_speed_m_s", -landing.vertical_speed_m_s);
1123 let both_fly: Vec<(&SeriesRow, &SeriesRow)> = setup
1129 .series
1130 .iter()
1131 .zip(&peaks.on_grid)
1132 .filter(|(_, ours)| ours.0 <= landing.time_s)
1133 .collect();
1134 if let Some((_, ours)) = both_fly
1135 .iter()
1136 .find(|(_, ours)| !(ours.1.is_finite() && ours.2.is_finite()))
1137 {
1138 return Ok(Err(format!(
1139 "the flight's trajectory is not a number at {} s: {ours:?}",
1140 ours.0
1141 )));
1142 }
1143 if both_fly.is_empty() {
1144 return Ok(Err(
1145 "the flight reached none of the reference's series times".into(),
1146 ));
1147 }
1148 let rms = |difference: fn(&SeriesRow, &SeriesRow) -> f64| {
1149 let sum: f64 = both_fly
1150 .iter()
1151 .map(|(theirs, ours)| difference(theirs, ours).powi(2))
1152 .sum();
1153 #[allow(
1154 clippy::cast_precision_loss,
1155 reason = "a count of rows is exact in an f64 below 2^53; the fixtures have 120"
1156 )]
1157 let count = both_fly.len() as f64;
1158 (sum / count).sqrt()
1159 };
1160 measured.insert("series_height_rms_m", rms(|theirs, ours| ours.1 - theirs.1));
1161 measured.insert(
1162 "series_speed_rms_m_s",
1163 rms(|theirs, ours| ours.2 - theirs.2),
1164 );
1165 Ok(Ok(Flown::Measured(measured)))
1166 };
1167 match flight() {
1168 Ok(Ok(flown)) => Ok(flown),
1169 Ok(Err(what)) => Err(refuse(what)),
1170 Err(source) => Err(ValidateError::Flight {
1171 case: case.id.clone(),
1172 what: source.to_string(),
1173 source: Some(Box::new(source)),
1174 }),
1175 }
1176}
1177
1178fn point_velocity(state: &State, p: DVec3) -> DVec3 {
1180 state.velocity_enu_m_s + state.attitude.mul_vec3(state.body_rate_rad_s.cross(p))
1181}
1182
1183#[derive(Debug, Clone, Copy)]
1185struct Row {
1186 time_s: f64,
1187 speed_m_s: f64,
1188 mach: f64,
1189 acceleration_m_s2: f64,
1190}
1191
1192impl Row {
1193 fn is_finite(&self) -> bool {
1195 self.speed_m_s.is_finite() && self.mach.is_finite() && self.acceleration_m_s2.is_finite()
1196 }
1197
1198 const PEAKED: [fn(&Row) -> f64; 3] = [
1200 |row| row.speed_m_s,
1201 |row| row.mach,
1202 |row| row.acceleration_m_s2,
1203 ];
1204}
1205
1206const PEAK_ITERATIONS: usize = 50;
1209
1210pub(crate) fn peak_between<E>(
1221 low: f64,
1222 high: f64,
1223 mut value: impl FnMut(f64) -> Result<f64, E>,
1224) -> Result<f64, E> {
1225 let keep = 0.5 * (5.0_f64.sqrt() - 1.0);
1226 let (mut low, mut high) = (low, high);
1227 let (mut left, mut right) = (high - keep * (high - low), low + keep * (high - low));
1228 let (mut at_left, mut at_right) = (value(left)?, value(right)?);
1229 for _ in 0..PEAK_ITERATIONS {
1230 if at_left > at_right {
1231 (high, right, at_right) = (right, left, at_left);
1232 left = high - keep * (high - low);
1233 at_left = value(left)?;
1234 } else {
1235 (low, left, at_left) = (left, right, at_right);
1236 right = low + keep * (high - low);
1237 at_right = value(right)?;
1238 }
1239 }
1240 Ok(if at_left > at_right { left } else { right })
1241}
1242
1243struct Peaks {
1247 dry_cg_m: DVec3,
1249 rail_axis_enu: DVec3,
1251 start_enu_m: DVec3,
1253 forward_guide_travel_m: f64,
1256 forward_guide_exit: Option<(f64, f64)>,
1258 rows: Vec<Row>,
1260 start_height_m: f64,
1262 grid_s: Vec<f64>,
1264 on_grid: Vec<SeriesRow>,
1267}
1268
1269impl Peaks {
1270 fn row(&self, step: &dyn FlightStep, t_s: f64) -> Result<Option<Row>, SimError> {
1272 let sample: Sample = step.sample(t_s)?;
1273 let state = sample.state;
1274 let omega = state.body_rate_rad_s;
1275 let p = self.dry_cg_m;
1276 let omega_dot = if step.phase() == Phase::Free {
1280 let back = (0.25 * (t_s - step.start_s())).min(1e-6);
1281 let ahead = (0.25 * (step.end_s() - t_s)).min(1e-6);
1282 if back > 0.0 {
1283 (omega - step.state_at(t_s - back).body_rate_rad_s) / back
1284 } else if ahead > 0.0 {
1285 (step.state_at(t_s + ahead).body_rate_rad_s - omega) / ahead
1286 } else {
1287 return Ok(None);
1290 }
1291 } else {
1292 DVec3::ZERO
1293 };
1294 let acceleration = sample.acceleration_enu_m_s2
1295 + state
1296 .attitude
1297 .mul_vec3(omega_dot.cross(p) + omega.cross(omega.cross(p)));
1298 Ok(Some(Row {
1299 time_s: t_s,
1300 speed_m_s: point_velocity(&state, p).length(),
1301 mach: sample.mach,
1302 acceleration_m_s2: acceleration.length(),
1303 }))
1304 }
1305
1306 fn peaks_within(
1330 &self,
1331 step: &dyn FlightStep,
1332 first: &Row,
1333 last: &Row,
1334 ) -> Result<Vec<Row>, SimError> {
1335 let (start_s, end_s) = (step.start_s(), step.end_s());
1336 let (near, far) = (
1341 (0.25 * (end_s - start_s)).min(1e-6),
1342 (0.25 * (end_s - start_s)).min(1e-3),
1343 );
1344 let (Some(second), Some(penultimate), Some(inner), Some(inner_end)) = (
1347 self.row(step, start_s + near)?,
1348 self.row(step, end_s - near)?,
1349 self.row(step, start_s + far)?,
1350 self.row(step, end_s - far)?,
1351 ) else {
1352 return Ok(Vec::new());
1353 };
1354 let mut not_a_number = [second, penultimate, inner, inner_end]
1355 .into_iter()
1356 .find(|row| !row.is_finite());
1357 let mut peaks = Vec::new();
1358 for quantity in Row::PEAKED {
1359 let brackets =
1360 |a: &Row, b: &Row| quantity(a) > quantity(first) && quantity(b) > quantity(last);
1361 if !(brackets(&second, &penultimate) || brackets(&inner, &inner_end)) {
1362 continue;
1363 }
1364 let peak_s = peak_between(start_s, end_s, |t_s| -> Result<f64, SimError> {
1365 Ok(match self.row(step, t_s)? {
1366 Some(row) if row.is_finite() => quantity(&row),
1367 Some(row) => {
1368 not_a_number.get_or_insert(row);
1369 f64::NAN
1370 }
1371 None => f64::NEG_INFINITY,
1373 })
1374 })?;
1375 peaks.extend(self.row(step, peak_s)?);
1376 }
1377 peaks.extend(not_a_number);
1378 Ok(peaks)
1379 }
1380
1381 fn past_forward_guide(&self, step: &dyn FlightStep, t_s: f64) -> f64 {
1383 (step.state_at(t_s).position_enu_m - self.start_enu_m).dot(self.rail_axis_enu)
1384 - self.forward_guide_travel_m
1385 }
1386}
1387
1388impl Observer for Peaks {
1389 fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
1390 let (start_s, end_s) = (step.start_s(), step.end_s());
1391 let (first, last) = (self.row(step, start_s)?, self.row(step, end_s)?);
1396 let peaks = match (&first, &last) {
1397 (Some(first), Some(last)) => self.peaks_within(step, first, last)?,
1398 _ => Vec::new(),
1399 };
1400 self.rows.extend(first);
1401 self.rows.extend(last);
1402 self.rows.extend(peaks);
1403 while let Some(&t_s) = self.grid_s.get(self.on_grid.len()) {
1406 if t_s > end_s {
1407 break;
1408 }
1409 let state = step.state_at(t_s);
1410 let height_m = (state.position_enu_m + state.attitude.mul_vec3(self.dry_cg_m)).z
1411 - self.start_height_m;
1412 let speed_m_s = point_velocity(&state, self.dry_cg_m).length();
1413 self.on_grid.push((t_s, height_m, speed_m_s));
1414 }
1415 if self.forward_guide_exit.is_none()
1416 && step.phase() == Phase::Rail
1417 && self.past_forward_guide(step, end_s) >= 0.0
1418 {
1419 let (mut before, mut after) = (start_s, end_s);
1422 for _ in 0..80 {
1423 let middle = 0.5 * (before + after);
1424 if self.past_forward_guide(step, middle) >= 0.0 {
1425 after = middle;
1426 } else {
1427 before = middle;
1428 }
1429 }
1430 let speed = step.state_at(after).velocity_enu_m_s.length();
1431 self.forward_guide_exit = Some((after, speed));
1432 }
1433 Ok(())
1434 }
1435}
1436
1437fn read(path: &Path, what: &'static str) -> Result<String, ValidateError> {
1439 std::fs::read_to_string(path).map_err(|source| ValidateError::Io {
1440 what,
1441 path: path.display().to_string(),
1442 source,
1443 })
1444}
1445
1446pub const DESCENT_METRICS: [&str; 6] = [
1448 "descent_time_s",
1449 "impact_speed_m_s",
1450 "drift_m",
1451 "drift_east_m",
1452 "drift_north_m",
1453 "mean_descent_rate_m_s",
1454];
1455
1456pub const WHOLE_FLIGHT_METRICS: [&str; 17] = [
1460 "apogee_agl_m",
1461 "apogee_time_s",
1462 "max_speed_m_s",
1463 "max_mach",
1464 "max_acceleration_m_s2",
1465 "max_acceleration_time_s",
1466 "max_acceleration_power_on_m_s2",
1467 "rail_exit_speed_m_s",
1468 "rail_exit_time_s",
1469 "burnout_altitude_agl_m",
1470 "burnout_speed_m_s",
1471 "flight_time_s",
1472 "apogee_drift_m",
1473 "landing_drift_m",
1474 "impact_speed_m_s",
1475 "series_height_rms_m",
1476 "series_speed_rms_m_s",
1477];
1478
1479const PREDICTED_TOLERANCE: f64 = 1e-11;
1490
1491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1493pub struct WholeFlightSetup {
1494 pub design: String,
1496 pub dry_mass_kg: f64,
1498 pub latitude_deg: f64,
1500 pub longitude_deg: f64,
1502 pub elevation_m: f64,
1504 pub wind: Vec<(f64, f64, f64)>,
1506 pub rail_length_m: f64,
1508 pub inclination_deg: f64,
1510 pub heading_deg: f64,
1512 pub effective_1rl_m: f64,
1515 pub motor: FlightMotor,
1517 pub cd0_vs_mach: Option<Vec<(f64, f64)>>,
1520 pub declared_cd0_vs_mach: Option<Vec<(f64, f64)>>,
1523 pub own_drag_source: Option<String>,
1528 pub reference_radius_m: f64,
1530 pub reference_area_m2: f64,
1532 pub devices: Vec<FlightDevice>,
1534 pub series: Vec<SeriesRow>,
1538}
1539
1540pub type SeriesRow = (f64, f64, f64);
1543
1544#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1546pub struct FlightMotor {
1547 pub total_impulse_ns: f64,
1549 pub burn_out_time_s: f64,
1551 pub propellant_initial_mass_kg: f64,
1553 pub reference_pressure_pa: Option<f64>,
1555}
1556
1557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1559pub struct FlightDevice {
1560 pub name: String,
1562 pub cd_s_m2: f64,
1564 pub lag_s: f64,
1566 pub height_above_ground_m: Option<f64>,
1568}
1569
1570#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1572pub struct DescentSetup {
1573 pub design: String,
1575 pub dry_mass_kg: f64,
1577 pub start_height_above_ground_m: f64,
1580 pub latitude_deg: f64,
1582 pub longitude_deg: f64,
1584 pub elevation_m: f64,
1586 pub wind: Vec<(f64, f64, f64)>,
1588 pub start_time_s: f64,
1590 pub start_position_m: (f64, f64, f64),
1592 pub start_velocity_m_s: (f64, f64, f64),
1594 pub devices: Vec<DescentDevice>,
1596}
1597
1598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1600pub struct DescentDevice {
1601 pub name: String,
1603 pub cd_s_m2: f64,
1605 pub lag_s: f64,
1607 pub height_above_ground_m: Option<f64>,
1609}
1610
1611#[cfg(test)]
1612mod peak_tests {
1613 use hpr_core::DQuat;
1614
1615 use super::*;
1616
1617 struct Climb {
1619 start_s: f64,
1620 end_s: f64,
1621 speed: fn(f64) -> f64,
1622 }
1623
1624 impl FlightStep for Climb {
1625 fn phase(&self) -> Phase {
1626 Phase::Free
1627 }
1628 fn start_s(&self) -> f64 {
1629 self.start_s
1630 }
1631 fn end_s(&self) -> f64 {
1632 self.end_s
1633 }
1634 fn state_at(&self, t_s: f64) -> State {
1635 let t_s = if (self.start_s..=self.end_s).contains(&t_s) {
1637 t_s
1638 } else {
1639 f64::NAN
1640 };
1641 State {
1642 position_enu_m: DVec3::new(0.0, 0.0, t_s),
1644 velocity_enu_m_s: DVec3::new(0.0, 0.0, (self.speed)(t_s)),
1645 attitude: DQuat::IDENTITY,
1646 body_rate_rad_s: DVec3::ZERO,
1647 }
1648 }
1649 fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
1650 let state = self.state_at(t_s);
1651 let speed = state.velocity_enu_m_s.z;
1652 Ok(Sample {
1653 time_s: t_s,
1654 phase: Phase::Free,
1655 state,
1656 cg_enu_m: DVec3::ZERO,
1657 cg_velocity_enu_m_s: state.velocity_enu_m_s,
1658 height_above_ground_m: 0.0,
1659 vertical_speed_m_s: speed,
1660 acceleration_enu_m_s2: DVec3::new(0.0, 0.0, 9.0),
1661 airspeed_m_s: speed,
1662 mach: speed / 340.0,
1663 angle_of_attack_rad: 0.0,
1664 dynamic_pressure_pa: 0.0,
1665 axial_coefficient: 0.0,
1666 thrust_n: 0.0,
1667 mass_kg: 1.0,
1668 recovery_drag_area_m2: 0.0,
1669 })
1670 }
1671 fn stability(&self, t_s: f64) -> Result<hpr_sim::metrics::Stability, SimError> {
1672 Err(SimError::Domain {
1674 what: "stability of a test step with no aerodynamic model",
1675 value: t_s,
1676 })
1677 }
1678 }
1679
1680 #[test]
1681 fn each_series_time_is_sampled_once_from_the_step_that_holds_it() {
1682 let mut peaks = Peaks {
1685 dry_cg_m: DVec3::new(0.0, 0.0, -0.5),
1686 rail_axis_enu: DVec3::Z,
1687 start_enu_m: DVec3::ZERO,
1688 forward_guide_travel_m: 1.0,
1689 forward_guide_exit: None,
1690 rows: Vec::new(),
1691 start_height_m: -0.5,
1692 grid_s: vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5],
1693 on_grid: Vec::new(),
1694 };
1695 let speed = |t_s: f64| 10.0 + t_s;
1696 for (start_s, end_s) in [(0.0, 1.0), (1.0, 2.0), (2.0, 2.0)] {
1698 let step = Climb {
1699 start_s,
1700 end_s,
1701 speed,
1702 };
1703 peaks.step(&step).expect("the stub's samples never fail");
1704 }
1705 let expected: Vec<SeriesRow> = [0.0, 0.5, 1.0, 1.5, 2.0]
1706 .into_iter()
1707 .map(|t_s| (t_s, t_s, speed(t_s)))
1708 .collect();
1709 assert_eq!(peaks.on_grid, expected);
1710 }
1711
1712 fn rows(start_s: f64, end_s: f64, speed: fn(f64) -> f64) -> Vec<Row> {
1714 let mut peaks = Peaks {
1715 dry_cg_m: DVec3::ZERO,
1716 rail_axis_enu: DVec3::Z,
1717 start_enu_m: DVec3::ZERO,
1718 forward_guide_travel_m: 1.0,
1719 forward_guide_exit: None,
1720 rows: Vec::new(),
1721 start_height_m: 0.0,
1722 grid_s: Vec::new(),
1723 on_grid: Vec::new(),
1724 };
1725 let step = Climb {
1726 start_s,
1727 end_s,
1728 speed,
1729 };
1730 peaks.step(&step).expect("the stub's samples never fail");
1731 peaks.rows
1732 }
1733
1734 #[test]
1735 fn a_peak_inside_a_step_gets_a_row_and_a_rise_does_not() {
1736 let peaked = rows(0.0, 0.05, |t| 100.0 - 1e4 * (t - 0.013).powi(2));
1739 assert_eq!(peaked.len(), 4, "{peaked:?}");
1740 for row in &peaked[2..] {
1741 assert!((row.time_s - 0.013).abs() < 1e-8, "{row:?}");
1742 assert!((row.speed_m_s - 100.0).abs() < 1e-12, "{row:?}");
1743 }
1744 assert_eq!(rows(0.0, 0.05, |t| 100.0 + t).len(), 2);
1746 assert!(rows(0.03, 0.03, |t| 100.0 + t).is_empty());
1749 }
1750
1751 #[test]
1752 fn a_sample_inside_a_step_that_is_not_a_number_is_kept_to_be_refused() {
1753 let rows = rows(0.0, 0.05, |t| {
1755 if (t - 0.013).abs() < 1e-3 {
1756 f64::NAN
1757 } else {
1758 100.0 - 1e4 * (t - 0.013).powi(2)
1759 }
1760 });
1761 assert!(rows.iter().any(|row| !row.is_finite()), "{rows:?}");
1762 }
1763}