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