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//! - Bissell, A.F. (1990), "How Reliable is Your Capability Index?",
16//!   *Applied Statistics* 39(3), pp. 331--340 -- the Cpk interval.
17//! - Pearn, W.L. & Kotz, S. (2006), *Encyclopedia and Handbook of Process
18//!   Capability Indices*, World Scientific -- the Cp and Cpk intervals.
19
20use u_numflow::special::{chi_squared_quantile, inverse_normal_cdf};
21use u_numflow::stats;
22
23/// Input specification for process capability analysis.
24///
25/// Defines the upper and/or lower specification limits and an optional target
26/// value. At least one specification limit must be provided.
27///
28/// # Examples
29///
30/// ```
31/// use u_analytics::capability::ProcessCapability;
32///
33/// // Two-sided specification: LSL = 9.0, USL = 11.0
34/// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
35///
36/// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
37/// let indices = spec.compute(&data, 0.15).unwrap();
38/// assert!(indices.cp.is_some());
39/// assert!(indices.cpk.is_some());
40/// ```
41pub struct ProcessCapability {
42    usl: Option<f64>,
43    lsl: Option<f64>,
44    target: Option<f64>,
45}
46
47/// Computed capability indices.
48///
49/// Fields are `Option<f64>` because not every index can be computed for
50/// every input. Cp requires both USL and LSL; the short-term quartet
51/// (`cp`, `cpk`, `cpu`, `cpl`) requires a within-subgroup sigma, so it is
52/// `None` from [`ProcessCapability::compute_overall`], which has none. A
53/// long-term number is never reported under a short-term name.
54///
55/// # Index interpretation
56///
57/// | Index | Value | Interpretation |
58/// |-------|-------|----------------|
59/// | Cp/Pp | >= 1.33 | Process is capable |
60/// | Cpk/Ppk | >= 1.33 | Process is capable and centered |
61/// | Cpm | >= 1.33 | Process meets Taguchi loss criterion |
62///
63/// Reference: Montgomery (2020), Chapter 8, Table 8.5.
64#[derive(Debug, Clone)]
65pub struct CapabilityIndices {
66    /// Cp = (USL - LSL) / (6 * sigma_within). Requires both limits.
67    pub cp: Option<f64>,
68    /// Cpk = min(Cpu, Cpl). Requires at least one limit.
69    pub cpk: Option<f64>,
70    /// Cpu = (USL - mean) / (3 * sigma_within). Requires USL.
71    pub cpu: Option<f64>,
72    /// Cpl = (mean - LSL) / (3 * sigma_within). Requires LSL.
73    pub cpl: Option<f64>,
74    /// Pp = (USL - LSL) / (6 * sigma_overall). Requires both limits.
75    pub pp: Option<f64>,
76    /// Ppk = min(Ppu, Ppl). Requires at least one limit.
77    pub ppk: Option<f64>,
78    /// Ppu = (USL - mean) / (3 * sigma_overall). Requires USL.
79    pub ppu: Option<f64>,
80    /// Ppl = (mean - LSL) / (3 * sigma_overall). Requires LSL.
81    pub ppl: Option<f64>,
82    /// Cpm = min(T - LSL, USL - T) / (3 * sigma_t), where
83    /// sigma_t = sqrt(sum((x_i - T)^2) / (n - 1)) is the spread of every
84    /// observation about the target T. With T at the specification midpoint
85    /// the numerator is (USL - LSL) / 6, the form Chan, Cheng & Spiring (1988)
86    /// give.
87    ///
88    /// `None` unless both limits and a target are set, the target lies within
89    /// the limits, and sigma_t is positive. Cpm measures how closely the
90    /// process clusters about the target, so it depends on neither sigma
91    /// estimate above and is the same from [`ProcessCapability::compute`] and
92    /// [`ProcessCapability::compute_overall`].
93    ///
94    /// References: Chan, Cheng & Spiring (1988); Minitab, *Capability Analysis
95    /// (Normal) Formulas*, for the numerator when T is not the midpoint.
96    pub cpm: Option<f64>,
97    /// Sample mean of the data.
98    pub mean: f64,
99    /// Short-term (within-group) standard deviation -- the value the caller
100    /// supplied to [`ProcessCapability::compute`]. `None` from
101    /// [`ProcessCapability::compute_overall`], along with the four indices
102    /// that would be computed from it.
103    pub std_dev_within: Option<f64>,
104    /// Long-term (overall) standard deviation.
105    pub std_dev_overall: f64,
106    /// Number of observations the indices were computed from.
107    pub n: usize,
108}
109
110/// A two-sided confidence interval.
111///
112/// The lower limit of a two-sided interval at confidence `1 - 2a` is the
113/// one-sided lower bound at `1 - a`: a customer requirement "Cpk lower bound at
114/// 95 % >= 1.33" is the `lower` of the interval at `confidence = 0.90`.
115#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct ConfidenceInterval {
117    /// Lower confidence limit.
118    pub lower: f64,
119    /// Upper confidence limit.
120    pub upper: f64,
121}
122
123/// Confidence intervals for the capability indices -- see
124/// [`CapabilityIndices::intervals`].
125///
126/// An index's interval is `None` when the index is, and the short-term pair
127/// (`cp`, `cpk`) is `None` unless the degrees of freedom of the within sigma
128/// were given: they depend on how that sigma was estimated, which the indices
129/// do not record.
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct CapabilityIntervals {
132    /// Confidence level of every interval, in (0, 1).
133    pub confidence: f64,
134    /// Cp, from the chi-square distribution of the within variance.
135    pub cp: Option<ConfidenceInterval>,
136    /// Cpk, by Bissell's normal approximation.
137    pub cpk: Option<ConfidenceInterval>,
138    /// Pp, from the chi-square distribution of the sample variance (n - 1 df).
139    pub pp: Option<ConfidenceInterval>,
140    /// Ppk, by Bissell's normal approximation (n - 1 df).
141    pub ppk: Option<ConfidenceInterval>,
142}
143
144/// Why [`CapabilityIndices::intervals`] refused its arguments.
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub enum IntervalError {
147    /// `confidence` is not strictly between 0 and 1.
148    ConfidenceOutOfRange,
149    /// The within degrees of freedom are not a positive, finite number.
150    WithinDfOutOfRange,
151    /// Within degrees of freedom were given, but the indices have no within sigma.
152    WithinDfWithoutWithinSigma,
153}
154
155impl CapabilityIndices {
156    /// Two-sided confidence intervals for Cp, Cpk, Pp and Ppk at `confidence`.
157    ///
158    /// For a ratio of the specification width to `k` sigma estimated with `df`
159    /// degrees of freedom (Cp, Pp):
160    ///
161    /// ```text
162    /// C * sqrt(chi2(a/2; df) / df)  <=  C  <=  C * sqrt(chi2(1 - a/2; df) / df)
163    /// ```
164    ///
165    /// For the nearer-limit index (Cpk, Ppk), Bissell's (1990) approximation:
166    ///
167    /// ```text
168    /// Ck -/+ z(1 - a/2) * sqrt(1 / (9 n) + Ck^2 / (2 df))
169    /// ```
170    ///
171    /// with `a = 1 - confidence`, `n` the number of observations (the mean's
172    /// share) and `df` the degrees of freedom of the sigma (its share). The
173    /// overall sigma is the sample standard deviation, so Pp/Ppk use
174    /// `n - 1`. The within sigma's degrees of freedom depend on how it was
175    /// estimated -- `n - m` for a pooled standard deviation of `m` subgroups,
176    /// fewer for R-bar/d2 or a moving range (about `0.62 (n - 1)` for MR-bar/d2,
177    /// Wheeler 2004) -- so Cp/Cpk intervals are given only when `within_df` is.
178    ///
179    /// # Errors
180    ///
181    /// [`IntervalError`] when `confidence` is not in (0, 1), `within_df` is
182    /// not positive and finite, or `within_df` is given for indices computed
183    /// without a within sigma.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use u_analytics::capability::ProcessCapability;
189    ///
190    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
191    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0, 10.4, 9.7];
192    /// let indices = spec.compute_overall(&data).unwrap();
193    ///
194    /// // A one-sided 95 % lower bound is the lower limit of the 90 % interval.
195    /// let ci = indices.intervals(0.90, None).unwrap();
196    /// let ppk = ci.ppk.unwrap();
197    /// assert!(ppk.lower < indices.ppk.unwrap() && indices.ppk.unwrap() < ppk.upper);
198    /// assert!(ci.cpk.is_none()); // no within sigma
199    /// ```
200    pub fn intervals(
201        &self,
202        confidence: f64,
203        within_df: Option<f64>,
204    ) -> Result<CapabilityIntervals, IntervalError> {
205        if !(confidence > 0.0 && confidence < 1.0) {
206            return Err(IntervalError::ConfidenceOutOfRange);
207        }
208        if let Some(df) = within_df {
209            if !(df.is_finite() && df > 0.0) {
210                return Err(IntervalError::WithinDfOutOfRange);
211            }
212            if self.std_dev_within.is_none() {
213                return Err(IntervalError::WithinDfWithoutWithinSigma);
214            }
215        }
216        let alpha = 1.0 - confidence;
217        let n = self.n as f64;
218        let overall_df = n - 1.0;
219        let ratio = |c: f64, df: f64| ConfidenceInterval {
220            lower: c * (chi_squared_quantile(alpha / 2.0, df) / df).sqrt(),
221            upper: c * (chi_squared_quantile(1.0 - alpha / 2.0, df) / df).sqrt(),
222        };
223        let z = inverse_normal_cdf(1.0 - alpha / 2.0);
224        let nearer = |c: f64, df: f64| {
225            let half = z * (1.0 / (9.0 * n) + c * c / (2.0 * df)).sqrt();
226            ConfidenceInterval {
227                lower: c - half,
228                upper: c + half,
229            }
230        };
231        Ok(CapabilityIntervals {
232            confidence,
233            cp: within_df.and_then(|df| self.cp.map(|c| ratio(c, df))),
234            cpk: within_df.and_then(|df| self.cpk.map(|c| nearer(c, df))),
235            pp: self.pp.map(|c| ratio(c, overall_df)),
236            ppk: self.ppk.map(|c| nearer(c, overall_df)),
237        })
238    }
239}
240
241impl ProcessCapability {
242    /// Creates a new process capability specification.
243    ///
244    /// At least one of `usl` or `lsl` must be `Some`. If both are provided,
245    /// `usl` must be greater than `lsl`.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error string if:
250    /// - Both `usl` and `lsl` are `None`
251    /// - `usl <= lsl` when both are provided
252    /// - Either limit is non-finite (NaN or infinity)
253    ///
254    /// # Examples
255    ///
256    /// ```
257    /// use u_analytics::capability::ProcessCapability;
258    ///
259    /// // Two-sided
260    /// let spec = ProcessCapability::new(Some(10.0), Some(5.0)).unwrap();
261    ///
262    /// // Upper limit only
263    /// let spec = ProcessCapability::new(Some(10.0), None).unwrap();
264    ///
265    /// // Lower limit only
266    /// let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
267    ///
268    /// // Error: no limits
269    /// assert!(ProcessCapability::new(None, None).is_err());
270    ///
271    /// // Error: USL <= LSL
272    /// assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
273    /// ```
274    pub fn new(usl: Option<f64>, lsl: Option<f64>) -> Result<Self, &'static str> {
275        if usl.is_none() && lsl.is_none() {
276            return Err("at least one specification limit (USL or LSL) is required");
277        }
278        if let Some(u) = usl {
279            if !u.is_finite() {
280                return Err("USL must be finite");
281            }
282        }
283        if let Some(l) = lsl {
284            if !l.is_finite() {
285                return Err("LSL must be finite");
286            }
287        }
288        if let (Some(u), Some(l)) = (usl, lsl) {
289            if u <= l {
290                return Err("USL must be greater than LSL");
291            }
292        }
293        Ok(Self {
294            usl,
295            lsl,
296            target: None,
297        })
298    }
299
300    /// Sets the target value for Cpm.
301    ///
302    /// Cpm is computed only against a declared target. Without one it is
303    /// `None` rather than measured against the specification midpoint: a
304    /// substituted target cannot be told apart from a declared one in the
305    /// result. A process whose target is the midpoint passes it here.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use u_analytics::capability::ProcessCapability;
311    ///
312    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0))
313    ///     .unwrap()
314    ///     .with_target(10.0);
315    /// ```
316    pub fn with_target(mut self, target: f64) -> Self {
317        self.target = Some(target);
318        self
319    }
320
321    /// Computes all capability indices using the provided within-group sigma.
322    ///
323    /// The `sigma_within` parameter represents the short-term (within-group)
324    /// standard deviation, typically estimated from a control chart as R-bar/d2
325    /// or S-bar/c4.
326    ///
327    /// The overall (long-term) standard deviation is computed from the data
328    /// using the sample standard deviation.
329    ///
330    /// # Returns
331    ///
332    /// `None` if:
333    /// - `data` has fewer than 2 elements
334    /// - `sigma_within` is not positive or not finite
335    /// - `data` contains NaN or infinity values
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use u_analytics::capability::ProcessCapability;
341    ///
342    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
343    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
344    /// let sigma_within = 0.15; // from control chart
345    ///
346    /// let indices = spec.compute(&data, sigma_within).unwrap();
347    /// assert!(indices.cp.unwrap() > 0.0);
348    /// assert!(indices.cpk.unwrap() > 0.0);
349    /// assert!(indices.pp.unwrap() > 0.0);
350    /// assert!(indices.ppk.unwrap() > 0.0);
351    /// ```
352    pub fn compute(&self, data: &[f64], sigma_within: f64) -> Option<CapabilityIndices> {
353        if !sigma_within.is_finite() || sigma_within <= 0.0 {
354            return None;
355        }
356        let x_bar = stats::mean(data)?;
357        let sigma_overall = stats::std_dev(data)?;
358
359        Some(self.compute_indices(data, x_bar, Some(sigma_within), sigma_overall))
360    }
361
362    /// Computes the long-term (performance) indices only.
363    ///
364    /// Use this when no within-subgroup sigma is available -- a flat vector
365    /// with no rational subgrouping. Pp/Ppk/Ppu/Ppl come from the overall
366    /// sample standard deviation, and Cpm from the spread about the target,
367    /// as in [`ProcessCapability::compute`]. The short-term quartet
368    /// (`cp`, `cpk`, `cpu`, `cpl`) and `std_dev_within` are `None`: filling
369    /// them from the overall sigma would make `cp` equal `pp` for every
370    /// input, a long-term number under a short-term name. To report Cp/Cpk,
371    /// estimate the within sigma from a control chart (R-bar/d2, S-bar/c4 or
372    /// MR-bar/d2) and call [`ProcessCapability::compute`].
373    ///
374    /// # Returns
375    ///
376    /// `None` if:
377    /// - `data` has fewer than 2 elements
378    /// - `data` contains NaN or infinity values
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use u_analytics::capability::ProcessCapability;
384    ///
385    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
386    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
387    ///
388    /// let indices = spec.compute_overall(&data).unwrap();
389    /// assert!(indices.pp.unwrap() > 0.0);
390    /// // No within sigma, so no short-term index is claimed.
391    /// assert!(indices.cp.is_none());
392    /// assert!(indices.std_dev_within.is_none());
393    /// ```
394    pub fn compute_overall(&self, data: &[f64]) -> Option<CapabilityIndices> {
395        let x_bar = stats::mean(data)?;
396        let sigma_overall = stats::std_dev(data)?;
397
398        Some(self.compute_indices(data, x_bar, None, sigma_overall))
399    }
400
401    /// Internal computation of all indices given the data, its mean, the
402    /// overall sigma and -- when the caller has one -- the within sigma.
403    fn compute_indices(
404        &self,
405        data: &[f64],
406        x_bar: f64,
407        sigma_within: Option<f64>,
408        sigma_overall: f64,
409    ) -> CapabilityIndices {
410        // Short-term indices (within-group sigma) -- only with a within sigma.
411        let cpu = sigma_within.and_then(|s| self.usl.map(|u| (u - x_bar) / (3.0 * s)));
412        let cpl = sigma_within.and_then(|s| self.lsl.map(|l| (x_bar - l) / (3.0 * s)));
413        let cp = match (sigma_within, self.usl, self.lsl) {
414            (Some(s), Some(u), Some(l)) => Some((u - l) / (6.0 * s)),
415            _ => None,
416        };
417        let cpk = match (cpu, cpl) {
418            (Some(u), Some(l)) => Some(u.min(l)),
419            (Some(u), None) => Some(u),
420            (None, Some(l)) => Some(l),
421            (None, None) => None,
422        };
423
424        // Long-term indices (overall sigma)
425        let ppu = self.usl.map(|u| (u - x_bar) / (3.0 * sigma_overall));
426        let ppl = self.lsl.map(|l| (x_bar - l) / (3.0 * sigma_overall));
427        let pp = match (self.usl, self.lsl) {
428            (Some(u), Some(l)) => Some((u - l) / (6.0 * sigma_overall)),
429            _ => None,
430        };
431        let ppk = match (ppu, ppl) {
432            (Some(u), Some(l)) => Some(u.min(l)),
433            (Some(u), None) => Some(u),
434            (None, Some(l)) => Some(l),
435            (None, None) => None,
436        };
437
438        let cpm = self.cpm(data);
439
440        CapabilityIndices {
441            cp,
442            cpk,
443            cpu,
444            cpl,
445            pp,
446            ppk,
447            ppu,
448            ppl,
449            cpm,
450            mean: x_bar,
451            std_dev_within: sigma_within,
452            std_dev_overall: sigma_overall,
453            n: data.len(),
454        }
455    }
456
457    /// Cpm against the declared target -- see [`CapabilityIndices::cpm`].
458    ///
459    /// The spread is taken about T directly rather than assembled from a sigma
460    /// and the offset of the mean: `sum((x_i - T)^2) / (n - 1)` is the
461    /// estimator Chan, Cheng & Spiring (1988) give, and it differs from
462    /// `s^2 + (mean - T)^2` by a factor of n / (n - 1) on the offset term.
463    fn cpm(&self, data: &[f64]) -> Option<f64> {
464        let (usl, lsl, target) = (self.usl?, self.lsl?, self.target?);
465        if !target.is_finite() || target < lsl || target > usl || data.len() < 2 {
466            return None;
467        }
468        let sum_sq: f64 = data.iter().map(|&x| (x - target).powi(2)).sum();
469        let sigma_t = (sum_sq / (data.len() - 1) as f64).sqrt();
470        // Every observation on the target leaves no spread to divide by.
471        if sigma_t <= 0.0 {
472            return None;
473        }
474        Some((target - lsl).min(usl - target) / (3.0 * sigma_t))
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    // -----------------------------------------------------------------------
483    // Confidence intervals
484    // -----------------------------------------------------------------------
485
486    /// Indices with chosen values, for checking the interval arithmetic
487    /// against hand-worked table values.
488    fn indices(n: usize, c: f64, within: bool) -> CapabilityIndices {
489        CapabilityIndices {
490            cp: within.then_some(c),
491            cpk: within.then_some(c),
492            cpu: None,
493            cpl: None,
494            pp: Some(c),
495            ppk: Some(c),
496            ppu: None,
497            ppl: None,
498            cpm: None,
499            mean: 0.0,
500            std_dev_within: within.then_some(1.0),
501            std_dev_overall: 1.0,
502            n,
503        }
504    }
505
506    /// n = 30, an index of 1.33, 95 %: chi-square table values for 29 df
507    /// (16.047, 45.722) and z = 1.960 give the published-arithmetic limits.
508    #[test]
509    fn intervals_match_hand_worked_values() {
510        let ci = indices(30, 1.33, false).intervals(0.95, None).unwrap();
511        let pp = ci.pp.unwrap();
512        assert!(
513            (pp.lower - 1.33 * (16.047_f64 / 29.0).sqrt()).abs() < 1e-3,
514            "{pp:?}"
515        );
516        assert!(
517            (pp.upper - 1.33 * (45.722_f64 / 29.0).sqrt()).abs() < 1e-3,
518            "{pp:?}"
519        );
520        let ppk = ci.ppk.unwrap();
521        let half = 1.959964 * (1.0 / 270.0 + 1.33_f64.powi(2) / 58.0).sqrt();
522        assert!((ppk.lower - (1.33 - half)).abs() < 1e-6, "{ppk:?}");
523        assert!((ppk.upper - (1.33 + half)).abs() < 1e-6, "{ppk:?}");
524        assert!((ppk.lower - 0.9675).abs() < 1e-3, "{ppk:?}");
525    }
526
527    /// The short-term pair needs the within degrees of freedom; given them, it
528    /// uses them in place of n - 1.
529    #[test]
530    fn short_term_intervals_need_the_within_df() {
531        let i = indices(50, 1.5, true);
532        let without = i.intervals(0.95, None).unwrap();
533        assert!(without.cp.is_none() && without.cpk.is_none());
534        assert!(without.pp.is_some() && without.ppk.is_some());
535
536        let with = i.intervals(0.95, Some(30.0)).unwrap();
537        let (cp, pp) = (with.cp.unwrap(), with.pp.unwrap());
538        // Fewer degrees of freedom: a wider interval about the same value.
539        assert!(cp.upper - cp.lower > pp.upper - pp.lower);
540        assert!((cp.lower - 1.5 * (chi_squared_quantile(0.025, 30.0) / 30.0).sqrt()).abs() < 1e-12);
541    }
542
543    #[test]
544    fn interval_arguments_are_checked() {
545        let overall = indices(20, 1.2, false);
546        for c in [0.0, 1.0, -0.5, 1.5, f64::NAN] {
547            assert_eq!(
548                overall.intervals(c, None),
549                Err(IntervalError::ConfidenceOutOfRange)
550            );
551        }
552        assert_eq!(
553            overall.intervals(0.95, Some(10.0)),
554            Err(IntervalError::WithinDfWithoutWithinSigma)
555        );
556        let within = indices(20, 1.2, true);
557        for df in [0.0, -1.0, f64::INFINITY, f64::NAN] {
558            assert_eq!(
559                within.intervals(0.95, Some(df)),
560                Err(IntervalError::WithinDfOutOfRange)
561            );
562        }
563    }
564
565    /// Higher confidence, wider interval; more data, narrower.
566    #[test]
567    fn intervals_widen_with_confidence_and_narrow_with_n() {
568        let width = |n: usize, conf: f64| {
569            let ppk = indices(n, 1.33, false)
570                .intervals(conf, None)
571                .unwrap()
572                .ppk
573                .unwrap();
574            ppk.upper - ppk.lower
575        };
576        assert!(width(30, 0.99) > width(30, 0.95));
577        assert!(width(30, 0.95) > width(30, 0.90));
578        assert!(width(100, 0.95) < width(30, 0.95));
579    }
580
581    // -----------------------------------------------------------------------
582    // Construction tests
583    // -----------------------------------------------------------------------
584
585    #[test]
586    fn new_requires_at_least_one_limit() {
587        assert!(ProcessCapability::new(None, None).is_err());
588    }
589
590    #[test]
591    fn new_rejects_usl_leq_lsl() {
592        assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
593        assert!(ProcessCapability::new(Some(5.0), Some(5.0)).is_err());
594    }
595
596    #[test]
597    fn new_rejects_non_finite() {
598        assert!(ProcessCapability::new(Some(f64::NAN), Some(1.0)).is_err());
599        assert!(ProcessCapability::new(Some(10.0), Some(f64::INFINITY)).is_err());
600    }
601
602    #[test]
603    fn new_accepts_valid_two_sided() {
604        assert!(ProcessCapability::new(Some(10.0), Some(5.0)).is_ok());
605    }
606
607    #[test]
608    fn new_accepts_usl_only() {
609        assert!(ProcessCapability::new(Some(10.0), None).is_ok());
610    }
611
612    #[test]
613    fn new_accepts_lsl_only() {
614        assert!(ProcessCapability::new(None, Some(5.0)).is_ok());
615    }
616
617    // -----------------------------------------------------------------------
618    // Computation -- textbook example
619    // -----------------------------------------------------------------------
620
621    /// Textbook example: Montgomery (2019), Example 8.1
622    ///
623    /// LSL = 200, USL = 220, no target declared
624    /// Process mean ~ 210, sigma_within = 2.0
625    ///
626    /// Cp = (220 - 200) / (6 * 2) = 20/12 = 1.6667
627    #[test]
628    fn textbook_centered_process() {
629        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
630
631        let data = [
632            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0, 209.0, 211.0,
633            210.0, 209.5, 210.5, 210.0, 210.0, 210.0, 209.0, 211.0,
634        ];
635
636        let sigma_within = 2.0;
637        let indices = spec.compute(&data, sigma_within).unwrap();
638
639        // Cp = (220 - 200) / (6 * 2) = 1.6667
640        let cp = indices.cp.unwrap();
641        assert!(
642            (cp - 1.6667).abs() < 0.001,
643            "expected Cp ~ 1.6667, got {cp}"
644        );
645
646        let cpk = indices.cpk.unwrap();
647        assert!(cpk > 0.0, "Cpk should be positive");
648
649        // No target was declared, so there is no Cpm -- the midpoint is not
650        // assumed on the caller's behalf.
651        assert!(indices.cpm.is_none());
652    }
653
654    /// Off-center process: mean shifted toward USL.
655    ///
656    /// LSL = 200, USL = 220, mean ~ 215, sigma_within = 2.0
657    /// Cp = (220 - 200) / (6 * 2) = 1.6667
658    /// Cpu = (220 - 215) / (3 * 2) = 0.8333
659    /// Cpl = (215 - 200) / (3 * 2) = 2.5
660    /// Cpk = min(0.8333, 2.5) = 0.8333
661    #[test]
662    fn off_center_process() {
663        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
664
665        let data = [
666            213.0, 214.0, 215.0, 216.0, 217.0, 213.5, 214.5, 215.5, 216.5, 215.0, 214.0, 216.0,
667            215.0, 214.5, 215.5, 215.0, 215.0, 215.0, 214.0, 216.0,
668        ];
669
670        let sigma_within = 2.0;
671        let indices = spec.compute(&data, sigma_within).unwrap();
672
673        let cp = indices.cp.unwrap();
674        assert!(
675            (cp - 1.6667).abs() < 0.001,
676            "expected Cp ~ 1.6667, got {cp}"
677        );
678
679        let cpu = indices.cpu.unwrap();
680        assert!(
681            (cpu - 0.8333).abs() < 0.05,
682            "expected Cpu ~ 0.8333, got {cpu}"
683        );
684
685        let cpl = indices.cpl.unwrap();
686        assert!((cpl - 2.5).abs() < 0.05, "expected Cpl ~ 2.5, got {cpl}");
687
688        let cpk = indices.cpk.unwrap();
689        assert!((cpk - cpu).abs() < 1e-15, "Cpk should equal min(Cpu, Cpl)");
690    }
691
692    // -----------------------------------------------------------------------
693    // One-sided specifications
694    // -----------------------------------------------------------------------
695
696    #[test]
697    fn usl_only_computes_cpu_not_cpl() {
698        let spec = ProcessCapability::new(Some(10.0), None).unwrap();
699        let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
700        let indices = spec.compute(&data, 0.5).unwrap();
701
702        assert!(indices.cpu.is_some());
703        assert!(indices.cpl.is_none());
704        assert!(indices.cp.is_none(), "Cp requires both limits");
705        assert!(indices.cpk.is_some(), "Cpk should be Cpu for USL-only");
706        assert!(
707            (indices.cpk.unwrap() - indices.cpu.unwrap()).abs() < 1e-15,
708            "Cpk == Cpu when only USL is set"
709        );
710    }
711
712    #[test]
713    fn lsl_only_computes_cpl_not_cpu() {
714        let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
715        let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
716        let indices = spec.compute(&data, 0.5).unwrap();
717
718        assert!(indices.cpl.is_some());
719        assert!(indices.cpu.is_none());
720        assert!(indices.cp.is_none());
721        assert!(indices.cpk.is_some(), "Cpk should be Cpl for LSL-only");
722        assert!(
723            (indices.cpk.unwrap() - indices.cpl.unwrap()).abs() < 1e-15,
724            "Cpk == Cpl when only LSL is set"
725        );
726    }
727
728    // -----------------------------------------------------------------------
729    // Overall-only computation
730    // -----------------------------------------------------------------------
731
732    #[test]
733    fn compute_overall_reports_no_short_term_index() {
734        // The previous version of this test asserted cp == pp here, pinning
735        // the overall sigma being reported under the short-term names. A
736        // flat vector has no within sigma, so nothing short-term is claimed.
737        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
738        let data = [
739            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0,
740        ];
741        let indices = spec.compute_overall(&data).unwrap();
742
743        assert!(indices.cp.is_none());
744        assert!(indices.cpk.is_none());
745        assert!(indices.cpu.is_none());
746        assert!(indices.cpl.is_none());
747        assert!(indices.std_dev_within.is_none());
748        assert!(indices.pp.unwrap() > 0.0);
749        assert!(indices.ppk.unwrap() > 0.0);
750        assert!(indices.ppu.unwrap() > 0.0);
751        assert!(indices.ppl.unwrap() > 0.0);
752
753        // The long-term indices are the same ones `compute` reports.
754        let with_within = spec.compute(&data, 1.0).unwrap();
755        assert_eq!(indices.pp, with_within.pp);
756        assert_eq!(indices.ppk, with_within.ppk);
757        assert_eq!(indices.std_dev_overall, with_within.std_dev_overall);
758        assert_eq!(with_within.std_dev_within, Some(1.0));
759    }
760
761    // -----------------------------------------------------------------------
762    // Cpm with explicit target
763    // -----------------------------------------------------------------------
764
765    /// The case reported against 0.8.0, which derived Cpm from Cp: with no
766    /// target it reported 2.8793 against the midpoint, and 0.7361 against a
767    /// target of 11 -- both from the within sigma of 0.2.
768    ///
769    /// Hand computation, sigma_t = sqrt(sum((x - T)^2) / 5):
770    /// - T = 10: sum = 0.35, sigma_t = 0.264575, Cpm = (4/6) / sigma_t = 2.519763
771    /// - T = 11: sum = 4.95, sigma_t = 0.994987, Cpm = (1/3) / sigma_t = 0.335013
772    #[test]
773    fn cpm_is_the_spread_about_a_declared_target() {
774        let data = [9.8, 10.1, 10.3, 9.9, 10.2, 10.4];
775        let spec = || ProcessCapability::new(Some(12.0), Some(8.0)).unwrap();
776
777        assert!(spec().compute(&data, 0.2).unwrap().cpm.is_none());
778        assert!(spec().compute_overall(&data).unwrap().cpm.is_none());
779
780        for (target, expected) in [(10.0, 2.519_763), (11.0, 0.335_013)] {
781            let spec = spec().with_target(target);
782            let within = spec.compute(&data, 0.2).unwrap().cpm.unwrap();
783            let overall = spec.compute_overall(&data).unwrap().cpm.unwrap();
784            assert!((within - expected).abs() < 1e-6, "T={target}: {within}");
785            // No sigma enters Cpm, so the two entry points agree exactly.
786            assert_eq!(within, overall);
787        }
788    }
789
790    /// T off the midpoint: the numerator is the distance to the nearer limit.
791    ///
792    /// LSL = 200, USL = 220, T = 212: min(12, 8) / 3 = 8/3.
793    /// sum((x - 212)^2) = 16 + 9 + 4 + 1 + 0 = 30, sigma_t = sqrt(7.5).
794    /// Cpm = (8/3) / 2.738613 = 0.973729.
795    #[test]
796    fn cpm_off_midpoint_uses_the_nearer_limit() {
797        let spec = ProcessCapability::new(Some(220.0), Some(200.0))
798            .unwrap()
799            .with_target(212.0);
800        let data = [208.0, 209.0, 210.0, 211.0, 212.0];
801        let cpm = spec.compute_overall(&data).unwrap().cpm.unwrap();
802        assert!((cpm - 0.973_729).abs() < 1e-6, "got {cpm}");
803    }
804
805    /// With the mean on the target, sum((x - T)^2) / (n - 1) is the sample
806    /// variance, so Cpm is exactly Pp.
807    #[test]
808    fn cpm_equals_pp_when_the_mean_is_on_target() {
809        let spec = ProcessCapability::new(Some(220.0), Some(200.0))
810            .unwrap()
811            .with_target(210.0);
812        let data = [208.0, 209.0, 210.0, 211.0, 212.0];
813        let indices = spec.compute(&data, 2.0).unwrap();
814        let (cpm, pp) = (indices.cpm.unwrap(), indices.pp.unwrap());
815        assert!((cpm - pp).abs() < 1e-12, "Cpm {cpm} vs Pp {pp}");
816    }
817
818    #[test]
819    fn cpm_is_none_where_it_is_undefined() {
820        let data = [208.0, 209.0, 210.0, 211.0, 212.0];
821        let two_sided = || ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
822
823        // A target outside the specification.
824        for t in [199.0, 221.0, f64::NAN] {
825            let cpm = two_sided()
826                .with_target(t)
827                .compute_overall(&data)
828                .unwrap()
829                .cpm;
830            assert!(cpm.is_none(), "T={t}");
831        }
832        // Every observation on the target: no spread to divide by.
833        let on_target = two_sided().with_target(210.0).compute(&[210.0; 5], 2.0);
834        assert!(on_target.unwrap().cpm.is_none());
835        // One-sided specification, even with a target.
836        let upper = ProcessCapability::new(Some(220.0), None)
837            .unwrap()
838            .with_target(210.0);
839        assert!(upper.compute_overall(&data).unwrap().cpm.is_none());
840    }
841
842    // -----------------------------------------------------------------------
843    // Edge cases
844    // -----------------------------------------------------------------------
845
846    #[test]
847    fn compute_returns_none_for_insufficient_data() {
848        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
849        assert!(spec.compute(&[5.0], 1.0).is_none());
850        assert!(spec.compute(&[], 1.0).is_none());
851    }
852
853    #[test]
854    fn compute_returns_none_for_invalid_sigma() {
855        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
856        let data = [4.0, 5.0, 6.0, 5.0, 5.5];
857        assert!(spec.compute(&data, 0.0).is_none());
858        assert!(spec.compute(&data, -1.0).is_none());
859        assert!(spec.compute(&data, f64::NAN).is_none());
860        assert!(spec.compute(&data, f64::INFINITY).is_none());
861    }
862
863    #[test]
864    fn compute_returns_none_for_nan_data() {
865        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
866        let data = [4.0, f64::NAN, 6.0];
867        assert!(spec.compute(&data, 1.0).is_none());
868    }
869
870    #[test]
871    fn compute_overall_returns_none_for_insufficient_data() {
872        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
873        assert!(spec.compute_overall(&[5.0]).is_none());
874        assert!(spec.compute_overall(&[]).is_none());
875    }
876
877    // -----------------------------------------------------------------------
878    // Montgomery (2020) §7.1 reference values
879    // -----------------------------------------------------------------------
880
881    /// Exact reference from Montgomery (2020), §7.1.
882    ///
883    /// μ = 74.001, σ_within = 0.0099, USL = 74.05, LSL = 73.95
884    ///
885    /// Cp  = (74.05 - 73.95) / (6 × 0.0099) = 0.1 / 0.0594 = 1.6835
886    /// Cpu = (74.05 - 74.001) / (3 × 0.0099) = 0.049 / 0.0297 = 1.6499
887    /// Cpl = (74.001 - 73.95) / (3 × 0.0099) = 0.051 / 0.0297 = 1.7172
888    /// Cpk = min(1.6499, 1.7172) = 1.6499
889    #[test]
890    fn cp_cpk_montgomery_2020_reference() {
891        let usl = 74.05_f64;
892        let lsl = 73.95_f64;
893        let mu = 74.001_f64;
894        let sigma_within = 0.0099_f64;
895
896        let spec = ProcessCapability::new(Some(usl), Some(lsl)).unwrap();
897
898        // Single-point data: mean == mu, sigma_overall irrelevant for Cp/Cpk.
899        // Use many identical points so sample mean == mu exactly.
900        let data = vec![mu; 100];
901        let indices = spec.compute(&data, sigma_within).unwrap();
902
903        let cp = indices.cp.unwrap();
904        let expected_cp = (usl - lsl) / (6.0 * sigma_within); // 1.6835...
905        assert!(
906            (cp - expected_cp).abs() < 0.001,
907            "Cp: expected {expected_cp:.4}, got {cp:.4}"
908        );
909
910        let cpu = indices.cpu.unwrap();
911        let expected_cpu = (usl - mu) / (3.0 * sigma_within); // 1.6499...
912        assert!(
913            (cpu - expected_cpu).abs() < 0.001,
914            "Cpu: expected {expected_cpu:.4}, got {cpu:.4}"
915        );
916
917        let cpl = indices.cpl.unwrap();
918        let expected_cpl = (mu - lsl) / (3.0 * sigma_within); // 1.7172...
919        assert!(
920            (cpl - expected_cpl).abs() < 0.001,
921            "Cpl: expected {expected_cpl:.4}, got {cpl:.4}"
922        );
923
924        let cpk = indices.cpk.unwrap();
925        let expected_cpk = expected_cpu.min(expected_cpl); // 1.6499...
926        assert!(
927            (cpk - expected_cpk).abs() < 0.001,
928            "Cpk: expected {expected_cpk:.4}, got {cpk:.4}"
929        );
930
931        // Verify direction: process is slightly above centre, so Cpu < Cpl
932        assert!(
933            cpu < cpl,
934            "mean above centre → Cpu ({cpu:.4}) should be < Cpl ({cpl:.4})"
935        );
936    }
937
938    // -----------------------------------------------------------------------
939    // Pp/Ppk differ from Cp/Cpk when sigmas differ
940    // -----------------------------------------------------------------------
941
942    #[test]
943    fn pp_differs_from_cp_when_sigmas_differ() {
944        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
945
946        let data = [
947            205.0, 207.0, 210.0, 213.0, 215.0, 206.0, 208.0, 212.0, 214.0, 210.0, 204.0, 216.0,
948            209.0, 211.0, 210.0,
949        ];
950
951        let sigma_within = 1.5;
952        let indices = spec.compute(&data, sigma_within).unwrap();
953
954        let cp = indices.cp.unwrap();
955        let pp = indices.pp.unwrap();
956
957        assert!(
958            pp < cp,
959            "Pp ({pp}) should be < Cp ({cp}) when sigma_overall > sigma_within"
960        );
961    }
962
963    // -----------------------------------------------------------------------
964    // Known numerical example
965    // -----------------------------------------------------------------------
966
967    /// Exact numerical verification.
968    ///
969    /// USL = 10, LSL = 0, sigma_within = 1.0
970    /// Cp = (10 - 0) / (6 * 1) = 1.6667
971    #[test]
972    fn exact_numerical_verification() {
973        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
974
975        let data = [4.0, 4.5, 5.0, 5.5, 6.0, 4.0, 5.0, 6.0, 5.0, 5.0];
976        let x_bar = stats::mean(&data).unwrap();
977
978        let sigma_within = 1.0;
979        let indices = spec.compute(&data, sigma_within).unwrap();
980
981        let expected_cp = 10.0 / 6.0;
982        let expected_cpu = (10.0 - x_bar) / 3.0;
983        let expected_cpl = (x_bar - 0.0) / 3.0;
984
985        assert!(
986            (indices.cp.unwrap() - expected_cp).abs() < 1e-10,
987            "Cp: expected {expected_cp}, got {}",
988            indices.cp.unwrap()
989        );
990        assert!(
991            (indices.cpu.unwrap() - expected_cpu).abs() < 1e-10,
992            "Cpu: expected {expected_cpu}, got {}",
993            indices.cpu.unwrap()
994        );
995        assert!(
996            (indices.cpl.unwrap() - expected_cpl).abs() < 1e-10,
997            "Cpl: expected {expected_cpl}, got {}",
998            indices.cpl.unwrap()
999        );
1000        assert!(
1001            (indices.cpk.unwrap() - expected_cpu.min(expected_cpl)).abs() < 1e-10,
1002            "Cpk: expected {}, got {}",
1003            expected_cpu.min(expected_cpl),
1004            indices.cpk.unwrap()
1005        );
1006    }
1007}