pub struct Cusum {
target: f64,
sigma: f64,
k: f64,
h: f64,
}
#[derive(Debug, Clone)]
pub struct CusumResult {
pub s_upper: f64,
pub s_lower: f64,
pub signal: bool,
pub index: usize,
}
impl Cusum {
pub fn new(target: f64, sigma: f64) -> Option<Self> {
Self::with_params(target, sigma, 0.5, 5.0)
}
pub fn with_params(target: f64, sigma: f64, k: f64, h: f64) -> Option<Self> {
if !target.is_finite() {
return None;
}
if !sigma.is_finite() || sigma <= 0.0 {
return None;
}
if !k.is_finite() || k < 0.0 {
return None;
}
if !h.is_finite() || h <= 0.0 {
return None;
}
Some(Self {
target,
sigma,
k,
h,
})
}
pub fn analyze(&self, data: &[f64]) -> Vec<CusumResult> {
let mut results = Vec::with_capacity(data.len());
let mut s_upper = 0.0_f64;
let mut s_lower = 0.0_f64;
for (i, &x) in data.iter().enumerate() {
if !x.is_finite() {
results.push(CusumResult {
s_upper,
s_lower,
signal: false,
index: i,
});
continue;
}
let z = (x - self.target) / self.sigma;
s_upper = (s_upper + z - self.k).max(0.0);
s_lower = (s_lower - z - self.k).max(0.0);
let signal = s_upper > self.h || s_lower > self.h;
results.push(CusumResult {
s_upper,
s_lower,
signal,
index: i,
});
}
results
}
pub fn signal_points(&self, data: &[f64]) -> Vec<usize> {
self.analyze(data)
.into_iter()
.filter(|r| r.signal)
.map(|r| r.index)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cusum_in_control_no_signals() {
let target = 10.0;
let sigma = 1.0;
let cusum = Cusum::new(target, sigma).expect("valid params");
let data: Vec<f64> = vec![target; 50];
let results = cusum.analyze(&data);
assert_eq!(results.len(), 50);
for r in &results {
assert!(
!r.signal,
"no signals expected for in-control data at index {}",
r.index
);
assert!(
r.s_upper.abs() < 1e-10,
"s_upper should be 0 when data == target"
);
assert!(
r.s_lower.abs() < 1e-10,
"s_lower should be 0 when data == target"
);
}
}
#[test]
fn test_cusum_in_control_with_noise() {
let target = 50.0;
let sigma = 2.0;
let cusum = Cusum::new(target, sigma).expect("valid params");
let data: Vec<f64> = (0..100)
.map(|i| {
if i % 2 == 0 {
target + 0.3 * sigma
} else {
target - 0.3 * sigma
}
})
.collect();
let signals = cusum.signal_points(&data);
assert!(
signals.is_empty(),
"symmetric noise of 0.3sigma should not trigger CUSUM signals"
);
}
#[test]
fn test_cusum_step_shift_detected() {
let target = 100.0;
let sigma = 5.0;
let cusum = Cusum::new(target, sigma).expect("valid params");
let mut data = vec![target; 50];
for x in data.iter_mut().skip(20) {
*x = target + 2.0 * sigma;
}
let signals = cusum.signal_points(&data);
assert!(
!signals.is_empty(),
"CUSUM should detect a 2-sigma step shift"
);
let first_signal = signals[0];
assert!(
first_signal >= 20,
"signal should not appear before the shift at index 20, got {}",
first_signal
);
assert!(
first_signal <= 30,
"first signal should appear soon after shift at index 20, got {}",
first_signal
);
}
#[test]
fn test_cusum_downward_shift_detected() {
let target = 50.0;
let sigma = 3.0;
let cusum = Cusum::new(target, sigma).expect("valid params");
let mut data = vec![target; 40];
for x in data.iter_mut().skip(15) {
*x = target - 2.0 * sigma;
}
let results = cusum.analyze(&data);
let signals: Vec<usize> = results
.iter()
.filter(|r| r.signal)
.map(|r| r.index)
.collect();
assert!(
!signals.is_empty(),
"CUSUM should detect a -2sigma downward shift"
);
let first_signal_result = results.iter().find(|r| r.signal).expect("signal exists");
assert!(
first_signal_result.s_lower > first_signal_result.s_upper,
"lower CUSUM should be larger for downward shift"
);
}
#[test]
fn test_cusum_custom_params() {
let cusum = Cusum::with_params(0.0, 1.0, 0.25, 4.0);
assert!(cusum.is_some(), "valid custom params should succeed");
let cusum = cusum.expect("valid params");
let mut data = vec![0.0; 30];
for x in data.iter_mut().skip(10) {
*x = 1.0; }
let signals = cusum.signal_points(&data);
assert!(
!signals.is_empty(),
"k=0.25, h=4.0 should detect a 1-sigma shift"
);
}
#[test]
fn test_cusum_known_arl_k05_h5() {
let cusum = Cusum::new(0.0, 1.0).expect("valid params");
let data: Vec<f64> = vec![1.0; 20];
let signals = cusum.signal_points(&data);
assert!(
!signals.is_empty(),
"1-sigma shift should trigger within 20 observations"
);
assert!(
signals[0] <= 15,
"first signal should appear within ~15 observations for ARL ~8, got {}",
signals[0]
);
}
#[test]
fn test_cusum_empty_data() {
let cusum = Cusum::new(0.0, 1.0).expect("valid params");
let results = cusum.analyze(&[]);
assert!(
results.is_empty(),
"empty data should produce empty results"
);
let signals = cusum.signal_points(&[]);
assert!(signals.is_empty(), "empty data should produce no signals");
}
#[test]
fn test_cusum_single_point() {
let cusum = Cusum::new(0.0, 1.0).expect("valid params");
let results = cusum.analyze(&[0.0]);
assert_eq!(results.len(), 1);
assert!(
!results[0].signal,
"single in-control point should not signal"
);
let results = cusum.analyze(&[100.0]);
assert_eq!(results.len(), 1);
assert!(results[0].signal, "extreme single point should signal");
}
#[test]
fn test_cusum_invalid_params() {
assert!(Cusum::new(0.0, 0.0).is_none());
assert!(Cusum::new(0.0, -1.0).is_none());
assert!(Cusum::new(0.0, f64::NAN).is_none());
assert!(Cusum::new(0.0, f64::INFINITY).is_none());
assert!(Cusum::new(f64::NAN, 1.0).is_none());
assert!(Cusum::new(f64::INFINITY, 1.0).is_none());
assert!(Cusum::with_params(0.0, 1.0, -0.1, 5.0).is_none());
assert!(Cusum::with_params(0.0, 1.0, 0.5, 0.0).is_none());
assert!(Cusum::with_params(0.0, 1.0, 0.5, -1.0).is_none());
}
#[test]
fn test_cusum_non_finite_data_skipped() {
let cusum = Cusum::new(0.0, 1.0).expect("valid params");
let data = [0.0, f64::NAN, 0.0, f64::INFINITY, 0.0];
let results = cusum.analyze(&data);
assert_eq!(results.len(), 5);
assert!(!results[1].signal);
assert!(!results[3].signal);
}
#[test]
fn test_cusum_numeric_reference_montgomery() {
let cusum = Cusum::with_params(0.0, 1.0, 0.5, 4.77).expect("valid params");
let data = [1.5f64; 5];
let results = cusum.analyze(&data);
assert!(
(results[0].s_upper - 1.0).abs() < 1e-10,
"C+_1 expected 1.0, got {}",
results[0].s_upper
);
assert!(
(results[1].s_upper - 2.0).abs() < 1e-10,
"C+_2 expected 2.0, got {}",
results[1].s_upper
);
assert!(
(results[2].s_upper - 3.0).abs() < 1e-10,
"C+_3 expected 3.0, got {}",
results[2].s_upper
);
assert!(
(results[3].s_upper - 4.0).abs() < 1e-10,
"C+_4 expected 4.0, got {}",
results[3].s_upper
);
assert!(
(results[4].s_upper - 5.0).abs() < 1e-10,
"C+_5 expected 5.0, got {}",
results[4].s_upper
);
assert!(!results[0].signal, "point 1 should not signal");
assert!(!results[1].signal, "point 2 should not signal");
assert!(!results[2].signal, "point 3 should not signal");
assert!(!results[3].signal, "point 4 should not signal");
assert!(results[4].signal, "point 5 should signal (5.0 > 4.77)");
for r in &results {
assert!(
r.s_lower.abs() < 1e-10,
"s_lower should be 0 with upward data, got {} at {}",
r.s_lower,
r.index
);
}
}
#[test]
fn test_cusum_s_upper_s_lower_non_negative() {
let cusum = Cusum::new(50.0, 5.0).expect("valid params");
let data: Vec<f64> = (0..100)
.map(|i| 50.0 + (i as f64 * 0.1).sin() * 3.0)
.collect();
let results = cusum.analyze(&data);
for r in &results {
assert!(
r.s_upper >= 0.0,
"s_upper must be non-negative at index {}",
r.index
);
assert!(
r.s_lower >= 0.0,
"s_lower must be non-negative at index {}",
r.index
);
}
}
}