use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridAnomalyType {
FalseDataInjection,
ReplayAttack,
ManInTheMiddle,
DenialOfService,
StealthyAttack,
PhysicalTampering,
OperationalAnomaly,
RampingAnomaly,
MeasurementOutlier,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DetectionSeverity {
Info,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MeasurementKind {
VoltageMagnitude,
VoltageAngle,
ActivePower,
ReactivePower,
Current,
Frequency,
}
#[derive(Debug, Clone)]
pub struct Measurement {
pub id: usize,
pub timestamp: f64,
pub value: f64,
pub measurement_type: MeasurementKind,
pub location_id: usize,
pub quality_flag: u8,
pub noise_std: f64,
}
#[derive(Debug, Clone)]
pub struct DetectionAlert {
pub id: usize,
pub timestamp: f64,
pub anomaly_type: GridAnomalyType,
pub severity: DetectionSeverity,
pub confidence: f64,
pub affected_measurements: Vec<usize>,
pub description: String,
pub recommended_action: String,
}
#[derive(Debug, Clone)]
pub struct StatisticalModel {
pub mean: f64,
pub variance: f64,
pub n_samples: u64,
pub ema_mean: f64,
pub ema_variance: f64,
pub alpha: f64,
m2: f64,
}
impl StatisticalModel {
pub fn new(alpha: f64) -> Self {
Self {
mean: 0.0,
variance: 0.0,
n_samples: 0,
ema_mean: 0.0,
ema_variance: 1.0,
alpha: alpha.clamp(1e-6, 1.0),
m2: 0.0,
}
}
pub fn update(&mut self, value: f64) {
self.n_samples += 1;
let delta = value - self.mean;
self.mean += delta / self.n_samples as f64;
let delta2 = value - self.mean;
self.m2 += delta * delta2;
self.variance = if self.n_samples > 1 {
self.m2 / (self.n_samples - 1) as f64
} else {
0.0
};
if self.n_samples == 1 {
self.ema_mean = value;
self.ema_variance = 0.0;
} else {
let prev_ema = self.ema_mean;
self.ema_mean = self.alpha * value + (1.0 - self.alpha) * prev_ema;
self.ema_variance = self.alpha * (value - self.ema_mean).powi(2)
+ (1.0 - self.alpha) * self.ema_variance;
}
}
pub fn z_score(&self, value: f64) -> f64 {
if self.n_samples < 2 {
return 0.0;
}
let std = self.variance.sqrt();
if std < 1e-12 {
return 0.0;
}
(value - self.mean) / std
}
pub fn is_outlier(&self, value: f64, threshold: f64) -> bool {
self.z_score(value).abs() > threshold
}
}
#[derive(Debug, Clone)]
pub struct CorrelationMatrix {
pub n: usize,
pub data: Vec<f64>,
}
impl CorrelationMatrix {
pub fn new(n: usize) -> Self {
let mut data = vec![0.0_f64; n * n];
for i in 0..n {
data[i * n + i] = 1.0;
}
Self { n, data }
}
pub fn get(&self, i: usize, j: usize) -> f64 {
if i < self.n && j < self.n {
self.data[i * self.n + j]
} else {
0.0
}
}
pub fn set(&mut self, i: usize, j: usize, v: f64) {
if i < self.n && j < self.n {
self.data[i * self.n + j] = v;
}
}
pub fn update_online(&mut self, z_scores: &[f64]) {
let alpha = 0.05_f64;
let m = z_scores.len().min(self.n);
for i in 0..m {
for j in 0..m {
let outer = z_scores[i] * z_scores[j];
let old = self.get(i, j);
self.set(i, j, (1.0 - alpha) * old + alpha * outer);
}
}
}
pub fn mahalanobis_distance(&self, z_scores: &[f64]) -> f64 {
let m = z_scores.len().min(self.n);
if m == 0 {
return 0.0;
}
let sum: f64 = (0..m)
.map(|i| {
let cii = self.get(i, i).max(1e-10);
z_scores[i].powi(2) / cii
})
.sum();
(sum / m as f64).sqrt()
}
}
pub struct AnomalyDetector {
pub n_measurements: usize,
pub models: Vec<StatisticalModel>,
pub history: VecDeque<Vec<f64>>,
pub window_size: usize,
pub chi2_threshold: f64,
pub z_threshold: f64,
pub correlation_matrix: CorrelationMatrix,
pub alert_history: Vec<DetectionAlert>,
pub next_alert_id: usize,
}
impl AnomalyDetector {
pub fn new(n_measurements: usize, window_size: usize) -> Self {
let models = (0..n_measurements)
.map(|_| StatisticalModel::new(0.05))
.collect();
Self {
n_measurements,
models,
history: VecDeque::with_capacity(window_size + 1),
window_size: window_size.max(1),
chi2_threshold: 3.84,
z_threshold: 3.0,
correlation_matrix: CorrelationMatrix::new(n_measurements),
alert_history: Vec::new(),
next_alert_id: 0,
}
}
pub fn update(&mut self, measurements: &[f64], timestamp: f64) -> Vec<DetectionAlert> {
let m = measurements.len().min(self.n_measurements);
for (i, &v) in measurements.iter().enumerate().take(m) {
self.models[i].update(v);
}
let z_scores: Vec<f64> = (0..m)
.map(|i| self.models[i].z_score(measurements[i]))
.collect();
self.correlation_matrix.update_online(&z_scores);
let mut alerts = Vec::new();
alerts.extend(self.detect_statistical_outliers(measurements, timestamp));
alerts.extend(self.detect_correlation_anomalies(measurements, timestamp));
alerts.extend(self.detect_ramp_anomalies(measurements, timestamp));
alerts.extend(self.detect_replay_attacks(measurements, timestamp));
for alert in &mut alerts {
alert.id = self.next_alert_id;
self.next_alert_id += 1;
}
self.alert_history.extend(alerts.clone());
self.history.push_back(measurements[..m].to_vec());
while self.history.len() > self.window_size {
self.history.pop_front();
}
alerts
}
pub fn detect_statistical_outliers(
&self,
measurements: &[f64],
timestamp: f64,
) -> Vec<DetectionAlert> {
let mut alerts = Vec::new();
let m = measurements.len().min(self.n_measurements);
for (i, meas_val) in measurements.iter().take(m).enumerate() {
let z = self.models[i].z_score(*meas_val);
if z.abs() > self.z_threshold && self.models[i].n_samples >= 10 {
let confidence = ((z.abs() - self.z_threshold) / self.z_threshold).clamp(0.0, 1.0);
let severity = if z.abs() > 6.0 {
DetectionSeverity::Critical
} else if z.abs() > 5.0 {
DetectionSeverity::High
} else if z.abs() > 4.0 {
DetectionSeverity::Medium
} else {
DetectionSeverity::Low
};
alerts.push(DetectionAlert {
id: 0, timestamp,
anomaly_type: GridAnomalyType::MeasurementOutlier,
severity,
confidence,
affected_measurements: vec![i],
description: format!(
"Measurement channel {} has Z-score {:.2} (threshold {:.2})",
i, z, self.z_threshold
),
recommended_action:
"Verify sensor calibration and cross-check redundant measurements".into(),
});
}
}
alerts
}
pub fn detect_correlation_anomalies(
&self,
measurements: &[f64],
timestamp: f64,
) -> Vec<DetectionAlert> {
if self.n_measurements < 2 {
return Vec::new();
}
let m = measurements.len().min(self.n_measurements);
let min_samples = self
.models
.iter()
.take(m)
.map(|mdl| mdl.n_samples)
.min()
.unwrap_or(0);
if min_samples < 10 {
return Vec::new();
}
let z_scores: Vec<f64> = (0..m)
.map(|i| self.models[i].z_score(measurements[i]))
.collect();
let dist = self.correlation_matrix.mahalanobis_distance(&z_scores);
let threshold = self.z_threshold * 1.2;
if dist > threshold {
let confidence = ((dist - threshold) / threshold).clamp(0.0, 1.0);
let severity = if dist > threshold * 2.0 {
DetectionSeverity::High
} else {
DetectionSeverity::Medium
};
return vec![DetectionAlert {
id: 0,
timestamp,
anomaly_type: GridAnomalyType::OperationalAnomaly,
severity,
confidence,
affected_measurements: (0..m).collect(),
description: format!(
"Mahalanobis distance {:.3} exceeds threshold {:.3}; correlated anomaly detected",
dist, threshold
),
recommended_action: "Inspect correlated measurement group for systematic bias or coordinated manipulation".into(),
}];
}
Vec::new()
}
pub fn detect_ramp_anomalies(
&self,
measurements: &[f64],
timestamp: f64,
) -> Vec<DetectionAlert> {
let prev = match self.history.back() {
Some(v) => v,
None => return Vec::new(),
};
let m = measurements.len().min(self.n_measurements).min(prev.len());
let mut alerts = Vec::new();
for i in 0..m {
let sigma = self.models[i].variance.sqrt();
if sigma < 1e-12 {
continue;
}
let ramp_threshold = 3.0 * sigma;
let delta = (measurements[i] - prev[i]).abs();
if delta > ramp_threshold {
let confidence = ((delta / ramp_threshold) - 1.0).clamp(0.0, 1.0);
let severity = if delta > 6.0 * sigma {
DetectionSeverity::High
} else {
DetectionSeverity::Medium
};
alerts.push(DetectionAlert {
id: 0,
timestamp,
anomaly_type: GridAnomalyType::RampingAnomaly,
severity,
confidence,
affected_measurements: vec![i],
description: format!(
"Channel {} ramp |Δ|={:.4} exceeds 3σ={:.4}",
i, delta, ramp_threshold
),
recommended_action:
"Verify generator/load ramp rates and check for sudden topology changes"
.into(),
});
}
}
alerts
}
pub fn detect_replay_attacks(
&self,
measurements: &[f64],
timestamp: f64,
) -> Vec<DetectionAlert> {
if self.history.is_empty() {
return Vec::new();
}
let n = self.n_measurements as f64;
let replay_threshold = 0.01 * n.sqrt();
let m = measurements.len().min(self.n_measurements);
for (hist_idx, past) in self.history.iter().enumerate() {
let pm = past.len().min(m);
let dist: f64 = (0..pm)
.map(|i| (measurements[i] - past[i]).powi(2))
.sum::<f64>()
.sqrt();
if dist < replay_threshold {
let confidence = (1.0 - dist / replay_threshold.max(1e-12)).clamp(0.0, 1.0);
return vec![DetectionAlert {
id: 0,
timestamp,
anomaly_type: GridAnomalyType::ReplayAttack,
severity: DetectionSeverity::High,
confidence,
affected_measurements: (0..m).collect(),
description: format!(
"Measurement vector matches history entry {} (dist={:.6} < threshold={:.6})",
hist_idx, dist, replay_threshold
),
recommended_action: "Verify measurement timestamps; check for replay injection at RTU/IED level".into(),
}];
}
}
Vec::new()
}
pub fn detect_false_data_injection(
&self,
_measurements: &[f64],
residuals: &[f64],
timestamp: f64,
) -> Vec<DetectionAlert> {
let m = residuals.len().min(self.n_measurements);
let sigma: Vec<f64> = (0..m)
.map(|i| self.models[i].variance.sqrt().max(1e-6))
.collect();
let chi2 = self.chi2_test(residuals, &sigma);
let mut suspicious: Vec<usize> = Vec::new();
for i in 0..m {
if residuals[i].abs() > 4.0 * sigma[i] {
suspicious.push(i);
}
}
if !suspicious.is_empty() && chi2 < self.chi2_threshold * suspicious.len() as f64 {
let confidence = (suspicious.len() as f64 / m as f64).clamp(0.0, 1.0);
let severity = if suspicious.len() > m / 2 {
DetectionSeverity::Critical
} else {
DetectionSeverity::High
};
return vec![DetectionAlert {
id: 0,
timestamp,
anomaly_type: GridAnomalyType::StealthyAttack,
severity,
confidence,
affected_measurements: suspicious,
description: format!(
"Stealthy FDI detected: {} channels have |residual|>4σ but chi2={:.3} passes",
m, chi2
),
recommended_action:
"Run topology-constrained bad-data analysis; isolate suspect RTUs".into(),
}];
}
Vec::new()
}
pub fn compute_residuals(&self, measurements: &[f64], estimated: &[f64]) -> Vec<f64> {
let m = measurements.len().min(estimated.len());
(0..m).map(|i| measurements[i] - estimated[i]).collect()
}
pub fn chi2_test(&self, residuals: &[f64], sigma: &[f64]) -> f64 {
let m = residuals.len().min(sigma.len());
(0..m)
.map(|i| {
let s = sigma[i].max(1e-12);
(residuals[i] / s).powi(2)
})
.sum()
}
pub fn get_active_alerts(&self, severity_threshold: DetectionSeverity) -> Vec<&DetectionAlert> {
self.alert_history
.iter()
.filter(|a| a.severity >= severity_threshold)
.collect()
}
pub fn clear_alert_history(&mut self) {
self.alert_history.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_statistical_model_update() {
let mut m = StatisticalModel::new(0.05);
for _ in 0..1000 {
m.update(5.0);
}
assert!((m.mean - 5.0).abs() < 1e-10, "mean should converge to 5.0");
assert!(
m.variance < 1e-20,
"variance should be ~0 for constant signal"
);
}
#[test]
fn test_welford_correctness() {
let data = [1.0_f64, 2.0, 3.0, 4.0, 5.0];
let mut m = StatisticalModel::new(0.05);
for &v in &data {
m.update(v);
}
assert!((m.mean - 3.0).abs() < 1e-10, "Welford mean mismatch");
assert!(
(m.variance - 2.5).abs() < 1e-10,
"Welford variance mismatch"
);
}
#[test]
fn test_z_score_normal() {
let mut m = StatisticalModel::new(0.05);
for _ in 0..200 {
m.update(10.0);
}
let z = m.z_score(10.0);
assert!(z.abs() < 1e-6, "z-score of mean should be ~0");
}
#[test]
fn test_outlier_detection() {
let mut m = StatisticalModel::new(0.05);
let values: Vec<f64> = (0..100)
.map(|i| {
((i * 1664525 + 1013904223) % 1000) as f64 / 500.0 - 1.0
})
.collect();
for &v in &values {
m.update(v);
}
let extreme = m.mean + 5.0 * m.variance.sqrt();
assert!(m.is_outlier(extreme, 3.0), "5σ outlier should be detected");
}
#[test]
fn test_detector_initialization() {
let det = AnomalyDetector::new(5, 60);
assert_eq!(det.n_measurements, 5);
assert_eq!(det.models.len(), 5);
assert_eq!(det.window_size, 60);
assert!(det.alert_history.is_empty());
}
#[test]
fn test_update_no_alert_normal() {
let mut det = AnomalyDetector::new(3, 60);
for _ in 0..100 {
let alerts = det.update(&[1.0, 2.0, 3.0], 0.0);
for a in &alerts {
assert_ne!(
a.anomaly_type,
GridAnomalyType::MeasurementOutlier,
"constant signal should not trigger outlier alert"
);
}
}
}
#[test]
fn test_update_alerts_on_spike() {
let mut det = AnomalyDetector::new(1, 60);
for k in 0..100 {
let v = (k as f64 * 0.1).sin();
det.update(&[v], k as f64);
}
let std = det.models[0].variance.sqrt();
let spike = det.models[0].mean + 10.0 * std;
let alerts = det.update(&[spike], 100.0);
let has_outlier = alerts
.iter()
.any(|a| a.anomaly_type == GridAnomalyType::MeasurementOutlier);
assert!(
has_outlier,
"10σ spike should trigger MeasurementOutlier alert"
);
}
#[test]
fn test_replay_detection_identical() {
let mut det = AnomalyDetector::new(4, 60);
let vec1 = [1.0_f64, 2.0, 3.0, 4.0];
det.update(&vec1, 0.0);
let alerts = det.update(&vec1, 1.0);
let has_replay = alerts
.iter()
.any(|a| a.anomaly_type == GridAnomalyType::ReplayAttack);
assert!(has_replay, "Identical vector should trigger ReplayAttack");
}
#[test]
fn test_replay_detection_different() {
let mut det = AnomalyDetector::new(4, 60);
det.update(&[1.0, 2.0, 3.0, 4.0], 0.0);
let alerts = det.update(&[100.0, 200.0, 300.0, 400.0], 1.0);
let has_replay = alerts
.iter()
.any(|a| a.anomaly_type == GridAnomalyType::ReplayAttack);
assert!(
!has_replay,
"Different vector should not trigger ReplayAttack"
);
}
#[test]
fn test_ramp_detection_fast() {
let mut det = AnomalyDetector::new(1, 60);
for k in 0..50 {
let v = (k as f64 * 0.05).sin() * 0.1;
det.update(&[v], k as f64);
}
let std = det.models[0].variance.sqrt().max(1e-3);
let big_jump = det.models[0].mean + 100.0 * std;
let alerts = det.update(&[big_jump], 50.0);
let has_ramp = alerts
.iter()
.any(|a| a.anomaly_type == GridAnomalyType::RampingAnomaly);
assert!(has_ramp, "Large step should trigger RampingAnomaly");
}
#[test]
fn test_ramp_detection_slow() {
let mut det = AnomalyDetector::new(1, 60);
for k in 0..100 {
det.update(&[k as f64 * 0.01], k as f64);
}
let last = det.models[0].mean;
let alerts = det.update(&[last + 1e-6], 100.0);
let has_ramp = alerts
.iter()
.any(|a| a.anomaly_type == GridAnomalyType::RampingAnomaly);
assert!(
!has_ramp,
"Tiny increment should not trigger RampingAnomaly"
);
}
#[test]
fn test_chi2_test_formula() {
let det = AnomalyDetector::new(1, 10);
let chi2 = det.chi2_test(&[1.0], &[1.0]);
assert!(
(chi2 - 1.0).abs() < 1e-12,
"chi2([1.0],[1.0]) should equal 1.0"
);
}
#[test]
fn test_chi2_test_multi() {
let det = AnomalyDetector::new(3, 10);
let chi2 = det.chi2_test(&[2.0, 3.0, 4.0], &[1.0, 1.0, 1.0]);
assert!((chi2 - 29.0).abs() < 1e-10, "chi2 should be 29.0");
}
#[test]
fn test_fdi_detection_stealthy() {
let mut det = AnomalyDetector::new(3, 10);
for _ in 0..20 {
det.update(&[1.0, 1.0, 1.0], 0.0);
}
let sigma: Vec<f64> = det
.models
.iter()
.map(|m| m.variance.sqrt().max(0.01))
.collect();
let _large_sigma = [100.0_f64, 100.0, 100.0];
let residuals = vec![sigma[0] * 5.0, sigma[1] * 5.0, sigma[2] * 5.0];
let alerts = det.detect_false_data_injection(&[1.0, 1.0, 1.0], &residuals, 1.0);
let _ = alerts;
let r = vec![0.001_f64, 0.001, 0.001];
let s = vec![0.001_f64, 0.001, 0.001]; let chi2 = det.chi2_test(&r, &s);
assert!((chi2 - 3.0).abs() < 1e-10);
}
#[test]
fn test_residual_computation() {
let det = AnomalyDetector::new(3, 10);
let measured = [3.0_f64, 5.0, 7.0];
let estimated = [2.0_f64, 4.0, 6.0];
let residuals = det.compute_residuals(&measured, &estimated);
assert_eq!(residuals.len(), 3);
assert!((residuals[0] - 1.0).abs() < 1e-12);
assert!((residuals[1] - 1.0).abs() < 1e-12);
assert!((residuals[2] - 1.0).abs() < 1e-12);
}
#[test]
fn test_alert_severity_filter() {
let mut det = AnomalyDetector::new(1, 60);
det.alert_history.push(DetectionAlert {
id: 0,
timestamp: 0.0,
anomaly_type: GridAnomalyType::MeasurementOutlier,
severity: DetectionSeverity::Low,
confidence: 0.5,
affected_measurements: vec![0],
description: "low".into(),
recommended_action: "".into(),
});
det.alert_history.push(DetectionAlert {
id: 1,
timestamp: 1.0,
anomaly_type: GridAnomalyType::ReplayAttack,
severity: DetectionSeverity::High,
confidence: 0.9,
affected_measurements: vec![0],
description: "high".into(),
recommended_action: "".into(),
});
det.alert_history.push(DetectionAlert {
id: 2,
timestamp: 2.0,
anomaly_type: GridAnomalyType::StealthyAttack,
severity: DetectionSeverity::Critical,
confidence: 0.99,
affected_measurements: vec![0],
description: "critical".into(),
recommended_action: "".into(),
});
let high_plus = det.get_active_alerts(DetectionSeverity::High);
assert_eq!(high_plus.len(), 2, "High + Critical should be returned");
let critical_only = det.get_active_alerts(DetectionSeverity::Critical);
assert_eq!(critical_only.len(), 1, "Only Critical should be returned");
}
#[test]
fn test_correlation_matrix_identity() {
let cm = CorrelationMatrix::new(3);
assert!((cm.get(0, 0) - 1.0).abs() < 1e-12, "diagonal should be 1");
assert!((cm.get(0, 1)).abs() < 1e-12, "off-diagonal should be 0");
assert!((cm.get(1, 0)).abs() < 1e-12, "off-diagonal should be 0");
assert!((cm.get(2, 2) - 1.0).abs() < 1e-12);
}
#[test]
fn test_ema_convergence() {
let mut m = StatisticalModel::new(0.05);
for _ in 0..200 {
m.update(5.0);
}
assert!(
(m.ema_mean - 5.0).abs() < 0.01,
"EMA should converge to 5.0, got {}",
m.ema_mean
);
}
#[test]
fn test_alert_history_accumulates() {
let mut det = AnomalyDetector::new(1, 60);
for k in 0..50 {
det.update(&[(k as f64 * 0.01).sin() * 0.1], k as f64);
}
let std = det.models[0].variance.sqrt().max(1e-3);
let mean = det.models[0].mean;
det.update(&[mean + 20.0 * std], 50.0);
assert!(
!det.alert_history.is_empty(),
"alert_history should contain alerts after spike"
);
}
#[test]
fn test_clear_alert_history() {
let mut det = AnomalyDetector::new(1, 60);
det.alert_history.push(DetectionAlert {
id: 0,
timestamp: 0.0,
anomaly_type: GridAnomalyType::MeasurementOutlier,
severity: DetectionSeverity::Info,
confidence: 0.1,
affected_measurements: vec![],
description: "test".into(),
recommended_action: "".into(),
});
assert!(!det.alert_history.is_empty());
det.clear_alert_history();
assert!(
det.alert_history.is_empty(),
"alert_history should be empty after clear"
);
}
#[test]
fn test_sliding_window_size() {
let window = 10_usize;
let mut det = AnomalyDetector::new(2, window);
for k in 0..(window + 15) {
det.update(&[k as f64, k as f64 * 2.0], k as f64);
}
assert_eq!(
det.history.len(),
window,
"history length should be capped at window_size"
);
}
#[test]
fn test_z_score_zero_samples() {
let m = StatisticalModel::new(0.05);
assert_eq!(
m.z_score(999.0),
0.0,
"z_score with 0 samples should return 0"
);
}
#[test]
fn test_is_outlier_false_for_mean() {
let mut m = StatisticalModel::new(0.05);
let values: Vec<f64> = (0..50).map(|i| i as f64).collect();
for &v in &values {
m.update(v);
}
assert!(
!m.is_outlier(m.mean, 3.0),
"mean value should not be an outlier"
);
}
#[test]
fn test_mahalanobis_identity_matrix() {
let cm = CorrelationMatrix::new(3);
let dist = cm.mahalanobis_distance(&[1.0, 1.0, 1.0]);
assert!(
(dist - 1.0).abs() < 1e-10,
"Mahalanobis on identity should equal 1.0"
);
}
#[test]
fn test_compute_residuals_lengths() {
let det = AnomalyDetector::new(5, 10);
let res = det.compute_residuals(&[1.0, 2.0, 3.0], &[0.5, 1.5, 2.5, 999.0]);
assert_eq!(res.len(), 3);
}
}