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, Eq)]
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.
196 InvalidStandard {
197 /// The parameter's name (`p_bar`, `u_bar`, `phi`).
198 parameter: &'static str,
199 },
200}
201
202impl fmt::Display for ControlChartError {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 match self {
205 ControlChartError::SubgroupSizeOutOfRange { got, min, max } => {
206 write!(f, "subgroup size must be {min}..={max}, got {got}")
207 }
208 ControlChartError::ZeroSampleSize => write!(f, "sample size must be at least 1"),
209 ControlChartError::InvalidStandard { parameter } => write!(
210 f,
211 "{parameter}: outside its domain (p_bar in (0, 1), u_bar > 0, phi >= 0, all finite)"
212 ),
213 ControlChartError::SampleLengthMismatch { expected, got } => {
214 write!(f, "sample has {got} values, expected {expected}")
215 }
216 ControlChartError::NonFiniteValue => {
217 write!(f, "sample contains a value that is not a finite number")
218 }
219 ControlChartError::DefectivesExceedSampleSize {
220 defectives,
221 sample_size,
222 } => write!(
223 f,
224 "{defectives} defectives out of a sample of {sample_size}"
225 ),
226 ControlChartError::NonPositiveUnits => {
227 write!(f, "units inspected must be a positive, finite number")
228 }
229 }
230 }
231}
232
233impl std::error::Error for ControlChartError {}
234
235/// Why a chart computed from a whole slice of samples refused that slice.
236///
237/// Carries the position of the refused sample, which a sample-by-sample
238/// [`ControlChartError`] cannot: the chart functions that take every sample
239/// at once are the only place that position is known.
240#[derive(Debug, Clone, PartialEq, Eq)]
241#[non_exhaustive]
242pub enum ChartInputError {
243 /// The sample at `index` cannot be charted.
244 Sample {
245 /// Zero-based position of the sample in the input slice.
246 index: usize,
247 /// What is wrong with it.
248 error: ControlChartError,
249 },
250 /// Fewer samples than the chart needs.
251 TooFewSamples {
252 /// Samples given.
253 got: usize,
254 /// Samples required.
255 min: usize,
256 },
257 /// A known (Phase I) parameter given with the samples is invalid
258 /// ([`ControlChartError::InvalidStandard`]).
259 Standard(ControlChartError),
260}
261
262impl fmt::Display for ChartInputError {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 match self {
265 ChartInputError::Sample { index, error } => write!(f, "sample {index}: {error}"),
266 ChartInputError::TooFewSamples { got, min } => {
267 write!(f, "at least {min} samples are needed, got {got}")
268 }
269 ChartInputError::Standard(error) => error.fmt(f),
270 }
271 }
272}
273
274impl std::error::Error for ChartInputError {
275 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
276 match self {
277 ChartInputError::Sample { error, .. } | ChartInputError::Standard(error) => Some(error),
278 ChartInputError::TooFewSamples { .. } => None,
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn test_control_limits_construction() {
289 let limits = ControlLimits {
290 ucl: 30.0,
291 cl: 25.0,
292 lcl: 20.0,
293 };
294 assert!((limits.ucl - 30.0).abs() < f64::EPSILON);
295 assert!((limits.cl - 25.0).abs() < f64::EPSILON);
296 assert!((limits.lcl - 20.0).abs() < f64::EPSILON);
297 }
298
299 #[test]
300 fn test_chart_point_construction() {
301 let point = ChartPoint {
302 value: 25.5,
303 index: 0,
304 violations: vec![ViolationType::BeyondLimits],
305 };
306 assert!((point.value - 25.5).abs() < f64::EPSILON);
307 assert_eq!(point.index, 0);
308 assert_eq!(point.violations.len(), 1);
309 assert_eq!(point.violations[0], ViolationType::BeyondLimits);
310 }
311
312 #[test]
313 fn test_violation_type_equality() {
314 assert_eq!(ViolationType::BeyondLimits, ViolationType::BeyondLimits);
315 assert_ne!(ViolationType::BeyondLimits, ViolationType::NineOneSide);
316 }
317
318 #[test]
319 fn test_violation_construction() {
320 let v = Violation {
321 point_index: 5,
322 violation_type: ViolationType::SixTrend,
323 };
324 assert_eq!(v.point_index, 5);
325 assert_eq!(v.violation_type, ViolationType::SixTrend);
326 }
327
328 #[test]
329 fn test_control_limits_clone() {
330 let limits = ControlLimits {
331 ucl: 30.0,
332 cl: 25.0,
333 lcl: 20.0,
334 };
335 let cloned = limits.clone();
336 assert_eq!(limits, cloned);
337 }
338}