#[derive(Debug, Clone, PartialEq)]
pub struct Cusum {
ref_shift_k: f64,
alarm_h: f64,
startup: usize,
count: usize,
mean: f64,
m2: f64,
statistic: f64,
alarmed: bool,
}
impl Cusum {
pub fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> crate::Result<Self> {
if !ref_shift_k.is_finite() || ref_shift_k < 0.0 {
return Err(crate::Error::InvalidCusum(format!(
"reference shift k must be finite and >= 0, got {ref_shift_k}"
)));
}
if !alarm_h.is_finite() || alarm_h <= 0.0 {
return Err(crate::Error::InvalidCusum(format!(
"alarm threshold h must be finite and > 0, got {alarm_h}"
)));
}
Ok(Self {
ref_shift_k,
alarm_h,
startup,
count: 0,
mean: 0.0,
m2: 0.0,
statistic: 0.0,
alarmed: false,
})
}
pub fn count(&self) -> usize {
self.count
}
pub fn mean(&self) -> f64 {
self.mean
}
pub fn variance(&self) -> f64 {
if self.count >= 2 {
self.m2 / (self.count as f64 - 1.0)
} else {
0.0
}
}
pub fn std(&self) -> f64 {
self.variance().sqrt()
}
pub fn statistic(&self) -> f64 {
self.statistic
}
pub fn status(&self) -> bool {
self.alarmed
}
pub fn update(&mut self, x: f64) -> bool {
if !x.is_finite() {
return self.alarmed;
}
self.count += 1;
let n = self.count as f64;
let old_mean = self.mean;
self.mean += (x - old_mean) / n;
self.m2 += (x - old_mean) * (x - self.mean);
let std = self.std();
let shifted = x - self.mean - self.ref_shift_k * std;
self.statistic = (self.statistic + shifted).max(0.0);
self.alarmed = self.count > self.startup && self.statistic > self.alarm_h * std;
self.alarmed
}
pub fn reset(&mut self) {
self.count = 0;
self.mean = 0.0;
self.m2 = 0.0;
self.statistic = 0.0;
self.alarmed = false;
}
}
impl Default for Cusum {
fn default() -> Self {
Self {
ref_shift_k: 0.5,
alarm_h: 4.0,
startup: 10,
count: 0,
mean: 0.0,
m2: 0.0,
statistic: 0.0,
alarmed: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64, tol: f64) {
assert!(
(a - b).abs() <= tol,
"{a} != {b} within absolute tolerance {tol}"
);
}
#[test]
fn default_tuning_matches_canonical_values() {
let cusum = Cusum::default();
assert_eq!(cusum.ref_shift_k, 0.5);
assert_eq!(cusum.alarm_h, 4.0);
assert_eq!(cusum.startup, 10);
assert_eq!(cusum.count(), 0);
assert_eq!(cusum.statistic(), 0.0);
assert!(!cusum.status());
assert_eq!(Cusum::new(0.5, 4.0, 10).unwrap(), cusum);
}
#[test]
fn invalid_parameters_rejected() {
assert!(matches!(
Cusum::new(f64::NAN, 4.0, 10),
Err(crate::Error::InvalidCusum(_))
));
assert!(matches!(
Cusum::new(-0.1, 4.0, 10),
Err(crate::Error::InvalidCusum(_))
));
assert!(matches!(
Cusum::new(0.5, 0.0, 10),
Err(crate::Error::InvalidCusum(_))
));
assert!(matches!(
Cusum::new(0.5, f64::INFINITY, 10),
Err(crate::Error::InvalidCusum(_))
));
}
#[test]
fn welford_tracks_naive_mean_and_sample_variance() {
let xs = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
let mut cusum = Cusum::new(0.5, 4.0, 100).unwrap();
let mut seen: Vec<f64> = Vec::new();
for &x in &xs {
seen.push(x);
cusum.update(x);
let n = seen.len() as f64;
let mean = seen.iter().sum::<f64>() / n;
let var = if seen.len() >= 2 {
seen.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0)
} else {
0.0
};
close(cusum.mean(), mean, 1e-12);
close(cusum.variance(), var, 1e-12);
close(cusum.std(), var.sqrt(), 1e-12);
assert_eq!(cusum.count(), seen.len());
}
close(cusum.mean(), 5.0, 1e-12);
close(cusum.variance(), 32.0 / 7.0, 1e-12);
}
#[test]
fn constant_stream_never_alarms() {
let mut cusum = Cusum::default();
for _ in 0..50 {
assert!(!cusum.update(1.0));
}
assert_eq!(cusum.statistic(), 0.0);
assert_eq!(cusum.mean(), 1.0);
assert_eq!(cusum.variance(), 0.0);
assert!(!cusum.status());
}
#[test]
fn step_change_alarms_after_changepoint() {
let mut cusum = Cusum::default();
for _ in 0..10 {
assert!(!cusum.update(1.0), "baseline must stay quiet");
}
assert_eq!(cusum.statistic(), 0.0);
assert!(!cusum.update(2.0));
close(cusum.statistic(), 0.7583352368020273, 1e-12);
assert!(!cusum.update(2.0));
close(cusum.statistic(), 1.39704383409498, 1e-12);
assert!(cusum.update(2.0));
close(cusum.statistic(), 1.947010098498992, 1e-12);
assert!(cusum.status());
assert_eq!(cusum.count(), 13);
}
#[test]
fn reset_clears_state_but_keeps_tuning() {
let mut cusum = Cusum::default();
for _ in 0..10 {
cusum.update(1.0);
}
for _ in 0..10 {
cusum.update(2.0);
}
assert!(cusum.status());
cusum.reset();
assert_eq!(cusum.count(), 0);
assert_eq!(cusum.mean(), 0.0);
assert_eq!(cusum.variance(), 0.0);
assert_eq!(cusum.statistic(), 0.0);
assert!(!cusum.status());
assert_eq!(cusum, Cusum::default());
}
#[test]
fn non_finite_inputs_leave_state_untouched() {
let mut cusum = Cusum::default();
cusum.update(1.0);
let snapshot = cusum.clone();
assert_eq!(cusum.update(f64::NAN), snapshot.status());
assert_eq!(cusum.update(f64::INFINITY), snapshot.status());
assert_eq!(cusum, snapshot);
}
}