u_analytics/detection/cusum.rs
1//! Cumulative Sum (CUSUM) chart for detecting small persistent shifts in process mean.
2//!
3//! # Algorithm
4//!
5//! Given observations x_1, ..., x_n with known process mean mu_0 and standard
6//! deviation sigma, the standardized values are:
7//!
8//! ```text
9//! z_i = (x_i - mu_0) / sigma
10//! ```
11//!
12//! The upper and lower CUSUM statistics are:
13//!
14//! ```text
15//! S_H(i) = max(0, S_H(i-1) + z_i - k)
16//! S_L(i) = max(0, S_L(i-1) - z_i - k)
17//! ```
18//!
19//! A signal is generated when `S_H(i) > h` or `S_L(i) > h`.
20//!
21//! # Parameters
22//!
23//! - **k**: reference value (allowance), typically 0.5 (designed to detect a 1-sigma shift)
24//! - **h**: decision interval, typically 4 or 5
25//!
26//! # Reference
27//!
28//! Page, E.S. (1954). "Continuous inspection schemes", *Biometrika* 41(1-2), pp. 100-115.
29
30/// CUSUM chart parameters and state.
31///
32/// Implements the tabular (two-sided) CUSUM procedure for detecting
33/// both upward and downward shifts in a process mean.
34///
35/// # Examples
36///
37/// ```
38/// use u_analytics::detection::Cusum;
39///
40/// let cusum = Cusum::new(10.0, 1.0).unwrap();
41/// // In-control data
42/// let data = [10.1, 9.8, 10.2, 9.9, 10.0, 10.1, 9.7, 10.3];
43/// let results = cusum.analyze(&data);
44/// assert_eq!(results.len(), data.len());
45/// assert!(cusum.signal_points(&data).is_empty());
46/// ```
47///
48/// # Reference
49///
50/// Page, E.S. (1954). "Continuous inspection schemes", *Biometrika* 41(1-2).
51pub struct Cusum {
52 /// Target process mean (mu_0).
53 target: f64,
54 /// Known process standard deviation (sigma).
55 sigma: f64,
56 /// Reference value (allowance), default 0.5.
57 k: f64,
58 /// Decision interval, default 5.0.
59 h: f64,
60}
61
62/// Result of CUSUM analysis for a single observation.
63#[derive(Debug, Clone)]
64pub struct CusumResult {
65 /// Upper cumulative sum S_H(i).
66 pub s_upper: f64,
67 /// Lower cumulative sum S_L(i).
68 pub s_lower: f64,
69 /// Whether the decision interval was exceeded (S_H > h or S_L > h).
70 pub signal: bool,
71 /// Index of this observation in the data sequence.
72 pub index: usize,
73}
74
75impl Cusum {
76 /// Creates a new CUSUM chart with the given target mean and standard deviation.
77 ///
78 /// Uses default parameters k=0.5 (detects 1-sigma shift) and h=5.0.
79 ///
80 /// # Returns
81 ///
82 /// `None` if `sigma` is not positive or finite, or if `target` is not finite.
83 ///
84 /// # Reference
85 ///
86 /// Page (1954), *Biometrika* 41(1-2). Default k=0.5 is optimal for detecting
87 /// a shift of 1 sigma; h=5 gives ARL_0 ~ 465.
88 pub fn new(target: f64, sigma: f64) -> Option<Self> {
89 Self::with_params(target, sigma, 0.5, 5.0)
90 }
91
92 /// Creates a CUSUM chart with custom k and h parameters.
93 ///
94 /// # Parameters
95 ///
96 /// - `target`: Process target mean (mu_0)
97 /// - `sigma`: Process standard deviation (must be positive and finite)
98 /// - `k`: Reference value / allowance (must be non-negative and finite)
99 /// - `h`: Decision interval (must be positive and finite)
100 ///
101 /// # Returns
102 ///
103 /// `None` if any parameter is invalid.
104 pub fn with_params(target: f64, sigma: f64, k: f64, h: f64) -> Option<Self> {
105 if !target.is_finite() {
106 return None;
107 }
108 if !sigma.is_finite() || sigma <= 0.0 {
109 return None;
110 }
111 if !k.is_finite() || k < 0.0 {
112 return None;
113 }
114 if !h.is_finite() || h <= 0.0 {
115 return None;
116 }
117 Some(Self {
118 target,
119 sigma,
120 k,
121 h,
122 })
123 }
124
125 /// Analyzes a sequence of observations and returns CUSUM statistics for each point.
126 ///
127 /// The upper CUSUM detects upward shifts; the lower CUSUM detects downward shifts.
128 /// Both are initialized to zero. Non-finite values in the data are skipped
129 /// (their index is still consumed).
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use u_analytics::detection::Cusum;
135 ///
136 /// let cusum = Cusum::new(10.0, 1.0).unwrap();
137 /// // Data with upward shift
138 /// let mut data: Vec<f64> = vec![10.0; 10];
139 /// data.extend(vec![12.0; 10]); // shift of 2 sigma
140 /// let signals = cusum.signal_points(&data);
141 /// assert!(!signals.is_empty()); // shift detected
142 /// ```
143 ///
144 /// # Complexity
145 ///
146 /// Time: O(n), Space: O(n)
147 pub fn analyze(&self, data: &[f64]) -> Vec<CusumResult> {
148 let mut results = Vec::with_capacity(data.len());
149 let mut s_upper = 0.0_f64;
150 let mut s_lower = 0.0_f64;
151
152 for (i, &x) in data.iter().enumerate() {
153 if !x.is_finite() {
154 // Non-finite observations: carry forward previous CUSUM values, no signal.
155 results.push(CusumResult {
156 s_upper,
157 s_lower,
158 signal: false,
159 index: i,
160 });
161 continue;
162 }
163
164 let z = (x - self.target) / self.sigma;
165 s_upper = (s_upper + z - self.k).max(0.0);
166 s_lower = (s_lower - z - self.k).max(0.0);
167
168 let signal = s_upper > self.h || s_lower > self.h;
169
170 results.push(CusumResult {
171 s_upper,
172 s_lower,
173 signal,
174 index: i,
175 });
176 }
177
178 results
179 }
180
181 /// Returns the indices of observations where a CUSUM signal occurred.
182 ///
183 /// A signal occurs when the upper or lower cumulative sum exceeds the
184 /// decision interval h.
185 ///
186 /// # Complexity
187 ///
188 /// Time: O(n), Space: O(k) where k is the number of signal points
189 pub fn signal_points(&self, data: &[f64]) -> Vec<usize> {
190 self.analyze(data)
191 .into_iter()
192 .filter(|r| r.signal)
193 .map(|r| r.index)
194 .collect()
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_cusum_in_control_no_signals() {
204 // Data centered around target with small noise — should produce no signals.
205 let target = 10.0;
206 let sigma = 1.0;
207 let cusum = Cusum::new(target, sigma).expect("valid params");
208
209 // 50 points right at the target: z_i = 0 for all i
210 let data: Vec<f64> = vec![target; 50];
211 let results = cusum.analyze(&data);
212
213 assert_eq!(results.len(), 50);
214 for r in &results {
215 assert!(
216 !r.signal,
217 "no signals expected for in-control data at index {}",
218 r.index
219 );
220 assert!(
221 r.s_upper.abs() < 1e-10,
222 "s_upper should be 0 when data == target"
223 );
224 assert!(
225 r.s_lower.abs() < 1e-10,
226 "s_lower should be 0 when data == target"
227 );
228 }
229 }
230
231 #[test]
232 fn test_cusum_in_control_with_noise() {
233 // Small symmetric deviations should not trigger signals with h=5.
234 let target = 50.0;
235 let sigma = 2.0;
236 let cusum = Cusum::new(target, sigma).expect("valid params");
237
238 // Alternating +0.3sigma and -0.3sigma around target
239 let data: Vec<f64> = (0..100)
240 .map(|i| {
241 if i % 2 == 0 {
242 target + 0.3 * sigma
243 } else {
244 target - 0.3 * sigma
245 }
246 })
247 .collect();
248
249 let signals = cusum.signal_points(&data);
250 assert!(
251 signals.is_empty(),
252 "symmetric noise of 0.3sigma should not trigger CUSUM signals"
253 );
254 }
255
256 #[test]
257 fn test_cusum_step_shift_detected() {
258 // Step shift of +2sigma at index 20.
259 let target = 100.0;
260 let sigma = 5.0;
261 let cusum = Cusum::new(target, sigma).expect("valid params");
262
263 let mut data = vec![target; 50];
264 // Introduce a +2sigma shift starting at index 20
265 for x in data.iter_mut().skip(20) {
266 *x = target + 2.0 * sigma;
267 }
268
269 let signals = cusum.signal_points(&data);
270 assert!(
271 !signals.is_empty(),
272 "CUSUM should detect a 2-sigma step shift"
273 );
274
275 // First signal should occur near the shift point (within a few samples)
276 let first_signal = signals[0];
277 assert!(
278 first_signal >= 20,
279 "signal should not appear before the shift at index 20, got {}",
280 first_signal
281 );
282 assert!(
283 first_signal <= 30,
284 "first signal should appear soon after shift at index 20, got {}",
285 first_signal
286 );
287 }
288
289 #[test]
290 fn test_cusum_downward_shift_detected() {
291 // Step shift of -2sigma at index 15.
292 let target = 50.0;
293 let sigma = 3.0;
294 let cusum = Cusum::new(target, sigma).expect("valid params");
295
296 let mut data = vec![target; 40];
297 for x in data.iter_mut().skip(15) {
298 *x = target - 2.0 * sigma;
299 }
300
301 let results = cusum.analyze(&data);
302 let signals: Vec<usize> = results
303 .iter()
304 .filter(|r| r.signal)
305 .map(|r| r.index)
306 .collect();
307 assert!(
308 !signals.is_empty(),
309 "CUSUM should detect a -2sigma downward shift"
310 );
311
312 // Check that the lower CUSUM accumulated (not the upper)
313 let first_signal_result = results.iter().find(|r| r.signal).expect("signal exists");
314 assert!(
315 first_signal_result.s_lower > first_signal_result.s_upper,
316 "lower CUSUM should be larger for downward shift"
317 );
318 }
319
320 #[test]
321 fn test_cusum_custom_params() {
322 let cusum = Cusum::with_params(0.0, 1.0, 0.25, 4.0);
323 assert!(cusum.is_some(), "valid custom params should succeed");
324
325 let cusum = cusum.expect("valid params");
326 // k=0.25 is more sensitive, h=4.0 is a tighter threshold
327 // A 1-sigma shift should be detected faster than with default params
328 let mut data = vec![0.0; 30];
329 for x in data.iter_mut().skip(10) {
330 *x = 1.0; // 1-sigma shift
331 }
332
333 let signals = cusum.signal_points(&data);
334 assert!(
335 !signals.is_empty(),
336 "k=0.25, h=4.0 should detect a 1-sigma shift"
337 );
338 }
339
340 #[test]
341 fn test_cusum_known_arl_k05_h5() {
342 // With k=0.5 and h=5, a 1-sigma shift should have ARL ~ 8-10.
343 // We verify that a sustained 1-sigma shift triggers within ~15 points.
344 let cusum = Cusum::new(0.0, 1.0).expect("valid params");
345
346 // All data points shifted by +1 sigma
347 let data: Vec<f64> = vec![1.0; 20];
348 let signals = cusum.signal_points(&data);
349
350 assert!(
351 !signals.is_empty(),
352 "1-sigma shift should trigger within 20 observations"
353 );
354 // The first signal should appear within about 15 observations
355 // (theoretical ARL ~ 8.38 for k=0.5, h=5, delta=1)
356 assert!(
357 signals[0] <= 15,
358 "first signal should appear within ~15 observations for ARL ~8, got {}",
359 signals[0]
360 );
361 }
362
363 #[test]
364 fn test_cusum_empty_data() {
365 let cusum = Cusum::new(0.0, 1.0).expect("valid params");
366 let results = cusum.analyze(&[]);
367 assert!(
368 results.is_empty(),
369 "empty data should produce empty results"
370 );
371
372 let signals = cusum.signal_points(&[]);
373 assert!(signals.is_empty(), "empty data should produce no signals");
374 }
375
376 #[test]
377 fn test_cusum_single_point() {
378 let cusum = Cusum::new(0.0, 1.0).expect("valid params");
379
380 let results = cusum.analyze(&[0.0]);
381 assert_eq!(results.len(), 1);
382 assert!(
383 !results[0].signal,
384 "single in-control point should not signal"
385 );
386
387 let results = cusum.analyze(&[100.0]);
388 assert_eq!(results.len(), 1);
389 // z = 100, s_upper = 100 - 0.5 = 99.5 > 5 → signal
390 assert!(results[0].signal, "extreme single point should signal");
391 }
392
393 #[test]
394 fn test_cusum_invalid_params() {
395 // sigma must be positive
396 assert!(Cusum::new(0.0, 0.0).is_none());
397 assert!(Cusum::new(0.0, -1.0).is_none());
398 assert!(Cusum::new(0.0, f64::NAN).is_none());
399 assert!(Cusum::new(0.0, f64::INFINITY).is_none());
400
401 // target must be finite
402 assert!(Cusum::new(f64::NAN, 1.0).is_none());
403 assert!(Cusum::new(f64::INFINITY, 1.0).is_none());
404
405 // k must be non-negative
406 assert!(Cusum::with_params(0.0, 1.0, -0.1, 5.0).is_none());
407
408 // h must be positive
409 assert!(Cusum::with_params(0.0, 1.0, 0.5, 0.0).is_none());
410 assert!(Cusum::with_params(0.0, 1.0, 0.5, -1.0).is_none());
411 }
412
413 #[test]
414 fn test_cusum_non_finite_data_skipped() {
415 let cusum = Cusum::new(0.0, 1.0).expect("valid params");
416 let data = [0.0, f64::NAN, 0.0, f64::INFINITY, 0.0];
417 let results = cusum.analyze(&data);
418 assert_eq!(results.len(), 5);
419 // Non-finite points should not produce signals
420 assert!(!results[1].signal);
421 assert!(!results[3].signal);
422 }
423
424 #[test]
425 fn test_cusum_s_upper_s_lower_non_negative() {
426 // CUSUM statistics should always be >= 0 by construction.
427 let cusum = Cusum::new(50.0, 5.0).expect("valid params");
428 let data: Vec<f64> = (0..100)
429 .map(|i| 50.0 + (i as f64 * 0.1).sin() * 3.0)
430 .collect();
431 let results = cusum.analyze(&data);
432 for r in &results {
433 assert!(
434 r.s_upper >= 0.0,
435 "s_upper must be non-negative at index {}",
436 r.index
437 );
438 assert!(
439 r.s_lower >= 0.0,
440 "s_lower must be non-negative at index {}",
441 r.index
442 );
443 }
444 }
445}