Skip to main content

u_analytics/spc/
variables.rs

1//! Variables control charts: X-bar-R, X-bar-S, and Individual-MR.
2//!
3//! These charts monitor continuous (variables) data from a process.
4//! Subgroup charts (X-bar-R, X-bar-S) track the mean and within-subgroup
5//! variation of small samples; the Individual-MR chart handles single
6//! observations.
7//!
8//! # Control Chart Factors
9//!
10//! All constants (A2, D3, D4, d2, A3, B3, B4, c4, E2) are sourced from
11//! ASTM E2587 — Standard Practice for Use of Control Charts in Statistical
12//! Process Control.
13//!
14//! # References
15//!
16//! - Montgomery, D.C. (2020). *Introduction to Statistical Quality Control*, 8th ed.
17//! - ASTM E2587 — Standard Practice for Use of Control Charts
18//! - Shewhart, W.A. (1931). *Economic Control of Quality of Manufactured Product*.
19
20use super::chart::{
21    ChartPoint, ControlChart, ControlChartError, ControlLimits, Violation, ViolationType,
22    MAX_SUBGROUP_SIZE, MIN_SUBGROUP_SIZE,
23};
24use super::rules::{RuleSet, RunRule};
25
26// ---------------------------------------------------------------------------
27// Control chart factor tables, indexed by subgroup size n=2..=25.
28// Index 0 corresponds to n=2.
29//
30// The whole range is computed from the definitions rather than transcribed:
31//
32//   d2(n) = E[W],  d3(n) = sd[W]   for W the range of n iid standard normals
33//   c4(n) = sqrt(2/(n-1)) * Gamma(n/2) / Gamma((n-1)/2)
34//   A2 = 3/(d2*sqrt(n))            A3 = 3/(c4*sqrt(n))
35//   D3 = max(0, 1 - 3*d3/d2)       D4 = 1 + 3*d3/d2
36//   B3 = max(0, 1 - 3*sqrt(1-c4^2)/c4)   B4 = 1 + 3*sqrt(1-c4^2)/c4
37//
38// The test `factor_tables_reproduce_the_published_table` holds all eight
39// tables against a published table for every n = 2..=25 (192 values), so a
40// future edit cannot quietly break them.
41// ---------------------------------------------------------------------------
42
43/// The range-chart and average-chart factors for a subgroup of `n`.
44///
45/// `(a2, d3, d4)`, as tabulated in AIAG's SPC manual: an average chart's limits
46/// are `x̿ ± a2·r̄` and a range chart's are `d3·r̄` and `d4·r̄`.
47///
48/// Exposed because a caller that draws those charts otherwise copies the tables
49/// out of a manual and has nothing to check the copy against. A subgroup size
50/// the tables do not cover is refused rather than approximated -- falling back
51/// to another size's factors draws limits that look right and are not.
52///
53/// # Errors
54/// [`ControlChartError::SubgroupSizeOutOfRange`] outside
55/// `MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE`.
56///
57/// # Examples
58/// ```
59/// use u_analytics::spc::range_chart_factors;
60///
61/// let (a2, d3, d4) = range_chart_factors(2).unwrap();
62/// assert_eq!((a2, d3, d4), (1.880, 0.000, 3.267));
63/// assert!(range_chart_factors(1).is_err());
64/// ```
65pub fn range_chart_factors(n: usize) -> Result<(f64, f64, f64), ControlChartError> {
66    if !(MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE).contains(&n) {
67        return Err(ControlChartError::SubgroupSizeOutOfRange {
68            got: n,
69            min: MIN_SUBGROUP_SIZE,
70            max: MAX_SUBGROUP_SIZE,
71        });
72    }
73    Ok((A2[n - 2], D3[n - 2], D4[n - 2]))
74}
75
76/// A2 factors for X-bar-R chart UCL/LCL computation.
77///
78/// UCL = X-double-bar + A2 * R-bar, LCL = X-double-bar - A2 * R-bar.
79const A2: [f64; 24] = [
80    1.880, 1.023, 0.729, 0.577, 0.483, 0.419, 0.373, 0.337, 0.308, 0.285, 0.266, 0.249, 0.235,
81    0.223, 0.212, 0.203, 0.194, 0.187, 0.180, 0.173, 0.167, 0.162, 0.157, 0.153,
82];
83
84/// D3 factors for R chart lower control limit.
85///
86/// LCL_R = D3 * R-bar.
87const D3: [f64; 24] = [
88    0.000, 0.000, 0.000, 0.000, 0.000, 0.076, 0.136, 0.184, 0.223, 0.256, 0.283, 0.307, 0.328,
89    0.347, 0.363, 0.378, 0.391, 0.404, 0.415, 0.425, 0.435, 0.443, 0.452, 0.459,
90];
91
92/// D4 factors for R chart upper control limit.
93///
94/// UCL_R = D4 * R-bar.
95const D4: [f64; 24] = [
96    3.267, 2.575, 2.282, 2.114, 2.004, 1.924, 1.864, 1.816, 1.777, 1.744, 1.717, 1.693, 1.672,
97    1.653, 1.637, 1.622, 1.609, 1.596, 1.585, 1.575, 1.565, 1.557, 1.548, 1.541,
98];
99
100/// d2 factors (mean of the range distribution) for estimating sigma from R-bar.
101///
102/// sigma-hat = R-bar / d2.
103///
104/// Index 0 corresponds to subgroup size n = 2.
105///
106/// # Reference
107///
108/// Montgomery, D.C. (2020). *Introduction to Statistical Quality Control*, 8th ed.,
109/// Appendix Table VI.
110const D2: [f64; 24] = [
111    // d2(2) is exactly 2/sqrt(pi): the range of two standard normals is
112    // |N(0, 2)|, whose mean is sqrt(2) * sqrt(2/pi).
113    std::f64::consts::FRAC_2_SQRT_PI,
114    1.6926,
115    2.0588,
116    2.3259,
117    2.5344,
118    2.7044,
119    2.8472,
120    2.9700,
121    3.0775,
122    3.1729,
123    3.2585,
124    3.3360,
125    3.4068,
126    3.4718,
127    3.5320,
128    3.5879,
129    3.6401,
130    3.6890,
131    3.7350,
132    3.7783,
133    3.8194,
134    3.8583,
135    3.8953,
136    3.9306,
137];
138
139/// A3 factors for X-bar-S chart UCL/LCL computation.
140///
141/// UCL = X-double-bar + A3 * S-bar, LCL = X-double-bar - A3 * S-bar.
142const A3: [f64; 24] = [
143    2.659, 1.954, 1.628, 1.427, 1.287, 1.182, 1.099, 1.032, 0.975, 0.927, 0.886, 0.850, 0.817,
144    0.789, 0.763, 0.739, 0.718, 0.698, 0.680, 0.663, 0.647, 0.633, 0.619, 0.606,
145];
146
147/// B3 factors for S chart lower control limit.
148///
149/// LCL_S = B3 * S-bar.
150const B3: [f64; 24] = [
151    0.000, 0.000, 0.000, 0.000, 0.030, 0.118, 0.185, 0.239, 0.284, 0.321, 0.354, 0.382, 0.406,
152    0.428, 0.448, 0.466, 0.482, 0.497, 0.510, 0.523, 0.534, 0.545, 0.555, 0.565,
153];
154
155/// B4 factors for S chart upper control limit.
156///
157/// UCL_S = B4 * S-bar.
158const B4: [f64; 24] = [
159    3.267, 2.568, 2.266, 2.089, 1.970, 1.882, 1.815, 1.761, 1.716, 1.679, 1.646, 1.618, 1.594,
160    1.572, 1.552, 1.534, 1.518, 1.503, 1.490, 1.477, 1.466, 1.455, 1.445, 1.435,
161];
162
163/// c4 factors for unbiased estimation of sigma from S-bar.
164///
165/// sigma-hat = S-bar / c4.
166///
167/// Index 0 corresponds to subgroup size n = 2.
168///
169/// # Reference
170///
171/// Montgomery, D.C. (2020). *Introduction to Statistical Quality Control*, 8th ed.,
172/// Appendix Table VI.
173const C4: [f64; 24] = [
174    0.7979, 0.8862, 0.9213, 0.9400, 0.9515, 0.9594, 0.9650, 0.9693, 0.9727, 0.9754, 0.9776, 0.9794,
175    0.9810, 0.9823, 0.9835, 0.9845, 0.9854, 0.9862, 0.9869, 0.9876, 0.9882, 0.9887, 0.9892, 0.9896,
176];
177
178/// Degrees of freedom of a scaled-chi estimate of sigma whose squared
179/// coefficient of variation is `cv2` -- David's (1951) approximation,
180/// `1/A - 3A/16 + 3A^2/64 + 1/4` with `A = 2 cv2`.
181///
182/// A sample standard deviation on `v` degrees of freedom has
183/// `cv2 = 1/(2v) + 1/(8v^2) + ...`, so `1/A + 1/4` recovers `v`; the series
184/// carries that inversion to the next terms. Applied to R-bar/d2 and S-bar/c4,
185/// it is the "equivalent degrees of freedom" the capability intervals need
186/// (one range of five values: 3.83, Patnaik's tabulated 3.8).
187fn equivalent_df(cv2: f64) -> f64 {
188    let a = 2.0 * cv2;
189    1.0 / a - 3.0 * a / 16.0 + 3.0 * a * a / 64.0 + 0.25
190}
191
192/// E2 factor for Individual chart UCL/LCL.
193///
194/// UCL = X-bar + E2 * MR-bar, LCL = X-bar - E2 * MR-bar.
195/// E2 = 3 / d2(n=2) = 3 / 1.128 = 2.6596...
196const E2: f64 = 2.660;
197
198/// D4 factor for MR chart (n=2 moving range).
199const D4_MR: f64 = 3.267;
200
201// ---------------------------------------------------------------------------
202// X-bar-R Chart
203// ---------------------------------------------------------------------------
204
205/// X-bar and Range (X-bar-R) control chart.
206///
207/// Monitors the process mean (X-bar chart) and process variability (R chart)
208/// using subgroup ranges. Accepts subgroup sizes
209/// `MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE`; above about n = 10 the range
210/// discards enough of each subgroup's information that [`XBarSChart`] is the
211/// usual choice.
212///
213/// # Algorithm
214///
215/// 1. For each subgroup, compute the mean (X-bar) and range (R).
216/// 2. Compute the grand mean (X-double-bar) and average range (R-bar).
217/// 3. X-bar chart limits: CL = X-double-bar, UCL/LCL = CL +/- A2 * R-bar.
218/// 4. R chart limits: CL = R-bar, UCL = D4 * R-bar, LCL = D3 * R-bar.
219///
220/// # Examples
221///
222/// ```
223/// use u_analytics::spc::{XBarRChart, ControlChart};
224///
225/// let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
226/// chart.add_sample(&[25.0, 26.0, 24.5, 25.5, 25.0]).unwrap();
227/// chart.add_sample(&[25.2, 24.8, 25.1, 24.9, 25.3]).unwrap();
228/// chart.add_sample(&[25.1, 25.0, 24.7, 25.3, 24.9]).unwrap();
229///
230/// let limits = chart.control_limits().expect("should have limits after 3 samples");
231/// assert!(limits.ucl > limits.cl);
232/// assert!(limits.cl > limits.lcl);
233/// ```
234///
235/// # Reference
236///
237/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
238/// Chapter 6: Control Charts for Variables.
239#[derive(Debug, Clone)]
240pub struct XBarRChart {
241    /// Fixed subgroup size, within `MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE`.
242    subgroup_size: usize,
243    /// Stored subgroups.
244    subgroups: Vec<Vec<f64>>,
245    /// Computed X-bar chart points.
246    xbar_points: Vec<ChartPoint>,
247    /// Computed R chart points.
248    r_points: Vec<ChartPoint>,
249    /// X-bar chart control limits.
250    xbar_limits: Option<ControlLimits>,
251    /// R chart control limits.
252    r_limits: Option<ControlLimits>,
253    /// Run tests applied when limits are computed.
254    rules: RuleSet,
255}
256
257impl XBarRChart {
258    /// Apply only the given run tests when computing limits.
259    ///
260    /// Defaults to [`RuleSet::shewhart`] -- the 3-sigma test alone; the
261    /// supplementary tests raise the false alarm rate and are asked for here.
262    /// Existing points are re-evaluated, so the call may be made at any time.
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// use u_analytics::spc::{XBarRChart, ControlChart, RuleSet, ViolationType};
268    ///
269    /// let chart = XBarRChart::new(5).expect("5 is within range")
270    ///     .with_rules(RuleSet::nelson().without(ViolationType::FourteenAlternating));
271    /// ```
272    #[must_use]
273    pub fn with_rules(mut self, rules: RuleSet) -> Self {
274        self.rules = rules;
275        self.recompute();
276        self
277    }
278
279    /// The run tests this chart applies.
280    pub fn rules(&self) -> RuleSet {
281        self.rules
282    }
283    /// Create a new X-bar-R chart with the given subgroup size.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`ControlChartError::SubgroupSizeOutOfRange`] when
288    /// `subgroup_size` is outside `MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE`.
289    /// The size is data in most callers -- it comes from the measurements a
290    /// user supplied -- so rejecting it is an ordinary outcome rather than a
291    /// contract violation, and a boundary that cannot unwind (WebAssembly, C)
292    /// needs it as a value.
293    pub fn new(subgroup_size: usize) -> Result<Self, ControlChartError> {
294        if !(MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE).contains(&subgroup_size) {
295            return Err(ControlChartError::SubgroupSizeOutOfRange {
296                got: subgroup_size,
297                min: MIN_SUBGROUP_SIZE,
298                max: MAX_SUBGROUP_SIZE,
299            });
300        }
301        Ok(Self {
302            subgroup_size,
303            subgroups: Vec::new(),
304            xbar_points: Vec::new(),
305            r_points: Vec::new(),
306            xbar_limits: None,
307            r_limits: None,
308            rules: RuleSet::default(),
309        })
310    }
311
312    /// Short-term (within-subgroup) sigma estimated from this chart:
313    /// `sigma-hat = R-bar / d2(n)`.
314    ///
315    /// This is the quantity a capability study needs for Cp/Cpk, and it cannot
316    /// be recovered from a flat measurement vector — it depends on the subgroup
317    /// structure the chart already holds. Returning it here is what lets a
318    /// caller feed [`crate::capability::ProcessCapability::compute`] without
319    /// reimplementing the d2 table.
320    ///
321    /// Returns `None` if there is not enough data for control limits.
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// use u_analytics::spc::{ControlChart, XBarRChart};
327    ///
328    /// let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
329    /// for g in [[9.9, 10.1, 10.0, 9.8, 10.2], [10.3, 9.7, 10.0, 10.1, 9.9]] {
330    ///     chart.add_sample(&g).unwrap();
331    /// }
332    /// let sigma = chart.sigma_hat().expect("limits available");
333    /// assert!(sigma > 0.0);
334    /// ```
335    pub fn sigma_hat(&self) -> Option<f64> {
336        let r_bar = self.r_limits.as_ref()?.cl;
337        let d2 = D2[self.subgroup_size - 2];
338        (d2 > 0.0).then(|| r_bar / d2)
339    }
340
341    /// Degrees of freedom of [`sigma_hat`](Self::sigma_hat) -- what
342    /// [`CapabilityIndices::intervals`](crate::capability::CapabilityIndices::intervals)
343    /// takes as `within_df` for Cp/Cpk intervals.
344    ///
345    /// R-bar of `m` subgroups of `n` has squared coefficient of variation
346    /// `(d3/d2)^2 / m`; David's (1951) approximation turns it into the degrees
347    /// of freedom of a sample standard deviation with the same precision --
348    /// fewer than the `m (n - 1)` a pooled standard deviation would have, the
349    /// price of using the range. `d3/d2` is `(D4 - 1) / 3`.
350    ///
351    /// Returns `None` if there is not enough data for control limits.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use u_analytics::spc::{ControlChart, XBarRChart};
357    ///
358    /// let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
359    /// for g in [[9.9, 10.1, 10.0, 9.8, 10.2], [10.3, 9.7, 10.0, 10.1, 9.9]] {
360    ///     chart.add_sample(&g).unwrap();
361    /// }
362    /// let df = chart.sigma_hat_df().expect("limits available");
363    /// assert!(df > 7.0 && df < 8.0); // two ranges of five: fewer than 2 x 4
364    /// ```
365    pub fn sigma_hat_df(&self) -> Option<f64> {
366        self.r_limits.as_ref()?;
367        let cv = (D4[self.subgroup_size - 2] - 1.0) / 3.0;
368        Some(equivalent_df(cv * cv / self.subgroups.len() as f64))
369    }
370
371    /// Get the R chart control limits, or `None` if insufficient data.
372    pub fn r_limits(&self) -> Option<ControlLimits> {
373        self.r_limits.clone()
374    }
375
376    /// Get the R chart points.
377    pub fn r_points(&self) -> &[ChartPoint] {
378        &self.r_points
379    }
380
381    /// Recompute limits and points from stored subgroups.
382    fn recompute(&mut self) {
383        if self.subgroups.is_empty() {
384            self.xbar_limits = None;
385            self.r_limits = None;
386            self.xbar_points.clear();
387            self.r_points.clear();
388            return;
389        }
390
391        let idx = self.subgroup_size - 2; // Factor table index
392
393        // Compute subgroup means and ranges
394        let mut xbar_values = Vec::with_capacity(self.subgroups.len());
395        let mut r_values = Vec::with_capacity(self.subgroups.len());
396
397        for subgroup in &self.subgroups {
398            let mean_val = u_numflow::stats::mean(subgroup)
399                .expect("subgroup should be non-empty with finite values");
400            let range = subgroup_range(subgroup);
401            xbar_values.push(mean_val);
402            r_values.push(range);
403        }
404
405        // Grand mean and average range
406        let grand_mean = u_numflow::stats::mean(&xbar_values)
407            .expect("xbar_values should be non-empty with finite values");
408        let r_bar = u_numflow::stats::mean(&r_values)
409            .expect("r_values should be non-empty with finite values");
410
411        // X-bar chart limits
412        let a2 = A2[idx];
413        self.xbar_limits = Some(ControlLimits {
414            ucl: grand_mean + a2 * r_bar,
415            cl: grand_mean,
416            lcl: grand_mean - a2 * r_bar,
417        });
418
419        // R chart limits
420        let d3 = D3[idx];
421        let d4 = D4[idx];
422        self.r_limits = Some(ControlLimits {
423            ucl: d4 * r_bar,
424            cl: r_bar,
425            lcl: d3 * r_bar,
426        });
427
428        // Build points
429        self.xbar_points = xbar_values
430            .iter()
431            .enumerate()
432            .map(|(i, &v)| ChartPoint {
433                value: v,
434                index: i,
435                violations: Vec::new(),
436            })
437            .collect();
438
439        self.r_points = r_values
440            .iter()
441            .enumerate()
442            .map(|(i, &v)| ChartPoint {
443                value: v,
444                index: i,
445                violations: Vec::new(),
446            })
447            .collect();
448
449        // Apply the chart's run tests to the X-bar chart
450        if let Some(ref limits) = self.xbar_limits {
451            let violations = self.rules.check(&self.xbar_points, limits);
452            apply_violations(&mut self.xbar_points, &violations);
453        }
454
455        // Apply the chart's run tests to the R chart
456        if let Some(ref limits) = self.r_limits {
457            let violations = self.rules.check(&self.r_points, limits);
458            apply_violations(&mut self.r_points, &violations);
459        }
460    }
461}
462
463/// The check every variables chart applies to a sample before taking it.
464fn check_sample(sample: &[f64], expected: usize) -> Result<(), ControlChartError> {
465    if sample.len() != expected {
466        return Err(ControlChartError::SampleLengthMismatch {
467            expected,
468            got: sample.len(),
469        });
470    }
471    if !sample.iter().all(|x| x.is_finite()) {
472        return Err(ControlChartError::NonFiniteValue);
473    }
474    Ok(())
475}
476
477impl ControlChart for XBarRChart {
478    /// Add a subgroup sample. The sample length must equal the chart's subgroup size.
479    fn add_sample(&mut self, sample: &[f64]) -> Result<(), ControlChartError> {
480        check_sample(sample, self.subgroup_size)?;
481        self.subgroups.push(sample.to_vec());
482        self.recompute();
483        Ok(())
484    }
485
486    fn control_limits(&self) -> Option<ControlLimits> {
487        self.xbar_limits.clone()
488    }
489
490    fn is_in_control(&self) -> bool {
491        self.xbar_points.iter().all(|p| p.violations.is_empty())
492            && self.r_points.iter().all(|p| p.violations.is_empty())
493    }
494
495    fn violations(&self) -> Vec<Violation> {
496        collect_violations(&self.xbar_points)
497            .into_iter()
498            .chain(collect_violations(&self.r_points))
499            .collect()
500    }
501
502    fn points(&self) -> &[ChartPoint] {
503        &self.xbar_points
504    }
505}
506
507// ---------------------------------------------------------------------------
508// X-bar-S Chart
509// ---------------------------------------------------------------------------
510
511/// X-bar and Standard Deviation (X-bar-S) control chart.
512///
513/// Monitors the process mean (X-bar chart) and process variability (S chart)
514/// using subgroup standard deviations. Preferred over X-bar-R for larger
515/// subgroups where range is a less efficient estimator.
516///
517/// # Algorithm
518///
519/// 1. For each subgroup, compute the mean (X-bar) and sample standard deviation (S).
520/// 2. Compute the grand mean (X-double-bar) and average S (S-bar).
521/// 3. X-bar chart limits: CL = X-double-bar, UCL/LCL = CL +/- A3 * S-bar.
522/// 4. S chart limits: CL = S-bar, UCL = B4 * S-bar, LCL = B3 * S-bar.
523///
524/// # Reference
525///
526/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
527/// Chapter 6: Control Charts for Variables.
528#[derive(Debug, Clone)]
529pub struct XBarSChart {
530    /// Fixed subgroup size, within `MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE`.
531    subgroup_size: usize,
532    /// Stored subgroups.
533    subgroups: Vec<Vec<f64>>,
534    /// Computed X-bar chart points.
535    xbar_points: Vec<ChartPoint>,
536    /// Computed S chart points.
537    s_points: Vec<ChartPoint>,
538    /// X-bar chart control limits.
539    xbar_limits: Option<ControlLimits>,
540    /// S chart control limits.
541    s_limits: Option<ControlLimits>,
542    /// Run tests applied when limits are computed.
543    rules: RuleSet,
544}
545
546impl XBarSChart {
547    /// Apply only the given run tests when computing limits.
548    ///
549    /// Defaults to [`RuleSet::shewhart`] -- the 3-sigma test alone; the
550    /// supplementary tests raise the false alarm rate and are asked for here.
551    /// Existing points are re-evaluated, so the call may be made at any time.
552    ///
553    /// # Examples
554    ///
555    /// ```
556    /// use u_analytics::spc::{XBarSChart, ControlChart, RuleSet, ViolationType};
557    ///
558    /// let chart = XBarSChart::new(5).expect("5 is within range")
559    ///     .with_rules(RuleSet::nelson().without(ViolationType::FourteenAlternating));
560    /// ```
561    #[must_use]
562    pub fn with_rules(mut self, rules: RuleSet) -> Self {
563        self.rules = rules;
564        self.recompute();
565        self
566    }
567
568    /// The run tests this chart applies.
569    pub fn rules(&self) -> RuleSet {
570        self.rules
571    }
572    /// Create a new X-bar-S chart with the given subgroup size.
573    ///
574    /// # Errors
575    ///
576    /// Returns [`ControlChartError::SubgroupSizeOutOfRange`] when
577    /// `subgroup_size` is outside `MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE`.
578    /// The size is data in most callers -- it comes from the measurements a
579    /// user supplied -- so rejecting it is an ordinary outcome rather than a
580    /// contract violation, and a boundary that cannot unwind (WebAssembly, C)
581    /// needs it as a value.
582    pub fn new(subgroup_size: usize) -> Result<Self, ControlChartError> {
583        if !(MIN_SUBGROUP_SIZE..=MAX_SUBGROUP_SIZE).contains(&subgroup_size) {
584            return Err(ControlChartError::SubgroupSizeOutOfRange {
585                got: subgroup_size,
586                min: MIN_SUBGROUP_SIZE,
587                max: MAX_SUBGROUP_SIZE,
588            });
589        }
590        Ok(Self {
591            subgroup_size,
592            subgroups: Vec::new(),
593            xbar_points: Vec::new(),
594            s_points: Vec::new(),
595            xbar_limits: None,
596            s_limits: None,
597            rules: RuleSet::default(),
598        })
599    }
600
601    /// Short-term (within-subgroup) sigma estimated from this chart:
602    /// `sigma-hat = S-bar / c4(n)`.
603    ///
604    /// The S-chart counterpart of [`XBarRChart::sigma_hat`], and the estimator
605    /// preferred for larger subgroups. Returns `None` if there is not enough
606    /// data for control limits.
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use u_analytics::spc::{ControlChart, XBarSChart};
612    ///
613    /// let mut chart = XBarSChart::new(5).expect("subgroup size is in range");
614    /// for g in [[9.9, 10.1, 10.0, 9.8, 10.2], [10.3, 9.7, 10.0, 10.1, 9.9]] {
615    ///     chart.add_sample(&g).unwrap();
616    /// }
617    /// let sigma = chart.sigma_hat().expect("limits available");
618    /// assert!(sigma > 0.0);
619    /// ```
620    pub fn sigma_hat(&self) -> Option<f64> {
621        let s_bar = self.s_limits.as_ref()?.cl;
622        let c4 = C4[self.subgroup_size - 2];
623        (c4 > 0.0).then(|| s_bar / c4)
624    }
625
626    /// Degrees of freedom of [`sigma_hat`](Self::sigma_hat), for Cp/Cpk
627    /// intervals: S-bar of `m` subgroups has squared coefficient of variation
628    /// `(1 - c4^2) / (m c4^2)`, turned into degrees of freedom by David's
629    /// approximation -- a little under the `m (n - 1)` of a pooled standard
630    /// deviation.
631    ///
632    /// Returns `None` if there is not enough data for control limits.
633    pub fn sigma_hat_df(&self) -> Option<f64> {
634        self.s_limits.as_ref()?;
635        let c4 = C4[self.subgroup_size - 2];
636        Some(equivalent_df(
637            (1.0 - c4 * c4) / (c4 * c4 * self.subgroups.len() as f64),
638        ))
639    }
640
641    /// Get the S chart control limits, or `None` if insufficient data.
642    pub fn s_limits(&self) -> Option<ControlLimits> {
643        self.s_limits.clone()
644    }
645
646    /// Get the S chart points.
647    pub fn s_points(&self) -> &[ChartPoint] {
648        &self.s_points
649    }
650
651    /// Recompute limits and points from stored subgroups.
652    fn recompute(&mut self) {
653        if self.subgroups.is_empty() {
654            self.xbar_limits = None;
655            self.s_limits = None;
656            self.xbar_points.clear();
657            self.s_points.clear();
658            return;
659        }
660
661        let idx = self.subgroup_size - 2;
662
663        // Compute subgroup means and standard deviations
664        let mut xbar_values = Vec::with_capacity(self.subgroups.len());
665        let mut s_values = Vec::with_capacity(self.subgroups.len());
666
667        for subgroup in &self.subgroups {
668            let mean_val = u_numflow::stats::mean(subgroup)
669                .expect("subgroup should be non-empty with finite values");
670            let sd = u_numflow::stats::std_dev(subgroup)
671                .expect("subgroup should have >= 2 elements for std_dev");
672            xbar_values.push(mean_val);
673            s_values.push(sd);
674        }
675
676        // Grand mean and average S
677        let grand_mean = u_numflow::stats::mean(&xbar_values)
678            .expect("xbar_values should be non-empty with finite values");
679        let s_bar = u_numflow::stats::mean(&s_values)
680            .expect("s_values should be non-empty with finite values");
681
682        // X-bar chart limits
683        let a3 = A3[idx];
684        self.xbar_limits = Some(ControlLimits {
685            ucl: grand_mean + a3 * s_bar,
686            cl: grand_mean,
687            lcl: grand_mean - a3 * s_bar,
688        });
689
690        // S chart limits
691        let b3 = B3[idx];
692        let b4 = B4[idx];
693        self.s_limits = Some(ControlLimits {
694            ucl: b4 * s_bar,
695            cl: s_bar,
696            lcl: b3 * s_bar,
697        });
698
699        // Build points
700        self.xbar_points = xbar_values
701            .iter()
702            .enumerate()
703            .map(|(i, &v)| ChartPoint {
704                value: v,
705                index: i,
706                violations: Vec::new(),
707            })
708            .collect();
709
710        self.s_points = s_values
711            .iter()
712            .enumerate()
713            .map(|(i, &v)| ChartPoint {
714                value: v,
715                index: i,
716                violations: Vec::new(),
717            })
718            .collect();
719
720        // Apply the chart's run tests to the X-bar chart
721        if let Some(ref limits) = self.xbar_limits {
722            let violations = self.rules.check(&self.xbar_points, limits);
723            apply_violations(&mut self.xbar_points, &violations);
724        }
725
726        // Apply the chart's run tests to the S chart
727        if let Some(ref limits) = self.s_limits {
728            let violations = self.rules.check(&self.s_points, limits);
729            apply_violations(&mut self.s_points, &violations);
730        }
731    }
732}
733
734impl ControlChart for XBarSChart {
735    /// Add a subgroup sample. The sample length must equal the chart's subgroup size.
736    fn add_sample(&mut self, sample: &[f64]) -> Result<(), ControlChartError> {
737        check_sample(sample, self.subgroup_size)?;
738        self.subgroups.push(sample.to_vec());
739        self.recompute();
740        Ok(())
741    }
742
743    fn control_limits(&self) -> Option<ControlLimits> {
744        self.xbar_limits.clone()
745    }
746
747    fn is_in_control(&self) -> bool {
748        self.xbar_points.iter().all(|p| p.violations.is_empty())
749            && self.s_points.iter().all(|p| p.violations.is_empty())
750    }
751
752    fn violations(&self) -> Vec<Violation> {
753        collect_violations(&self.xbar_points)
754            .into_iter()
755            .chain(collect_violations(&self.s_points))
756            .collect()
757    }
758
759    fn points(&self) -> &[ChartPoint] {
760        &self.xbar_points
761    }
762}
763
764// ---------------------------------------------------------------------------
765// Individual-MR Chart
766// ---------------------------------------------------------------------------
767
768/// Individual and Moving Range (I-MR) control chart.
769///
770/// Monitors individual observations (subgroup size = 1) using the moving range
771/// of consecutive observations to estimate process variability.
772///
773/// # Algorithm
774///
775/// 1. Compute moving ranges: MR_i = |x_i - x_{i-1}| for i >= 1.
776/// 2. Compute the mean of individual observations (X-bar) and the average
777///    moving range (MR-bar).
778/// 3. I chart limits: CL = X-bar, UCL/LCL = X-bar +/- E2 * MR-bar.
779/// 4. MR chart limits: CL = MR-bar, UCL = D4 * MR-bar, LCL = 0.
780///
781/// # Examples
782///
783/// ```
784/// use u_analytics::spc::{IndividualMRChart, ControlChart};
785///
786/// let mut chart = IndividualMRChart::new();
787/// for &x in &[25.0, 25.2, 24.8, 25.1, 24.9, 25.3, 25.0, 24.7] {
788///     chart.add_sample(&[x]).unwrap();
789/// }
790///
791/// let limits = chart.control_limits().expect("should have limits after 2+ observations");
792/// assert!(limits.ucl > limits.cl);
793/// assert!(limits.cl > limits.lcl);
794/// ```
795///
796/// # Reference
797///
798/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
799/// Chapter 6: Control Charts for Variables.
800#[derive(Debug, Clone)]
801pub struct IndividualMRChart {
802    /// Individual observations.
803    observations: Vec<f64>,
804    /// Computed I chart points.
805    i_points: Vec<ChartPoint>,
806    /// Computed MR chart points.
807    mr_points: Vec<ChartPoint>,
808    /// I chart control limits.
809    i_limits: Option<ControlLimits>,
810    /// MR chart control limits.
811    mr_limits: Option<ControlLimits>,
812    /// Run tests applied when limits are computed.
813    rules: RuleSet,
814}
815
816impl IndividualMRChart {
817    /// Apply only the given run tests when computing limits.
818    ///
819    /// Defaults to [`RuleSet::shewhart`] -- the 3-sigma test alone; the
820    /// supplementary tests raise the false alarm rate and are asked for here.
821    /// Existing points are re-evaluated, so the call may be made at any time.
822    ///
823    /// # Examples
824    ///
825    /// ```
826    /// use u_analytics::spc::{IndividualMRChart, ControlChart, RuleSet, ViolationType};
827    ///
828    /// let chart = IndividualMRChart::new()
829    ///     .with_rules(RuleSet::nelson().without(ViolationType::FourteenAlternating));
830    /// ```
831    #[must_use]
832    pub fn with_rules(mut self, rules: RuleSet) -> Self {
833        self.rules = rules;
834        self.recompute();
835        self
836    }
837
838    /// The run tests this chart applies.
839    pub fn rules(&self) -> RuleSet {
840        self.rules
841    }
842    /// Create a new Individual-MR chart.
843    pub fn new() -> Self {
844        Self {
845            observations: Vec::new(),
846            i_points: Vec::new(),
847            mr_points: Vec::new(),
848            i_limits: None,
849            mr_limits: None,
850            rules: RuleSet::default(),
851        }
852    }
853
854    /// Short-term sigma estimated from this chart:
855    /// `sigma-hat = MR-bar / d2(2)`.
856    ///
857    /// The moving range of consecutive observations is a range of two, so
858    /// `d2(2) = 2/sqrt(pi)` is the unbiasing constant. This is the within
859    /// sigma a capability study of individual observations uses, and the
860    /// counterpart of [`XBarRChart::sigma_hat`] for subgroups of one.
861    ///
862    /// Returns `None` if there is not enough data for control limits.
863    ///
864    /// # Examples
865    ///
866    /// ```
867    /// use u_analytics::spc::{ControlChart, IndividualMRChart};
868    ///
869    /// let mut chart = IndividualMRChart::new();
870    /// for x in [1.0, 3.0, 2.0] {
871    ///     chart.add_sample(&[x]).unwrap();
872    /// }
873    /// // MR-bar = (2 + 1) / 2 = 1.5
874    /// let sigma = chart.sigma_hat().expect("limits available");
875    /// assert!((sigma - 1.5 / (2.0 / std::f64::consts::PI.sqrt())).abs() < 1e-12);
876    /// ```
877    pub fn sigma_hat(&self) -> Option<f64> {
878        let mr_bar = self.mr_limits.as_ref()?.cl;
879        Some(mr_bar / D2[0])
880    }
881
882    /// Degrees of freedom of [`sigma_hat`](Self::sigma_hat), for Cp/Cpk
883    /// intervals: `0.62 (N - 1)` for `N` observations. Successive moving
884    /// ranges share a value, so they are not independent and no
885    /// range-distribution formula applies; the factor is Wheeler's empirical
886    /// one (as JMP uses it).
887    ///
888    /// Returns `None` if there is not enough data for control limits.
889    pub fn sigma_hat_df(&self) -> Option<f64> {
890        self.mr_limits.as_ref()?;
891        Some(0.62 * (self.observations.len() - 1) as f64)
892    }
893
894    /// Get the MR chart control limits, or `None` if insufficient data.
895    pub fn mr_limits(&self) -> Option<ControlLimits> {
896        self.mr_limits.clone()
897    }
898
899    /// Get the MR chart points.
900    pub fn mr_points(&self) -> &[ChartPoint] {
901        &self.mr_points
902    }
903
904    /// Recompute limits and points from stored observations.
905    fn recompute(&mut self) {
906        if self.observations.len() < 2 {
907            self.i_limits = None;
908            self.mr_limits = None;
909            self.i_points.clear();
910            self.mr_points.clear();
911            return;
912        }
913
914        // Compute moving ranges
915        let mr_values: Vec<f64> = self
916            .observations
917            .windows(2)
918            .map(|w| (w[1] - w[0]).abs())
919            .collect();
920
921        // X-bar and MR-bar
922        let x_bar = u_numflow::stats::mean(&self.observations)
923            .expect("observations should be non-empty with finite values");
924        let mr_bar = u_numflow::stats::mean(&mr_values)
925            .expect("mr_values should be non-empty with finite values");
926
927        // I chart limits
928        self.i_limits = Some(ControlLimits {
929            ucl: x_bar + E2 * mr_bar,
930            cl: x_bar,
931            lcl: x_bar - E2 * mr_bar,
932        });
933
934        // MR chart limits (LCL is always 0 for n=2)
935        self.mr_limits = Some(ControlLimits {
936            ucl: D4_MR * mr_bar,
937            cl: mr_bar,
938            lcl: 0.0,
939        });
940
941        // Build I chart points
942        self.i_points = self
943            .observations
944            .iter()
945            .enumerate()
946            .map(|(i, &v)| ChartPoint {
947                value: v,
948                index: i,
949                violations: Vec::new(),
950            })
951            .collect();
952
953        // Build MR chart points (starts at index 1, since MR_0 is undefined)
954        self.mr_points = mr_values
955            .iter()
956            .enumerate()
957            .map(|(i, &v)| ChartPoint {
958                value: v,
959                index: i + 1,
960                violations: Vec::new(),
961            })
962            .collect();
963
964        // Apply the chart's run tests to the I chart
965        if let Some(ref limits) = self.i_limits {
966            let violations = self.rules.check(&self.i_points, limits);
967            apply_violations(&mut self.i_points, &violations);
968        }
969
970        // Apply the chart's run tests to the MR chart
971        if let Some(ref limits) = self.mr_limits {
972            let violations = self.rules.check(&self.mr_points, limits);
973            apply_violations(&mut self.mr_points, &violations);
974        }
975    }
976}
977
978impl Default for IndividualMRChart {
979    fn default() -> Self {
980        Self::new()
981    }
982}
983
984impl ControlChart for IndividualMRChart {
985    /// Add a single observation. The sample slice must contain exactly one element.
986    fn add_sample(&mut self, sample: &[f64]) -> Result<(), ControlChartError> {
987        check_sample(sample, 1)?;
988        self.observations.push(sample[0]);
989        self.recompute();
990        Ok(())
991    }
992
993    fn control_limits(&self) -> Option<ControlLimits> {
994        self.i_limits.clone()
995    }
996
997    fn is_in_control(&self) -> bool {
998        self.i_points.iter().all(|p| p.violations.is_empty())
999            && self.mr_points.iter().all(|p| p.violations.is_empty())
1000    }
1001
1002    fn violations(&self) -> Vec<Violation> {
1003        collect_violations(&self.i_points)
1004            .into_iter()
1005            .chain(collect_violations(&self.mr_points))
1006            .collect()
1007    }
1008
1009    fn points(&self) -> &[ChartPoint] {
1010        &self.i_points
1011    }
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Helpers
1016// ---------------------------------------------------------------------------
1017
1018/// Compute the range (max - min) of a subgroup.
1019///
1020/// # Panics
1021///
1022/// Uses `expect` — callers must ensure `subgroup` is non-empty with finite values.
1023fn subgroup_range(subgroup: &[f64]) -> f64 {
1024    let max_val =
1025        u_numflow::stats::max(subgroup).expect("subgroup should be non-empty without NaN");
1026    let min_val =
1027        u_numflow::stats::min(subgroup).expect("subgroup should be non-empty without NaN");
1028    max_val - min_val
1029}
1030
1031/// Apply a list of violations to chart points, matching by index.
1032fn apply_violations(points: &mut [ChartPoint], violations: &[(usize, ViolationType)]) {
1033    for &(idx, vtype) in violations {
1034        if let Some(point) = points.iter_mut().find(|p| p.index == idx) {
1035            point.violations.push(vtype);
1036        }
1037    }
1038}
1039
1040/// Collect all violations from chart points into a flat list.
1041fn collect_violations(points: &[ChartPoint]) -> Vec<Violation> {
1042    let mut result = Vec::new();
1043    for point in points {
1044        for &vtype in &point.violations {
1045            result.push(Violation {
1046                point_index: point.index,
1047                violation_type: vtype,
1048            });
1049        }
1050    }
1051    result
1052}
1053
1054// ---------------------------------------------------------------------------
1055// Tests
1056// ---------------------------------------------------------------------------
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061
1062    // --- XBarRChart ---
1063
1064    #[test]
1065    fn test_xbar_r_basic_limits() {
1066        let mut chart = XBarRChart::new(4).expect("subgroup size is in range");
1067        chart.add_sample(&[72.0, 84.0, 79.0, 49.0]).unwrap();
1068        chart.add_sample(&[56.0, 87.0, 33.0, 42.0]).unwrap();
1069        chart.add_sample(&[55.0, 73.0, 22.0, 60.0]).unwrap();
1070        chart.add_sample(&[44.0, 80.0, 54.0, 74.0]).unwrap();
1071        chart.add_sample(&[97.0, 26.0, 48.0, 58.0]).unwrap();
1072
1073        let limits = chart.control_limits().expect("should have limits");
1074        // Subgroup means: 71.0, 54.5, 52.5, 63.0, 57.25
1075        let expected_grand_mean = (71.0 + 54.5 + 52.5 + 63.0 + 57.25) / 5.0;
1076        assert!(
1077            (limits.cl - expected_grand_mean).abs() < 0.1,
1078            "CL={}, expected ~{expected_grand_mean}",
1079            limits.cl
1080        );
1081
1082        // Verify UCL > CL > LCL
1083        assert!(limits.ucl > limits.cl);
1084        assert!(limits.cl > limits.lcl);
1085    }
1086
1087    #[test]
1088    fn test_xbar_r_rejects_wrong_size() {
1089        let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
1090        assert_eq!(
1091            chart.add_sample(&[1.0, 2.0, 3.0]),
1092            Err(ControlChartError::SampleLengthMismatch {
1093                expected: 5,
1094                got: 3
1095            })
1096        );
1097        assert!(chart.control_limits().is_none());
1098    }
1099
1100    #[test]
1101    fn test_xbar_r_rejects_nan() {
1102        let mut chart = XBarRChart::new(3).expect("subgroup size is in range");
1103        assert_eq!(
1104            chart.add_sample(&[1.0, f64::NAN, 3.0]),
1105            Err(ControlChartError::NonFiniteValue)
1106        );
1107        assert!(chart.control_limits().is_none());
1108    }
1109
1110    #[test]
1111    fn test_xbar_r_r_chart_limits() {
1112        let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
1113        chart.add_sample(&[10.0, 12.0, 11.0, 13.0, 14.0]).unwrap();
1114        chart.add_sample(&[11.0, 13.0, 12.0, 10.0, 15.0]).unwrap();
1115        chart.add_sample(&[12.0, 11.0, 14.0, 13.0, 10.0]).unwrap();
1116
1117        let r_limits = chart.r_limits().expect("should have R limits");
1118        assert!(r_limits.ucl > r_limits.cl);
1119        assert!(r_limits.lcl >= 0.0);
1120    }
1121
1122    #[test]
1123    fn test_xbar_r_constant_subgroups() {
1124        // All identical values: R-bar = 0, limits collapse
1125        let mut chart = XBarRChart::new(3).expect("subgroup size is in range");
1126        chart.add_sample(&[10.0, 10.0, 10.0]).unwrap();
1127        chart.add_sample(&[10.0, 10.0, 10.0]).unwrap();
1128
1129        let limits = chart.control_limits().expect("should have limits");
1130        assert!((limits.cl - 10.0).abs() < f64::EPSILON);
1131        assert!((limits.ucl - 10.0).abs() < f64::EPSILON);
1132        assert!((limits.lcl - 10.0).abs() < f64::EPSILON);
1133    }
1134
1135    #[test]
1136    fn test_xbar_r_detects_out_of_control() {
1137        let mut chart = XBarRChart::new(3).expect("subgroup size is in range");
1138        for _ in 0..5 {
1139            chart.add_sample(&[10.0, 10.5, 9.5]).unwrap();
1140        }
1141        // Add an outlier subgroup
1142        chart.add_sample(&[50.0, 51.0, 49.0]).unwrap();
1143
1144        assert!(!chart.is_in_control());
1145    }
1146
1147    /// The n=2..=10 half of every factor table must still be the ASTM E2587
1148    /// published constants.
1149    ///
1150    /// The tables are computed from the definitions rather than transcribed, so
1151    /// this is the check that licenses that: agreement with the published values
1152    /// over the range a standard covers is the evidence that the extension to
1153    /// n=25 is the same quantity and not a plausible-looking different one.
1154    #[test]
1155    fn factor_tables_reproduce_the_published_table() {
1156        // "Factors useful in the Construction of Control Charts", n = 2..=25 --
1157        // the table of Duncan, *Quality Control and Industrial Statistics*, as
1158        // reproduced by the University of York (histstat/tables/controlfactors.pdf);
1159        // n = 2..=10 agree with ASTM E2587 and Montgomery, Appendix VI.
1160        const PUB_A2: [f64; 24] = [
1161            1.880, 1.023, 0.729, 0.577, 0.483, 0.419, 0.373, 0.337, 0.308, 0.285, 0.266, 0.249,
1162            0.235, 0.223, 0.212, 0.203, 0.194, 0.187, 0.180, 0.173, 0.167, 0.162, 0.157, 0.153,
1163        ];
1164        const PUB_A3: [f64; 24] = [
1165            2.659, 1.954, 1.628, 1.427, 1.287, 1.182, 1.099, 1.032, 0.975, 0.927, 0.886, 0.850,
1166            0.817, 0.789, 0.763, 0.739, 0.718, 0.698, 0.680, 0.663, 0.647, 0.633, 0.619, 0.606,
1167        ];
1168        const PUB_C4: [f64; 24] = [
1169            0.7979, 0.8862, 0.9213, 0.9400, 0.9515, 0.9594, 0.9650, 0.9693, 0.9727, 0.9754, 0.9776,
1170            0.9794, 0.9810, 0.9823, 0.9835, 0.9845, 0.9854, 0.9862, 0.9869, 0.9876, 0.9882, 0.9887,
1171            0.9892, 0.9896,
1172        ];
1173        const PUB_B3: [f64; 24] = [
1174            0.0, 0.0, 0.0, 0.0, 0.030, 0.118, 0.185, 0.239, 0.284, 0.321, 0.354, 0.382, 0.406,
1175            0.428, 0.448, 0.466, 0.482, 0.497, 0.510, 0.523, 0.534, 0.545, 0.555, 0.565,
1176        ];
1177        const PUB_B4: [f64; 24] = [
1178            3.267, 2.568, 2.266, 2.089, 1.970, 1.882, 1.815, 1.761, 1.716, 1.679, 1.646, 1.618,
1179            1.594, 1.572, 1.552, 1.534, 1.518, 1.503, 1.490, 1.477, 1.466, 1.455, 1.445, 1.435,
1180        ];
1181        const PUB_D2: [f64; 24] = [
1182            1.128, 1.693, 2.059, 2.326, 2.534, 2.704, 2.847, 2.970, 3.078, 3.173, 3.258, 3.336,
1183            3.407, 3.472, 3.532, 3.588, 3.640, 3.689, 3.735, 3.778, 3.819, 3.858, 3.895, 3.931,
1184        ];
1185        const PUB_D3: [f64; 24] = [
1186            0.0, 0.0, 0.0, 0.0, 0.0, 0.076, 0.137, 0.184, 0.223, 0.256, 0.284, 0.307, 0.328, 0.347,
1187            0.363, 0.378, 0.392, 0.404, 0.415, 0.425, 0.435, 0.444, 0.452, 0.460,
1188        ];
1189        const PUB_D4: [f64; 24] = [
1190            3.266, 2.574, 2.281, 2.114, 2.003, 1.924, 1.863, 1.816, 1.777, 1.744, 1.716, 1.693,
1191            1.672, 1.653, 1.637, 1.622, 1.608, 1.596, 1.585, 1.575, 1.565, 1.556, 1.548, 1.540,
1192        ];
1193
1194        // Half a unit in the table's last place: the crate's values are the
1195        // definitions rounded the same way -- d2 to four places where the table
1196        // has three, so its bound adds the crate's own half unit (3.2585 is
1197        // d2(12) = 3.25846 rounded; the table prints 3.258).
1198        for (name, table, published, tol) in [
1199            ("A2", &A2, &PUB_A2, 5e-4),
1200            ("A3", &A3, &PUB_A3, 5e-4),
1201            ("c4", &C4, &PUB_C4, 5e-5),
1202            ("B3", &B3, &PUB_B3, 5e-4),
1203            ("B4", &B4, &PUB_B4, 5e-4),
1204            ("d2", &D2, &PUB_D2, 5.5e-4),
1205        ] {
1206            for (i, (got, want)) in table.iter().zip(published).enumerate() {
1207                assert!(
1208                    (got - want).abs() <= tol,
1209                    "{name}[n={}] = {got}, published {want}",
1210                    i + 2
1211                );
1212            }
1213        }
1214
1215        // D3 and D4 rest on d3, the standard deviation of the range, whose
1216        // fourth significant figure the older tables carry in doubt for n > 5
1217        // (Duncan's own caveat). This table's D3/D4 differ from the rounded
1218        // definition by one unit in the last place at n = 2, 6, 8, 12, 18, 23,
1219        // 25 -- e.g. n = 8: d3/d2 = 0.8198/2.8472 gives D3 = 0.1362, D4 =
1220        // 1.8638, which ASTM E2587 prints as 0.136 / 1.864 and this table as
1221        // 0.137 / 1.863. So D3/D4 are held to one unit in the last place, and
1222        // D3 + D4 = 2 exactly wherever D3 > 0 (both are 1 -/+ 3 d3/d2).
1223        for (name, table, published) in [("D3", &D3, &PUB_D3), ("D4", &D4, &PUB_D4)] {
1224            for (i, (got, want)) in table.iter().zip(published).enumerate() {
1225                assert!(
1226                    (got - want).abs() <= 1e-3 + 1e-12,
1227                    "{name}[n={}] = {got}, published {want}",
1228                    i + 2
1229                );
1230            }
1231        }
1232        for i in 0..24 {
1233            if D3[i] > 0.0 {
1234                assert!(
1235                    (D3[i] + D4[i] - 2.0).abs() < 1e-12,
1236                    "D3 + D4 at n = {} is {}",
1237                    i + 2,
1238                    D3[i] + D4[i]
1239                );
1240            }
1241        }
1242    }
1243
1244    #[test]
1245    fn test_xbar_r_subgroup_size_below_range_is_an_error() {
1246        assert_eq!(
1247            XBarRChart::new(1).unwrap_err(),
1248            ControlChartError::SubgroupSizeOutOfRange {
1249                got: 1,
1250                min: 2,
1251                max: 25
1252            }
1253        );
1254    }
1255
1256    /// Every table covers the whole declared range and stays monotone in the
1257    /// direction its definition requires.
1258    #[test]
1259    fn factor_tables_cover_the_declared_range() {
1260        let span = MAX_SUBGROUP_SIZE - MIN_SUBGROUP_SIZE + 1;
1261        for (name, len) in [
1262            ("A2", A2.len()),
1263            ("D3", D3.len()),
1264            ("D4", D4.len()),
1265            ("d2", D2.len()),
1266            ("A3", A3.len()),
1267            ("B3", B3.len()),
1268            ("B4", B4.len()),
1269            ("c4", C4.len()),
1270        ] {
1271            assert_eq!(
1272                len, span,
1273                "{name} does not cover {MIN_SUBGROUP_SIZE}..={MAX_SUBGROUP_SIZE}"
1274            );
1275        }
1276        // d2 and c4 rise with n; A2, A3, D4 and B4 fall; D3 and B3 rise from 0.
1277        for i in 1..span {
1278            assert!(D2[i] > D2[i - 1], "d2 not increasing at n={}", i + 2);
1279            assert!(C4[i] > C4[i - 1], "c4 not increasing at n={}", i + 2);
1280            assert!(A2[i] < A2[i - 1], "A2 not decreasing at n={}", i + 2);
1281            assert!(A3[i] < A3[i - 1], "A3 not decreasing at n={}", i + 2);
1282            assert!(D4[i] < D4[i - 1], "D4 not decreasing at n={}", i + 2);
1283            assert!(B4[i] < B4[i - 1], "B4 not decreasing at n={}", i + 2);
1284            assert!(D3[i] >= D3[i - 1], "D3 not non-decreasing at n={}", i + 2);
1285            assert!(B3[i] >= B3[i - 1], "B3 not non-decreasing at n={}", i + 2);
1286        }
1287        // c4 is bounded by 1 and approaches it.
1288        assert!(C4[span - 1] < 1.0 && C4[span - 1] > 0.98);
1289    }
1290
1291    #[test]
1292    fn test_xbar_r_subgroup_size_above_range_is_an_error() {
1293        assert_eq!(
1294            XBarRChart::new(26).unwrap_err(),
1295            ControlChartError::SubgroupSizeOutOfRange {
1296                got: 26,
1297                min: 2,
1298                max: 25
1299            }
1300        );
1301    }
1302
1303    #[test]
1304    fn test_subgroup_size_11_is_now_accepted() {
1305        // The regression this guards: n=11 was rejected outright, so a study
1306        // running larger subgroups could not use the chart at all.
1307        let mut chart = XBarRChart::new(11).expect("11 is within 2..=25");
1308        for _ in 0..5 {
1309            chart
1310                .add_sample(&[10.0, 10.2, 9.8, 10.1, 9.9, 10.3, 9.7, 10.0, 10.1, 9.9, 10.0])
1311                .unwrap();
1312        }
1313        let limits = chart.control_limits().expect("limits from 5 subgroups");
1314        assert!(limits.ucl > limits.cl && limits.cl > limits.lcl);
1315        assert!(chart.sigma_hat().is_some_and(|s| s > 0.0));
1316    }
1317
1318    // --- XBarSChart ---
1319
1320    #[test]
1321    fn test_xbar_s_basic_limits() {
1322        let mut chart = XBarSChart::new(4).expect("subgroup size is in range");
1323        chart.add_sample(&[72.0, 84.0, 79.0, 49.0]).unwrap();
1324        chart.add_sample(&[56.0, 87.0, 33.0, 42.0]).unwrap();
1325        chart.add_sample(&[55.0, 73.0, 22.0, 60.0]).unwrap();
1326        chart.add_sample(&[44.0, 80.0, 54.0, 74.0]).unwrap();
1327        chart.add_sample(&[97.0, 26.0, 48.0, 58.0]).unwrap();
1328
1329        let limits = chart.control_limits().expect("should have limits");
1330        assert!(limits.ucl > limits.cl);
1331        assert!(limits.cl > limits.lcl);
1332
1333        let s_limits = chart.s_limits().expect("should have S limits");
1334        assert!(s_limits.ucl > s_limits.cl);
1335        assert!(s_limits.lcl >= 0.0);
1336    }
1337
1338    #[test]
1339    fn test_xbar_s_rejects_wrong_size() {
1340        let mut chart = XBarSChart::new(5).expect("subgroup size is in range");
1341        assert_eq!(
1342            chart.add_sample(&[1.0, 2.0]),
1343            Err(ControlChartError::SampleLengthMismatch {
1344                expected: 5,
1345                got: 2
1346            })
1347        );
1348        assert!(chart.control_limits().is_none());
1349    }
1350
1351    #[test]
1352    fn test_xbar_s_in_control() {
1353        let mut chart = XBarSChart::new(4).expect("subgroup size is in range");
1354        for _ in 0..10 {
1355            chart.add_sample(&[10.0, 10.2, 9.8, 10.1]).unwrap();
1356        }
1357        assert!(chart.is_in_control());
1358    }
1359
1360    // --- IndividualMRChart ---
1361
1362    #[test]
1363    fn test_imr_basic_limits() {
1364        let mut chart = IndividualMRChart::new();
1365        let data = [10.0, 12.0, 11.0, 13.0, 10.0, 14.0, 11.0, 12.0, 13.0, 10.0];
1366        for &x in &data {
1367            chart.add_sample(&[x]).unwrap();
1368        }
1369
1370        let limits = chart.control_limits().expect("should have limits");
1371        assert!(limits.ucl > limits.cl);
1372        assert!(limits.cl > limits.lcl);
1373
1374        let mr_limits = chart.mr_limits().expect("should have MR limits");
1375        assert!(mr_limits.ucl > mr_limits.cl);
1376        assert!((mr_limits.lcl).abs() < f64::EPSILON);
1377    }
1378
1379    #[test]
1380    fn test_imr_needs_two_points() {
1381        let mut chart = IndividualMRChart::new();
1382        chart.add_sample(&[10.0]).unwrap();
1383        assert!(chart.control_limits().is_none());
1384    }
1385
1386    #[test]
1387    fn test_imr_center_line_is_mean() {
1388        let mut chart = IndividualMRChart::new();
1389        let data = [5.0, 10.0, 15.0, 20.0, 25.0];
1390        for &x in &data {
1391            chart.add_sample(&[x]).unwrap();
1392        }
1393        let limits = chart.control_limits().expect("should have limits");
1394        assert!((limits.cl - 15.0).abs() < f64::EPSILON);
1395    }
1396
1397    #[test]
1398    fn test_imr_mr_values() {
1399        let mut chart = IndividualMRChart::new();
1400        let data = [10.0, 12.0, 9.0];
1401        for &x in &data {
1402            chart.add_sample(&[x]).unwrap();
1403        }
1404        // MR values: |12-10| = 2, |9-12| = 3
1405        let mr_pts = chart.mr_points();
1406        assert_eq!(mr_pts.len(), 2);
1407        assert!((mr_pts[0].value - 2.0).abs() < f64::EPSILON);
1408        assert!((mr_pts[1].value - 3.0).abs() < f64::EPSILON);
1409    }
1410
1411    #[test]
1412    fn test_imr_rejects_multi_element_sample() {
1413        let mut chart = IndividualMRChart::new();
1414        assert_eq!(
1415            chart.add_sample(&[1.0, 2.0]),
1416            Err(ControlChartError::SampleLengthMismatch {
1417                expected: 1,
1418                got: 2
1419            })
1420        );
1421        assert!(chart.points().is_empty());
1422    }
1423
1424    #[test]
1425    fn test_imr_detects_out_of_control() {
1426        let mut chart = IndividualMRChart::new();
1427        for i in 0..10 {
1428            chart.add_sample(&[50.0 + (i as f64 % 3.0) * 0.5]).unwrap();
1429        }
1430        // Add a far outlier
1431        chart.add_sample(&[100.0]).unwrap();
1432
1433        assert!(!chart.is_in_control());
1434    }
1435
1436    #[test]
1437    fn test_imr_default() {
1438        let chart = IndividualMRChart::default();
1439        assert!(chart.points().is_empty());
1440    }
1441
1442    // --- Helper function tests ---
1443
1444    #[test]
1445    fn test_subgroup_range() {
1446        assert!((subgroup_range(&[1.0, 5.0, 3.0]) - 4.0).abs() < f64::EPSILON);
1447        assert!((subgroup_range(&[10.0, 10.0, 10.0])).abs() < f64::EPSILON);
1448    }
1449
1450    // --- Textbook verification: X-bar-R chart factors ---
1451
1452    #[test]
1453    fn test_xbar_r_chart_factors_n5() {
1454        // For n=5: A2=0.577, D3=0.0, D4=2.114
1455        // Subgroup with mean=50, range=10
1456        let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
1457        chart.add_sample(&[45.0, 47.0, 50.0, 53.0, 55.0]).unwrap();
1458
1459        let limits = chart.control_limits().expect("limits");
1460        assert!((limits.cl - 50.0).abs() < f64::EPSILON);
1461
1462        let r_limits = chart.r_limits().expect("R limits");
1463        assert!((r_limits.cl - 10.0).abs() < f64::EPSILON);
1464
1465        // UCL = 50 + 0.577 * 10 = 55.77
1466        assert!((limits.ucl - 55.77).abs() < 0.01);
1467        // LCL = 50 - 0.577 * 10 = 44.23
1468        assert!((limits.lcl - 44.23).abs() < 0.01);
1469    }
1470
1471    // --- Textbook verification: I-MR chart ---
1472
1473    #[test]
1474    fn test_imr_e2_factor() {
1475        // E2 = 2.660
1476        // Two points with X-bar = 100, MR = |105-95| = 10
1477        let mut chart = IndividualMRChart::new();
1478        chart.add_sample(&[95.0]).unwrap();
1479        chart.add_sample(&[105.0]).unwrap();
1480
1481        let limits = chart.control_limits().expect("limits");
1482        // X-bar = 100
1483        assert!((limits.cl - 100.0).abs() < f64::EPSILON);
1484        // MR-bar = 10
1485        // UCL = 100 + 2.660 * 10 = 126.6
1486        assert!((limits.ucl - 126.6).abs() < 0.1);
1487        // LCL = 100 - 2.660 * 10 = 73.4
1488        assert!((limits.lcl - 73.4).abs() < 0.1);
1489    }
1490
1491    // --- Montgomery Table VI: d2 constant verification ---
1492
1493    // --- Montgomery Table VI: c4 constant verification ---
1494
1495    // --- sigma-hat from the chart (Cycle 266) --------------------------------
1496    //
1497    // Short-term sigma is what a capability study needs for Cp/Cpk, and it
1498    // cannot be recovered from a flat measurement vector: it depends on the
1499    // subgroup structure the chart holds. Without these the caller has to
1500    // reimplement the d2/c4 tables.
1501
1502    #[test]
1503    fn xbar_r_sigma_hat_is_r_bar_over_d2() {
1504        let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
1505        let groups = [
1506            [9.9, 10.1, 10.0, 9.8, 10.2],
1507            [10.3, 9.7, 10.0, 10.1, 9.9],
1508            [9.8, 10.2, 10.1, 9.9, 10.0],
1509            [10.5, 9.5, 10.0, 10.2, 9.8],
1510        ];
1511        for g in &groups {
1512            chart.add_sample(g).unwrap();
1513        }
1514        let r_bar = chart.r_limits().expect("limits").cl;
1515        let expected = r_bar / D2[5 - 2];
1516        let sigma = chart.sigma_hat().expect("sigma-hat");
1517        assert!(
1518            (sigma - expected).abs() < 1e-12,
1519            "sigma-hat must be R-bar/d2: got {sigma}, expected {expected}"
1520        );
1521        assert!(sigma > 0.0);
1522    }
1523
1524    #[test]
1525    fn xbar_s_sigma_hat_is_s_bar_over_c4() {
1526        let mut chart = XBarSChart::new(5).expect("subgroup size is in range");
1527        for g in &[
1528            [9.9, 10.1, 10.0, 9.8, 10.2],
1529            [10.3, 9.7, 10.0, 10.1, 9.9],
1530            [9.8, 10.2, 10.1, 9.9, 10.0],
1531        ] {
1532            chart.add_sample(g).unwrap();
1533        }
1534        let s_bar = chart.s_limits().expect("limits").cl;
1535        let expected = s_bar / C4[5 - 2];
1536        let sigma = chart.sigma_hat().expect("sigma-hat");
1537        assert!((sigma - expected).abs() < 1e-12);
1538    }
1539
1540    #[test]
1541    fn sigma_hat_is_none_without_limits() {
1542        assert!(XBarRChart::new(5).unwrap().sigma_hat().is_none());
1543        assert!(XBarSChart::new(5).unwrap().sigma_hat().is_none());
1544        assert!(XBarRChart::new(5).unwrap().sigma_hat_df().is_none());
1545        assert!(XBarSChart::new(5).unwrap().sigma_hat_df().is_none());
1546        assert!(IndividualMRChart::new().sigma_hat_df().is_none());
1547    }
1548
1549    /// David's approximation inverts the precision of a sample standard
1550    /// deviation: from the exact c4 of `v + 1` values (one subgroup) it gives
1551    /// back `v`.
1552    #[test]
1553    fn equivalent_df_recovers_a_standard_deviations_df() {
1554        for (i, c4) in C4.iter().enumerate() {
1555            let v = (i + 1) as f64; // subgroup of i + 2 values
1556            let df = equivalent_df((1.0 - c4 * c4) / (c4 * c4));
1557            // c4 is printed to four places; the inversion is good to a few hundredths.
1558            assert!(
1559                (df - v).abs() < 0.05 + 0.01 * v,
1560                "n = {}: {df} vs {v}",
1561                i + 2
1562            );
1563        }
1564    }
1565
1566    /// One range of n values: Patnaik's (1950) equivalent degrees of freedom
1567    /// (as tabulated in Duncan): 1.0, 2.0, 2.9, 3.8, 4.7 for n = 2..=6.
1568    #[test]
1569    fn single_range_df_matches_patnaiks_table() {
1570        for (n, want) in [(2, 1.0), (3, 2.0), (4, 2.9), (5, 3.8), (6, 4.7)] {
1571            let cv = (D4[n - 2] - 1.0) / 3.0;
1572            let df = equivalent_df(cv * cv);
1573            assert!((df - want).abs() < 0.06, "n = {n}: {df} vs {want}");
1574        }
1575    }
1576
1577    /// More subgroups, more degrees of freedom; the range spends some of what
1578    /// a pooled standard deviation would have, the S chart less.
1579    #[test]
1580    fn chart_df_grow_with_subgroups_and_stay_under_pooled() {
1581        let mut r = XBarRChart::new(5).unwrap();
1582        let mut s = XBarSChart::new(5).unwrap();
1583        let mut last = 0.0;
1584        for k in 0..20 {
1585            let g = [
1586                10.0 + 0.1 * (k % 3) as f64,
1587                10.2,
1588                9.9,
1589                10.1,
1590                9.8 + 0.05 * k as f64,
1591            ];
1592            r.add_sample(&g).unwrap();
1593            s.add_sample(&g).unwrap();
1594            if let (Some(dr), Some(ds)) = (r.sigma_hat_df(), s.sigma_hat_df()) {
1595                let pooled = ((k + 1) * 4) as f64;
1596                assert!(
1597                    dr > last && dr < pooled && ds < pooled + 0.5 && ds > dr,
1598                    "k={k}: {dr} {ds}"
1599                );
1600                last = dr;
1601            }
1602        }
1603        let mut i = IndividualMRChart::new();
1604        for x in [1.0, 3.0, 2.0, 4.0, 3.5] {
1605            i.add_sample(&[x]).unwrap();
1606        }
1607        assert!((i.sigma_hat_df().unwrap() - 0.62 * 4.0).abs() < 1e-12);
1608    }
1609
1610    #[test]
1611    fn short_term_sigma_differs_from_overall_when_subgroups_drift() {
1612        use u_numflow::stats;
1613
1614        // Six subgroups of five with real between-subgroup drift: the whole
1615        // point of a short-term estimate is that it is NOT the overall sigma.
1616        let groups = [
1617            [9.9, 10.1, 10.0, 9.8, 10.2],
1618            [10.3, 9.7, 10.0, 10.1, 9.9],
1619            [9.8, 10.2, 10.1, 9.9, 10.0],
1620            [10.5, 9.5, 10.0, 10.2, 9.8],
1621            [9.6, 10.4, 10.0, 9.9, 10.1],
1622            [10.1, 9.9, 10.0, 10.3, 9.7],
1623        ];
1624        let mut chart = XBarRChart::new(5).expect("subgroup size is in range");
1625        for g in &groups {
1626            chart.add_sample(g).unwrap();
1627        }
1628        let sigma_within = chart.sigma_hat().expect("sigma-hat");
1629        let flat: Vec<f64> = groups.iter().flatten().copied().collect();
1630        let sigma_overall = stats::std_dev(&flat).expect("overall sigma");
1631
1632        assert!(
1633            (sigma_within - sigma_overall).abs() > 1e-6,
1634            "short-term and long-term sigma must differ on drifting subgroups: within={sigma_within}, overall={sigma_overall}"
1635        );
1636    }
1637}