Skip to main content

fdars_core/spm/
rules.rs

1//! Runs and zone rules for control chart analysis.
2//!
3//! Implements Western Electric (WE) and Nelson rules for detecting
4//! non-random patterns in control chart data. These rules supplement
5//! T²/SPE alarm logic by detecting non-random patterns (trends, runs,
6//! stratification) that may indicate process shifts even when individual
7//! points remain within control limits.
8//!
9//! # Theoretical ARL₀ (independent normal data)
10//!
11//! | Rule | ARL₀ |
12//! |------|------|
13//! | WE1 (3σ) | 370.4 |
14//! | WE2 (2/3 beyond 2σ) | 122.0 |
15//! | WE3 (4/5 beyond 1σ) | 90.7 |
16//! | WE4 (8 same side) | 119.7 |
17//! | Nelson5 (6 monotone) | 360.0 |
18//! | Nelson6 (14 alternating) | 182.0 |
19//! | Nelson7 (15 within 1σ) | 44.1 |
20//! | All WE combined | ~46 |
21//!
22//! # Assumptions
23//!
24//! These rules assume observations are independent and identically distributed
25//! under in-control conditions. When applied to autocorrelated data (e.g.,
26//! EWMA-smoothed statistics), false alarm rates may be inflated.
27//!
28//! # Computational complexity
29//!
30//! All rules evaluate in O(n·W) time where W is the maximum window size
31//! (1 for WE1, 15 for Nelson7). Memory usage is O(W) per rule.
32//!
33//! # NaN handling
34//!
35//! NaN values in the input are not handled specially; comparisons involving
36//! NaN return false, so NaN points will not trigger WE1–WE3 or Nelson5–Nelson6
37//! violations but may affect WE4/Nelson7 (which check all-same-side or
38//! all-within conditions).
39//!
40//! # References
41//!
42//! - Western Electric Company (1956). *Statistical Quality Control Handbook*.
43//!   Western Electric Co., Indianapolis. Chapter 4, pp. 25–28.
44//! - Nelson, L.S. (1984). The Shewhart control chart — tests for special
45//!   causes. *Journal of Quality Technology*, 16(4), 237-239, p. 238.
46//! - Nelson, L.S. (1985). Interpreting Shewhart X̄ control charts. *Journal
47//!   of Quality Technology*, 17(2), 114-116, p. 115.
48//!
49//! # False Alarm Rates
50//!
51//! For false alarm rate estimation under these rules with independent data,
52//! the theoretical ARL₀ for WE1 alone is ~370 (at 3σ). Combining multiple
53//! rules increases sensitivity but reduces ARL₀; empirical calibration via
54//! `arl0_t2` or simulation is recommended when using multiple rules
55//! simultaneously.
56
57use crate::error::FdarError;
58
59/// A control chart pattern rule.
60#[derive(Debug, Clone, PartialEq)]
61#[non_exhaustive]
62pub enum ChartRule {
63    /// WE1: Any single point beyond 3σ.
64    WE1,
65    /// WE2: 2 of 3 consecutive points beyond 2σ on the same side.
66    ///
67    /// Multiple overlapping windows may detect the same event; deduplicate
68    /// by index if needed.
69    WE2,
70    /// WE3: 4 of 5 consecutive points beyond 1σ on the same side.
71    ///
72    /// Multiple overlapping windows may detect the same event; deduplicate
73    /// by index if needed.
74    WE3,
75    /// WE4: 8 consecutive points on the same side of center.
76    WE4,
77    /// Nelson5: 6 points in a row steadily increasing or decreasing.
78    Nelson5,
79    /// Nelson6: 14 points in a row alternating up and down.
80    Nelson6,
81    /// Nelson7: 15 consecutive points within 1σ of center (stratification).
82    Nelson7,
83    /// Custom run rule: `n_points` consecutive points beyond `k_sigma`.
84    CustomRun {
85        /// Number of consecutive points required.
86        n_points: usize,
87        /// Sigma threshold.
88        k_sigma: f64,
89    },
90}
91
92/// A detected rule violation.
93#[derive(Debug, Clone, PartialEq)]
94#[non_exhaustive]
95pub struct RuleViolation {
96    /// The rule that was violated.
97    pub rule: ChartRule,
98    /// Indices of the points involved in the violation.
99    pub indices: Vec<usize>,
100}
101
102/// Evaluate a set of chart rules against a sequence of values.
103///
104/// Each [`RuleViolation`] in the returned vector contains the triggering
105/// rule and the indices of the observations involved. Multiple violations
106/// from different rules may overlap. For multi-rule evaluation, consider
107/// using [`western_electric_rules`] or [`nelson_rules`] convenience
108/// functions.
109///
110/// # Arguments
111/// * `values` - Monitoring statistic values (in time order)
112/// * `center` - Center line (e.g. process mean)
113/// * `sigma` - Standard deviation estimate
114/// * `rules` - Rules to evaluate
115///
116/// # Example
117///
118/// ```
119/// use fdars_core::spm::rules::{evaluate_rules, ChartRule};
120/// let values = vec![0.0, 0.1, 0.0, -0.1, 0.0, 0.0, 0.1, 10.0]; // outlier at index 7
121/// let violations = evaluate_rules(&values, 0.0, 1.0, &[ChartRule::WE1]).unwrap();
122/// assert!(!violations.is_empty());
123/// assert_eq!(violations[0].indices, vec![7]);
124/// ```
125///
126/// # Errors
127///
128/// Returns [`FdarError::InvalidParameter`] if `sigma` is not positive.
129#[must_use = "violations should not be discarded"]
130pub fn evaluate_rules(
131    values: &[f64],
132    center: f64,
133    sigma: f64,
134    rules: &[ChartRule],
135) -> Result<Vec<RuleViolation>, FdarError> {
136    if sigma <= 0.0 {
137        return Err(FdarError::InvalidParameter {
138            parameter: "sigma",
139            message: format!("sigma must be positive, got {sigma}"),
140        });
141    }
142
143    let mut violations = Vec::new();
144    for rule in rules {
145        let mut rule_violations = evaluate_single_rule(values, center, sigma, rule);
146        violations.append(&mut rule_violations);
147    }
148    Ok(violations)
149}
150
151/// Apply all four Western Electric rules.
152///
153/// # Example
154///
155/// ```
156/// use fdars_core::spm::rules::western_electric_rules;
157/// let values = vec![0.1, -0.2, 0.3, -0.1, 0.2, -0.3, 0.1, -0.2]; // in-control
158/// let violations = western_electric_rules(&values, 0.0, 1.0).unwrap();
159/// assert!(violations.is_empty()); // no violations for mild fluctuations
160/// ```
161///
162/// # Errors
163///
164/// Returns [`FdarError::InvalidParameter`] if `sigma` is not positive.
165#[must_use = "violations should not be discarded"]
166pub fn western_electric_rules(
167    values: &[f64],
168    center: f64,
169    sigma: f64,
170) -> Result<Vec<RuleViolation>, FdarError> {
171    evaluate_rules(
172        values,
173        center,
174        sigma,
175        &[
176            ChartRule::WE1,
177            ChartRule::WE2,
178            ChartRule::WE3,
179            ChartRule::WE4,
180        ],
181    )
182}
183
184/// Apply Nelson rules 5-7 (in addition to WE rules which cover Nelson 1-4).
185///
186/// # Errors
187///
188/// Returns [`FdarError::InvalidParameter`] if `sigma` is not positive.
189#[must_use = "violations should not be discarded"]
190pub fn nelson_rules(
191    values: &[f64],
192    center: f64,
193    sigma: f64,
194) -> Result<Vec<RuleViolation>, FdarError> {
195    evaluate_rules(
196        values,
197        center,
198        sigma,
199        &[
200            ChartRule::WE1,
201            ChartRule::WE2,
202            ChartRule::WE3,
203            ChartRule::WE4,
204            ChartRule::Nelson5,
205            ChartRule::Nelson6,
206            ChartRule::Nelson7,
207        ],
208    )
209}
210
211fn evaluate_single_rule(
212    values: &[f64],
213    center: f64,
214    sigma: f64,
215    rule: &ChartRule,
216) -> Vec<RuleViolation> {
217    match rule {
218        ChartRule::WE1 => eval_we1(values, center, sigma),
219        ChartRule::WE2 => eval_we2(values, center, sigma),
220        ChartRule::WE3 => eval_we3(values, center, sigma),
221        ChartRule::WE4 => eval_we4(values, center),
222        ChartRule::Nelson5 => eval_nelson5(values),
223        ChartRule::Nelson6 => eval_nelson6(values),
224        ChartRule::Nelson7 => eval_nelson7(values, center, sigma),
225        ChartRule::CustomRun { n_points, k_sigma } => {
226            eval_custom_run(values, center, sigma, *n_points, *k_sigma)
227        }
228    }
229}
230
231// WE1: any single point beyond 3σ
232fn eval_we1(values: &[f64], center: f64, sigma: f64) -> Vec<RuleViolation> {
233    let mut violations = Vec::new();
234    for (i, &v) in values.iter().enumerate() {
235        if (v - center).abs() > 3.0 * sigma {
236            violations.push(RuleViolation {
237                rule: ChartRule::WE1,
238                indices: vec![i],
239            });
240        }
241    }
242    violations
243}
244
245// WE2: 2 of 3 consecutive points beyond 2σ on the same side
246fn eval_we2(values: &[f64], center: f64, sigma: f64) -> Vec<RuleViolation> {
247    let mut violations = Vec::new();
248    if values.len() < 3 {
249        return violations;
250    }
251    for start in 0..values.len() - 2 {
252        let window = &values[start..start + 3];
253        // Check upper side
254        let above_2s: Vec<usize> = window
255            .iter()
256            .enumerate()
257            .filter(|(_, &v)| v - center > 2.0 * sigma)
258            .map(|(j, _)| start + j)
259            .collect();
260        if above_2s.len() >= 2 {
261            violations.push(RuleViolation {
262                rule: ChartRule::WE2,
263                indices: above_2s,
264            });
265        }
266        // Check lower side
267        let below_2s: Vec<usize> = window
268            .iter()
269            .enumerate()
270            .filter(|(_, &v)| center - v > 2.0 * sigma)
271            .map(|(j, _)| start + j)
272            .collect();
273        if below_2s.len() >= 2 {
274            violations.push(RuleViolation {
275                rule: ChartRule::WE2,
276                indices: below_2s,
277            });
278        }
279    }
280    violations
281}
282
283// WE3: 4 of 5 consecutive points beyond 1σ on the same side
284fn eval_we3(values: &[f64], center: f64, sigma: f64) -> Vec<RuleViolation> {
285    let mut violations = Vec::new();
286    if values.len() < 5 {
287        return violations;
288    }
289    for start in 0..values.len() - 4 {
290        let window = &values[start..start + 5];
291        // Check upper side
292        let above_1s: Vec<usize> = window
293            .iter()
294            .enumerate()
295            .filter(|(_, &v)| v - center > sigma)
296            .map(|(j, _)| start + j)
297            .collect();
298        if above_1s.len() >= 4 {
299            violations.push(RuleViolation {
300                rule: ChartRule::WE3,
301                indices: above_1s,
302            });
303        }
304        // Check lower side
305        let below_1s: Vec<usize> = window
306            .iter()
307            .enumerate()
308            .filter(|(_, &v)| center - v > sigma)
309            .map(|(j, _)| start + j)
310            .collect();
311        if below_1s.len() >= 4 {
312            violations.push(RuleViolation {
313                rule: ChartRule::WE3,
314                indices: below_1s,
315            });
316        }
317    }
318    violations
319}
320
321// WE4: 8 consecutive points on the same side of center
322fn eval_we4(values: &[f64], center: f64) -> Vec<RuleViolation> {
323    let mut violations = Vec::new();
324    if values.len() < 8 {
325        return violations;
326    }
327    for start in 0..values.len() - 7 {
328        let window = &values[start..start + 8];
329        let all_above = window.iter().all(|&v| v > center);
330        let all_below = window.iter().all(|&v| v < center);
331        if all_above || all_below {
332            violations.push(RuleViolation {
333                rule: ChartRule::WE4,
334                indices: (start..start + 8).collect(),
335            });
336        }
337    }
338    violations
339}
340
341// Nelson5: 6 points in a row steadily increasing or decreasing
342fn eval_nelson5(values: &[f64]) -> Vec<RuleViolation> {
343    let mut violations = Vec::new();
344    if values.len() < 6 {
345        return violations;
346    }
347    for start in 0..values.len() - 5 {
348        let window = &values[start..start + 6];
349        let increasing = window.windows(2).all(|w| w[1] > w[0]);
350        let decreasing = window.windows(2).all(|w| w[1] < w[0]);
351        if increasing || decreasing {
352            violations.push(RuleViolation {
353                rule: ChartRule::Nelson5,
354                indices: (start..start + 6).collect(),
355            });
356        }
357    }
358    violations
359}
360
361// Nelson6: 14 points in a row alternating up and down
362fn eval_nelson6(values: &[f64]) -> Vec<RuleViolation> {
363    let mut violations = Vec::new();
364    if values.len() < 14 {
365        return violations;
366    }
367    for start in 0..values.len() - 13 {
368        let window = &values[start..start + 14];
369        let alternating = window
370            .windows(3)
371            .all(|w| (w[1] > w[0] && w[1] > w[2]) || (w[1] < w[0] && w[1] < w[2]));
372        if alternating {
373            violations.push(RuleViolation {
374                rule: ChartRule::Nelson6,
375                indices: (start..start + 14).collect(),
376            });
377        }
378    }
379    violations
380}
381
382// Nelson7: 15 consecutive points within 1σ of center (stratification)
383fn eval_nelson7(values: &[f64], center: f64, sigma: f64) -> Vec<RuleViolation> {
384    let mut violations = Vec::new();
385    if values.len() < 15 {
386        return violations;
387    }
388    for start in 0..values.len() - 14 {
389        let window = &values[start..start + 15];
390        let all_within = window.iter().all(|&v| (v - center).abs() <= sigma);
391        if all_within {
392            violations.push(RuleViolation {
393                rule: ChartRule::Nelson7,
394                indices: (start..start + 15).collect(),
395            });
396        }
397    }
398    violations
399}
400
401// CustomRun: n_points consecutive beyond k_sigma (on either side)
402fn eval_custom_run(
403    values: &[f64],
404    center: f64,
405    sigma: f64,
406    n_points: usize,
407    k_sigma: f64,
408) -> Vec<RuleViolation> {
409    let mut violations = Vec::new();
410    // A negative sigma threshold is meaningless — no point can be beyond
411    // a negative multiple of sigma, so return early with no violations.
412    if k_sigma < 0.0 {
413        return violations;
414    }
415    if n_points == 0 || values.len() < n_points {
416        return violations;
417    }
418    for start in 0..=values.len() - n_points {
419        let window = &values[start..start + n_points];
420        let all_beyond = window.iter().all(|&v| (v - center).abs() > k_sigma * sigma);
421        if all_beyond {
422            violations.push(RuleViolation {
423                rule: ChartRule::CustomRun { n_points, k_sigma },
424                indices: (start..start + n_points).collect(),
425            });
426        }
427    }
428    violations
429}