Skip to main content

u_analytics/spc/
attributes.rs

1//! Attributes control charts: P, NP, C, and U charts.
2//!
3//! These charts monitor discrete (count/proportion) data from a process.
4//! Unlike variables charts, attributes charts use the binomial or Poisson
5//! distribution to compute control limits.
6//!
7//! # Chart Selection Guide
8//!
9//! | Chart | Data Type | Sample Size |
10//! |-------|-----------|-------------|
11//! | P     | Proportion defective | Variable |
12//! | NP    | Count defective | Constant |
13//! | C     | Count of defects | Constant area |
14//! | U     | Defects per unit | Variable area |
15//!
16//! # References
17//!
18//! - Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
19//!   Chapter 7: Control Charts for Attributes.
20//! - ASTM E2587 — Standard Practice for Use of Control Charts
21
22/// A single data point on an attributes control chart.
23///
24/// Contains the computed statistic, its control limits (which may vary
25/// per point for charts with variable sample sizes), and an out-of-control flag.
26#[derive(Debug, Clone)]
27pub struct AttributeChartPoint {
28    /// The zero-based index of this point.
29    pub index: usize,
30    /// The computed statistic value (proportion, count, or rate).
31    pub value: f64,
32    /// Upper control limit for this point.
33    pub ucl: f64,
34    /// Center line for this point.
35    pub cl: f64,
36    /// Lower control limit for this point.
37    pub lcl: f64,
38    /// Whether this point is out of control (beyond UCL or below LCL).
39    pub out_of_control: bool,
40}
41
42// ---------------------------------------------------------------------------
43// P Chart
44// ---------------------------------------------------------------------------
45
46/// Proportion nonconforming (P) chart.
47///
48/// Monitors the fraction of defective items in samples that may have
49/// different sizes. Control limits vary per subgroup when sample sizes differ.
50///
51/// # Formulas
52///
53/// - CL = p-bar = total_defectives / total_inspected
54/// - UCL_i = p-bar + 3 * sqrt(p-bar * (1 - p-bar) / n_i)
55/// - LCL_i = max(0, p-bar - 3 * sqrt(p-bar * (1 - p-bar) / n_i))
56///
57/// # Examples
58///
59/// ```
60/// use u_analytics::spc::PChart;
61///
62/// let mut chart = PChart::new();
63/// chart.add_sample(3, 100).unwrap();  // 3 defectives out of 100
64/// chart.add_sample(5, 100).unwrap();
65/// chart.add_sample(2, 100).unwrap();
66/// chart.add_sample(4, 100).unwrap();
67///
68/// let p_bar = chart.p_bar().expect("should have p_bar after adding samples");
69/// assert!(p_bar > 0.0);
70/// ```
71///
72/// # Reference
73///
74/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
75/// Chapter 7, Section 7.3.
76pub struct PChart {
77    /// Stored samples as (defective_count, sample_size) pairs.
78    samples: Vec<(u64, u64)>,
79    /// Computed chart points.
80    chart_points: Vec<AttributeChartPoint>,
81    /// Overall proportion defective (p-bar).
82    p_bar: Option<f64>,
83}
84
85impl PChart {
86    /// Create a new P chart.
87    pub fn new() -> Self {
88        Self {
89            samples: Vec::new(),
90            chart_points: Vec::new(),
91            p_bar: None,
92        }
93    }
94
95    /// Add a sample with the number of defective items and the total sample size.
96    ///
97    /// # Errors
98    ///
99    /// [`ControlChartError::ZeroSampleSize`](crate::spc::ControlChartError::ZeroSampleSize)
100    /// if `sample_size` is 0, or
101    /// [`ControlChartError::DefectivesExceedSampleSize`](crate::spc::ControlChartError::DefectivesExceedSampleSize)
102    /// if `defectives > sample_size`. The chart is left unchanged; such a
103    /// sample used to be dropped silently, shifting every later point.
104    pub fn add_sample(
105        &mut self,
106        defectives: u64,
107        sample_size: u64,
108    ) -> Result<(), super::chart::ControlChartError> {
109        use super::chart::ControlChartError;
110        if sample_size == 0 {
111            return Err(ControlChartError::ZeroSampleSize);
112        }
113        if defectives > sample_size {
114            return Err(ControlChartError::DefectivesExceedSampleSize {
115                defectives,
116                sample_size,
117            });
118        }
119        self.samples.push((defectives, sample_size));
120        self.recompute();
121        Ok(())
122    }
123
124    /// Get the overall proportion defective (p-bar), or `None` if no data.
125    pub fn p_bar(&self) -> Option<f64> {
126        self.p_bar
127    }
128
129    /// Get all chart points.
130    pub fn points(&self) -> &[AttributeChartPoint] {
131        &self.chart_points
132    }
133
134    /// Check if the process is in statistical control.
135    pub fn is_in_control(&self) -> bool {
136        self.chart_points.iter().all(|p| !p.out_of_control)
137    }
138
139    /// Recompute p-bar, control limits, and out-of-control flags.
140    fn recompute(&mut self) {
141        if self.samples.is_empty() {
142            self.p_bar = None;
143            self.chart_points.clear();
144            return;
145        }
146
147        let total_defectives: u64 = self.samples.iter().map(|&(d, _)| d).sum();
148        let total_inspected: u64 = self.samples.iter().map(|&(_, n)| n).sum();
149        let p_bar = total_defectives as f64 / total_inspected as f64;
150        self.p_bar = Some(p_bar);
151
152        self.chart_points = self
153            .samples
154            .iter()
155            .enumerate()
156            .map(|(i, &(defectives, sample_size))| {
157                let p = defectives as f64 / sample_size as f64;
158                let n = sample_size as f64;
159                let sigma = (p_bar * (1.0 - p_bar) / n).sqrt();
160                let ucl = p_bar + 3.0 * sigma;
161                let lcl = (p_bar - 3.0 * sigma).max(0.0);
162
163                AttributeChartPoint {
164                    index: i,
165                    value: p,
166                    ucl,
167                    cl: p_bar,
168                    lcl,
169                    out_of_control: p > ucl || p < lcl,
170                }
171            })
172            .collect();
173    }
174}
175
176impl Default for PChart {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182// ---------------------------------------------------------------------------
183// NP Chart
184// ---------------------------------------------------------------------------
185
186/// Count of nonconforming items (NP) chart.
187///
188/// Monitors the count of defective items in samples of constant size.
189/// Simpler than the P chart when sample sizes are uniform.
190///
191/// # Formulas
192///
193/// - CL = n * p-bar
194/// - UCL = n * p-bar + 3 * sqrt(n * p-bar * (1 - p-bar))
195/// - LCL = max(0, n * p-bar - 3 * sqrt(n * p-bar * (1 - p-bar)))
196///
197/// # Reference
198///
199/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
200/// Chapter 7, Section 7.3.
201pub struct NPChart {
202    /// Constant sample size.
203    sample_size: u64,
204    /// Defective counts per subgroup.
205    defective_counts: Vec<u64>,
206    /// Computed chart points.
207    chart_points: Vec<AttributeChartPoint>,
208    /// Control limits (constant for NP chart).
209    limits: Option<(f64, f64, f64)>, // (ucl, cl, lcl)
210}
211
212impl NPChart {
213    /// Create a new NP chart with a constant sample size.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`ControlChartError::ZeroSampleSize`](crate::spc::ControlChartError::ZeroSampleSize)
218    /// when `sample_size == 0`. The size usually comes from data, so this is an
219    /// ordinary outcome rather than a contract violation -- and a boundary that
220    /// cannot unwind, such as the WebAssembly entry points, needs it as a value.
221    pub fn new(sample_size: u64) -> Result<Self, super::chart::ControlChartError> {
222        if sample_size == 0 {
223            return Err(super::chart::ControlChartError::ZeroSampleSize);
224        }
225        Ok(Self {
226            sample_size,
227            defective_counts: Vec::new(),
228            chart_points: Vec::new(),
229            limits: None,
230        })
231    }
232
233    /// Add a defective count for one subgroup.
234    ///
235    /// # Errors
236    ///
237    /// [`ControlChartError::DefectivesExceedSampleSize`](crate::spc::ControlChartError::DefectivesExceedSampleSize)
238    /// if `defectives` exceeds the chart's sample size. The chart is left
239    /// unchanged.
240    pub fn add_sample(&mut self, defectives: u64) -> Result<(), super::chart::ControlChartError> {
241        if defectives > self.sample_size {
242            return Err(
243                super::chart::ControlChartError::DefectivesExceedSampleSize {
244                    defectives,
245                    sample_size: self.sample_size,
246                },
247            );
248        }
249        self.defective_counts.push(defectives);
250        self.recompute();
251        Ok(())
252    }
253
254    /// Get the control limits as `(ucl, cl, lcl)`, or `None` if no data.
255    pub fn control_limits(&self) -> Option<(f64, f64, f64)> {
256        self.limits
257    }
258
259    /// Get all chart points.
260    pub fn points(&self) -> &[AttributeChartPoint] {
261        &self.chart_points
262    }
263
264    /// Check if the process is in statistical control.
265    pub fn is_in_control(&self) -> bool {
266        self.chart_points.iter().all(|p| !p.out_of_control)
267    }
268
269    /// Recompute limits and points.
270    fn recompute(&mut self) {
271        if self.defective_counts.is_empty() {
272            self.limits = None;
273            self.chart_points.clear();
274            return;
275        }
276
277        let total_defectives: u64 = self.defective_counts.iter().sum();
278        let total_inspected = self.sample_size * self.defective_counts.len() as u64;
279        let p_bar = total_defectives as f64 / total_inspected as f64;
280        let n = self.sample_size as f64;
281
282        let np_bar = n * p_bar;
283        let sigma = (n * p_bar * (1.0 - p_bar)).sqrt();
284        let ucl = np_bar + 3.0 * sigma;
285        let lcl = (np_bar - 3.0 * sigma).max(0.0);
286
287        self.limits = Some((ucl, np_bar, lcl));
288
289        self.chart_points = self
290            .defective_counts
291            .iter()
292            .enumerate()
293            .map(|(i, &count)| {
294                let value = count as f64;
295                AttributeChartPoint {
296                    index: i,
297                    value,
298                    ucl,
299                    cl: np_bar,
300                    lcl,
301                    out_of_control: value > ucl || value < lcl,
302                }
303            })
304            .collect();
305    }
306}
307
308// ---------------------------------------------------------------------------
309// C Chart
310// ---------------------------------------------------------------------------
311
312/// Count of defects per unit (C) chart.
313///
314/// Monitors the total number of defects observed in a constant area of
315/// opportunity (inspection unit). Based on the Poisson distribution.
316///
317/// # Formulas
318///
319/// - CL = c-bar (mean defect count)
320/// - UCL = c-bar + 3 * sqrt(c-bar)
321/// - LCL = max(0, c-bar - 3 * sqrt(c-bar))
322///
323/// # Reference
324///
325/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
326/// Chapter 7, Section 7.4.
327pub struct CChart {
328    /// Defect counts per unit.
329    defect_counts: Vec<u64>,
330    /// Computed chart points.
331    chart_points: Vec<AttributeChartPoint>,
332    /// Control limits (constant for C chart).
333    limits: Option<(f64, f64, f64)>, // (ucl, cl, lcl)
334}
335
336impl CChart {
337    /// Create a new C chart.
338    pub fn new() -> Self {
339        Self {
340            defect_counts: Vec::new(),
341            chart_points: Vec::new(),
342            limits: None,
343        }
344    }
345
346    /// Add a defect count for one inspection unit.
347    pub fn add_sample(&mut self, defects: u64) {
348        self.defect_counts.push(defects);
349        self.recompute();
350    }
351
352    /// Get the control limits as `(ucl, cl, lcl)`, or `None` if no data.
353    pub fn control_limits(&self) -> Option<(f64, f64, f64)> {
354        self.limits
355    }
356
357    /// Get all chart points.
358    pub fn points(&self) -> &[AttributeChartPoint] {
359        &self.chart_points
360    }
361
362    /// Check if the process is in statistical control.
363    pub fn is_in_control(&self) -> bool {
364        self.chart_points.iter().all(|p| !p.out_of_control)
365    }
366
367    /// Recompute limits and points.
368    fn recompute(&mut self) {
369        if self.defect_counts.is_empty() {
370            self.limits = None;
371            self.chart_points.clear();
372            return;
373        }
374
375        let total: u64 = self.defect_counts.iter().sum();
376        let c_bar = total as f64 / self.defect_counts.len() as f64;
377        let sigma = c_bar.sqrt();
378        let ucl = c_bar + 3.0 * sigma;
379        let lcl = (c_bar - 3.0 * sigma).max(0.0);
380
381        self.limits = Some((ucl, c_bar, lcl));
382
383        self.chart_points = self
384            .defect_counts
385            .iter()
386            .enumerate()
387            .map(|(i, &count)| {
388                let value = count as f64;
389                AttributeChartPoint {
390                    index: i,
391                    value,
392                    ucl,
393                    cl: c_bar,
394                    lcl,
395                    out_of_control: value > ucl || value < lcl,
396                }
397            })
398            .collect();
399    }
400}
401
402impl Default for CChart {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408// ---------------------------------------------------------------------------
409// U Chart
410// ---------------------------------------------------------------------------
411
412/// Defects per unit (U) chart.
413///
414/// Monitors the defect rate when the area of opportunity (inspection size)
415/// varies between subgroups. Control limits are computed individually for
416/// each subgroup based on its inspection size.
417///
418/// # Formulas
419///
420/// - CL = u-bar = total_defects / total_units
421/// - UCL_i = u-bar + 3 * sqrt(u-bar / n_i)
422/// - LCL_i = max(0, u-bar - 3 * sqrt(u-bar / n_i))
423///
424/// # Reference
425///
426/// Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.,
427/// Chapter 7, Section 7.4.
428pub struct UChart {
429    /// Stored samples as (defect_count, units_inspected) pairs.
430    samples: Vec<(u64, f64)>,
431    /// Computed chart points.
432    chart_points: Vec<AttributeChartPoint>,
433    /// Overall defect rate (u-bar).
434    u_bar: Option<f64>,
435}
436
437impl UChart {
438    /// Create a new U chart.
439    pub fn new() -> Self {
440        Self {
441            samples: Vec::new(),
442            chart_points: Vec::new(),
443            u_bar: None,
444        }
445    }
446
447    /// Add a sample with the number of defects and the number of units inspected.
448    ///
449    /// The `units_inspected` can be fractional (e.g., area or length).
450    ///
451    /// # Errors
452    ///
453    /// [`ControlChartError::NonPositiveUnits`](crate::spc::ControlChartError::NonPositiveUnits)
454    /// if `units_inspected` is not a positive, finite number. The chart is left
455    /// unchanged.
456    pub fn add_sample(
457        &mut self,
458        defects: u64,
459        units_inspected: f64,
460    ) -> Result<(), super::chart::ControlChartError> {
461        if !units_inspected.is_finite() || units_inspected <= 0.0 {
462            return Err(super::chart::ControlChartError::NonPositiveUnits);
463        }
464        self.samples.push((defects, units_inspected));
465        self.recompute();
466        Ok(())
467    }
468
469    /// Get the overall defect rate (u-bar), or `None` if no data.
470    pub fn u_bar(&self) -> Option<f64> {
471        self.u_bar
472    }
473
474    /// Get all chart points.
475    pub fn points(&self) -> &[AttributeChartPoint] {
476        &self.chart_points
477    }
478
479    /// Check if the process is in statistical control.
480    pub fn is_in_control(&self) -> bool {
481        self.chart_points.iter().all(|p| !p.out_of_control)
482    }
483
484    /// Recompute u-bar, control limits, and out-of-control flags.
485    fn recompute(&mut self) {
486        if self.samples.is_empty() {
487            self.u_bar = None;
488            self.chart_points.clear();
489            return;
490        }
491
492        let total_defects: u64 = self.samples.iter().map(|&(d, _)| d).sum();
493        let total_units: f64 = self.samples.iter().map(|&(_, n)| n).sum();
494        let u_bar = total_defects as f64 / total_units;
495        self.u_bar = Some(u_bar);
496
497        self.chart_points = self
498            .samples
499            .iter()
500            .enumerate()
501            .map(|(i, &(defects, units))| {
502                let u = defects as f64 / units;
503                let sigma = (u_bar / units).sqrt();
504                let ucl = u_bar + 3.0 * sigma;
505                let lcl = (u_bar - 3.0 * sigma).max(0.0);
506
507                AttributeChartPoint {
508                    index: i,
509                    value: u,
510                    ucl,
511                    cl: u_bar,
512                    lcl,
513                    out_of_control: u > ucl || u < lcl,
514                }
515            })
516            .collect();
517    }
518}
519
520impl Default for UChart {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526// ---------------------------------------------------------------------------
527// Laney P' Chart
528// ---------------------------------------------------------------------------
529
530/// A single data point for a Laney P' or U' chart.
531///
532/// Laney charts correct for overdispersion or underdispersion by estimating
533/// a process sigma-inflation factor φ from the moving range of standardized
534/// subgroup statistics.
535///
536/// # References
537///
538/// - Laney, D.B. (2002). "Improved control charts for attributes",
539///   *Quality Engineering* 14(4), pp. 531-537.
540#[derive(Debug, Clone)]
541pub struct LaneyAttributePoint {
542    /// Zero-based index of this subgroup.
543    pub index: usize,
544    /// The observed statistic (proportion or defect rate) for this subgroup.
545    pub value: f64,
546    /// Upper control limit (overdispersion-adjusted).
547    pub ucl: f64,
548    /// Center line (overall mean proportion or rate).
549    pub cl: f64,
550    /// Lower control limit (overdispersion-adjusted, clamped to 0).
551    pub lcl: f64,
552    /// Whether this point lies beyond its control limits.
553    pub out_of_control: bool,
554}
555
556/// Laney P' chart result.
557///
558/// Contains the overall proportion defective, the overdispersion factor φ,
559/// and the per-subgroup chart points with adjusted limits.
560#[derive(Debug, Clone)]
561pub struct LaneyPChart {
562    /// Overall proportion defective (p̄ = Σdᵢ / Σnᵢ).
563    pub p_bar: f64,
564    /// Overdispersion/underdispersion correction factor φ = MR̄ / d₂.
565    /// φ = 1.0 means no correction needed (ordinary P chart).
566    pub phi: f64,
567    /// Per-subgroup chart points.
568    pub points: Vec<LaneyAttributePoint>,
569}
570
571/// Laney U' chart result.
572///
573/// Contains the overall defect rate, the overdispersion factor φ,
574/// and the per-subgroup chart points with adjusted limits.
575#[derive(Debug, Clone)]
576pub struct LaneyUChart {
577    /// Overall defect rate (ū = Σdefectsᵢ / Σunitsᵢ).
578    pub u_bar: f64,
579    /// Overdispersion/underdispersion correction factor φ = MR̄ / d₂.
580    /// φ = 1.0 means no correction needed (ordinary U chart).
581    pub phi: f64,
582    /// Per-subgroup chart points.
583    pub points: Vec<LaneyAttributePoint>,
584}
585
586/// Compute the Laney P' chart from `(defective_count, sample_size)` pairs.
587///
588/// The Laney P' chart adjusts control limits for overdispersion or underdispersion
589/// by estimating a sigma-inflation factor φ from the moving range of standardized
590/// proportions. When φ = 1.0 the limits reduce to those of a standard P chart.
591///
592/// # Algorithm
593///
594/// 1. p̄ = Σdᵢ / Σnᵢ
595/// 2. zᵢ = (pᵢ − p̄) / √(p̄·(1−p̄)/nᵢ)
596/// 3. MR̄ = mean(|zᵢ − z_{i-1}|) for i = 1..n-1
597/// 4. φ = MR̄ / d₂,  d₂ = 1.128 (for moving range of 2 observations)
598/// 5. UCLᵢ = p̄ + 3·φ·√(p̄·(1−p̄)/nᵢ)
599/// 6. LCLᵢ = max(0, p̄ − 3·φ·√(p̄·(1−p̄)/nᵢ))
600///
601/// # Returns
602///
603/// `None` if fewer than 3 subgroups are provided, or if any sample has a size
604/// of zero or more defectives than its size. When p̄ is 0 or 1 there is no
605/// variation to scale (σ = 0): the chart comes back with φ = 0 and every limit
606/// on the centre line.
607///
608/// # Reference
609///
610/// Laney, D.B. (2002). "Improved control charts for attributes",
611/// *Quality Engineering* 14(4), pp. 531-537.
612pub fn laney_p_chart(samples: &[(u64, u64)]) -> Option<LaneyPChart> {
613    if samples.len() < 3 {
614        return None;
615    }
616    // Checked per sample, not only in total: one sample with nothing inspected
617    // makes its z-score NaN, and a NaN phi puts every limit at NaN -- where no
618    // point compares as out of control and the chart reads as in control.
619    if samples.iter().any(|&(d, n)| n == 0 || d > n) {
620        return None;
621    }
622
623    let total_defectives: u64 = samples.iter().map(|&(d, _)| d).sum();
624    let total_inspected: u64 = samples.iter().map(|&(_, n)| n).sum();
625
626    let p_bar = total_defectives as f64 / total_inspected as f64;
627    // Degenerate: σ = 0 means all proportions are exactly p̄ — φ computation is undefined.
628    let base_var = p_bar * (1.0 - p_bar);
629    if base_var <= 0.0 {
630        // φ = 0 (no variability), build chart with zero-width limits.
631        let points = samples
632            .iter()
633            .enumerate()
634            .map(|(i, &(d, n))| {
635                let value = d as f64 / n as f64;
636                LaneyAttributePoint {
637                    index: i,
638                    value,
639                    ucl: p_bar,
640                    cl: p_bar,
641                    lcl: p_bar,
642                    out_of_control: false,
643                }
644            })
645            .collect();
646        return Some(LaneyPChart {
647            p_bar,
648            phi: 0.0,
649            points,
650        });
651    }
652
653    // Step 2: standardized proportions.
654    let z_scores: Vec<f64> = samples
655        .iter()
656        .map(|&(d, n)| {
657            let p_i = d as f64 / n as f64;
658            let sigma_i = (base_var / n as f64).sqrt();
659            (p_i - p_bar) / sigma_i
660        })
661        .collect();
662
663    // Step 3: moving ranges of z-scores.
664    let mr_bar = {
665        let mrs: Vec<f64> = z_scores.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
666        mrs.iter().sum::<f64>() / mrs.len() as f64
667    };
668
669    // Step 4: φ = MR̄ / d₂ (d₂ = 1.128 for subgroup of 2).
670    const D2: f64 = 1.128;
671    let phi = mr_bar / D2;
672
673    // Steps 5-6: build chart points.
674    let points = samples
675        .iter()
676        .enumerate()
677        .map(|(i, &(d, n))| {
678            let p_i = d as f64 / n as f64;
679            let sigma_i = (base_var / n as f64).sqrt();
680            let ucl = p_bar + 3.0 * phi * sigma_i;
681            let lcl = (p_bar - 3.0 * phi * sigma_i).max(0.0);
682            LaneyAttributePoint {
683                index: i,
684                value: p_i,
685                ucl,
686                cl: p_bar,
687                lcl,
688                out_of_control: p_i > ucl || p_i < lcl,
689            }
690        })
691        .collect();
692
693    Some(LaneyPChart { p_bar, phi, points })
694}
695
696/// Compute the Laney U' chart from `(defect_count, inspection_units)` pairs.
697///
698/// The Laney U' chart adjusts control limits for overdispersion or underdispersion
699/// by estimating a sigma-inflation factor φ from the moving range of standardized
700/// defect rates. When φ = 1.0 the limits reduce to those of a standard U chart.
701///
702/// # Algorithm
703///
704/// 1. ū = Σdefectsᵢ / Σunitsᵢ
705/// 2. zᵢ = (uᵢ − ū) / √(ū / unitsᵢ)
706/// 3. MR̄ = mean(|zᵢ − z_{i-1}|)
707/// 4. φ = MR̄ / d₂,  d₂ = 1.128
708/// 5. UCLᵢ = ū + 3·φ·√(ū / unitsᵢ)
709/// 6. LCLᵢ = max(0, ū − 3·φ·√(ū / unitsᵢ))
710///
711/// # Returns
712///
713/// `None` if fewer than 3 subgroups, if total units are zero, or if any
714/// `units` entry is non-positive.
715///
716/// # Reference
717///
718/// Laney, D.B. (2002). "Improved control charts for attributes",
719/// *Quality Engineering* 14(4), pp. 531-537.
720pub fn laney_u_chart(samples: &[(u64, f64)]) -> Option<LaneyUChart> {
721    if samples.len() < 3 {
722        return None;
723    }
724
725    // Validate: all units must be positive and finite.
726    if samples.iter().any(|&(_, n)| !n.is_finite() || n <= 0.0) {
727        return None;
728    }
729
730    let total_defects: u64 = samples.iter().map(|&(d, _)| d).sum();
731    let total_units: f64 = samples.iter().map(|&(_, n)| n).sum();
732    if total_units <= 0.0 {
733        return None;
734    }
735
736    let u_bar = total_defects as f64 / total_units;
737
738    // Degenerate: ū = 0 means no defects observed — φ computation is undefined.
739    if u_bar <= 0.0 {
740        let points = samples
741            .iter()
742            .enumerate()
743            .map(|(i, &(d, n))| {
744                let value = d as f64 / n;
745                LaneyAttributePoint {
746                    index: i,
747                    value,
748                    ucl: 0.0,
749                    cl: 0.0,
750                    lcl: 0.0,
751                    out_of_control: false,
752                }
753            })
754            .collect();
755        return Some(LaneyUChart {
756            u_bar: 0.0,
757            phi: 0.0,
758            points,
759        });
760    }
761
762    // Step 2: standardized defect rates.
763    let z_scores: Vec<f64> = samples
764        .iter()
765        .map(|&(d, n)| {
766            let u_i = d as f64 / n;
767            let sigma_i = (u_bar / n).sqrt();
768            (u_i - u_bar) / sigma_i
769        })
770        .collect();
771
772    // Step 3: moving ranges.
773    let mr_bar = {
774        let mrs: Vec<f64> = z_scores.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
775        mrs.iter().sum::<f64>() / mrs.len() as f64
776    };
777
778    // Step 4: φ = MR̄ / d₂.
779    const D2: f64 = 1.128;
780    let phi = mr_bar / D2;
781
782    // Steps 5-6: build chart points.
783    let points = samples
784        .iter()
785        .enumerate()
786        .map(|(i, &(d, n))| {
787            let u_i = d as f64 / n;
788            let sigma_i = (u_bar / n).sqrt();
789            let ucl = u_bar + 3.0 * phi * sigma_i;
790            let lcl = (u_bar - 3.0 * phi * sigma_i).max(0.0);
791            LaneyAttributePoint {
792                index: i,
793                value: u_i,
794                ucl,
795                cl: u_bar,
796                lcl,
797                out_of_control: u_i > ucl || u_i < lcl,
798            }
799        })
800        .collect();
801
802    Some(LaneyUChart { u_bar, phi, points })
803}
804
805// ---------------------------------------------------------------------------
806// G Chart (Geometric — inter-defect conforming count)
807// ---------------------------------------------------------------------------
808
809/// A single data point on a G or T chart.
810#[derive(Debug, Clone)]
811pub struct GChartPoint {
812    /// Zero-based index of this inter-event observation.
813    pub index: usize,
814    /// The observed inter-event count (for G) or time (for T).
815    pub value: f64,
816    /// Upper control limit.
817    pub ucl: f64,
818    /// Center line (mean of the series).
819    pub cl: f64,
820    /// Lower control limit (clamped to 0).
821    pub lcl: f64,
822    /// Whether this point lies beyond its control limits.
823    pub out_of_control: bool,
824}
825
826/// G chart (geometric distribution) result for rare-event monitoring.
827///
828/// Monitors the number of conforming units between consecutive defect events.
829/// Appropriate when the defect rate is very low (< 1%) and standard P/NP
830/// charts produce degenerate limits.
831///
832/// # References
833///
834/// - Kaminsky, F.C. et al. (1992). "Statistical control charts based on a
835///   geometric distribution", *Journal of Quality Technology* 24(2), pp. 63-69.
836/// - Benneyan, J.C. (2001). "Number-Between g-Type Statistical Quality Control
837///   Charts for Healthcare Applications", *Health Care Management Science* 4(4),
838///   pp. 305-318.
839/// - Woodall, W.H. (2006). "The Use of Control Charts in Health-Care and
840///   Public-Health Surveillance", *Journal of Quality Technology* 38(2),
841///   pp. 89-104.
842#[derive(Debug, Clone)]
843pub struct GChart {
844    /// Mean inter-event conforming count (ḡ).
845    pub g_bar: f64,
846    /// Per-observation chart points.
847    pub points: Vec<GChartPoint>,
848}
849
850/// T chart (exponential distribution) result for rare-event monitoring.
851///
852/// Monitors the time between consecutive defect events.
853/// Control limits are derived from the exponential distribution percentiles
854/// corresponding to ±3σ probability mass (α/2 = 0.00135).
855///
856/// # Reference
857///
858/// Borror, C.M., Keats, J.B. & Montgomery, D.C. (2003). "Robustness of the
859/// time between events CUSUM", *International Journal of Production Research*
860/// 41(15), pp. 3435-3444.
861#[derive(Debug, Clone)]
862pub struct TChart {
863    /// Mean inter-event time (t̄).
864    pub t_bar: f64,
865    /// Per-observation chart points.
866    pub points: Vec<TChartPoint>,
867}
868
869/// A single data point on a T chart.
870#[derive(Debug, Clone)]
871pub struct TChartPoint {
872    /// Zero-based index of this inter-event observation.
873    pub index: usize,
874    /// The observed inter-event time.
875    pub value: f64,
876    /// Upper control limit.
877    pub ucl: f64,
878    /// Center line (mean inter-event time).
879    pub cl: f64,
880    /// Lower control limit (clamped to 0).
881    pub lcl: f64,
882    /// Whether this point lies beyond its control limits.
883    pub out_of_control: bool,
884}
885
886/// Compute the G chart from inter-event conforming counts.
887///
888/// # Formulas
889///
890/// - ḡ = mean(gᵢ)
891/// - UCL = ḡ + 3·√(ḡ·(ḡ+1))
892/// - LCL = max(0, ḡ − 3·√(ḡ·(ḡ+1)))
893///
894/// The spread term √(ḡ·(ḡ+1)) follows directly from the variance of the
895/// geometric distribution: Var(G) = (1−p)/p² ≈ ḡ·(ḡ+1) when p is small.
896///
897/// # Returns
898///
899/// `None` if fewer than 3 observations or any count is negative.
900///
901/// # References
902///
903/// - Kaminsky, F.C. et al. (1992). "Statistical control charts based on a
904///   geometric distribution", *Journal of Quality Technology* 24(2), pp. 63-69.
905/// - Benneyan, J.C. (2001). "Number-Between g-Type Statistical Quality Control
906///   Charts for Healthcare Applications", *Health Care Management Science* 4(4),
907///   pp. 305-318.
908/// - Woodall, W.H. (2006). "The Use of Control Charts in Health-Care and
909///   Public-Health Surveillance", *Journal of Quality Technology* 38(2),
910///   pp. 89-104.
911pub fn g_chart(inter_event_counts: &[f64]) -> Option<GChart> {
912    if inter_event_counts.len() < 3 {
913        return None;
914    }
915    if inter_event_counts
916        .iter()
917        .any(|&v| !v.is_finite() || v < 0.0)
918    {
919        return None;
920    }
921
922    let g_bar = inter_event_counts.iter().sum::<f64>() / inter_event_counts.len() as f64;
923    let spread = (g_bar * (g_bar + 1.0)).sqrt();
924    let ucl = g_bar + 3.0 * spread;
925    let lcl = (g_bar - 3.0 * spread).max(0.0);
926
927    let points = inter_event_counts
928        .iter()
929        .enumerate()
930        .map(|(i, &v)| GChartPoint {
931            index: i,
932            value: v,
933            ucl,
934            cl: g_bar,
935            lcl,
936            out_of_control: v > ucl || v < lcl,
937        })
938        .collect();
939
940    Some(GChart { g_bar, points })
941}
942
943/// Compute the T chart from inter-event times.
944///
945/// # Formulas (exponential distribution percentiles)
946///
947/// - t̄ = mean(tᵢ)
948/// - UCL = t̄ · (−ln(0.00135))  ≈ t̄ · 6.6077
949/// - LCL = max(0, t̄ · (−ln(0.99865)))  ≈ t̄ · 0.00135
950///
951/// The constants are derived from the 0.00135 and 0.99865 quantiles of the
952/// standard exponential distribution, matching the ±3σ tail probability used
953/// in Shewhart charts (α/2 = 0.00135).
954///
955/// # Returns
956///
957/// `None` if fewer than 3 observations or any time is non-positive.
958///
959/// # Reference
960///
961/// Borror, C.M., Keats, J.B. & Montgomery, D.C. (2003). "Robustness of the
962/// time between events CUSUM", *International Journal of Production Research*
963/// 41(15), pp. 3435-3444.
964pub fn t_chart(inter_event_times: &[f64]) -> Option<TChart> {
965    if inter_event_times.len() < 3 {
966        return None;
967    }
968    if inter_event_times
969        .iter()
970        .any(|&v| !v.is_finite() || v <= 0.0)
971    {
972        return None;
973    }
974
975    let t_bar = inter_event_times.iter().sum::<f64>() / inter_event_times.len() as f64;
976
977    // Exponential quantile: Q(p) = -t̄ · ln(1 - p) = t̄ · (-ln(p)) for the survival function.
978    // UCL corresponds to the 0.99865 quantile of Exp(1/t̄): −ln(1 − 0.99865) = −ln(0.00135).
979    // LCL corresponds to the 0.00135 quantile of Exp(1/t̄): −ln(1 − 0.00135) = −ln(0.99865).
980    let ucl_factor = -(0.00135_f64.ln()); // ≈ 6.6077
981    let lcl_factor = -(0.99865_f64.ln()); // ≈ 0.001351
982
983    let ucl = t_bar * ucl_factor;
984    let lcl = (t_bar * lcl_factor).max(0.0);
985
986    let points = inter_event_times
987        .iter()
988        .enumerate()
989        .map(|(i, &v)| TChartPoint {
990            index: i,
991            value: v,
992            ucl,
993            cl: t_bar,
994            lcl,
995            out_of_control: v > ucl || v < lcl,
996        })
997        .collect();
998
999    Some(TChart { t_bar, points })
1000}
1001
1002// ---------------------------------------------------------------------------
1003// Tests
1004// ---------------------------------------------------------------------------
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009
1010    // --- P Chart ---
1011
1012    #[test]
1013    fn test_p_chart_basic() {
1014        // Textbook example: 10 samples of size 100
1015        let mut chart = PChart::new();
1016        let defectives = [5, 8, 3, 6, 4, 7, 2, 9, 5, 6];
1017        for &d in &defectives {
1018            chart.add_sample(d, 100).unwrap();
1019        }
1020
1021        let p_bar = chart.p_bar().expect("should have p_bar");
1022        // p-bar = 55/1000 = 0.055
1023        assert!(
1024            (p_bar - 0.055).abs() < 1e-10,
1025            "p_bar={p_bar}, expected 0.055"
1026        );
1027
1028        // All 10 points should exist
1029        assert_eq!(chart.points().len(), 10);
1030
1031        // Verify center line on all points
1032        for pt in chart.points() {
1033            assert!((pt.cl - 0.055).abs() < 1e-10);
1034        }
1035    }
1036
1037    #[test]
1038    fn test_p_chart_limits() {
1039        let mut chart = PChart::new();
1040        // p-bar = 0.10, n = 100
1041        // sigma = sqrt(0.1 * 0.9 / 100) = 0.03
1042        // UCL = 0.10 + 0.09 = 0.19
1043        // LCL = 0.10 - 0.09 = 0.01
1044        chart.add_sample(10, 100).unwrap();
1045
1046        let pt = &chart.points()[0];
1047        assert!((pt.cl - 0.1).abs() < 1e-10);
1048        assert!((pt.ucl - 0.19).abs() < 0.001);
1049        assert!((pt.lcl - 0.01).abs() < 0.001);
1050    }
1051
1052    #[test]
1053    fn test_p_chart_variable_sample_sizes() {
1054        let mut chart = PChart::new();
1055        chart.add_sample(5, 100).unwrap();
1056        chart.add_sample(10, 200).unwrap();
1057        chart.add_sample(3, 50).unwrap();
1058
1059        // p-bar = 18/350
1060        let p_bar = chart.p_bar().expect("p_bar");
1061        assert!((p_bar - 18.0 / 350.0).abs() < 1e-10);
1062
1063        // UCL should differ per point due to variable n
1064        let pts = chart.points();
1065        // Larger sample = tighter limits
1066        assert!(pts[1].ucl - pts[1].cl < pts[0].ucl - pts[0].cl);
1067    }
1068
1069    #[test]
1070    fn test_p_chart_rejects_invalid() {
1071        use crate::spc::ControlChartError;
1072        let mut chart = PChart::new();
1073        assert_eq!(
1074            chart.add_sample(5, 0),
1075            Err(ControlChartError::ZeroSampleSize)
1076        );
1077        assert_eq!(
1078            chart.add_sample(10, 5),
1079            Err(ControlChartError::DefectivesExceedSampleSize {
1080                defectives: 10,
1081                sample_size: 5
1082            })
1083        );
1084        assert!(chart.p_bar().is_none());
1085    }
1086
1087    #[test]
1088    fn test_p_chart_lcl_clamped_to_zero() {
1089        let mut chart = PChart::new();
1090        // Very small p with small n → LCL would be negative
1091        chart.add_sample(1, 10).unwrap();
1092        let pt = &chart.points()[0];
1093        assert!(pt.lcl >= 0.0);
1094    }
1095
1096    #[test]
1097    fn test_p_chart_out_of_control() {
1098        let mut chart = PChart::new();
1099        // Establish baseline with many normal samples
1100        for _ in 0..20 {
1101            chart.add_sample(5, 100).unwrap();
1102        }
1103        // Add an outlier
1104        chart.add_sample(30, 100).unwrap();
1105
1106        assert!(!chart.is_in_control());
1107        let last = chart.points().last().expect("should have points");
1108        assert!(last.out_of_control);
1109    }
1110
1111    #[test]
1112    fn test_p_chart_default() {
1113        let chart = PChart::default();
1114        assert!(chart.p_bar().is_none());
1115        assert!(chart.points().is_empty());
1116    }
1117
1118    // --- NP Chart ---
1119
1120    #[test]
1121    fn test_np_chart_basic() {
1122        let mut chart = NPChart::new(100).expect("100 is a valid sample size");
1123        let defectives = [5, 8, 3, 6, 4, 7, 2, 9, 5, 6];
1124        for &d in &defectives {
1125            chart.add_sample(d).unwrap();
1126        }
1127
1128        let (ucl, cl, lcl) = chart.control_limits().expect("should have limits");
1129        // np-bar = 55/10 = 5.5
1130        assert!((cl - 5.5).abs() < 1e-10);
1131        assert!(ucl > cl);
1132        assert!(lcl < cl);
1133        assert!(lcl >= 0.0);
1134    }
1135
1136    #[test]
1137    fn test_np_chart_rejects_invalid() {
1138        let mut chart = NPChart::new(100).expect("100 is a valid sample size");
1139        assert_eq!(
1140            chart.add_sample(101),
1141            Err(crate::spc::ControlChartError::DefectivesExceedSampleSize {
1142                defectives: 101,
1143                sample_size: 100
1144            })
1145        );
1146        assert!(chart.control_limits().is_none());
1147    }
1148
1149    #[test]
1150    fn test_np_chart_zero_sample_size() {
1151        assert!(matches!(
1152            NPChart::new(0),
1153            Err(crate::spc::ControlChartError::ZeroSampleSize)
1154        ));
1155    }
1156
1157    #[test]
1158    fn test_np_chart_out_of_control() {
1159        let mut chart = NPChart::new(100).expect("100 is a valid sample size");
1160        for _ in 0..20 {
1161            chart.add_sample(5).unwrap();
1162        }
1163        chart.add_sample(30).unwrap();
1164
1165        assert!(!chart.is_in_control());
1166    }
1167
1168    #[test]
1169    fn test_np_chart_limits_formula() {
1170        // n=200, p-bar = 0.05 → np-bar = 10
1171        // sigma = sqrt(200 * 0.05 * 0.95) = sqrt(9.5) ≈ 3.082
1172        // UCL = 10 + 3*3.082 = 19.246
1173        // LCL = 10 - 3*3.082 = 0.754
1174        let mut chart = NPChart::new(200).expect("200 is a valid sample size");
1175        for _ in 0..10 {
1176            chart.add_sample(10).unwrap();
1177        }
1178
1179        let (ucl, cl, lcl) = chart.control_limits().expect("limits");
1180        assert!((cl - 10.0).abs() < 1e-10);
1181        let expected_sigma = (200.0_f64 * 0.05 * 0.95).sqrt();
1182        assert!((ucl - (10.0 + 3.0 * expected_sigma)).abs() < 0.01);
1183        assert!((lcl - (10.0 - 3.0 * expected_sigma)).abs() < 0.01);
1184    }
1185
1186    // --- C Chart ---
1187
1188    #[test]
1189    fn test_c_chart_basic() {
1190        let mut chart = CChart::new();
1191        let counts = [3, 5, 4, 6, 2, 7, 3, 4, 5, 6];
1192        for &c in &counts {
1193            chart.add_sample(c);
1194        }
1195
1196        let (ucl, cl, lcl) = chart.control_limits().expect("should have limits");
1197        // c-bar = 45/10 = 4.5
1198        assert!((cl - 4.5).abs() < 1e-10);
1199        // UCL = 4.5 + 3*sqrt(4.5) = 4.5 + 6.364 = 10.864
1200        let expected_ucl = 4.5 + 3.0 * 4.5_f64.sqrt();
1201        assert!((ucl - expected_ucl).abs() < 0.01);
1202        assert!(lcl >= 0.0);
1203    }
1204
1205    #[test]
1206    fn test_c_chart_out_of_control() {
1207        let mut chart = CChart::new();
1208        for _ in 0..20 {
1209            chart.add_sample(5);
1210        }
1211        chart.add_sample(50); // Way out of control
1212
1213        assert!(!chart.is_in_control());
1214    }
1215
1216    #[test]
1217    fn test_c_chart_single_sample() {
1218        let mut chart = CChart::new();
1219        chart.add_sample(10);
1220
1221        let (_, cl, _) = chart.control_limits().expect("limits");
1222        assert!((cl - 10.0).abs() < f64::EPSILON);
1223    }
1224
1225    #[test]
1226    fn test_c_chart_lcl_clamped() {
1227        // c-bar = 1 → LCL = 1 - 3*1 = -2 → clamped to 0
1228        let mut chart = CChart::new();
1229        chart.add_sample(1);
1230
1231        let (_, _, lcl) = chart.control_limits().expect("limits");
1232        assert!((lcl - 0.0).abs() < f64::EPSILON);
1233    }
1234
1235    #[test]
1236    fn test_c_chart_default() {
1237        let chart = CChart::default();
1238        assert!(chart.control_limits().is_none());
1239        assert!(chart.points().is_empty());
1240    }
1241
1242    // --- U Chart ---
1243
1244    #[test]
1245    fn test_u_chart_basic() {
1246        let mut chart = UChart::new();
1247        // 5 samples, each inspecting 10 units
1248        chart.add_sample(3, 10.0).unwrap();
1249        chart.add_sample(5, 10.0).unwrap();
1250        chart.add_sample(4, 10.0).unwrap();
1251        chart.add_sample(6, 10.0).unwrap();
1252        chart.add_sample(2, 10.0).unwrap();
1253
1254        let u_bar = chart.u_bar().expect("should have u_bar");
1255        // u-bar = 20/50 = 0.4
1256        assert!((u_bar - 0.4).abs() < 1e-10);
1257
1258        assert_eq!(chart.points().len(), 5);
1259    }
1260
1261    #[test]
1262    fn test_u_chart_variable_units() {
1263        let mut chart = UChart::new();
1264        chart.add_sample(10, 5.0).unwrap(); // u = 2.0
1265        chart.add_sample(20, 10.0).unwrap(); // u = 2.0
1266        chart.add_sample(5, 2.5).unwrap(); // u = 2.0
1267
1268        let u_bar = chart.u_bar().expect("u_bar");
1269        // u-bar = 35/17.5 = 2.0
1270        assert!((u_bar - 2.0).abs() < 1e-10);
1271
1272        // Larger inspection area → tighter limits
1273        let pts = chart.points();
1274        let width_0 = pts[0].ucl - pts[0].cl; // n=5
1275        let width_1 = pts[1].ucl - pts[1].cl; // n=10
1276        assert!(width_1 < width_0, "larger n should have tighter limits");
1277    }
1278
1279    #[test]
1280    fn test_u_chart_rejects_invalid() {
1281        let mut chart = UChart::new();
1282        for units in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1283            assert_eq!(
1284                chart.add_sample(5, units),
1285                Err(crate::spc::ControlChartError::NonPositiveUnits),
1286                "units = {units}"
1287            );
1288        }
1289        assert!(chart.u_bar().is_none());
1290    }
1291
1292    #[test]
1293    fn test_u_chart_out_of_control() {
1294        let mut chart = UChart::new();
1295        for _ in 0..20 {
1296            chart.add_sample(4, 10.0).unwrap();
1297        }
1298        chart.add_sample(50, 10.0).unwrap(); // Far outlier
1299
1300        assert!(!chart.is_in_control());
1301    }
1302
1303    #[test]
1304    fn test_u_chart_lcl_clamped() {
1305        let mut chart = UChart::new();
1306        // Small u-bar with small n → LCL would be negative
1307        chart.add_sample(1, 1.0).unwrap();
1308        let pt = &chart.points()[0];
1309        assert!(pt.lcl >= 0.0);
1310    }
1311
1312    #[test]
1313    fn test_u_chart_default() {
1314        let chart = UChart::default();
1315        assert!(chart.u_bar().is_none());
1316        assert!(chart.points().is_empty());
1317    }
1318
1319    #[test]
1320    fn test_u_chart_limits_formula() {
1321        // u-bar = 2.0, n = 4.0
1322        // sigma = sqrt(2.0/4.0) = sqrt(0.5) ≈ 0.7071
1323        // UCL = 2.0 + 3*0.7071 = 4.1213
1324        // LCL = max(0, 2.0 - 2.1213) = 0.0 (clamped)
1325        let mut chart = UChart::new();
1326        chart.add_sample(8, 4.0).unwrap();
1327
1328        let pt = &chart.points()[0];
1329        assert!((pt.cl - 2.0).abs() < 1e-10);
1330        let expected_sigma = (2.0_f64 / 4.0).sqrt();
1331        assert!((pt.ucl - (2.0 + 3.0 * expected_sigma)).abs() < 0.001);
1332    }
1333
1334    // --- Cross-chart consistency ---
1335
1336    #[test]
1337    fn test_p_and_np_consistent() {
1338        // P chart with constant n should give equivalent results to NP chart
1339        let mut p_chart = PChart::new();
1340        let mut np_chart = NPChart::new(100).expect("100 is a valid sample size");
1341
1342        let defectives = [5, 8, 3, 6, 4];
1343        for &d in &defectives {
1344            p_chart.add_sample(d, 100).unwrap();
1345            np_chart.add_sample(d).unwrap();
1346        }
1347
1348        let p_bar = p_chart.p_bar().expect("p_bar");
1349        let (_, np_cl, _) = np_chart.control_limits().expect("np limits");
1350
1351        // NP center line = n * p-bar
1352        assert!(
1353            (np_cl - 100.0 * p_bar).abs() < 1e-10,
1354            "NP CL should equal n * p_bar"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_c_and_u_consistent_equal_units() {
1360        // U chart with constant n=1 should give same limits as C chart
1361        let mut c_chart = CChart::new();
1362        let mut u_chart = UChart::new();
1363
1364        let defects = [3, 5, 4, 6, 2];
1365        for &d in &defects {
1366            c_chart.add_sample(d);
1367            u_chart.add_sample(d, 1.0).unwrap();
1368        }
1369
1370        let (c_ucl, c_cl, c_lcl) = c_chart.control_limits().expect("C limits");
1371        let u_bar = u_chart.u_bar().expect("u_bar");
1372
1373        assert!(
1374            (c_cl - u_bar).abs() < 1e-10,
1375            "C chart CL should equal U chart u-bar when n=1"
1376        );
1377
1378        let u_pt = &u_chart.points()[0];
1379        assert!((u_pt.ucl - c_ucl).abs() < 1e-10);
1380        assert!((u_pt.lcl - c_lcl).abs() < 1e-10);
1381    }
1382
1383    // --- Laney P' Chart ---
1384
1385    #[test]
1386    fn laney_p_basic() {
1387        let samples: Vec<(u64, u64)> = (0..10).map(|i| (i % 5 + 2, 200)).collect();
1388        let chart = laney_p_chart(&samples).expect("chart should be Some");
1389        assert!(chart.phi > 0.0);
1390        assert!(chart.p_bar > 0.0 && chart.p_bar < 1.0);
1391        assert_eq!(chart.points.len(), 10);
1392    }
1393
1394    #[test]
1395    fn laney_p_constant_proportion_phi_near_zero() {
1396        // All samples identical → all z-scores = 0 → MR = 0 → phi = 0
1397        let samples: Vec<(u64, u64)> = vec![(10, 1000); 20];
1398        let chart = laney_p_chart(&samples).expect("chart should be Some");
1399        assert!((chart.p_bar - 0.01).abs() < 1e-10);
1400        assert!(chart.phi >= 0.0);
1401    }
1402
1403    #[test]
1404    fn laney_p_ucl_above_lcl() {
1405        let samples: Vec<(u64, u64)> = vec![(5, 100), (8, 100), (3, 100), (6, 100), (4, 100)];
1406        let chart = laney_p_chart(&samples).expect("chart should be Some");
1407        for p in &chart.points {
1408            assert!(p.ucl >= p.lcl);
1409            assert!((p.cl - chart.p_bar).abs() < 1e-10);
1410        }
1411    }
1412
1413    #[test]
1414    fn laney_p_insufficient_data() {
1415        let samples: Vec<(u64, u64)> = vec![(2, 100), (3, 100)];
1416        assert!(laney_p_chart(&samples).is_none());
1417    }
1418
1419    #[test]
1420    fn laney_p_rejects_a_sample_with_no_proportion() {
1421        // One sample with nothing inspected divides by zero: its z-score is not
1422        // a number, so phi and every limit become NaN, no point compares as out
1423        // of control, and the chart reads as in control.
1424        assert!(laney_p_chart(&[(3, 100), (0, 0), (4, 100), (2, 100)]).is_none());
1425        // More defectives than inspected is not a proportion either.
1426        assert!(laney_p_chart(&[(3, 100), (12, 10), (4, 100), (2, 100)]).is_none());
1427    }
1428    #[test]
1429    fn laney_u_basic() {
1430        let samples: Vec<(u64, f64)> = vec![(5, 10.0); 10];
1431        let chart = laney_u_chart(&samples).expect("chart should be Some");
1432        assert!((chart.u_bar - 0.5).abs() < 1e-10);
1433        assert!(chart.phi >= 0.0);
1434    }
1435
1436    #[test]
1437    fn laney_u_ucl_above_cl() {
1438        let samples: Vec<(u64, f64)> = (0..8).map(|i| ((i % 4 + 2) as u64, 10.0)).collect();
1439        let chart = laney_u_chart(&samples).expect("chart should be Some");
1440        for p in &chart.points {
1441            assert!(p.ucl > p.cl || (p.ucl - p.cl).abs() < 1e-10);
1442        }
1443    }
1444
1445    // --- Montgomery (2020) Example 7.1 Reference Validation ---
1446
1447    /// P chart reference validation against Montgomery (2019) §7.2–7.3 formula.
1448    ///
1449    /// Uses 20 samples of n=100 with Σd=198, so p̄=198/2000=0.099 exactly.
1450    /// UCL = 0.099 + 3·√(0.099·0.901/100) ≈ 0.188196
1451    /// LCL = max(0, 0.099 − 0.089196) ≈ 0.009804
1452    ///
1453    /// Reference: Montgomery, D.C. (2019). *Introduction to Statistical Quality
1454    /// Control*, 8th ed., §7.2–7.3.
1455    #[test]
1456    fn p_chart_montgomery_reference_formula() {
1457        // Construct 20 samples of n=100 so total defectives = 198 (p̄ = 0.099).
1458        // Spread as [10, 10, 10, ..., 10, 8] so sum = 19*10 + 8 = 198.
1459        let mut chart = PChart::new();
1460        for _ in 0..19 {
1461            chart.add_sample(10, 100).unwrap();
1462        }
1463        chart.add_sample(8, 100).unwrap();
1464
1465        let p_bar = chart.p_bar().expect("p_bar");
1466        assert!(
1467            (p_bar - 0.099).abs() < 1e-10,
1468            "p̄ expected 0.099, got {p_bar}"
1469        );
1470
1471        let sigma = (0.099_f64 * 0.901 / 100.0).sqrt();
1472        let expected_ucl = 0.099 + 3.0 * sigma; // ≈ 0.188196
1473        let expected_lcl = (0.099 - 3.0 * sigma).max(0.0); // ≈ 0.009804
1474
1475        for pt in chart.points() {
1476            assert!(
1477                (pt.ucl - expected_ucl).abs() < 1e-6,
1478                "UCL mismatch at index {}: expected {expected_ucl:.6}, got {:.6}",
1479                pt.index,
1480                pt.ucl
1481            );
1482            assert!(
1483                (pt.lcl - expected_lcl).abs() < 1e-6,
1484                "LCL mismatch at index {}: expected {expected_lcl:.6}, got {:.6}",
1485                pt.index,
1486                pt.lcl
1487            );
1488        }
1489    }
1490
1491    /// NP chart reference validation against Montgomery (2019) §7.2–7.3 formula.
1492    ///
1493    /// n=100, p̄=0.099 → np̄=9.9
1494    /// UCL = 9.9 + 3·√(9.9·0.901) = 9.9 + 3·2.9867 ≈ 18.860
1495    /// LCL = max(0, 9.9 − 8.960) ≈ 0.940
1496    ///
1497    /// Reference: Montgomery, D.C. (2019). *Introduction to Statistical Quality
1498    /// Control*, 8th ed., §7.2–7.3.
1499    #[test]
1500    fn np_chart_montgomery_reference() {
1501        // 20 samples of n=100, total defectives=198 → p̄=0.099, np̄=9.9
1502        let mut chart = NPChart::new(100).expect("100 is a valid sample size");
1503        for _ in 0..19 {
1504            chart.add_sample(10).unwrap();
1505        }
1506        chart.add_sample(8).unwrap();
1507
1508        let (ucl, cl, lcl) = chart.control_limits().expect("limits");
1509        // np̄ = 9.9
1510        assert!((cl - 9.9).abs() < 1e-10, "NP CL expected 9.9, got {cl}");
1511        // sigma = sqrt(9.9 * 0.901) = sqrt(8.9199) ≈ 2.98662
1512        let expected_sigma = (9.9_f64 * 0.901).sqrt();
1513        let expected_ucl = 9.9 + 3.0 * expected_sigma; // ≈ 18.860
1514        let expected_lcl = (9.9 - 3.0 * expected_sigma).max(0.0); // ≈ 0.940
1515        assert!(
1516            (ucl - expected_ucl).abs() < 1e-6,
1517            "NP UCL expected {expected_ucl:.4}, got {ucl:.4}"
1518        );
1519        assert!(
1520            (lcl - expected_lcl).abs() < 1e-6,
1521            "NP LCL expected {expected_lcl:.4}, got {lcl:.4}"
1522        );
1523    }
1524
1525    /// C chart validation against Montgomery (2020) §7.4.
1526    ///
1527    /// Reference: c̄=10
1528    /// UCL = 10 + 3·√10 = 10 + 9.4868 = 19.4868
1529    /// LCL = max(0, 10 − 9.4868) = 0.5132
1530    #[test]
1531    fn c_chart_montgomery_reference() {
1532        // 20 samples all with defect count 10 → c̄ = 10 exactly
1533        let mut chart = CChart::new();
1534        for _ in 0..20 {
1535            chart.add_sample(10);
1536        }
1537
1538        let (ucl, cl, lcl) = chart.control_limits().expect("limits");
1539        assert!(
1540            (cl - 10.0).abs() < 1e-10,
1541            "C chart CL expected 10.0, got {cl}"
1542        );
1543        let expected_ucl = 10.0 + 3.0 * 10.0_f64.sqrt(); // ≈ 19.4868
1544        let expected_lcl = (10.0 - 3.0 * 10.0_f64.sqrt()).max(0.0); // ≈ 0.5132
1545        assert!(
1546            (ucl - expected_ucl).abs() < 1e-6,
1547            "C chart UCL expected {expected_ucl:.4}, got {ucl:.4}"
1548        );
1549        assert!(
1550            (lcl - expected_lcl).abs() < 1e-6,
1551            "C chart LCL expected {expected_lcl:.4}, got {lcl:.4}"
1552        );
1553    }
1554
1555    /// U chart validation against Montgomery (2020) §7.4.
1556    ///
1557    /// Reference: ū=2.0, n=10
1558    /// UCL = 2.0 + 3·√(2.0/10) = 2.0 + 3·0.4472 = 3.3416
1559    /// LCL = max(0, 2.0 − 1.3416) = 0.6584
1560    #[test]
1561    fn u_chart_montgomery_reference() {
1562        // 20 samples each inspecting 10 units, defects arranged so u=2.0
1563        let mut chart = UChart::new();
1564        for _ in 0..20 {
1565            chart.add_sample(20, 10.0).unwrap(); // u = 20/10 = 2.0
1566        }
1567
1568        let u_bar = chart.u_bar().expect("u_bar");
1569        assert!(
1570            (u_bar - 2.0).abs() < 1e-10,
1571            "U chart ū expected 2.0, got {u_bar}"
1572        );
1573
1574        let sigma = (2.0_f64 / 10.0).sqrt(); // sqrt(0.2) ≈ 0.44721
1575        let expected_ucl = 2.0 + 3.0 * sigma; // ≈ 3.3416
1576        let expected_lcl = (2.0 - 3.0 * sigma).max(0.0); // ≈ 0.6584
1577
1578        for pt in chart.points() {
1579            assert!(
1580                (pt.ucl - expected_ucl).abs() < 1e-6,
1581                "U chart UCL expected {expected_ucl:.4}, got {:.4}",
1582                pt.ucl
1583            );
1584            assert!(
1585                (pt.lcl - expected_lcl).abs() < 1e-6,
1586                "U chart LCL expected {expected_lcl:.4}, got {:.4}",
1587                pt.lcl
1588            );
1589        }
1590    }
1591
1592    // --- G Chart ---
1593
1594    #[test]
1595    fn g_chart_ucl_above_cl() {
1596        let gaps = vec![100.0, 120.0, 95.0, 110.0, 105.0];
1597        let chart = g_chart(&gaps).expect("chart should be Some");
1598        assert!(chart.points[0].ucl > chart.points[0].cl);
1599        assert!(chart.points[0].lcl >= 0.0);
1600    }
1601
1602    #[test]
1603    fn g_chart_insufficient() {
1604        assert!(g_chart(&[100.0, 120.0]).is_none());
1605    }
1606
1607    #[test]
1608    fn g_chart_all_same() {
1609        let chart = g_chart(&[50.0; 8]).expect("chart should be Some");
1610        assert!((chart.g_bar - 50.0).abs() < 1e-10);
1611        assert!(chart.points[0].ucl > chart.points[0].cl);
1612    }
1613
1614    // --- T Chart ---
1615
1616    #[test]
1617    fn t_chart_ucl_factor() {
1618        // UCL = t_bar * (-ln(0.00135)) ≈ t_bar * 6.6077
1619        let times = vec![100.0; 10];
1620        let chart = t_chart(&times).expect("chart should be Some");
1621        let ratio = chart.points[0].ucl / chart.t_bar;
1622        assert!((ratio - 6.6077).abs() < 0.01, "ratio={ratio}");
1623    }
1624
1625    #[test]
1626    fn t_chart_non_positive() {
1627        assert!(t_chart(&[10.0, -5.0, 20.0, 15.0]).is_none());
1628    }
1629
1630    #[test]
1631    fn t_chart_insufficient() {
1632        assert!(t_chart(&[10.0, 20.0]).is_none());
1633    }
1634
1635    // --- Laney P' invariant: UCL = p̄ + 3·φ·σᵢ for every point ---
1636    //
1637    // The Laney P' UCL formula is exactly: UCL_i = p̄ + 3·φ·√(p̄·(1−p̄)/nᵢ).
1638    // This invariant must hold for every subgroup regardless of φ.
1639    //
1640    // Reference: Laney (2002) §3, steps 5–6.
1641    #[test]
1642    fn laney_p_ucl_formula_invariant() {
1643        let samples: Vec<(u64, u64)> = vec![
1644            (3, 100),
1645            (7, 100),
1646            (2, 100),
1647            (8, 100),
1648            (4, 100),
1649            (5, 150),
1650            (9, 150),
1651            (3, 150),
1652            (6, 150),
1653            (4, 150),
1654        ];
1655        let chart = laney_p_chart(&samples).expect("chart should be Some");
1656
1657        for (i, (&(d, n), pt)) in samples.iter().zip(&chart.points).enumerate() {
1658            let p_i = d as f64 / n as f64;
1659            let sigma_i = (chart.p_bar * (1.0 - chart.p_bar) / n as f64).sqrt();
1660            let expected_ucl = chart.p_bar + 3.0 * chart.phi * sigma_i;
1661            let expected_lcl = (chart.p_bar - 3.0 * chart.phi * sigma_i).max(0.0);
1662
1663            assert!(
1664                (pt.value - p_i).abs() < 1e-10,
1665                "point {i}: value expected {p_i:.6}, got {:.6}",
1666                pt.value
1667            );
1668            assert!(
1669                (pt.ucl - expected_ucl).abs() < 1e-10,
1670                "point {i}: UCL expected {expected_ucl:.6}, got {:.6}",
1671                pt.ucl
1672            );
1673            assert!(
1674                (pt.lcl - expected_lcl).abs() < 1e-10,
1675                "point {i}: LCL expected {expected_lcl:.6}, got {:.6}",
1676                pt.lcl
1677            );
1678        }
1679    }
1680
1681    // --- Laney P' invariant: φ=1 means Laney limits = standard P chart limits ---
1682    //
1683    // When all z-scores are identical, MR=0 → φ=0, not φ=1.
1684    // To get φ=1 we need to engineer data where MR̄=d₂=1.128.
1685    // Strategy: use 4 samples with exactly two z-values (+δ, -δ, +δ, -δ)
1686    // so that |Δz|=2δ for all 3 consecutive pairs.
1687    // MR̄ = 2δ → φ = 2δ/1.128.  For φ=1: δ = 0.564.
1688    //
1689    // We need integer counts (d, n) such that z = (d/n - p̄)/σ = ±0.564 exactly.
1690    // Choose p̄ = 0.5, n = 10000: σ = √(0.25/10000) = 0.005.
1691    // p_high = 0.5 + 0.564·0.005 = 0.5028  → d_high = 5028
1692    // p_low  = 0.5 − 0.564·0.005 = 0.4972  → d_low  = 4972
1693    // Verify: z_high = (0.5028-0.5)/0.005 = 0.56 (but MR̄=2·0.56=1.12, φ=0.9929)
1694    // The integer representation does not produce φ=1 exactly; we therefore
1695    // verify only that φ is close to 1 and that the UCL/LCL formula is self-consistent.
1696    //
1697    // Reference: Laney (2002) §3.
1698    #[test]
1699    fn laney_p_phi_near_one_limits_close_to_standard() {
1700        // δ = 0.564 → p_high/low with n=10000 and p̄=0.5 produce z = ±0.56
1701        // (integer rounding: 5028/10000, 4972/10000)
1702        // MR̄ = 2·0.56 = 1.12, φ = 1.12/1.128 ≈ 0.993 (very close to 1.0)
1703        let n: u64 = 10_000;
1704        let d_high: u64 = 5028; // z ≈ +0.56
1705        let d_low: u64 = 4972; // z ≈ -0.56
1706                               // 6 samples alternating: φ = MR̄/1.128 with MR̄ from 5 identical MRs of 1.12
1707        let samples: Vec<(u64, u64)> = vec![
1708            (d_high, n),
1709            (d_low, n),
1710            (d_high, n),
1711            (d_low, n),
1712            (d_high, n),
1713            (d_low, n),
1714        ];
1715
1716        let laney = laney_p_chart(&samples).expect("chart should be Some");
1717
1718        // φ should be close to 1 (within 1%).
1719        assert!(
1720            (laney.phi - 1.0).abs() < 0.01,
1721            "φ expected ≈1.0, got {}",
1722            laney.phi
1723        );
1724
1725        // Standard P chart limits for same data at n=10000.
1726        let p_bar = laney.p_bar;
1727        let sigma_std = (p_bar * (1.0 - p_bar) / n as f64).sqrt();
1728        let std_ucl = p_bar + 3.0 * sigma_std;
1729        let std_lcl = (p_bar - 3.0 * sigma_std).max(0.0);
1730
1731        // Laney UCL must equal standard UCL scaled by φ; since φ≈1, the
1732        // difference is bounded by 3·σ·|φ−1|.
1733        let max_deviation = 3.0 * sigma_std * (laney.phi - 1.0).abs();
1734        assert!(
1735            (laney.points[0].ucl - std_ucl).abs() <= max_deviation + 1e-12,
1736            "Laney UCL={:.6} vs std UCL={std_ucl:.6}, deviation bound={max_deviation:.2e}",
1737            laney.points[0].ucl
1738        );
1739        assert!(
1740            (laney.points[0].lcl - std_lcl).abs() <= max_deviation + 1e-12,
1741            "Laney LCL={:.6} vs std LCL={std_lcl:.6}, deviation bound={max_deviation:.2e}",
1742            laney.points[0].lcl
1743        );
1744    }
1745
1746    // --- Laney P' invariant: φ > 1 → wider limits than standard P chart ---
1747    //
1748    // Overdispersed data (variance greater than binomial prediction) forces
1749    // φ > 1.  The Laney UCL must then exceed the standard P chart UCL.
1750    //
1751    // Reference: Laney (2002) §2–3.
1752    #[test]
1753    fn laney_p_phi_gt_one_wider_than_standard() {
1754        // Strongly overdispersed: proportions swing far from p̄.
1755        let samples: Vec<(u64, u64)> = vec![
1756            (1, 100),
1757            (20, 100),
1758            (2, 100),
1759            (18, 100),
1760            (1, 100),
1761            (22, 100),
1762            (3, 100),
1763            (19, 100),
1764            (2, 100),
1765            (20, 100),
1766        ];
1767
1768        let laney = laney_p_chart(&samples).expect("chart should be Some");
1769        assert!(
1770            laney.phi > 1.0,
1771            "φ expected > 1 for overdispersed data, got {}",
1772            laney.phi
1773        );
1774
1775        // Standard P chart UCL for the same data.
1776        let total_d: u64 = samples.iter().map(|&(d, _)| d).sum();
1777        let total_n: u64 = samples.iter().map(|&(_, n)| n).sum();
1778        let p_bar = total_d as f64 / total_n as f64;
1779        let std_ucl_first = p_bar + 3.0 * (p_bar * (1.0 - p_bar) / 100.0_f64).sqrt();
1780
1781        assert!(
1782            laney.points[0].ucl > std_ucl_first,
1783            "Laney UCL ({:.4}) must exceed standard P chart UCL ({std_ucl_first:.4}) when φ>1",
1784            laney.points[0].ucl
1785        );
1786    }
1787
1788    // --- Laney P' numerical reference test ---
1789    //
1790    // Hand-computed example:
1791    //   5 samples of n=50, defectives = [3, 7, 2, 8, 4]
1792    //   p̄ = 24/250 = 0.096
1793    //   p_i = [0.06, 0.14, 0.04, 0.16, 0.08]
1794    //   σ_i = √(0.096·0.904/50) = √(0.001737...) ≈ 0.041681
1795    //   z_i = (p_i − 0.096) / 0.041681
1796    //        = [−0.8636, +1.0557, −1.3467, +1.5393, −0.3832]
1797    //   MR = [|z1−z0|, |z2−z1|, |z3−z2|, |z4−z3|]
1798    //       = [1.9193, 2.4024, 2.8860, 1.9225]
1799    //   MR̄ = (1.9193+2.4024+2.8860+1.9225)/4 = 9.1302/4 = 2.28255
1800    //   φ = 2.28255 / 1.128 = 2.0235
1801    //   UCL_0 = 0.096 + 3·2.0235·0.041681 = 0.096 + 0.25296 = 0.34896
1802    //   LCL_0 = max(0, 0.096 − 0.25296) = 0  (negative → 0)
1803    //
1804    // Reference: Laney (2002) §3 algorithm steps 1–6.
1805    #[test]
1806    fn laney_p_numerical_reference() {
1807        let samples: Vec<(u64, u64)> = vec![(3, 50), (7, 50), (2, 50), (8, 50), (4, 50)];
1808        let chart = laney_p_chart(&samples).expect("chart should be Some");
1809
1810        let p_bar = 24.0_f64 / 250.0;
1811        assert!(
1812            (chart.p_bar - p_bar).abs() < 1e-10,
1813            "p̄ expected {p_bar:.6}, got {:.6}",
1814            chart.p_bar
1815        );
1816
1817        // Recompute φ from first principles to validate implementation.
1818        let sigma_i = (p_bar * (1.0 - p_bar) / 50.0_f64).sqrt();
1819        let p_vals = [0.06_f64, 0.14, 0.04, 0.16, 0.08];
1820        let z: Vec<f64> = p_vals.iter().map(|&p| (p - p_bar) / sigma_i).collect();
1821        let mr_bar = z.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>() / 4.0;
1822        let phi_expected = mr_bar / 1.128;
1823
1824        assert!(
1825            (chart.phi - phi_expected).abs() < 1e-10,
1826            "φ expected {phi_expected:.6}, got {:.6}",
1827            chart.phi
1828        );
1829
1830        // UCL for point 0 (n=50).
1831        let ucl_0 = p_bar + 3.0 * phi_expected * sigma_i;
1832        let lcl_0 = (p_bar - 3.0 * phi_expected * sigma_i).max(0.0);
1833        assert!(
1834            (chart.points[0].ucl - ucl_0).abs() < 1e-10,
1835            "UCL[0] expected {ucl_0:.6}, got {:.6}",
1836            chart.points[0].ucl
1837        );
1838        assert!(
1839            (chart.points[0].lcl - lcl_0).abs() < 1e-10,
1840            "LCL[0] expected {lcl_0:.6}, got {:.6}",
1841            chart.points[0].lcl
1842        );
1843    }
1844
1845    // --- Laney U' invariant: φ > 1 → wider limits than standard U chart ---
1846    //
1847    // Reference: Laney (2002) §4 (U' extension).
1848    #[test]
1849    fn laney_u_phi_gt_one_wider_than_standard() {
1850        // Overdispersed U data: defect rates swing from low to high.
1851        let samples: Vec<(u64, f64)> = vec![
1852            (1, 10.0),
1853            (15, 10.0),
1854            (2, 10.0),
1855            (14, 10.0),
1856            (1, 10.0),
1857            (16, 10.0),
1858            (2, 10.0),
1859            (13, 10.0),
1860        ];
1861
1862        let laney = laney_u_chart(&samples).expect("chart should be Some");
1863        assert!(
1864            laney.phi > 1.0,
1865            "φ expected > 1 for overdispersed data, got {}",
1866            laney.phi
1867        );
1868
1869        let total_d: u64 = samples.iter().map(|&(d, _)| d).sum();
1870        let total_n: f64 = samples.iter().map(|&(_, n)| n).sum();
1871        let u_bar = total_d as f64 / total_n;
1872        let std_ucl = u_bar + 3.0 * (u_bar / 10.0_f64).sqrt();
1873
1874        assert!(
1875            laney.points[0].ucl > std_ucl,
1876            "Laney U' UCL ({:.4}) must exceed standard U chart UCL ({std_ucl:.4}) when φ>1",
1877            laney.points[0].ucl
1878        );
1879    }
1880
1881    // --- G chart formula verification ---
1882    //
1883    // Kaminsky (1992) formula: UCL = ḡ + 3·√(ḡ·(ḡ+1)), LCL = max(0, ḡ − 3·√(ḡ·(ḡ+1)))
1884    // The spread √(ḡ·(ḡ+1)) comes from the geometric distribution variance
1885    // Var(G) = (1−p)/p² where p = 1/(ḡ+1), giving √((ḡ+1)·ḡ) = √(ḡ·(ḡ+1)).
1886    //
1887    // Numerical check with ḡ = 50.0:
1888    //   spread = √(50·51) = √2550 ≈ 50.4975
1889    //   UCL = 50 + 3·50.4975 ≈ 201.4925
1890    //   LCL = max(0, 50 − 151.4925) = 0
1891    //
1892    // Reference: Kaminsky et al. (1992) §3; Benneyan (2001) §2.
1893    #[test]
1894    fn g_chart_formula_verification() {
1895        // 8 identical observations so ḡ = 50.0 exactly.
1896        let gaps = vec![50.0_f64; 8];
1897        let chart = g_chart(&gaps).expect("chart should be Some");
1898
1899        let g_bar = 50.0_f64;
1900        assert!(
1901            (chart.g_bar - g_bar).abs() < 1e-10,
1902            "ḡ expected 50.0, got {}",
1903            chart.g_bar
1904        );
1905
1906        let spread = (g_bar * (g_bar + 1.0)).sqrt(); // √(50·51) = √2550
1907        let expected_ucl = g_bar + 3.0 * spread;
1908        let expected_lcl = (g_bar - 3.0 * spread).max(0.0);
1909
1910        assert!(
1911            (chart.points[0].ucl - expected_ucl).abs() < 1e-10,
1912            "UCL expected {expected_ucl:.6}, got {:.6}",
1913            chart.points[0].ucl
1914        );
1915        assert!(
1916            (chart.points[0].lcl - expected_lcl).abs() < 1e-10,
1917            "LCL expected {expected_lcl:.6}, got {:.6}",
1918            chart.points[0].lcl
1919        );
1920        // LCL is 0 because ḡ - 3·spread < 0 for ḡ = 50.
1921        assert!(
1922            chart.points[0].lcl >= 0.0,
1923            "LCL must be clamped to 0, got {}",
1924            chart.points[0].lcl
1925        );
1926    }
1927
1928    // --- G chart spread > ḡ for any positive ḡ ---
1929    //
1930    // Invariant: √(ḡ·(ḡ+1)) > ḡ  ⟺  ḡ+1 > ḡ  ⟺  true.
1931    // Therefore LCL is always 0 for any ḡ > 0 (the lower 3σ band is negative).
1932    #[test]
1933    fn g_chart_lcl_always_zero() {
1934        for &g in &[1.0_f64, 5.0, 20.0, 100.0, 500.0] {
1935            let gaps = vec![g; 5];
1936            let chart = g_chart(&gaps).expect("chart should be Some");
1937            assert!(
1938                (chart.points[0].lcl - 0.0).abs() < 1e-10,
1939                "LCL must be 0 for ḡ={g}, got {}",
1940                chart.points[0].lcl
1941            );
1942        }
1943    }
1944
1945    // --- T chart LCL factor verification ---
1946    //
1947    // The T chart LCL factor is −ln(0.99865) ≈ 0.001351 (not exactly 0.00135).
1948    // This test pins the exact numeric value.
1949    //
1950    // Exponential quantile: Q(p; θ) = θ·(−ln(1−p)) where θ = t̄.
1951    //   LCL factor = −ln(1 − 0.00135) = −ln(0.99865) ≈ 0.0013509
1952    //   UCL factor = −ln(1 − 0.99865) = −ln(0.00135) ≈ 6.6077
1953    //
1954    // Reference: Borror, Keats & Montgomery (2003) §2.
1955    #[test]
1956    fn t_chart_lcl_factor_verification() {
1957        let times = vec![100.0_f64; 10];
1958        let chart = t_chart(&times).expect("chart should be Some");
1959
1960        let ucl_factor = chart.points[0].ucl / chart.t_bar;
1961        let lcl_factor = chart.points[0].lcl / chart.t_bar;
1962
1963        let expected_ucl_factor = -(0.00135_f64.ln()); // ≈ 6.6077
1964        let expected_lcl_factor = -(0.99865_f64.ln()); // ≈ 0.001351
1965
1966        assert!(
1967            (ucl_factor - expected_ucl_factor).abs() < 1e-10,
1968            "UCL factor expected {expected_ucl_factor:.6}, got {ucl_factor:.6}"
1969        );
1970        assert!(
1971            (lcl_factor - expected_lcl_factor).abs() < 1e-10,
1972            "LCL factor expected {expected_lcl_factor:.6}, got {lcl_factor:.6}"
1973        );
1974    }
1975
1976    // --- T chart invariant: UCL / LCL ratio is constant ---
1977    //
1978    // UCL = t̄ · k_U,  LCL = t̄ · k_L  →  UCL/LCL = k_U/k_L = constant.
1979    // k_U/k_L = −ln(0.00135) / −ln(0.99865) ≈ 4893.
1980    // This is a scale-invariant property of the exponential distribution.
1981    #[test]
1982    fn t_chart_ucl_lcl_ratio_scale_invariant() {
1983        let k_u = -(0.00135_f64.ln());
1984        let k_l = -(0.99865_f64.ln());
1985        let expected_ratio = k_u / k_l;
1986
1987        for &t_bar in &[10.0_f64, 100.0, 1000.0] {
1988            let times = vec![t_bar; 10];
1989            let chart = t_chart(&times).expect("chart should be Some");
1990            let ratio = chart.points[0].ucl / chart.points[0].lcl;
1991            assert!(
1992                (ratio - expected_ratio).abs() < 0.01,
1993                "UCL/LCL ratio expected {expected_ratio:.2}, got {ratio:.2} at t̄={t_bar}"
1994            );
1995        }
1996    }
1997}