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::InvalidLambdaRange { .. } => {
104 NonNormalCapabilityError::InvalidLambdaRange
105 }
106 _ => NonNormalCapabilityError::CapabilityError,
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
117pub struct NonNormalCapabilityResult {
118 pub lambda: f64,
123 pub lambda_at_bound: bool,
129 pub indices: Option<CapabilityIndices>,
139}
140
141pub fn boxcox_capability(
191 data: &[f64],
192 usl: Option<f64>,
193 lsl: Option<f64>,
194 lambda_range: (f64, f64),
195) -> Result<NonNormalCapabilityResult, NonNormalCapabilityError> {
196 if data.len() < 4 {
198 return Err(NonNormalCapabilityError::InsufficientData);
199 }
200
201 if data.iter().any(|v| !v.is_finite()) {
203 return Err(NonNormalCapabilityError::NonFiniteData);
204 }
205 if data.iter().any(|&v| v <= 0.0) {
206 return Err(NonNormalCapabilityError::NonPositiveData);
207 }
208
209 let estimate = estimate_lambda(data, lambda_range.0, lambda_range.1)?;
211 let lambda = estimate.lambda;
212
213 if usl.is_none() && lsl.is_none() {
214 return Ok(NonNormalCapabilityResult {
215 lambda,
216 lambda_at_bound: estimate.at_bound,
217 indices: None,
218 });
219 }
220
221 let y_t = box_cox(data, lambda)?;
223
224 let transform_limit = |limit: f64| {
225 if limit <= 0.0 {
226 return Err(NonNormalCapabilityError::SpecTransformError);
227 }
228 let pair = [limit, data[0]];
230 box_cox(&pair, lambda)
231 .map(|v| v[0])
232 .map_err(|_| NonNormalCapabilityError::SpecTransformError)
233 };
234 let usl_t = usl.map(transform_limit).transpose()?;
235 let lsl_t = lsl.map(transform_limit).transpose()?;
236
237 let spec = ProcessCapability::new(usl_t, lsl_t)
240 .map_err(|_| NonNormalCapabilityError::CapabilityError)?;
241
242 let indices = spec
246 .compute_overall(&y_t)
247 .ok_or(NonNormalCapabilityError::CapabilityError)?;
248
249 Ok(NonNormalCapabilityResult {
250 lambda,
251 lambda_at_bound: estimate.at_bound,
252 indices: Some(indices),
253 })
254}
255
256#[cfg(test)]
259mod tests {
260 use super::*;
261
262 const R: (f64, f64) = DEFAULT_LAMBDA_RANGE;
263
264 fn indices(r: &NonNormalCapabilityResult) -> &CapabilityIndices {
265 r.indices.as_ref().expect("limits were given")
266 }
267
268 #[test]
277 fn a_narrow_sample_reports_a_lambda_that_sits_on_the_range_limit() {
278 let data = vec![
279 24.994, 25.007, 24.998, 25.006, 24.993, 25.002, 24.996, 25.004, 24.995, 25.005, 25.007,
280 24.995, 25.005, 24.993, 25.006, 24.996, 25.004, 24.998, 25.002, 24.994, 24.996, 25.004,
281 24.994, 25.006, 24.998, 25.002, 24.993, 25.007, 24.995, 25.005, 25.005, 24.995, 25.003,
282 24.993, 25.007, 24.996, 25.006, 24.994, 25.004, 24.997, 24.993, 25.006, 24.997, 25.005,
283 24.994, 25.007, 24.996, 25.004, 24.998, 25.0, 25.009, 24.999, 25.01, 24.998, 25.008,
284 25.002, 24.997, 25.006, 25.011, 25.0, 25.011, 25.0, 24.998, 25.006, 24.999, 25.009,
285 25.002, 24.997, 25.008, 25.01, 24.997, 25.01, 25.002, 24.999, 25.008, 25.011, 24.998,
286 25.009, 25.0, 25.006, 25.006, 24.999, 25.011, 25.002, 24.998, 25.01, 25.009, 24.997,
287 25.0, 25.008, 25.008, 25.01, 24.997, 25.006, 25.011, 24.999, 25.009, 25.002, 24.998,
288 25.0, 25.0, 24.997, 25.009, 25.011, 25.006, 24.998, 25.01, 24.999, 25.008, 25.002,
289 25.002, 25.008, 25.0, 24.999, 25.01, 25.006, 24.997, 25.011, 24.998, 25.009, 25.01,
290 25.002, 25.009, 24.997, 25.001, 25.008, 24.999, 25.006, 25.011, 24.997, 24.999, 25.01,
291 25.006, 24.998, 25.009, 25.001, 25.011, 25.008, 25.002, 24.996, 25.005, 24.994, 25.007,
292 24.996, 24.993, 25.006, 24.998, 25.004, 24.995, 25.002, 24.996, 25.003, 24.993, 25.007,
293 25.004, 24.995, 25.006, 24.994, 25.002, 25.0, 25.007, 24.998, 25.004, 24.993, 25.005,
294 24.996, 25.002, 24.994, 25.006, 24.995, 24.993, 25.006, 24.995, 25.004, 24.997, 25.007,
295 24.994, 25.002, 24.996, 25.006, 25.004, 24.997, 25.006, 24.994, 25.002, 24.993, 25.005,
296 24.996, 25.007, 24.996, 24.995, 25.005, 24.998, 25.003, 24.993, 25.007, 24.996, 25.004,
297 24.994, 25.005, 24.996, 25.003, 24.998, 25.002, 24.997, 25.004, 25.0, 25.023, 25.025,
298 25.021, 25.006, 24.994, 25.003, 24.997, 25.005, 24.998, 25.007, 24.995, 25.002, 24.993,
299 24.994, 25.007, 24.997, 25.006, 24.993, 25.005, 24.996, 25.003, 24.999, 25.0, 25.003,
300 24.996, 25.007, 24.994, 25.005, 24.997, 25.002, 24.993, 25.006, 24.997, 24.997, 25.004,
301 24.993, 25.006, 24.998, 25.005, 24.995, 25.007, 24.994, 25.001,
302 ];
303 assert_eq!(data.len(), 250);
304
305 let wide = boxcox_capability(&data, None, None, R).expect("positive data, no spec");
306 assert!(wide.lambda_at_bound, "{wide:?}");
307 assert_eq!(wide.lambda, R.0, "{wide:?}");
308 assert!(wide.indices.is_none(), "no spec was given");
309
310 let with_spec =
313 boxcox_capability(&data, Some(25.05), Some(24.95), R).expect("positive data");
314 assert!(with_spec.lambda_at_bound, "{with_spec:?}");
315 assert_eq!(with_spec.lambda, wide.lambda);
316 assert!(
317 indices(&with_spec).pp.is_some_and(f64::is_finite),
318 "{with_spec:?}"
319 );
320 }
321
322 fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
325 (1..=n)
326 .map(|i| {
327 let p = (i as f64 - 0.5) / n as f64;
328 let z = 10.0 + 2.0 * u_numflow::special::inverse_normal_cdf(p);
329 (lambda0 * z + 1.0).powf(1.0 / lambda0)
330 })
331 .collect()
332 }
333
334 #[test]
335 fn boxcox_capability_skewed_data() {
336 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
338 let result = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
339 assert!(result.lambda.abs() < 0.6, "lambda={}", result.lambda);
340 assert!(!result.lambda_at_bound);
341 assert!(indices(&result).pp.is_some() || indices(&result).ppk.is_some());
342 }
343
344 #[test]
345 fn boxcox_capability_reports_no_short_term_indices() {
346 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
349 let r = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
350 let i = indices(&r);
351 assert!(i.cp.is_none());
352 assert!(i.cpk.is_none());
353 assert!(i.cpu.is_none());
354 assert!(i.cpl.is_none());
355 assert!(i.pp.is_some(), "the long-term indices are reported");
356 }
357
358 #[test]
359 fn boxcox_capability_says_when_the_range_cut_lambda_short() {
360 let data = normal_after_boxcox(4.0, 100);
363 let wide = boxcox_capability(&data, Some(40.0), None, R).unwrap();
364 assert!(!wide.lambda_at_bound, "lambda={}", wide.lambda);
365 assert!((wide.lambda - 4.0).abs() < 0.5, "lambda={}", wide.lambda);
366
367 let narrow = boxcox_capability(&data, Some(40.0), None, (-2.0, 2.0)).unwrap();
368 assert!(narrow.lambda_at_bound);
369 assert_eq!(narrow.lambda, 2.0);
370 assert_ne!(indices(&narrow).ppk, indices(&wide).ppk);
373 }
374
375 #[test]
376 fn boxcox_capability_without_limits_estimates_lambda_only() {
377 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
378 let bare = boxcox_capability(&data, None, None, R).unwrap();
379 let with = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
380 assert!(bare.indices.is_none());
381 assert_eq!(bare.lambda, with.lambda);
382 assert_eq!(bare.lambda_at_bound, with.lambda_at_bound);
383 }
384
385 #[test]
386 fn boxcox_capability_rejects_an_invalid_lambda_range() {
387 let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
388 for range in [(2.0, -2.0), (1.0, 1.0), (f64::NAN, 2.0)] {
389 assert_eq!(
390 boxcox_capability(&data, Some(20.0), None, range).unwrap_err(),
391 NonNormalCapabilityError::InvalidLambdaRange,
392 "{range:?}"
393 );
394 }
395 }
396
397 #[test]
398 fn boxcox_capability_non_finite_error() {
399 let data = vec![1.0, f64::NAN, 2.0, 3.0, 4.0];
400 assert_eq!(
401 boxcox_capability(&data, Some(10.0), None, R).unwrap_err(),
402 NonNormalCapabilityError::NonFiniteData
403 );
404 }
405
406 #[test]
407 fn boxcox_capability_non_positive_error() {
408 let data = vec![1.0, -1.0, 2.0, 3.0, 4.0, 5.0];
409 assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
410 }
411
412 #[test]
413 fn boxcox_capability_insufficient_data() {
414 let data = vec![1.0, 2.0, 3.0]; assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
416 }
417
418 #[test]
419 fn boxcox_capability_lambda_in_range() {
420 let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
421 let result = boxcox_capability(&data, Some(25.0), Some(0.5), R).unwrap();
422 assert!(result.lambda >= R.0 && result.lambda <= R.1);
423 }
424
425 #[test]
426 fn boxcox_capability_usl_only() {
427 let data: Vec<f64> = (1..=20).map(|i| i as f64 * 0.5).collect();
428 let result = boxcox_capability(&data, Some(20.0), None, R).unwrap();
429 assert!(indices(&result).ppk.is_some());
431 assert!(indices(&result).pp.is_none());
432 }
433
434 #[test]
435 fn boxcox_capability_lsl_only() {
436 let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
437 let result = boxcox_capability(&data, None, Some(0.5), R).unwrap();
438 assert!(indices(&result).ppk.is_some());
439 assert!(indices(&result).pp.is_none());
440 }
441
442 #[test]
443 fn boxcox_capability_two_sided() {
444 let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.1).exp()).collect();
449 let result = boxcox_capability(&data, Some(20.0), Some(1.0), R).unwrap();
450 let i = indices(&result);
451 assert!(i.pp.is_some());
452 assert!(i.ppk.is_some());
453 assert!(i.cp.is_none());
454 assert!(i.cpk.is_none());
455 }
456
457 #[test]
458 fn boxcox_capability_non_positive_spec_error() {
459 let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
460 assert!(boxcox_capability(&data, Some(20.0), Some(-1.0), R).is_err());
462 }
463
464 #[test]
465 fn boxcox_capability_result_has_valid_lambda() {
466 let data: Vec<f64> = (1..=15).map(|i| (i as f64).powi(2)).collect();
467 let result = boxcox_capability(&data, Some(250.0), Some(0.5), R).unwrap();
468 assert!(result.lambda.is_finite());
469 assert!(result.lambda >= R.0 && result.lambda <= R.1);
470 }
471}