fdars_core/spm/cusum.rs
1//! CUSUM (Cumulative Sum) monitoring for functional data.
2//!
3//! Provides both multivariate (Crosier's MCUSUM) and per-component
4//! univariate CUSUM charts operating on FPCA scores from an SPM chart.
5//! CUSUM charts are designed to detect small sustained shifts quickly,
6//! complementing T² (large shifts) and EWMA (small persistent shifts).
7//!
8//! CUSUM is optimal for detecting sustained shifts of known magnitude
9//! delta (set k ~ delta/2). For unknown shift sizes, consider adaptive
10//! EWMA ([`super::amewma::spm_amewma_monitor`]) which automatically
11//! adjusts sensitivity.
12//!
13//! # References
14//!
15//! - Page, E.S. (1954). Continuous inspection schemes. *Biometrika*,
16//! 41(1--2), 100--115 (original univariate CUSUM).
17//! - Crosier, R.B. (1988). Multivariate generalizations of cumulative sum
18//! quality-control schemes. *Technometrics*, 30(3), 291--303,
19//! section 2, Eq. 2.1 (MCUSUM shrinkage update), Table 1 (ARL values).
20
21use crate::error::FdarError;
22use crate::matrix::FdMatrix;
23
24use super::phase::{center_data, centered_reconstruct, SpmChart};
25use super::stats::spe_univariate;
26
27/// Configuration for CUSUM monitoring.
28#[derive(Debug, Clone, PartialEq)]
29pub struct CusumConfig {
30 /// CUSUM reference value (allowance parameter, default 0.5).
31 /// Controls the size of shifts the chart is designed to detect.
32 pub k: f64,
33 /// CUSUM decision interval (threshold, default 5.0).
34 pub h: f64,
35 /// Number of principal components (default 5).
36 pub ncomp: usize,
37 /// Significance level for fallback chi-squared limit (default 0.05).
38 pub alpha: f64,
39 /// Whether to use multivariate CUSUM (Crosier's MCUSUM) or
40 /// per-component univariate CUSUM (default: true = multivariate).
41 pub multivariate: bool,
42 /// Whether to restart the CUSUM accumulator after each alarm (default false).
43 /// When true, the accumulator resets to zero after crossing the threshold,
44 /// making the chart memoryless post-alarm. This improves sensitivity for
45 /// detecting subsequent shifts but loses information about the magnitude
46 /// of the current shift. When false (default), the accumulator remains
47 /// elevated after an alarm; all subsequent observations will also alarm
48 /// until the process returns to the in-control state and the accumulator
49 /// decays back below h.
50 pub restart: bool,
51}
52
53impl Default for CusumConfig {
54 fn default() -> Self {
55 Self {
56 k: 0.5,
57 h: 5.0,
58 ncomp: 5,
59 alpha: 0.05,
60 multivariate: true,
61 restart: false,
62 }
63 }
64}
65
66/// Result of CUSUM monitoring.
67#[derive(Debug, Clone, PartialEq)]
68#[non_exhaustive]
69pub struct CusumMonitorResult {
70 /// CUSUM statistics for each observation.
71 /// For multivariate: single MCUSUM statistic per obs.
72 /// For univariate: max of per-component CUSUMs per obs.
73 pub cusum_statistic: Vec<f64>,
74 /// Upper threshold (h or chi-squared UCL).
75 pub ucl: f64,
76 /// Alarm flags.
77 pub alarm: Vec<bool>,
78 /// Score matrix from FPCA projection (n × ncomp, standardized).
79 pub scores: FdMatrix,
80 /// Per-component CUSUM+ values (n × ncomp), only for univariate mode.
81 pub cusum_plus: Option<FdMatrix>,
82 /// Per-component CUSUM- values (n × ncomp), only for univariate mode.
83 pub cusum_minus: Option<FdMatrix>,
84 /// SPE values (reconstruction error).
85 pub spe: Vec<f64>,
86 /// SPE control limit.
87 pub spe_limit: f64,
88 /// SPE alarm flags.
89 pub spe_alarm: Vec<bool>,
90}
91
92/// Validate CUSUM configuration parameters.
93fn validate_config(config: &CusumConfig) -> Result<(), FdarError> {
94 if config.k <= 0.0 {
95 return Err(FdarError::InvalidParameter {
96 parameter: "k",
97 message: format!("k must be positive, got {}", config.k),
98 });
99 }
100 if config.h <= 0.0 {
101 return Err(FdarError::InvalidParameter {
102 parameter: "h",
103 message: format!("h must be positive, got {}", config.h),
104 });
105 }
106 Ok(())
107}
108
109/// Validate dimensions of sequential data against the chart.
110fn validate_dimensions(chart: &SpmChart, sequential_data: &FdMatrix) -> Result<(), FdarError> {
111 let m = chart.fpca.mean.len();
112 if sequential_data.ncols() != m {
113 return Err(FdarError::InvalidDimension {
114 parameter: "sequential_data",
115 expected: format!("{m} columns"),
116 actual: format!("{} columns", sequential_data.ncols()),
117 });
118 }
119 Ok(())
120}
121
122/// Project data and standardize scores by eigenvalue square roots.
123///
124/// Returns the truncated, standardized score matrix (n × ncomp) and the
125/// actual number of components used.
126fn project_and_standardize(
127 chart: &SpmChart,
128 sequential_data: &FdMatrix,
129 ncomp_requested: usize,
130) -> Result<(FdMatrix, usize), FdarError> {
131 let all_scores = chart.fpca.project(sequential_data)?;
132 let ncomp = chart.eigenvalues.len().min(ncomp_requested);
133 let n = sequential_data.nrows();
134
135 let mut z = FdMatrix::zeros(n, ncomp);
136 for i in 0..n {
137 for l in 0..ncomp {
138 z[(i, l)] = all_scores[(i, l)] / chart.eigenvalues[l].sqrt();
139 }
140 }
141 Ok((z, ncomp))
142}
143
144/// Compute multivariate CUSUM (Crosier's MCUSUM) statistics.
145///
146/// Implements the MCUSUM update from Crosier (1988, section 2, Eq. 2.1):
147/// S_new = S + z_i
148/// if ||S_new|| > k: S = (1 - k/||S_new||) · S_new (shrink toward origin)
149/// else: S = 0 (reset)
150/// C_i = ||S||
151///
152/// Returns `(cusum_statistic, alarm)`. When `restart` is true, the
153/// cumulative sum vector S is reset to zero after each alarm, making
154/// the chart sensitive to subsequent shifts. Without restart, S remains
155/// elevated after the first alarm, and subsequent observations continue
156/// to accumulate.
157fn mcusum_core(z: &FdMatrix, ncomp: usize, k: f64, h: f64, restart: bool) -> (Vec<f64>, Vec<bool>) {
158 let n = z.nrows();
159 let mut s = vec![0.0; ncomp];
160 let mut cusum_statistic = Vec::with_capacity(n);
161 let mut alarm = Vec::with_capacity(n);
162
163 for i in 0..n {
164 // S_new = S + z_i
165 for l in 0..ncomp {
166 s[l] += z[(i, l)];
167 }
168
169 // C = ||S_new||
170 let c: f64 = s.iter().map(|&v| v * v).sum::<f64>().sqrt();
171
172 if c > k {
173 // Shrink toward origin by k
174 let scale = (c - k) / c;
175 for l in 0..ncomp {
176 s[l] *= scale;
177 }
178 } else {
179 // Reset to zero
180 s.fill(0.0);
181 }
182
183 let stat = s.iter().map(|&v| v * v).sum::<f64>().sqrt();
184 let is_alarm = stat > h;
185 cusum_statistic.push(stat);
186 alarm.push(is_alarm);
187
188 if restart && is_alarm {
189 s.fill(0.0);
190 }
191 }
192
193 (cusum_statistic, alarm)
194}
195
196/// Compute per-component univariate CUSUM statistics (Page, 1954, pp. 102--103).
197///
198/// For each component l and observation i:
199/// C+_{i,l} = max(0, C+_{i-1,l} + z_{i,l} - k) (upward shift)
200/// C-_{i,l} = max(0, C-_{i-1,l} - z_{i,l} - k) (downward shift)
201///
202/// The overall statistic is max over all components and directions.
203///
204/// Returns `(cusum_statistic, alarm, cusum_plus_mat, cusum_minus_mat)`.
205/// When `restart` is true, the per-component accumulators C+ and C- are
206/// reset to zero after each alarm.
207fn univariate_cusum_core(
208 z: &FdMatrix,
209 ncomp: usize,
210 k: f64,
211 h: f64,
212 restart: bool,
213) -> (Vec<f64>, Vec<bool>, FdMatrix, FdMatrix) {
214 let n = z.nrows();
215 let mut cp = vec![0.0; ncomp];
216 let mut cm = vec![0.0; ncomp];
217 let mut cusum_plus_mat = FdMatrix::zeros(n, ncomp);
218 let mut cusum_minus_mat = FdMatrix::zeros(n, ncomp);
219 let mut cusum_statistic = Vec::with_capacity(n);
220 let mut alarm = Vec::with_capacity(n);
221
222 for i in 0..n {
223 for l in 0..ncomp {
224 cp[l] = (cp[l] + z[(i, l)] - k).max(0.0);
225 cm[l] = (cm[l] - z[(i, l)] - k).max(0.0);
226 cusum_plus_mat[(i, l)] = cp[l];
227 cusum_minus_mat[(i, l)] = cm[l];
228 }
229
230 let mut max_val = 0.0_f64;
231 for l in 0..ncomp {
232 max_val = max_val.max(cp[l]).max(cm[l]);
233 }
234
235 let is_alarm = max_val > h;
236 cusum_statistic.push(max_val);
237 alarm.push(is_alarm);
238
239 if restart && is_alarm {
240 for l in 0..ncomp {
241 cp[l] = 0.0;
242 cm[l] = 0.0;
243 }
244 }
245 }
246
247 (cusum_statistic, alarm, cusum_plus_mat, cusum_minus_mat)
248}
249
250/// Run CUSUM monitoring on sequential functional data.
251///
252/// 1. Projects each observation through the chart's FPCA
253/// 2. Standardizes scores by eigenvalue square roots (unit variance)
254/// 3. Computes either multivariate (Crosier's MCUSUM) or per-component
255/// univariate CUSUM statistics
256/// 4. Flags alarms where the statistic exceeds the threshold `h`
257///
258/// # Parameter guidance
259///
260/// Common (k, h) pairs for MCUSUM monitoring and their approximate
261/// ARL_0 (Crosier, 1988, Table 1, p. 296):
262/// - k=0.5, h=5.0: ARL_0 ~ 370 (standard choice for detecting 1-sigma shifts)
263/// - k=0.25, h=8.0: ARL_0 ~ 370 (better for smaller shifts, ~0.5 sigma)
264/// - k=1.0, h=4.0: ARL_0 ~ 370 (faster response to larger shifts, ~2 sigma)
265///
266/// Edge cases: k = 0 makes the CUSUM equivalent to a cumulative sum (high
267/// sensitivity but high false alarm rate). Very large h reduces false
268/// alarms but delays detection. Typical starting point: k = 0.5 (detects
269/// 1-sigma shifts), h = 4-5.
270///
271/// # Arguments
272/// * `chart` - Phase I SPM chart
273/// * `sequential_data` - Sequential functional data (n × m), rows in time order
274/// * `argvals` - Grid points (length m), reserved for future use
275/// * `config` - CUSUM configuration
276///
277/// # Example
278///
279/// ```
280/// use fdars_core::matrix::FdMatrix;
281/// use fdars_core::spm::phase::{spm_phase1, SpmConfig};
282/// use fdars_core::spm::cusum::{spm_cusum_monitor, CusumConfig};
283/// // Build tiny Phase I chart
284/// let data = FdMatrix::from_column_major(
285/// (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
286/// ).unwrap();
287/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
288/// let chart = spm_phase1(&data, &argvals, &SpmConfig { ncomp: 2, ..SpmConfig::default() }).unwrap();
289/// let new_data = FdMatrix::from_column_major(
290/// (0..50).map(|i| (i as f64 * 0.1).sin()).collect(), 5, 10
291/// ).unwrap();
292/// let result = spm_cusum_monitor(&chart, &new_data, &argvals, &CusumConfig::default()).unwrap();
293/// assert_eq!(result.cusum_statistic.len(), 5);
294/// ```
295///
296/// # Errors
297///
298/// Returns [`FdarError::InvalidDimension`] if data columns do not match the chart.
299/// Returns [`FdarError::InvalidParameter`] if `k` or `h` are non-positive.
300#[must_use = "monitoring result should not be discarded"]
301pub fn spm_cusum_monitor(
302 chart: &SpmChart,
303 sequential_data: &FdMatrix,
304 argvals: &[f64],
305 config: &CusumConfig,
306) -> Result<CusumMonitorResult, FdarError> {
307 validate_config(config)?;
308 validate_dimensions(chart, sequential_data)?;
309
310 let (z, ncomp) = project_and_standardize(chart, sequential_data, config.ncomp)?;
311
312 // SPE from reconstruction error (un-standardize scores for reconstruction)
313 let n = sequential_data.nrows();
314 let mut raw_scores = FdMatrix::zeros(n, ncomp);
315 for i in 0..n {
316 for l in 0..ncomp {
317 raw_scores[(i, l)] = z[(i, l)] * chart.eigenvalues[l].sqrt();
318 }
319 }
320 let centered = center_data(sequential_data, &chart.fpca.mean);
321 let recon_centered = centered_reconstruct(&chart.fpca, &raw_scores, ncomp);
322 let spe = spe_univariate(¢ered, &recon_centered, argvals)?;
323 let spe_limit = chart.spe_limit.ucl;
324 let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > spe_limit).collect();
325
326 if config.multivariate {
327 let (cusum_statistic, alarm) = mcusum_core(&z, ncomp, config.k, config.h, config.restart);
328 Ok(CusumMonitorResult {
329 cusum_statistic,
330 ucl: config.h,
331 alarm,
332 scores: z,
333 cusum_plus: None,
334 cusum_minus: None,
335 spe,
336 spe_limit,
337 spe_alarm,
338 })
339 } else {
340 let (cusum_statistic, alarm, cusum_plus_mat, cusum_minus_mat) =
341 univariate_cusum_core(&z, ncomp, config.k, config.h, config.restart);
342 Ok(CusumMonitorResult {
343 cusum_statistic,
344 ucl: config.h,
345 alarm,
346 scores: z,
347 cusum_plus: Some(cusum_plus_mat),
348 cusum_minus: Some(cusum_minus_mat),
349 spe,
350 spe_limit,
351 spe_alarm,
352 })
353 }
354}
355
356/// Run CUSUM monitoring with automatic restart after each alarm.
357///
358/// Identical to [`spm_cusum_monitor`] but resets the cumulative sum
359/// accumulators to zero after each alarm. This prevents a single large
360/// shift from producing a long streak of consecutive alarms.
361///
362/// After an alarm, the CUSUM accumulator resets to zero, making the chart
363/// sensitive to subsequent shifts. Without restart, the accumulator remains
364/// elevated after the first alarm, potentially masking new events. Use
365/// restart when monitoring for intermittent faults.
366///
367/// # Arguments
368/// * `chart` - Phase I SPM chart
369/// * `sequential_data` - Sequential functional data (n × m), rows in time order
370/// * `argvals` - Grid points (length m), reserved for future use
371/// * `config` - CUSUM configuration
372///
373/// # Errors
374///
375/// Returns [`FdarError::InvalidDimension`] if data columns do not match the chart.
376/// Returns [`FdarError::InvalidParameter`] if `k` or `h` are non-positive.
377#[must_use = "monitoring result should not be discarded"]
378pub fn spm_cusum_monitor_with_restart(
379 chart: &SpmChart,
380 sequential_data: &FdMatrix,
381 argvals: &[f64],
382 config: &CusumConfig,
383) -> Result<CusumMonitorResult, FdarError> {
384 validate_config(config)?;
385 validate_dimensions(chart, sequential_data)?;
386
387 let (z, ncomp) = project_and_standardize(chart, sequential_data, config.ncomp)?;
388
389 // SPE from reconstruction error
390 let n = sequential_data.nrows();
391 let mut raw_scores = FdMatrix::zeros(n, ncomp);
392 for i in 0..n {
393 for l in 0..ncomp {
394 raw_scores[(i, l)] = z[(i, l)] * chart.eigenvalues[l].sqrt();
395 }
396 }
397 let centered = center_data(sequential_data, &chart.fpca.mean);
398 let recon_centered = centered_reconstruct(&chart.fpca, &raw_scores, ncomp);
399 let spe = spe_univariate(¢ered, &recon_centered, argvals)?;
400 let spe_limit = chart.spe_limit.ucl;
401 let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > spe_limit).collect();
402
403 if config.multivariate {
404 let (cusum_statistic, alarm) = mcusum_core(&z, ncomp, config.k, config.h, true);
405 Ok(CusumMonitorResult {
406 cusum_statistic,
407 ucl: config.h,
408 alarm,
409 scores: z,
410 cusum_plus: None,
411 cusum_minus: None,
412 spe,
413 spe_limit,
414 spe_alarm,
415 })
416 } else {
417 let (cusum_statistic, alarm, cusum_plus_mat, cusum_minus_mat) =
418 univariate_cusum_core(&z, ncomp, config.k, config.h, true);
419 Ok(CusumMonitorResult {
420 cusum_statistic,
421 ucl: config.h,
422 alarm,
423 scores: z,
424 cusum_plus: Some(cusum_plus_mat),
425 cusum_minus: Some(cusum_minus_mat),
426 spe,
427 spe_limit,
428 spe_alarm,
429 })
430 }
431}