|
| 1 | +//! Lowpass filter. |
| 2 | +//! |
| 3 | +//! This module provides a simple first-order lowpass filter (alpha filter) |
| 4 | +//! with constructors based on either a cutoff frequency or a direct alpha |
| 5 | +//! coefficient. |
| 6 | +//! |
| 7 | +//! # Examples |
| 8 | +//! |
| 9 | +//! ```rust |
| 10 | +//! use utilities::lowpass::LowPassFilter; |
| 11 | +//! |
| 12 | +//! // 5 Hz cutoff, 100 Hz sample rate. |
| 13 | +//! let mut filter = LowPassFilter::from_frequency(5.0, 100.0); |
| 14 | +//! |
| 15 | +//! let y0 = filter.update(1.0); |
| 16 | +//! let y1 = filter.update(1.0); |
| 17 | +//! |
| 18 | +//! assert!(y1 >= y0); |
| 19 | +//! assert!(y1 <= 1.0); |
| 20 | +//! ``` |
| 21 | +
|
| 22 | +use serde::{Deserialize, Serialize}; |
| 23 | + |
| 24 | +/// First order lowpass filter, "alpha filter" |
| 25 | +#[derive(Debug, Serialize, Deserialize)] |
| 26 | +pub struct LowPassFilter { |
| 27 | + /// Filter coefficient, 0 -> no update, 1 -> passthrough |
| 28 | + alpha: f64, |
| 29 | + /// Current filtered state |
| 30 | + value: f64, |
| 31 | +} |
| 32 | + |
| 33 | +impl LowPassFilter { |
| 34 | + /// Create a lowpass filter from cutoff frequency and sample rate |
| 35 | + #[inline] |
| 36 | + pub fn from_frequency(cutoff_hz: f64, sample_rate: f64) -> Self { |
| 37 | + // Reject invalid or effectively-zero parameters |
| 38 | + if sample_rate < 1e-9 || cutoff_hz < 1e-9 { |
| 39 | + return Self::from_alpha(0.0); |
| 40 | + } |
| 41 | + |
| 42 | + let dt = 1.0 / sample_rate; |
| 43 | + let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz); |
| 44 | + let alpha = dt / (rc + dt); |
| 45 | + |
| 46 | + let clamped_alpha = alpha.clamp(0.0, 1.0); |
| 47 | + Self { |
| 48 | + alpha: clamped_alpha, |
| 49 | + value: 0.0, |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + /// Create a lowpass filter from alpha coefficient |
| 54 | + #[inline] |
| 55 | + pub fn from_alpha(filter_alpha: f64) -> Self { |
| 56 | + let clamped_alpha = filter_alpha.clamp(0.0, 1.0); |
| 57 | + Self { |
| 58 | + alpha: clamped_alpha, |
| 59 | + value: 0.0, |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + /// Update lowpass with a new sample |
| 64 | + #[inline] |
| 65 | + pub fn update(&mut self, input: f64) -> f64 { |
| 66 | + let new_value = self.value * (1.0 - self.alpha) + self.alpha * input; |
| 67 | + self.value = new_value; |
| 68 | + self.value |
| 69 | + } |
| 70 | + |
| 71 | + /// Get the current filtered value |
| 72 | + #[inline] |
| 73 | + pub fn value(&self) -> f64 { |
| 74 | + self.value |
| 75 | + } |
| 76 | + |
| 77 | + /// Get alpha coefficient |
| 78 | + #[inline] |
| 79 | + pub fn alpha(&self) -> f64 { |
| 80 | + self.alpha |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +#[cfg(test)] |
| 85 | +mod tests { |
| 86 | + use super::*; |
| 87 | + const KINDA_SMALL_NUMBER: f64 = 1e-4; |
| 88 | + |
| 89 | + #[test] |
| 90 | + fn convergence_test() { |
| 91 | + let mut filter = LowPassFilter::from_alpha(0.1); |
| 92 | + for _ in 0..1000 { |
| 93 | + filter.update(3.0); |
| 94 | + } |
| 95 | + // Should converge near 3.0 |
| 96 | + |
| 97 | + assert!((filter.value() - 3.0).abs() < KINDA_SMALL_NUMBER); |
| 98 | + } |
| 99 | + |
| 100 | + #[test] |
| 101 | + fn alpha_edge_cases() { |
| 102 | + let mut f = LowPassFilter::from_alpha(0.0); |
| 103 | + assert_eq!(f.update(10.0), 0.0); // no change |
| 104 | + |
| 105 | + let mut f = LowPassFilter::from_alpha(1.0); |
| 106 | + assert_eq!(f.update(10.0), 10.0); // passthrough |
| 107 | + } |
| 108 | + |
| 109 | + #[test] |
| 110 | + fn from_frequency_computes_alpha() { |
| 111 | + let cutoff_hz = 5.0; |
| 112 | + let sample_rate = 100.0; |
| 113 | + let filter = LowPassFilter::from_frequency(cutoff_hz, sample_rate); |
| 114 | + |
| 115 | + let dt = 1.0 / sample_rate; |
| 116 | + let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz); |
| 117 | + let expected_alpha = dt / (rc + dt); |
| 118 | + |
| 119 | + assert!((filter.alpha() - expected_alpha).abs() < KINDA_SMALL_NUMBER); |
| 120 | + } |
| 121 | + |
| 122 | + #[test] |
| 123 | + fn from_frequency_clamps_alpha_for_edge_cases() { |
| 124 | + let cases = [(0.0, 100.0), (-10.0, 100.0), (10.0, 0.0), (10.0, -100.0)]; |
| 125 | + |
| 126 | + for (cutoff_hz, sample_rate) in cases { |
| 127 | + let filter = LowPassFilter::from_frequency(cutoff_hz, sample_rate); |
| 128 | + let alpha = filter.alpha(); |
| 129 | + assert!( |
| 130 | + (0.0..=1.0).contains(&alpha), |
| 131 | + "alpha out of range for cutoff {cutoff_hz}, sample_rate {sample_rate}" |
| 132 | + ); |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + #[test] |
| 137 | + fn from_frequency_monotonic_in_cutoff() { |
| 138 | + let sample_rate = 100.0; |
| 139 | + let low = LowPassFilter::from_frequency(1.0, sample_rate); |
| 140 | + let high = LowPassFilter::from_frequency(10.0, sample_rate); |
| 141 | + assert!(high.alpha() > low.alpha()); |
| 142 | + } |
| 143 | + |
| 144 | + #[test] |
| 145 | + fn step_response_moves_toward_input_without_overshoot() { |
| 146 | + let mut filter = LowPassFilter::from_alpha(0.25); |
| 147 | + let mut last = filter.value(); |
| 148 | + for _ in 0..50 { |
| 149 | + let value = filter.update(1.0); |
| 150 | + assert!(value >= last, "response should be non-decreasing"); |
| 151 | + assert!(value <= 1.0, "response should not overshoot input"); |
| 152 | + last = value; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + #[test] |
| 157 | + fn from_frequency_extremes_approximate_alpha_bounds() { |
| 158 | + let sample_rate = 100.0; |
| 159 | + let near_zero = LowPassFilter::from_frequency(1e-9, sample_rate); |
| 160 | + let near_one = LowPassFilter::from_frequency(1e9, sample_rate); |
| 161 | + |
| 162 | + assert!(near_zero.alpha() < KINDA_SMALL_NUMBER); |
| 163 | + assert!((1.0 - near_one.alpha()) < KINDA_SMALL_NUMBER); |
| 164 | + } |
| 165 | +} |
0 commit comments