Skip to main content

fdars_core/spm/
arl.rs

1//! Average Run Length (ARL) computation for SPM control charts.
2//!
3//! Provides Monte Carlo simulation of in-control (ARL0) and
4//! out-of-control (ARL1) average run lengths for T-squared, SPE,
5//! and EWMA-T-squared charts.
6//!
7//! # ARL computation
8//!
9//! The ARL is defined as E\[RL\] where RL = inf{t : S_t > UCL} is the run
10//! length (first time the monitoring statistic exceeds the upper control
11//! limit). For a Shewhart T² chart with i.i.d. N(0, Lambda) scores and
12//! chi-squared UCL at significance level alpha, ARL₀ = 1/alpha exactly
13//! (e.g., 20 for alpha = 0.05, 100 for alpha = 0.01). The Monte Carlo
14//! estimate should agree within 2–3 standard errors.
15//!
16//! # Monte Carlo convergence
17//!
18//! The standard error of the ARL estimate is sigma(RL) / sqrt(n_sim).
19//! For ARL₀ ~ 370 (geometrically distributed RL), sigma(RL) ~ ARL₀, so
20//! using n_sim = 10000 gives SE ~ 370 / sqrt(10000) ~ 3.7 (relative
21//! SE ~ 1%). For tighter precision, increase `n_simulations` in
22//! [`ArlConfig`].
23//!
24//! # Non-independence note
25//!
26//! For EWMA and CUSUM charts, the ARL cannot be computed from the marginal
27//! alarm rate 1/alpha because successive statistics are dependent. The
28//! Monte Carlo approach in [`arl0_ewma_t2`] handles this correctly by
29//! simulating the full sequential process.
30
31use crate::error::FdarError;
32use crate::iter_maybe_parallel;
33
34#[cfg(feature = "parallel")]
35use rayon::iter::ParallelIterator;
36
37use rand::rngs::StdRng;
38use rand::{Rng, SeedableRng};
39use rand_distr::StandardNormal;
40
41/// Configuration for ARL simulation.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ArlConfig {
44    /// Number of simulation replicates (default 10_000).
45    pub n_simulations: usize,
46    /// Maximum run length before truncation (default 5_000).
47    pub max_run_length: usize,
48    /// Random seed (default 42).
49    pub seed: u64,
50}
51
52impl Default for ArlConfig {
53    fn default() -> Self {
54        Self {
55            n_simulations: 10_000,
56            max_run_length: 5_000,
57            seed: 42,
58        }
59    }
60}
61
62/// Result of ARL computation.
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub struct ArlResult {
66    /// Average run length.
67    pub arl: f64,
68    /// Standard deviation of run lengths.
69    pub std_dev: f64,
70    /// Median run length.
71    pub median_rl: f64,
72    /// Individual run lengths from each simulation.
73    pub run_lengths: Vec<usize>,
74}
75
76/// Compute in-control ARL for T-squared chart (ARL0).
77///
78/// Simulates observations from N(0, diag(eigenvalues)) and computes
79/// T² = Σ score²/eigenvalue. Counts steps until T² > ucl.
80///
81/// # Arguments
82/// * `eigenvalues` - Eigenvalues (one per PC)
83/// * `ucl` - Upper control limit for T-squared
84/// * `config` - ARL simulation configuration
85///
86/// # Errors
87///
88/// Returns [`FdarError::InvalidDimension`] if `eigenvalues` is empty.
89/// Returns [`FdarError::InvalidParameter`] if `ucl` is not positive.
90///
91/// # Example
92/// ```
93/// use fdars_core::spm::arl::{arl0_t2, ArlConfig};
94/// let eigenvalues = vec![2.0, 1.0];
95/// let ucl = 5.991; // chi2(0.95, 2)
96/// let config = ArlConfig { n_simulations: 1000, max_run_length: 500, seed: 42 };
97/// let result = arl0_t2(&eigenvalues, ucl, &config).unwrap();
98/// assert!(result.arl > 1.0);
99/// assert!(result.std_dev > 0.0);
100/// ```
101#[must_use = "ARL result should not be discarded"]
102pub fn arl0_t2(eigenvalues: &[f64], ucl: f64, config: &ArlConfig) -> Result<ArlResult, FdarError> {
103    arl_t2_impl(eigenvalues, ucl, &vec![0.0; eigenvalues.len()], config)
104}
105
106/// Compute out-of-control ARL for T-squared chart (ARL1).
107///
108/// Simulates observations from N(shift, diag(eigenvalues)) and computes
109/// T² = Σ score²/eigenvalue. Counts steps until T² > ucl.
110///
111/// # Arguments
112/// * `eigenvalues` - Eigenvalues (one per PC)
113/// * `ucl` - Upper control limit
114/// * `shift` - Mean shift vector (one per PC)
115/// * `config` - ARL simulation configuration
116///
117/// # Errors
118///
119/// Returns [`FdarError::InvalidDimension`] if dimensions don't match.
120/// Returns [`FdarError::InvalidParameter`] if `ucl` is not positive.
121#[must_use = "ARL result should not be discarded"]
122pub fn arl1_t2(
123    eigenvalues: &[f64],
124    ucl: f64,
125    shift: &[f64],
126    config: &ArlConfig,
127) -> Result<ArlResult, FdarError> {
128    if shift.len() != eigenvalues.len() {
129        return Err(FdarError::InvalidDimension {
130            parameter: "shift",
131            expected: format!("{}", eigenvalues.len()),
132            actual: format!("{}", shift.len()),
133        });
134    }
135    arl_t2_impl(eigenvalues, ucl, shift, config)
136}
137
138/// Compute in-control ARL for EWMA-T-squared chart.
139///
140/// Simulates observations from N(0, diag(eigenvalues)), applies EWMA
141/// smoothing with parameter lambda, and computes T² on the smoothed
142/// scores with adjusted eigenvalues lambda/(2-lambda) * eigenvalue.
143///
144/// # Arguments
145/// * `eigenvalues` - Eigenvalues (one per PC)
146/// * `ucl` - Upper control limit for EWMA T-squared
147/// * `lambda` - EWMA smoothing parameter in (0, 1]
148/// * `config` - ARL simulation configuration
149///
150/// # Errors
151///
152/// Returns [`FdarError::InvalidParameter`] if `lambda` is not in (0, 1].
153#[must_use = "ARL result should not be discarded"]
154pub fn arl0_ewma_t2(
155    eigenvalues: &[f64],
156    ucl: f64,
157    lambda: f64,
158    config: &ArlConfig,
159) -> Result<ArlResult, FdarError> {
160    validate_common(eigenvalues, ucl)?;
161    if lambda <= 0.0 || lambda > 1.0 {
162        return Err(FdarError::InvalidParameter {
163            parameter: "lambda",
164            message: format!("lambda must be in (0, 1], got {lambda}"),
165        });
166    }
167
168    let ncomp = eigenvalues.len();
169    let adj_eigenvalues: Vec<f64> = eigenvalues
170        .iter()
171        .map(|&ev| ev * lambda / (2.0 - lambda))
172        .collect();
173
174    let run_lengths: Vec<usize> =
175        iter_maybe_parallel!((0..config.n_simulations).collect::<Vec<_>>())
176            .map(|rep| {
177                let mut rng = StdRng::seed_from_u64(config.seed + rep as u64);
178                let mut z = vec![0.0_f64; ncomp];
179                for step in 1..=config.max_run_length {
180                    // Generate N(0, eigenvalue) scores and apply EWMA
181                    for l in 0..ncomp {
182                        let xi: f64 = rng.sample::<f64, _>(StandardNormal) * eigenvalues[l].sqrt();
183                        z[l] = lambda * xi + (1.0 - lambda) * z[l];
184                    }
185                    // T² on smoothed scores with adjusted eigenvalues
186                    let t2: f64 = z
187                        .iter()
188                        .zip(adj_eigenvalues.iter())
189                        .map(|(&zl, &ev)| zl * zl / ev)
190                        .sum();
191                    if t2 > ucl {
192                        return step;
193                    }
194                }
195                config.max_run_length
196            })
197            .collect();
198
199    Ok(build_result(run_lengths))
200}
201
202/// Compute in-control ARL for SPE chart.
203///
204/// Simulates SPE values as `scale * chi²(df)` using a Gamma distribution.
205///
206/// # Arguments
207/// * `spe_df` - Estimated degrees of freedom for SPE distribution
208/// * `spe_scale` - Estimated scale for SPE distribution
209/// * `ucl` - Upper control limit for SPE
210/// * `config` - ARL simulation configuration
211///
212/// # Errors
213///
214/// Returns [`FdarError::InvalidParameter`] if parameters are non-positive.
215#[must_use = "ARL result should not be discarded"]
216pub fn arl0_spe(
217    spe_df: f64,
218    spe_scale: f64,
219    ucl: f64,
220    config: &ArlConfig,
221) -> Result<ArlResult, FdarError> {
222    if spe_df <= 0.0 {
223        return Err(FdarError::InvalidParameter {
224            parameter: "spe_df",
225            message: format!("spe_df must be positive, got {spe_df}"),
226        });
227    }
228    if spe_scale <= 0.0 {
229        return Err(FdarError::InvalidParameter {
230            parameter: "spe_scale",
231            message: format!("spe_scale must be positive, got {spe_scale}"),
232        });
233    }
234    if ucl <= 0.0 {
235        return Err(FdarError::InvalidParameter {
236            parameter: "ucl",
237            message: format!("ucl must be positive, got {ucl}"),
238        });
239    }
240
241    // chi²(df) = Gamma(df/2, 2), so scale*chi²(df) = Gamma(df/2, 2*scale)
242    let shape = spe_df / 2.0;
243    let gamma_scale = 2.0 * spe_scale;
244
245    let run_lengths: Vec<usize> =
246        iter_maybe_parallel!((0..config.n_simulations).collect::<Vec<_>>())
247            .map(|rep| {
248                let mut rng = StdRng::seed_from_u64(config.seed + rep as u64);
249                for step in 1..=config.max_run_length {
250                    let spe = sample_gamma(&mut rng, shape) * gamma_scale;
251                    if spe > ucl {
252                        return step;
253                    }
254                }
255                config.max_run_length
256            })
257            .collect();
258
259    Ok(build_result(run_lengths))
260}
261
262// ── Internal helpers ─────────────────────────────────────────────────────
263
264fn validate_common(eigenvalues: &[f64], ucl: f64) -> Result<(), FdarError> {
265    if eigenvalues.is_empty() {
266        return Err(FdarError::InvalidDimension {
267            parameter: "eigenvalues",
268            expected: "at least 1 eigenvalue".to_string(),
269            actual: "0 eigenvalues".to_string(),
270        });
271    }
272    if ucl <= 0.0 {
273        return Err(FdarError::InvalidParameter {
274            parameter: "ucl",
275            message: format!("ucl must be positive, got {ucl}"),
276        });
277    }
278    for (l, &ev) in eigenvalues.iter().enumerate() {
279        if ev <= 0.0 {
280            return Err(FdarError::InvalidParameter {
281                parameter: "eigenvalues",
282                message: format!("eigenvalue[{l}] = {ev} must be positive"),
283            });
284        }
285    }
286    Ok(())
287}
288
289fn arl_t2_impl(
290    eigenvalues: &[f64],
291    ucl: f64,
292    shift: &[f64],
293    config: &ArlConfig,
294) -> Result<ArlResult, FdarError> {
295    validate_common(eigenvalues, ucl)?;
296    let ncomp = eigenvalues.len();
297
298    let run_lengths: Vec<usize> =
299        iter_maybe_parallel!((0..config.n_simulations).collect::<Vec<_>>())
300            .map(|rep| {
301                let mut rng = StdRng::seed_from_u64(config.seed + rep as u64);
302                for step in 1..=config.max_run_length {
303                    let mut t2 = 0.0_f64;
304                    for l in 0..ncomp {
305                        let score: f64 =
306                            shift[l] + rng.sample::<f64, _>(StandardNormal) * eigenvalues[l].sqrt();
307                        t2 += score * score / eigenvalues[l];
308                    }
309                    if t2 > ucl {
310                        return step;
311                    }
312                }
313                config.max_run_length
314            })
315            .collect();
316
317    Ok(build_result(run_lengths))
318}
319
320fn build_result(run_lengths: Vec<usize>) -> ArlResult {
321    let n = run_lengths.len() as f64;
322    let arl = run_lengths.iter().map(|&r| r as f64).sum::<f64>() / n;
323    let var = run_lengths
324        .iter()
325        .map(|&r| {
326            let d = r as f64 - arl;
327            d * d
328        })
329        .sum::<f64>()
330        / (n - 1.0);
331    let std_dev = var.sqrt();
332
333    let mut sorted: Vec<usize> = run_lengths.clone();
334    sorted.sort_unstable();
335    let median_rl = if sorted.len() % 2 == 0 {
336        (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) as f64 / 2.0
337    } else {
338        sorted[sorted.len() / 2] as f64
339    };
340
341    ArlResult {
342        arl,
343        std_dev,
344        median_rl,
345        run_lengths,
346    }
347}
348
349/// Sample from Gamma(shape, 1) using Marsaglia and Tsang's method.
350///
351/// Reference: Marsaglia, G. & Tsang, W.W. (2000). A simple method for
352/// generating gamma variables. *ACM Transactions on Mathematical Software*,
353/// 26(3), 363-372.
354///
355/// The squeeze test constant 0.0331 = (1/3) * (1/(3*(shape-1/3)))^2
356/// (Eq. 6 of Marsaglia & Tsang 2000) provides a fast rejection path.
357fn sample_gamma(rng: &mut StdRng, shape: f64) -> f64 {
358    if shape < 1.0 {
359        // Gamma(a, 1) = Gamma(a+1, 1) * U^(1/a)
360        let g = sample_gamma(rng, shape + 1.0);
361        let u: f64 = rng.gen();
362        return g * u.powf(1.0 / shape);
363    }
364
365    let d = shape - 1.0 / 3.0;
366    let c = 1.0 / (9.0 * d).sqrt();
367
368    loop {
369        let x: f64 = rng.sample(StandardNormal);
370        let v = 1.0 + c * x;
371        if v <= 0.0 {
372            continue;
373        }
374        let v = v * v * v;
375        let u: f64 = rng.gen();
376        if u < 1.0 - 0.0331 * x * x * x * x {
377            return d * v;
378        }
379        if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
380            return d * v;
381        }
382    }
383}