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}