Skip to main content

u_analytics/capability/
indices.rs

1//! Process capability indices (Cp, Cpk, Pp, Ppk, Cpm).
2//!
3//! Process capability indices quantify how well a process output fits within
4//! specification limits. Short-term indices (Cp, Cpk) use within-group
5//! variation, while long-term indices (Pp, Ppk) use overall variation.
6//!
7//! # References
8//!
9//! - Montgomery, D.C. (2020). *Introduction to Statistical Quality Control*, 8th ed.,
10//!   §7.1–7.3, §8.
11//! - Kane (1986), "Process Capability Indices", *Journal of Quality Technology*
12//!   18(1), pp. 41--52.
13//! - Chan, Cheng & Spiring (1988), "A New Measure of Process Capability: Cpm",
14//!   *Journal of Quality Technology* 20(3), pp. 162--175.
15
16use u_numflow::stats;
17
18/// Input specification for process capability analysis.
19///
20/// Defines the upper and/or lower specification limits and an optional target
21/// value. At least one specification limit must be provided.
22///
23/// # Examples
24///
25/// ```
26/// use u_analytics::capability::ProcessCapability;
27///
28/// // Two-sided specification: LSL = 9.0, USL = 11.0
29/// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
30///
31/// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
32/// let indices = spec.compute(&data, 0.15).unwrap();
33/// assert!(indices.cp.is_some());
34/// assert!(indices.cpk.is_some());
35/// ```
36pub struct ProcessCapability {
37    usl: Option<f64>,
38    lsl: Option<f64>,
39    target: Option<f64>,
40}
41
42/// Computed capability indices.
43///
44/// Fields are `Option<f64>` because not every index can be computed for
45/// every input. Cp requires both USL and LSL; the short-term quartet
46/// (`cp`, `cpk`, `cpu`, `cpl`) requires a within-subgroup sigma, so it is
47/// `None` from [`ProcessCapability::compute_overall`], which has none. A
48/// long-term number is never reported under a short-term name.
49///
50/// # Index interpretation
51///
52/// | Index | Value | Interpretation |
53/// |-------|-------|----------------|
54/// | Cp/Pp | >= 1.33 | Process is capable |
55/// | Cpk/Ppk | >= 1.33 | Process is capable and centered |
56/// | Cpm | >= 1.33 | Process meets Taguchi loss criterion |
57///
58/// Reference: Montgomery (2020), Chapter 8, Table 8.5.
59#[derive(Debug, Clone)]
60pub struct CapabilityIndices {
61    /// Cp = (USL - LSL) / (6 * sigma_within). Requires both limits.
62    pub cp: Option<f64>,
63    /// Cpk = min(Cpu, Cpl). Requires at least one limit.
64    pub cpk: Option<f64>,
65    /// Cpu = (USL - mean) / (3 * sigma_within). Requires USL.
66    pub cpu: Option<f64>,
67    /// Cpl = (mean - LSL) / (3 * sigma_within). Requires LSL.
68    pub cpl: Option<f64>,
69    /// Pp = (USL - LSL) / (6 * sigma_overall). Requires both limits.
70    pub pp: Option<f64>,
71    /// Ppk = min(Ppu, Ppl). Requires at least one limit.
72    pub ppk: Option<f64>,
73    /// Ppu = (USL - mean) / (3 * sigma_overall). Requires USL.
74    pub ppu: Option<f64>,
75    /// Ppl = (mean - LSL) / (3 * sigma_overall). Requires LSL.
76    pub ppl: Option<f64>,
77    /// Cpm = min(T - LSL, USL - T) / (3 * sigma_t), where
78    /// sigma_t = sqrt(sum((x_i - T)^2) / (n - 1)) is the spread of every
79    /// observation about the target T. With T at the specification midpoint
80    /// the numerator is (USL - LSL) / 6, the form Chan, Cheng & Spiring (1988)
81    /// give.
82    ///
83    /// `None` unless both limits and a target are set, the target lies within
84    /// the limits, and sigma_t is positive. Cpm measures how closely the
85    /// process clusters about the target, so it depends on neither sigma
86    /// estimate above and is the same from [`ProcessCapability::compute`] and
87    /// [`ProcessCapability::compute_overall`].
88    ///
89    /// References: Chan, Cheng & Spiring (1988); Minitab, *Capability Analysis
90    /// (Normal) Formulas*, for the numerator when T is not the midpoint.
91    pub cpm: Option<f64>,
92    /// Sample mean of the data.
93    pub mean: f64,
94    /// Short-term (within-group) standard deviation -- the value the caller
95    /// supplied to [`ProcessCapability::compute`]. `None` from
96    /// [`ProcessCapability::compute_overall`], along with the four indices
97    /// that would be computed from it.
98    pub std_dev_within: Option<f64>,
99    /// Long-term (overall) standard deviation.
100    pub std_dev_overall: f64,
101}
102
103impl ProcessCapability {
104    /// Creates a new process capability specification.
105    ///
106    /// At least one of `usl` or `lsl` must be `Some`. If both are provided,
107    /// `usl` must be greater than `lsl`.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error string if:
112    /// - Both `usl` and `lsl` are `None`
113    /// - `usl <= lsl` when both are provided
114    /// - Either limit is non-finite (NaN or infinity)
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use u_analytics::capability::ProcessCapability;
120    ///
121    /// // Two-sided
122    /// let spec = ProcessCapability::new(Some(10.0), Some(5.0)).unwrap();
123    ///
124    /// // Upper limit only
125    /// let spec = ProcessCapability::new(Some(10.0), None).unwrap();
126    ///
127    /// // Lower limit only
128    /// let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
129    ///
130    /// // Error: no limits
131    /// assert!(ProcessCapability::new(None, None).is_err());
132    ///
133    /// // Error: USL <= LSL
134    /// assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
135    /// ```
136    pub fn new(usl: Option<f64>, lsl: Option<f64>) -> Result<Self, &'static str> {
137        if usl.is_none() && lsl.is_none() {
138            return Err("at least one specification limit (USL or LSL) is required");
139        }
140        if let Some(u) = usl {
141            if !u.is_finite() {
142                return Err("USL must be finite");
143            }
144        }
145        if let Some(l) = lsl {
146            if !l.is_finite() {
147                return Err("LSL must be finite");
148            }
149        }
150        if let (Some(u), Some(l)) = (usl, lsl) {
151            if u <= l {
152                return Err("USL must be greater than LSL");
153            }
154        }
155        Ok(Self {
156            usl,
157            lsl,
158            target: None,
159        })
160    }
161
162    /// Sets the target value for Cpm.
163    ///
164    /// Cpm is computed only against a declared target. Without one it is
165    /// `None` rather than measured against the specification midpoint: a
166    /// substituted target cannot be told apart from a declared one in the
167    /// result. A process whose target is the midpoint passes it here.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use u_analytics::capability::ProcessCapability;
173    ///
174    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0))
175    ///     .unwrap()
176    ///     .with_target(10.0);
177    /// ```
178    pub fn with_target(mut self, target: f64) -> Self {
179        self.target = Some(target);
180        self
181    }
182
183    /// Computes all capability indices using the provided within-group sigma.
184    ///
185    /// The `sigma_within` parameter represents the short-term (within-group)
186    /// standard deviation, typically estimated from a control chart as R-bar/d2
187    /// or S-bar/c4.
188    ///
189    /// The overall (long-term) standard deviation is computed from the data
190    /// using the sample standard deviation.
191    ///
192    /// # Returns
193    ///
194    /// `None` if:
195    /// - `data` has fewer than 2 elements
196    /// - `sigma_within` is not positive or not finite
197    /// - `data` contains NaN or infinity values
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use u_analytics::capability::ProcessCapability;
203    ///
204    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
205    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
206    /// let sigma_within = 0.15; // from control chart
207    ///
208    /// let indices = spec.compute(&data, sigma_within).unwrap();
209    /// assert!(indices.cp.unwrap() > 0.0);
210    /// assert!(indices.cpk.unwrap() > 0.0);
211    /// assert!(indices.pp.unwrap() > 0.0);
212    /// assert!(indices.ppk.unwrap() > 0.0);
213    /// ```
214    pub fn compute(&self, data: &[f64], sigma_within: f64) -> Option<CapabilityIndices> {
215        if !sigma_within.is_finite() || sigma_within <= 0.0 {
216            return None;
217        }
218        let x_bar = stats::mean(data)?;
219        let sigma_overall = stats::std_dev(data)?;
220
221        Some(self.compute_indices(data, x_bar, Some(sigma_within), sigma_overall))
222    }
223
224    /// Computes the long-term (performance) indices only.
225    ///
226    /// Use this when no within-subgroup sigma is available -- a flat vector
227    /// with no rational subgrouping. Pp/Ppk/Ppu/Ppl come from the overall
228    /// sample standard deviation, and Cpm from the spread about the target,
229    /// as in [`ProcessCapability::compute`]. The short-term quartet
230    /// (`cp`, `cpk`, `cpu`, `cpl`) and `std_dev_within` are `None`: filling
231    /// them from the overall sigma would make `cp` equal `pp` for every
232    /// input, a long-term number under a short-term name. To report Cp/Cpk,
233    /// estimate the within sigma from a control chart (R-bar/d2, S-bar/c4 or
234    /// MR-bar/d2) and call [`ProcessCapability::compute`].
235    ///
236    /// # Returns
237    ///
238    /// `None` if:
239    /// - `data` has fewer than 2 elements
240    /// - `data` contains NaN or infinity values
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use u_analytics::capability::ProcessCapability;
246    ///
247    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
248    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
249    ///
250    /// let indices = spec.compute_overall(&data).unwrap();
251    /// assert!(indices.pp.unwrap() > 0.0);
252    /// // No within sigma, so no short-term index is claimed.
253    /// assert!(indices.cp.is_none());
254    /// assert!(indices.std_dev_within.is_none());
255    /// ```
256    pub fn compute_overall(&self, data: &[f64]) -> Option<CapabilityIndices> {
257        let x_bar = stats::mean(data)?;
258        let sigma_overall = stats::std_dev(data)?;
259
260        Some(self.compute_indices(data, x_bar, None, sigma_overall))
261    }
262
263    /// Internal computation of all indices given the data, its mean, the
264    /// overall sigma and -- when the caller has one -- the within sigma.
265    fn compute_indices(
266        &self,
267        data: &[f64],
268        x_bar: f64,
269        sigma_within: Option<f64>,
270        sigma_overall: f64,
271    ) -> CapabilityIndices {
272        // Short-term indices (within-group sigma) -- only with a within sigma.
273        let cpu = sigma_within.and_then(|s| self.usl.map(|u| (u - x_bar) / (3.0 * s)));
274        let cpl = sigma_within.and_then(|s| self.lsl.map(|l| (x_bar - l) / (3.0 * s)));
275        let cp = match (sigma_within, self.usl, self.lsl) {
276            (Some(s), Some(u), Some(l)) => Some((u - l) / (6.0 * s)),
277            _ => None,
278        };
279        let cpk = match (cpu, cpl) {
280            (Some(u), Some(l)) => Some(u.min(l)),
281            (Some(u), None) => Some(u),
282            (None, Some(l)) => Some(l),
283            (None, None) => None,
284        };
285
286        // Long-term indices (overall sigma)
287        let ppu = self.usl.map(|u| (u - x_bar) / (3.0 * sigma_overall));
288        let ppl = self.lsl.map(|l| (x_bar - l) / (3.0 * sigma_overall));
289        let pp = match (self.usl, self.lsl) {
290            (Some(u), Some(l)) => Some((u - l) / (6.0 * sigma_overall)),
291            _ => None,
292        };
293        let ppk = match (ppu, ppl) {
294            (Some(u), Some(l)) => Some(u.min(l)),
295            (Some(u), None) => Some(u),
296            (None, Some(l)) => Some(l),
297            (None, None) => None,
298        };
299
300        let cpm = self.cpm(data);
301
302        CapabilityIndices {
303            cp,
304            cpk,
305            cpu,
306            cpl,
307            pp,
308            ppk,
309            ppu,
310            ppl,
311            cpm,
312            mean: x_bar,
313            std_dev_within: sigma_within,
314            std_dev_overall: sigma_overall,
315        }
316    }
317
318    /// Cpm against the declared target -- see [`CapabilityIndices::cpm`].
319    ///
320    /// The spread is taken about T directly rather than assembled from a sigma
321    /// and the offset of the mean: `sum((x_i - T)^2) / (n - 1)` is the
322    /// estimator Chan, Cheng & Spiring (1988) give, and it differs from
323    /// `s^2 + (mean - T)^2` by a factor of n / (n - 1) on the offset term.
324    fn cpm(&self, data: &[f64]) -> Option<f64> {
325        let (usl, lsl, target) = (self.usl?, self.lsl?, self.target?);
326        if !target.is_finite() || target < lsl || target > usl || data.len() < 2 {
327            return None;
328        }
329        let sum_sq: f64 = data.iter().map(|&x| (x - target).powi(2)).sum();
330        let sigma_t = (sum_sq / (data.len() - 1) as f64).sqrt();
331        // Every observation on the target leaves no spread to divide by.
332        if sigma_t <= 0.0 {
333            return None;
334        }
335        Some((target - lsl).min(usl - target) / (3.0 * sigma_t))
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    // -----------------------------------------------------------------------
344    // Construction tests
345    // -----------------------------------------------------------------------
346
347    #[test]
348    fn new_requires_at_least_one_limit() {
349        assert!(ProcessCapability::new(None, None).is_err());
350    }
351
352    #[test]
353    fn new_rejects_usl_leq_lsl() {
354        assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
355        assert!(ProcessCapability::new(Some(5.0), Some(5.0)).is_err());
356    }
357
358    #[test]
359    fn new_rejects_non_finite() {
360        assert!(ProcessCapability::new(Some(f64::NAN), Some(1.0)).is_err());
361        assert!(ProcessCapability::new(Some(10.0), Some(f64::INFINITY)).is_err());
362    }
363
364    #[test]
365    fn new_accepts_valid_two_sided() {
366        assert!(ProcessCapability::new(Some(10.0), Some(5.0)).is_ok());
367    }
368
369    #[test]
370    fn new_accepts_usl_only() {
371        assert!(ProcessCapability::new(Some(10.0), None).is_ok());
372    }
373
374    #[test]
375    fn new_accepts_lsl_only() {
376        assert!(ProcessCapability::new(None, Some(5.0)).is_ok());
377    }
378
379    // -----------------------------------------------------------------------
380    // Computation -- textbook example
381    // -----------------------------------------------------------------------
382
383    /// Textbook example: Montgomery (2019), Example 8.1
384    ///
385    /// LSL = 200, USL = 220, no target declared
386    /// Process mean ~ 210, sigma_within = 2.0
387    ///
388    /// Cp = (220 - 200) / (6 * 2) = 20/12 = 1.6667
389    #[test]
390    fn textbook_centered_process() {
391        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
392
393        let data = [
394            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0, 209.0, 211.0,
395            210.0, 209.5, 210.5, 210.0, 210.0, 210.0, 209.0, 211.0,
396        ];
397
398        let sigma_within = 2.0;
399        let indices = spec.compute(&data, sigma_within).unwrap();
400
401        // Cp = (220 - 200) / (6 * 2) = 1.6667
402        let cp = indices.cp.unwrap();
403        assert!(
404            (cp - 1.6667).abs() < 0.001,
405            "expected Cp ~ 1.6667, got {cp}"
406        );
407
408        let cpk = indices.cpk.unwrap();
409        assert!(cpk > 0.0, "Cpk should be positive");
410
411        // No target was declared, so there is no Cpm -- the midpoint is not
412        // assumed on the caller's behalf.
413        assert!(indices.cpm.is_none());
414    }
415
416    /// Off-center process: mean shifted toward USL.
417    ///
418    /// LSL = 200, USL = 220, mean ~ 215, sigma_within = 2.0
419    /// Cp = (220 - 200) / (6 * 2) = 1.6667
420    /// Cpu = (220 - 215) / (3 * 2) = 0.8333
421    /// Cpl = (215 - 200) / (3 * 2) = 2.5
422    /// Cpk = min(0.8333, 2.5) = 0.8333
423    #[test]
424    fn off_center_process() {
425        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
426
427        let data = [
428            213.0, 214.0, 215.0, 216.0, 217.0, 213.5, 214.5, 215.5, 216.5, 215.0, 214.0, 216.0,
429            215.0, 214.5, 215.5, 215.0, 215.0, 215.0, 214.0, 216.0,
430        ];
431
432        let sigma_within = 2.0;
433        let indices = spec.compute(&data, sigma_within).unwrap();
434
435        let cp = indices.cp.unwrap();
436        assert!(
437            (cp - 1.6667).abs() < 0.001,
438            "expected Cp ~ 1.6667, got {cp}"
439        );
440
441        let cpu = indices.cpu.unwrap();
442        assert!(
443            (cpu - 0.8333).abs() < 0.05,
444            "expected Cpu ~ 0.8333, got {cpu}"
445        );
446
447        let cpl = indices.cpl.unwrap();
448        assert!((cpl - 2.5).abs() < 0.05, "expected Cpl ~ 2.5, got {cpl}");
449
450        let cpk = indices.cpk.unwrap();
451        assert!((cpk - cpu).abs() < 1e-15, "Cpk should equal min(Cpu, Cpl)");
452    }
453
454    // -----------------------------------------------------------------------
455    // One-sided specifications
456    // -----------------------------------------------------------------------
457
458    #[test]
459    fn usl_only_computes_cpu_not_cpl() {
460        let spec = ProcessCapability::new(Some(10.0), None).unwrap();
461        let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
462        let indices = spec.compute(&data, 0.5).unwrap();
463
464        assert!(indices.cpu.is_some());
465        assert!(indices.cpl.is_none());
466        assert!(indices.cp.is_none(), "Cp requires both limits");
467        assert!(indices.cpk.is_some(), "Cpk should be Cpu for USL-only");
468        assert!(
469            (indices.cpk.unwrap() - indices.cpu.unwrap()).abs() < 1e-15,
470            "Cpk == Cpu when only USL is set"
471        );
472    }
473
474    #[test]
475    fn lsl_only_computes_cpl_not_cpu() {
476        let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
477        let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
478        let indices = spec.compute(&data, 0.5).unwrap();
479
480        assert!(indices.cpl.is_some());
481        assert!(indices.cpu.is_none());
482        assert!(indices.cp.is_none());
483        assert!(indices.cpk.is_some(), "Cpk should be Cpl for LSL-only");
484        assert!(
485            (indices.cpk.unwrap() - indices.cpl.unwrap()).abs() < 1e-15,
486            "Cpk == Cpl when only LSL is set"
487        );
488    }
489
490    // -----------------------------------------------------------------------
491    // Overall-only computation
492    // -----------------------------------------------------------------------
493
494    #[test]
495    fn compute_overall_reports_no_short_term_index() {
496        // The previous version of this test asserted cp == pp here, pinning
497        // the overall sigma being reported under the short-term names. A
498        // flat vector has no within sigma, so nothing short-term is claimed.
499        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
500        let data = [
501            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0,
502        ];
503        let indices = spec.compute_overall(&data).unwrap();
504
505        assert!(indices.cp.is_none());
506        assert!(indices.cpk.is_none());
507        assert!(indices.cpu.is_none());
508        assert!(indices.cpl.is_none());
509        assert!(indices.std_dev_within.is_none());
510        assert!(indices.pp.unwrap() > 0.0);
511        assert!(indices.ppk.unwrap() > 0.0);
512        assert!(indices.ppu.unwrap() > 0.0);
513        assert!(indices.ppl.unwrap() > 0.0);
514
515        // The long-term indices are the same ones `compute` reports.
516        let with_within = spec.compute(&data, 1.0).unwrap();
517        assert_eq!(indices.pp, with_within.pp);
518        assert_eq!(indices.ppk, with_within.ppk);
519        assert_eq!(indices.std_dev_overall, with_within.std_dev_overall);
520        assert_eq!(with_within.std_dev_within, Some(1.0));
521    }
522
523    // -----------------------------------------------------------------------
524    // Cpm with explicit target
525    // -----------------------------------------------------------------------
526
527    /// The case reported against 0.8.0, which derived Cpm from Cp: with no
528    /// target it reported 2.8793 against the midpoint, and 0.7361 against a
529    /// target of 11 -- both from the within sigma of 0.2.
530    ///
531    /// Hand computation, sigma_t = sqrt(sum((x - T)^2) / 5):
532    /// - T = 10: sum = 0.35, sigma_t = 0.264575, Cpm = (4/6) / sigma_t = 2.519763
533    /// - T = 11: sum = 4.95, sigma_t = 0.994987, Cpm = (1/3) / sigma_t = 0.335013
534    #[test]
535    fn cpm_is_the_spread_about_a_declared_target() {
536        let data = [9.8, 10.1, 10.3, 9.9, 10.2, 10.4];
537        let spec = || ProcessCapability::new(Some(12.0), Some(8.0)).unwrap();
538
539        assert!(spec().compute(&data, 0.2).unwrap().cpm.is_none());
540        assert!(spec().compute_overall(&data).unwrap().cpm.is_none());
541
542        for (target, expected) in [(10.0, 2.519_763), (11.0, 0.335_013)] {
543            let spec = spec().with_target(target);
544            let within = spec.compute(&data, 0.2).unwrap().cpm.unwrap();
545            let overall = spec.compute_overall(&data).unwrap().cpm.unwrap();
546            assert!((within - expected).abs() < 1e-6, "T={target}: {within}");
547            // No sigma enters Cpm, so the two entry points agree exactly.
548            assert_eq!(within, overall);
549        }
550    }
551
552    /// T off the midpoint: the numerator is the distance to the nearer limit.
553    ///
554    /// LSL = 200, USL = 220, T = 212: min(12, 8) / 3 = 8/3.
555    /// sum((x - 212)^2) = 16 + 9 + 4 + 1 + 0 = 30, sigma_t = sqrt(7.5).
556    /// Cpm = (8/3) / 2.738613 = 0.973729.
557    #[test]
558    fn cpm_off_midpoint_uses_the_nearer_limit() {
559        let spec = ProcessCapability::new(Some(220.0), Some(200.0))
560            .unwrap()
561            .with_target(212.0);
562        let data = [208.0, 209.0, 210.0, 211.0, 212.0];
563        let cpm = spec.compute_overall(&data).unwrap().cpm.unwrap();
564        assert!((cpm - 0.973_729).abs() < 1e-6, "got {cpm}");
565    }
566
567    /// With the mean on the target, sum((x - T)^2) / (n - 1) is the sample
568    /// variance, so Cpm is exactly Pp.
569    #[test]
570    fn cpm_equals_pp_when_the_mean_is_on_target() {
571        let spec = ProcessCapability::new(Some(220.0), Some(200.0))
572            .unwrap()
573            .with_target(210.0);
574        let data = [208.0, 209.0, 210.0, 211.0, 212.0];
575        let indices = spec.compute(&data, 2.0).unwrap();
576        let (cpm, pp) = (indices.cpm.unwrap(), indices.pp.unwrap());
577        assert!((cpm - pp).abs() < 1e-12, "Cpm {cpm} vs Pp {pp}");
578    }
579
580    #[test]
581    fn cpm_is_none_where_it_is_undefined() {
582        let data = [208.0, 209.0, 210.0, 211.0, 212.0];
583        let two_sided = || ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
584
585        // A target outside the specification.
586        for t in [199.0, 221.0, f64::NAN] {
587            let cpm = two_sided()
588                .with_target(t)
589                .compute_overall(&data)
590                .unwrap()
591                .cpm;
592            assert!(cpm.is_none(), "T={t}");
593        }
594        // Every observation on the target: no spread to divide by.
595        let on_target = two_sided().with_target(210.0).compute(&[210.0; 5], 2.0);
596        assert!(on_target.unwrap().cpm.is_none());
597        // One-sided specification, even with a target.
598        let upper = ProcessCapability::new(Some(220.0), None)
599            .unwrap()
600            .with_target(210.0);
601        assert!(upper.compute_overall(&data).unwrap().cpm.is_none());
602    }
603
604    // -----------------------------------------------------------------------
605    // Edge cases
606    // -----------------------------------------------------------------------
607
608    #[test]
609    fn compute_returns_none_for_insufficient_data() {
610        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
611        assert!(spec.compute(&[5.0], 1.0).is_none());
612        assert!(spec.compute(&[], 1.0).is_none());
613    }
614
615    #[test]
616    fn compute_returns_none_for_invalid_sigma() {
617        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
618        let data = [4.0, 5.0, 6.0, 5.0, 5.5];
619        assert!(spec.compute(&data, 0.0).is_none());
620        assert!(spec.compute(&data, -1.0).is_none());
621        assert!(spec.compute(&data, f64::NAN).is_none());
622        assert!(spec.compute(&data, f64::INFINITY).is_none());
623    }
624
625    #[test]
626    fn compute_returns_none_for_nan_data() {
627        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
628        let data = [4.0, f64::NAN, 6.0];
629        assert!(spec.compute(&data, 1.0).is_none());
630    }
631
632    #[test]
633    fn compute_overall_returns_none_for_insufficient_data() {
634        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
635        assert!(spec.compute_overall(&[5.0]).is_none());
636        assert!(spec.compute_overall(&[]).is_none());
637    }
638
639    // -----------------------------------------------------------------------
640    // Montgomery (2020) §7.1 reference values
641    // -----------------------------------------------------------------------
642
643    /// Exact reference from Montgomery (2020), §7.1.
644    ///
645    /// μ = 74.001, σ_within = 0.0099, USL = 74.05, LSL = 73.95
646    ///
647    /// Cp  = (74.05 - 73.95) / (6 × 0.0099) = 0.1 / 0.0594 = 1.6835
648    /// Cpu = (74.05 - 74.001) / (3 × 0.0099) = 0.049 / 0.0297 = 1.6499
649    /// Cpl = (74.001 - 73.95) / (3 × 0.0099) = 0.051 / 0.0297 = 1.7172
650    /// Cpk = min(1.6499, 1.7172) = 1.6499
651    #[test]
652    fn cp_cpk_montgomery_2020_reference() {
653        let usl = 74.05_f64;
654        let lsl = 73.95_f64;
655        let mu = 74.001_f64;
656        let sigma_within = 0.0099_f64;
657
658        let spec = ProcessCapability::new(Some(usl), Some(lsl)).unwrap();
659
660        // Single-point data: mean == mu, sigma_overall irrelevant for Cp/Cpk.
661        // Use many identical points so sample mean == mu exactly.
662        let data = vec![mu; 100];
663        let indices = spec.compute(&data, sigma_within).unwrap();
664
665        let cp = indices.cp.unwrap();
666        let expected_cp = (usl - lsl) / (6.0 * sigma_within); // 1.6835...
667        assert!(
668            (cp - expected_cp).abs() < 0.001,
669            "Cp: expected {expected_cp:.4}, got {cp:.4}"
670        );
671
672        let cpu = indices.cpu.unwrap();
673        let expected_cpu = (usl - mu) / (3.0 * sigma_within); // 1.6499...
674        assert!(
675            (cpu - expected_cpu).abs() < 0.001,
676            "Cpu: expected {expected_cpu:.4}, got {cpu:.4}"
677        );
678
679        let cpl = indices.cpl.unwrap();
680        let expected_cpl = (mu - lsl) / (3.0 * sigma_within); // 1.7172...
681        assert!(
682            (cpl - expected_cpl).abs() < 0.001,
683            "Cpl: expected {expected_cpl:.4}, got {cpl:.4}"
684        );
685
686        let cpk = indices.cpk.unwrap();
687        let expected_cpk = expected_cpu.min(expected_cpl); // 1.6499...
688        assert!(
689            (cpk - expected_cpk).abs() < 0.001,
690            "Cpk: expected {expected_cpk:.4}, got {cpk:.4}"
691        );
692
693        // Verify direction: process is slightly above centre, so Cpu < Cpl
694        assert!(
695            cpu < cpl,
696            "mean above centre → Cpu ({cpu:.4}) should be < Cpl ({cpl:.4})"
697        );
698    }
699
700    // -----------------------------------------------------------------------
701    // Pp/Ppk differ from Cp/Cpk when sigmas differ
702    // -----------------------------------------------------------------------
703
704    #[test]
705    fn pp_differs_from_cp_when_sigmas_differ() {
706        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
707
708        let data = [
709            205.0, 207.0, 210.0, 213.0, 215.0, 206.0, 208.0, 212.0, 214.0, 210.0, 204.0, 216.0,
710            209.0, 211.0, 210.0,
711        ];
712
713        let sigma_within = 1.5;
714        let indices = spec.compute(&data, sigma_within).unwrap();
715
716        let cp = indices.cp.unwrap();
717        let pp = indices.pp.unwrap();
718
719        assert!(
720            pp < cp,
721            "Pp ({pp}) should be < Cp ({cp}) when sigma_overall > sigma_within"
722        );
723    }
724
725    // -----------------------------------------------------------------------
726    // Known numerical example
727    // -----------------------------------------------------------------------
728
729    /// Exact numerical verification.
730    ///
731    /// USL = 10, LSL = 0, sigma_within = 1.0
732    /// Cp = (10 - 0) / (6 * 1) = 1.6667
733    #[test]
734    fn exact_numerical_verification() {
735        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
736
737        let data = [4.0, 4.5, 5.0, 5.5, 6.0, 4.0, 5.0, 6.0, 5.0, 5.0];
738        let x_bar = stats::mean(&data).unwrap();
739
740        let sigma_within = 1.0;
741        let indices = spec.compute(&data, sigma_within).unwrap();
742
743        let expected_cp = 10.0 / 6.0;
744        let expected_cpu = (10.0 - x_bar) / 3.0;
745        let expected_cpl = (x_bar - 0.0) / 3.0;
746
747        assert!(
748            (indices.cp.unwrap() - expected_cp).abs() < 1e-10,
749            "Cp: expected {expected_cp}, got {}",
750            indices.cp.unwrap()
751        );
752        assert!(
753            (indices.cpu.unwrap() - expected_cpu).abs() < 1e-10,
754            "Cpu: expected {expected_cpu}, got {}",
755            indices.cpu.unwrap()
756        );
757        assert!(
758            (indices.cpl.unwrap() - expected_cpl).abs() < 1e-10,
759            "Cpl: expected {expected_cpl}, got {}",
760            indices.cpl.unwrap()
761        );
762        assert!(
763            (indices.cpk.unwrap() - expected_cpu.min(expected_cpl)).abs() < 1e-10,
764            "Cpk: expected {}, got {}",
765            expected_cpu.min(expected_cpl),
766            indices.cpk.unwrap()
767        );
768    }
769}