use std::fmt;
use u_numflow::distributions::{
BetaDistribution, Exponential, GammaDistribution, LogNormal, Normal, Weibull,
};
use crate::distribution::{
fit_best, fit_beta, fit_exponential, fit_gamma, fit_lognormal, fit_normal, fit_weibull,
FitResult,
};
const P_LOWER: f64 = 0.001_349_898_031_630_094_6;
const P_UPPER: f64 = 1.0 - P_LOWER;
pub const MIN_PERCENTILE_DATA: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CapabilityModel {
#[default]
Auto,
Normal,
LogNormal,
Weibull,
Gamma,
Exponential,
Beta,
}
impl CapabilityModel {
fn of_fit(name: &str) -> Option<Self> {
Some(match name {
"Normal" => Self::Normal,
"LogNormal" => Self::LogNormal,
"Weibull" => Self::Weibull,
"Gamma" => Self::Gamma,
"Exponential" => Self::Exponential,
"Beta" => Self::Beta,
_ => return None,
})
}
fn fit(self, data: &[f64]) -> Option<FitResult> {
match self {
Self::Auto => fit_best(data)
.into_iter()
.find(|f| Self::of_fit(&f.distribution).is_some()),
Self::Normal => fit_normal(data),
Self::LogNormal => fit_lognormal(data),
Self::Weibull => fit_weibull(data),
Self::Gamma => fit_gamma(data),
Self::Exponential => fit_exponential(data),
Self::Beta => fit_beta(data),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum PercentileCapabilityError {
NoSpecification,
InvalidSpecification,
InsufficientData {
got: usize,
},
NonFiniteData {
index: usize,
},
NoVariation,
ModelNotFitted(CapabilityModel),
}
impl fmt::Display for PercentileCapabilityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoSpecification => {
write!(f, "at least one specification limit (USL or LSL) is required")
}
Self::InvalidSpecification => write!(f, "USL must be greater than LSL"),
Self::InsufficientData { got } => write!(
f,
"at least {MIN_PERCENTILE_DATA} data points are required for percentile capability, got {got}"
),
Self::NonFiniteData { index } => {
write!(f, "data[{index}] is not a finite number")
}
Self::NoVariation => write!(f, "data has no variation"),
Self::ModelNotFitted(model) => {
write!(f, "the {model:?} distribution cannot be fitted to this data")
}
}
}
}
impl std::error::Error for PercentileCapabilityError {}
#[derive(Debug, Clone)]
pub struct PercentileCapabilityResult {
pub cp_star: Option<f64>,
pub cpk_star: Option<f64>,
pub cpu_star: Option<f64>,
pub cpl_star: Option<f64>,
pub median: f64,
pub percentile_lower: f64,
pub percentile_upper: f64,
pub model: CapabilityModel,
pub parameters: Vec<(String, f64)>,
}
pub fn percentile_capability(
data: &[f64],
lsl: Option<f64>,
usl: Option<f64>,
model: CapabilityModel,
) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
if usl.is_none() && lsl.is_none() {
return Err(PercentileCapabilityError::NoSpecification);
}
if let (Some(u), Some(l)) = (usl, lsl) {
if u <= l {
return Err(PercentileCapabilityError::InvalidSpecification);
}
}
if data.len() < MIN_PERCENTILE_DATA {
return Err(PercentileCapabilityError::InsufficientData { got: data.len() });
}
if let Some(index) = data.iter().position(|v| !v.is_finite()) {
return Err(PercentileCapabilityError::NonFiniteData { index });
}
if data.iter().all(|&v| v == data[0]) {
return Err(PercentileCapabilityError::NoVariation);
}
let fit = model
.fit(data)
.ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
let fitted = CapabilityModel::of_fit(&fit.distribution)
.ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
let (p_lower, median, p_upper) =
quantiles(fitted, &fit).ok_or(PercentileCapabilityError::ModelNotFitted(model))?;
let spread = p_upper - p_lower;
if spread <= 0.0 {
return Err(PercentileCapabilityError::NoVariation);
}
let upper_spread = p_upper - median;
let lower_spread = median - p_lower;
let cp_star = match (usl, lsl) {
(Some(u), Some(l)) => Some((u - l) / spread),
_ => None,
};
let cpu_star = usl.and_then(|u| (upper_spread > 0.0).then(|| (u - median) / upper_spread));
let cpl_star = lsl.and_then(|l| (lower_spread > 0.0).then(|| (median - l) / lower_spread));
let cpk_star = match (cpu_star, cpl_star) {
(Some(u), Some(l)) => Some(u.min(l)),
(one, other) => one.or(other),
};
Ok(PercentileCapabilityResult {
cp_star,
cpk_star,
cpu_star,
cpl_star,
median,
percentile_lower: p_lower,
percentile_upper: p_upper,
model: fitted,
parameters: fit.parameters,
})
}
fn quantiles(model: CapabilityModel, fit: &FitResult) -> Option<(f64, f64, f64)> {
let param = |name: &str| {
fit.parameters
.iter()
.find(|(n, _)| n == name)
.map(|&(_, v)| v)
};
let q: Box<dyn Fn(f64) -> Option<f64>> = match model {
CapabilityModel::Normal => {
let d = Normal::new(param("mu")?, param("sigma")?).ok()?;
Box::new(move |p| d.quantile(p))
}
CapabilityModel::LogNormal => {
let d = LogNormal::new(param("mu")?, param("sigma")?).ok()?;
Box::new(move |p| d.quantile(p))
}
CapabilityModel::Weibull => {
let d = Weibull::new(param("shape")?, param("scale")?).ok()?;
Box::new(move |p| d.quantile(p))
}
CapabilityModel::Gamma => {
let d = GammaDistribution::new(param("alpha")?, param("beta")?).ok()?;
Box::new(move |p| d.quantile(p))
}
CapabilityModel::Exponential => {
let d = Exponential::new(param("lambda")?).ok()?;
Box::new(move |p| d.quantile(p))
}
CapabilityModel::Beta => {
let d = BetaDistribution::new(param("alpha")?, param("beta")?).ok()?;
Box::new(move |p| d.quantile(p))
}
CapabilityModel::Auto => return None,
};
let (lo, mid, hi) = (q(P_LOWER)?, q(0.5)?, q(P_UPPER)?);
(lo.is_finite() && mid.is_finite() && hi.is_finite()).then_some((lo, mid, hi))
}
#[cfg(test)]
mod tests {
use super::*;
use u_numflow::distributions::Sample;
fn uniform_data(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 10.0 + (i as f64) / (n as f64 - 1.0) * 10.0)
.collect()
}
fn pc(
data: &[f64],
lsl: Option<f64>,
usl: Option<f64>,
) -> Result<PercentileCapabilityResult, PercentileCapabilityError> {
percentile_capability(data, lsl, usl, CapabilityModel::Auto)
}
#[test]
fn basic_two_sided() {
let data = uniform_data(100);
let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
assert!(result.cp_star.is_some());
assert!(result.cpk_star.is_some());
assert!(result.cpu_star.is_some());
assert!(result.cpl_star.is_some());
assert!(result.cp_star.unwrap() > 0.0);
assert!(
(result.median - 15.0).abs() < 0.5,
"median should be ~15, got {}",
result.median
);
}
#[test]
fn usl_only() {
let data = uniform_data(50);
let result = pc(&data, None, Some(25.0)).unwrap();
assert!(result.cp_star.is_none(), "Cp* requires both limits");
assert!(result.cpu_star.is_some());
assert!(result.cpl_star.is_none());
assert_eq!(result.cpk_star, result.cpu_star);
}
#[test]
fn lsl_only() {
let data = uniform_data(50);
let result = pc(&data, Some(5.0), None).unwrap();
assert!(result.cp_star.is_none());
assert!(result.cpu_star.is_none());
assert!(result.cpl_star.is_some());
assert_eq!(result.cpk_star, result.cpl_star);
}
#[test]
fn refusals_name_their_reason() {
let few: Vec<f64> = (0..19).map(|i| i as f64).collect();
assert_eq!(
pc(&few, Some(0.0), Some(20.0)).unwrap_err(),
PercentileCapabilityError::InsufficientData { got: 19 }
);
let data = uniform_data(30);
assert_eq!(
pc(&data, None, None).unwrap_err(),
PercentileCapabilityError::NoSpecification
);
assert_eq!(
pc(&data, Some(10.0), Some(5.0)).unwrap_err(),
PercentileCapabilityError::InvalidSpecification
);
assert_eq!(
pc(&data, Some(10.0), Some(10.0)).unwrap_err(),
PercentileCapabilityError::InvalidSpecification
);
let mut nan = uniform_data(30);
nan[10] = f64::NAN;
assert_eq!(
pc(&nan, Some(0.0), Some(30.0)).unwrap_err(),
PercentileCapabilityError::NonFiniteData { index: 10 }
);
assert_eq!(
pc(&[3.0; 25], Some(0.0), Some(30.0)).unwrap_err(),
PercentileCapabilityError::NoVariation
);
let mut negative = uniform_data(30);
negative[0] = -1.0;
assert_eq!(
percentile_capability(
&negative,
Some(-5.0),
Some(30.0),
CapabilityModel::LogNormal
)
.unwrap_err(),
PercentileCapabilityError::ModelNotFitted(CapabilityModel::LogNormal)
);
}
#[test]
fn cpk_star_equals_min_of_cpu_cpl() {
let data = uniform_data(100);
let result = pc(&data, Some(5.0), Some(25.0)).unwrap();
let (cpu, cpl, cpk) = (
result.cpu_star.unwrap(),
result.cpl_star.unwrap(),
result.cpk_star.unwrap(),
);
assert!((cpk - cpu.min(cpl)).abs() < 1e-10);
}
#[test]
fn symmetric_data_gives_balanced_cpu_cpl() {
let data: Vec<f64> = (0..200).map(|i| 40.0 + (i as f64) * 0.1).collect();
let result = pc(&data, Some(30.0), Some(70.0)).unwrap();
let (cpu, cpl) = (result.cpu_star.unwrap(), result.cpl_star.unwrap());
assert!(
(cpu - cpl).abs() < cpu * 0.2,
"symmetric data should give similar Cpu* ({cpu}) and Cpl* ({cpl})"
);
}
#[test]
fn small_samples_do_not_inflate_cp_star() {
let process = Normal::new(10.0, 1.0).unwrap();
for (n, empirical_at_least) in [(20_usize, 1.5), (50, 1.25), (100, 1.15)] {
let reps = 400;
let (mut sum, mut empirical) = (0.0, 0.0);
for rep in 0..reps {
let data = process.sample_n(n, 1_000 + rep as u64 * 7 + n as u64);
let r =
percentile_capability(&data, Some(7.0), Some(13.0), CapabilityModel::Normal)
.unwrap();
sum += r.cp_star.unwrap();
let lo = u_numflow::stats::quantile(&data, P_LOWER).unwrap();
let hi = u_numflow::stats::quantile(&data, P_UPPER).unwrap();
empirical += 6.0 / (hi - lo);
}
let mean = sum / reps as f64;
let empirical = empirical / reps as f64;
assert!(
empirical > empirical_at_least,
"n = {n}: the empirical Cp* {empirical} no longer shows the bias this guards"
);
assert!(
(mean - 1.0).abs() < 0.08,
"n = {n}: mean Cp* {mean} (true 1.0)"
);
}
}
#[test]
fn quantiles_are_the_fitted_model_s() {
let process = Normal::new(50.0, 2.0).unwrap();
let data = process.sample_n(200, 42);
let r =
percentile_capability(&data, Some(40.0), Some(60.0), CapabilityModel::Normal).unwrap();
let mu = r.parameters.iter().find(|(n, _)| n == "mu").unwrap().1;
let sigma = r.parameters.iter().find(|(n, _)| n == "sigma").unwrap().1;
assert_eq!(r.model, CapabilityModel::Normal);
assert!((r.median - mu).abs() < 1e-9);
assert!((r.percentile_lower - (mu - 3.0 * sigma)).abs() < 1e-6);
assert!((r.percentile_upper - (mu + 3.0 * sigma)).abs() < 1e-6);
}
#[test]
fn auto_reports_the_family_it_used() {
let process = LogNormal::new(1.0, 0.6).unwrap();
let data = process.sample_n(300, 7);
let r = pc(&data, None, Some(30.0)).unwrap();
assert_ne!(r.model, CapabilityModel::Auto);
assert_eq!(
CapabilityModel::of_fit(&fit_best(&data)[0].distribution),
Some(r.model)
);
}
}