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