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 (2019), *Introduction to Statistical Quality Control*, 8th ed.,
10//!   Chapter 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 all indices can be computed for
45/// one-sided specifications. For example, Cp requires both USL and LSL.
46///
47/// # Index interpretation
48///
49/// | Index | Value | Interpretation |
50/// |-------|-------|----------------|
51/// | Cp/Pp | >= 1.33 | Process is capable |
52/// | Cpk/Ppk | >= 1.33 | Process is capable and centered |
53/// | Cpm | >= 1.33 | Process meets Taguchi loss criterion |
54///
55/// Reference: Montgomery (2019), Chapter 8, Table 8.5.
56#[derive(Debug, Clone)]
57pub struct CapabilityIndices {
58    /// Cp = (USL - LSL) / (6 * sigma_within). Requires both limits.
59    pub cp: Option<f64>,
60    /// Cpk = min(Cpu, Cpl). Requires at least one limit.
61    pub cpk: Option<f64>,
62    /// Cpu = (USL - mean) / (3 * sigma_within). Requires USL.
63    pub cpu: Option<f64>,
64    /// Cpl = (mean - LSL) / (3 * sigma_within). Requires LSL.
65    pub cpl: Option<f64>,
66    /// Pp = (USL - LSL) / (6 * sigma_overall). Requires both limits.
67    pub pp: Option<f64>,
68    /// Ppk = min(Ppu, Ppl). Requires at least one limit.
69    pub ppk: Option<f64>,
70    /// Ppu = (USL - mean) / (3 * sigma_overall). Requires USL.
71    pub ppu: Option<f64>,
72    /// Ppl = (mean - LSL) / (3 * sigma_overall). Requires LSL.
73    pub ppl: Option<f64>,
74    /// Cpm = Cp / sqrt(1 + ((mean - target) / sigma_within)^2).
75    /// Requires both limits and a target.
76    ///
77    /// Reference: Chan, Cheng & Spiring (1988).
78    pub cpm: Option<f64>,
79    /// Sample mean of the data.
80    pub mean: f64,
81    /// Short-term (within-group) standard deviation.
82    pub std_dev_within: f64,
83    /// Long-term (overall) standard deviation.
84    pub std_dev_overall: f64,
85}
86
87impl ProcessCapability {
88    /// Creates a new process capability specification.
89    ///
90    /// At least one of `usl` or `lsl` must be `Some`. If both are provided,
91    /// `usl` must be greater than `lsl`.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error string if:
96    /// - Both `usl` and `lsl` are `None`
97    /// - `usl <= lsl` when both are provided
98    /// - Either limit is non-finite (NaN or infinity)
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use u_analytics::capability::ProcessCapability;
104    ///
105    /// // Two-sided
106    /// let spec = ProcessCapability::new(Some(10.0), Some(5.0)).unwrap();
107    ///
108    /// // Upper limit only
109    /// let spec = ProcessCapability::new(Some(10.0), None).unwrap();
110    ///
111    /// // Lower limit only
112    /// let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
113    ///
114    /// // Error: no limits
115    /// assert!(ProcessCapability::new(None, None).is_err());
116    ///
117    /// // Error: USL <= LSL
118    /// assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
119    /// ```
120    pub fn new(usl: Option<f64>, lsl: Option<f64>) -> Result<Self, &'static str> {
121        if usl.is_none() && lsl.is_none() {
122            return Err("at least one specification limit (USL or LSL) is required");
123        }
124        if let Some(u) = usl {
125            if !u.is_finite() {
126                return Err("USL must be finite");
127            }
128        }
129        if let Some(l) = lsl {
130            if !l.is_finite() {
131                return Err("LSL must be finite");
132            }
133        }
134        if let (Some(u), Some(l)) = (usl, lsl) {
135            if u <= l {
136                return Err("USL must be greater than LSL");
137            }
138        }
139        Ok(Self {
140            usl,
141            lsl,
142            target: None,
143        })
144    }
145
146    /// Sets the target value for Cpm calculation.
147    ///
148    /// If not set, the target defaults to the midpoint `(USL + LSL) / 2`
149    /// when both limits are available. Cpm is not computed for one-sided
150    /// specifications without an explicit target.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use u_analytics::capability::ProcessCapability;
156    ///
157    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0))
158    ///     .unwrap()
159    ///     .with_target(10.0);
160    /// ```
161    pub fn with_target(mut self, target: f64) -> Self {
162        self.target = Some(target);
163        self
164    }
165
166    /// Computes all capability indices using the provided within-group sigma.
167    ///
168    /// The `sigma_within` parameter represents the short-term (within-group)
169    /// standard deviation, typically estimated from a control chart as R-bar/d2
170    /// or S-bar/c4.
171    ///
172    /// The overall (long-term) standard deviation is computed from the data
173    /// using the sample standard deviation.
174    ///
175    /// # Returns
176    ///
177    /// `None` if:
178    /// - `data` has fewer than 2 elements
179    /// - `sigma_within` is not positive or not finite
180    /// - `data` contains NaN or infinity values
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use u_analytics::capability::ProcessCapability;
186    ///
187    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
188    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
189    /// let sigma_within = 0.15; // from control chart
190    ///
191    /// let indices = spec.compute(&data, sigma_within).unwrap();
192    /// assert!(indices.cp.unwrap() > 0.0);
193    /// assert!(indices.cpk.unwrap() > 0.0);
194    /// assert!(indices.pp.unwrap() > 0.0);
195    /// assert!(indices.ppk.unwrap() > 0.0);
196    /// ```
197    pub fn compute(&self, data: &[f64], sigma_within: f64) -> Option<CapabilityIndices> {
198        if !sigma_within.is_finite() || sigma_within <= 0.0 {
199            return None;
200        }
201        let x_bar = stats::mean(data)?;
202        let sigma_overall = stats::std_dev(data)?;
203
204        Some(self.compute_indices(x_bar, sigma_within, sigma_overall))
205    }
206
207    /// Computes capability indices using overall sigma for both short-term
208    /// and long-term estimates.
209    ///
210    /// Use this when no within-group sigma estimate is available (e.g., no
211    /// rational subgrouping). Both Cp/Cpk and Pp/Ppk will use the same
212    /// sigma, so Cp == Pp and Cpk == Ppk.
213    ///
214    /// # Returns
215    ///
216    /// `None` if:
217    /// - `data` has fewer than 2 elements
218    /// - `data` contains NaN or infinity values
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use u_analytics::capability::ProcessCapability;
224    ///
225    /// let spec = ProcessCapability::new(Some(11.0), Some(9.0)).unwrap();
226    /// let data = [9.5, 10.0, 10.2, 9.8, 10.1, 10.3, 9.9, 10.0];
227    ///
228    /// let indices = spec.compute_overall(&data).unwrap();
229    /// // When using overall sigma for both, Cp == Pp
230    /// assert!((indices.cp.unwrap() - indices.pp.unwrap()).abs() < 1e-15);
231    /// ```
232    pub fn compute_overall(&self, data: &[f64]) -> Option<CapabilityIndices> {
233        let x_bar = stats::mean(data)?;
234        let sigma_overall = stats::std_dev(data)?;
235
236        Some(self.compute_indices(x_bar, sigma_overall, sigma_overall))
237    }
238
239    /// Internal computation of all indices given mean and sigma values.
240    fn compute_indices(
241        &self,
242        x_bar: f64,
243        sigma_within: f64,
244        sigma_overall: f64,
245    ) -> CapabilityIndices {
246        // Short-term indices (within-group sigma)
247        let cpu = self.usl.map(|u| (u - x_bar) / (3.0 * sigma_within));
248        let cpl = self.lsl.map(|l| (x_bar - l) / (3.0 * sigma_within));
249        let cp = match (self.usl, self.lsl) {
250            (Some(u), Some(l)) => Some((u - l) / (6.0 * sigma_within)),
251            _ => None,
252        };
253        let cpk = match (cpu, cpl) {
254            (Some(u), Some(l)) => Some(u.min(l)),
255            (Some(u), None) => Some(u),
256            (None, Some(l)) => Some(l),
257            (None, None) => None,
258        };
259
260        // Long-term indices (overall sigma)
261        let ppu = self.usl.map(|u| (u - x_bar) / (3.0 * sigma_overall));
262        let ppl = self.lsl.map(|l| (x_bar - l) / (3.0 * sigma_overall));
263        let pp = match (self.usl, self.lsl) {
264            (Some(u), Some(l)) => Some((u - l) / (6.0 * sigma_overall)),
265            _ => None,
266        };
267        let ppk = match (ppu, ppl) {
268            (Some(u), Some(l)) => Some(u.min(l)),
269            (Some(u), None) => Some(u),
270            (None, Some(l)) => Some(l),
271            (None, None) => None,
272        };
273
274        // Taguchi Cpm index
275        let cpm = cp.and_then(|cp_val| {
276            let target = self.target.or_else(|| match (self.usl, self.lsl) {
277                (Some(u), Some(l)) => Some((u + l) / 2.0),
278                _ => None,
279            })?;
280            let deviation_ratio = (x_bar - target) / sigma_within;
281            Some(cp_val / (1.0 + deviation_ratio * deviation_ratio).sqrt())
282        });
283
284        CapabilityIndices {
285            cp,
286            cpk,
287            cpu,
288            cpl,
289            pp,
290            ppk,
291            ppu,
292            ppl,
293            cpm,
294            mean: x_bar,
295            std_dev_within: sigma_within,
296            std_dev_overall: sigma_overall,
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    // -----------------------------------------------------------------------
306    // Construction tests
307    // -----------------------------------------------------------------------
308
309    #[test]
310    fn new_requires_at_least_one_limit() {
311        assert!(ProcessCapability::new(None, None).is_err());
312    }
313
314    #[test]
315    fn new_rejects_usl_leq_lsl() {
316        assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
317        assert!(ProcessCapability::new(Some(5.0), Some(5.0)).is_err());
318    }
319
320    #[test]
321    fn new_rejects_non_finite() {
322        assert!(ProcessCapability::new(Some(f64::NAN), Some(1.0)).is_err());
323        assert!(ProcessCapability::new(Some(10.0), Some(f64::INFINITY)).is_err());
324    }
325
326    #[test]
327    fn new_accepts_valid_two_sided() {
328        assert!(ProcessCapability::new(Some(10.0), Some(5.0)).is_ok());
329    }
330
331    #[test]
332    fn new_accepts_usl_only() {
333        assert!(ProcessCapability::new(Some(10.0), None).is_ok());
334    }
335
336    #[test]
337    fn new_accepts_lsl_only() {
338        assert!(ProcessCapability::new(None, Some(5.0)).is_ok());
339    }
340
341    // -----------------------------------------------------------------------
342    // Computation -- textbook example
343    // -----------------------------------------------------------------------
344
345    /// Textbook example: Montgomery (2019), Example 8.1
346    ///
347    /// LSL = 200, USL = 220, target = 210 (midpoint)
348    /// Process mean ~ 210, sigma_within = 2.0
349    ///
350    /// Cp = (220 - 200) / (6 * 2) = 20/12 = 1.6667
351    #[test]
352    fn textbook_centered_process() {
353        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
354
355        let data = [
356            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0, 209.0, 211.0,
357            210.0, 209.5, 210.5, 210.0, 210.0, 210.0, 209.0, 211.0,
358        ];
359
360        let sigma_within = 2.0;
361        let indices = spec.compute(&data, sigma_within).unwrap();
362
363        // Cp = (220 - 200) / (6 * 2) = 1.6667
364        let cp = indices.cp.unwrap();
365        assert!(
366            (cp - 1.6667).abs() < 0.001,
367            "expected Cp ~ 1.6667, got {cp}"
368        );
369
370        let cpk = indices.cpk.unwrap();
371        assert!(cpk > 0.0, "Cpk should be positive");
372
373        let cpm = indices.cpm.unwrap();
374        assert!(cpm > 0.0, "Cpm should be positive for centered process");
375    }
376
377    /// Off-center process: mean shifted toward USL.
378    ///
379    /// LSL = 200, USL = 220, mean ~ 215, sigma_within = 2.0
380    /// Cp = (220 - 200) / (6 * 2) = 1.6667
381    /// Cpu = (220 - 215) / (3 * 2) = 0.8333
382    /// Cpl = (215 - 200) / (3 * 2) = 2.5
383    /// Cpk = min(0.8333, 2.5) = 0.8333
384    #[test]
385    fn off_center_process() {
386        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
387
388        let data = [
389            213.0, 214.0, 215.0, 216.0, 217.0, 213.5, 214.5, 215.5, 216.5, 215.0, 214.0, 216.0,
390            215.0, 214.5, 215.5, 215.0, 215.0, 215.0, 214.0, 216.0,
391        ];
392
393        let sigma_within = 2.0;
394        let indices = spec.compute(&data, sigma_within).unwrap();
395
396        let cp = indices.cp.unwrap();
397        assert!(
398            (cp - 1.6667).abs() < 0.001,
399            "expected Cp ~ 1.6667, got {cp}"
400        );
401
402        let cpu = indices.cpu.unwrap();
403        assert!(
404            (cpu - 0.8333).abs() < 0.05,
405            "expected Cpu ~ 0.8333, got {cpu}"
406        );
407
408        let cpl = indices.cpl.unwrap();
409        assert!((cpl - 2.5).abs() < 0.05, "expected Cpl ~ 2.5, got {cpl}");
410
411        let cpk = indices.cpk.unwrap();
412        assert!((cpk - cpu).abs() < 1e-15, "Cpk should equal min(Cpu, Cpl)");
413    }
414
415    // -----------------------------------------------------------------------
416    // One-sided specifications
417    // -----------------------------------------------------------------------
418
419    #[test]
420    fn usl_only_computes_cpu_not_cpl() {
421        let spec = ProcessCapability::new(Some(10.0), None).unwrap();
422        let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
423        let indices = spec.compute(&data, 0.5).unwrap();
424
425        assert!(indices.cpu.is_some());
426        assert!(indices.cpl.is_none());
427        assert!(indices.cp.is_none(), "Cp requires both limits");
428        assert!(indices.cpk.is_some(), "Cpk should be Cpu for USL-only");
429        assert!(
430            (indices.cpk.unwrap() - indices.cpu.unwrap()).abs() < 1e-15,
431            "Cpk == Cpu when only USL is set"
432        );
433    }
434
435    #[test]
436    fn lsl_only_computes_cpl_not_cpu() {
437        let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
438        let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
439        let indices = spec.compute(&data, 0.5).unwrap();
440
441        assert!(indices.cpl.is_some());
442        assert!(indices.cpu.is_none());
443        assert!(indices.cp.is_none());
444        assert!(indices.cpk.is_some(), "Cpk should be Cpl for LSL-only");
445        assert!(
446            (indices.cpk.unwrap() - indices.cpl.unwrap()).abs() < 1e-15,
447            "Cpk == Cpl when only LSL is set"
448        );
449    }
450
451    // -----------------------------------------------------------------------
452    // Overall-only computation
453    // -----------------------------------------------------------------------
454
455    #[test]
456    fn compute_overall_matches_pp_equals_cp() {
457        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
458        let data = [
459            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0,
460        ];
461        let indices = spec.compute_overall(&data).unwrap();
462
463        let cp = indices.cp.unwrap();
464        let pp = indices.pp.unwrap();
465        assert!(
466            (cp - pp).abs() < 1e-15,
467            "Cp should equal Pp in compute_overall"
468        );
469
470        let cpk = indices.cpk.unwrap();
471        let ppk = indices.ppk.unwrap();
472        assert!(
473            (cpk - ppk).abs() < 1e-15,
474            "Cpk should equal Ppk in compute_overall"
475        );
476    }
477
478    // -----------------------------------------------------------------------
479    // Cpm with explicit target
480    // -----------------------------------------------------------------------
481
482    #[test]
483    fn cpm_with_explicit_target() {
484        let spec = ProcessCapability::new(Some(220.0), Some(200.0))
485            .unwrap()
486            .with_target(212.0);
487
488        let data = [
489            208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0,
490        ];
491
492        let sigma_within = 2.0;
493        let indices = spec.compute(&data, sigma_within).unwrap();
494
495        let cpm = indices.cpm.unwrap();
496        let cp = indices.cp.unwrap();
497
498        assert!(
499            cpm < cp,
500            "Cpm ({cpm}) should be less than Cp ({cp}) when mean != target"
501        );
502    }
503
504    #[test]
505    fn cpm_equals_cp_when_on_target() {
506        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
507
508        // Data perfectly at midpoint (target = 210)
509        let data = [210.0; 20];
510
511        let sigma_within = 2.0;
512        let indices = spec.compute(&data, sigma_within).unwrap();
513
514        let cpm = indices.cpm.unwrap();
515        let cp = indices.cp.unwrap();
516
517        assert!(
518            (cpm - cp).abs() < 1e-10,
519            "Cpm ({cpm}) should equal Cp ({cp}) when mean == target"
520        );
521    }
522
523    // -----------------------------------------------------------------------
524    // Edge cases
525    // -----------------------------------------------------------------------
526
527    #[test]
528    fn compute_returns_none_for_insufficient_data() {
529        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
530        assert!(spec.compute(&[5.0], 1.0).is_none());
531        assert!(spec.compute(&[], 1.0).is_none());
532    }
533
534    #[test]
535    fn compute_returns_none_for_invalid_sigma() {
536        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
537        let data = [4.0, 5.0, 6.0, 5.0, 5.5];
538        assert!(spec.compute(&data, 0.0).is_none());
539        assert!(spec.compute(&data, -1.0).is_none());
540        assert!(spec.compute(&data, f64::NAN).is_none());
541        assert!(spec.compute(&data, f64::INFINITY).is_none());
542    }
543
544    #[test]
545    fn compute_returns_none_for_nan_data() {
546        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
547        let data = [4.0, f64::NAN, 6.0];
548        assert!(spec.compute(&data, 1.0).is_none());
549    }
550
551    #[test]
552    fn compute_overall_returns_none_for_insufficient_data() {
553        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
554        assert!(spec.compute_overall(&[5.0]).is_none());
555        assert!(spec.compute_overall(&[]).is_none());
556    }
557
558    // -----------------------------------------------------------------------
559    // Pp/Ppk differ from Cp/Cpk when sigmas differ
560    // -----------------------------------------------------------------------
561
562    #[test]
563    fn pp_differs_from_cp_when_sigmas_differ() {
564        let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
565
566        let data = [
567            205.0, 207.0, 210.0, 213.0, 215.0, 206.0, 208.0, 212.0, 214.0, 210.0, 204.0, 216.0,
568            209.0, 211.0, 210.0,
569        ];
570
571        let sigma_within = 1.5;
572        let indices = spec.compute(&data, sigma_within).unwrap();
573
574        let cp = indices.cp.unwrap();
575        let pp = indices.pp.unwrap();
576
577        assert!(
578            pp < cp,
579            "Pp ({pp}) should be < Cp ({cp}) when sigma_overall > sigma_within"
580        );
581    }
582
583    // -----------------------------------------------------------------------
584    // Known numerical example
585    // -----------------------------------------------------------------------
586
587    /// Exact numerical verification.
588    ///
589    /// USL = 10, LSL = 0, sigma_within = 1.0
590    /// Cp = (10 - 0) / (6 * 1) = 1.6667
591    #[test]
592    fn exact_numerical_verification() {
593        let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
594
595        let data = [4.0, 4.5, 5.0, 5.5, 6.0, 4.0, 5.0, 6.0, 5.0, 5.0];
596        let x_bar = stats::mean(&data).unwrap();
597
598        let sigma_within = 1.0;
599        let indices = spec.compute(&data, sigma_within).unwrap();
600
601        let expected_cp = 10.0 / 6.0;
602        let expected_cpu = (10.0 - x_bar) / 3.0;
603        let expected_cpl = (x_bar - 0.0) / 3.0;
604
605        assert!(
606            (indices.cp.unwrap() - expected_cp).abs() < 1e-10,
607            "Cp: expected {expected_cp}, got {}",
608            indices.cp.unwrap()
609        );
610        assert!(
611            (indices.cpu.unwrap() - expected_cpu).abs() < 1e-10,
612            "Cpu: expected {expected_cpu}, got {}",
613            indices.cpu.unwrap()
614        );
615        assert!(
616            (indices.cpl.unwrap() - expected_cpl).abs() < 1e-10,
617            "Cpl: expected {expected_cpl}, got {}",
618            indices.cpl.unwrap()
619        );
620        assert!(
621            (indices.cpk.unwrap() - expected_cpu.min(expected_cpl)).abs() < 1e-10,
622            "Cpk: expected {}, got {}",
623            expected_cpu.min(expected_cpl),
624            indices.cpk.unwrap()
625        );
626    }
627}