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}