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            // The data at the estimated λ does not fit in f64: nothing to compute on.
104            TransformError::InvalidTransform => NonNormalCapabilityError::CapabilityError,
105            TransformError::InvalidLambdaRange => NonNormalCapabilityError::InvalidLambdaRange,
106        }
107    }
108}
109
110// ── Result type ───────────────────────────────────────────────────────────────
111
112/// Result of a Box-Cox-based non-normal capability analysis.
113#[derive(Debug, Clone)]
114pub struct NonNormalCapabilityResult {
115    /// The estimated optimal Box-Cox transformation parameter λ.
116    ///
117    /// λ ≈ 0 corresponds to a log transform; λ = 1 is the identity (no transform);
118    /// λ = 0.5 is approximately a square-root transform.
119    pub lambda: f64,
120    /// `true` when the likelihood maximum lies on an end of the λ search range:
121    /// the likelihood was still rising there, so `lambda` is that range limit
122    /// (exactly) rather than an interior estimate, and the transformed-scale
123    /// indices are computed at a λ the data did not choose. Widen the range to
124    /// find the unconstrained optimum.
125    pub lambda_at_bound: bool,
126    /// Capability indices computed on the Box-Cox-transformed scale, or `None`
127    /// when no specification limit was given (the transform alone was asked
128    /// for).
129    ///
130    /// Only the **long-term** indices (`pp`, `ppk`, `ppu`, `ppl`) are reported.
131    /// `cp`, `cpk`, `cpu` and `cpl` are always `None`: they are defined against
132    /// a within-subgroup sigma, and a flat observation vector carries no
133    /// subgroup structure to estimate one from. `cpm` follows its usual rule
134    /// (both limits and a target, on the transformed scale).
135    pub indices: Option<CapabilityIndices>,
136}
137
138// ── Public API ────────────────────────────────────────────────────────────────
139
140/// Compute process capability indices for non-normal data via Box-Cox transformation.
141///
142/// The data are first transformed to approximate normality using the optimal
143/// Box-Cox parameter λ, estimated via maximum likelihood over `lambda_range`
144/// (usually [`DEFAULT_LAMBDA_RANGE`]). Specification limits are transformed
145/// using the same λ. The **long-term** capability indices (Pp, Ppk, Ppu, Ppl)
146/// are then computed on the transformed scale. The short-term indices
147/// (Cp, Cpk, Cpu, Cpl) are **not** reported — see
148/// [`NonNormalCapabilityResult::indices`] for why.
149///
150/// Specification limits are optional: without either, λ and
151/// [`NonNormalCapabilityResult::lambda_at_bound`] are still estimated and
152/// `indices` is `None` — the "does a transform achieve normality" half of a
153/// non-normal capability workflow comes before the limits are known.
154///
155/// # Arguments
156///
157/// * `data` — Process observations. All values must be strictly positive.
158/// * `usl` — Upper specification limit (optional). Must be positive if provided.
159/// * `lsl` — Lower specification limit (optional). Must be positive if provided.
160/// * `lambda_range` — `(min, max)` λ search range, finite with `min < max`.
161///
162/// # Errors
163///
164/// Returns [`NonNormalCapabilityError`] if:
165/// - Fewer than 4 data points are provided.
166/// - Any data value is ≤ 0 (Box-Cox requires strictly positive data).
167/// - `lambda_range` is not finite with `min < max`.
168/// - A specification limit is ≤ 0 (cannot be Box-Cox transformed).
169/// - Capability computation fails (e.g., zero variance in transformed data).
170///
171/// # Examples
172///
173/// ```
174/// use u_analytics::capability::{boxcox_capability, DEFAULT_LAMBDA_RANGE};
175///
176/// // Right-skewed data
177/// let data: Vec<f64> = (1..=20).map(|i| (i as f64 * 0.3_f64).exp()).collect();
178/// let result = boxcox_capability(&data, Some(100.0), Some(1.0), DEFAULT_LAMBDA_RANGE).unwrap();
179/// assert!(!result.lambda_at_bound);
180/// assert!(result.indices.expect("limits given").ppk.is_some());
181///
182/// // No limits yet: the transform alone.
183/// let lambda_only = boxcox_capability(&data, None, None, DEFAULT_LAMBDA_RANGE).unwrap();
184/// assert_eq!(lambda_only.lambda, result.lambda);
185/// assert!(lambda_only.indices.is_none());
186/// ```
187pub fn boxcox_capability(
188    data: &[f64],
189    usl: Option<f64>,
190    lsl: Option<f64>,
191    lambda_range: (f64, f64),
192) -> Result<NonNormalCapabilityResult, NonNormalCapabilityError> {
193    // Validate: sufficient data
194    if data.len() < 4 {
195        return Err(NonNormalCapabilityError::InsufficientData);
196    }
197
198    // Validate: finite, then strictly positive
199    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    // Estimate optimal λ
207    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    // Transform data
219    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        // box_cox needs at least 2 values; pair the limit with data[0] (positive)
226        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    // Build ProcessCapability on transformed scale
235    // ProcessCapability::new validates usl > lsl when both present
236    let spec = ProcessCapability::new(usl_t, lsl_t)
237        .map_err(|_| NonNormalCapabilityError::CapabilityError)?;
238
239    // A Box-Cox analysis starts from a flat vector, so there is no rational
240    // subgrouping and therefore no short-term sigma to estimate: only the
241    // long-term indices are reported, which is what `compute_overall` does.
242    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// ── Tests ─────────────────────────────────────────────────────────────────────
254
255#[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    /// A narrow sample's λ sits on the end of the range, and the result says so.
266    ///
267    /// The estimator lives in the foundation crate, so what this pins is the
268    /// path: a caller reaching Box-Cox capability through this function is told
269    /// the same thing a caller reaching the estimator directly is told. The
270    /// sample is one a consumer reported -- 250 readings inside a 0.13 % band,
271    /// where the likelihood is monotone and the estimate used to stop just
272    /// short of the bound and call itself interior.
273    #[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        // A spec does not change where λ came from, and the indices are then
308        // computed at that same λ rather than at a point beside it.
309        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    /// Data exactly normal after a Box-Cox transform with `lambda0`: normal
320    /// quantiles pushed through the inverse transform.
321    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        // Right-skewed exponential-like data
334        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        // Cp/Cpk need a within-subgroup sigma; a flat vector has none. Filling
344        // them from the overall sigma would make Cp == Pp for every input.
345        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        // Likelihood peaks near lambda = 4: the default range finds it inside,
358        // a [-2, 2] range stops at 2 and must say so.
359        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        // The indices are still computed — at the bound — and they differ from
368        // the ones at the data's own lambda.
369        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]; // < 4 points
412        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        // With USL only: pp is None (needs both limits), ppk should be Some
427        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        // Both limits present → the long-term pair is reported. The short-term
442        // pair is not: this assertion used to require `cp`/`cpk` to be `Some`,
443        // which pinned the very behaviour that made Cp equal Pp for every
444        // input. See `boxcox_capability_reports_no_short_term_indices`.
445        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        // LSL = -1 is non-positive → SpecTransformError
458        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}