1use std::fmt;
34
35use crate::distribution::{
36 fit_best, fit_beta, fit_exponential, fit_gamma, fit_lognormal, fit_normal, fit_weibull,
37 fitted_quantile, FitResult,
38};
39
40const P_LOWER: f64 = 0.001_349_898_031_630_094_6;
42const P_UPPER: f64 = 1.0 - P_LOWER;
44
45pub const MIN_PERCENTILE_DATA: usize = 20;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50#[non_exhaustive]
51pub enum CapabilityModel {
52 #[default]
54 Auto,
55 Normal,
57 LogNormal,
59 Weibull,
61 Gamma,
63 Exponential,
65 Beta,
67}
68
69impl CapabilityModel {
70 fn of_fit(name: &str) -> Option<Self> {
72 Some(match name {
73 "Normal" => Self::Normal,
74 "LogNormal" => Self::LogNormal,
75 "Weibull" => Self::Weibull,
76 "Gamma" => Self::Gamma,
77 "Exponential" => Self::Exponential,
78 "Beta" => Self::Beta,
79 _ => return None,
80 })
81 }
82
83 pub(crate) fn fit(self, data: &[f64]) -> Option<FitResult> {
84 match self {
85 Self::Auto => fit_best(data)
86 .into_iter()
87 .find(|f| Self::of_fit(&f.distribution).is_some()),
88 Self::Normal => fit_normal(data),
89 Self::LogNormal => fit_lognormal(data),
90 Self::Weibull => fit_weibull(data),
91 Self::Gamma => fit_gamma(data),
92 Self::Exponential => fit_exponential(data),
93 Self::Beta => fit_beta(data),
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq)]
100#[non_exhaustive]
101pub enum PercentileCapabilityError {
102 NoSpecification,
104 InvalidSpecification,
106 InsufficientData {
108 got: usize,
110 },
111 NonFiniteData {
113 index: usize,
115 },
116 NoVariation,
118 ModelNotFitted(CapabilityModel),
121}
122
123impl fmt::Display for PercentileCapabilityError {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 match self {
126 Self::NoSpecification => {
127 write!(f, "at least one specification limit (USL or LSL) is required")
128 }
129 Self::InvalidSpecification => write!(f, "USL must be greater than LSL"),
130 Self::InsufficientData { got } => write!(
131 f,
132 "at least {MIN_PERCENTILE_DATA} data points are required for percentile capability, got {got}"
133 ),
134 Self::NonFiniteData { index } => {
135 write!(f, "data[{index}] is not a finite number")
136 }
137 Self::NoVariation => write!(f, "data has no variation"),
138 Self::ModelNotFitted(model) => {
139 write!(f, "the {model:?} distribution cannot be fitted to this data")
140 }
141 }
142 }
143}
144
145impl std::error::Error for PercentileCapabilityError {}
146
147#[derive(Debug, Clone)]
149pub struct PercentileCapabilityResult {
150 pub cp_star: Option<f64>,
152 pub cpk_star: Option<f64>,
154 pub cpu_star: Option<f64>,
156 pub cpl_star: Option<f64>,
158 pub median: f64,
160 pub percentile_lower: f64,
162 pub percentile_upper: f64,
164 pub model: CapabilityModel,
166 pub fit: FitResult,
172}
173
174pub fn percentile_capability(
197 data: &[f64],
198 lsl: Option<f64>,
199 usl: Option<f64>,
200 model: CapabilityModel,
201) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
202 if usl.is_none() && lsl.is_none() {
203 return Err(PercentileCapabilityError::NoSpecification);
204 }
205 if let (Some(u), Some(l)) = (usl, lsl) {
206 if u <= l {
207 return Err(PercentileCapabilityError::InvalidSpecification);
208 }
209 }
210 if data.len() < MIN_PERCENTILE_DATA {
211 return Err(PercentileCapabilityError::InsufficientData { got: data.len() });
212 }
213 if let Some(index) = data.iter().position(|v| !v.is_finite()) {
214 return Err(PercentileCapabilityError::NonFiniteData { index });
215 }
216 if data.iter().all(|&v| v == data[0]) {
217 return Err(PercentileCapabilityError::NoVariation);
218 }
219
220 let fit = model
221 .fit(data)
222 .ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
223 let fitted = CapabilityModel::of_fit(&fit.distribution)
224 .ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
225 let (p_lower, median, p_upper) =
226 quantiles(&fit).ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
227
228 let spread = p_upper - p_lower;
229 if spread <= 0.0 {
230 return Err(PercentileCapabilityError::NoVariation);
231 }
232 let upper_spread = p_upper - median;
233 let lower_spread = median - p_lower;
234
235 let cp_star = match (usl, lsl) {
236 (Some(u), Some(l)) => Some((u - l) / spread),
237 _ => None,
238 };
239 let cpu_star = usl.and_then(|u| (upper_spread > 0.0).then(|| (u - median) / upper_spread));
240 let cpl_star = lsl.and_then(|l| (lower_spread > 0.0).then(|| (median - l) / lower_spread));
241 let cpk_star = match (cpu_star, cpl_star) {
242 (Some(u), Some(l)) => Some(u.min(l)),
243 (one, other) => one.or(other),
244 };
245
246 Ok(PercentileCapabilityResult {
247 cp_star,
248 cpk_star,
249 cpu_star,
250 cpl_star,
251 median,
252 percentile_lower: p_lower,
253 percentile_upper: p_upper,
254 model: fitted,
255 fit,
256 })
257}
258
259fn quantiles(fit: &FitResult) -> Option<(f64, f64, f64)> {
261 let q = |p| fitted_quantile(fit, p);
262 let (lo, mid, hi) = (q(P_LOWER)?, q(0.5)?, q(P_UPPER)?);
263 (lo.is_finite() && mid.is_finite() && hi.is_finite()).then_some((lo, mid, hi))
264}
265
266#[cfg(test)]
271mod tests {
272 use super::*;
273 use u_numflow::distributions::Sample;
274 use u_numflow::distributions::{LogNormal, Normal};
275
276 fn uniform_data(n: usize) -> Vec<f64> {
278 (0..n)
279 .map(|i| 10.0 + (i as f64) / (n as f64 - 1.0) * 10.0)
280 .collect()
281 }
282
283 fn pc(
284 data: &[f64],
285 lsl: Option<f64>,
286 usl: Option<f64>,
287 ) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
288 percentile_capability(data, lsl, usl, CapabilityModel::Auto)
289 }
290
291 #[test]
292 fn basic_two_sided() {
293 let data = uniform_data(100);
294 let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
295
296 assert!(result.cp_star.is_some());
297 assert!(result.cpk_star.is_some());
298 assert!(result.cpu_star.is_some());
299 assert!(result.cpl_star.is_some());
300 assert!(result.cp_star.unwrap() > 0.0);
301 assert!(
302 (result.median - 15.0).abs() < 0.5,
303 "median should be ~15, got {}",
304 result.median
305 );
306 }
307
308 #[test]
309 fn usl_only() {
310 let data = uniform_data(50);
311 let result = pc(&data, None, Some(25.0)).unwrap();
312
313 assert!(result.cp_star.is_none(), "Cp* requires both limits");
314 assert!(result.cpu_star.is_some());
315 assert!(result.cpl_star.is_none());
316 assert_eq!(result.cpk_star, result.cpu_star);
317 }
318
319 #[test]
320 fn lsl_only() {
321 let data = uniform_data(50);
322 let result = pc(&data, Some(5.0), None).unwrap();
323
324 assert!(result.cp_star.is_none());
325 assert!(result.cpu_star.is_none());
326 assert!(result.cpl_star.is_some());
327 assert_eq!(result.cpk_star, result.cpl_star);
328 }
329
330 #[test]
331 fn refusals_name_their_reason() {
332 let few: Vec<f64> = (0..19).map(|i| i as f64).collect();
333 assert_eq!(
334 pc(&few, Some(0.0), Some(20.0)).unwrap_err(),
335 PercentileCapabilityError::InsufficientData { got: 19 }
336 );
337 let data = uniform_data(30);
338 assert_eq!(
339 pc(&data, None, None).unwrap_err(),
340 PercentileCapabilityError::NoSpecification
341 );
342 assert_eq!(
343 pc(&data, Some(10.0), Some(5.0)).unwrap_err(),
344 PercentileCapabilityError::InvalidSpecification
345 );
346 assert_eq!(
347 pc(&data, Some(10.0), Some(10.0)).unwrap_err(),
348 PercentileCapabilityError::InvalidSpecification
349 );
350 let mut nan = uniform_data(30);
351 nan[10] = f64::NAN;
352 assert_eq!(
353 pc(&nan, Some(0.0), Some(30.0)).unwrap_err(),
354 PercentileCapabilityError::NonFiniteData { index: 10 }
355 );
356 assert_eq!(
357 pc(&[3.0; 25], Some(0.0), Some(30.0)).unwrap_err(),
358 PercentileCapabilityError::NoVariation
359 );
360 let mut negative = uniform_data(30);
361 negative[0] = -1.0;
362 assert_eq!(
363 percentile_capability(
364 &negative,
365 Some(-5.0),
366 Some(30.0),
367 CapabilityModel::LogNormal
368 )
369 .unwrap_err(),
370 PercentileCapabilityError::ModelNotFitted(CapabilityModel::LogNormal)
371 );
372 }
373
374 #[test]
375 fn cpk_star_equals_min_of_cpu_cpl() {
376 let data = uniform_data(100);
377 let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
378 let (cpu, cpl, cpk) = (
379 result.cpu_star.unwrap(),
380 result.cpl_star.unwrap(),
381 result.cpk_star.unwrap(),
382 );
383 assert!((cpk - cpu.min(cpl)).abs() < 1e-10);
384 }
385
386 #[test]
387 fn symmetric_data_gives_balanced_cpu_cpl() {
388 let data: Vec<f64> = (0..200).map(|i| 40.0 + (i as f64) * 0.1).collect();
389 let result = pc(&data, Some(30.0), Some(70.0)).unwrap();
390 let (cpu, cpl) = (result.cpu_star.unwrap(), result.cpl_star.unwrap());
391 assert!(
392 (cpu - cpl).abs() < cpu * 0.2,
393 "symmetric data should give similar Cpu* ({cpu}) and Cpl* ({cpl})"
394 );
395 }
396
397 #[test]
402 fn small_samples_do_not_inflate_cp_star() {
403 let process = Normal::new(10.0, 1.0).unwrap();
404 for (n, empirical_at_least) in [(20_usize, 1.5), (50, 1.25), (100, 1.15)] {
405 let reps = 400;
406 let (mut sum, mut empirical) = (0.0, 0.0);
407 for rep in 0..reps {
408 let data = process.sample_n(n, 1_000 + rep as u64 * 7 + n as u64);
409 let r =
410 percentile_capability(&data, Some(7.0), Some(13.0), CapabilityModel::Normal)
411 .unwrap();
412 sum += r.cp_star.unwrap();
413 let lo = u_numflow::stats::quantile(&data, P_LOWER).unwrap();
415 let hi = u_numflow::stats::quantile(&data, P_UPPER).unwrap();
416 empirical += 6.0 / (hi - lo);
417 }
418 let mean = sum / reps as f64;
419 let empirical = empirical / reps as f64;
420 assert!(
421 empirical > empirical_at_least,
422 "n = {n}: the empirical Cp* {empirical} no longer shows the bias this guards"
423 );
424 assert!(
428 (mean - 1.0).abs() < 0.08,
429 "n = {n}: mean Cp* {mean} (true 1.0)"
430 );
431 }
432 }
433
434 #[test]
435 fn quantiles_are_the_fitted_model_s() {
436 let process = Normal::new(50.0, 2.0).unwrap();
437 let data = process.sample_n(200, 42);
438 let r =
439 percentile_capability(&data, Some(40.0), Some(60.0), CapabilityModel::Normal).unwrap();
440 let mu = r.fit.parameters.iter().find(|(n, _)| n == "mu").unwrap().1;
441 let sigma = r
442 .fit
443 .parameters
444 .iter()
445 .find(|(n, _)| n == "sigma")
446 .unwrap()
447 .1;
448 assert_eq!(r.model, CapabilityModel::Normal);
449 assert!((r.median - mu).abs() < 1e-9);
450 assert!((r.percentile_lower - (mu - 3.0 * sigma)).abs() < 1e-6);
451 assert!((r.percentile_upper - (mu + 3.0 * sigma)).abs() < 1e-6);
452 }
453
454 #[test]
455 fn auto_reports_the_family_it_used() {
456 let process = LogNormal::new(1.0, 0.6).unwrap();
457 let data = process.sample_n(300, 7);
458 let r = pc(&data, None, Some(30.0)).unwrap();
459 assert_ne!(r.model, CapabilityModel::Auto);
460 assert_eq!(
461 CapabilityModel::of_fit(&fit_best(&data)[0].distribution),
462 Some(r.model)
463 );
464 }
465}
466
467#[cfg(test)]
473mod scipy_reference {
474 use super::*;
475
476 const DATA: [f64; 40] = [
477 3.195, 5.365, 1.217, 5.806, 2.661, 1.896, 1.894, 1.67, 2.458, 3.956, 3.535, 3.623, 1.268,
478 1.341, 5.47, 4.204, 7.26, 4.685, 1.714, 4.847, 3.607, 2.994, 1.88, 2.722, 2.544, 4.062,
479 2.875, 5.546, 1.918, 3.36, 3.402, 1.842, 1.597, 4.289, 3.431, 2.527, 3.246, 2.006, 3.026,
480 2.07,
481 ];
482
483 fn close(got: f64, want: f64, rel: f64, what: &str) {
484 assert!(
485 (got - want).abs() <= rel * want.abs(),
486 "{what}: got {got}, SciPy {want}"
487 );
488 }
489
490 fn check(model: CapabilityModel, rel: f64, want: [f64; 7]) {
491 let r = percentile_capability(&DATA, Some(0.9), Some(9.0), model).expect("valid");
492 assert_eq!(r.model, model);
493 let [lo, med, hi, cp, cpk, cpu, cpl] = want;
494 close(r.percentile_lower, lo, rel, "X0.135");
495 close(r.median, med, rel, "X50");
496 close(r.percentile_upper, hi, rel, "X99.865");
497 close(r.cp_star.expect("both limits"), cp, rel, "Cp*");
498 close(r.cpk_star.expect("limits"), cpk, rel, "Cpk*");
499 close(r.cpu_star.expect("USL"), cpu, rel, "Cpu*");
500 close(r.cpl_star.expect("LSL"), cpl, rel, "Cpl*");
501 }
502
503 #[test]
504 fn log_normal_percentile_capability_reproduces_scipy() {
505 check(
507 CapabilityModel::LogNormal,
508 1e-9,
509 [
510 0.7542968892328896,
511 2.8798090991738787,
512 10.994743006455773,
513 0.7909811650077455,
514 0.754188631817962,
515 0.754188631817962,
516 0.9314503534321473,
517 ],
518 );
519 }
520
521 #[test]
522 fn weibull_percentile_capability_reproduces_scipy() {
523 check(
528 CapabilityModel::Weibull,
529 1e-9,
530 [
531 0.23258289429884546,
532 3.0872615285886305,
533 7.858145308508201,
534 1.0622167336676158,
535 0.7662023676906101,
536 1.2393381906090049,
537 0.7662023676906101,
538 ],
539 );
540 }
541}