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`).
12//! 2. Transform the data: `y(λ)`.
13//! 3. Transform the specification limits using the same λ.
14//! 4. Compute capability indices on the transformed scale.
15//!
16//! # References
17//!
18//! - Box, G. E. P. & Cox, D. R. (1964). "An analysis of transformations."
19//!   *Journal of the Royal Statistical Society, Series B*, 26(2), 211–252.
20//! - Clements, J. A. (1989). "Process capability calculations for non-normal
21//!   distributions." *Quality Progress*, 22(9), 95–100.
22
23use std::fmt;
24
25use u_numflow::transforms::{box_cox, estimate_lambda, TransformError};
26
27use crate::capability::{CapabilityIndices, ProcessCapability};
28
29// ── Error type ────────────────────────────────────────────────────────────────
30
31/// Errors that can arise from non-normal process capability analysis.
32#[derive(Debug, Clone, PartialEq)]
33pub enum NonNormalCapabilityError {
34    /// All data values must be strictly positive for Box-Cox transformation.
35    NonPositiveData,
36    /// At least 4 data points are required for reliable capability analysis.
37    InsufficientData,
38    /// Failed to transform a specification limit (e.g., limit is not positive).
39    SpecTransformError,
40    /// Capability computation failed (e.g., all transformed data are identical).
41    CapabilityError,
42    /// At least one specification limit (USL or LSL) must be provided.
43    NoSpecLimits,
44}
45
46impl fmt::Display for NonNormalCapabilityError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            NonNormalCapabilityError::NonPositiveData => {
50                write!(
51                    f,
52                    "Box-Cox requires all data values to be strictly positive"
53                )
54            }
55            NonNormalCapabilityError::InsufficientData => {
56                write!(
57                    f,
58                    "at least 4 data points are required for capability analysis"
59                )
60            }
61            NonNormalCapabilityError::SpecTransformError => {
62                write!(
63                    f,
64                    "failed to transform specification limit — limit must be positive"
65                )
66            }
67            NonNormalCapabilityError::CapabilityError => {
68                write!(
69                    f,
70                    "capability computation failed — check that data has non-zero variance"
71                )
72            }
73            NonNormalCapabilityError::NoSpecLimits => {
74                write!(
75                    f,
76                    "at least one specification limit (USL or LSL) must be provided"
77                )
78            }
79        }
80    }
81}
82
83impl std::error::Error for NonNormalCapabilityError {}
84
85impl From<TransformError> for NonNormalCapabilityError {
86    fn from(e: TransformError) -> Self {
87        match e {
88            TransformError::NonPositiveData => NonNormalCapabilityError::NonPositiveData,
89            TransformError::InsufficientData => NonNormalCapabilityError::InsufficientData,
90            TransformError::InvalidInverse => NonNormalCapabilityError::SpecTransformError,
91        }
92    }
93}
94
95// ── Result type ───────────────────────────────────────────────────────────────
96
97/// Result of a Box-Cox-based non-normal capability analysis.
98#[derive(Debug, Clone)]
99pub struct NonNormalCapabilityResult {
100    /// The estimated optimal Box-Cox transformation parameter λ.
101    ///
102    /// λ ≈ 0 corresponds to a log transform; λ = 1 is the identity (no transform);
103    /// λ = 0.5 is approximately a square-root transform.
104    pub lambda: f64,
105    /// Capability indices computed on the Box-Cox-transformed scale.
106    ///
107    /// Only the **long-term** indices (`pp`, `ppk`, `ppu`, `ppl`) are reported.
108    /// `cp`, `cpk`, `cpu` and `cpl` are always `None`: they are defined against
109    /// a within-subgroup sigma, and a flat observation vector carries no
110    /// subgroup structure to estimate one from. `cpm` follows its usual rule
111    /// (both limits and a target, on the transformed scale).
112    pub indices: CapabilityIndices,
113}
114
115// ── Public API ────────────────────────────────────────────────────────────────
116
117/// Compute process capability indices for non-normal data via Box-Cox transformation.
118///
119/// The data are first transformed to approximate normality using the optimal
120/// Box-Cox parameter λ (estimated via maximum likelihood over `[-2, 2]`).
121/// Specification limits are transformed using the same λ. The **long-term**
122/// capability indices (Pp, Ppk, Ppu, Ppl) are then computed on the transformed
123/// scale. The short-term indices (Cp, Cpk, Cpu, Cpl) are **not** reported —
124/// see [`NonNormalCapabilityResult::indices`] for why.
125///
126/// # Arguments
127///
128/// * `data` — Process observations. All values must be strictly positive.
129/// * `usl` — Upper specification limit (optional). Must be positive if provided.
130/// * `lsl` — Lower specification limit (optional). Must be positive if provided.
131///
132/// # Errors
133///
134/// Returns [`NonNormalCapabilityError`] if:
135/// - Fewer than 4 data points are provided.
136/// - Any data value is ≤ 0 (Box-Cox requires strictly positive data).
137/// - Neither `usl` nor `lsl` is provided.
138/// - A specification limit is ≤ 0 (cannot be Box-Cox transformed).
139/// - Capability computation fails (e.g., zero variance in transformed data).
140///
141/// # Examples
142///
143/// ```
144/// use u_analytics::capability::boxcox_capability;
145///
146/// // Right-skewed data
147/// let data: Vec<f64> = (1..=20).map(|i| (i as f64 * 0.3_f64).exp()).collect();
148/// let result = boxcox_capability(&data, Some(100.0), Some(1.0)).unwrap();
149/// assert!(result.lambda >= -2.0 && result.lambda <= 2.0);
150/// assert!(result.indices.ppk.is_some());
151/// ```
152pub fn boxcox_capability(
153    data: &[f64],
154    usl: Option<f64>,
155    lsl: Option<f64>,
156) -> Result<NonNormalCapabilityResult, NonNormalCapabilityError> {
157    // Validate: at least one spec limit
158    if usl.is_none() && lsl.is_none() {
159        return Err(NonNormalCapabilityError::NoSpecLimits);
160    }
161
162    // Validate: sufficient data
163    if data.len() < 4 {
164        return Err(NonNormalCapabilityError::InsufficientData);
165    }
166
167    // Validate: all data must be strictly positive
168    if data.iter().any(|&v| v <= 0.0) {
169        return Err(NonNormalCapabilityError::NonPositiveData);
170    }
171
172    // Estimate optimal λ
173    let lambda = estimate_lambda(data, -2.0, 2.0)?;
174
175    // Transform data
176    let y_t = box_cox(data, lambda)?;
177
178    // Transform spec limits (box_cox requires len >= 2, use a dummy second element)
179    let usl_t = usl
180        .map(|u| {
181            if u <= 0.0 {
182                return Err(NonNormalCapabilityError::SpecTransformError);
183            }
184            // pair with data[0] (positive) so box_cox gets len=2
185            let pair = [u, data[0]];
186            box_cox(&pair, lambda)
187                .map(|v| v[0])
188                .map_err(|_| NonNormalCapabilityError::SpecTransformError)
189        })
190        .transpose()?;
191
192    let lsl_t = lsl
193        .map(|l| {
194            if l <= 0.0 {
195                return Err(NonNormalCapabilityError::SpecTransformError);
196            }
197            let pair = [l, data[0]];
198            box_cox(&pair, lambda)
199                .map(|v| v[0])
200                .map_err(|_| NonNormalCapabilityError::SpecTransformError)
201        })
202        .transpose()?;
203
204    // Build ProcessCapability on transformed scale
205    // ProcessCapability::new validates usl > lsl when both present
206    let spec = ProcessCapability::new(usl_t, lsl_t)
207        .map_err(|_| NonNormalCapabilityError::CapabilityError)?;
208
209    // A Box-Cox analysis starts from a flat vector, so there is no rational
210    // subgrouping and therefore no short-term sigma to estimate: only the
211    // long-term indices are reported, which is what `compute_overall` does.
212    let indices = spec
213        .compute_overall(&y_t)
214        .ok_or(NonNormalCapabilityError::CapabilityError)?;
215
216    Ok(NonNormalCapabilityResult { lambda, indices })
217}
218
219// ── Tests ─────────────────────────────────────────────────────────────────────
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn boxcox_capability_skewed_data() {
227        // Right-skewed exponential-like data
228        let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
229        let result = boxcox_capability(&data, Some(150.0), Some(1.0)).unwrap();
230        assert!(result.lambda.abs() < 0.6, "lambda={}", result.lambda);
231        assert!(result.indices.pp.is_some() || result.indices.ppk.is_some());
232    }
233
234    #[test]
235    fn boxcox_capability_reports_no_short_term_indices() {
236        // Cp/Cpk need a within-subgroup sigma; a flat vector has none. Filling
237        // them from the overall sigma would make Cp == Pp for every input.
238        let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
239        let r = boxcox_capability(&data, Some(150.0), Some(1.0)).unwrap();
240        assert!(r.indices.cp.is_none());
241        assert!(r.indices.cpk.is_none());
242        assert!(r.indices.cpu.is_none());
243        assert!(r.indices.cpl.is_none());
244        assert!(r.indices.pp.is_some(), "the long-term indices are reported");
245    }
246
247    #[test]
248    fn boxcox_capability_non_positive_error() {
249        let data = vec![1.0, -1.0, 2.0, 3.0, 4.0, 5.0];
250        assert!(boxcox_capability(&data, Some(10.0), None).is_err());
251    }
252
253    #[test]
254    fn boxcox_capability_insufficient_data() {
255        let data = vec![1.0, 2.0, 3.0]; // < 4 points
256        assert!(boxcox_capability(&data, Some(10.0), None).is_err());
257    }
258
259    #[test]
260    fn boxcox_capability_lambda_in_range() {
261        let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
262        let result = boxcox_capability(&data, Some(25.0), Some(0.5)).unwrap();
263        assert!(result.lambda >= -2.0 && result.lambda <= 2.0);
264    }
265
266    #[test]
267    fn boxcox_capability_no_spec_error() {
268        let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
269        assert!(boxcox_capability(&data, None, None).is_err());
270    }
271
272    #[test]
273    fn boxcox_capability_usl_only() {
274        let data: Vec<f64> = (1..=20).map(|i| i as f64 * 0.5).collect();
275        let result = boxcox_capability(&data, Some(20.0), None).unwrap();
276        // With USL only: pp is None (needs both limits), ppk should be Some
277        assert!(result.indices.ppk.is_some());
278        assert!(result.indices.pp.is_none());
279    }
280
281    #[test]
282    fn boxcox_capability_lsl_only() {
283        let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
284        let result = boxcox_capability(&data, None, Some(0.5)).unwrap();
285        assert!(result.indices.ppk.is_some());
286        assert!(result.indices.pp.is_none());
287    }
288
289    #[test]
290    fn boxcox_capability_two_sided() {
291        // Both limits present → the long-term pair is reported. The short-term
292        // pair is not: this assertion used to require `cp`/`cpk` to be `Some`,
293        // which pinned the very behaviour that made Cp equal Pp for every
294        // input. See `boxcox_capability_reports_no_short_term_indices`.
295        let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.1).exp()).collect();
296        let result = boxcox_capability(&data, Some(20.0), Some(1.0)).unwrap();
297        assert!(result.indices.pp.is_some());
298        assert!(result.indices.ppk.is_some());
299        assert!(result.indices.cp.is_none());
300        assert!(result.indices.cpk.is_none());
301    }
302
303    #[test]
304    fn boxcox_capability_non_positive_spec_error() {
305        let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
306        // LSL = -1 is non-positive → SpecTransformError
307        assert!(boxcox_capability(&data, Some(20.0), Some(-1.0)).is_err());
308    }
309
310    #[test]
311    fn boxcox_capability_result_has_valid_lambda() {
312        let data: Vec<f64> = (1..=15).map(|i| (i as f64).powi(2)).collect();
313        let result = boxcox_capability(&data, Some(250.0), Some(0.5)).unwrap();
314        assert!(result.lambda.is_finite());
315        assert!(result.lambda >= -2.0 && result.lambda <= 2.0);
316    }
317}