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}