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