1use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
34use serde::{Deserialize, Serialize};
35
36use crate::filter::{median, running_median};
37use crate::log::{FlightLog, LogFormat};
38
39pub const MEDIAN_WINDOW_S: f64 = 0.3;
49pub const MAX_MEDIAN_HALF_WINDOW: usize = 1000;
53pub const LIFTOFF_HEIGHT_M: f64 = 3.0;
55pub const PAD_RISE_M: f64 = 1.0;
59pub const LANDING_HEIGHT_M: f64 = 2.0;
61pub const LANDED_CEILING_M: f64 = 5.0;
63pub const LANDED_FOR_S: f64 = 1.0;
65pub const IMPLAUSIBLE_SPEED_M_S: f64 = 4000.0;
67pub const ASCENT_NOISE_FRACTION: f64 = 0.2;
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(tag = "status", rename_all = "snake_case")]
77pub enum Reading<T> {
78 Read(T),
80 Withheld(Withheld),
82}
83
84impl<T> Reading<T> {
85 pub fn value(&self) -> Option<&T> {
87 match self {
88 Self::Read(value) => Some(value),
89 Self::Withheld(_) => None,
90 }
91 }
92
93 fn withheld(reason: Reason, detail: impl Into<String>) -> Self {
94 Self::Withheld(Withheld {
95 reason,
96 detail: detail.into(),
97 })
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Withheld {
104 pub reason: Reason,
106 pub detail: String,
108}
109
110macro_rules! with_all {
113 ($(#[$meta:meta])* pub enum $name:ident { $($(#[$variant_meta:meta])* $variant:ident,)* }) => {
114 $(#[$meta])*
115 pub enum $name {
116 $($(#[$variant_meta])* $variant,)*
117 }
118
119 impl $name {
120 pub const ALL: &'static [Self] = &[$(Self::$variant),*];
123 }
124 };
125}
126
127with_all! {
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131#[non_exhaustive]
132pub enum Reason {
133 TooShort,
135 NoClimb,
137 StartsOffThePad,
140 EndsBeforeLanding,
142 FasterThanFreeFall,
144 NoSpeedColumn,
146 ImplausibleSpeed,
148 NoisySpeed,
150 SpeedPeakAtLiftoff,
152 NoAccelerometer,
154 Needs,
156 BadRecord,
159 SampledTooFast,
162}
163}
164
165with_all! {
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum Source {
171 Barometer,
173 LoggerSpeedFromBarometer,
175}
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
180pub struct Liftoff {
181 pub time_s: f64,
185 pub source: Source,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
191pub struct Apogee {
192 pub time_s: f64,
195 pub time_after_liftoff_s: Option<f64>,
197 pub altitude_m: f64,
199 pub is_floor: bool,
202 pub highest_sample: Sample,
205 pub source: Source,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
211pub struct Sample {
212 pub time_s: f64,
214 pub altitude_m: f64,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
220pub struct MaxSpeed {
221 pub speed_m_s: f64,
223 pub time_s: f64,
225 pub altitude_m: f64,
227 pub source: Source,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
234pub struct MaxAcceleration {
235 pub acceleration_m_s2: f64,
237 pub time_s: f64,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
243pub struct Landing {
244 pub time_s: f64,
247 pub flight_time_s: f64,
249 pub descent_time_s: f64,
251 pub mean_descent_rate_m_s: f64,
254 pub source: Source,
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct Readings {
261 pub sample_interval_s: Option<f64>,
264 pub median_window_s: Option<f64>,
266 pub peak_bound_m: Option<f64>,
269 pub pad_altitude_m: Option<f64>,
272 pub liftoff: Reading<Liftoff>,
274 pub apogee: Reading<Apogee>,
276 pub max_speed: Reading<MaxSpeed>,
278 pub max_acceleration: Reading<MaxAcceleration>,
280 pub landing: Reading<Landing>,
282}
283
284pub fn read(log: &FlightLog) -> Readings {
286 let max_acceleration = no_accelerometer(log);
287 let every = |reason: Reason, detail: &str| Readings {
288 sample_interval_s: None,
289 median_window_s: None,
290 peak_bound_m: None,
291 pad_altitude_m: None,
292 liftoff: Reading::withheld(reason, detail),
293 apogee: Reading::withheld(reason, detail),
294 max_speed: Reading::withheld(reason, detail),
295 max_acceleration: max_acceleration.clone(),
296 landing: Reading::withheld(reason, detail),
297 };
298 if let Err(detail) = check_record(log) {
299 return every(Reason::BadRecord, &detail);
300 }
301 let time = &log.time_s;
302 let n = time.len();
303 let mut steps: Vec<f64> = time.windows(2).map(|pair| pair[1] - pair[0]).collect();
304 let (Some(interval), true) = (median(&mut steps), n >= 3) else {
305 return every(
306 Reason::TooShort,
307 &format!("the log has {n} samples, too few to take a reading from"),
308 );
309 };
310 let half = half_window(interval);
311 if half > MAX_MEDIAN_HALF_WINDOW {
312 let mut readings = every(
313 Reason::SampledTooFast,
314 &format!(
315 "the log's samples come every {interval:.3e} s, so a {MEDIAN_WINDOW_S} s running \
316 median would take more than {MAX_MEDIAN_HALF_WINDOW} samples either side: faster \
317 than any logger hpr reads"
318 ),
319 );
320 readings.sample_interval_s = Some(interval);
321 return readings;
322 }
323 let filtered = running_median(&log.altitude_m, half);
324 let bound = peak_bound_m(half, interval);
325 #[expect(
326 clippy::cast_precision_loss,
327 reason = "a window of a few samples converts exactly"
328 )]
329 let window_s = 2.0 * half as f64 * interval;
330 let mut readings = every(Reason::TooShort, "");
331 readings.sample_interval_s = Some(interval);
332 readings.median_window_s = Some(window_s);
333 readings.peak_bound_m = Some(bound);
334
335 let start = filtered[0];
337 let (Some(apogee_index), Some(climbed)) = (
338 arg_max(&filtered),
339 filtered.iter().position(|h| *h >= start + LIFTOFF_HEIGHT_M),
340 ) else {
341 let detail =
342 format!("the altitude never climbs {LIFTOFF_HEIGHT_M} m above where the log starts");
343 readings.liftoff = Reading::withheld(Reason::NoClimb, detail.clone());
344 readings.apogee = Reading::withheld(Reason::NoClimb, detail.clone());
345 readings.max_speed = Reading::withheld(Reason::NoClimb, detail.clone());
346 readings.landing = Reading::withheld(Reason::NoClimb, detail);
347 return readings;
348 };
349 let risen = filtered
352 .iter()
353 .position(|h| *h >= start + PAD_RISE_M)
354 .unwrap_or(climbed);
355 let mut before: Vec<f64> = log.altitude_m[..risen.max(1)]
356 .iter()
357 .copied()
358 .filter(|h| h.is_finite())
359 .collect();
360 let pad = median(&mut before).unwrap_or(start);
361 readings.pad_altitude_m = Some(pad);
362 let top = filtered[apogee_index];
363
364 let liftoff_index = if pad.abs() > LIFTOFF_HEIGHT_M {
365 readings.liftoff = Reading::withheld(
366 Reason::StartsOffThePad,
367 format!(
368 "the log starts {pad:.2} m from the logger's zero, more than {LIFTOFF_HEIGHT_M} m: \
369 it didn't start on the pad"
370 ),
371 );
372 None
373 } else {
374 let level = pad + 0.5 * log.format.altitude_resolution_m();
377 let index = filtered[..climbed]
378 .iter()
379 .rposition(|h| *h <= level)
380 .unwrap_or(0);
381 readings.liftoff = Reading::Read(Liftoff {
382 time_s: time[index],
383 source: Source::Barometer,
384 });
385 Some(index)
386 };
387
388 let run_end = apogee_index
391 + filtered[apogee_index..]
392 .iter()
393 .take_while(|h| **h == top)
394 .count()
395 - 1;
396 let apogee_s = 0.5 * (time[apogee_index] + time[run_end]);
397 let highest = arg_max(&log.altitude_m).unwrap_or(apogee_index);
398 readings.apogee = Reading::Read(Apogee {
399 time_s: apogee_s,
400 time_after_liftoff_s: liftoff_index.map(|index| apogee_s - time[index]),
401 altitude_m: top,
402 is_floor: run_end + half >= n - 1,
403 highest_sample: Sample {
404 time_s: time[highest],
405 altitude_m: log.altitude_m[highest],
406 },
407 source: Source::Barometer,
408 });
409
410 let Some(liftoff) = liftoff_index else {
411 readings.max_speed = Reading::withheld(
412 Reason::Needs,
413 "the top speed is taken from liftoff to apogee, and liftoff was withheld",
414 );
415 readings.landing = Reading::withheld(
416 Reason::Needs,
417 "landing is found against the pad, and the log didn't start on it",
418 );
419 return readings;
420 };
421 readings.max_speed = max_speed(log, &filtered, liftoff, apogee_index);
422 readings.landing = landing(
423 time,
424 &filtered,
425 pad,
426 (apogee_index, apogee_s),
427 (liftoff, interval),
428 log.format.altitude_resolution_m(),
429 );
430 readings
431}
432
433fn half_window(interval: f64) -> usize {
438 let exact = MEDIAN_WINDOW_S / (2.0 * interval);
439 #[expect(
440 clippy::cast_possible_truncation,
441 clippy::cast_sign_loss,
442 reason = "a positive, finite count of samples, rounded, clamped to at least one"
443 )]
444 let half = (exact * (1.0 + 1e-9)).round().max(1.0) as usize;
445 half
446}
447
448pub fn peak_bound_m(half: usize, interval: f64) -> f64 {
454 #[expect(
455 clippy::cast_precision_loss,
456 reason = "a window of a few samples converts exactly"
457 )]
458 let reach = (half.div_ceil(2) as f64 + 0.5) * interval;
459 0.5 * STANDARD_GRAVITY_MPS2 * reach * reach
460}
461
462fn check_record(log: &FlightLog) -> Result<(), String> {
465 let n = log.time_s.len();
466 let channels = [
467 ("altitude", Some(log.altitude_m.len())),
468 ("speed", log.vertical_speed_m_s.as_ref().map(Vec::len)),
469 ("temperature", log.temperature_k.as_ref().map(Vec::len)),
470 ("battery", log.battery_v.as_ref().map(Vec::len)),
471 ];
472 for (name, len) in channels {
473 if let Some(len) = len
474 && len != n
475 {
476 return Err(format!(
477 "the record has {n} times and {len} {name} samples: a reader gives each channel \
478 one sample per time"
479 ));
480 }
481 }
482 if let Some(index) = log
483 .time_s
484 .windows(2)
485 .position(|pair| !(pair[0].is_finite() && pair[1].is_finite() && pair[1] > pair[0]))
486 {
487 return Err(format!(
488 "the record's times don't increase at sample {}: {} s, then {} s",
489 index + 1,
490 log.time_s[index],
491 log.time_s[index + 1]
492 ));
493 }
494 Ok(())
495}
496
497fn arg_max(values: &[f64]) -> Option<usize> {
499 values
500 .iter()
501 .enumerate()
502 .filter(|(_, value)| value.is_finite())
503 .fold(
504 None,
505 |best: Option<(usize, f64)>, (index, value)| match best {
506 Some((_, top)) if *value <= top => best,
507 _ => Some((index, *value)),
508 },
509 )
510 .map(|(index, _)| index)
511}
512
513fn no_accelerometer(log: &FlightLog) -> Reading<MaxAcceleration> {
515 let detail = match log.format {
516 LogFormat::PerfectFlitePf2 => {
517 "a PerfectFlite logger has no accelerometer; hpr doesn't difference the altitude \
518 twice to make one, as its one-foot steps would read as spikes of many g"
519 }
520 };
521 Reading::withheld(Reason::NoAccelerometer, detail)
522}
523
524fn max_speed(
526 log: &FlightLog,
527 filtered: &[f64],
528 liftoff: usize,
529 apogee: usize,
530) -> Reading<MaxSpeed> {
531 let Some(speed) = &log.vertical_speed_m_s else {
532 return Reading::withheld(
533 Reason::NoSpeedColumn,
534 "the log has no speed column, and hpr doesn't difference the altitude to make one yet",
535 );
536 };
537 let Some(offset) = speed.get(liftoff..=apogee).and_then(arg_max) else {
538 return Reading::withheld(
539 Reason::NoSpeedColumn,
540 "the speed column has no value from liftoff to apogee",
541 );
542 };
543 let climb = &speed[liftoff..=apogee];
544 let (index, top) = (liftoff + offset, climb[offset]);
545 let worst = climb
546 .iter()
547 .copied()
548 .filter(|v| v.is_finite())
549 .fold(f64::INFINITY, f64::min);
550 if top > IMPLAUSIBLE_SPEED_M_S {
551 return Reading::withheld(
552 Reason::ImplausibleSpeed,
553 format!("the speed column peaks at {top:.1} m/s, above {IMPLAUSIBLE_SPEED_M_S} m/s"),
554 );
555 }
556 if top <= 0.0 || -worst > ASCENT_NOISE_FRACTION * top {
557 return Reading::withheld(
558 Reason::NoisySpeed,
559 format!(
560 "the speed from liftoff to apogee swings from {worst:.1} to {top:.1} m/s: a \
561 negative swing over {:.0}% of the top has no usable sign",
562 ASCENT_NOISE_FRACTION * 100.0
563 ),
564 );
565 }
566 if index == liftoff {
567 return Reading::withheld(
568 Reason::SpeedPeakAtLiftoff,
569 format!("the speed peaks at {top:.1} m/s on the liftoff sample itself: a spike"),
570 );
571 }
572 Reading::Read(MaxSpeed {
573 speed_m_s: top,
574 time_s: log.time_s[index],
575 altitude_m: filtered[index],
576 source: Source::LoggerSpeedFromBarometer,
577 })
578}
579
580fn landing(
582 time: &[f64],
583 filtered: &[f64],
584 pad: f64,
585 (apogee, apogee_s): (usize, f64),
586 (liftoff, interval): (usize, f64),
587 rounding: f64,
588) -> Reading<Landing> {
589 let end = time[filtered.len() - 1];
590 let landed = (apogee + 1..filtered.len()).find(|&index| {
591 filtered[index] < pad + LANDING_HEIGHT_M
592 && time[index] + LANDED_FOR_S <= end
593 && filtered[index..]
594 .iter()
595 .zip(&time[index..])
596 .take_while(|(_, t)| **t <= time[index] + LANDED_FOR_S)
597 .all(|(h, _)| *h < pad + LANDED_CEILING_M)
598 });
599 let Some(index) = landed else {
600 return Reading::withheld(
601 Reason::EndsBeforeLanding,
602 format!(
603 "the log ends at {end:.2} s, {:.1} m above the pad, before the altitude comes \
604 within {LANDING_HEIGHT_M} m of it and stays under {LANDED_CEILING_M} m for \
605 {LANDED_FOR_S} s",
606 filtered[filtered.len() - 1] - pad
607 ),
608 );
609 };
610 let descent_time_s = time[index] - apogee_s;
611 let drop = filtered[apogee] - filtered[index];
612 let quickest = (2.0 * (drop - rounding).max(0.0) / STANDARD_GRAVITY_MPS2).sqrt();
618 if descent_time_s + interval < quickest {
619 return Reading::withheld(
620 Reason::FasterThanFreeFall,
621 format!(
622 "the altitude reaches the pad {descent_time_s:.2} s after apogee, sooner than a \
623 fall from rest in vacuum could ({quickest:.2} s): the trace isn't a height there"
624 ),
625 );
626 }
627 Reading::Read(Landing {
628 time_s: time[index],
629 flight_time_s: time[index] - time[liftoff],
630 descent_time_s,
631 mean_descent_rate_m_s: drop / descent_time_s,
632 source: Source::Barometer,
633 })
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::log::Stated;
640
641 const DT: f64 = 0.05;
642
643 fn log(end_s: f64, flight: impl Fn(f64) -> (f64, f64)) -> FlightLog {
645 log_every(DT, end_s, flight)
646 }
647
648 fn log_every(dt: f64, end_s: f64, flight: impl Fn(f64) -> (f64, f64)) -> FlightLog {
650 let times: Vec<f64> = (0..)
651 .map(|i| f64::from(i) * dt)
652 .take_while(|t| *t <= end_s + 1e-9)
653 .collect();
654 let (altitude_m, speed): (Vec<f64>, Vec<f64>) = times.iter().map(|t| flight(*t)).unzip();
655 FlightLog {
656 format: LogFormat::PerfectFlitePf2,
657 logger: "PerfectFlite Pnut".to_owned(),
658 serial_number: None,
659 firmware: None,
660 flight_number: None,
661 stated: Stated::default(),
662 time_s: times,
663 altitude_m,
664 vertical_speed_m_s: Some(speed),
665 temperature_k: None,
666 battery_v: None,
667 notes: Vec::new(),
668 }
669 }
670
671 fn flight(descent: f64) -> impl Fn(f64) -> (f64, f64) {
673 move |t| {
674 if t <= 1.0 {
675 (0.0, 0.0)
676 } else if t < 6.0 {
677 (20.0 * (t - 1.0), 20.0)
678 } else {
679 let h = 100.0 - descent * (t - 6.0);
680 if h > 0.0 { (h, -descent) } else { (0.0, 0.0) }
681 }
682 }
683 }
684
685 fn reason<T>(reading: &Reading<T>) -> Option<Reason> {
686 match reading {
687 Reading::Read(_) => None,
688 Reading::Withheld(withheld) => Some(withheld.reason),
689 }
690 }
691
692 #[test]
694 fn a_plain_flight_reads() {
695 let read = read(&log(20.0, flight(10.0)));
696 assert_eq!(read.liftoff.value().unwrap().time_s, 1.0);
697 let apogee = read.apogee.value().unwrap();
700 assert_eq!(apogee.altitude_m, 99.0);
701 assert!((apogee.time_s - 6.025).abs() < 1e-12, "{}", apogee.time_s);
702 assert_eq!(apogee.highest_sample.altitude_m, 100.0);
703 assert_eq!(read.max_speed.value().unwrap().speed_m_s, 20.0);
704 let landing = read.landing.value().unwrap();
705 assert!((landing.time_s - 15.85).abs() < 1e-9, "{}", landing.time_s);
707 assert!((landing.mean_descent_rate_m_s - 97.5 / 9.825).abs() < 1e-9);
708 assert_eq!(
709 reason(&read.max_acceleration),
710 Some(Reason::NoAccelerometer)
711 );
712 }
713
714 #[test]
716 fn each_refusal_names_its_reason() {
717 let short = read(&log(0.05, flight(10.0)));
718 for reason_of in [
719 reason(&short.liftoff),
720 reason(&short.apogee),
721 reason(&short.max_speed),
722 reason(&short.landing),
723 ] {
724 assert_eq!(reason_of, Some(Reason::TooShort));
725 }
726
727 let flat = read(&log(10.0, |_| (0.5, 0.0)));
728 assert_eq!(reason(&flat.apogee), Some(Reason::NoClimb));
729 assert_eq!(reason(&flat.landing), Some(Reason::NoClimb));
730
731 let aloft = read(&log(30.0, |t| flight(10.0)(t + 3.5)));
733 assert_eq!(reason(&aloft.liftoff), Some(Reason::StartsOffThePad));
734 assert_eq!(reason(&aloft.max_speed), Some(Reason::Needs));
735 assert_eq!(reason(&aloft.landing), Some(Reason::Needs));
736 assert_eq!(aloft.apogee.value().unwrap().time_after_liftoff_s, None);
737
738 let mut short_speed = log(20.0, flight(10.0));
740 short_speed.vertical_speed_m_s.as_mut().unwrap().pop();
741 let refused = read(&short_speed);
742 assert_eq!(reason(&refused.apogee), Some(Reason::BadRecord));
743 assert_eq!(reason(&refused.landing), Some(Reason::BadRecord));
744 let mut repeated = log(20.0, flight(10.0));
745 repeated.time_s[10] = repeated.time_s[9];
746 assert_eq!(reason(&read(&repeated).liftoff), Some(Reason::BadRecord));
747
748 let cut = read(&log(12.0, flight(10.0)));
749 assert_eq!(reason(&cut.landing), Some(Reason::EndsBeforeLanding));
750 assert!(cut.apogee.value().is_some());
751
752 let dropped = read(&log(20.0, flight(100.0 / 3.0)));
754 assert_eq!(reason(&dropped.landing), Some(Reason::FasterThanFreeFall));
755
756 let climbing = read(&log(4.0, flight(10.0)));
758 assert!(climbing.apogee.value().unwrap().is_floor);
759 assert_eq!(reason(&climbing.landing), Some(Reason::EndsBeforeLanding));
760 }
761
762 #[test]
765 fn early_jitter_leaves_the_pad_where_it_is() {
766 let foot = crate::perfectflite::FOOT_M;
767 let mut log = log(20.0, |t| flight(10.0)(t - 2.0));
768 for (index, feet) in [0.0, -1.0, -1.0, 0.0].into_iter().enumerate() {
769 log.altitude_m[index] = feet * foot;
770 }
771 let read = read(&log);
772 assert_eq!(read.pad_altitude_m, Some(0.0));
773 assert_eq!(read.liftoff.value().unwrap().time_s, 3.0);
774 }
775
776 fn hop(apogee_m: f64, t0: f64, fall_g: f64) -> (impl Fn(f64) -> (f64, f64), f64) {
779 let g = STANDARD_GRAVITY_MPS2;
780 let foot = crate::perfectflite::FOOT_M;
781 let v0 = (2.0 * g * apogee_m).sqrt();
782 let top = t0 + v0 / g;
783 let fall = fall_g * g;
784 let down = top + (2.0 * apogee_m / fall).sqrt();
785 let flight = move |t: f64| {
786 let (h, v) = if t <= t0 {
787 (0.0, 0.0)
788 } else if t <= top {
789 (
790 v0 * (t - t0) - 0.5 * g * (t - t0).powi(2),
791 v0 - g * (t - t0),
792 )
793 } else if t < down {
794 (apogee_m - 0.5 * fall * (t - top).powi(2), -fall * (t - top))
795 } else {
796 (0.0, 0.0)
797 };
798 ((h / foot).round() * foot, v)
799 };
800 (flight, down)
801 }
802
803 #[test]
806 fn a_vacuum_fall_lands_and_a_faster_one_is_refused() {
807 let foot = crate::perfectflite::FOOT_M;
808 let apogees: Vec<f64> = (0..50)
811 .map(|k| (16.0 + f64::from(k) / 50.0) * foot)
812 .chain([30.0, 300.0, 3000.0])
813 .collect();
814 for dt in [0.01, 0.05, 0.1] {
815 for &apogee_m in &apogees {
816 for step in 0..13 {
817 let t0 = 1.0 + f64::from(step) * dt / 13.0;
818 let (flight, down) = hop(apogee_m, t0, 1.0);
819 let read = read(&log_every(dt, down + 3.0, flight));
820 assert!(
821 read.landing.value().is_some(),
822 "{dt} s, {apogee_m} m, from {t0} s: {:?}",
823 read.landing
824 );
825 }
826 }
827 }
828 for apogee_m in [100.0, 1000.0] {
829 let (flight, down) = hop(apogee_m, 1.0, 1.3);
830 let read = read(&log(down + 3.0, flight));
831 assert_eq!(
832 reason(&read.landing),
833 Some(Reason::FasterThanFreeFall),
834 "{apogee_m} m"
835 );
836 }
837 }
838
839 #[test]
843 fn a_short_pad_isnt_lifted_by_the_climb() {
844 let read = read(&log(5.0, |t| {
845 if t <= 0.1 {
846 (0.0, 0.0)
847 } else {
848 (5.0 * (t - 0.1), 5.0)
849 }
850 }));
851 assert!((read.pad_altitude_m.unwrap() - 0.125).abs() < 1e-12);
852 assert!((read.liftoff.value().unwrap().time_s - 0.15).abs() < 1e-12);
853 }
854
855 #[test]
859 fn a_pad_between_two_feet_takes_half_a_foot_either_way() {
860 let foot = crate::perfectflite::FOOT_M;
861 let read = read(&log(20.0, |t| {
862 if t < 0.525 {
863 (0.0, 0.0)
864 } else if t < 1.075 {
865 (foot, 0.0)
866 } else {
867 (foot + 20.0 * (t - 1.05), 20.0)
868 }
869 }));
870 assert_eq!(read.pad_altitude_m, Some(foot / 2.0));
871 assert_eq!(read.liftoff.value().unwrap().time_s, 21.0 * DT);
872 }
873
874 #[test]
877 fn a_clock_too_fine_for_the_window_is_withheld() {
878 let with_interval = |interval: f64| {
879 let mut log = log(20.0, flight(10.0));
880 for (index, time) in log.time_s.iter_mut().enumerate() {
881 *time = f64::from(u32::try_from(index).unwrap()) * interval;
882 }
883 read(&log)
884 };
885 for interval in [1e-30, 1e-6, MEDIAN_WINDOW_S / 2002.0] {
886 let read = with_interval(interval);
887 assert_eq!(
888 reason(&read.apogee),
889 Some(Reason::SampledTooFast),
890 "{interval}"
891 );
892 assert_eq!(reason(&read.landing), Some(Reason::SampledTooFast));
893 assert_eq!(read.sample_interval_s.map(|s| s > 0.0), Some(true));
894 }
895 let read = with_interval(0.000_15);
897 assert_ne!(reason(&read.apogee), Some(Reason::SampledTooFast));
898 let window_s = read.median_window_s.unwrap();
899 assert!((window_s - 0.3).abs() < 1e-12, "{window_s}");
900 }
901
902 #[test]
904 fn the_window_settles_ties_upwards() {
905 assert_eq!(half_window(0.05), 3);
906 for interval in [0.1, 0.1 + 1e-15, 0.1 - 1e-15, 0.2 - 0.1] {
907 assert_eq!(half_window(interval), 2, "{interval}");
908 }
909 assert_eq!(half_window(1.0), 1);
910 assert!((peak_bound_m(3, 0.05) - 0.076_614).abs() < 1e-6);
911 }
912
913 proptest::proptest! {
914 #[test]
917 fn the_peak_bound_holds(phase in 0.0..1.0_f64, half in 1_usize..7, dt in 0.01..0.2_f64) {
918 let trace: Vec<f64> = (-60..=60)
919 .map(|i| {
920 let t = (f64::from(i) - phase) * dt;
921 -0.5 * STANDARD_GRAVITY_MPS2 * t * t
922 })
923 .collect();
924 let top = running_median(&trace, half)
925 .into_iter()
926 .fold(f64::NEG_INFINITY, f64::max);
927 proptest::prop_assert!(top <= 0.0);
928 proptest::prop_assert!(-top <= peak_bound_m(half, dt) * (1.0 + 1e-12));
929 }
930 }
931
932 #[test]
934 fn a_top_speed_is_refused_as_debrief_refuses_one() {
935 let mut no_column = log(20.0, flight(10.0));
936 no_column.vertical_speed_m_s = None;
937 assert_eq!(
938 reason(&read(&no_column).max_speed),
939 Some(Reason::NoSpeedColumn)
940 );
941
942 let speed_at = |t: f64, value: f64| {
943 let mut log = log(20.0, flight(10.0));
944 let index = (t / DT).round() as usize;
945 log.vertical_speed_m_s.as_mut().unwrap()[index] = value;
946 read(&log).max_speed
947 };
948 assert_eq!(
949 reason(&speed_at(3.0, 4000.5)),
950 Some(Reason::ImplausibleSpeed)
951 );
952 assert!(speed_at(3.0, 4000.0).value().is_some());
953 assert!(speed_at(3.0, -4.0).value().is_some());
955 assert_eq!(reason(&speed_at(3.0, -4.5)), Some(Reason::NoisySpeed));
956 assert_eq!(
958 reason(&speed_at(1.0, 50.0)),
959 Some(Reason::SpeedPeakAtLiftoff)
960 );
961 assert_eq!(speed_at(1.05, 50.0).value().unwrap().speed_m_s, 50.0);
962 }
963
964 #[test]
967 fn all_lists_every_reason_and_source_once() {
968 let codes = |json: Vec<serde_json::Value>| {
969 let mut codes: Vec<String> = json.iter().map(ToString::to_string).collect();
970 let listed = codes.len();
971 codes.sort();
972 codes.dedup();
973 (listed, codes.len())
974 };
975 let reasons = Reason::ALL.iter().map(|r| serde_json::json!(r)).collect();
976 assert_eq!(codes(reasons), (13, 13));
977 let sources = Source::ALL.iter().map(|s| serde_json::json!(s)).collect();
978 assert_eq!(codes(sources), (2, 2));
979 }
980
981 #[test]
983 fn a_reading_serializes_with_its_status() {
984 let read = read(&log(20.0, flight(10.0)));
985 let json = serde_json::to_value(&read).unwrap();
986 assert_eq!(json["apogee"]["status"], "read");
987 assert_eq!(json["apogee"]["altitude_m"], 99.0);
988 assert_eq!(json["max_acceleration"]["status"], "withheld");
989 assert_eq!(json["max_acceleration"]["reason"], "no_accelerometer");
990 let back: Readings = serde_json::from_value(json).unwrap();
991 assert_eq!(back, read);
992 }
993}