Skip to main content

chronix_analytics/preprocess/
smoothing.rs

1//! Smoothing filters for noise reduction.
2
3use crate::compute::simd_sum;
4
5/// Smoothing method enum for configuration.
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7pub enum Smoother {
8    /// Exponential smoothing with alpha parameter.
9    Exponential {
10        /// Smoothing factor in `(0.0, 1.0]`.
11        alpha: f64,
12    },
13    /// Simple moving average with window size.
14    MovingAverage {
15        /// Number of points in the sliding window.
16        window: usize,
17    },
18    /// Weighted moving average with custom weights.
19    WeightedMovingAverage {
20        /// Weights applied to the sliding window.
21        weights: Vec<f64>,
22    },
23}
24
25impl Smoother {
26    /// Apply the smoother to the given values.
27    ///
28    /// Invalid parameters are clamped to safe defaults to prevent panics
29    /// from deserialized or user-supplied configuration:
30    /// - `alpha` is clamped to `(0.0, 1.0]`
31    /// - `window` is clamped to `max(1, window)`
32    /// - empty `weights` produces a pass-through (identity)
33    pub fn smooth(&self, values: &[f64]) -> Vec<f64> {
34        match self {
35            Smoother::Exponential { alpha } => {
36                let safe_alpha = alpha.clamp(f64::MIN_POSITIVE, 1.0);
37                ExponentialSmoother::new(safe_alpha).smooth(values)
38            }
39            Smoother::MovingAverage { window } => {
40                let safe_window = (*window).max(1);
41                MovingAverageSmoother::new(safe_window).smooth(values)
42            }
43            Smoother::WeightedMovingAverage { weights } => {
44                if weights.is_empty() {
45                    return values.to_vec();
46                }
47                WeightedMovingAverage::new(weights.clone()).smooth(values)
48            }
49        }
50    }
51}
52
53/// Exponential smoothing with configurable alpha (0..1).
54///
55/// Single-pass O(n) — each output is `alpha * x[i] + (1 - alpha) * s[i-1]`.
56pub struct ExponentialSmoother {
57    alpha: f64,
58}
59
60impl ExponentialSmoother {
61    /// Creates a new exponential smoother.
62    ///
63    /// # Panics
64    /// Panics if alpha is not in (0, 1].
65    pub fn new(alpha: f64) -> Self {
66        assert!(
67            alpha > 0.0 && alpha <= 1.0,
68            "alpha must be in (0, 1], got {alpha}"
69        );
70        Self { alpha }
71    }
72
73    /// Smooths the input values.
74    pub fn smooth(&self, values: &[f64]) -> Vec<f64> {
75        if values.is_empty() {
76            return Vec::new();
77        }
78        let mut result = Vec::with_capacity(values.len());
79        result.push(values[0]);
80        let one_minus = 1.0 - self.alpha;
81        for i in 1..values.len() {
82            result.push(self.alpha * values[i] + one_minus * result[i - 1]);
83        }
84        result
85    }
86}
87
88/// Simple moving average with configurable window size.
89///
90/// Uses a running sum for O(n) computation. Handles series shorter than window
91/// by reducing the effective window.
92pub struct MovingAverageSmoother {
93    window: usize,
94}
95
96impl MovingAverageSmoother {
97    /// Creates a new moving average smoother.
98    ///
99    /// # Panics
100    /// Panics if window is 0.
101    pub fn new(window: usize) -> Self {
102        assert!(window > 0, "window must be > 0");
103        Self { window }
104    }
105
106    /// Smooths the input values using a centered moving average.
107    ///
108    /// For edge elements where the full window is not available, uses the
109    /// available range (reduced window). Uses a running sum for O(n)
110    /// computation in the interior of the series.
111    pub fn smooth(&self, values: &[f64]) -> Vec<f64> {
112        if values.is_empty() {
113            return Vec::new();
114        }
115
116        let n = values.len();
117        let w = self.window.min(n);
118        // For even windows use asymmetric halves so the effective window
119        // matches the requested size exactly.
120        let half_left = if w.is_multiple_of(2) {
121            w / 2 - 1
122        } else {
123            w / 2
124        };
125        let half_right = w / 2;
126        let mut result = Vec::with_capacity(n);
127
128        // Prefix sum for O(n) windowed average computation.
129        // prefix[i] = values[0] + values[1] + … + values[i-1]
130        let mut prefix = Vec::with_capacity(n + 1);
131        prefix.push(0.0);
132        let mut acc = 0.0;
133        for &v in values {
134            acc += v;
135            prefix.push(acc);
136        }
137
138        for i in 0..n {
139            let lo = i.saturating_sub(half_left);
140            let hi = (i + half_right + 1).min(n);
141            let count = (hi - lo) as f64;
142            let sum = prefix[hi] - prefix[lo];
143            result.push(sum / count);
144        }
145
146        result
147    }
148}
149
150/// Weighted moving average with custom weight vector.
151pub struct WeightedMovingAverage {
152    weights: Vec<f64>,
153    weight_sum: f64,
154}
155
156impl WeightedMovingAverage {
157    /// Creates a new weighted moving average smoother.
158    ///
159    /// # Panics
160    /// Panics if weights is empty.
161    pub fn new(weights: Vec<f64>) -> Self {
162        assert!(!weights.is_empty(), "weights must not be empty");
163        let weight_sum = simd_sum(&weights);
164        Self {
165            weights,
166            weight_sum,
167        }
168    }
169
170    /// Smooths the input values.
171    ///
172    /// Weight vector is applied as a trailing window — `weights[0]` is the
173    /// oldest value in the window.
174    pub fn smooth(&self, values: &[f64]) -> Vec<f64> {
175        if values.is_empty() {
176            return Vec::new();
177        }
178
179        let n = values.len();
180        let w = self.weights.len();
181        let mut result = Vec::with_capacity(n);
182
183        for i in 0..n {
184            if i + 1 < w {
185                // Not enough history — use available weights with renormalization
186                let available = i + 1;
187                let offset = w - available;
188                let mut sum = 0.0;
189                let mut wsum = 0.0;
190                for (j, &val) in values[..available].iter().enumerate() {
191                    sum += val * self.weights[offset + j];
192                    wsum += self.weights[offset + j];
193                }
194                result.push(if wsum.abs() > 1e-15 {
195                    sum / wsum
196                } else {
197                    values[i]
198                });
199            } else {
200                let mut sum = 0.0;
201                let start = i + 1 - w;
202                for j in 0..w {
203                    sum += values[start + j] * self.weights[j];
204                }
205                result.push(if self.weight_sum.abs() > 1e-15 {
206                    sum / self.weight_sum
207                } else {
208                    values[i]
209                });
210            }
211        }
212        result
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::compute::simd_mean;
220
221    #[test]
222    fn exponential_smoother_alpha_1() {
223        let s = ExponentialSmoother::new(1.0);
224        let vals = vec![1.0, 5.0, 3.0, 7.0];
225        let result = s.smooth(&vals);
226        assert_eq!(result, vals); // alpha=1 → passthrough
227    }
228
229    #[test]
230    fn exponential_smoother_smooth() {
231        let s = ExponentialSmoother::new(0.5);
232        let vals = vec![10.0, 20.0, 30.0, 40.0];
233        let result = s.smooth(&vals);
234        assert!((result[0] - 10.0).abs() < 1e-10);
235        assert!((result[1] - 15.0).abs() < 1e-10); // 0.5*20 + 0.5*10
236        assert!((result[2] - 22.5).abs() < 1e-10); // 0.5*30 + 0.5*15
237        assert!((result[3] - 31.25).abs() < 1e-10); // 0.5*40 + 0.5*22.5
238    }
239
240    #[test]
241    fn moving_average_basic() {
242        let s = MovingAverageSmoother::new(3);
243        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
244        let result = s.smooth(&vals);
245        assert_eq!(result.len(), 5);
246        // Center at index 1: mean(1,2,3) = 2.0
247        assert!((result[1] - 2.0).abs() < 1e-10);
248        // Center at index 2: mean(2,3,4) = 3.0
249        assert!((result[2] - 3.0).abs() < 1e-10);
250    }
251
252    #[test]
253    fn moving_average_window_larger_than_data() {
254        let s = MovingAverageSmoother::new(100);
255        let vals = vec![1.0, 2.0, 3.0];
256        let result = s.smooth(&vals);
257        assert_eq!(result.len(), 3);
258        // All values should be the mean of the available window
259        let mean = simd_mean(&vals);
260        // Middle value: mean of all 3
261        assert!((result[1] - mean).abs() < 1e-10);
262    }
263
264    #[test]
265    fn weighted_moving_average() {
266        // Weights: [1, 2, 3] — most recent gets highest weight
267        let s = WeightedMovingAverage::new(vec![1.0, 2.0, 3.0]);
268        let vals = vec![10.0, 20.0, 30.0, 40.0];
269        let result = s.smooth(&vals);
270        assert_eq!(result.len(), 4);
271        // Index 2: (1*10 + 2*20 + 3*30) / 6 = (10+40+90)/6 = 140/6 ≈ 23.33
272        assert!((result[2] - 140.0 / 6.0).abs() < 1e-10);
273    }
274
275    #[test]
276    fn empty_data() {
277        assert!(ExponentialSmoother::new(0.5).smooth(&[]).is_empty());
278        assert!(MovingAverageSmoother::new(3).smooth(&[]).is_empty());
279        assert!(WeightedMovingAverage::new(vec![1.0]).smooth(&[]).is_empty());
280    }
281
282    #[test]
283    fn smoother_enum_dispatch() {
284        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
285        let s = Smoother::Exponential { alpha: 0.5 };
286        let r = s.smooth(&vals);
287        assert_eq!(r.len(), 5);
288    }
289}