Skip to main content

plato_threshold/
lib.rs

1use serde::{Deserialize, Serialize};
2
3/// A min/max range for thresholds.
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct ThresholdRange {
6    pub min: f64,
7    pub max: f64,
8}
9
10impl ThresholdRange {
11    pub fn new(min: f64, max: f64) -> Self {
12        Self { min, max }
13    }
14
15    pub fn contains(&self, value: f64) -> bool {
16        value >= self.min && value <= self.max
17    }
18}
19
20/// Fixed thresholds per sensor type.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct StaticThreshold {
23    pub range: ThresholdRange,
24}
25
26impl StaticThreshold {
27    pub fn new(min: f64, max: f64) -> Self {
28        Self {
29            range: ThresholdRange::new(min, max),
30        }
31    }
32
33    pub fn check(&self, value: f64) -> ThresholdResult {
34        if value.is_nan() {
35            return ThresholdResult {
36                value,
37                within_range: false,
38                distance_from_range: f64::NAN,
39                confidence: 0.0,
40            };
41        }
42        let within = self.range.contains(value);
43        let distance = if within {
44            0.0
45        } else if value < self.range.min {
46            self.range.min - value
47        } else {
48            value - self.range.max
49        };
50        ThresholdResult {
51            value,
52            within_range: within,
53            distance_from_range: distance,
54            confidence: 1.0,
55        }
56    }
57}
58
59/// Method used for adaptive threshold calculation.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub enum ThresholdMethod {
62    Static,
63    MovingAverage { window: usize },
64    ExponentialSmoothing { alpha: f64 },
65    Percentile { lower: f64, upper: f64 },
66}
67
68/// Configuration for adaptive thresholds.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ThresholdConfig {
71    pub method: ThresholdMethod,
72    pub warmup_samples: usize,
73    pub sensitivity: f64,
74}
75
76impl Default for ThresholdConfig {
77    fn default() -> Self {
78        Self {
79            method: ThresholdMethod::MovingAverage { window: 10 },
80            warmup_samples: 5,
81            sensitivity: 2.0,
82        }
83    }
84}
85
86/// Result of a threshold check.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct ThresholdResult {
89    pub value: f64,
90    pub within_range: bool,
91    pub distance_from_range: f64,
92    pub confidence: f64,
93}
94
95/// Adaptive threshold that learns from historical data.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct AdaptiveThreshold {
98    pub base: ThresholdRange,
99    pub std_dev_multiplier: f64,
100    pub history: Vec<f64>,
101    pub config: ThresholdConfig,
102}
103
104impl AdaptiveThreshold {
105    pub fn new(config: ThresholdConfig) -> Self {
106        Self {
107            base: ThresholdRange::new(f64::NEG_INFINITY, f64::INFINITY),
108            std_dev_multiplier: config.sensitivity,
109            history: Vec::new(),
110            config,
111        }
112    }
113
114    pub fn is_warmed_up(&self) -> bool {
115        self.history.len() >= self.config.warmup_samples
116    }
117
118    pub fn update(&mut self, value: f64) {
119        if !value.is_nan() {
120            self.history.push(value);
121        }
122    }
123
124    pub fn current_range(&self) -> ThresholdRange {
125        if !self.is_warmed_up() {
126            return ThresholdRange::new(f64::NEG_INFINITY, f64::INFINITY);
127        }
128
129        let data: Vec<f64> = self.history.iter().copied().filter(|v| !v.is_nan()).collect();
130        if data.is_empty() {
131            return ThresholdRange::new(f64::NEG_INFINITY, f64::INFINITY);
132        }
133
134        let (center, spread) = match &self.config.method {
135            ThresholdMethod::Static => {
136                let mean = data.iter().sum::<f64>() / data.len() as f64;
137                let std_dev = compute_std_dev(&data, mean);
138                (mean, std_dev)
139            }
140            ThresholdMethod::MovingAverage { window } => {
141                let smoothed = moving_average(&data, *window);
142                let last = *smoothed.last().unwrap_or(&0.0);
143                let std_dev = compute_std_dev(&data, last);
144                (last, std_dev)
145            }
146            ThresholdMethod::ExponentialSmoothing { alpha } => {
147                let smoothed = exponential_smoothing(&data, *alpha);
148                let last = *smoothed.last().unwrap_or(&0.0);
149                let std_dev = compute_std_dev(&data, last);
150                (last, std_dev)
151            }
152            ThresholdMethod::Percentile { lower, upper } => {
153                let lo = percentile(&data, *lower);
154                let hi = percentile(&data, *upper);
155                return ThresholdRange::new(lo, hi);
156            }
157        };
158
159        let margin = spread * self.std_dev_multiplier;
160        ThresholdRange::new(center - margin, center + margin)
161    }
162
163    pub fn check(&self, value: f64) -> ThresholdResult {
164        if value.is_nan() {
165            return ThresholdResult {
166                value,
167                within_range: false,
168                distance_from_range: f64::NAN,
169                confidence: 0.0,
170            };
171        }
172
173        let range = self.current_range();
174        let within = range.contains(value);
175        let distance = if within {
176            0.0
177        } else if value < range.min {
178            range.min - value
179        } else {
180            value - range.max
181        };
182
183        let confidence = if self.is_warmed_up() {
184            1.0_f64.min(self.history.len() as f64 / (self.config.warmup_samples * 3) as f64)
185        } else {
186            0.0
187        };
188
189        ThresholdResult {
190            value,
191            within_range: within,
192            distance_from_range: distance,
193            confidence,
194        }
195    }
196}
197
198// --- Free functions ---
199
200pub fn moving_average(data: &[f64], window: usize) -> Vec<f64> {
201    if window == 0 || data.is_empty() {
202        return data.to_vec();
203    }
204    let mut result = Vec::with_capacity(data.len());
205    for i in 0..data.len() {
206        let start = i.saturating_sub(window - 1);
207        let window_slice = &data[start..=i];
208        let avg = window_slice.iter().sum::<f64>() / window_slice.len() as f64;
209        result.push(avg);
210    }
211    result
212}
213
214pub fn exponential_smoothing(data: &[f64], alpha: f64) -> Vec<f64> {
215    if data.is_empty() {
216        return Vec::new();
217    }
218    let alpha = alpha.clamp(0.0, 1.0);
219    let mut result = Vec::with_capacity(data.len());
220    result.push(data[0]);
221    for i in 1..data.len() {
222        let prev = result[i - 1];
223        let smoothed = alpha * data[i] + (1.0 - alpha) * prev;
224        result.push(smoothed);
225    }
226    result
227}
228
229pub fn percentile(data: &[f64], p: f64) -> f64 {
230    if data.is_empty() {
231        return f64::NAN;
232    }
233    let p = p.clamp(0.0, 100.0);
234    let mut sorted: Vec<f64> = data.iter().copied().filter(|v| !v.is_nan()).collect();
235    if sorted.is_empty() {
236        return f64::NAN;
237    }
238    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
239
240    if sorted.len() == 1 {
241        return sorted[0];
242    }
243
244    let idx = (p / 100.0) * (sorted.len() - 1) as f64;
245    let lower = idx.floor() as usize;
246    let upper = idx.ceil() as usize;
247    let lower = lower.min(sorted.len() - 1);
248    let upper = upper.min(sorted.len() - 1);
249    let frac = idx - lower as f64;
250    sorted[lower] * (1.0 - frac) + sorted[upper] * frac
251}
252
253fn compute_std_dev(data: &[f64], mean: f64) -> f64 {
254    if data.is_empty() {
255        return 0.0;
256    }
257    let variance: f64 =
258        data.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / data.len() as f64;
259    variance.sqrt()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // --- StaticThreshold tests ---
267
268    #[test]
269    fn static_threshold_in_range() {
270        let t = StaticThreshold::new(0.0, 100.0);
271        let r = t.check(50.0);
272        assert!(r.within_range);
273        assert_eq!(r.distance_from_range, 0.0);
274    }
275
276    #[test]
277    fn static_threshold_above_range() {
278        let t = StaticThreshold::new(0.0, 100.0);
279        let r = t.check(150.0);
280        assert!(!r.within_range);
281        assert!((r.distance_from_range - 50.0).abs() < 1e-9);
282    }
283
284    #[test]
285    fn static_threshold_below_range() {
286        let t = StaticThreshold::new(0.0, 100.0);
287        let r = t.check(-10.0);
288        assert!(!r.within_range);
289        assert!((r.distance_from_range - 10.0).abs() < 1e-9);
290    }
291
292    #[test]
293    fn static_threshold_boundary() {
294        let t = StaticThreshold::new(0.0, 100.0);
295        assert!(t.check(0.0).within_range);
296        assert!(t.check(100.0).within_range);
297    }
298
299    // --- Moving average ---
300
301    #[test]
302    fn moving_average_basic() {
303        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
304        let result = moving_average(&data, 3);
305        assert!((result[0] - 1.0).abs() < 1e-9);
306        assert!((result[2] - 2.0).abs() < 1e-9);
307        assert!((result[4] - 4.0).abs() < 1e-9);
308    }
309
310    #[test]
311    fn moving_average_window_one() {
312        let data = vec![1.0, 2.0, 3.0];
313        let result = moving_average(&data, 1);
314        assert_eq!(result, data);
315    }
316
317    #[test]
318    fn moving_average_empty() {
319        let data: Vec<f64> = vec![];
320        let result = moving_average(&data, 3);
321        assert!(result.is_empty());
322    }
323
324    // --- Exponential smoothing ---
325
326    #[test]
327    fn exponential_smoothing_basic() {
328        let data = vec![1.0, 2.0, 3.0];
329        let result = exponential_smoothing(&data, 0.5);
330        assert!((result[0] - 1.0).abs() < 1e-9);
331        assert!((result[1] - 1.5).abs() < 1e-9);
332        assert!((result[2] - 2.25).abs() < 1e-9);
333    }
334
335    #[test]
336    fn exponential_smoothing_alpha_zero() {
337        let data = vec![1.0, 2.0, 3.0];
338        let result = exponential_smoothing(&data, 0.0);
339        assert!((result[0] - 1.0).abs() < 1e-9);
340        assert!((result[1] - 1.0).abs() < 1e-9);
341        assert!((result[2] - 1.0).abs() < 1e-9);
342    }
343
344    #[test]
345    fn exponential_smoothing_alpha_one() {
346        let data = vec![1.0, 2.0, 3.0];
347        let result = exponential_smoothing(&data, 1.0);
348        assert_eq!(result, data);
349    }
350
351    // --- Percentile ---
352
353    #[test]
354    fn percentile_median() {
355        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
356        let result = percentile(&data, 50.0);
357        assert!((result - 3.0).abs() < 1e-9);
358    }
359
360    #[test]
361    fn percentile_min() {
362        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
363        let result = percentile(&data, 0.0);
364        assert!((result - 1.0).abs() < 1e-9);
365    }
366
367    #[test]
368    fn percentile_max() {
369        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
370        let result = percentile(&data, 100.0);
371        assert!((result - 5.0).abs() < 1e-9);
372    }
373
374    #[test]
375    fn percentile_empty() {
376        let data: Vec<f64> = vec![];
377        let result = percentile(&data, 50.0);
378        assert!(result.is_nan());
379    }
380
381    // --- AdaptiveThreshold ---
382
383    #[test]
384    fn adaptive_threshold_learns_normal_data() {
385        let config = ThresholdConfig {
386            method: ThresholdMethod::MovingAverage { window: 5 },
387            warmup_samples: 3,
388            sensitivity: 2.0,
389        };
390        let mut at = AdaptiveThreshold::new(config);
391        for v in &[10.0, 11.0, 10.5, 10.2, 9.8] {
392            at.update(*v);
393        }
394        assert!(at.is_warmed_up());
395        // Value near the mean should be in range
396        let result = at.check(10.3);
397        assert!(result.within_range);
398        assert!(result.confidence > 0.0);
399    }
400
401    #[test]
402    fn adaptive_threshold_detects_anomaly() {
403        let config = ThresholdConfig {
404            method: ThresholdMethod::MovingAverage { window: 5 },
405            warmup_samples: 3,
406            sensitivity: 1.0, // tight
407        };
408        let mut at = AdaptiveThreshold::new(config);
409        for v in &[10.0, 10.1, 9.9, 10.0, 10.1] {
410            at.update(*v);
411        }
412        // Way out of range
413        let result = at.check(100.0);
414        assert!(!result.within_range);
415    }
416
417    #[test]
418    fn adaptive_threshold_warmup_period() {
419        let config = ThresholdConfig {
420            method: ThresholdMethod::MovingAverage { window: 3 },
421            warmup_samples: 5,
422            sensitivity: 2.0,
423        };
424        let mut at = AdaptiveThreshold::new(config);
425        assert!(!at.is_warmed_up());
426        at.update(10.0);
427        at.update(10.0);
428        at.update(10.0);
429        at.update(10.0);
430        assert!(!at.is_warmed_up());
431        at.update(10.0);
432        assert!(at.is_warmed_up());
433    }
434
435    #[test]
436    fn adaptive_threshold_not_warmed_accepts_anything() {
437        let config = ThresholdConfig {
438            method: ThresholdMethod::MovingAverage { window: 3 },
439            warmup_samples: 10,
440            sensitivity: 2.0,
441        };
442        let at = AdaptiveThreshold::new(config);
443        let result = at.check(99999.0);
444        assert!(result.within_range); // range is [-inf, inf] before warmup
445        assert_eq!(result.confidence, 0.0);
446    }
447
448    // --- Sensitivity ---
449
450    #[test]
451    fn tighter_sensitivity_narrows_range() {
452        let config_tight = ThresholdConfig {
453            method: ThresholdMethod::MovingAverage { window: 5 },
454            warmup_samples: 3,
455            sensitivity: 0.5,
456        };
457        let config_loose = ThresholdConfig {
458            method: ThresholdMethod::MovingAverage { window: 5 },
459            warmup_samples: 3,
460            sensitivity: 3.0,
461        };
462        let data = vec![10.0, 11.0, 9.0, 10.5, 9.5];
463        let mut at_tight = AdaptiveThreshold::new(config_tight);
464        let mut at_loose = AdaptiveThreshold::new(config_loose);
465        for v in &data {
466            at_tight.update(*v);
467            at_loose.update(*v);
468        }
469        let range_tight = at_tight.current_range();
470        let range_loose = at_loose.current_range();
471        assert!(range_tight.max - range_tight.min < range_loose.max - range_loose.min);
472    }
473
474    // --- Edge cases ---
475
476    #[test]
477    fn constant_data() {
478        let config = ThresholdConfig {
479            method: ThresholdMethod::MovingAverage { window: 3 },
480            warmup_samples: 2,
481            sensitivity: 2.0,
482        };
483        let mut at = AdaptiveThreshold::new(config);
484        for _ in 0..5 {
485            at.update(42.0);
486        }
487        // With zero std dev, range should be exactly [42, 42]
488        let range = at.current_range();
489        assert!((range.min - 42.0).abs() < 1e-9);
490        assert!((range.max - 42.0).abs() < 1e-9);
491    }
492
493    #[test]
494    fn single_sample() {
495        let config = ThresholdConfig {
496            method: ThresholdMethod::MovingAverage { window: 3 },
497            warmup_samples: 1,
498            sensitivity: 2.0,
499        };
500        let mut at = AdaptiveThreshold::new(config);
501        at.update(10.0);
502        assert!(at.is_warmed_up());
503        let range = at.current_range();
504        assert!((range.min - 10.0).abs() < 1e-9);
505        assert!((range.max - 10.0).abs() < 1e-9);
506    }
507
508    #[test]
509    fn nan_handling_static() {
510        let t = StaticThreshold::new(0.0, 100.0);
511        let r = t.check(f64::NAN);
512        assert!(!r.within_range);
513        assert!(r.distance_from_range.is_nan());
514    }
515
516    #[test]
517    fn nan_handling_adaptive() {
518        let config = ThresholdConfig {
519            method: ThresholdMethod::MovingAverage { window: 3 },
520            warmup_samples: 2,
521            sensitivity: 2.0,
522        };
523        let mut at = AdaptiveThreshold::new(config);
524        at.update(f64::NAN);
525        at.update(f64::NAN);
526        assert!(!at.is_warmed_up()); // NaN samples don't count toward history for warmup... actually they do get pushed
527        // Let's just check NaN check
528        let r = at.check(f64::NAN);
529        assert!(!r.within_range);
530    }
531
532    #[test]
533    fn all_same_value_percentile() {
534        let data = vec![5.0; 10];
535        let result = percentile(&data, 50.0);
536        assert!((result - 5.0).abs() < 1e-9);
537    }
538
539    // --- Serialization ---
540
541    #[test]
542    fn serde_threshold_range() {
543        let range = ThresholdRange::new(1.0, 10.0);
544        let json = serde_json::to_string(&range).unwrap();
545        let deserialized: ThresholdRange = serde_json::from_str(&json).unwrap();
546        assert_eq!(range, deserialized);
547    }
548
549    #[test]
550    fn serde_adaptive_threshold() {
551        let config = ThresholdConfig {
552            method: ThresholdMethod::ExponentialSmoothing { alpha: 0.3 },
553            warmup_samples: 5,
554            sensitivity: 1.5,
555        };
556        let mut at = AdaptiveThreshold::new(config);
557        at.base = ThresholdRange::new(0.0, 100.0);
558        at.update(10.0);
559        at.update(11.0);
560        at.update(9.0);
561        let json = serde_json::to_string(&at).unwrap();
562        let deserialized: AdaptiveThreshold = serde_json::from_str(&json).unwrap();
563        assert_eq!(deserialized.history.len(), 3);
564        assert!((deserialized.history[0] - 10.0).abs() < 1e-9);
565        assert!((deserialized.base.min - 0.0).abs() < 1e-9);
566    }
567
568    #[test]
569    fn serde_threshold_result() {
570        let result = ThresholdResult {
571            value: 42.0,
572            within_range: true,
573            distance_from_range: 0.0,
574            confidence: 0.95,
575        };
576        let json = serde_json::to_string(&result).unwrap();
577        let deserialized: ThresholdResult = serde_json::from_str(&json).unwrap();
578        assert_eq!(result, deserialized);
579    }
580}