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