1use std::fmt;
25
26use u_numflow::transforms::{box_cox, estimate_lambda, TransformError};
27
28use crate::capability::{CapabilityIndices, ProcessCapability};
29
30pub const DEFAULT_LAMBDA_RANGE: (f64, f64) = (-5.0, 5.0);
37
38#[derive(Debug, Clone, PartialEq)]
42pub enum NonNormalCapabilityError {
43 NonPositiveData,
45 NonFiniteData,
47 InsufficientData,
49 SpecTransformError,
51 CapabilityError,
53 InvalidLambdaRange,
55}
56
57impl fmt::Display for NonNormalCapabilityError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 NonNormalCapabilityError::NonPositiveData => {
61 write!(
62 f,
63 "Box-Cox requires all data values to be strictly positive"
64 )
65 }
66 NonNormalCapabilityError::NonFiniteData => {
67 write!(f, "data must not contain NaN or infinite values")
68 }
69 NonNormalCapabilityError::InsufficientData => {
70 write!(
71 f,
72 "at least 4 data points are required for capability analysis"
73 )
74 }
75 NonNormalCapabilityError::SpecTransformError => {
76 write!(
77 f,
78 "failed to transform specification limit — limit must be positive"
79 )
80 }
81 NonNormalCapabilityError::CapabilityError => {
82 write!(
83 f,
84 "capability computation failed — check that data has non-zero variance"
85 )
86 }
87 NonNormalCapabilityError::InvalidLambdaRange => {
88 write!(f, "lambda range must be finite with min < max")
89 }
90 }
91 }
92}
93
94impl std::error::Error for NonNormalCapabilityError {}
95
96impl From<TransformError> for NonNormalCapabilityError {
97 fn from(e: TransformError) -> Self {
98 match e {
99 TransformError::NonPositiveData => NonNormalCapabilityError::NonPositiveData,
100 TransformError::NonFiniteData => NonNormalCapabilityError::NonFiniteData,
101 TransformError::InsufficientData => NonNormalCapabilityError::InsufficientData,
102 TransformError::InvalidInverse => NonNormalCapabilityError::SpecTransformError,
103 TransformError::InvalidTransform => NonNormalCapabilityError::CapabilityError,
105 TransformError::InvalidLambdaRange => NonNormalCapabilityError::InvalidLambdaRange,
106 }
107 }
108}
109
110#[derive(Debug, Clone)]
114pub struct NonNormalCapabilityResult {
115 pub lambda: f64,
120 pub lambda_at_bound: bool,
126 pub indices: Option<CapabilityIndices>,
136}
137
138pub fn boxcox_capability(
188 data: &[f64],
189 usl: Option<f64>,
190 lsl: Option<f64>,
191 lambda_range: (f64, f64),
192) -> Result<NonNormalCapabilityResult, NonNormalCapabilityError> {
193 if data.len() < 4 {
195 return Err(NonNormalCapabilityError::InsufficientData);
196 }
197
198 if data.iter().any(|v| !v.is_finite()) {
200 return Err(NonNormalCapabilityError::NonFiniteData);
201 }
202 if data.iter().any(|&v| v <= 0.0) {
203 return Err(NonNormalCapabilityError::NonPositiveData);
204 }
205
206 let estimate = estimate_lambda(data, lambda_range.0, lambda_range.1)?;
208 let lambda = estimate.lambda;
209
210 if usl.is_none() && lsl.is_none() {
211 return Ok(NonNormalCapabilityResult {
212 lambda,
213 lambda_at_bound: estimate.at_bound,
214 indices: None,
215 });
216 }
217
218 let y_t = box_cox(data, lambda)?;
220
221 let transform_limit = |limit: f64| {
222 if limit <= 0.0 {
223 return Err(NonNormalCapabilityError::SpecTransformError);
224 }
225 let pair = [limit, data[0]];
227 box_cox(&pair, lambda)
228 .map(|v| v[0])
229 .map_err(|_| NonNormalCapabilityError::SpecTransformError)
230 };
231 let usl_t = usl.map(transform_limit).transpose()?;
232 let lsl_t = lsl.map(transform_limit).transpose()?;
233
234 let spec = ProcessCapability::new(usl_t, lsl_t)
237 .map_err(|_| NonNormalCapabilityError::CapabilityError)?;
238
239 let indices = spec
243 .compute_overall(&y_t)
244 .ok_or(NonNormalCapabilityError::CapabilityError)?;
245
246 Ok(NonNormalCapabilityResult {
247 lambda,
248 lambda_at_bound: estimate.at_bound,
249 indices: Some(indices),
250 })
251}
252
253#[cfg(test)]
256mod tests {
257 use super::*;
258
259 const R: (f64, f64) = DEFAULT_LAMBDA_RANGE;
260
261 fn indices(r: &NonNormalCapabilityResult) -> &CapabilityIndices {
262 r.indices.as_ref().expect("limits were given")
263 }
264
265 #[test]
274 fn a_narrow_sample_reports_a_lambda_that_sits_on_the_range_limit() {
275 let data = vec![
276 24.994, 25.007, 24.998, 25.006, 24.993, 25.002, 24.996, 25.004, 24.995, 25.005, 25.007,
277 24.995, 25.005, 24.993, 25.006, 24.996, 25.004, 24.998, 25.002, 24.994, 24.996, 25.004,
278 24.994, 25.006, 24.998, 25.002, 24.993, 25.007, 24.995, 25.005, 25.005, 24.995, 25.003,
279 24.993, 25.007, 24.996, 25.006, 24.994, 25.004, 24.997, 24.993, 25.006, 24.997, 25.005,
280 24.994, 25.007, 24.996, 25.004, 24.998, 25.0, 25.009, 24.999, 25.01, 24.998, 25.008,
281 25.002, 24.997, 25.006, 25.011, 25.0, 25.011, 25.0, 24.998, 25.006, 24.999, 25.009,
282 25.002, 24.997, 25.008, 25.01, 24.997, 25.01, 25.002, 24.999, 25.008, 25.011, 24.998,
283 25.009, 25.0, 25.006, 25.006, 24.999, 25.011, 25.002, 24.998, 25.01, 25.009, 24.997,
284 25.0, 25.008, 25.008, 25.01, 24.997, 25.006, 25.011, 24.999, 25.009, 25.002, 24.998,
285 25.0, 25.0, 24.997, 25.009, 25.011, 25.006, 24.998, 25.01, 24.999, 25.008, 25.002,
286 25.002, 25.008, 25.0, 24.999, 25.01, 25.006, 24.997, 25.011, 24.998, 25.009, 25.01,
287 25.002, 25.009, 24.997, 25.001, 25.008, 24.999, 25.006, 25.011, 24.997, 24.999, 25.01,
288 25.006, 24.998, 25.009, 25.001, 25.011, 25.008, 25.002, 24.996, 25.005, 24.994, 25.007,
289 24.996, 24.993, 25.006, 24.998, 25.004, 24.995, 25.002, 24.996, 25.003, 24.993, 25.007,
290 25.004, 24.995, 25.006, 24.994, 25.002, 25.0, 25.007, 24.998, 25.004, 24.993, 25.005,
291 24.996, 25.002, 24.994, 25.006, 24.995, 24.993, 25.006, 24.995, 25.004, 24.997, 25.007,
292 24.994, 25.002, 24.996, 25.006, 25.004, 24.997, 25.006, 24.994, 25.002, 24.993, 25.005,
293 24.996, 25.007, 24.996, 24.995, 25.005, 24.998, 25.003, 24.993, 25.007, 24.996, 25.004,
294 24.994, 25.005, 24.996, 25.003, 24.998, 25.002, 24.997, 25.004, 25.0, 25.023, 25.025,
295 25.021, 25.006, 24.994, 25.003, 24.997, 25.005, 24.998, 25.007, 24.995, 25.002, 24.993,
296 24.994, 25.007, 24.997, 25.006, 24.993, 25.005, 24.996, 25.003, 24.999, 25.0, 25.003,
297 24.996, 25.007, 24.994, 25.005, 24.997, 25.002, 24.993, 25.006, 24.997, 24.997, 25.004,
298 24.993, 25.006, 24.998, 25.005, 24.995, 25.007, 24.994, 25.001,
299 ];
300 assert_eq!(data.len(), 250);
301
302 let wide = boxcox_capability(&data, None, None, R).expect("positive data, no spec");
303 assert!(wide.lambda_at_bound, "{wide:?}");
304 assert_eq!(wide.lambda, R.0, "{wide:?}");
305 assert!(wide.indices.is_none(), "no spec was given");
306
307 let with_spec =
310 boxcox_capability(&data, Some(25.05), Some(24.95), R).expect("positive data");
311 assert!(with_spec.lambda_at_bound, "{with_spec:?}");
312 assert_eq!(with_spec.lambda, wide.lambda);
313 assert!(
314 indices(&with_spec).pp.is_some_and(f64::is_finite),
315 "{with_spec:?}"
316 );
317 }
318
319 fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
322 (1..=n)
323 .map(|i| {
324 let p = (i as f64 - 0.5) / n as f64;
325 let z = 10.0 + 2.0 * u_numflow::special::inverse_normal_cdf(p);
326 (lambda0 * z + 1.0).powf(1.0 / lambda0)
327 })
328 .collect()
329 }
330
331 #[test]
332 fn boxcox_capability_skewed_data() {
333 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
335 let result = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
336 assert!(result.lambda.abs() < 0.6, "lambda={}", result.lambda);
337 assert!(!result.lambda_at_bound);
338 assert!(indices(&result).pp.is_some() || indices(&result).ppk.is_some());
339 }
340
341 #[test]
342 fn boxcox_capability_reports_no_short_term_indices() {
343 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
346 let r = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
347 let i = indices(&r);
348 assert!(i.cp.is_none());
349 assert!(i.cpk.is_none());
350 assert!(i.cpu.is_none());
351 assert!(i.cpl.is_none());
352 assert!(i.pp.is_some(), "the long-term indices are reported");
353 }
354
355 #[test]
356 fn boxcox_capability_says_when_the_range_cut_lambda_short() {
357 let data = normal_after_boxcox(4.0, 100);
360 let wide = boxcox_capability(&data, Some(40.0), None, R).unwrap();
361 assert!(!wide.lambda_at_bound, "lambda={}", wide.lambda);
362 assert!((wide.lambda - 4.0).abs() < 0.5, "lambda={}", wide.lambda);
363
364 let narrow = boxcox_capability(&data, Some(40.0), None, (-2.0, 2.0)).unwrap();
365 assert!(narrow.lambda_at_bound);
366 assert_eq!(narrow.lambda, 2.0);
367 assert_ne!(indices(&narrow).ppk, indices(&wide).ppk);
370 }
371
372 #[test]
373 fn boxcox_capability_without_limits_estimates_lambda_only() {
374 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
375 let bare = boxcox_capability(&data, None, None, R).unwrap();
376 let with = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
377 assert!(bare.indices.is_none());
378 assert_eq!(bare.lambda, with.lambda);
379 assert_eq!(bare.lambda_at_bound, with.lambda_at_bound);
380 }
381
382 #[test]
383 fn boxcox_capability_rejects_an_invalid_lambda_range() {
384 let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
385 for range in [(2.0, -2.0), (1.0, 1.0), (f64::NAN, 2.0)] {
386 assert_eq!(
387 boxcox_capability(&data, Some(20.0), None, range).unwrap_err(),
388 NonNormalCapabilityError::InvalidLambdaRange,
389 "{range:?}"
390 );
391 }
392 }
393
394 #[test]
395 fn boxcox_capability_non_finite_error() {
396 let data = vec![1.0, f64::NAN, 2.0, 3.0, 4.0];
397 assert_eq!(
398 boxcox_capability(&data, Some(10.0), None, R).unwrap_err(),
399 NonNormalCapabilityError::NonFiniteData
400 );
401 }
402
403 #[test]
404 fn boxcox_capability_non_positive_error() {
405 let data = vec![1.0, -1.0, 2.0, 3.0, 4.0, 5.0];
406 assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
407 }
408
409 #[test]
410 fn boxcox_capability_insufficient_data() {
411 let data = vec![1.0, 2.0, 3.0]; assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
413 }
414
415 #[test]
416 fn boxcox_capability_lambda_in_range() {
417 let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
418 let result = boxcox_capability(&data, Some(25.0), Some(0.5), R).unwrap();
419 assert!(result.lambda >= R.0 && result.lambda <= R.1);
420 }
421
422 #[test]
423 fn boxcox_capability_usl_only() {
424 let data: Vec<f64> = (1..=20).map(|i| i as f64 * 0.5).collect();
425 let result = boxcox_capability(&data, Some(20.0), None, R).unwrap();
426 assert!(indices(&result).ppk.is_some());
428 assert!(indices(&result).pp.is_none());
429 }
430
431 #[test]
432 fn boxcox_capability_lsl_only() {
433 let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
434 let result = boxcox_capability(&data, None, Some(0.5), R).unwrap();
435 assert!(indices(&result).ppk.is_some());
436 assert!(indices(&result).pp.is_none());
437 }
438
439 #[test]
440 fn boxcox_capability_two_sided() {
441 let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.1).exp()).collect();
446 let result = boxcox_capability(&data, Some(20.0), Some(1.0), R).unwrap();
447 let i = indices(&result);
448 assert!(i.pp.is_some());
449 assert!(i.ppk.is_some());
450 assert!(i.cp.is_none());
451 assert!(i.cpk.is_none());
452 }
453
454 #[test]
455 fn boxcox_capability_non_positive_spec_error() {
456 let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
457 assert!(boxcox_capability(&data, Some(20.0), Some(-1.0), R).is_err());
459 }
460
461 #[test]
462 fn boxcox_capability_result_has_valid_lambda() {
463 let data: Vec<f64> = (1..=15).map(|i| (i as f64).powi(2)).collect();
464 let result = boxcox_capability(&data, Some(250.0), Some(0.5), R).unwrap();
465 assert!(result.lambda.is_finite());
466 assert!(result.lambda >= R.0 && result.lambda <= R.1);
467 }
468}