1#[derive(Debug, Clone, PartialEq)]
29pub struct Cusum {
30 ref_shift_k: f64,
31 alarm_h: f64,
32 startup: usize,
33 count: usize,
34 mean: f64,
35 m2: f64,
36 statistic: f64,
37 alarmed: bool,
38}
39
40impl Cusum {
41 pub fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> crate::Result<Self> {
44 if !ref_shift_k.is_finite() || ref_shift_k < 0.0 {
45 return Err(crate::Error::InvalidCusum(format!(
46 "reference shift k must be finite and >= 0, got {ref_shift_k}"
47 )));
48 }
49 if !alarm_h.is_finite() || alarm_h <= 0.0 {
50 return Err(crate::Error::InvalidCusum(format!(
51 "alarm threshold h must be finite and > 0, got {alarm_h}"
52 )));
53 }
54 Ok(Self {
55 ref_shift_k,
56 alarm_h,
57 startup,
58 count: 0,
59 mean: 0.0,
60 m2: 0.0,
61 statistic: 0.0,
62 alarmed: false,
63 })
64 }
65
66 pub fn count(&self) -> usize {
68 self.count
69 }
70
71 pub fn mean(&self) -> f64 {
73 self.mean
74 }
75
76 pub fn variance(&self) -> f64 {
78 if self.count >= 2 {
79 self.m2 / (self.count as f64 - 1.0)
80 } else {
81 0.0
82 }
83 }
84
85 pub fn std(&self) -> f64 {
87 self.variance().sqrt()
88 }
89
90 pub fn statistic(&self) -> f64 {
92 self.statistic
93 }
94
95 pub fn status(&self) -> bool {
100 self.alarmed
101 }
102
103 pub fn update(&mut self, x: f64) -> bool {
108 if !x.is_finite() {
109 return self.alarmed;
110 }
111 self.count += 1;
112 let n = self.count as f64;
113 let old_mean = self.mean;
114 self.mean += (x - old_mean) / n;
115 self.m2 += (x - old_mean) * (x - self.mean);
116 let std = self.std();
117 let shifted = x - self.mean - self.ref_shift_k * std;
118 self.statistic = (self.statistic + shifted).max(0.0);
119 self.alarmed = self.count > self.startup && self.statistic > self.alarm_h * std;
120 self.alarmed
121 }
122
123 pub fn reset(&mut self) {
125 self.count = 0;
126 self.mean = 0.0;
127 self.m2 = 0.0;
128 self.statistic = 0.0;
129 self.alarmed = false;
130 }
131}
132
133impl Default for Cusum {
134 fn default() -> Self {
136 Self {
137 ref_shift_k: 0.5,
138 alarm_h: 4.0,
139 startup: 10,
140 count: 0,
141 mean: 0.0,
142 m2: 0.0,
143 statistic: 0.0,
144 alarmed: false,
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn close(a: f64, b: f64, tol: f64) {
154 assert!(
155 (a - b).abs() <= tol,
156 "{a} != {b} within absolute tolerance {tol}"
157 );
158 }
159
160 #[test]
161 fn default_tuning_matches_canonical_values() {
162 let cusum = Cusum::default();
163 assert_eq!(cusum.ref_shift_k, 0.5);
164 assert_eq!(cusum.alarm_h, 4.0);
165 assert_eq!(cusum.startup, 10);
166 assert_eq!(cusum.count(), 0);
167 assert_eq!(cusum.statistic(), 0.0);
168 assert!(!cusum.status());
169 assert_eq!(Cusum::new(0.5, 4.0, 10).unwrap(), cusum);
171 }
172
173 #[test]
174 fn invalid_parameters_rejected() {
175 assert!(matches!(
176 Cusum::new(f64::NAN, 4.0, 10),
177 Err(crate::Error::InvalidCusum(_))
178 ));
179 assert!(matches!(
180 Cusum::new(-0.1, 4.0, 10),
181 Err(crate::Error::InvalidCusum(_))
182 ));
183 assert!(matches!(
184 Cusum::new(0.5, 0.0, 10),
185 Err(crate::Error::InvalidCusum(_))
186 ));
187 assert!(matches!(
188 Cusum::new(0.5, f64::INFINITY, 10),
189 Err(crate::Error::InvalidCusum(_))
190 ));
191 }
192
193 #[test]
194 fn welford_tracks_naive_mean_and_sample_variance() {
195 let xs = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
197 let mut cusum = Cusum::new(0.5, 4.0, 100).unwrap();
198 let mut seen: Vec<f64> = Vec::new();
199 for &x in &xs {
200 seen.push(x);
201 cusum.update(x);
202 let n = seen.len() as f64;
203 let mean = seen.iter().sum::<f64>() / n;
204 let var = if seen.len() >= 2 {
205 seen.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0)
206 } else {
207 0.0
208 };
209 close(cusum.mean(), mean, 1e-12);
210 close(cusum.variance(), var, 1e-12);
211 close(cusum.std(), var.sqrt(), 1e-12);
212 assert_eq!(cusum.count(), seen.len());
213 }
214 close(cusum.mean(), 5.0, 1e-12);
217 close(cusum.variance(), 32.0 / 7.0, 1e-12);
218 }
219
220 #[test]
221 fn constant_stream_never_alarms() {
222 let mut cusum = Cusum::default();
223 for _ in 0..50 {
224 assert!(!cusum.update(1.0));
225 }
226 assert_eq!(cusum.statistic(), 0.0);
227 assert_eq!(cusum.mean(), 1.0);
228 assert_eq!(cusum.variance(), 0.0);
229 assert!(!cusum.status());
230 }
231
232 #[test]
233 fn step_change_alarms_after_changepoint() {
234 let mut cusum = Cusum::default();
236 for _ in 0..10 {
237 assert!(!cusum.update(1.0), "baseline must stay quiet");
238 }
239 assert_eq!(cusum.statistic(), 0.0);
240 assert!(!cusum.update(2.0));
246 close(cusum.statistic(), 0.7583352368020273, 1e-12);
247 assert!(!cusum.update(2.0));
248 close(cusum.statistic(), 1.39704383409498, 1e-12);
249 assert!(cusum.update(2.0));
252 close(cusum.statistic(), 1.947010098498992, 1e-12);
253 assert!(cusum.status());
254 assert_eq!(cusum.count(), 13);
255 }
256
257 #[test]
258 fn reset_clears_state_but_keeps_tuning() {
259 let mut cusum = Cusum::default();
260 for _ in 0..10 {
261 cusum.update(1.0);
262 }
263 for _ in 0..10 {
264 cusum.update(2.0);
265 }
266 assert!(cusum.status());
267 cusum.reset();
268 assert_eq!(cusum.count(), 0);
269 assert_eq!(cusum.mean(), 0.0);
270 assert_eq!(cusum.variance(), 0.0);
271 assert_eq!(cusum.statistic(), 0.0);
272 assert!(!cusum.status());
273 assert_eq!(cusum, Cusum::default());
274 }
275
276 #[test]
277 fn non_finite_inputs_leave_state_untouched() {
278 let mut cusum = Cusum::default();
279 cusum.update(1.0);
280 let snapshot = cusum.clone();
281 assert_eq!(cusum.update(f64::NAN), snapshot.status());
282 assert_eq!(cusum.update(f64::INFINITY), snapshot.status());
283 assert_eq!(cusum, snapshot);
284 }
285}