use std::fmt;
use u_numflow::transforms::{box_cox, estimate_lambda, TransformError};
use crate::capability::{CapabilityIndices, ProcessCapability};
pub const DEFAULT_LAMBDA_RANGE: (f64, f64) = (-5.0, 5.0);
#[derive(Debug, Clone, PartialEq)]
pub enum NonNormalCapabilityError {
NonPositiveData,
NonFiniteData,
InsufficientData,
SpecTransformError,
CapabilityError,
InvalidLambdaRange,
}
impl fmt::Display for NonNormalCapabilityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NonNormalCapabilityError::NonPositiveData => {
write!(
f,
"Box-Cox requires all data values to be strictly positive"
)
}
NonNormalCapabilityError::NonFiniteData => {
write!(f, "data must not contain NaN or infinite values")
}
NonNormalCapabilityError::InsufficientData => {
write!(
f,
"at least 4 data points are required for capability analysis"
)
}
NonNormalCapabilityError::SpecTransformError => {
write!(
f,
"failed to transform specification limit — limit must be positive"
)
}
NonNormalCapabilityError::CapabilityError => {
write!(
f,
"capability computation failed — check that data has non-zero variance"
)
}
NonNormalCapabilityError::InvalidLambdaRange => {
write!(f, "lambda range must be finite with min < max")
}
}
}
}
impl std::error::Error for NonNormalCapabilityError {}
impl From<TransformError> for NonNormalCapabilityError {
fn from(e: TransformError) -> Self {
match e {
TransformError::NonPositiveData { .. } => NonNormalCapabilityError::NonPositiveData,
TransformError::NonFiniteData { .. } => NonNormalCapabilityError::NonFiniteData,
TransformError::InsufficientData { .. } => NonNormalCapabilityError::InsufficientData,
TransformError::InvalidInverse => NonNormalCapabilityError::SpecTransformError,
TransformError::InvalidLambdaRange { .. } => {
NonNormalCapabilityError::InvalidLambdaRange
}
_ => NonNormalCapabilityError::CapabilityError,
}
}
}
#[derive(Debug, Clone)]
pub struct NonNormalCapabilityResult {
pub lambda: f64,
pub lambda_at_bound: bool,
pub indices: Option<CapabilityIndices>,
}
pub fn boxcox_capability(
data: &[f64],
usl: Option<f64>,
lsl: Option<f64>,
lambda_range: (f64, f64),
) -> Result<NonNormalCapabilityResult, NonNormalCapabilityError> {
if data.len() < 4 {
return Err(NonNormalCapabilityError::InsufficientData);
}
if data.iter().any(|v| !v.is_finite()) {
return Err(NonNormalCapabilityError::NonFiniteData);
}
if data.iter().any(|&v| v <= 0.0) {
return Err(NonNormalCapabilityError::NonPositiveData);
}
let estimate = estimate_lambda(data, lambda_range.0, lambda_range.1)?;
let lambda = estimate.lambda;
if usl.is_none() && lsl.is_none() {
return Ok(NonNormalCapabilityResult {
lambda,
lambda_at_bound: estimate.at_bound,
indices: None,
});
}
let y_t = box_cox(data, lambda)?;
let transform_limit = |limit: f64| {
if limit <= 0.0 {
return Err(NonNormalCapabilityError::SpecTransformError);
}
let pair = [limit, data[0]];
box_cox(&pair, lambda)
.map(|v| v[0])
.map_err(|_| NonNormalCapabilityError::SpecTransformError)
};
let usl_t = usl.map(transform_limit).transpose()?;
let lsl_t = lsl.map(transform_limit).transpose()?;
let spec = ProcessCapability::new(usl_t, lsl_t)
.map_err(|_| NonNormalCapabilityError::CapabilityError)?;
let indices = spec
.compute_overall(&y_t)
.ok_or(NonNormalCapabilityError::CapabilityError)?;
Ok(NonNormalCapabilityResult {
lambda,
lambda_at_bound: estimate.at_bound,
indices: Some(indices),
})
}
#[cfg(test)]
mod tests {
use super::*;
const R: (f64, f64) = DEFAULT_LAMBDA_RANGE;
fn indices(r: &NonNormalCapabilityResult) -> &CapabilityIndices {
r.indices.as_ref().expect("limits were given")
}
#[test]
fn a_narrow_sample_reports_a_lambda_that_sits_on_the_range_limit() {
let data = vec![
24.994, 25.007, 24.998, 25.006, 24.993, 25.002, 24.996, 25.004, 24.995, 25.005, 25.007,
24.995, 25.005, 24.993, 25.006, 24.996, 25.004, 24.998, 25.002, 24.994, 24.996, 25.004,
24.994, 25.006, 24.998, 25.002, 24.993, 25.007, 24.995, 25.005, 25.005, 24.995, 25.003,
24.993, 25.007, 24.996, 25.006, 24.994, 25.004, 24.997, 24.993, 25.006, 24.997, 25.005,
24.994, 25.007, 24.996, 25.004, 24.998, 25.0, 25.009, 24.999, 25.01, 24.998, 25.008,
25.002, 24.997, 25.006, 25.011, 25.0, 25.011, 25.0, 24.998, 25.006, 24.999, 25.009,
25.002, 24.997, 25.008, 25.01, 24.997, 25.01, 25.002, 24.999, 25.008, 25.011, 24.998,
25.009, 25.0, 25.006, 25.006, 24.999, 25.011, 25.002, 24.998, 25.01, 25.009, 24.997,
25.0, 25.008, 25.008, 25.01, 24.997, 25.006, 25.011, 24.999, 25.009, 25.002, 24.998,
25.0, 25.0, 24.997, 25.009, 25.011, 25.006, 24.998, 25.01, 24.999, 25.008, 25.002,
25.002, 25.008, 25.0, 24.999, 25.01, 25.006, 24.997, 25.011, 24.998, 25.009, 25.01,
25.002, 25.009, 24.997, 25.001, 25.008, 24.999, 25.006, 25.011, 24.997, 24.999, 25.01,
25.006, 24.998, 25.009, 25.001, 25.011, 25.008, 25.002, 24.996, 25.005, 24.994, 25.007,
24.996, 24.993, 25.006, 24.998, 25.004, 24.995, 25.002, 24.996, 25.003, 24.993, 25.007,
25.004, 24.995, 25.006, 24.994, 25.002, 25.0, 25.007, 24.998, 25.004, 24.993, 25.005,
24.996, 25.002, 24.994, 25.006, 24.995, 24.993, 25.006, 24.995, 25.004, 24.997, 25.007,
24.994, 25.002, 24.996, 25.006, 25.004, 24.997, 25.006, 24.994, 25.002, 24.993, 25.005,
24.996, 25.007, 24.996, 24.995, 25.005, 24.998, 25.003, 24.993, 25.007, 24.996, 25.004,
24.994, 25.005, 24.996, 25.003, 24.998, 25.002, 24.997, 25.004, 25.0, 25.023, 25.025,
25.021, 25.006, 24.994, 25.003, 24.997, 25.005, 24.998, 25.007, 24.995, 25.002, 24.993,
24.994, 25.007, 24.997, 25.006, 24.993, 25.005, 24.996, 25.003, 24.999, 25.0, 25.003,
24.996, 25.007, 24.994, 25.005, 24.997, 25.002, 24.993, 25.006, 24.997, 24.997, 25.004,
24.993, 25.006, 24.998, 25.005, 24.995, 25.007, 24.994, 25.001,
];
assert_eq!(data.len(), 250);
let wide = boxcox_capability(&data, None, None, R).expect("positive data, no spec");
assert!(wide.lambda_at_bound, "{wide:?}");
assert_eq!(wide.lambda, R.0, "{wide:?}");
assert!(wide.indices.is_none(), "no spec was given");
let with_spec =
boxcox_capability(&data, Some(25.05), Some(24.95), R).expect("positive data");
assert!(with_spec.lambda_at_bound, "{with_spec:?}");
assert_eq!(with_spec.lambda, wide.lambda);
assert!(
indices(&with_spec).pp.is_some_and(f64::is_finite),
"{with_spec:?}"
);
}
fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
(1..=n)
.map(|i| {
let p = (i as f64 - 0.5) / n as f64;
let z = 10.0 + 2.0 * u_numflow::special::inverse_normal_cdf(p);
(lambda0 * z + 1.0).powf(1.0 / lambda0)
})
.collect()
}
#[test]
fn boxcox_capability_skewed_data() {
let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
let result = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
assert!(result.lambda.abs() < 0.6, "lambda={}", result.lambda);
assert!(!result.lambda_at_bound);
assert!(indices(&result).pp.is_some() || indices(&result).ppk.is_some());
}
#[test]
fn boxcox_capability_reports_no_short_term_indices() {
let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
let r = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
let i = indices(&r);
assert!(i.cp.is_none());
assert!(i.cpk.is_none());
assert!(i.cpu.is_none());
assert!(i.cpl.is_none());
assert!(i.pp.is_some(), "the long-term indices are reported");
}
#[test]
fn boxcox_capability_says_when_the_range_cut_lambda_short() {
let data = normal_after_boxcox(4.0, 100);
let wide = boxcox_capability(&data, Some(40.0), None, R).unwrap();
assert!(!wide.lambda_at_bound, "lambda={}", wide.lambda);
assert!((wide.lambda - 4.0).abs() < 0.5, "lambda={}", wide.lambda);
let narrow = boxcox_capability(&data, Some(40.0), None, (-2.0, 2.0)).unwrap();
assert!(narrow.lambda_at_bound);
assert_eq!(narrow.lambda, 2.0);
assert_ne!(indices(&narrow).ppk, indices(&wide).ppk);
}
#[test]
fn boxcox_capability_without_limits_estimates_lambda_only() {
let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
let bare = boxcox_capability(&data, None, None, R).unwrap();
let with = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
assert!(bare.indices.is_none());
assert_eq!(bare.lambda, with.lambda);
assert_eq!(bare.lambda_at_bound, with.lambda_at_bound);
}
#[test]
fn boxcox_capability_rejects_an_invalid_lambda_range() {
let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
for range in [(2.0, -2.0), (1.0, 1.0), (f64::NAN, 2.0)] {
assert_eq!(
boxcox_capability(&data, Some(20.0), None, range).unwrap_err(),
NonNormalCapabilityError::InvalidLambdaRange,
"{range:?}"
);
}
}
#[test]
fn boxcox_capability_non_finite_error() {
let data = vec![1.0, f64::NAN, 2.0, 3.0, 4.0];
assert_eq!(
boxcox_capability(&data, Some(10.0), None, R).unwrap_err(),
NonNormalCapabilityError::NonFiniteData
);
}
#[test]
fn boxcox_capability_non_positive_error() {
let data = vec![1.0, -1.0, 2.0, 3.0, 4.0, 5.0];
assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
}
#[test]
fn boxcox_capability_insufficient_data() {
let data = vec![1.0, 2.0, 3.0]; assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
}
#[test]
fn boxcox_capability_lambda_in_range() {
let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
let result = boxcox_capability(&data, Some(25.0), Some(0.5), R).unwrap();
assert!(result.lambda >= R.0 && result.lambda <= R.1);
}
#[test]
fn boxcox_capability_usl_only() {
let data: Vec<f64> = (1..=20).map(|i| i as f64 * 0.5).collect();
let result = boxcox_capability(&data, Some(20.0), None, R).unwrap();
assert!(indices(&result).ppk.is_some());
assert!(indices(&result).pp.is_none());
}
#[test]
fn boxcox_capability_lsl_only() {
let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
let result = boxcox_capability(&data, None, Some(0.5), R).unwrap();
assert!(indices(&result).ppk.is_some());
assert!(indices(&result).pp.is_none());
}
#[test]
fn boxcox_capability_two_sided() {
let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.1).exp()).collect();
let result = boxcox_capability(&data, Some(20.0), Some(1.0), R).unwrap();
let i = indices(&result);
assert!(i.pp.is_some());
assert!(i.ppk.is_some());
assert!(i.cp.is_none());
assert!(i.cpk.is_none());
}
#[test]
fn boxcox_capability_non_positive_spec_error() {
let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
assert!(boxcox_capability(&data, Some(20.0), Some(-1.0), R).is_err());
}
#[test]
fn boxcox_capability_result_has_valid_lambda() {
let data: Vec<f64> = (1..=15).map(|i| (i as f64).powi(2)).collect();
let result = boxcox_capability(&data, Some(250.0), Some(0.5), R).unwrap();
assert!(result.lambda.is_finite());
assert!(result.lambda >= R.0 && result.lambda <= R.1);
}
}