use u_numflow::special::{chi_squared_quantile, inverse_normal_cdf};
use u_numflow::stats;
pub struct ProcessCapability {
usl: Option<f64>,
lsl: Option<f64>,
target: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct CapabilityIndices {
pub cp: Option<f64>,
pub cpk: Option<f64>,
pub cpu: Option<f64>,
pub cpl: Option<f64>,
pub pp: Option<f64>,
pub ppk: Option<f64>,
pub ppu: Option<f64>,
pub ppl: Option<f64>,
pub cpm: Option<f64>,
pub mean: f64,
pub std_dev_within: Option<f64>,
pub std_dev_overall: f64,
pub n: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ConfidenceInterval {
pub lower: f64,
pub upper: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CapabilityIntervals {
pub confidence: f64,
pub cp: Option<ConfidenceInterval>,
pub cpk: Option<ConfidenceInterval>,
pub pp: Option<ConfidenceInterval>,
pub ppk: Option<ConfidenceInterval>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum IntervalError {
ConfidenceOutOfRange,
WithinDfOutOfRange,
WithinDfWithoutWithinSigma,
}
impl CapabilityIndices {
pub fn intervals(
&self,
confidence: f64,
within_df: Option<f64>,
) -> Result<CapabilityIntervals, IntervalError> {
if !(confidence > 0.0 && confidence < 1.0) {
return Err(IntervalError::ConfidenceOutOfRange);
}
if let Some(df) = within_df {
if !(df.is_finite() && df > 0.0) {
return Err(IntervalError::WithinDfOutOfRange);
}
if self.std_dev_within.is_none() {
return Err(IntervalError::WithinDfWithoutWithinSigma);
}
}
let alpha = 1.0 - confidence;
let n = self.n as f64;
let overall_df = n - 1.0;
let ratio = |c: f64, df: f64| ConfidenceInterval {
lower: c * (chi_squared_quantile(alpha / 2.0, df) / df).sqrt(),
upper: c * (chi_squared_quantile(1.0 - alpha / 2.0, df) / df).sqrt(),
};
let z = inverse_normal_cdf(1.0 - alpha / 2.0);
let nearer = |c: f64, df: f64| {
let half = z * (1.0 / (9.0 * n) + c * c / (2.0 * df)).sqrt();
ConfidenceInterval {
lower: c - half,
upper: c + half,
}
};
Ok(CapabilityIntervals {
confidence,
cp: within_df.and_then(|df| self.cp.map(|c| ratio(c, df))),
cpk: within_df.and_then(|df| self.cpk.map(|c| nearer(c, df))),
pp: self.pp.map(|c| ratio(c, overall_df)),
ppk: self.ppk.map(|c| nearer(c, overall_df)),
})
}
}
impl ProcessCapability {
pub fn new(usl: Option<f64>, lsl: Option<f64>) -> Result<Self, &'static str> {
if usl.is_none() && lsl.is_none() {
return Err("at least one specification limit (USL or LSL) is required");
}
if let Some(u) = usl {
if !u.is_finite() {
return Err("USL must be finite");
}
}
if let Some(l) = lsl {
if !l.is_finite() {
return Err("LSL must be finite");
}
}
if let (Some(u), Some(l)) = (usl, lsl) {
if u <= l {
return Err("USL must be greater than LSL");
}
}
Ok(Self {
usl,
lsl,
target: None,
})
}
pub fn with_target(mut self, target: f64) -> Self {
self.target = Some(target);
self
}
pub fn compute(&self, data: &[f64], sigma_within: f64) -> Option<CapabilityIndices> {
if !sigma_within.is_finite() || sigma_within <= 0.0 {
return None;
}
let x_bar = stats::mean(data)?;
let sigma_overall = stats::std_dev(data)?;
Some(self.compute_indices(data, x_bar, Some(sigma_within), sigma_overall))
}
pub fn compute_overall(&self, data: &[f64]) -> Option<CapabilityIndices> {
let x_bar = stats::mean(data)?;
let sigma_overall = stats::std_dev(data)?;
Some(self.compute_indices(data, x_bar, None, sigma_overall))
}
fn compute_indices(
&self,
data: &[f64],
x_bar: f64,
sigma_within: Option<f64>,
sigma_overall: f64,
) -> CapabilityIndices {
let cpu = sigma_within.and_then(|s| self.usl.map(|u| (u - x_bar) / (3.0 * s)));
let cpl = sigma_within.and_then(|s| self.lsl.map(|l| (x_bar - l) / (3.0 * s)));
let cp = match (sigma_within, self.usl, self.lsl) {
(Some(s), Some(u), Some(l)) => Some((u - l) / (6.0 * s)),
_ => None,
};
let cpk = match (cpu, cpl) {
(Some(u), Some(l)) => Some(u.min(l)),
(Some(u), None) => Some(u),
(None, Some(l)) => Some(l),
(None, None) => None,
};
let ppu = self.usl.map(|u| (u - x_bar) / (3.0 * sigma_overall));
let ppl = self.lsl.map(|l| (x_bar - l) / (3.0 * sigma_overall));
let pp = match (self.usl, self.lsl) {
(Some(u), Some(l)) => Some((u - l) / (6.0 * sigma_overall)),
_ => None,
};
let ppk = match (ppu, ppl) {
(Some(u), Some(l)) => Some(u.min(l)),
(Some(u), None) => Some(u),
(None, Some(l)) => Some(l),
(None, None) => None,
};
let cpm = self.cpm(data);
CapabilityIndices {
cp,
cpk,
cpu,
cpl,
pp,
ppk,
ppu,
ppl,
cpm,
mean: x_bar,
std_dev_within: sigma_within,
std_dev_overall: sigma_overall,
n: data.len(),
}
}
fn cpm(&self, data: &[f64]) -> Option<f64> {
let (usl, lsl, target) = (self.usl?, self.lsl?, self.target?);
if !target.is_finite() || target < lsl || target > usl || data.len() < 2 {
return None;
}
let sum_sq: f64 = data.iter().map(|&x| (x - target).powi(2)).sum();
let sigma_t = (sum_sq / (data.len() - 1) as f64).sqrt();
if sigma_t <= 0.0 {
return None;
}
Some((target - lsl).min(usl - target) / (3.0 * sigma_t))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn indices(n: usize, c: f64, within: bool) -> CapabilityIndices {
CapabilityIndices {
cp: within.then_some(c),
cpk: within.then_some(c),
cpu: None,
cpl: None,
pp: Some(c),
ppk: Some(c),
ppu: None,
ppl: None,
cpm: None,
mean: 0.0,
std_dev_within: within.then_some(1.0),
std_dev_overall: 1.0,
n,
}
}
#[test]
fn intervals_match_hand_worked_values() {
let ci = indices(30, 1.33, false).intervals(0.95, None).unwrap();
let pp = ci.pp.unwrap();
assert!(
(pp.lower - 1.33 * (16.047_f64 / 29.0).sqrt()).abs() < 1e-3,
"{pp:?}"
);
assert!(
(pp.upper - 1.33 * (45.722_f64 / 29.0).sqrt()).abs() < 1e-3,
"{pp:?}"
);
let ppk = ci.ppk.unwrap();
let half = 1.959964 * (1.0 / 270.0 + 1.33_f64.powi(2) / 58.0).sqrt();
assert!((ppk.lower - (1.33 - half)).abs() < 1e-6, "{ppk:?}");
assert!((ppk.upper - (1.33 + half)).abs() < 1e-6, "{ppk:?}");
assert!((ppk.lower - 0.9675).abs() < 1e-3, "{ppk:?}");
}
#[test]
fn short_term_intervals_need_the_within_df() {
let i = indices(50, 1.5, true);
let without = i.intervals(0.95, None).unwrap();
assert!(without.cp.is_none() && without.cpk.is_none());
assert!(without.pp.is_some() && without.ppk.is_some());
let with = i.intervals(0.95, Some(30.0)).unwrap();
let (cp, pp) = (with.cp.unwrap(), with.pp.unwrap());
assert!(cp.upper - cp.lower > pp.upper - pp.lower);
assert!((cp.lower - 1.5 * (chi_squared_quantile(0.025, 30.0) / 30.0).sqrt()).abs() < 1e-12);
}
#[test]
fn interval_arguments_are_checked() {
let overall = indices(20, 1.2, false);
for c in [0.0, 1.0, -0.5, 1.5, f64::NAN] {
assert_eq!(
overall.intervals(c, None),
Err(IntervalError::ConfidenceOutOfRange)
);
}
assert_eq!(
overall.intervals(0.95, Some(10.0)),
Err(IntervalError::WithinDfWithoutWithinSigma)
);
let within = indices(20, 1.2, true);
for df in [0.0, -1.0, f64::INFINITY, f64::NAN] {
assert_eq!(
within.intervals(0.95, Some(df)),
Err(IntervalError::WithinDfOutOfRange)
);
}
}
#[test]
fn intervals_widen_with_confidence_and_narrow_with_n() {
let width = |n: usize, conf: f64| {
let ppk = indices(n, 1.33, false)
.intervals(conf, None)
.unwrap()
.ppk
.unwrap();
ppk.upper - ppk.lower
};
assert!(width(30, 0.99) > width(30, 0.95));
assert!(width(30, 0.95) > width(30, 0.90));
assert!(width(100, 0.95) < width(30, 0.95));
}
#[test]
fn new_requires_at_least_one_limit() {
assert!(ProcessCapability::new(None, None).is_err());
}
#[test]
fn new_rejects_usl_leq_lsl() {
assert!(ProcessCapability::new(Some(5.0), Some(10.0)).is_err());
assert!(ProcessCapability::new(Some(5.0), Some(5.0)).is_err());
}
#[test]
fn new_rejects_non_finite() {
assert!(ProcessCapability::new(Some(f64::NAN), Some(1.0)).is_err());
assert!(ProcessCapability::new(Some(10.0), Some(f64::INFINITY)).is_err());
}
#[test]
fn new_accepts_valid_two_sided() {
assert!(ProcessCapability::new(Some(10.0), Some(5.0)).is_ok());
}
#[test]
fn new_accepts_usl_only() {
assert!(ProcessCapability::new(Some(10.0), None).is_ok());
}
#[test]
fn new_accepts_lsl_only() {
assert!(ProcessCapability::new(None, Some(5.0)).is_ok());
}
#[test]
fn textbook_centered_process() {
let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
let data = [
208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0, 209.0, 211.0,
210.0, 209.5, 210.5, 210.0, 210.0, 210.0, 209.0, 211.0,
];
let sigma_within = 2.0;
let indices = spec.compute(&data, sigma_within).unwrap();
let cp = indices.cp.unwrap();
assert!(
(cp - 1.6667).abs() < 0.001,
"expected Cp ~ 1.6667, got {cp}"
);
let cpk = indices.cpk.unwrap();
assert!(cpk > 0.0, "Cpk should be positive");
assert!(indices.cpm.is_none());
}
#[test]
fn off_center_process() {
let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
let data = [
213.0, 214.0, 215.0, 216.0, 217.0, 213.5, 214.5, 215.5, 216.5, 215.0, 214.0, 216.0,
215.0, 214.5, 215.5, 215.0, 215.0, 215.0, 214.0, 216.0,
];
let sigma_within = 2.0;
let indices = spec.compute(&data, sigma_within).unwrap();
let cp = indices.cp.unwrap();
assert!(
(cp - 1.6667).abs() < 0.001,
"expected Cp ~ 1.6667, got {cp}"
);
let cpu = indices.cpu.unwrap();
assert!(
(cpu - 0.8333).abs() < 0.05,
"expected Cpu ~ 0.8333, got {cpu}"
);
let cpl = indices.cpl.unwrap();
assert!((cpl - 2.5).abs() < 0.05, "expected Cpl ~ 2.5, got {cpl}");
let cpk = indices.cpk.unwrap();
assert!((cpk - cpu).abs() < 1e-15, "Cpk should equal min(Cpu, Cpl)");
}
#[test]
fn usl_only_computes_cpu_not_cpl() {
let spec = ProcessCapability::new(Some(10.0), None).unwrap();
let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
let indices = spec.compute(&data, 0.5).unwrap();
assert!(indices.cpu.is_some());
assert!(indices.cpl.is_none());
assert!(indices.cp.is_none(), "Cp requires both limits");
assert!(indices.cpk.is_some(), "Cpk should be Cpu for USL-only");
assert!(
(indices.cpk.unwrap() - indices.cpu.unwrap()).abs() < 1e-15,
"Cpk == Cpu when only USL is set"
);
}
#[test]
fn lsl_only_computes_cpl_not_cpu() {
let spec = ProcessCapability::new(None, Some(5.0)).unwrap();
let data = [7.0, 8.0, 9.0, 7.5, 8.5, 8.0, 7.0, 9.0, 8.0, 8.5];
let indices = spec.compute(&data, 0.5).unwrap();
assert!(indices.cpl.is_some());
assert!(indices.cpu.is_none());
assert!(indices.cp.is_none());
assert!(indices.cpk.is_some(), "Cpk should be Cpl for LSL-only");
assert!(
(indices.cpk.unwrap() - indices.cpl.unwrap()).abs() < 1e-15,
"Cpk == Cpl when only LSL is set"
);
}
#[test]
fn compute_overall_reports_no_short_term_index() {
let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
let data = [
208.0, 209.0, 210.0, 211.0, 212.0, 208.5, 209.5, 210.5, 211.5, 210.0,
];
let indices = spec.compute_overall(&data).unwrap();
assert!(indices.cp.is_none());
assert!(indices.cpk.is_none());
assert!(indices.cpu.is_none());
assert!(indices.cpl.is_none());
assert!(indices.std_dev_within.is_none());
assert!(indices.pp.unwrap() > 0.0);
assert!(indices.ppk.unwrap() > 0.0);
assert!(indices.ppu.unwrap() > 0.0);
assert!(indices.ppl.unwrap() > 0.0);
let with_within = spec.compute(&data, 1.0).unwrap();
assert_eq!(indices.pp, with_within.pp);
assert_eq!(indices.ppk, with_within.ppk);
assert_eq!(indices.std_dev_overall, with_within.std_dev_overall);
assert_eq!(with_within.std_dev_within, Some(1.0));
}
#[test]
fn cpm_is_the_spread_about_a_declared_target() {
let data = [9.8, 10.1, 10.3, 9.9, 10.2, 10.4];
let spec = || ProcessCapability::new(Some(12.0), Some(8.0)).unwrap();
assert!(spec().compute(&data, 0.2).unwrap().cpm.is_none());
assert!(spec().compute_overall(&data).unwrap().cpm.is_none());
for (target, expected) in [(10.0, 2.519_763), (11.0, 0.335_013)] {
let spec = spec().with_target(target);
let within = spec.compute(&data, 0.2).unwrap().cpm.unwrap();
let overall = spec.compute_overall(&data).unwrap().cpm.unwrap();
assert!((within - expected).abs() < 1e-6, "T={target}: {within}");
assert_eq!(within, overall);
}
}
#[test]
fn cpm_off_midpoint_uses_the_nearer_limit() {
let spec = ProcessCapability::new(Some(220.0), Some(200.0))
.unwrap()
.with_target(212.0);
let data = [208.0, 209.0, 210.0, 211.0, 212.0];
let cpm = spec.compute_overall(&data).unwrap().cpm.unwrap();
assert!((cpm - 0.973_729).abs() < 1e-6, "got {cpm}");
}
#[test]
fn cpm_equals_pp_when_the_mean_is_on_target() {
let spec = ProcessCapability::new(Some(220.0), Some(200.0))
.unwrap()
.with_target(210.0);
let data = [208.0, 209.0, 210.0, 211.0, 212.0];
let indices = spec.compute(&data, 2.0).unwrap();
let (cpm, pp) = (indices.cpm.unwrap(), indices.pp.unwrap());
assert!((cpm - pp).abs() < 1e-12, "Cpm {cpm} vs Pp {pp}");
}
#[test]
fn cpm_is_none_where_it_is_undefined() {
let data = [208.0, 209.0, 210.0, 211.0, 212.0];
let two_sided = || ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
for t in [199.0, 221.0, f64::NAN] {
let cpm = two_sided()
.with_target(t)
.compute_overall(&data)
.unwrap()
.cpm;
assert!(cpm.is_none(), "T={t}");
}
let on_target = two_sided().with_target(210.0).compute(&[210.0; 5], 2.0);
assert!(on_target.unwrap().cpm.is_none());
let upper = ProcessCapability::new(Some(220.0), None)
.unwrap()
.with_target(210.0);
assert!(upper.compute_overall(&data).unwrap().cpm.is_none());
}
#[test]
fn compute_returns_none_for_insufficient_data() {
let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
assert!(spec.compute(&[5.0], 1.0).is_none());
assert!(spec.compute(&[], 1.0).is_none());
}
#[test]
fn compute_returns_none_for_invalid_sigma() {
let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
let data = [4.0, 5.0, 6.0, 5.0, 5.5];
assert!(spec.compute(&data, 0.0).is_none());
assert!(spec.compute(&data, -1.0).is_none());
assert!(spec.compute(&data, f64::NAN).is_none());
assert!(spec.compute(&data, f64::INFINITY).is_none());
}
#[test]
fn compute_returns_none_for_nan_data() {
let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
let data = [4.0, f64::NAN, 6.0];
assert!(spec.compute(&data, 1.0).is_none());
}
#[test]
fn compute_overall_returns_none_for_insufficient_data() {
let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
assert!(spec.compute_overall(&[5.0]).is_none());
assert!(spec.compute_overall(&[]).is_none());
}
#[test]
fn cp_cpk_montgomery_2020_reference() {
let usl = 74.05_f64;
let lsl = 73.95_f64;
let mu = 74.001_f64;
let sigma_within = 0.0099_f64;
let spec = ProcessCapability::new(Some(usl), Some(lsl)).unwrap();
let data = vec![mu; 100];
let indices = spec.compute(&data, sigma_within).unwrap();
let cp = indices.cp.unwrap();
let expected_cp = (usl - lsl) / (6.0 * sigma_within); assert!(
(cp - expected_cp).abs() < 0.001,
"Cp: expected {expected_cp:.4}, got {cp:.4}"
);
let cpu = indices.cpu.unwrap();
let expected_cpu = (usl - mu) / (3.0 * sigma_within); assert!(
(cpu - expected_cpu).abs() < 0.001,
"Cpu: expected {expected_cpu:.4}, got {cpu:.4}"
);
let cpl = indices.cpl.unwrap();
let expected_cpl = (mu - lsl) / (3.0 * sigma_within); assert!(
(cpl - expected_cpl).abs() < 0.001,
"Cpl: expected {expected_cpl:.4}, got {cpl:.4}"
);
let cpk = indices.cpk.unwrap();
let expected_cpk = expected_cpu.min(expected_cpl); assert!(
(cpk - expected_cpk).abs() < 0.001,
"Cpk: expected {expected_cpk:.4}, got {cpk:.4}"
);
assert!(
cpu < cpl,
"mean above centre → Cpu ({cpu:.4}) should be < Cpl ({cpl:.4})"
);
}
#[test]
fn pp_differs_from_cp_when_sigmas_differ() {
let spec = ProcessCapability::new(Some(220.0), Some(200.0)).unwrap();
let data = [
205.0, 207.0, 210.0, 213.0, 215.0, 206.0, 208.0, 212.0, 214.0, 210.0, 204.0, 216.0,
209.0, 211.0, 210.0,
];
let sigma_within = 1.5;
let indices = spec.compute(&data, sigma_within).unwrap();
let cp = indices.cp.unwrap();
let pp = indices.pp.unwrap();
assert!(
pp < cp,
"Pp ({pp}) should be < Cp ({cp}) when sigma_overall > sigma_within"
);
}
#[test]
fn exact_numerical_verification() {
let spec = ProcessCapability::new(Some(10.0), Some(0.0)).unwrap();
let data = [4.0, 4.5, 5.0, 5.5, 6.0, 4.0, 5.0, 6.0, 5.0, 5.0];
let x_bar = stats::mean(&data).unwrap();
let sigma_within = 1.0;
let indices = spec.compute(&data, sigma_within).unwrap();
let expected_cp = 10.0 / 6.0;
let expected_cpu = (10.0 - x_bar) / 3.0;
let expected_cpl = (x_bar - 0.0) / 3.0;
assert!(
(indices.cp.unwrap() - expected_cp).abs() < 1e-10,
"Cp: expected {expected_cp}, got {}",
indices.cp.unwrap()
);
assert!(
(indices.cpu.unwrap() - expected_cpu).abs() < 1e-10,
"Cpu: expected {expected_cpu}, got {}",
indices.cpu.unwrap()
);
assert!(
(indices.cpl.unwrap() - expected_cpl).abs() < 1e-10,
"Cpl: expected {expected_cpl}, got {}",
indices.cpl.unwrap()
);
assert!(
(indices.cpk.unwrap() - expected_cpu.min(expected_cpl)).abs() < 1e-10,
"Cpk: expected {}, got {}",
expected_cpu.min(expected_cpl),
indices.cpk.unwrap()
);
}
}