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}