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