use serde::{Deserialize, Serialize};
fn mean(v: &[f64]) -> f64 {
if v.is_empty() {
return 0.0;
}
v.iter().sum::<f64>() / v.len() as f64
}
fn std_dev(v: &[f64]) -> f64 {
if v.len() < 2 {
return 0.0;
}
let m = mean(v);
let variance = v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64;
variance.sqrt()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridAnomalyDetector {
pub window_size: usize,
pub z_score_threshold: f64,
pub ewma_alpha: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnomalyResult {
pub is_anomaly: bool,
pub score: f64,
pub z_score: f64,
pub ewma_value: f64,
pub upper_bound: f64,
pub lower_bound: f64,
pub change_point: bool,
}
impl GridAnomalyDetector {
pub fn new(window_size: usize, threshold: f64) -> Self {
Self {
window_size,
z_score_threshold: threshold,
ewma_alpha: 0.1,
}
}
pub fn detect_point(&self, value: f64, history: &[f64]) -> AnomalyResult {
let window: &[f64] = if history.len() > self.window_size {
&history[history.len() - self.window_size..]
} else {
history
};
let m = mean(window);
let raw_std = std_dev(window);
let (z_score, is_anomaly) = if raw_std < 1e-10 {
let abs_dev = (value - m).abs();
let anomaly = abs_dev > 1e-4;
let z = if anomaly {
self.z_score_threshold + abs_dev
} else {
abs_dev * self.z_score_threshold
};
(z, anomaly)
} else {
let z = (value - m) / raw_std;
(z, z.abs() > self.z_score_threshold)
};
let s = if raw_std < 1e-10 { 1e-10 } else { raw_std };
let ewma_value = compute_ewma(history, self.ewma_alpha);
let k = 0.5 * s;
let h = 5.0 * s;
let change_point = self.cusum_test(history, k, h).is_some();
AnomalyResult {
is_anomaly,
score: z_score.abs(),
z_score,
ewma_value,
upper_bound: m + self.z_score_threshold * s,
lower_bound: m - self.z_score_threshold * s,
change_point,
}
}
pub fn detect_batch(&self, values: &[f64], history: &[Vec<f64>]) -> Vec<AnomalyResult> {
values
.iter()
.enumerate()
.map(|(i, &v)| {
let h = history.get(i).map(|x| x.as_slice()).unwrap_or(&[]);
self.detect_point(v, h)
})
.collect()
}
pub fn cusum_test(&self, series: &[f64], threshold_k: f64, threshold_h: f64) -> Option<usize> {
if series.is_empty() {
return None;
}
let mu = mean(series);
let mut s_pos = 0.0_f64;
let mut s_neg = 0.0_f64;
for (t, &x) in series.iter().enumerate() {
s_pos = (s_pos + x - mu - threshold_k).max(0.0);
s_neg = (s_neg - x + mu - threshold_k).max(0.0);
if s_pos > threshold_h || s_neg > threshold_h {
return Some(t);
}
}
None
}
}
fn compute_ewma(values: &[f64], alpha: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut s = values[0];
for &x in &values[1..] {
s = alpha * x + (1.0 - alpha) * s;
}
s
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeasurementCorrelationAnalyzer {
pub n_measurements: usize,
pub correlation_window: usize,
}
impl MeasurementCorrelationAnalyzer {
pub fn new(n_measurements: usize, window: usize) -> Self {
Self {
n_measurements,
correlation_window: window,
}
}
pub fn compute_correlation(&self, history: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = self.n_measurements.min(history.len());
let mut corr = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in 0..n {
corr[i][j] = if i == j {
1.0
} else {
pearson_correlation_windowed(&history[i], &history[j], self.correlation_window)
};
}
}
corr
}
pub fn detect_correlation_change(
&self,
current_window: &[Vec<f64>],
baseline_correlation: &[Vec<f64>],
) -> f64 {
let current = self.compute_correlation(current_window);
let n = baseline_correlation.len().min(current.len());
let mut sq_sum = 0.0_f64;
for i in 0..n {
let brow = &baseline_correlation[i];
let crow = ¤t[i];
for j in 0..brow.len().min(crow.len()) {
let diff = crow[j] - brow[j];
sq_sum += diff * diff;
}
}
sq_sum.sqrt()
}
}
fn pearson_correlation_windowed(a: &[f64], b: &[f64], window: usize) -> f64 {
let len = a.len().min(b.len());
if len == 0 {
return 0.0;
}
let start = len.saturating_sub(window);
let a_w = &a[start..];
let b_w = &b[start..];
let ma = mean(a_w);
let mb = mean(b_w);
let num: f64 = a_w
.iter()
.zip(b_w.iter())
.map(|(x, y)| (x - ma) * (y - mb))
.sum();
let da: f64 = a_w.iter().map(|x| (x - ma).powi(2)).sum::<f64>().sqrt();
let db: f64 = b_w.iter().map(|y| (y - mb).powi(2)).sum::<f64>().sqrt();
let denom = da * db;
if denom < 1e-15 {
0.0
} else {
(num / denom).clamp(-1.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn z_score_detects_outlier() {
let detector = GridAnomalyDetector::new(20, 3.0);
let history: Vec<f64> = (0..20).map(|i| i as f64).collect();
let result = detector.detect_point(200.0, &history);
assert!(result.is_anomaly);
assert!(result.z_score > 3.0);
}
#[test]
fn z_score_passes_normal() {
let detector = GridAnomalyDetector::new(20, 3.0);
let history: Vec<f64> = (0..20).map(|i| 9.0 + (i as f64) * 0.1).collect(); let value = 10.0_f64; let result = detector.detect_point(value, &history);
assert!(
!result.is_anomaly,
"Value {} should be normal; z={}",
value, result.z_score
);
}
#[test]
fn cusum_detects_step_change() {
let detector = GridAnomalyDetector::new(20, 3.0);
let mut series: Vec<f64> = vec![0.0; 10];
series.extend(vec![5.0; 10]);
let s = std_dev(&series[..10]).max(0.01);
let result = detector.cusum_test(&series, 0.5 * s, 5.0 * s);
assert!(result.is_some());
}
#[test]
fn correlation_matrix_diagonal_is_one() {
let analyzer = MeasurementCorrelationAnalyzer::new(3, 10);
let history = vec![
vec![1.0, 2.0, 3.0, 4.0],
vec![2.0, 3.0, 4.0, 5.0],
vec![0.5, 1.5, 2.5, 3.5],
];
let corr = analyzer.compute_correlation(&history);
for (i, row) in corr.iter().enumerate().take(3) {
assert!((row[i] - 1.0).abs() < 1e-10);
}
}
}