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}