Skip to main content

u_analytics/capability/
nonnormal.rs

1//! Non-normal process capability using Box-Cox transformation.
2//!
3//! When process data is non-normal (e.g., right-skewed exponential-like data),
4//! standard capability indices (Cp, Cpk) computed on raw data can be misleading.
5//! This module applies the Box-Cox power transformation to map the data to
6//! approximate normality, then computes standard capability indices on the
7//! transformed scale.
8//!
9//! # Algorithm
10//!
11//! 1. Estimate the optimal λ via maximum likelihood (`estimate_lambda`) over a
12//!    search range — by default `[-5, 5]`, the range Minitab searches.
13//! 2. Transform the data: `y(λ)`.
14//! 3. Transform the specification limits using the same λ.
15//! 4. Compute capability indices on the transformed scale.
16//!
17//! # References
18//!
19//! - Box, G. E. P. & Cox, D. R. (1964). "An analysis of transformations."
20//!   *Journal of the Royal Statistical Society, Series B*, 26(2), 211–252.
21//! - Clements, J. A. (1989). "Process capability calculations for non-normal
22//!   distributions." *Quality Progress*, 22(9), 95–100.
23
24use std::fmt;
25
26use u_numflow::transforms::{box_cox, estimate_lambda, TransformError};
27
28use crate::capability::{CapabilityIndices, ProcessCapability};
29
30/// The default λ search range, `[-5, 5]`.
31///
32/// This is the range Minitab searches for the optimal Box-Cox λ. Practice
33/// commonly prefers a λ within `[-2, 2]`; a narrower range can be passed, and
34/// [`NonNormalCapabilityResult::lambda_at_bound`] reports when it cut the
35/// search short.
36pub const DEFAULT_LAMBDA_RANGE: (f64, f64) = (-5.0, 5.0);
37
38// ── Error type ────────────────────────────────────────────────────────────────
39
40/// Errors that can arise from non-normal process capability analysis.
41#[derive(Debug, Clone, PartialEq)]
42pub enum NonNormalCapabilityError {
43    /// All data values must be strictly positive for Box-Cox transformation.
44    NonPositiveData,
45    /// Data contains NaN or an infinity.
46    NonFiniteData,
47    /// At least 4 data points are required for reliable capability analysis.
48    InsufficientData,
49    /// Failed to transform a specification limit (e.g., limit is not positive).
50    SpecTransformError,
51    /// Capability computation failed (e.g., all transformed data are identical).
52    CapabilityError,
53    /// The λ search range is not finite with `min < max`.
54    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            // The data at the estimated λ does not fit in f64: nothing to compute on
107            // (and any reason u-numflow adds later is a failure to compute, not input).
108            _ => NonNormalCapabilityError::CapabilityError,
109        }
110    }
111}
112
113// ── Result type ───────────────────────────────────────────────────────────────
114
115/// Result of a Box-Cox-based non-normal capability analysis.
116#[derive(Debug, Clone)]
117pub struct NonNormalCapabilityResult {
118    /// The estimated optimal Box-Cox transformation parameter λ.
119    ///
120    /// λ ≈ 0 corresponds to a log transform; λ = 1 is the identity (no transform);
121    /// λ = 0.5 is approximately a square-root transform.
122    pub lambda: f64,
123    /// `true` when the likelihood maximum lies on an end of the λ search range:
124    /// the likelihood was still rising there, so `lambda` is that range limit
125    /// (exactly) rather than an interior estimate, and the transformed-scale
126    /// indices are computed at a λ the data did not choose. Widen the range to
127    /// find the unconstrained optimum.
128    pub lambda_at_bound: bool,
129    /// Capability indices computed on the Box-Cox-transformed scale, or `None`
130    /// when no specification limit was given (the transform alone was asked
131    /// for).
132    ///
133    /// Only the **long-term** indices (`pp`, `ppk`, `ppu`, `ppl`) are reported.
134    /// `cp`, `cpk`, `cpu` and `cpl` are always `None`: they are defined against
135    /// a within-subgroup sigma, and a flat observation vector carries no
136    /// subgroup structure to estimate one from. `cpm` follows its usual rule
137    /// (both limits and a target, on the transformed scale).
138    pub indices: Option<CapabilityIndices>,
139}
140
141// ── Public API ────────────────────────────────────────────────────────────────
142
143/// Compute process capability indices for non-normal data via Box-Cox transformation.
144///
145/// The data are first transformed to approximate normality using the optimal
146/// Box-Cox parameter λ, estimated via maximum likelihood over `lambda_range`
147/// (usually [`DEFAULT_LAMBDA_RANGE`]). Specification limits are transformed
148/// using the same λ. The **long-term** capability indices (Pp, Ppk, Ppu, Ppl)
149/// are then computed on the transformed scale. The short-term indices
150/// (Cp, Cpk, Cpu, Cpl) are **not** reported — see
151/// [`NonNormalCapabilityResult::indices`] for why.
152///
153/// Specification limits are optional: without either, λ and
154/// [`NonNormalCapabilityResult::lambda_at_bound`] are still estimated and
155/// `indices` is `None` — the "does a transform achieve normality" half of a
156/// non-normal capability workflow comes before the limits are known.
157///
158/// # Arguments
159///
160/// * `data` — Process observations. All values must be strictly positive.
161/// * `usl` — Upper specification limit (optional). Must be positive if provided.
162/// * `lsl` — Lower specification limit (optional). Must be positive if provided.
163/// * `lambda_range` — `(min, max)` λ search range, finite with `min < max`.
164///
165/// # Errors
166///
167/// Returns [`NonNormalCapabilityError`] if:
168/// - Fewer than 4 data points are provided.
169/// - Any data value is ≤ 0 (Box-Cox requires strictly positive data).
170/// - `lambda_range` is not finite with `min < max`.
171/// - A specification limit is ≤ 0 (cannot be Box-Cox transformed).
172/// - Capability computation fails (e.g., zero variance in transformed data).
173///
174/// # Examples
175///
176/// ```
177/// use u_analytics::capability::{boxcox_capability, DEFAULT_LAMBDA_RANGE};
178///
179/// // Right-skewed data
180/// let data: Vec<f64> = (1..=20).map(|i| (i as f64 * 0.3_f64).exp()).collect();
181/// let result = boxcox_capability(&data, Some(100.0), Some(1.0), DEFAULT_LAMBDA_RANGE).unwrap();
182/// assert!(!result.lambda_at_bound);
183/// assert!(result.indices.expect("limits given").ppk.is_some());
184///
185/// // No limits yet: the transform alone.
186/// let lambda_only = boxcox_capability(&data, None, None, DEFAULT_LAMBDA_RANGE).unwrap();
187/// assert_eq!(lambda_only.lambda, result.lambda);
188/// assert!(lambda_only.indices.is_none());
189/// ```
190pub fn boxcox_capability(
191    data: &[f64],
192    usl: Option<f64>,
193    lsl: Option<f64>,
194    lambda_range: (f64, f64),
195) -> Result<NonNormalCapabilityResult, NonNormalCapabilityError> {
196    // Validate: sufficient data
197    if data.len() < 4 {
198        return Err(NonNormalCapabilityError::InsufficientData);
199    }
200
201    // Validate: finite, then strictly positive
202    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    // Estimate optimal λ
210    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    // Transform data
222    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        // box_cox needs at least 2 values; pair the limit with data[0] (positive)
229        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    // Build ProcessCapability on transformed scale
238    // ProcessCapability::new validates usl > lsl when both present
239    let spec = ProcessCapability::new(usl_t, lsl_t)
240        .map_err(|_| NonNormalCapabilityError::CapabilityError)?;
241
242    // A Box-Cox analysis starts from a flat vector, so there is no rational
243    // subgrouping and therefore no short-term sigma to estimate: only the
244    // long-term indices are reported, which is what `compute_overall` does.
245    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// ── Tests ─────────────────────────────────────────────────────────────────────
257
258#[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    /// A narrow sample's λ sits on the end of the range, and the result says so.
269    ///
270    /// The estimator lives in the foundation crate, so what this pins is the
271    /// path: a caller reaching Box-Cox capability through this function is told
272    /// the same thing a caller reaching the estimator directly is told. The
273    /// sample is one a consumer reported -- 250 readings inside a 0.13 % band,
274    /// where the likelihood is monotone and the estimate used to stop just
275    /// short of the bound and call itself interior.
276    #[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        // A spec does not change where λ came from, and the indices are then
311        // computed at that same λ rather than at a point beside it.
312        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    /// Data exactly normal after a Box-Cox transform with `lambda0`: normal
323    /// quantiles pushed through the inverse transform.
324    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        // Right-skewed exponential-like data
337        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        // Cp/Cpk need a within-subgroup sigma; a flat vector has none. Filling
347        // them from the overall sigma would make Cp == Pp for every input.
348        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        // Likelihood peaks near lambda = 4: the default range finds it inside,
361        // a [-2, 2] range stops at 2 and must say so.
362        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        // The indices are still computed — at the bound — and they differ from
371        // the ones at the data's own lambda.
372        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]; // < 4 points
415        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        // With USL only: pp is None (needs both limits), ppk should be Some
430        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        // Both limits present → the long-term pair is reported. The short-term
445        // pair is not: this assertion used to require `cp`/`cpk` to be `Some`,
446        // which pinned the very behaviour that made Cp equal Pp for every
447        // input. See `boxcox_capability_reports_no_short_term_indices`.
448        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        // LSL = -1 is non-positive → SpecTransformError
461        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}