Skip to main content

fdars_core/spm/
control.rs

1//! Control limits for SPM monitoring statistics.
2//!
3//! Provides upper control limits (UCL) for T-squared and SPE statistics:
4//! - **T-squared**: chi-squared distribution quantile. For finite calibration
5//!   samples of size *n*, T² follows `(n·ncomp/(n−ncomp))·F(ncomp, n−ncomp)`
6//!   rather than `χ²(ncomp)`. The chi-squared limit is the large-sample
7//!   (*n* → ∞) limit.
8//! - **SPE**: moment-matched chi-squared approximation (Box, 1954, Theorem 1,
9//!   pp. 292–295). The derivation matches the first two moments of the SPE
10//!   distribution to a scaled chi-squared: `E[a·χ²(b)] = a·b = mean`,
11//!   `Var[a·χ²(b)] = 2a²·b = var`, giving `a = var/(2·mean)`,
12//!   `b = 2·mean²/var`.
13//!
14//! # Accuracy
15//!
16//! The moment-matching approximation is exact when SPE follows a scaled
17//! chi-squared distribution (holds under Gaussian scores). For non-Gaussian
18//! data, the approximation error is O(κ₄) where κ₄ is the excess kurtosis
19//! of the SPE distribution. Use [`spe_moment_match_diagnostic`] to assess
20//! adequacy.
21//!
22//! # References
23//!
24//! - Box, G.E.P. (1954). Some theorems on quadratic forms applied in the
25//!   study of analysis of variance problems, I. *Annals of Mathematical
26//!   Statistics*, 25(2), 290–302. Theorem 1, pp. 292–295.
27//! - Woodall, W.H. & Ncube, M.M. (1985). Multivariate CUSUM quality-control
28//!   procedures. *Technometrics*, 27(3), 285–292. §2, pp. 286–288.
29
30use super::chi_squared::chi2_quantile;
31use crate::error::FdarError;
32
33/// A control limit for a monitoring statistic.
34#[derive(Debug, Clone, PartialEq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36#[non_exhaustive]
37pub struct ControlLimit {
38    /// Upper control limit value.
39    pub ucl: f64,
40    /// Significance level used.
41    pub alpha: f64,
42    /// Human-readable description of how the limit was computed.
43    pub description: String,
44}
45
46/// Compute the T-squared control limit based on the chi-squared distribution.
47///
48/// UCL = chi2_quantile(1 - alpha, ncomp)
49///
50/// Based on the chi-squared approximation to the Hotelling T² distribution.
51/// For small calibration samples (n < 30), this may be anti-conservative;
52/// consider `t2_limit_robust()` with bootstrap method.
53///
54/// # Finite-sample correction
55///
56/// For finite calibration samples of size *n*, T² follows
57/// `(n·ncomp/(n−ncomp))·F(ncomp, n−ncomp)` rather than `χ²(ncomp)`. The
58/// chi-squared limit used here is the large-sample (*n* → ∞) limit. For
59/// *n* < 30, the F-based limit should be preferred (Woodall & Ncube, 1985,
60/// §2, pp. 286–288).
61///
62/// # Arguments
63/// * `ncomp` - Number of principal components (degrees of freedom)
64/// * `alpha` - Significance level (e.g. 0.05)
65///
66/// # Example
67///
68/// ```
69/// use fdars_core::spm::control::t2_control_limit;
70/// let limit = t2_control_limit(3, 0.05).unwrap();
71/// assert!(limit.ucl > 0.0);
72/// assert!((limit.ucl - 7.815).abs() < 0.01); // chi2(0.95, 3)
73/// ```
74///
75/// # Errors
76///
77/// Returns [`FdarError::InvalidParameter`] if `ncomp` is 0 or `alpha` is not in (0, 1).
78#[must_use = "control limit should not be discarded"]
79pub fn t2_control_limit(ncomp: usize, alpha: f64) -> Result<ControlLimit, FdarError> {
80    if ncomp == 0 {
81        return Err(FdarError::InvalidParameter {
82            parameter: "ncomp",
83            message: "ncomp must be at least 1".to_string(),
84        });
85    }
86    if alpha <= 0.0 || alpha >= 1.0 {
87        return Err(FdarError::InvalidParameter {
88            parameter: "alpha",
89            message: format!("alpha must be in (0, 1), got {alpha}"),
90        });
91    }
92
93    let ucl = chi2_quantile(1.0 - alpha, ncomp);
94
95    Ok(ControlLimit {
96        ucl,
97        alpha,
98        description: format!("T2 ~ chi2({ncomp}), alpha={alpha}"),
99    })
100}
101
102/// Compute the SPE control limit using moment-matched chi-squared approximation.
103///
104/// Uses the Box (1954, Theorem 1, pp. 292–295) moment-matching approximation:
105/// SPE ~ a * chi²(b) where `a = var/(2·mean)`, `b = ceil(2·mean²/var)`. This
106/// is accurate when SPE values are approximately chi-squared distributed,
107/// which holds when the number of retained components is moderate (5–20)
108/// and sample size is adequate (n > 30).
109///
110/// Estimates parameters a and b such that SPE ~ a * chi2(b):
111///   a = var(spe) / (2 * mean(spe))
112///   b = 2 * mean(spe)^2 / var(spe)
113///   UCL = a * chi2_quantile(1 - alpha, ceil(b))
114///
115/// # Accuracy
116///
117/// The moment-matching approximation is exact when SPE follows a scaled
118/// chi-squared distribution (holds under Gaussian scores). For non-Gaussian
119/// data, the approximation error is O(κ₄) where κ₄ is the excess kurtosis
120/// of the SPE distribution. Use [`spe_moment_match_diagnostic`] to check.
121///
122/// # Rounding choice
123///
124/// Using `ceil()` rather than `round()` for the degrees-of-freedom parameter
125/// *b* gives a conservative (wider) control limit, ensuring the nominal
126/// false-alarm rate is not exceeded.
127///
128/// # Arguments
129/// * `spe_values` - In-control SPE values from calibration data
130/// * `alpha` - Significance level (e.g. 0.05)
131///
132/// # Example
133///
134/// ```
135/// use fdars_core::spm::control::spe_control_limit;
136/// let spe_values = vec![1.0, 2.0, 1.5, 3.0, 2.5, 1.8, 2.2, 1.2, 2.8, 1.6];
137/// let limit = spe_control_limit(&spe_values, 0.05).unwrap();
138/// assert!(limit.ucl > 0.0);
139/// ```
140///
141/// # Errors
142///
143/// Returns [`FdarError::InvalidDimension`] if `spe_values` has fewer than 2 elements.
144/// Returns [`FdarError::InvalidParameter`] if `alpha` is not in (0, 1).
145/// Returns [`FdarError::ComputationFailed`] if the estimated variance is zero or negative.
146#[must_use = "control limit should not be discarded"]
147pub fn spe_control_limit(spe_values: &[f64], alpha: f64) -> Result<ControlLimit, FdarError> {
148    let n = spe_values.len();
149    if n < 2 {
150        return Err(FdarError::InvalidDimension {
151            parameter: "spe_values",
152            expected: "at least 2 values".to_string(),
153            actual: format!("{n} values"),
154        });
155    }
156    if alpha <= 0.0 || alpha >= 1.0 {
157        return Err(FdarError::InvalidParameter {
158            parameter: "alpha",
159            message: format!("alpha must be in (0, 1), got {alpha}"),
160        });
161    }
162
163    let mean: f64 = spe_values.iter().sum::<f64>() / n as f64;
164    let var: f64 = spe_values.iter().map(|&s| (s - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
165
166    if mean <= 0.0 || var <= 0.0 {
167        return Err(FdarError::ComputationFailed {
168            operation: "spe_control_limit",
169            detail: format!(
170                "SPE mean={mean:.6}, var={var:.6}; cannot estimate chi-squared parameters from non-positive moments"
171            ),
172        });
173    }
174
175    let a = var / (2.0 * mean);
176    let b = 2.0 * mean * mean / var;
177    // Using ceil() rather than round() gives a conservative (wider) control
178    // limit, ensuring the nominal false-alarm rate is not exceeded.
179    let b_int = b.ceil().max(1.0) as usize;
180
181    let ucl = a * chi2_quantile(1.0 - alpha, b_int);
182
183    Ok(ControlLimit {
184        ucl,
185        alpha,
186        description: format!("SPE ~ {a:.4} * chi2({b_int}), alpha={alpha}"),
187    })
188}
189
190/// Diagnostic for the SPE moment-match chi-squared approximation.
191///
192/// Computes the excess kurtosis of the SPE values and compares it to the
193/// theoretical kurtosis of the fitted chi-squared distribution (12/b).
194/// A large discrepancy suggests the chi-squared approximation may be poor.
195///
196/// Returns `(excess_kurtosis, theoretical_kurtosis, is_adequate)` where
197/// `is_adequate` is true when the absolute difference is within 50% of
198/// the theoretical value. Interpretation: `is_adequate == true` (ratio
199/// within 50%) indicates the chi-squared model fits well. When inadequate,
200/// the SPE distribution may be far from chi-squared (possibly multi-modal
201/// or heavy-tailed); in that case, use `spe_limit_robust()` with bootstrap
202/// or KDE method instead.
203///
204/// # Arguments
205/// * `spe_values` - In-control SPE values
206///
207/// # Errors
208///
209/// Returns [`FdarError::InvalidDimension`] if fewer than 4 values are provided.
210#[must_use = "diagnostic result should not be discarded"]
211pub fn spe_moment_match_diagnostic(spe_values: &[f64]) -> Result<(f64, f64, bool), FdarError> {
212    let n = spe_values.len();
213    if n < 4 {
214        return Err(FdarError::InvalidDimension {
215            parameter: "spe_values",
216            expected: "at least 4 values".to_string(),
217            actual: format!("{n} values"),
218        });
219    }
220
221    let mean: f64 = spe_values.iter().sum::<f64>() / n as f64;
222    let var: f64 = spe_values.iter().map(|&s| (s - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
223
224    if var <= 0.0 {
225        return Err(FdarError::ComputationFailed {
226            operation: "spe_moment_match_diagnostic",
227            detail: "variance is zero".to_string(),
228        });
229    }
230
231    // Excess kurtosis: E[(X-mu)^4]/sigma^4 - 3
232    let m4: f64 = spe_values.iter().map(|&s| (s - mean).powi(4)).sum::<f64>() / n as f64;
233    let excess_kurtosis = m4 / (var * var) - 3.0;
234
235    // Theoretical: for chi2(b) scaled by a, kurtosis = 12/b
236    // b = 2 * mean^2 / var
237    let b = 2.0 * mean * mean / var;
238    let theoretical_kurtosis = if b > 0.0 { 12.0 / b } else { f64::INFINITY };
239
240    // Adequate if |observed - theoretical| <= 0.5 * theoretical
241    let is_adequate = if theoretical_kurtosis.is_finite() {
242        (excess_kurtosis - theoretical_kurtosis).abs() <= 0.5 * theoretical_kurtosis
243    } else {
244        false
245    };
246
247    Ok((excess_kurtosis, theoretical_kurtosis, is_adequate))
248}