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::{ChartPoint, ControlChart, ControlLimits, Violation, ViolationType};
21use super::rules::{NelsonRules, RunRule};
22
23// ---------------------------------------------------------------------------
24// Control chart factor tables (ASTM E2587), indexed by subgroup size n=2..10
25// Index 0 corresponds to n=2.
26// ---------------------------------------------------------------------------
27
28/// A2 factors for X-bar-R chart UCL/LCL computation.
29///
30/// UCL = X-double-bar + A2 * R-bar, LCL = X-double-bar - A2 * R-bar.
31const A2: [f64; 9] = [
32    1.880, 1.023, 0.729, 0.577, 0.483, 0.419, 0.373, 0.337, 0.308,
33];
34
35/// D3 factors for R chart lower control limit.
36///
37/// LCL_R = D3 * R-bar.
38const D3: [f64; 9] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.076, 0.136, 0.184, 0.223];
39
40/// D4 factors for R chart upper control limit.
41///
42/// UCL_R = D4 * R-bar.
43const D4: [f64; 9] = [
44    3.267, 2.575, 2.282, 2.114, 2.004, 1.924, 1.864, 1.816, 1.777,
45];
46
47/// d2 factors (mean of the range distribution) for estimating sigma from R-bar.
48///
49/// sigma-hat = R-bar / d2.
50///
51/// Index 0 corresponds to subgroup size n = 2.
52///
53/// # Reference
54///
55/// Montgomery, D.C. (2020). *Introduction to Statistical Quality Control*, 8th ed.,
56/// Appendix Table VI.
57#[allow(dead_code)]
58const D2: [f64; 9] = [
59    1.128, 1.693, 2.059, 2.326, 2.534, 2.704, 2.847, 2.970, 3.078,
60];
61
62/// A3 factors for X-bar-S chart UCL/LCL computation.
63///
64/// UCL = X-double-bar + A3 * S-bar, LCL = X-double-bar - A3 * S-bar.
65const A3: [f64; 9] = [
66    2.659, 1.954, 1.628, 1.427, 1.287, 1.182, 1.099, 1.032, 0.975,
67];
68
69/// B3 factors for S chart lower control limit.
70///
71/// LCL_S = B3 * S-bar.
72const B3: [f64; 9] = [0.0, 0.0, 0.0, 0.0, 0.030, 0.118, 0.185, 0.239, 0.284];
73
74/// B4 factors for S chart upper control limit.
75///
76/// UCL_S = B4 * S-bar.
77const B4: [f64; 9] = [
78    3.267, 2.568, 2.266, 2.089, 1.970, 1.882, 1.815, 1.761, 1.716,
79];
80
81/// c4 factors for unbiased estimation of sigma from S-bar.
82///
83/// sigma-hat = S-bar / c4.
84///
85/// Index 0 corresponds to subgroup size n = 2.
86///
87/// # Reference
88///
89/// Montgomery, D.C. (2020). *Introduction to Statistical Quality Control*, 8th ed.,
90/// Appendix Table VI.
91#[allow(dead_code)]
92const C4: [f64; 9] = [
93    0.7979, 0.8862, 0.9213, 0.9400, 0.9515, 0.9594, 0.9650, 0.9693, 0.9727,
94];
95
96/// E2 factor for Individual chart UCL/LCL.
97///
98/// UCL = X-bar + E2 * MR-bar, LCL = X-bar - E2 * MR-bar.
99/// E2 = 3 / d2(n=2) = 3 / 1.128 = 2.6596...
100const E2: f64 = 2.660;
101
102/// D4 factor for MR chart (n=2 moving range).
103const D4_MR: f64 = 3.267;
104
105// ---------------------------------------------------------------------------
106// X-bar-R Chart
107// ---------------------------------------------------------------------------
108
109/// X-bar and Range (X-bar-R) control chart.
110///
111/// Monitors the process mean (X-bar chart) and process variability (R chart)
112/// using subgroup ranges. Suitable for subgroup sizes n = 2..=10.
113///
114/// # Algorithm
115///
116/// 1. For each subgroup, compute the mean (X-bar) and range (R).
117/// 2. Compute the grand mean (X-double-bar) and average range (R-bar).
118/// 3. X-bar chart limits: CL = X-double-bar, UCL/LCL = CL +/- A2 * R-bar.
119/// 4. R chart limits: CL = R-bar, UCL = D4 * R-bar, LCL = D3 * R-bar.
120///
121/// # Examples
122///
123/// ```
124/// use u_analytics::spc::{XBarRChart, ControlChart};
125///
126/// let mut chart = XBarRChart::new(5);
127/// chart.add_sample(&[25.0, 26.0, 24.5, 25.5, 25.0]);
128/// chart.add_sample(&[25.2, 24.8, 25.1, 24.9, 25.3]);
129/// chart.add_sample(&[25.1, 25.0, 24.7, 25.3, 24.9]);
130///
131/// let limits = chart.control_limits().expect("should have limits after 3 samples");
132/// assert!(limits.ucl > limits.cl);
133/// assert!(limits.cl > limits.lcl);
134/// ```
135///
136/// # Reference
137///
138/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
139/// Chapter 6: Control Charts for Variables.
140pub struct XBarRChart {
141    /// Fixed subgroup size (2..=10).
142    subgroup_size: usize,
143    /// Stored subgroups.
144    subgroups: Vec<Vec<f64>>,
145    /// Computed X-bar chart points.
146    xbar_points: Vec<ChartPoint>,
147    /// Computed R chart points.
148    r_points: Vec<ChartPoint>,
149    /// X-bar chart control limits.
150    xbar_limits: Option<ControlLimits>,
151    /// R chart control limits.
152    r_limits: Option<ControlLimits>,
153}
154
155impl XBarRChart {
156    /// Create a new X-bar-R chart with the given subgroup size.
157    ///
158    /// # Panics
159    ///
160    /// Panics if `subgroup_size` is not in the range 2..=10.
161    pub fn new(subgroup_size: usize) -> Self {
162        assert!(
163            (2..=10).contains(&subgroup_size),
164            "subgroup_size must be 2..=10, got {subgroup_size}"
165        );
166        Self {
167            subgroup_size,
168            subgroups: Vec::new(),
169            xbar_points: Vec::new(),
170            r_points: Vec::new(),
171            xbar_limits: None,
172            r_limits: None,
173        }
174    }
175
176    /// Get the R chart control limits, or `None` if insufficient data.
177    pub fn r_limits(&self) -> Option<ControlLimits> {
178        self.r_limits.clone()
179    }
180
181    /// Get the R chart points.
182    pub fn r_points(&self) -> &[ChartPoint] {
183        &self.r_points
184    }
185
186    /// Recompute limits and points from stored subgroups.
187    fn recompute(&mut self) {
188        if self.subgroups.is_empty() {
189            self.xbar_limits = None;
190            self.r_limits = None;
191            self.xbar_points.clear();
192            self.r_points.clear();
193            return;
194        }
195
196        let idx = self.subgroup_size - 2; // Factor table index
197
198        // Compute subgroup means and ranges
199        let mut xbar_values = Vec::with_capacity(self.subgroups.len());
200        let mut r_values = Vec::with_capacity(self.subgroups.len());
201
202        for subgroup in &self.subgroups {
203            let mean_val = u_numflow::stats::mean(subgroup)
204                .expect("subgroup should be non-empty with finite values");
205            let range = subgroup_range(subgroup);
206            xbar_values.push(mean_val);
207            r_values.push(range);
208        }
209
210        // Grand mean and average range
211        let grand_mean = u_numflow::stats::mean(&xbar_values)
212            .expect("xbar_values should be non-empty with finite values");
213        let r_bar = u_numflow::stats::mean(&r_values)
214            .expect("r_values should be non-empty with finite values");
215
216        // X-bar chart limits
217        let a2 = A2[idx];
218        self.xbar_limits = Some(ControlLimits {
219            ucl: grand_mean + a2 * r_bar,
220            cl: grand_mean,
221            lcl: grand_mean - a2 * r_bar,
222        });
223
224        // R chart limits
225        let d3 = D3[idx];
226        let d4 = D4[idx];
227        self.r_limits = Some(ControlLimits {
228            ucl: d4 * r_bar,
229            cl: r_bar,
230            lcl: d3 * r_bar,
231        });
232
233        // Build points
234        self.xbar_points = xbar_values
235            .iter()
236            .enumerate()
237            .map(|(i, &v)| ChartPoint {
238                value: v,
239                index: i,
240                violations: Vec::new(),
241            })
242            .collect();
243
244        self.r_points = r_values
245            .iter()
246            .enumerate()
247            .map(|(i, &v)| ChartPoint {
248                value: v,
249                index: i,
250                violations: Vec::new(),
251            })
252            .collect();
253
254        // Apply Nelson rules to X-bar chart
255        if let Some(ref limits) = self.xbar_limits {
256            let nelson = NelsonRules;
257            let violations = nelson.check(&self.xbar_points, limits);
258            apply_violations(&mut self.xbar_points, &violations);
259        }
260
261        // Apply Nelson rules to R chart
262        if let Some(ref limits) = self.r_limits {
263            let nelson = NelsonRules;
264            let violations = nelson.check(&self.r_points, limits);
265            apply_violations(&mut self.r_points, &violations);
266        }
267    }
268}
269
270impl ControlChart for XBarRChart {
271    /// Add a subgroup sample. The sample length must equal the chart's subgroup size.
272    fn add_sample(&mut self, sample: &[f64]) {
273        if sample.len() != self.subgroup_size {
274            return;
275        }
276        if !sample.iter().all(|x| x.is_finite()) {
277            return;
278        }
279        self.subgroups.push(sample.to_vec());
280        self.recompute();
281    }
282
283    fn control_limits(&self) -> Option<ControlLimits> {
284        self.xbar_limits.clone()
285    }
286
287    fn is_in_control(&self) -> bool {
288        self.xbar_points.iter().all(|p| p.violations.is_empty())
289            && self.r_points.iter().all(|p| p.violations.is_empty())
290    }
291
292    fn violations(&self) -> Vec<Violation> {
293        collect_violations(&self.xbar_points)
294            .into_iter()
295            .chain(collect_violations(&self.r_points))
296            .collect()
297    }
298
299    fn points(&self) -> &[ChartPoint] {
300        &self.xbar_points
301    }
302}
303
304// ---------------------------------------------------------------------------
305// X-bar-S Chart
306// ---------------------------------------------------------------------------
307
308/// X-bar and Standard Deviation (X-bar-S) control chart.
309///
310/// Monitors the process mean (X-bar chart) and process variability (S chart)
311/// using subgroup standard deviations. Preferred over X-bar-R for larger
312/// subgroups where range is a less efficient estimator.
313///
314/// # Algorithm
315///
316/// 1. For each subgroup, compute the mean (X-bar) and sample standard deviation (S).
317/// 2. Compute the grand mean (X-double-bar) and average S (S-bar).
318/// 3. X-bar chart limits: CL = X-double-bar, UCL/LCL = CL +/- A3 * S-bar.
319/// 4. S chart limits: CL = S-bar, UCL = B4 * S-bar, LCL = B3 * S-bar.
320///
321/// # Reference
322///
323/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
324/// Chapter 6: Control Charts for Variables.
325pub struct XBarSChart {
326    /// Fixed subgroup size (2..=10).
327    subgroup_size: usize,
328    /// Stored subgroups.
329    subgroups: Vec<Vec<f64>>,
330    /// Computed X-bar chart points.
331    xbar_points: Vec<ChartPoint>,
332    /// Computed S chart points.
333    s_points: Vec<ChartPoint>,
334    /// X-bar chart control limits.
335    xbar_limits: Option<ControlLimits>,
336    /// S chart control limits.
337    s_limits: Option<ControlLimits>,
338}
339
340impl XBarSChart {
341    /// Create a new X-bar-S chart with the given subgroup size.
342    ///
343    /// # Panics
344    ///
345    /// Panics if `subgroup_size` is not in the range 2..=10.
346    pub fn new(subgroup_size: usize) -> Self {
347        assert!(
348            (2..=10).contains(&subgroup_size),
349            "subgroup_size must be 2..=10, got {subgroup_size}"
350        );
351        Self {
352            subgroup_size,
353            subgroups: Vec::new(),
354            xbar_points: Vec::new(),
355            s_points: Vec::new(),
356            xbar_limits: None,
357            s_limits: None,
358        }
359    }
360
361    /// Get the S chart control limits, or `None` if insufficient data.
362    pub fn s_limits(&self) -> Option<ControlLimits> {
363        self.s_limits.clone()
364    }
365
366    /// Get the S chart points.
367    pub fn s_points(&self) -> &[ChartPoint] {
368        &self.s_points
369    }
370
371    /// Recompute limits and points from stored subgroups.
372    fn recompute(&mut self) {
373        if self.subgroups.is_empty() {
374            self.xbar_limits = None;
375            self.s_limits = None;
376            self.xbar_points.clear();
377            self.s_points.clear();
378            return;
379        }
380
381        let idx = self.subgroup_size - 2;
382
383        // Compute subgroup means and standard deviations
384        let mut xbar_values = Vec::with_capacity(self.subgroups.len());
385        let mut s_values = Vec::with_capacity(self.subgroups.len());
386
387        for subgroup in &self.subgroups {
388            let mean_val = u_numflow::stats::mean(subgroup)
389                .expect("subgroup should be non-empty with finite values");
390            let sd = u_numflow::stats::std_dev(subgroup)
391                .expect("subgroup should have >= 2 elements for std_dev");
392            xbar_values.push(mean_val);
393            s_values.push(sd);
394        }
395
396        // Grand mean and average S
397        let grand_mean = u_numflow::stats::mean(&xbar_values)
398            .expect("xbar_values should be non-empty with finite values");
399        let s_bar = u_numflow::stats::mean(&s_values)
400            .expect("s_values should be non-empty with finite values");
401
402        // X-bar chart limits
403        let a3 = A3[idx];
404        self.xbar_limits = Some(ControlLimits {
405            ucl: grand_mean + a3 * s_bar,
406            cl: grand_mean,
407            lcl: grand_mean - a3 * s_bar,
408        });
409
410        // S chart limits
411        let b3 = B3[idx];
412        let b4 = B4[idx];
413        self.s_limits = Some(ControlLimits {
414            ucl: b4 * s_bar,
415            cl: s_bar,
416            lcl: b3 * s_bar,
417        });
418
419        // Build points
420        self.xbar_points = xbar_values
421            .iter()
422            .enumerate()
423            .map(|(i, &v)| ChartPoint {
424                value: v,
425                index: i,
426                violations: Vec::new(),
427            })
428            .collect();
429
430        self.s_points = s_values
431            .iter()
432            .enumerate()
433            .map(|(i, &v)| ChartPoint {
434                value: v,
435                index: i,
436                violations: Vec::new(),
437            })
438            .collect();
439
440        // Apply Nelson rules to X-bar chart
441        if let Some(ref limits) = self.xbar_limits {
442            let nelson = NelsonRules;
443            let violations = nelson.check(&self.xbar_points, limits);
444            apply_violations(&mut self.xbar_points, &violations);
445        }
446
447        // Apply Nelson rules to S chart
448        if let Some(ref limits) = self.s_limits {
449            let nelson = NelsonRules;
450            let violations = nelson.check(&self.s_points, limits);
451            apply_violations(&mut self.s_points, &violations);
452        }
453    }
454}
455
456impl ControlChart for XBarSChart {
457    /// Add a subgroup sample. The sample length must equal the chart's subgroup size.
458    fn add_sample(&mut self, sample: &[f64]) {
459        if sample.len() != self.subgroup_size {
460            return;
461        }
462        if !sample.iter().all(|x| x.is_finite()) {
463            return;
464        }
465        self.subgroups.push(sample.to_vec());
466        self.recompute();
467    }
468
469    fn control_limits(&self) -> Option<ControlLimits> {
470        self.xbar_limits.clone()
471    }
472
473    fn is_in_control(&self) -> bool {
474        self.xbar_points.iter().all(|p| p.violations.is_empty())
475            && self.s_points.iter().all(|p| p.violations.is_empty())
476    }
477
478    fn violations(&self) -> Vec<Violation> {
479        collect_violations(&self.xbar_points)
480            .into_iter()
481            .chain(collect_violations(&self.s_points))
482            .collect()
483    }
484
485    fn points(&self) -> &[ChartPoint] {
486        &self.xbar_points
487    }
488}
489
490// ---------------------------------------------------------------------------
491// Individual-MR Chart
492// ---------------------------------------------------------------------------
493
494/// Individual and Moving Range (I-MR) control chart.
495///
496/// Monitors individual observations (subgroup size = 1) using the moving range
497/// of consecutive observations to estimate process variability.
498///
499/// # Algorithm
500///
501/// 1. Compute moving ranges: MR_i = |x_i - x_{i-1}| for i >= 1.
502/// 2. Compute the mean of individual observations (X-bar) and the average
503///    moving range (MR-bar).
504/// 3. I chart limits: CL = X-bar, UCL/LCL = X-bar +/- E2 * MR-bar.
505/// 4. MR chart limits: CL = MR-bar, UCL = D4 * MR-bar, LCL = 0.
506///
507/// # Examples
508///
509/// ```
510/// use u_analytics::spc::{IndividualMRChart, ControlChart};
511///
512/// let mut chart = IndividualMRChart::new();
513/// for &x in &[25.0, 25.2, 24.8, 25.1, 24.9, 25.3, 25.0, 24.7] {
514///     chart.add_sample(&[x]);
515/// }
516///
517/// let limits = chart.control_limits().expect("should have limits after 2+ observations");
518/// assert!(limits.ucl > limits.cl);
519/// assert!(limits.cl > limits.lcl);
520/// ```
521///
522/// # Reference
523///
524/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
525/// Chapter 6: Control Charts for Variables.
526pub struct IndividualMRChart {
527    /// Individual observations.
528    observations: Vec<f64>,
529    /// Computed I chart points.
530    i_points: Vec<ChartPoint>,
531    /// Computed MR chart points.
532    mr_points: Vec<ChartPoint>,
533    /// I chart control limits.
534    i_limits: Option<ControlLimits>,
535    /// MR chart control limits.
536    mr_limits: Option<ControlLimits>,
537}
538
539impl IndividualMRChart {
540    /// Create a new Individual-MR chart.
541    pub fn new() -> Self {
542        Self {
543            observations: Vec::new(),
544            i_points: Vec::new(),
545            mr_points: Vec::new(),
546            i_limits: None,
547            mr_limits: None,
548        }
549    }
550
551    /// Get the MR chart control limits, or `None` if insufficient data.
552    pub fn mr_limits(&self) -> Option<ControlLimits> {
553        self.mr_limits.clone()
554    }
555
556    /// Get the MR chart points.
557    pub fn mr_points(&self) -> &[ChartPoint] {
558        &self.mr_points
559    }
560
561    /// Recompute limits and points from stored observations.
562    fn recompute(&mut self) {
563        if self.observations.len() < 2 {
564            self.i_limits = None;
565            self.mr_limits = None;
566            self.i_points.clear();
567            self.mr_points.clear();
568            return;
569        }
570
571        // Compute moving ranges
572        let mr_values: Vec<f64> = self
573            .observations
574            .windows(2)
575            .map(|w| (w[1] - w[0]).abs())
576            .collect();
577
578        // X-bar and MR-bar
579        let x_bar = u_numflow::stats::mean(&self.observations)
580            .expect("observations should be non-empty with finite values");
581        let mr_bar = u_numflow::stats::mean(&mr_values)
582            .expect("mr_values should be non-empty with finite values");
583
584        // I chart limits
585        self.i_limits = Some(ControlLimits {
586            ucl: x_bar + E2 * mr_bar,
587            cl: x_bar,
588            lcl: x_bar - E2 * mr_bar,
589        });
590
591        // MR chart limits (LCL is always 0 for n=2)
592        self.mr_limits = Some(ControlLimits {
593            ucl: D4_MR * mr_bar,
594            cl: mr_bar,
595            lcl: 0.0,
596        });
597
598        // Build I chart points
599        self.i_points = self
600            .observations
601            .iter()
602            .enumerate()
603            .map(|(i, &v)| ChartPoint {
604                value: v,
605                index: i,
606                violations: Vec::new(),
607            })
608            .collect();
609
610        // Build MR chart points (starts at index 1, since MR_0 is undefined)
611        self.mr_points = mr_values
612            .iter()
613            .enumerate()
614            .map(|(i, &v)| ChartPoint {
615                value: v,
616                index: i + 1,
617                violations: Vec::new(),
618            })
619            .collect();
620
621        // Apply Nelson rules to I chart
622        if let Some(ref limits) = self.i_limits {
623            let nelson = NelsonRules;
624            let violations = nelson.check(&self.i_points, limits);
625            apply_violations(&mut self.i_points, &violations);
626        }
627
628        // Apply Nelson rules to MR chart
629        if let Some(ref limits) = self.mr_limits {
630            let nelson = NelsonRules;
631            let violations = nelson.check(&self.mr_points, limits);
632            apply_violations(&mut self.mr_points, &violations);
633        }
634    }
635}
636
637impl Default for IndividualMRChart {
638    fn default() -> Self {
639        Self::new()
640    }
641}
642
643impl ControlChart for IndividualMRChart {
644    /// Add a single observation. The sample slice must contain exactly one element.
645    fn add_sample(&mut self, sample: &[f64]) {
646        if sample.len() != 1 {
647            return;
648        }
649        if !sample[0].is_finite() {
650            return;
651        }
652        self.observations.push(sample[0]);
653        self.recompute();
654    }
655
656    fn control_limits(&self) -> Option<ControlLimits> {
657        self.i_limits.clone()
658    }
659
660    fn is_in_control(&self) -> bool {
661        self.i_points.iter().all(|p| p.violations.is_empty())
662            && self.mr_points.iter().all(|p| p.violations.is_empty())
663    }
664
665    fn violations(&self) -> Vec<Violation> {
666        collect_violations(&self.i_points)
667            .into_iter()
668            .chain(collect_violations(&self.mr_points))
669            .collect()
670    }
671
672    fn points(&self) -> &[ChartPoint] {
673        &self.i_points
674    }
675}
676
677// ---------------------------------------------------------------------------
678// Helpers
679// ---------------------------------------------------------------------------
680
681/// Compute the range (max - min) of a subgroup.
682///
683/// # Panics
684///
685/// Uses `expect` — callers must ensure `subgroup` is non-empty with finite values.
686fn subgroup_range(subgroup: &[f64]) -> f64 {
687    let max_val =
688        u_numflow::stats::max(subgroup).expect("subgroup should be non-empty without NaN");
689    let min_val =
690        u_numflow::stats::min(subgroup).expect("subgroup should be non-empty without NaN");
691    max_val - min_val
692}
693
694/// Apply a list of violations to chart points, matching by index.
695fn apply_violations(points: &mut [ChartPoint], violations: &[(usize, ViolationType)]) {
696    for &(idx, vtype) in violations {
697        if let Some(point) = points.iter_mut().find(|p| p.index == idx) {
698            point.violations.push(vtype);
699        }
700    }
701}
702
703/// Collect all violations from chart points into a flat list.
704fn collect_violations(points: &[ChartPoint]) -> Vec<Violation> {
705    let mut result = Vec::new();
706    for point in points {
707        for &vtype in &point.violations {
708            result.push(Violation {
709                point_index: point.index,
710                violation_type: vtype,
711            });
712        }
713    }
714    result
715}
716
717// ---------------------------------------------------------------------------
718// Tests
719// ---------------------------------------------------------------------------
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    // --- XBarRChart ---
726
727    #[test]
728    fn test_xbar_r_basic_limits() {
729        let mut chart = XBarRChart::new(4);
730        chart.add_sample(&[72.0, 84.0, 79.0, 49.0]);
731        chart.add_sample(&[56.0, 87.0, 33.0, 42.0]);
732        chart.add_sample(&[55.0, 73.0, 22.0, 60.0]);
733        chart.add_sample(&[44.0, 80.0, 54.0, 74.0]);
734        chart.add_sample(&[97.0, 26.0, 48.0, 58.0]);
735
736        let limits = chart.control_limits().expect("should have limits");
737        // Subgroup means: 71.0, 54.5, 52.5, 63.0, 57.25
738        let expected_grand_mean = (71.0 + 54.5 + 52.5 + 63.0 + 57.25) / 5.0;
739        assert!(
740            (limits.cl - expected_grand_mean).abs() < 0.1,
741            "CL={}, expected ~{expected_grand_mean}",
742            limits.cl
743        );
744
745        // Verify UCL > CL > LCL
746        assert!(limits.ucl > limits.cl);
747        assert!(limits.cl > limits.lcl);
748    }
749
750    #[test]
751    fn test_xbar_r_rejects_wrong_size() {
752        let mut chart = XBarRChart::new(5);
753        chart.add_sample(&[1.0, 2.0, 3.0]); // Wrong size, should be ignored
754        assert!(chart.control_limits().is_none());
755    }
756
757    #[test]
758    fn test_xbar_r_rejects_nan() {
759        let mut chart = XBarRChart::new(3);
760        chart.add_sample(&[1.0, f64::NAN, 3.0]);
761        assert!(chart.control_limits().is_none());
762    }
763
764    #[test]
765    fn test_xbar_r_r_chart_limits() {
766        let mut chart = XBarRChart::new(5);
767        chart.add_sample(&[10.0, 12.0, 11.0, 13.0, 14.0]);
768        chart.add_sample(&[11.0, 13.0, 12.0, 10.0, 15.0]);
769        chart.add_sample(&[12.0, 11.0, 14.0, 13.0, 10.0]);
770
771        let r_limits = chart.r_limits().expect("should have R limits");
772        assert!(r_limits.ucl > r_limits.cl);
773        assert!(r_limits.lcl >= 0.0);
774    }
775
776    #[test]
777    fn test_xbar_r_constant_subgroups() {
778        // All identical values: R-bar = 0, limits collapse
779        let mut chart = XBarRChart::new(3);
780        chart.add_sample(&[10.0, 10.0, 10.0]);
781        chart.add_sample(&[10.0, 10.0, 10.0]);
782
783        let limits = chart.control_limits().expect("should have limits");
784        assert!((limits.cl - 10.0).abs() < f64::EPSILON);
785        assert!((limits.ucl - 10.0).abs() < f64::EPSILON);
786        assert!((limits.lcl - 10.0).abs() < f64::EPSILON);
787    }
788
789    #[test]
790    fn test_xbar_r_detects_out_of_control() {
791        let mut chart = XBarRChart::new(3);
792        for _ in 0..5 {
793            chart.add_sample(&[10.0, 10.5, 9.5]);
794        }
795        // Add an outlier subgroup
796        chart.add_sample(&[50.0, 51.0, 49.0]);
797
798        assert!(!chart.is_in_control());
799    }
800
801    #[test]
802    #[should_panic(expected = "subgroup_size must be 2..=10")]
803    fn test_xbar_r_invalid_size_1() {
804        let _ = XBarRChart::new(1);
805    }
806
807    #[test]
808    #[should_panic(expected = "subgroup_size must be 2..=10")]
809    fn test_xbar_r_invalid_size_11() {
810        let _ = XBarRChart::new(11);
811    }
812
813    // --- XBarSChart ---
814
815    #[test]
816    fn test_xbar_s_basic_limits() {
817        let mut chart = XBarSChart::new(4);
818        chart.add_sample(&[72.0, 84.0, 79.0, 49.0]);
819        chart.add_sample(&[56.0, 87.0, 33.0, 42.0]);
820        chart.add_sample(&[55.0, 73.0, 22.0, 60.0]);
821        chart.add_sample(&[44.0, 80.0, 54.0, 74.0]);
822        chart.add_sample(&[97.0, 26.0, 48.0, 58.0]);
823
824        let limits = chart.control_limits().expect("should have limits");
825        assert!(limits.ucl > limits.cl);
826        assert!(limits.cl > limits.lcl);
827
828        let s_limits = chart.s_limits().expect("should have S limits");
829        assert!(s_limits.ucl > s_limits.cl);
830        assert!(s_limits.lcl >= 0.0);
831    }
832
833    #[test]
834    fn test_xbar_s_rejects_wrong_size() {
835        let mut chart = XBarSChart::new(5);
836        chart.add_sample(&[1.0, 2.0]);
837        assert!(chart.control_limits().is_none());
838    }
839
840    #[test]
841    fn test_xbar_s_in_control() {
842        let mut chart = XBarSChart::new(4);
843        for _ in 0..10 {
844            chart.add_sample(&[10.0, 10.2, 9.8, 10.1]);
845        }
846        assert!(chart.is_in_control());
847    }
848
849    // --- IndividualMRChart ---
850
851    #[test]
852    fn test_imr_basic_limits() {
853        let mut chart = IndividualMRChart::new();
854        let data = [10.0, 12.0, 11.0, 13.0, 10.0, 14.0, 11.0, 12.0, 13.0, 10.0];
855        for &x in &data {
856            chart.add_sample(&[x]);
857        }
858
859        let limits = chart.control_limits().expect("should have limits");
860        assert!(limits.ucl > limits.cl);
861        assert!(limits.cl > limits.lcl);
862
863        let mr_limits = chart.mr_limits().expect("should have MR limits");
864        assert!(mr_limits.ucl > mr_limits.cl);
865        assert!((mr_limits.lcl).abs() < f64::EPSILON);
866    }
867
868    #[test]
869    fn test_imr_needs_two_points() {
870        let mut chart = IndividualMRChart::new();
871        chart.add_sample(&[10.0]);
872        assert!(chart.control_limits().is_none());
873    }
874
875    #[test]
876    fn test_imr_center_line_is_mean() {
877        let mut chart = IndividualMRChart::new();
878        let data = [5.0, 10.0, 15.0, 20.0, 25.0];
879        for &x in &data {
880            chart.add_sample(&[x]);
881        }
882        let limits = chart.control_limits().expect("should have limits");
883        assert!((limits.cl - 15.0).abs() < f64::EPSILON);
884    }
885
886    #[test]
887    fn test_imr_mr_values() {
888        let mut chart = IndividualMRChart::new();
889        let data = [10.0, 12.0, 9.0];
890        for &x in &data {
891            chart.add_sample(&[x]);
892        }
893        // MR values: |12-10| = 2, |9-12| = 3
894        let mr_pts = chart.mr_points();
895        assert_eq!(mr_pts.len(), 2);
896        assert!((mr_pts[0].value - 2.0).abs() < f64::EPSILON);
897        assert!((mr_pts[1].value - 3.0).abs() < f64::EPSILON);
898    }
899
900    #[test]
901    fn test_imr_rejects_multi_element_sample() {
902        let mut chart = IndividualMRChart::new();
903        chart.add_sample(&[1.0, 2.0]);
904        assert!(chart.points().is_empty());
905    }
906
907    #[test]
908    fn test_imr_detects_out_of_control() {
909        let mut chart = IndividualMRChart::new();
910        for i in 0..10 {
911            chart.add_sample(&[50.0 + (i as f64 % 3.0) * 0.5]);
912        }
913        // Add a far outlier
914        chart.add_sample(&[100.0]);
915
916        assert!(!chart.is_in_control());
917    }
918
919    #[test]
920    fn test_imr_default() {
921        let chart = IndividualMRChart::default();
922        assert!(chart.points().is_empty());
923    }
924
925    // --- Helper function tests ---
926
927    #[test]
928    fn test_subgroup_range() {
929        assert!((subgroup_range(&[1.0, 5.0, 3.0]) - 4.0).abs() < f64::EPSILON);
930        assert!((subgroup_range(&[10.0, 10.0, 10.0])).abs() < f64::EPSILON);
931    }
932
933    // --- Textbook verification: X-bar-R chart factors ---
934
935    #[test]
936    fn test_xbar_r_chart_factors_n5() {
937        // For n=5: A2=0.577, D3=0.0, D4=2.114
938        // Subgroup with mean=50, range=10
939        let mut chart = XBarRChart::new(5);
940        chart.add_sample(&[45.0, 47.0, 50.0, 53.0, 55.0]);
941
942        let limits = chart.control_limits().expect("limits");
943        assert!((limits.cl - 50.0).abs() < f64::EPSILON);
944
945        let r_limits = chart.r_limits().expect("R limits");
946        assert!((r_limits.cl - 10.0).abs() < f64::EPSILON);
947
948        // UCL = 50 + 0.577 * 10 = 55.77
949        assert!((limits.ucl - 55.77).abs() < 0.01);
950        // LCL = 50 - 0.577 * 10 = 44.23
951        assert!((limits.lcl - 44.23).abs() < 0.01);
952    }
953
954    // --- Textbook verification: I-MR chart ---
955
956    #[test]
957    fn test_imr_e2_factor() {
958        // E2 = 2.660
959        // Two points with X-bar = 100, MR = |105-95| = 10
960        let mut chart = IndividualMRChart::new();
961        chart.add_sample(&[95.0]);
962        chart.add_sample(&[105.0]);
963
964        let limits = chart.control_limits().expect("limits");
965        // X-bar = 100
966        assert!((limits.cl - 100.0).abs() < f64::EPSILON);
967        // MR-bar = 10
968        // UCL = 100 + 2.660 * 10 = 126.6
969        assert!((limits.ucl - 126.6).abs() < 0.1);
970        // LCL = 100 - 2.660 * 10 = 73.4
971        assert!((limits.lcl - 73.4).abs() < 0.1);
972    }
973
974    // --- Montgomery Table VI: d2 constant verification ---
975
976    /// Verify d2 constants against Montgomery (2020), Appendix Table VI.
977    ///
978    /// d2 is the expected value of the sample range for a standard-normal
979    /// distribution with subgroup size n. Used as sigma-hat = R-bar / d2.
980    ///
981    /// Allowed tolerance: ±0.001 (matches 3-decimal precision in Table VI).
982    #[test]
983    fn test_d2_constants_montgomery_table_vi() {
984        // (n, expected_d2) pairs from Montgomery (2020) Table VI, n=2..10
985        let expected: [(usize, f64); 9] = [
986            (2, 1.128),
987            (3, 1.693),
988            (4, 2.059),
989            (5, 2.326),
990            (6, 2.534),
991            (7, 2.704),
992            (8, 2.847),
993            (9, 2.970),
994            (10, 3.078),
995        ];
996        for (n, d2_ref) in expected {
997            let d2 = D2[n - 2];
998            assert!(
999                (d2 - d2_ref).abs() < 0.001,
1000                "d2(n={n}): expected {d2_ref}, got {d2}"
1001            );
1002        }
1003    }
1004
1005    // --- Montgomery Table VI: c4 constant verification ---
1006
1007    /// Verify c4 constants against Montgomery (2020), Appendix Table VI.
1008    ///
1009    /// c4 is the bias-correction factor for estimating sigma from S-bar.
1010    /// sigma-hat = S-bar / c4.
1011    ///
1012    /// Allowed tolerance: ±0.001 (matches 4-decimal precision in Table VI).
1013    #[test]
1014    fn test_c4_constants_montgomery_table_vi() {
1015        // (n, expected_c4) pairs from Montgomery (2020) Table VI, n=2..6
1016        let expected: [(usize, f64); 6] = [
1017            (2, 0.7979),
1018            (3, 0.8862),
1019            (4, 0.9213),
1020            (5, 0.9400),
1021            (6, 0.9515),
1022            (7, 0.9594),
1023        ];
1024        for (n, c4_ref) in expected {
1025            let c4 = C4[n - 2];
1026            assert!(
1027                (c4 - c4_ref).abs() < 0.001,
1028                "c4(n={n}): expected {c4_ref}, got {c4}"
1029            );
1030        }
1031    }
1032}