Skip to main content

hpr_flightdata/
readings.rs

1//! The readings taken from a flight log on its own: liftoff, apogee, the top speed, landing and
2//! the descent, with no design file and no simulation.
3//!
4//! Each is a [`Reading`]: a value with where it came from, or [`Reading::Withheld`] with the reason
5//! the log can't support it. A reading is never guessed at; one the log can't support is left out
6//! and says why.
7//!
8//! **Method.** Every reading of a height or a time comes from the altitude after a running median
9//! over [`MEDIAN_WINDOW_S`] ([`crate::filter::running_median`]), which takes out the pressure
10//! pulse an ejection charge punches into a barometric trace. Then:
11//!
12//! - the **climb** begins at the first sample [`LIFTOFF_HEIGHT_M`] above where the log starts;
13//! - the **pad** is the median of the altitude before it first rises [`PAD_RISE_M`], which must be
14//!   within [`LIFTOFF_HEIGHT_M`] of the logger's zero, as a logger that zeroes itself on the pad
15//!   records;
16//! - **liftoff** is the last sample before the climb within half the altitude's resolution of the
17//!   pad: the rocket had risen less than that then, and more within one sample after;
18//! - **apogee** is the filtered altitude's highest value, at the middle of the run of samples
19//!   that hold it (the altitude's resolution leaves a peak flat for a few samples);
20//! - the **top speed** is the highest of the logger's own vertical speed from liftoff to apogee,
21//!   refused as Debrief refuses one ([`IMPLAUSIBLE_SPEED_M_S`], [`ASCENT_NOISE_FRACTION`], and a
22//!   peak on the liftoff sample itself);
23//! - **landing** is the first sample after apogee below [`LANDING_HEIGHT_M`] above the pad that
24//!   stays under [`LANDED_CEILING_M`] for [`LANDED_FOR_S`], and no sooner, give or take a sample,
25//!   than a fall from rest in vacuum would lose that height, `√(2h/g)`: drag only slows a fall;
26//! - the **top acceleration** is withheld when the log has no accelerometer: differencing an
27//!   altitude twice turns its resolution into spikes of many g.
28//!
29//! The thresholds are Debrief's (`lib/analyze/index.ts`, MIT, the project owner's own), set on its
30//! corpus of flight logs rather than taken from a published source; the running median in place of
31//! Debrief's Hampel filter is hpr's, for the reason on [`MEDIAN_WINDOW_S`].
32
33use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
34use serde::{Deserialize, Serialize};
35
36use crate::filter::{median, running_median};
37use crate::log::{FlightLog, LogFormat};
38
39/// The running median's span, s: 0.3 s, Debrief's despiking window. Debrief runs a Hampel filter
40/// over it ([`crate::filter::hampel`], threshold 4); hpr takes the plain median (threshold 0). On
41/// the public Pnut log Debrief ships, the ejection charge's pulse peaks at 1,028 ft, against the
42/// 1,009 ft apogee the logger states. The samples around the pulse, a dip before it and a lasting
43/// drop after it, widen its window's spread until the Hampel filter keeps it, and an apogee read
44/// after it is the pulse. After the median it reads 1,010 ft. The median removes any pulse up to
45/// half its window wide, and reads a noise-free peak bent by gravity alone no more than
46/// [`peak_bound_m`] low, 0.077 m at 20 Hz. The Pnut numbers are checked only where the log has been
47/// fetched into `refs/` (it isn't committed); CI checks an invented log of the same shape.
48pub const MEDIAN_WINDOW_S: f64 = 0.3;
49/// The most samples either side of its center the running median takes: hpr's choice, the
50/// [`MEDIAN_WINDOW_S`] window at over 6 kHz, faster than any logger hpr reads. A log sampled
51/// faster is withheld ([`Reason::SampledTooFast`]): the median's cost grows with its window.
52pub const MAX_MEDIAN_HALF_WINDOW: usize = 1000;
53/// The climb above the pad that marks a flight, m (Debrief's 3 m).
54pub const LIFTOFF_HEIGHT_M: f64 = 3.0;
55/// The pad is the median of the samples before the altitude first rises this far above where the
56/// log starts, m: hpr's choice, a third of [`LIFTOFF_HEIGHT_M`], so that a log which begins just
57/// before liftoff lends the pad few samples that are already climbing.
58pub const PAD_RISE_M: f64 = 1.0;
59/// How close to the pad the altitude must come to mark landing, m (Debrief's 2 m).
60pub const LANDING_HEIGHT_M: f64 = 2.0;
61/// The height above the pad the altitude must then stay under, m (Debrief's 5 m)…
62pub const LANDED_CEILING_M: f64 = 5.0;
63/// …and for how long, s (Debrief's 1 s).
64pub const LANDED_FOR_S: f64 = 1.0;
65/// A top speed above this is refused, m/s (Debrief's ceiling).
66pub const IMPLAUSIBLE_SPEED_M_S: f64 = 4000.0;
67/// A top speed is refused when the climb's most negative speed is more than this share of it: a
68/// trace swinging that far has no usable sign (Debrief's 20%).
69pub const ASCENT_NOISE_FRACTION: f64 = 0.2;
70
71/// A reading, or why the log can't support it.
72///
73/// Serialized, the status goes in beside the reading's own fields (`"status": "read"`), so `T`
74/// must serialize as a map, as the reading structs here do.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(tag = "status", rename_all = "snake_case")]
77pub enum Reading<T> {
78    /// The reading, with where it came from.
79    Read(T),
80    /// The log can't support the reading.
81    Withheld(Withheld),
82}
83
84impl<T> Reading<T> {
85    /// The reading, if it wasn't withheld.
86    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/// Why a reading was withheld.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Withheld {
104    /// The reason, as a code.
105    pub reason: Reason,
106    /// The reason in words, with the log's own numbers.
107    pub detail: String,
108}
109
110/// Declares a fieldless enum and its `ALL`, every variant in the order declared, from one list,
111/// so that a new variant can't be left out of `ALL`.
112macro_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            /// Every variant, in the order declared: what a program that maps them, as the
121            /// command line does, checks itself against.
122            pub const ALL: &'static [Self] = &[$(Self::$variant),*];
123        }
124    };
125}
126
127with_all! {
128/// The reason a reading was withheld.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131#[non_exhaustive]
132pub enum Reason {
133    /// The log has fewer than three samples.
134    TooShort,
135    /// The altitude never climbs [`LIFTOFF_HEIGHT_M`] above where the log starts.
136    NoClimb,
137    /// The pad, the median altitude before the first [`PAD_RISE_M`] of rise, is more than
138    /// [`LIFTOFF_HEIGHT_M`] from the logger's zero: the log didn't start on the pad.
139    StartsOffThePad,
140    /// The log ends before the rocket is seen to land.
141    EndsBeforeLanding,
142    /// The altitude reaches the ground sooner than a fall from rest at apogee in vacuum could.
143    FasterThanFreeFall,
144    /// The log has no speed column.
145    NoSpeedColumn,
146    /// The top speed is above [`IMPLAUSIBLE_SPEED_M_S`].
147    ImplausibleSpeed,
148    /// The climb's speed swings negative by more than [`ASCENT_NOISE_FRACTION`] of its top.
149    NoisySpeed,
150    /// The top speed falls on the liftoff sample itself: a spike, not a climb.
151    SpeedPeakAtLiftoff,
152    /// The log has no accelerometer.
153    NoAccelerometer,
154    /// The reading needs another, which was withheld.
155    Needs,
156    /// The record breaks what every reader guarantees: channels as long as the clock, and finite
157    /// times that increase. Only a record built by hand can.
158    BadRecord,
159    /// The samples come so often that the [`MEDIAN_WINDOW_S`] window would hold more than
160    /// [`MAX_MEDIAN_HALF_WINDOW`] either side.
161    SampledTooFast,
162}
163}
164
165with_all! {
166/// Where a reading's value came from.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum Source {
171    /// The logger's barometric altitude, after the running median.
172    Barometer,
173    /// A speed column the logger computed from its own barometric altitude.
174    LoggerSpeedFromBarometer,
175}
176}
177
178/// Liftoff.
179#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
180pub struct Liftoff {
181    /// The last sample on the pad, s on the log's clock: the filtered altitude was within half
182    /// the altitude's resolution of the pad then, and rose past it within one sample interval
183    /// after.
184    pub time_s: f64,
185    /// Where it came from.
186    pub source: Source,
187}
188
189/// The highest point.
190#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
191pub struct Apogee {
192    /// When, s on the log's clock: the middle of the run of samples at the filtered altitude's
193    /// highest value.
194    pub time_s: f64,
195    /// When, s after liftoff; `None` if liftoff was withheld.
196    pub time_after_liftoff_s: Option<f64>,
197    /// The filtered altitude there, m above the logger's zero.
198    pub altitude_m: f64,
199    /// Whether the peak lies within half the median's window of the log's end, so the log may
200    /// have stopped before the rocket did: the altitude is then a floor.
201    pub is_floor: bool,
202    /// The highest sample the log holds before the filter: above the apogee when the median set a
203    /// pulse aside.
204    pub highest_sample: Sample,
205    /// Where it came from.
206    pub source: Source,
207}
208
209/// One sample of the altitude.
210#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
211pub struct Sample {
212    /// When, s on the log's clock.
213    pub time_s: f64,
214    /// The altitude, m above the logger's zero.
215    pub altitude_m: f64,
216}
217
218/// The top vertical speed in the climb.
219#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
220pub struct MaxSpeed {
221    /// The speed, m/s, up.
222    pub speed_m_s: f64,
223    /// When, s on the log's clock.
224    pub time_s: f64,
225    /// The filtered altitude then, m above the logger's zero.
226    pub altitude_m: f64,
227    /// Where it came from.
228    pub source: Source,
229}
230
231/// The top acceleration in the climb. No reader fills it yet: the one format read so far has no
232/// accelerometer.
233#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
234pub struct MaxAcceleration {
235    /// The acceleration, m/s².
236    pub acceleration_m_s2: f64,
237    /// When, s on the log's clock.
238    pub time_s: f64,
239}
240
241/// Landing, and the descent before it.
242#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
243pub struct Landing {
244    /// The first sample within [`LANDING_HEIGHT_M`] of the pad, s on the log's clock: before
245    /// touchdown by the time the last 2 m took.
246    pub time_s: f64,
247    /// From liftoff to landing, s.
248    pub flight_time_s: f64,
249    /// From apogee to landing, s.
250    pub descent_time_s: f64,
251    /// The mean rate of descent from apogee to landing, m/s: the height lost over the time taken,
252    /// drogue and main together.
253    pub mean_descent_rate_m_s: f64,
254    /// Where it came from.
255    pub source: Source,
256}
257
258/// What was read from a flight log.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct Readings {
261    /// The median interval between samples, s; `None` for a log too short to read, or withheld
262    /// whole as [`Reason::BadRecord`].
263    pub sample_interval_s: Option<f64>,
264    /// The running median's span, s, whole samples of the interval: [`MEDIAN_WINDOW_S`] rounded.
265    pub median_window_s: Option<f64>,
266    /// How far below its true peak the running median can read a peak bent by gravity alone, m
267    /// ([`peak_bound_m`]).
268    pub peak_bound_m: Option<f64>,
269    /// The pad: the median of the altitude before it first rises [`PAD_RISE_M`], m above the
270    /// logger's zero; `None` when every reading is withheld, [`Reason::NoClimb`] included.
271    pub pad_altitude_m: Option<f64>,
272    /// Liftoff.
273    pub liftoff: Reading<Liftoff>,
274    /// The highest point.
275    pub apogee: Reading<Apogee>,
276    /// The top vertical speed from liftoff to apogee.
277    pub max_speed: Reading<MaxSpeed>,
278    /// The top acceleration.
279    pub max_acceleration: Reading<MaxAcceleration>,
280    /// Landing, and the descent before it.
281    pub landing: Reading<Landing>,
282}
283
284/// Takes the readings from a flight log.
285pub 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    // The climb: the first sample 3 m above where the log starts.
336    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    // The pad: the median of the samples before the altitude first rises [`PAD_RISE_M`], so no
350    // one sample's jitter sets it.
351    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        // Back from the climb to the last sample on the pad, to within half the altitude's
375        // resolution.
376        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    // The altitude's resolution leaves the peak flat over a run of samples: the apogee is the
389    // run's middle.
390    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
433/// Whole samples either side of the running median's center: [`MEDIAN_WINDOW_S`] over two
434/// intervals, rounded half up, at least one. At 20 Hz it is 3. A log's intervals are written to a
435/// few decimals, so a rounding tie, such as 1.5 at 10 Hz, must not turn on the last bit of the
436/// interval's median: a part in 10⁹ settles it upwards.
437fn 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
448/// How far below its true peak the running median of `half` samples either side can read a trace
449/// sampled every `interval` s and bent by gravity alone near its peak, m:
450/// `g ((⌈half/2⌉ + ½) Δt)² / 2`. At the highest sample, `half + 1` of the window's samples lie
451/// within `⌈half/2⌉` places of it, so the median is no lower than they are, and the true peak lies
452/// within half a sample of the highest sample. At 20 Hz, 0.077 m.
453pub 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
462/// Whether the record holds to what every reader guarantees: one time per altitude, each channel
463/// as long as the clock, and finite times that increase.
464fn 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
497/// The index of the first greatest finite value.
498fn 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
513/// The top acceleration, withheld: no format read so far records one.
514fn 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
524/// The top vertical speed from liftoff to apogee, or why it is withheld.
525fn 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
580/// Landing and the descent, or why they are withheld.
581fn 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    // The quickest any fall from rest at apogee can lose that height: in vacuum. Both heights
613    // are rounded, so the drop can read up to one resolution long; and the apogee's time, the
614    // middle of its flat run, can sit about half a sample from the true one, so a sample is
615    // allowed. A test passes a vacuum fall in feet at 10 to 100 samples a second, wherever its
616    // apogee falls within a foot and its liftoff between samples, and fails without either.
617    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    /// A log sampled every 0.05 s to `end_s`, its height and speed from `flight`.
644    fn log(end_s: f64, flight: impl Fn(f64) -> (f64, f64)) -> FlightLog {
645        log_every(DT, end_s, flight)
646    }
647
648    /// A log sampled every `dt` s to `end_s`, its height and speed from `flight`.
649    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    /// Up at 20 m/s from 1 s to 6 s (100 m), then down at `descent` m/s to the pad.
672    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    /// The test flight reads: liftoff at 1 s, apogee at 6 s, landing below 2 m.
693    #[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        // The peak is a corner, which the median takes a meter off: the fourth highest of the
698        // seven samples around it, 99 m, held from 5.95 s to 6.1 s.
699        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        // 100 m at 10 m/s from 6 s: below 2 m after 9.8 s, the sample after 15.8 s, at 1.5 m.
706        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    /// Each withheld reading names the reason that fired, and only the readings that need it go.
715    #[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        // Starting 50 m up: no liftoff and no pad, so no top speed and no landing; apogee reads.
732        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        // A record a reader would never give: refused whole, and saying why.
739        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        // 100 m in 3 s: a vacuum fall from rest takes √(200/g) = 4.5 s.
753        let dropped = read(&log(20.0, flight(100.0 / 3.0)));
754        assert_eq!(reason(&dropped.landing), Some(Reason::FasterThanFreeFall));
755
756        // Still climbing at the end: the apogee is a floor.
757        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    /// The pad is the median of everything before the climb: a jitter in the first samples
763    /// doesn't move liftoff to the start of the log.
764    #[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    /// A vacuum hop from `t0` to `apogee_m`, falling back at `fall_g` times gravity, its heights
777    /// rounded to whole feet as a PerfectFlite writes them; and when it is back on the pad.
778    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    /// The free-fall check passes a fall in vacuum wherever it falls between samples, with the
804    /// heights rounded to feet, at 10 to 100 samples a second, and refuses one at 1.3 g.
805    #[test]
806    fn a_vacuum_fall_lands_and_a_faster_one_is_refused() {
807        let foot = crate::perfectflite::FOOT_M;
808        // Low hops, their apogees a fiftieth of a foot apart, where the rounding matters most;
809        // and higher ones.
810        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    /// A log that starts just before liftoff: the pad is read from before the first meter of
840    /// rise, so the climb's first samples don't lift it and liftoff stays at the sample it was.
841    /// Taken from before the 3 m climb instead, the pad would read 1.1 m and liftoff 0.35 s.
842    #[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    /// A pad whose median falls between two feet: a sample a foot up is still on the pad, as it
856    /// lies within half a foot of that median, so liftoff is the last sample before the climb,
857    /// at 1.05 s, not the 0.5 s where the altitude last read zero.
858    #[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    /// A clock too fine for the median's window is withheld whole, saying so, at the edge: 0.3 s
875    /// is 1,000 samples either side at 0.15 ms, and 1,001 at 0.3/2002 s.
876    #[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        // At 0.15 ms the window is exactly 1,000 samples either side, and the log is read.
896        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    /// The window's half-width at a rounding tie doesn't turn on the interval's last bit.
903    #[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        /// The median's peak bound holds wherever the true peak falls between samples, at any
915        /// half-width, and the median never reads above the peak.
916        #[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    /// The top speed's guards, each on its own.
933    #[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        // 20 m/s at the top: a swing to −4 m/s is 20%, and passes; −4.5 m/s doesn't.
954        assert!(speed_at(3.0, -4.0).value().is_some());
955        assert_eq!(reason(&speed_at(3.0, -4.5)), Some(Reason::NoisySpeed));
956        // Liftoff is the sample at 1.0 s: a peak there is a spike.
957        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    /// `ALL` is built from the enum's own list, so it can't miss a variant; each is listed once,
965    /// with its own code.
966    #[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    /// Serialized, a reading carries its status beside its fields.
982    #[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}