Skip to main content

exporter/
filters.rs

1//! Noise filters applied to exported data (never to the archive).
2//!
3//! The same pipeline, with the same arithmetic, runs in the webapp's live plot
4//! (`webapp/src/lib/filters.ts`); the shared vectors in `testdata/filter-vectors.json`
5//! check that both give the same output. Per sample, in order:
6//!
7//! 1. Despike (raw volts): grey opening, a running minimum then a running maximum over
8//!    `w = ceil(2.5 ms × fs)` samples. It removes upward spikes narrower than `w` and
9//!    keeps wider pulses and anything downward. Output `j` needs input `j + w - 1`.
10//! 2. and 3. Remove the 10 Hz and 11.906 Hz square waves (raw volts): subtract running
11//!    templates of one period in phase bins, indexed by the sample count since the
12//!    segment started (both sources run on the LabJack clock). Each bin keeps a
13//!    running mean (cumulative at first, then exponential with a 30 s memory); the
14//!    template is the bin mean minus the running mean of all samples. Templates are
15//!    read before the sample updates them, and each learns from the sample minus the
16//!    other's current template.
17//! 4. Calibration.
18//! 5. High-pass, then low-pass: 2nd-order Butterworth biquads. The live plot runs them
19//!    causally; exports run them forward and backward ([`ExportFilter`]).
20//!
21//! The channel settings come from the export request (the webapp copies them from the
22//! channel's `sensor_settings.filters` entry); see `docs/src/reference/kv-config.md`.
23
24use std::collections::VecDeque;
25
26use serde_json::Value;
27
28/// Length of the despike window, ms.
29pub const DESPIKE_WINDOW_MS: f64 = 2.5;
30/// Frequency of the first square wave, Hz. Its period must be a whole number of samples.
31pub const TEN_HZ: f64 = 10.0;
32/// Frequency of the second square wave, Hz, measured on MU1 and MU2 (lines at 11.906,
33/// 35.72 and 59.53 Hz). It also runs on the LabJack clock.
34pub const ELEVEN_NINE_HZ: f64 = 11.906;
35/// Memory of the template running means, seconds.
36pub const TEMPLATE_MEMORY_S: f64 = 30.0;
37/// Fewest samples per period for a template.
38pub const MIN_TEMPLATE_PERIOD_SAMPLES: f64 = 4.0;
39/// How far `fs / 10` may be from a whole number of samples.
40pub const PERIOD_TOLERANCE_SAMPLES: f64 = 0.01;
41/// Fewest phase bins of the fractional (11.9 Hz) template.
42pub const MIN_FRACTIONAL_BINS: usize = 64;
43/// Phase bins per sample of period in the fractional template.
44pub const FRACTIONAL_BINS_PER_SAMPLE: usize = 4;
45/// Highest usable cutoff, as a fraction of the sample rate.
46pub const MAX_CUTOFF_FRACTION: f64 = 0.45;
47/// Longest run of missing samples filtered through as `NaN`. A longer gap ends the
48/// segment and every filter starts over.
49pub const MAX_FILLED_GAP_SAMPLES: i64 = 4;
50
51/// Filters of one channel, cleaned: `true` switches and positive cutoffs only.
52#[derive(Debug, Clone, Default, PartialEq)]
53pub struct ChannelFilterSettings {
54    pub despike: bool,
55    pub remove_10hz: bool,
56    pub remove_11_9hz: bool,
57    pub highpass_hz: Option<f64>,
58    pub lowpass_hz: Option<f64>,
59}
60
61/// Reads a cutoff: a finite number above 0, or a string holding one.
62fn cutoff(value: Option<&Value>) -> Option<f64> {
63    let number = match value? {
64        Value::Number(n) => n.as_f64()?,
65        Value::String(s) if !s.trim().is_empty() => s.trim().parse::<f64>().ok()?,
66        _ => return None,
67    };
68    (number.is_finite() && number > 0.0).then_some(number)
69}
70
71impl ChannelFilterSettings {
72    /// Cleans one channel's settings the way the webapp does: switches count only when
73    /// they are exactly `true`, cutoffs only when finite and above 0; anything else
74    /// (missing, `false`, `null`, malformed) is off.
75    pub fn from_value(value: &Value) -> Self {
76        let Some(object) = value.as_object() else {
77            return Self::default();
78        };
79        let switch = |name: &str| object.get(name).and_then(Value::as_bool) == Some(true);
80        Self {
81            despike: switch("despike"),
82            remove_10hz: switch("remove_10hz"),
83            remove_11_9hz: switch("remove_11_9hz"),
84            highpass_hz: cutoff(object.get("highpass_hz")),
85            lowpass_hz: cutoff(object.get("lowpass_hz")),
86        }
87    }
88
89    /// True when any filter is on.
90    pub fn is_active(&self) -> bool {
91        self.despike
92            || self.remove_10hz
93            || self.remove_11_9hz
94            || self.highpass_hz.is_some()
95            || self.lowpass_hz.is_some()
96    }
97}
98
99/// What runs for one channel at one sample rate; from [`plan_filters`].
100#[derive(Debug, Clone, PartialEq)]
101pub struct FilterPlan {
102    pub fs: f64,
103    /// Despike window in samples, or 0 when off.
104    pub despike_window: usize,
105    /// 10 Hz period in samples, or 0 when off.
106    pub period10: usize,
107    /// 11.9 Hz period in samples (fractional), or 0 when off.
108    pub period11: f64,
109    /// Phase bins of the 11.9 Hz template.
110    pub bins11: usize,
111    /// High-pass cutoff, Hz, or 0 when off.
112    pub highpass_hz: f64,
113    /// Low-pass cutoff, Hz, or 0 when off.
114    pub lowpass_hz: f64,
115}
116
117/// Works out which filters run at a sample rate; filters that cannot run (too low a
118/// rate, a 10 Hz period that is not a whole number of samples, a cutoff at or above
119/// 0.45 × the rate) are left off. Same rules as `planFilters` in the webapp.
120pub fn plan_filters(settings: &ChannelFilterSettings, fs: f64) -> FilterPlan {
121    let valid = fs.is_finite() && fs > 0.0;
122    let mut plan = FilterPlan {
123        fs,
124        despike_window: 0,
125        period10: 0,
126        period11: 0.0,
127        bins11: 0,
128        highpass_hz: 0.0,
129        lowpass_hz: 0.0,
130    };
131    if settings.despike && valid {
132        let window = (fs * DESPIKE_WINDOW_MS / 1000.0 - 1e-9).ceil();
133        if window >= 2.0 {
134            plan.despike_window = window as usize;
135        }
136    }
137    if settings.remove_10hz && valid {
138        let exact = fs / TEN_HZ;
139        let period = exact.round();
140        if (exact - period).abs() <= PERIOD_TOLERANCE_SAMPLES
141            && period >= MIN_TEMPLATE_PERIOD_SAMPLES
142        {
143            plan.period10 = period as usize;
144        }
145    }
146    if settings.remove_11_9hz && valid {
147        let period = fs / ELEVEN_NINE_HZ;
148        if period >= MIN_TEMPLATE_PERIOD_SAMPLES {
149            plan.period11 = period;
150            plan.bins11 =
151                MIN_FRACTIONAL_BINS.max(FRACTIONAL_BINS_PER_SAMPLE * period.ceil() as usize);
152        }
153    }
154    if let Some(hz) = settings
155        .highpass_hz
156        .filter(|hz| valid && *hz < MAX_CUTOFF_FRACTION * fs)
157    {
158        plan.highpass_hz = hz;
159    }
160    if let Some(hz) = settings
161        .lowpass_hz
162        .filter(|hz| valid && *hz < MAX_CUTOFF_FRACTION * fs)
163    {
164        plan.lowpass_hz = hz;
165    }
166    plan
167}
168
169/// Normalised biquad coefficients (`a0 = 1`).
170#[derive(Debug, Clone, Copy)]
171struct BiquadCoefficients {
172    b0: f64,
173    b1: f64,
174    b2: f64,
175    a1: f64,
176    a2: f64,
177}
178
179/// 2nd-order Butterworth by the bilinear transform with prewarping (the audio-EQ
180/// cookbook with Q = 1/√2).
181fn butterworth(highpass: bool, cutoff_hz: f64, fs: f64) -> BiquadCoefficients {
182    let w0 = 2.0 * std::f64::consts::PI * cutoff_hz / fs;
183    let cos_w = w0.cos();
184    let alpha = w0.sin() / std::f64::consts::SQRT_2;
185    let a0 = 1.0 + alpha;
186    let (b_mid, b_edge) = if highpass {
187        (-(1.0 + cos_w), (1.0 + cos_w) / 2.0)
188    } else {
189        (1.0 - cos_w, (1.0 - cos_w) / 2.0)
190    };
191    BiquadCoefficients {
192        b0: b_edge / a0,
193        b1: b_mid / a0,
194        b2: b_edge / a0,
195        a1: -2.0 * cos_w / a0,
196        a2: (1.0 - alpha) / a0,
197    }
198}
199
200/// One biquad, transposed direct form II. The first finite sample after a reset sets
201/// the steady state for that value; `NaN` in gives `NaN` out and keeps the state.
202#[derive(Debug, Clone)]
203struct Biquad {
204    c: BiquadCoefficients,
205    z1: f64,
206    z2: f64,
207    started: bool,
208}
209
210impl Biquad {
211    fn new(c: BiquadCoefficients) -> Self {
212        Self {
213            c,
214            z1: 0.0,
215            z2: 0.0,
216            started: false,
217        }
218    }
219
220    #[cfg(test)]
221    fn reset(&mut self) {
222        self.started = false;
223        self.z1 = 0.0;
224        self.z2 = 0.0;
225    }
226
227    fn process(&mut self, x: f64) -> f64 {
228        if !x.is_finite() {
229            return f64::NAN;
230        }
231        let c = self.c;
232        if !self.started {
233            let gain = (c.b0 + c.b1 + c.b2) / (1.0 + c.a1 + c.a2);
234            let y0 = gain * x;
235            self.z2 = c.b2 * x - c.a2 * y0;
236            self.z1 = c.b1 * x - c.a1 * y0 + self.z2;
237            self.started = true;
238        }
239        let y = c.b0 * x + self.z1;
240        self.z1 = c.b1 * x - c.a1 * y + self.z2;
241        self.z2 = c.b2 * x - c.a2 * y;
242        y
243    }
244}
245
246/// Streaming grey opening over `w` samples: output `j` is the largest of the minima of
247/// the `w`-sample windows containing `j`, windows cut at the segment's ends and `NaN`
248/// left out. Emitted once input `j + w - 1` has arrived, or by [`Self::flush`].
249#[derive(Debug, Clone)]
250struct Despiker {
251    w: usize,
252    xs: Vec<f64>,
253    es: Vec<f64>,
254    count: usize,
255    emitted: usize,
256}
257
258impl Despiker {
259    fn new(w: usize) -> Self {
260        Self {
261            w,
262            xs: vec![0.0; 2 * w],
263            es: vec![0.0; w],
264            count: 0,
265            emitted: 0,
266        }
267    }
268
269    fn window_min(&self, from: i64, to: i64) -> f64 {
270        let mut min = f64::NAN;
271        let start = from.max(0);
272        let end = to.min(self.count as i64 - 1);
273        let mut m = start;
274        while m <= end {
275            let x = self.xs[m as usize % self.xs.len()];
276            if !x.is_nan() && (min.is_nan() || x < min) {
277                min = x;
278            }
279            m += 1;
280        }
281        min
282    }
283
284    fn slot(&self, k: i64) -> usize {
285        k.rem_euclid(self.w as i64) as usize
286    }
287
288    fn emit(&mut self, j: usize, out: &mut Vec<f64>) {
289        let w = self.w as i64;
290        let j = j as i64;
291        if j == 0 {
292            for k in -(w - 1)..0 {
293                let slot = self.slot(k);
294                self.es[slot] = self.window_min(k, k + w - 1);
295            }
296        }
297        let slot = self.slot(j);
298        self.es[slot] = self.window_min(j, j + w - 1);
299        let x = self.xs[j as usize % self.xs.len()];
300        let mut y = f64::NAN;
301        if !x.is_nan() {
302            for k in (j - w + 1)..=j {
303                let e = self.es[self.slot(k)];
304                if !e.is_nan() && (y.is_nan() || e > y) {
305                    y = e;
306                }
307            }
308        }
309        out.push(y);
310        self.emitted = j as usize + 1;
311    }
312
313    fn push(&mut self, x: f64, out: &mut Vec<f64>) {
314        let len = self.xs.len();
315        self.xs[self.count % len] = x;
316        self.count += 1;
317        if self.count >= self.w {
318            self.emit(self.count - self.w, out);
319        }
320    }
321
322    fn flush(&mut self, out: &mut Vec<f64>) {
323        for j in self.emitted..self.count {
324            self.emit(j, out);
325        }
326        self.reset();
327    }
328
329    fn reset(&mut self) {
330        self.count = 0;
331        self.emitted = 0;
332    }
333}
334
335/// Running template in phase bins; see the module documentation. A bin with no sample
336/// yet uses the mean of its group of `group` bins (the coarse bin).
337#[derive(Debug, Clone)]
338struct PeriodicTemplate {
339    bins: usize,
340    group: usize,
341    alpha: f64,
342    means: Vec<f64>,
343    counts: Vec<f64>,
344    coarse_means: Vec<f64>,
345    coarse_counts: Vec<f64>,
346    mean: f64,
347    count: f64,
348}
349
350impl PeriodicTemplate {
351    fn new(bins: usize, group: usize, alpha: f64) -> Self {
352        let coarse = if group > 1 { bins.div_ceil(group) } else { 0 };
353        Self {
354            bins,
355            group,
356            alpha,
357            means: vec![0.0; bins],
358            counts: vec![0.0; bins],
359            coarse_means: vec![0.0; coarse],
360            coarse_counts: vec![0.0; coarse],
361            mean: 0.0,
362            count: 0.0,
363        }
364    }
365
366    fn template(&self, bin: usize) -> f64 {
367        if self.counts[bin] > 0.0 {
368            return self.means[bin] - self.mean;
369        }
370        if self.group > 1 {
371            let coarse = bin / self.group;
372            if self.coarse_counts[coarse] > 0.0 {
373                return self.coarse_means[coarse] - self.mean;
374            }
375        }
376        0.0
377    }
378
379    fn learn(&mut self, x: f64, bin: usize) {
380        let bin_count = self.counts[bin] + 1.0;
381        self.counts[bin] = bin_count;
382        self.means[bin] +=
383            (1.0 / bin_count).max(self.alpha * self.bins as f64) * (x - self.means[bin]);
384        if self.group > 1 {
385            let coarse = bin / self.group;
386            let coarse_count = self.coarse_counts[coarse] + 1.0;
387            self.coarse_counts[coarse] = coarse_count;
388            self.coarse_means[coarse] += (1.0 / coarse_count)
389                .max(self.alpha * self.coarse_means.len() as f64)
390                * (x - self.coarse_means[coarse]);
391        }
392        self.count += 1.0;
393        self.mean += (1.0 / self.count).max(self.alpha) * (x - self.mean);
394    }
395}
396
397/// Despike and template stages, on raw volts. Appends outputs in input order: none for
398/// the first `despike_window - 1` inputs of a segment, then one per input;
399/// [`Self::flush`] appends the rest.
400#[derive(Debug, Clone)]
401pub struct RawFilterChain {
402    plan: FilterPlan,
403    despiker: Option<Despiker>,
404    template10: Option<PeriodicTemplate>,
405    template11: Option<PeriodicTemplate>,
406    ratio11: f64,
407    index: u64,
408    pending: Vec<f64>,
409}
410
411impl RawFilterChain {
412    pub fn new(plan: &FilterPlan) -> Self {
413        let alpha = 1.0 / (TEMPLATE_MEMORY_S * plan.fs);
414        Self {
415            plan: plan.clone(),
416            despiker: (plan.despike_window > 1).then(|| Despiker::new(plan.despike_window)),
417            template10: (plan.period10 > 0).then(|| PeriodicTemplate::new(plan.period10, 1, alpha)),
418            template11: (plan.period11 > 0.0)
419                .then(|| PeriodicTemplate::new(plan.bins11, FRACTIONAL_BINS_PER_SAMPLE, alpha)),
420            ratio11: ELEVEN_NINE_HZ / plan.fs,
421            index: 0,
422            pending: Vec::new(),
423        }
424    }
425
426    fn finish(&mut self, x: f64, out: &mut Vec<f64>) {
427        let n = self.index;
428        self.index += 1;
429        let bin10 = if self.template10.is_some() {
430            (n % self.plan.period10 as u64) as usize
431        } else {
432            0
433        };
434        let bin11 = if self.template11.is_some() {
435            let turns = n as f64 * self.ratio11;
436            let bin = ((turns - turns.floor()) * self.plan.bins11 as f64).floor() as usize;
437            bin.min(self.plan.bins11 - 1)
438        } else {
439            0
440        };
441        let template10 = self.template10.as_ref().map_or(0.0, |t| t.template(bin10));
442        let template11 = self.template11.as_ref().map_or(0.0, |t| t.template(bin11));
443        if x.is_finite() {
444            if let Some(t) = self.template10.as_mut() {
445                t.learn(x - template11, bin10);
446            }
447            if let Some(t) = self.template11.as_mut() {
448                t.learn(x - template10, bin11);
449            }
450        }
451        out.push(x - template10 - template11);
452    }
453
454    /// Takes one raw sample (`NaN` for a missing one) and appends the outputs it completes.
455    pub fn push(&mut self, x: f64, out: &mut Vec<f64>) {
456        let Some(despiker) = self.despiker.as_mut() else {
457            self.finish(x, out);
458            return;
459        };
460        let mut pending = std::mem::take(&mut self.pending);
461        pending.clear();
462        despiker.push(x, &mut pending);
463        for &y in &pending {
464            self.finish(y, out);
465        }
466        self.pending = pending;
467    }
468
469    /// Appends the outputs still held back by the despike window.
470    pub fn flush(&mut self, out: &mut Vec<f64>) {
471        let Some(despiker) = self.despiker.as_mut() else {
472            return;
473        };
474        let mut pending = std::mem::take(&mut self.pending);
475        pending.clear();
476        despiker.flush(&mut pending);
477        for &y in &pending {
478            self.finish(y, out);
479        }
480        self.pending = pending;
481    }
482
483    /// Ends the segment before `missing` absent samples of the same run, keeping the
484    /// templates in phase (the live plot's rule for a long gap).
485    #[cfg(test)]
486    pub fn break_segment(&mut self, missing: u64, out: &mut Vec<f64>) {
487        self.flush(out);
488        self.index += missing;
489    }
490}
491
492/// High-pass then low-pass on calibrated values.
493#[derive(Debug, Clone)]
494pub struct LinearFilterChain {
495    highpass: Option<Biquad>,
496    lowpass: Option<Biquad>,
497}
498
499impl LinearFilterChain {
500    pub fn new(plan: &FilterPlan) -> Self {
501        Self {
502            highpass: (plan.highpass_hz > 0.0)
503                .then(|| Biquad::new(butterworth(true, plan.highpass_hz, plan.fs))),
504            lowpass: (plan.lowpass_hz > 0.0)
505                .then(|| Biquad::new(butterworth(false, plan.lowpass_hz, plan.fs))),
506        }
507    }
508
509    pub fn is_active(&self) -> bool {
510        self.highpass.is_some() || self.lowpass.is_some()
511    }
512
513    pub fn process(&mut self, x: f64) -> f64 {
514        let mut y = x;
515        if let Some(b) = self.highpass.as_mut() {
516            y = b.process(y);
517        }
518        if let Some(b) = self.lowpass.as_mut() {
519            y = b.process(y);
520        }
521        y
522    }
523
524    #[cfg(test)]
525    pub fn reset(&mut self) {
526        if let Some(b) = self.highpass.as_mut() {
527            b.reset();
528        }
529        if let Some(b) = self.lowpass.as_mut() {
530            b.reset();
531        }
532    }
533}
534
535/// Samples the backward pass needs past a sample for the result to have settled:
536/// twenty time constants of the lowest cutoff (a 2nd-order Butterworth pole decays
537/// with time constant `√2 / (2π fc)`), at least one second and at most two minutes.
538pub fn settle_seconds(settings: &ChannelFilterSettings) -> f64 {
539    let lowest = [settings.highpass_hz, settings.lowpass_hz]
540        .into_iter()
541        .flatten()
542        .fold(f64::INFINITY, f64::min);
543    if !lowest.is_finite() {
544        return 1.0;
545    }
546    (20.0 * std::f64::consts::SQRT_2 / (2.0 * std::f64::consts::PI * lowest)).clamp(1.0, 120.0)
547}
548
549/// One queued sample of [`ExportFilter`].
550#[derive(Debug)]
551struct Entry<T> {
552    /// The caller's row, or `None` for a missing sample filled in as `NaN`.
553    row: Option<T>,
554    /// Fed to the filters (false for a row repeating or preceding the last timestamp).
555    fed: bool,
556    /// Forward-filtered calibrated value, once known.
557    forward: f64,
558}
559
560/// A filtered row handed back by [`ExportFilter`].
561#[derive(Debug, PartialEq)]
562pub struct FilteredRow<T> {
563    pub row: T,
564    /// Filtered calibrated value; `NaN` when it cannot be computed.
565    pub filtered: f64,
566}
567
568/// Rows of one channel, in time order, filtered with the zero-phase pipeline.
569///
570/// Rows go in one at a time with [`Self::push`] and come out, in the same order, with
571/// their filtered value once it is settled. The sample interval is taken as the median
572/// step of the first rows of each segment. A step of up to
573/// [`MAX_FILLED_GAP_SAMPLES`] missing samples is filled with `NaN`; a longer gap, a
574/// step that is not close to a whole number of intervals, or a new rate ends the
575/// segment and every filter (templates included) starts over. Rows whose timestamp is
576/// not after the previous one are passed through with `NaN`.
577///
578/// Memory stays bounded: once `block + margin` samples wait, the backward pass runs
579/// from the newest sample (its state settled over `margin` samples) and the oldest
580/// `block` rows come out.
581pub struct ExportFilter<T> {
582    settings: ChannelFilterSettings,
583    block: usize,
584    /// Rows waiting for the interval to be estimated.
585    probe: VecDeque<(i64, f64, T)>,
586    segment: Option<Segment>,
587    queue: VecDeque<Entry<T>>,
588    /// Entries at the front of `queue` whose forward value is known.
589    ready: usize,
590    scratch: Vec<f64>,
591}
592
593/// Filter state of one contiguous segment.
594struct Segment {
595    interval_ns: i64,
596    last_ns: i64,
597    raw: RawFilterChain,
598    forward: LinearFilterChain,
599    plan: FilterPlan,
600    margin: usize,
601}
602
603/// Rows used to estimate the sample interval of a segment.
604const PROBE_ROWS: usize = 32;
605
606impl<T> ExportFilter<T> {
607    /// Default rows per backward pass.
608    pub const BLOCK: usize = 65_536;
609
610    pub fn new(settings: ChannelFilterSettings) -> Self {
611        Self::with_block(settings, Self::BLOCK)
612    }
613
614    pub fn with_block(settings: ChannelFilterSettings, block: usize) -> Self {
615        Self {
616            settings,
617            block: block.max(1),
618            probe: VecDeque::new(),
619            segment: None,
620            queue: VecDeque::new(),
621            ready: 0,
622            scratch: Vec::new(),
623        }
624    }
625
626    /// Adds one row and appends the rows that are done to `out`.
627    ///
628    /// * `timestamp_ns` - Sample time.
629    /// * `raw` - Raw value.
630    /// * `row` - The caller's row, handed back with the filtered value.
631    /// * `calibrate` - Converts a (filtered) raw value of a row into its unit.
632    pub fn push(
633        &mut self,
634        timestamp_ns: i64,
635        raw: f64,
636        row: T,
637        calibrate: &dyn Fn(&T, f64) -> f64,
638        out: &mut Vec<FilteredRow<T>>,
639    ) {
640        if self.segment.is_none() {
641            self.probe.push_back((timestamp_ns, raw, row));
642            if self.probe.len() >= PROBE_ROWS {
643                self.start_from_probe(calibrate, out);
644            }
645            return;
646        }
647        self.push_in_segment(timestamp_ns, raw, row, calibrate, out);
648    }
649
650    /// Filters and hands back every row still held.
651    pub fn finish(&mut self, calibrate: &dyn Fn(&T, f64) -> f64, out: &mut Vec<FilteredRow<T>>) {
652        while !self.probe.is_empty() {
653            self.start_from_probe(calibrate, out);
654        }
655        self.end_segment(calibrate, out);
656    }
657
658    /// Starts a segment with the interval estimated from the probe rows, then feeds them.
659    fn start_from_probe(
660        &mut self,
661        calibrate: &dyn Fn(&T, f64) -> f64,
662        out: &mut Vec<FilteredRow<T>>,
663    ) {
664        let mut steps: Vec<i64> = self
665            .probe
666            .iter()
667            .zip(self.probe.iter().skip(1))
668            .map(|(a, b)| b.0 - a.0)
669            .filter(|step| *step > 0)
670            .collect();
671        steps.sort_unstable();
672        let Some(&interval_ns) = steps.get(steps.len() / 2) else {
673            // Too few rows to tell the rate: pass them through unfiltered.
674            for (_, _, row) in self.probe.drain(..) {
675                out.push(FilteredRow {
676                    row,
677                    filtered: f64::NAN,
678                });
679            }
680            return;
681        };
682        let rows: Vec<(i64, f64, T)> = self.probe.drain(..).collect();
683        let mut rows = rows.into_iter();
684        if let Some((first_ns, raw, row)) = rows.next() {
685            self.begin_segment(interval_ns, first_ns);
686            self.feed(raw, Some(row), calibrate);
687        }
688        for (timestamp_ns, raw, row) in rows {
689            if self.segment.is_none() {
690                self.probe.push_back((timestamp_ns, raw, row));
691            } else {
692                self.push_in_segment(timestamp_ns, raw, row, calibrate, out);
693            }
694        }
695        self.drain_settled(calibrate, out, false);
696    }
697
698    fn begin_segment(&mut self, interval_ns: i64, first_ns: i64) {
699        let fs = 1e9 / interval_ns as f64;
700        let plan = plan_filters(&self.settings, fs);
701        let margin = (settle_seconds(&self.settings) * fs).ceil() as usize;
702        self.segment = Some(Segment {
703            interval_ns,
704            last_ns: first_ns,
705            raw: RawFilterChain::new(&plan),
706            forward: LinearFilterChain::new(&plan),
707            plan,
708            margin,
709        });
710    }
711
712    fn push_in_segment(
713        &mut self,
714        timestamp_ns: i64,
715        raw: f64,
716        row: T,
717        calibrate: &dyn Fn(&T, f64) -> f64,
718        out: &mut Vec<FilteredRow<T>>,
719    ) {
720        let segment = self.segment.as_ref().expect("segment");
721        let step = timestamp_ns - segment.last_ns;
722        if step <= 0 {
723            self.queue.push_back(Entry {
724                row: Some(row),
725                fed: false,
726                forward: f64::NAN,
727            });
728            self.advance_ready();
729            return;
730        }
731        let interval = segment.interval_ns;
732        let intervals = (step + interval / 2) / interval;
733        let off_grid = (step - intervals * interval).abs() > interval / 2;
734        let missing = intervals - 1;
735        if off_grid || missing > MAX_FILLED_GAP_SAMPLES {
736            self.end_segment(calibrate, out);
737            self.probe.push_back((timestamp_ns, raw, row));
738            return;
739        }
740        for _ in 0..missing {
741            self.feed(f64::NAN, None, calibrate);
742        }
743        self.segment.as_mut().expect("segment").last_ns = timestamp_ns;
744        self.feed(raw, Some(row), calibrate);
745        self.drain_settled(calibrate, out, false);
746    }
747
748    /// Queues one sample and runs it through the causal part.
749    fn feed(&mut self, raw: f64, row: Option<T>, calibrate: &dyn Fn(&T, f64) -> f64) {
750        self.queue.push_back(Entry {
751            row,
752            fed: true,
753            forward: f64::NAN,
754        });
755        let mut scratch = std::mem::take(&mut self.scratch);
756        scratch.clear();
757        self.segment
758            .as_mut()
759            .expect("segment")
760            .raw
761            .push(raw, &mut scratch);
762        self.apply_forward(&scratch, calibrate);
763        self.scratch = scratch;
764    }
765
766    /// Stores forward values for the oldest fed entries that lack one.
767    fn apply_forward(&mut self, values: &[f64], calibrate: &dyn Fn(&T, f64) -> f64) {
768        let segment = self.segment.as_mut().expect("segment");
769        for &value in values {
770            while !self.queue[self.ready].fed {
771                self.ready += 1;
772            }
773            let entry = &mut self.queue[self.ready];
774            let calibrated = match &entry.row {
775                Some(row) => calibrate(row, value),
776                None => f64::NAN,
777            };
778            entry.forward = segment.forward.process(calibrated);
779            self.ready += 1;
780        }
781        self.advance_ready();
782    }
783
784    /// Moves `ready` over unfed entries that directly follow the ready ones.
785    fn advance_ready(&mut self) {
786        while self.ready < self.queue.len() && !self.queue[self.ready].fed {
787            self.ready += 1;
788        }
789    }
790
791    /// Runs the backward pass and hands back settled rows: `block` of them once
792    /// `block + margin` are ready, or all of them when `all` is set.
793    fn drain_settled(
794        &mut self,
795        _calibrate: &dyn Fn(&T, f64) -> f64,
796        out: &mut Vec<FilteredRow<T>>,
797        all: bool,
798    ) {
799        let Some(segment) = self.segment.as_ref() else {
800            return;
801        };
802        let count = if all {
803            self.ready
804        } else if self.ready >= self.block + segment.margin {
805            self.block
806        } else {
807            return;
808        };
809        let mut backward = LinearFilterChain::new(&segment.plan);
810        let zero_phase = backward.is_active();
811        let mut values = std::mem::take(&mut self.scratch);
812        values.clear();
813        values.resize(self.ready, f64::NAN);
814        for i in (0..self.ready).rev() {
815            let entry = &self.queue[i];
816            values[i] = if !entry.fed {
817                f64::NAN
818            } else if zero_phase {
819                backward.process(entry.forward)
820            } else {
821                entry.forward
822            };
823        }
824        for value in values.iter().take(count) {
825            let entry = self.queue.pop_front().expect("entry");
826            if let Some(row) = entry.row {
827                out.push(FilteredRow {
828                    row,
829                    filtered: *value,
830                });
831            }
832        }
833        self.ready -= count;
834        self.scratch = values;
835    }
836
837    /// Flushes the causal part, hands back every queued row and forgets the segment.
838    fn end_segment(&mut self, calibrate: &dyn Fn(&T, f64) -> f64, out: &mut Vec<FilteredRow<T>>) {
839        if self.segment.is_none() {
840            return;
841        }
842        let mut scratch = std::mem::take(&mut self.scratch);
843        scratch.clear();
844        self.segment
845            .as_mut()
846            .expect("segment")
847            .raw
848            .flush(&mut scratch);
849        self.apply_forward(&scratch, calibrate);
850        self.scratch = scratch;
851        self.drain_settled(calibrate, out, true);
852        debug_assert!(self.queue.is_empty());
853        self.queue.clear();
854        self.ready = 0;
855        self.segment = None;
856    }
857}
858
859/// Runs the causal pipeline over an array with no calibration, as the webapp's
860/// `filterCausal` does (a gap longer than [`MAX_FILLED_GAP_SAMPLES`] breaks the segment
861/// but keeps the templates). Used to compare with the shared vectors.
862#[cfg(test)]
863pub fn filter_causal(values: &[f64], plan: &FilterPlan) -> Vec<f64> {
864    let mut raw = RawFilterChain::new(plan);
865    let mut linear = LinearFilterChain::new(plan);
866    let mut result = Vec::with_capacity(values.len());
867    let mut out = Vec::new();
868    let mut i = 0;
869    while i < values.len() {
870        if values[i].is_finite() {
871            raw.push(values[i], &mut out);
872            result.extend(out.drain(..).map(|y| linear.process(y)));
873            i += 1;
874            continue;
875        }
876        let mut end = i;
877        while end < values.len() && !values[end].is_finite() {
878            end += 1;
879        }
880        let missing = end - i;
881        if missing as i64 <= MAX_FILLED_GAP_SAMPLES {
882            for _ in 0..missing {
883                raw.push(f64::NAN, &mut out);
884            }
885            result.extend(out.drain(..).map(|y| linear.process(y)));
886        } else {
887            raw.break_segment(missing as u64, &mut out);
888            result.extend(out.drain(..).map(|y| linear.process(y)));
889            result.extend(std::iter::repeat_n(f64::NAN, missing));
890            linear.reset();
891        }
892        i = end;
893    }
894    raw.flush(&mut out);
895    result.extend(out.drain(..).map(|y| linear.process(y)));
896    result
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902
903    #[derive(serde::Deserialize)]
904    struct Vectors {
905        cases: Vec<VectorCase>,
906    }
907
908    #[derive(serde::Deserialize)]
909    struct VectorCase {
910        name: String,
911        fs: f64,
912        settings: Value,
913        input_uv: Vec<Option<f64>>,
914        squares_uv: Vec<f64>,
915        spikes: Vec<usize>,
916        spike_uv: Vec<f64>,
917        pulses: Vec<usize>,
918        pulse_uv: f64,
919        warmup: usize,
920        gaps: Vec<(usize, usize)>,
921        expected_causal_uv: Vec<Option<f64>>,
922    }
923
924    fn vectors() -> Vec<VectorCase> {
925        let path = concat!(
926            env!("CARGO_MANIFEST_DIR"),
927            "/../testdata/filter-vectors.json"
928        );
929        let text = std::fs::read_to_string(path).expect("read testdata/filter-vectors.json");
930        serde_json::from_str::<Vectors>(&text)
931            .expect("parse vectors")
932            .cases
933    }
934
935    fn volts(values: &[Option<f64>]) -> Vec<f64> {
936        values
937            .iter()
938            .map(|v| v.map_or(f64::NAN, |uv| uv / 1e6))
939            .collect()
940    }
941
942    /// Runs the export filter (identity calibration) over values with `NaN` for
943    /// missing samples; returns one output per input position.
944    fn export_filter(case: &VectorCase, values: &[f64], block: usize) -> Vec<f64> {
945        let settings = ChannelFilterSettings::from_value(&case.settings);
946        let interval = (1e9 / case.fs).round() as i64;
947        let mut filter = ExportFilter::with_block(settings, block);
948        let identity = |_: &usize, v: f64| v;
949        let mut out = Vec::new();
950        for (i, v) in values.iter().enumerate() {
951            if v.is_finite() {
952                filter.push(i as i64 * interval, *v, i, &identity, &mut out);
953            }
954        }
955        filter.finish(&identity, &mut out);
956        let mut result = vec![f64::NAN; values.len()];
957        let mut last = None;
958        for row in out {
959            assert!(last.is_none_or(|l| l < row.row), "rows out of order");
960            last = Some(row.row);
961            result[row.row] = row.filtered;
962        }
963        result
964    }
965
966    #[test]
967    fn causal_pipeline_matches_the_webapp_vectors() {
968        for case in vectors() {
969            let plan = plan_filters(&ChannelFilterSettings::from_value(&case.settings), case.fs);
970            let output = filter_causal(&volts(&case.input_uv), &plan);
971            assert_eq!(output.len(), case.expected_causal_uv.len(), "{}", case.name);
972            let mut worst: f64 = 0.0;
973            for (i, (got, want)) in output.iter().zip(&case.expected_causal_uv).enumerate() {
974                match want {
975                    None => assert!(
976                        got.is_nan(),
977                        "{} sample {i}: {got} should be NaN",
978                        case.name
979                    ),
980                    Some(uv) => {
981                        let want = uv / 1e6;
982                        let scale = want.abs().max(got.abs()).max(1e-3);
983                        let error = (got - want).abs() / scale;
984                        worst = worst.max(error);
985                        assert!(error <= 1e-9, "{} sample {i}: {got} vs {want}", case.name);
986                    }
987                }
988            }
989            println!("{}: worst relative difference {worst:e}", case.name);
990        }
991    }
992
993    /// What the zero-phase export leaves of the square waves (dB below them), of the
994    /// pulses (fraction of the height kept) and of the spikes (how many were checked,
995    /// and dB below them, against a run without them).
996    fn quality(case: &VectorCase, block: usize) -> (f64, Vec<f64>, (usize, f64)) {
997        let full = export_filter(case, &volts(&case.input_uv), block);
998        let without_squares: Vec<Option<f64>> = case
999            .input_uv
1000            .iter()
1001            .zip(&case.squares_uv)
1002            .map(|(v, s)| v.map(|v| v - s))
1003            .collect();
1004        let clean = export_filter(case, &volts(&without_squares), block);
1005        let mut without_spikes = case.input_uv.clone();
1006        for (k, &s) in case.spikes.iter().enumerate() {
1007            if let Some(v) = without_spikes[s].as_mut() {
1008                *v -= case.spike_uv[k];
1009            }
1010        }
1011        let smooth = export_filter(case, &volts(&without_spikes), block);
1012
1013        let fs = case.fs;
1014        let mut mask: Vec<bool> = case
1015            .input_uv
1016            .iter()
1017            .enumerate()
1018            .map(|(i, v)| v.is_some() && i >= case.warmup)
1019            .collect();
1020        let n = mask.len();
1021        let mut clear = |from: f64, to: f64| {
1022            for m in mask
1023                .iter_mut()
1024                .take((to.max(0.0) as usize).min(n))
1025                .skip(from.max(0.0) as usize)
1026            {
1027                *m = false;
1028            }
1029        };
1030        for &p in &case.pulses {
1031            clear(p as f64 - 0.08 * fs, p as f64 + 0.08 * fs);
1032        }
1033        for &(start, length) in &case.gaps {
1034            // Every filter, the templates included, restarts after a long gap in an
1035            // export; they need about 2000 samples to learn again.
1036            let after = if length as i64 > MAX_FILLED_GAP_SAMPLES {
1037                2000.0 / fs
1038            } else {
1039                0.3
1040            };
1041            clear(
1042                start as f64 - 0.05 * fs,
1043                (start + length) as f64 + after * fs,
1044            );
1045        }
1046        let (mut power, mut residual) = (0.0, 0.0);
1047        for i in 0..n {
1048            if mask[i] {
1049                power += (case.squares_uv[i] / 1e6).powi(2);
1050                residual += (full[i] - clean[i]).powi(2);
1051            }
1052        }
1053        let pulses = case
1054            .pulses
1055            .iter()
1056            .map(|&p| {
1057                let w = (0.01 * fs).round() as usize;
1058                let peak = full[p - w..=p + w].iter().cloned().fold(f64::MIN, f64::max);
1059                let mut base: Vec<f64> = full
1060                    [p - (0.07 * fs).round() as usize..p - (0.03 * fs).round() as usize]
1061                    .to_vec();
1062                base.sort_by(f64::total_cmp);
1063                (peak - base[base.len() / 2]) / (case.pulse_uv / 1e6)
1064            })
1065            .collect();
1066        // A spike next to a square-wave edge merges into the edge (the opening moves
1067        // the edge by a sample), so only spikes on a flat stretch count.
1068        let (mut spike_power, mut spike_residual, mut spike_count) = (0.0, 0.0, 0);
1069        for (k, &s) in case.spikes.iter().enumerate() {
1070            if mask[s] && (s - 6..=s + 6).all(|i| case.squares_uv[i] == case.squares_uv[s]) {
1071                spike_count += 1;
1072                spike_power += (case.spike_uv[k] / 1e6).powi(2);
1073                spike_residual += (full[s] - smooth[s]).powi(2);
1074            }
1075        }
1076        let spikes = (spike_count, 10.0 * (spike_power / spike_residual).log10());
1077        (10.0 * (power / residual).log10(), pulses, spikes)
1078    }
1079
1080    #[test]
1081    fn zero_phase_export_removes_the_interference_and_keeps_the_pulses() {
1082        for case in vectors() {
1083            let plan = plan_filters(&ChannelFilterSettings::from_value(&case.settings), case.fs);
1084            let (reduction, pulses, spikes) = quality(&case, ExportFilter::<usize>::BLOCK);
1085            println!(
1086                "{}: square waves {reduction:.1} dB down, pulses kept {pulses:.3?}, {} spikes {:.1} dB down",
1087                case.name, spikes.0, spikes.1
1088            );
1089            assert!(reduction >= 25.0, "{}: {reduction} dB", case.name);
1090            for kept in pulses {
1091                assert!((kept - 1.0).abs() < 0.1, "{}: pulse kept {kept}", case.name);
1092            }
1093            if plan.despike_window > 0 {
1094                assert!(spikes.0 > 50);
1095                assert!(
1096                    spikes.1 >= 20.0,
1097                    "{}: spikes {} dB down",
1098                    case.name,
1099                    spikes.1
1100                );
1101            }
1102        }
1103    }
1104
1105    #[test]
1106    fn small_blocks_give_the_same_export_as_one_pass() {
1107        for case in vectors() {
1108            let values = volts(&case.input_uv);
1109            let whole = export_filter(&case, &values, usize::MAX / 4);
1110            let blocks = export_filter(&case, &values, 997);
1111            let scale = whole
1112                .iter()
1113                .filter(|v| v.is_finite())
1114                .fold(1e-3_f64, |a, b| a.max(b.abs()));
1115            for (i, (a, b)) in whole.iter().zip(&blocks).enumerate() {
1116                assert!(
1117                    (a.is_nan() && b.is_nan()) || (a - b).abs() <= 1e-9 * scale,
1118                    "{} sample {i}: {a} vs {b}",
1119                    case.name
1120                );
1121            }
1122        }
1123    }
1124
1125    #[test]
1126    fn export_rows_come_back_once_in_order_with_repeats_unfiltered() {
1127        let settings = ChannelFilterSettings {
1128            despike: true,
1129            lowpass_hz: Some(100.0),
1130            ..Default::default()
1131        };
1132        let mut filter = ExportFilter::with_block(settings, 64);
1133        let identity = |_: &usize, v: f64| v;
1134        let mut out = Vec::new();
1135        let mut index = 0;
1136        for i in 0..1000_i64 {
1137            filter.push(i * 500_000, 1.0, index, &identity, &mut out);
1138            index += 1;
1139            if i == 500 {
1140                // Same timestamp again: passed through without a filtered value.
1141                filter.push(i * 500_000, 2.0, index, &identity, &mut out);
1142                index += 1;
1143            }
1144        }
1145        // A new run at another rate after a long gap.
1146        for i in 0..100_i64 {
1147            filter.push(
1148                10_000_000_000 + i * 10_000_000,
1149                1.0,
1150                index,
1151                &identity,
1152                &mut out,
1153            );
1154            index += 1;
1155        }
1156        filter.finish(&identity, &mut out);
1157        let order: Vec<usize> = out.iter().map(|r| r.row).collect();
1158        assert_eq!(order, (0..index).collect::<Vec<_>>());
1159        assert!(out[501].filtered.is_nan());
1160        for row in out.iter().filter(|r| r.row != 501) {
1161            assert!(
1162                (row.filtered - 1.0).abs() < 1e-12,
1163                "row {}: {}",
1164                row.row,
1165                row.filtered
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn settings_are_read_like_the_webapp() {
1172        let value: Value = serde_json::json!({
1173            "despike": true, "remove_10hz": "yes", "remove_11_9hz": null,
1174            "highpass_hz": "1.5", "lowpass_hz": 0
1175        });
1176        let settings = ChannelFilterSettings::from_value(&value);
1177        assert_eq!(
1178            settings,
1179            ChannelFilterSettings {
1180                despike: true,
1181                highpass_hz: Some(1.5),
1182                ..Default::default()
1183            }
1184        );
1185        let plan = plan_filters(&settings, 2000.0);
1186        assert_eq!(plan.despike_window, 5);
1187        let all = ChannelFilterSettings {
1188            despike: true,
1189            remove_10hz: true,
1190            remove_11_9hz: true,
1191            highpass_hz: Some(1.0),
1192            lowpass_hz: Some(100.0),
1193        };
1194        let plan = plan_filters(&all, 100.0);
1195        assert_eq!(
1196            (plan.despike_window, plan.period10, plan.lowpass_hz),
1197            (0, 10, 0.0)
1198        );
1199        assert_eq!(plan_filters(&all, 2000.0).bins11, 672);
1200        assert_eq!(plan_filters(&all, 2205.0).period10, 0);
1201    }
1202}