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}