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