Skip to main content

u_analytics/spc/
chart.rs

1//! Core control chart types and trait.
2//!
3//! Defines the fundamental building blocks for all control charts: control limits,
4//! chart points with violation annotations, and the [`ControlChart`] trait that
5//! all variables charts implement.
6//!
7//! # References
8//!
9//! - Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.
10//! - ASTM E2587 — Standard Practice for Use of Control Charts
11
12use std::fmt;
13
14/// Control limits for a chart.
15///
16/// Represents the upper control limit (UCL), center line (CL), and lower
17/// control limit (LCL) computed from the process data.
18///
19/// # Invariants
20///
21/// - `lcl <= cl <= ucl`
22/// - All values are finite
23#[derive(Debug, Clone, PartialEq)]
24pub struct ControlLimits {
25    /// Upper control limit (UCL = CL + 3 sigma).
26    pub ucl: f64,
27    /// Center line (process mean or target).
28    pub cl: f64,
29    /// Lower control limit (LCL = CL - 3 sigma).
30    pub lcl: f64,
31}
32
33/// A single point on a control chart.
34///
35/// Each point corresponds to a statistic computed from one subgroup or
36/// individual observation, along with any run-rule violations detected
37/// at that point.
38#[derive(Debug, Clone)]
39pub struct ChartPoint {
40    /// The computed statistic value (e.g., subgroup mean, range, proportion).
41    pub value: f64,
42    /// The zero-based index of this point in the sequence.
43    pub index: usize,
44    /// List of violations detected at this point.
45    pub violations: Vec<ViolationType>,
46}
47
48/// Types of control chart violations: Nelson's eight tests, and the Western
49/// Electric run rule where it differs from Nelson's.
50///
51/// Each variant is one test for special causes of variation, and its
52/// [`name`](ViolationType::name) is the name it goes by on the wire.
53///
54/// # Reference
55///
56/// Nelson, L.S. (1984). "The Shewhart Control Chart — Tests for Special Causes",
57/// *Journal of Quality Technology* 16(4), pp. 237-239.
58/// Western Electric (1956). *Statistical Quality Control Handbook*.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ViolationType {
61    /// Point beyond control limits (Nelson Rule 1).
62    ///
63    /// A single point falls outside the 3-sigma control limits.
64    BeyondLimits,
65
66    /// 9 points in a row on same side of center line (Nelson Rule 2).
67    ///
68    /// Indicates a sustained shift in the process mean.
69    NineOneSide,
70
71    /// 6 points in a row steadily increasing or decreasing (Nelson Rule 3).
72    ///
73    /// Indicates a trend in the process.
74    SixTrend,
75
76    /// 14 points in a row alternating up and down (Nelson Rule 4).
77    ///
78    /// Indicates systematic variation (e.g., two alternating streams).
79    FourteenAlternating,
80
81    /// 2 out of 3 points beyond 2 sigma on same side (Nelson Rule 5).
82    ///
83    /// An early warning of a potential shift.
84    TwoOfThreeBeyond2Sigma,
85
86    /// 4 out of 5 points beyond 1 sigma on same side (Nelson Rule 6).
87    ///
88    /// Indicates a small sustained shift.
89    FourOfFiveBeyond1Sigma,
90
91    /// 15 points in a row within 1 sigma of center line (Nelson Rule 7).
92    ///
93    /// Indicates stratification — reduced variation suggesting mixed streams.
94    FifteenWithin1Sigma,
95
96    /// 8 points in a row beyond 1 sigma on either side (Nelson Rule 8).
97    ///
98    /// Indicates a mixture pattern — points avoid the center zone.
99    EightBeyond1Sigma,
100
101    /// 8 points in a row on same side of center line (Western Electric rule 4).
102    ///
103    /// The Western Electric handbook's run rule; Nelson's Rule 2
104    /// ([`NineOneSide`](ViolationType::NineOneSide)) asks for nine.
105    EightOneSide,
106}
107
108impl ViolationType {
109    /// Every test: Nelson's eight in his numbering, then Western Electric's run rule.
110    pub const ALL: [ViolationType; 9] = [
111        ViolationType::BeyondLimits,
112        ViolationType::NineOneSide,
113        ViolationType::SixTrend,
114        ViolationType::FourteenAlternating,
115        ViolationType::TwoOfThreeBeyond2Sigma,
116        ViolationType::FourOfFiveBeyond1Sigma,
117        ViolationType::FifteenWithin1Sigma,
118        ViolationType::EightBeyond1Sigma,
119        ViolationType::EightOneSide,
120    ];
121
122    /// The name a point's violations and a request's rules use for this test.
123    pub fn name(self) -> &'static str {
124        match self {
125            ViolationType::BeyondLimits => "BeyondLimits",
126            ViolationType::NineOneSide => "NineOneSide",
127            ViolationType::SixTrend => "SixTrend",
128            ViolationType::FourteenAlternating => "FourteenAlternating",
129            ViolationType::TwoOfThreeBeyond2Sigma => "TwoOfThreeBeyond2Sigma",
130            ViolationType::FourOfFiveBeyond1Sigma => "FourOfFiveBeyond1Sigma",
131            ViolationType::FifteenWithin1Sigma => "FifteenWithin1Sigma",
132            ViolationType::EightBeyond1Sigma => "EightBeyond1Sigma",
133            ViolationType::EightOneSide => "EightOneSide",
134        }
135    }
136
137    /// The test a [`name`](ViolationType::name) stands for.
138    pub fn from_name(name: &str) -> Option<Self> {
139        Self::ALL.into_iter().find(|v| v.name() == name)
140    }
141}
142
143/// A violation detected on the chart.
144///
145/// Associates a specific point index with the type of violation observed.
146#[derive(Debug, Clone)]
147pub struct Violation {
148    /// The index of the point where the violation was detected.
149    pub point_index: usize,
150    /// The type of violation.
151    pub violation_type: ViolationType,
152}
153
154/// Trait for control charts that process subgroup or individual data.
155///
156/// Implementors accumulate sample data, compute control limits from the
157/// accumulated data, and detect violations using run rules.
158pub trait ControlChart {
159    /// Add a sample (subgroup) to the chart.
160    ///
161    /// For subgroup charts (X-bar-R, X-bar-S), the slice contains the
162    /// individual measurements within one subgroup.
163    ///
164    /// For individual charts (I-MR), the slice contains exactly one element.
165    ///
166    /// # Errors
167    ///
168    /// [`ControlChartError::SampleLengthMismatch`] if the slice is not the
169    /// chart's sample length, or [`ControlChartError::NonFiniteValue`] if it
170    /// holds a NaN or infinity. The chart is left unchanged. A rejected sample
171    /// used to be dropped without a word, so every later point sat one
172    /// position off the input it came from.
173    fn add_sample(&mut self, sample: &[f64]) -> Result<(), ControlChartError>;
174
175    /// Get the computed control limits, or `None` if insufficient data.
176    fn control_limits(&self) -> Option<ControlLimits>;
177
178    /// Check if the process is in statistical control.
179    ///
180    /// Returns `true` if no violations have been detected across all points.
181    fn is_in_control(&self) -> bool;
182
183    /// Get all violations detected across all chart points.
184    fn violations(&self) -> Vec<Violation>;
185
186    /// Get all chart points.
187    fn points(&self) -> &[ChartPoint];
188}
189
190/// Smallest subgroup size the variables-chart factor tables cover.
191pub const MIN_SUBGROUP_SIZE: usize = 2;
192
193/// Largest subgroup size the variables-chart factor tables cover.
194///
195/// The factor tables (A2, A3, D3, D4, B3, B4, d2, c4) are indexed by subgroup
196/// size, so this is the highest `n` for which control limits can be computed.
197pub const MAX_SUBGROUP_SIZE: usize = 25;
198
199/// Errors returned when a control chart cannot be constructed or cannot take
200/// a sample.
201#[derive(Debug, Clone, PartialEq)]
202#[non_exhaustive]
203pub enum ControlChartError {
204    /// A sample is not the length the chart takes: a subgroup of another size,
205    /// or more than one value for an individuals chart.
206    SampleLengthMismatch {
207        /// The chart's sample length.
208        expected: usize,
209        /// The length that was given.
210        got: usize,
211    },
212    /// A sample holds a NaN or an infinity.
213    NonFiniteValue,
214    /// A sample reports more defectives than it has items.
215    DefectivesExceedSampleSize {
216        /// Defectives reported.
217        defectives: u64,
218        /// Items in the sample.
219        sample_size: u64,
220    },
221    /// Units inspected that is not a positive, finite number.
222    NonPositiveUnits,
223    /// The requested subgroup size is outside the range the factor tables cover.
224    ///
225    /// Carries the rejected size so a caller can report it without re-deriving
226    /// it, and so a boundary that cannot unwind -- a WebAssembly or C entry
227    /// point -- can turn it into a value rather than a panic.
228    SubgroupSizeOutOfRange {
229        /// The size that was asked for.
230        got: usize,
231        /// Smallest supported size.
232        min: usize,
233        /// Largest supported size.
234        max: usize,
235    },
236    /// A sample size of zero: there is nothing to take a proportion of.
237    ZeroSampleSize,
238    /// A known (Phase I) parameter outside its domain: `p_bar` in (0, 1),
239    /// `u_bar` > 0, `phi` >= 0, each finite.
240    ///
241    /// Carries the value given and the domain, so a caller can say what the
242    /// parameter has to be without restating the rule.
243    InvalidStandard {
244        /// The parameter's name (`p_bar`, `u_bar`, `phi`).
245        parameter: &'static str,
246        /// What the parameter has to satisfy, for a message.
247        requirement: &'static str,
248        /// The value given.
249        got: f64,
250        /// Inclusive lower bound.
251        min: Option<f64>,
252        /// Exclusive lower bound.
253        exclusive_min: Option<f64>,
254        /// Inclusive upper bound.
255        max: Option<f64>,
256        /// Exclusive upper bound.
257        exclusive_max: Option<f64>,
258    },
259}
260
261impl ControlChartError {
262    /// A known proportion outside (0, 1).
263    pub(crate) fn proportion_standard(parameter: &'static str, got: f64) -> Self {
264        Self::InvalidStandard {
265            parameter,
266            requirement: "in (0, 1)",
267            got,
268            min: None,
269            exclusive_min: Some(0.0),
270            max: None,
271            exclusive_max: Some(1.0),
272        }
273    }
274
275    /// A known rate that is not > 0.
276    pub(crate) fn rate_standard(parameter: &'static str, got: f64) -> Self {
277        Self::InvalidStandard {
278            parameter,
279            requirement: "> 0",
280            got,
281            min: None,
282            exclusive_min: Some(0.0),
283            max: None,
284            exclusive_max: None,
285        }
286    }
287
288    /// A known dispersion factor below 0.
289    pub(crate) fn dispersion_standard(parameter: &'static str, got: f64) -> Self {
290        Self::InvalidStandard {
291            parameter,
292            requirement: ">= 0",
293            got,
294            min: Some(0.0),
295            exclusive_min: None,
296            max: None,
297            exclusive_max: None,
298        }
299    }
300}
301
302impl fmt::Display for ControlChartError {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        match self {
305            ControlChartError::SubgroupSizeOutOfRange { got, min, max } => {
306                write!(f, "subgroup size must be {min}..={max}, got {got}")
307            }
308            ControlChartError::ZeroSampleSize => write!(f, "sample size must be at least 1"),
309            ControlChartError::InvalidStandard {
310                parameter,
311                requirement,
312                got,
313                ..
314            } => write!(
315                f,
316                "{parameter} must be a finite number {requirement}, got {got}"
317            ),
318            ControlChartError::SampleLengthMismatch { expected, got } => {
319                write!(f, "sample has {got} values, expected {expected}")
320            }
321            ControlChartError::NonFiniteValue => {
322                write!(f, "sample contains a value that is not a finite number")
323            }
324            ControlChartError::DefectivesExceedSampleSize {
325                defectives,
326                sample_size,
327            } => write!(
328                f,
329                "{defectives} defectives out of a sample of {sample_size}"
330            ),
331            ControlChartError::NonPositiveUnits => {
332                write!(f, "units inspected must be a positive, finite number")
333            }
334        }
335    }
336}
337
338impl std::error::Error for ControlChartError {}
339
340/// Why a chart computed from a whole slice of samples refused that slice.
341///
342/// Carries the position of the refused sample, which a sample-by-sample
343/// [`ControlChartError`] cannot: the chart functions that take every sample
344/// at once are the only place that position is known.
345#[derive(Debug, Clone, PartialEq)]
346#[non_exhaustive]
347pub enum ChartInputError {
348    /// The sample at `index` cannot be charted.
349    Sample {
350        /// Zero-based position of the sample in the input slice.
351        index: usize,
352        /// What is wrong with it.
353        error: ControlChartError,
354    },
355    /// Fewer samples than the chart needs.
356    TooFewSamples {
357        /// Samples given.
358        got: usize,
359        /// Samples required.
360        min: usize,
361    },
362    /// A known (Phase I) parameter given with the samples is invalid
363    /// ([`ControlChartError::InvalidStandard`]).
364    Standard(ControlChartError),
365}
366
367impl fmt::Display for ChartInputError {
368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369        match self {
370            ChartInputError::Sample { index, error } => write!(f, "sample {index}: {error}"),
371            ChartInputError::TooFewSamples { got, min } => {
372                write!(f, "at least {min} samples are needed, got {got}")
373            }
374            ChartInputError::Standard(error) => error.fmt(f),
375        }
376    }
377}
378
379impl std::error::Error for ChartInputError {
380    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
381        match self {
382            ChartInputError::Sample { error, .. } | ChartInputError::Standard(error) => Some(error),
383            ChartInputError::TooFewSamples { .. } => None,
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_control_limits_construction() {
394        let limits = ControlLimits {
395            ucl: 30.0,
396            cl: 25.0,
397            lcl: 20.0,
398        };
399        assert!((limits.ucl - 30.0).abs() < f64::EPSILON);
400        assert!((limits.cl - 25.0).abs() < f64::EPSILON);
401        assert!((limits.lcl - 20.0).abs() < f64::EPSILON);
402    }
403
404    #[test]
405    fn test_chart_point_construction() {
406        let point = ChartPoint {
407            value: 25.5,
408            index: 0,
409            violations: vec![ViolationType::BeyondLimits],
410        };
411        assert!((point.value - 25.5).abs() < f64::EPSILON);
412        assert_eq!(point.index, 0);
413        assert_eq!(point.violations.len(), 1);
414        assert_eq!(point.violations[0], ViolationType::BeyondLimits);
415    }
416
417    #[test]
418    fn test_violation_type_equality() {
419        assert_eq!(ViolationType::BeyondLimits, ViolationType::BeyondLimits);
420        assert_ne!(ViolationType::BeyondLimits, ViolationType::NineOneSide);
421    }
422
423    #[test]
424    fn test_violation_construction() {
425        let v = Violation {
426            point_index: 5,
427            violation_type: ViolationType::SixTrend,
428        };
429        assert_eq!(v.point_index, 5);
430        assert_eq!(v.violation_type, ViolationType::SixTrend);
431    }
432
433    #[test]
434    fn test_control_limits_clone() {
435        let limits = ControlLimits {
436            ucl: 30.0,
437            cl: 25.0,
438            lcl: 20.0,
439        };
440        let cloned = limits.clone();
441        assert_eq!(limits, cloned);
442    }
443}