1use std::fmt;
34
35use u_numflow::distributions::{
36 BetaDistribution, Exponential, GammaDistribution, LogNormal, Normal, Weibull,
37};
38
39use crate::distribution::{
40 fit_best, fit_beta, fit_exponential, fit_gamma, fit_lognormal, fit_normal, fit_weibull,
41 FitResult,
42};
43
44const P_LOWER: f64 = 0.001_349_898_031_630_094_6;
46const P_UPPER: f64 = 1.0 - P_LOWER;
48
49pub const MIN_PERCENTILE_DATA: usize = 20;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54#[non_exhaustive]
55pub enum CapabilityModel {
56 #[default]
58 Auto,
59 Normal,
61 LogNormal,
63 Weibull,
65 Gamma,
67 Exponential,
69 Beta,
71}
72
73impl CapabilityModel {
74 fn of_fit(name: &str) -> Option<Self> {
76 Some(match name {
77 "Normal" => Self::Normal,
78 "LogNormal" => Self::LogNormal,
79 "Weibull" => Self::Weibull,
80 "Gamma" => Self::Gamma,
81 "Exponential" => Self::Exponential,
82 "Beta" => Self::Beta,
83 _ => return None,
84 })
85 }
86
87 fn fit(self, data: &[f64]) -> Option<FitResult> {
88 match self {
89 Self::Auto => fit_best(data)
90 .into_iter()
91 .find(|f| Self::of_fit(&f.distribution).is_some()),
92 Self::Normal => fit_normal(data),
93 Self::LogNormal => fit_lognormal(data),
94 Self::Weibull => fit_weibull(data),
95 Self::Gamma => fit_gamma(data),
96 Self::Exponential => fit_exponential(data),
97 Self::Beta => fit_beta(data),
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq)]
104#[non_exhaustive]
105pub enum PercentileCapabilityError {
106 NoSpecification,
108 InvalidSpecification,
110 InsufficientData {
112 got: usize,
114 },
115 NonFiniteData {
117 index: usize,
119 },
120 NoVariation,
122 ModelNotFitted(CapabilityModel),
125}
126
127impl fmt::Display for PercentileCapabilityError {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Self::NoSpecification => {
131 write!(f, "at least one specification limit (USL or LSL) is required")
132 }
133 Self::InvalidSpecification => write!(f, "USL must be greater than LSL"),
134 Self::InsufficientData { got } => write!(
135 f,
136 "at least {MIN_PERCENTILE_DATA} data points are required for percentile capability, got {got}"
137 ),
138 Self::NonFiniteData { index } => {
139 write!(f, "data[{index}] is not a finite number")
140 }
141 Self::NoVariation => write!(f, "data has no variation"),
142 Self::ModelNotFitted(model) => {
143 write!(f, "the {model:?} distribution cannot be fitted to this data")
144 }
145 }
146 }
147}
148
149impl std::error::Error for PercentileCapabilityError {}
150
151#[derive(Debug, Clone)]
153pub struct PercentileCapabilityResult {
154 pub cp_star: Option<f64>,
156 pub cpk_star: Option<f64>,
158 pub cpu_star: Option<f64>,
160 pub cpl_star: Option<f64>,
162 pub median: f64,
164 pub percentile_lower: f64,
166 pub percentile_upper: f64,
168 pub model: CapabilityModel,
170 pub parameters: Vec<(String, f64)>,
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(fitted, &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 parameters: fit.parameters,
256 })
257}
258
259fn quantiles(model: CapabilityModel, fit: &FitResult) -> Option<(f64, f64, f64)> {
261 let param = |name: &str| {
262 fit.parameters
263 .iter()
264 .find(|(n, _)| n == name)
265 .map(|&(_, v)| v)
266 };
267 let q: Box<dyn Fn(f64) -> Option<f64>> = match model {
268 CapabilityModel::Normal => {
269 let d = Normal::new(param("mu")?, param("sigma")?).ok()?;
270 Box::new(move |p| d.quantile(p))
271 }
272 CapabilityModel::LogNormal => {
273 let d = LogNormal::new(param("mu")?, param("sigma")?).ok()?;
274 Box::new(move |p| d.quantile(p))
275 }
276 CapabilityModel::Weibull => {
277 let d = Weibull::new(param("shape")?, param("scale")?).ok()?;
278 Box::new(move |p| d.quantile(p))
279 }
280 CapabilityModel::Gamma => {
281 let d = GammaDistribution::new(param("alpha")?, param("beta")?).ok()?;
283 Box::new(move |p| d.quantile(p))
284 }
285 CapabilityModel::Exponential => {
286 let d = Exponential::new(param("lambda")?).ok()?;
287 Box::new(move |p| d.quantile(p))
288 }
289 CapabilityModel::Beta => {
290 let d = BetaDistribution::new(param("alpha")?, param("beta")?).ok()?;
291 Box::new(move |p| d.quantile(p))
292 }
293 CapabilityModel::Auto => return None,
294 };
295 let (lo, mid, hi) = (q(P_LOWER)?, q(0.5)?, q(P_UPPER)?);
296 (lo.is_finite() && mid.is_finite() && hi.is_finite()).then_some((lo, mid, hi))
297}
298
299#[cfg(test)]
304mod tests {
305 use super::*;
306 use u_numflow::distributions::Sample;
307
308 fn uniform_data(n: usize) -> Vec<f64> {
310 (0..n)
311 .map(|i| 10.0 + (i as f64) / (n as f64 - 1.0) * 10.0)
312 .collect()
313 }
314
315 fn pc(
316 data: &[f64],
317 lsl: Option<f64>,
318 usl: Option<f64>,
319 ) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
320 percentile_capability(data, lsl, usl, CapabilityModel::Auto)
321 }
322
323 #[test]
324 fn basic_two_sided() {
325 let data = uniform_data(100);
326 let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
327
328 assert!(result.cp_star.is_some());
329 assert!(result.cpk_star.is_some());
330 assert!(result.cpu_star.is_some());
331 assert!(result.cpl_star.is_some());
332 assert!(result.cp_star.unwrap() > 0.0);
333 assert!(
334 (result.median - 15.0).abs() < 0.5,
335 "median should be ~15, got {}",
336 result.median
337 );
338 }
339
340 #[test]
341 fn usl_only() {
342 let data = uniform_data(50);
343 let result = pc(&data, None, Some(25.0)).unwrap();
344
345 assert!(result.cp_star.is_none(), "Cp* requires both limits");
346 assert!(result.cpu_star.is_some());
347 assert!(result.cpl_star.is_none());
348 assert_eq!(result.cpk_star, result.cpu_star);
349 }
350
351 #[test]
352 fn lsl_only() {
353 let data = uniform_data(50);
354 let result = pc(&data, Some(5.0), None).unwrap();
355
356 assert!(result.cp_star.is_none());
357 assert!(result.cpu_star.is_none());
358 assert!(result.cpl_star.is_some());
359 assert_eq!(result.cpk_star, result.cpl_star);
360 }
361
362 #[test]
363 fn refusals_name_their_reason() {
364 let few: Vec<f64> = (0..19).map(|i| i as f64).collect();
365 assert_eq!(
366 pc(&few, Some(0.0), Some(20.0)).unwrap_err(),
367 PercentileCapabilityError::InsufficientData { got: 19 }
368 );
369 let data = uniform_data(30);
370 assert_eq!(
371 pc(&data, None, None).unwrap_err(),
372 PercentileCapabilityError::NoSpecification
373 );
374 assert_eq!(
375 pc(&data, Some(10.0), Some(5.0)).unwrap_err(),
376 PercentileCapabilityError::InvalidSpecification
377 );
378 assert_eq!(
379 pc(&data, Some(10.0), Some(10.0)).unwrap_err(),
380 PercentileCapabilityError::InvalidSpecification
381 );
382 let mut nan = uniform_data(30);
383 nan[10] = f64::NAN;
384 assert_eq!(
385 pc(&nan, Some(0.0), Some(30.0)).unwrap_err(),
386 PercentileCapabilityError::NonFiniteData { index: 10 }
387 );
388 assert_eq!(
389 pc(&[3.0; 25], Some(0.0), Some(30.0)).unwrap_err(),
390 PercentileCapabilityError::NoVariation
391 );
392 let mut negative = uniform_data(30);
393 negative[0] = -1.0;
394 assert_eq!(
395 percentile_capability(
396 &negative,
397 Some(-5.0),
398 Some(30.0),
399 CapabilityModel::LogNormal
400 )
401 .unwrap_err(),
402 PercentileCapabilityError::ModelNotFitted(CapabilityModel::LogNormal)
403 );
404 }
405
406 #[test]
407 fn cpk_star_equals_min_of_cpu_cpl() {
408 let data = uniform_data(100);
409 let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
410 let (cpu, cpl, cpk) = (
411 result.cpu_star.unwrap(),
412 result.cpl_star.unwrap(),
413 result.cpk_star.unwrap(),
414 );
415 assert!((cpk - cpu.min(cpl)).abs() < 1e-10);
416 }
417
418 #[test]
419 fn symmetric_data_gives_balanced_cpu_cpl() {
420 let data: Vec<f64> = (0..200).map(|i| 40.0 + (i as f64) * 0.1).collect();
421 let result = pc(&data, Some(30.0), Some(70.0)).unwrap();
422 let (cpu, cpl) = (result.cpu_star.unwrap(), result.cpl_star.unwrap());
423 assert!(
424 (cpu - cpl).abs() < cpu * 0.2,
425 "symmetric data should give similar Cpu* ({cpu}) and Cpl* ({cpl})"
426 );
427 }
428
429 #[test]
434 fn small_samples_do_not_inflate_cp_star() {
435 let process = Normal::new(10.0, 1.0).unwrap();
436 for (n, empirical_at_least) in [(20_usize, 1.5), (50, 1.25), (100, 1.15)] {
437 let reps = 400;
438 let (mut sum, mut empirical) = (0.0, 0.0);
439 for rep in 0..reps {
440 let data = process.sample_n(n, 1_000 + rep as u64 * 7 + n as u64);
441 let r =
442 percentile_capability(&data, Some(7.0), Some(13.0), CapabilityModel::Normal)
443 .unwrap();
444 sum += r.cp_star.unwrap();
445 let lo = u_numflow::stats::quantile(&data, P_LOWER).unwrap();
447 let hi = u_numflow::stats::quantile(&data, P_UPPER).unwrap();
448 empirical += 6.0 / (hi - lo);
449 }
450 let mean = sum / reps as f64;
451 let empirical = empirical / reps as f64;
452 assert!(
453 empirical > empirical_at_least,
454 "n = {n}: the empirical Cp* {empirical} no longer shows the bias this guards"
455 );
456 assert!(
460 (mean - 1.0).abs() < 0.08,
461 "n = {n}: mean Cp* {mean} (true 1.0)"
462 );
463 }
464 }
465
466 #[test]
467 fn quantiles_are_the_fitted_model_s() {
468 let process = Normal::new(50.0, 2.0).unwrap();
469 let data = process.sample_n(200, 42);
470 let r =
471 percentile_capability(&data, Some(40.0), Some(60.0), CapabilityModel::Normal).unwrap();
472 let mu = r.parameters.iter().find(|(n, _)| n == "mu").unwrap().1;
473 let sigma = r.parameters.iter().find(|(n, _)| n == "sigma").unwrap().1;
474 assert_eq!(r.model, CapabilityModel::Normal);
475 assert!((r.median - mu).abs() < 1e-9);
476 assert!((r.percentile_lower - (mu - 3.0 * sigma)).abs() < 1e-6);
477 assert!((r.percentile_upper - (mu + 3.0 * sigma)).abs() < 1e-6);
478 }
479
480 #[test]
481 fn auto_reports_the_family_it_used() {
482 let process = LogNormal::new(1.0, 0.6).unwrap();
483 let data = process.sample_n(300, 7);
484 let r = pc(&data, None, Some(30.0)).unwrap();
485 assert_ne!(r.model, CapabilityModel::Auto);
486 assert_eq!(
487 CapabilityModel::of_fit(&fit_best(&data)[0].distribution),
488 Some(r.model)
489 );
490 }
491}
492
493#[cfg(test)]
499mod scipy_reference {
500 use super::*;
501
502 const DATA: [f64; 40] = [
503 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,
504 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,
505 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,
506 2.07,
507 ];
508
509 fn close(got: f64, want: f64, rel: f64, what: &str) {
510 assert!(
511 (got - want).abs() <= rel * want.abs(),
512 "{what}: got {got}, SciPy {want}"
513 );
514 }
515
516 fn check(model: CapabilityModel, rel: f64, want: [f64; 7]) {
517 let r = percentile_capability(&DATA, Some(0.9), Some(9.0), model).expect("valid");
518 assert_eq!(r.model, model);
519 let [lo, med, hi, cp, cpk, cpu, cpl] = want;
520 close(r.percentile_lower, lo, rel, "X0.135");
521 close(r.median, med, rel, "X50");
522 close(r.percentile_upper, hi, rel, "X99.865");
523 close(r.cp_star.expect("both limits"), cp, rel, "Cp*");
524 close(r.cpk_star.expect("limits"), cpk, rel, "Cpk*");
525 close(r.cpu_star.expect("USL"), cpu, rel, "Cpu*");
526 close(r.cpl_star.expect("LSL"), cpl, rel, "Cpl*");
527 }
528
529 #[test]
530 fn log_normal_percentile_capability_reproduces_scipy() {
531 check(
533 CapabilityModel::LogNormal,
534 1e-9,
535 [
536 0.7542968892328896,
537 2.8798090991738787,
538 10.994743006455773,
539 0.7909811650077455,
540 0.754188631817962,
541 0.754188631817962,
542 0.9314503534321473,
543 ],
544 );
545 }
546
547 #[test]
548 fn weibull_percentile_capability_reproduces_scipy() {
549 check(
554 CapabilityModel::Weibull,
555 1e-9,
556 [
557 0.23258289429884546,
558 3.0872615285886305,
559 7.858145308508201,
560 1.0622167336676158,
561 0.7662023676906101,
562 1.2393381906090049,
563 0.7662023676906101,
564 ],
565 );
566 }
567}