Skip to main content

hpr_flightdata/
filter.rs

1//! Filters a flight log's channels are cleaned with before a reading is taken from them.
2
3/// The running median of `values`: each sample replaced by the median of the samples within
4/// `half` places of it, the window cut short at either end of the record. `NaN`s are skipped; a
5/// window with no finite sample gives `NaN`. An even count takes the mean of the middle two.
6///
7/// This is the standard median filter, the Hampel filter at threshold `t = 0` (R. K. Pearson et
8/// al., *The Class of Generalized Hampel Filters*, EUSIPCO 2015, §2, eqs. 1 and 2, with `K =
9/// half`). It removes any excursion narrower than `half + 1` samples, such as the pressure pulse an
10/// ejection charge punches into a barometric altitude, and leaves a monotonic run exactly as it
11/// was. At a noise-free peak it reads low, never high: by the fall over `⌈half/2⌉` samples from
12/// the highest sample, as `half + 1` of the window's samples lie that close to it
13/// ([`crate::readings::peak_bound_m`] gives the bound for a peak bent by gravity). With noise, the
14/// highest of the medians can read above the peak.
15pub fn running_median(values: &[f64], half: usize) -> Vec<f64> {
16    // The window is kept sorted as it slides, one sample in and one out, so the cost is the
17    // record's length times the window's, not times a sort of it.
18    let mut window: Vec<f64> = Vec::with_capacity(window_len(half, values.len()));
19    let place =
20        |window: &[f64], value: f64| window.partition_point(|w| w.total_cmp(&value).is_lt());
21    for value in values.iter().take(half).copied().filter(|v| v.is_finite()) {
22        window.insert(place(&window, value), value);
23    }
24    (0..values.len())
25        .map(|index| {
26            if let Some(&value) = index.checked_add(half).and_then(|i| values.get(i))
27                && value.is_finite()
28            {
29                window.insert(place(&window, value), value);
30            }
31            if let Some(&value) = index
32                .checked_sub(half)
33                .and_then(|i| i.checked_sub(1))
34                .and_then(|i| values.get(i))
35                && value.is_finite()
36            {
37                let at = place(&window, value);
38                if window.get(at).is_some_and(|w| w.total_cmp(&value).is_eq()) {
39                    window.remove(at);
40                }
41            }
42            sorted_median(&window).unwrap_or(f64::NAN)
43        })
44        .collect()
45}
46
47/// The most samples a window of `half` either side can hold in a record of `len`.
48fn window_len(half: usize, len: usize) -> usize {
49    half.saturating_mul(2).saturating_add(1).min(len)
50}
51
52/// The Hampel filter: each sample more than `threshold` robust standard deviations from its
53/// window's median replaced by that median, the scale being 1.4826 times the window's median
54/// absolute deviation (R. K. Pearson et al., *The Class of Generalized Hampel Filters*, EUSIPCO
55/// 2015, §2, eqs. 1 and 2, with `K = half` and `t = threshold`). Windows are cut short at the ends
56/// and skip `NaN`s, as in [`running_median`], which is this filter at `threshold = 0`.
57///
58/// hpr reads heights after [`running_median`] instead (see
59/// [`MEDIAN_WINDOW_S`](crate::readings::MEDIAN_WINDOW_S)); this is here to show why: a pulse
60/// among other large departures, as an ejection charge's is, widens its own window's spread until
61/// the filter keeps it.
62pub fn hampel(values: &[f64], half: usize, threshold: f64) -> Vec<f64> {
63    let mut window = Vec::with_capacity(window_len(half, values.len()));
64    (0..values.len())
65        .map(|index| {
66            let value = values[index];
67            let start = index.saturating_sub(half);
68            let end = index
69                .saturating_add(half)
70                .saturating_add(1)
71                .min(values.len());
72            window.clear();
73            window.extend(values[start..end].iter().copied().filter(|v| v.is_finite()));
74            let Some(middle) = median(&mut window) else {
75                return value;
76            };
77            for sample in &mut window {
78                *sample = (*sample - middle).abs();
79            }
80            let scale = 1.4826 * median(&mut window).unwrap_or(0.0);
81            if (value - middle).abs() > threshold * scale {
82                middle
83            } else {
84                value
85            }
86        })
87        .collect()
88}
89
90/// The median of `values`, reordering them; `None` if there are none. An even count takes the
91/// mean of the middle two. The values must not be `NaN`.
92pub(crate) fn median(values: &mut [f64]) -> Option<f64> {
93    values.sort_by(f64::total_cmp);
94    sorted_median(values)
95}
96
97/// The median of values already sorted by [`f64::total_cmp`]; `None` if there are none.
98fn sorted_median(values: &[f64]) -> Option<f64> {
99    let middle = values.len() / 2;
100    match values.len() {
101        0 => None,
102        len if len % 2 == 1 => Some(values[middle]),
103        _ => Some(0.5 * (values[middle - 1] + values[middle])),
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    /// A spike of up to `half` samples goes; a step, and a monotonic run, stay as they were.
112    #[test]
113    fn spikes_go_and_monotonic_runs_stay() {
114        let spiked = [0.0, 0.0, 0.0, 0.0, 9.0, 8.0, 9.0, 0.0, 0.0, 0.0, 0.0];
115        assert_eq!(running_median(&spiked, 3), [0.0; 11]);
116        // Where the window is cut short at an end, fewer samples outvote the spike.
117        let early = [0.0, 0.0, 0.0, 9.0, 8.0, 9.0, 0.0, 0.0, 0.0, 0.0];
118        assert_eq!(running_median(&early, 3)[2], 4.0);
119        let step = [0.0, 0.0, 0.0, 0.0, 5.0, 5.0, 5.0, 5.0];
120        assert_eq!(running_median(&step, 3), step);
121        let ramp: Vec<f64> = (0..20).map(|i| f64::from(i) * 1.5).collect();
122        assert_eq!(running_median(&ramp, 3)[3..17], ramp[3..17]);
123        assert!(running_median(&[f64::NAN, f64::NAN], 1)[0].is_nan());
124        assert_eq!(running_median(&[1.0, f64::NAN, 3.0], 1), [1.0, 2.0, 3.0]);
125    }
126
127    /// A parabola `−a t²/2` sampled on its peak reads low by exactly the fall over `⌈half/2⌉`
128    /// samples at the peak sample.
129    #[test]
130    fn a_peak_sample_reads_the_fall_over_half_the_half_window() {
131        let (a, dt, half) = (9.806_65, 0.05, 3_usize);
132        let trace: Vec<f64> = (-20..=20)
133            .map(|i| -0.5 * a * (f64::from(i) * dt).powi(2))
134            .collect();
135        let peak = running_median(&trace, half)[20];
136        let bound = 0.5 * a * (2.0 * dt).powi(2);
137        assert!(
138            peak <= 0.0 && -peak <= bound * (1.0 + 1e-12),
139            "{peak} against {bound}"
140        );
141        assert_eq!(peak, trace[22]);
142    }
143
144    /// At threshold zero the Hampel filter is the running median, as Pearson et al. note.
145    #[test]
146    fn hampel_at_zero_is_the_running_median() {
147        let values = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0, 5.0, 3.0, 5.0];
148        for half in 1..4 {
149            assert_eq!(hampel(&values, half, 0.0), running_median(&values, half));
150        }
151        // At threshold 4, a lone spike on a ramp goes, replaced by its window's median, and the
152        // ramp stays.
153        let ramp: Vec<f64> = (0..11).map(f64::from).collect();
154        let mut spiked = ramp.clone();
155        spiked[5] = 50.0;
156        let mut expected = ramp;
157        expected[5] = 6.0;
158        assert_eq!(hampel(&spiked, 3, 4.0), expected);
159    }
160
161    /// A window as wide as a `usize` can say is cut to the record, in both filters.
162    #[test]
163    fn the_widest_window_is_the_whole_record() {
164        let values = [3.0, 1.0, 4.0, 1.0, 5.0];
165        assert_eq!(running_median(&values, usize::MAX), [3.0; 5]);
166        assert_eq!(hampel(&values, usize::MAX, 0.0), [3.0; 5]);
167    }
168
169    proptest::proptest! {
170        /// The sliding window gives what sorting each window afresh gives, bit for bit, gaps and
171        /// signed zeros included.
172        #[test]
173        fn the_sliding_median_is_each_windows_median(
174            values in proptest::collection::vec(
175                proptest::prop_oneof![
176                    -5.0..5.0_f64,
177                    proptest::strategy::Just(f64::NAN),
178                    proptest::strategy::Just(0.0),
179                    proptest::strategy::Just(-0.0),
180                    proptest::strategy::Just(1.0),
181                ],
182                0..60,
183            ),
184            half in 0_usize..8,
185        ) {
186            let slid = running_median(&values, half);
187            for (index, out) in slid.iter().enumerate() {
188                let end = (index + half + 1).min(values.len());
189                let mut window: Vec<f64> = values[index.saturating_sub(half)..end]
190                    .iter()
191                    .copied()
192                    .filter(|v| v.is_finite())
193                    .collect();
194                let expected = median(&mut window).unwrap_or(f64::NAN);
195                proptest::prop_assert_eq!(out.to_bits(), expected.to_bits());
196            }
197        }
198
199        /// Each output lies between its window's least and greatest finite input.
200        #[test]
201        fn outputs_lie_within_their_windows(
202            values in proptest::collection::vec(-1e6..1e6_f64, 0..60),
203            half in 0_usize..6,
204        ) {
205            let out = running_median(&values, half);
206            proptest::prop_assert_eq!(out.len(), values.len());
207            for (index, value) in out.iter().enumerate() {
208                let window = &values[index.saturating_sub(half)..(index + half + 1).min(values.len())];
209                let low = window.iter().copied().fold(f64::INFINITY, f64::min);
210                let high = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
211                proptest::prop_assert!(*value >= low && *value <= high);
212            }
213        }
214    }
215}