use u_numflow::stats;
#[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 fn percentile_capability(
data: &[f64],
lsl: Option<f64>,
usl: Option<f64>,
) -> Result<PercentileCapabilityResult, &'static str> {
if usl.is_none() && lsl.is_none() {
return Err("at least one specification limit (USL or LSL) is required");
}
if data.len() < 20 {
return Err("at least 20 data points are required for percentile capability");
}
if data.iter().any(|v| !v.is_finite()) {
return Err("all data values must be finite");
}
if let (Some(u), Some(l)) = (usl, lsl) {
if u <= l {
return Err("USL must be greater than LSL");
}
}
let p_lower = stats::quantile(data, 0.00135)
.ok_or("failed to compute 0.135th percentile")?;
let p_upper = stats::quantile(data, 0.99865)
.ok_or("failed to compute 99.865th percentile")?;
let median = stats::quantile(data, 0.5)
.ok_or("failed to compute median")?;
let spread = p_upper - p_lower;
if spread < 1e-300 {
return Err("percentile spread is zero — data has no variation");
}
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| {
if upper_spread > 1e-300 {
Some((u - median) / upper_spread)
} else {
None
}
});
let cpl_star = lsl.and_then(|l| {
if lower_spread > 1e-300 {
Some((median - l) / lower_spread)
} else {
None
}
});
let cpk_star = match (cpu_star, cpl_star) {
(Some(u), Some(l)) => Some(u.min(l)),
(Some(u), None) => Some(u),
(None, Some(l)) => Some(l),
(None, None) => None,
};
Ok(PercentileCapabilityResult {
cp_star,
cpk_star,
cpu_star,
cpl_star,
median,
percentile_lower: p_lower,
percentile_upper: p_upper,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn uniform_data(n: usize) -> Vec<f64> {
(0..n).map(|i| 10.0 + (i as f64) / (n as f64 - 1.0) * 10.0).collect()
}
#[test]
fn basic_two_sided() {
let data = uniform_data(100);
let result = percentile_capability(&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 = percentile_capability(&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!(result.cpk_star.is_some());
assert_eq!(result.cpk_star.unwrap(), result.cpu_star.unwrap());
}
#[test]
fn lsl_only() {
let data = uniform_data(50);
let result = percentile_capability(&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!(result.cpk_star.is_some());
assert_eq!(result.cpk_star.unwrap(), result.cpl_star.unwrap());
}
#[test]
fn rejects_insufficient_data() {
let data: Vec<f64> = (0..19).map(|i| i as f64).collect();
assert!(percentile_capability(&data, Some(0.0), Some(20.0)).is_err());
}
#[test]
fn rejects_no_spec_limits() {
let data = uniform_data(30);
assert!(percentile_capability(&data, None, None).is_err());
}
#[test]
fn rejects_non_finite_data() {
let mut data = uniform_data(30);
data[10] = f64::NAN;
assert!(percentile_capability(&data, Some(0.0), Some(30.0)).is_err());
}
#[test]
fn rejects_usl_leq_lsl() {
let data = uniform_data(30);
assert!(percentile_capability(&data, Some(10.0), Some(5.0)).is_err());
assert!(percentile_capability(&data, Some(10.0), Some(10.0)).is_err());
}
#[test]
fn cpk_star_equals_min_of_cpu_cpl() {
let data = uniform_data(100);
let result = percentile_capability(&data, Some(5.0), Some(25.0)).unwrap();
let cpu = result.cpu_star.unwrap();
let cpl = result.cpl_star.unwrap();
let cpk = result.cpk_star.unwrap();
assert!(
(cpk - cpu.min(cpl)).abs() < 1e-10,
"Cpk* should equal min(Cpu*, Cpl*)"
);
}
#[test]
fn percentiles_bracket_data() {
let data = uniform_data(200);
let result = percentile_capability(&data, Some(5.0), Some(25.0)).unwrap();
assert!(
result.percentile_lower >= data.iter().copied().fold(f64::INFINITY, f64::min) - 1e-10,
"lower percentile below data min"
);
assert!(
result.percentile_upper
<= data
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
+ 1e-10,
"upper percentile above data max"
);
assert!(result.percentile_lower < result.median);
assert!(result.median < result.percentile_upper);
}
#[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 lsl = 30.0;
let usl = 70.0;
let result = percentile_capability(&data, Some(lsl), Some(usl)).unwrap();
let cpu = result.cpu_star.unwrap();
let cpl = result.cpl_star.unwrap();
assert!(
(cpu - cpl).abs() < cpu * 0.2,
"symmetric data should give similar Cpu* ({}) and Cpl* ({})",
cpu,
cpl
);
}
}