1use std::collections::VecDeque;
25
26use serde_json::Value;
27
28pub const DESPIKE_WINDOW_MS: f64 = 2.5;
30pub const TEN_HZ: f64 = 10.0;
32pub const ELEVEN_NINE_HZ: f64 = 11.906;
35pub const TEMPLATE_MEMORY_S: f64 = 30.0;
37pub const MIN_TEMPLATE_PERIOD_SAMPLES: f64 = 4.0;
39pub const PERIOD_TOLERANCE_SAMPLES: f64 = 0.01;
41pub const MIN_FRACTIONAL_BINS: usize = 64;
43pub const FRACTIONAL_BINS_PER_SAMPLE: usize = 4;
45pub const MAX_CUTOFF_FRACTION: f64 = 0.45;
47pub const MAX_FILLED_GAP_SAMPLES: i64 = 4;
50
51#[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
61fn 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 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 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#[derive(Debug, Clone, PartialEq)]
101pub struct FilterPlan {
102 pub fs: f64,
103 pub despike_window: usize,
105 pub period10: usize,
107 pub period11: f64,
109 pub bins11: usize,
111 pub highpass_hz: f64,
113 pub lowpass_hz: f64,
115}
116
117pub 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#[derive(Debug, Clone, Copy)]
171struct BiquadCoefficients {
172 b0: f64,
173 b1: f64,
174 b2: f64,
175 a1: f64,
176 a2: f64,
177}
178
179fn 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#[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#[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#[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#[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 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 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 #[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#[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
535pub 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#[derive(Debug)]
551struct Entry<T> {
552 row: Option<T>,
554 fed: bool,
556 forward: f64,
558}
559
560#[derive(Debug, PartialEq)]
562pub struct FilteredRow<T> {
563 pub row: T,
564 pub filtered: f64,
566}
567
568pub struct ExportFilter<T> {
582 settings: ChannelFilterSettings,
583 block: usize,
584 probe: VecDeque<(i64, f64, T)>,
586 segment: Option<Segment>,
587 queue: VecDeque<Entry<T>>,
588 ready: usize,
590 scratch: Vec<f64>,
591}
592
593struct Segment {
595 interval_ns: i64,
596 last_ns: i64,
597 raw: RawFilterChain,
598 forward: LinearFilterChain,
599 plan: FilterPlan,
600 margin: usize,
601}
602
603const PROBE_ROWS: usize = 32;
605
606impl<T> ExportFilter<T> {
607 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 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 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 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 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 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 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 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 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 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#[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 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 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 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 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 filter.push(i * 500_000, 2.0, index, &identity, &mut out);
1142 index += 1;
1143 }
1144 }
1145 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}