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