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 /// Data exactly normal after a Box-Cox transform with `lambda0`: normal
266 /// quantiles pushed through the inverse transform.
267 fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
268 (1..=n)
269 .map(|i| {
270 let p = (i as f64 - 0.5) / n as f64;
271 let z = 10.0 + 2.0 * u_numflow::special::inverse_normal_cdf(p);
272 (lambda0 * z + 1.0).powf(1.0 / lambda0)
273 })
274 .collect()
275 }
276
277 #[test]
278 fn boxcox_capability_skewed_data() {
279 // Right-skewed exponential-like data
280 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
281 let result = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
282 assert!(result.lambda.abs() < 0.6, "lambda={}", result.lambda);
283 assert!(!result.lambda_at_bound);
284 assert!(indices(&result).pp.is_some() || indices(&result).ppk.is_some());
285 }
286
287 #[test]
288 fn boxcox_capability_reports_no_short_term_indices() {
289 // Cp/Cpk need a within-subgroup sigma; a flat vector has none. Filling
290 // them from the overall sigma would make Cp == Pp for every input.
291 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
292 let r = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
293 let i = indices(&r);
294 assert!(i.cp.is_none());
295 assert!(i.cpk.is_none());
296 assert!(i.cpu.is_none());
297 assert!(i.cpl.is_none());
298 assert!(i.pp.is_some(), "the long-term indices are reported");
299 }
300
301 #[test]
302 fn boxcox_capability_says_when_the_range_cut_lambda_short() {
303 // Likelihood peaks near lambda = 4: the default range finds it inside,
304 // a [-2, 2] range stops at 2 and must say so.
305 let data = normal_after_boxcox(4.0, 100);
306 let wide = boxcox_capability(&data, Some(40.0), None, R).unwrap();
307 assert!(!wide.lambda_at_bound, "lambda={}", wide.lambda);
308 assert!((wide.lambda - 4.0).abs() < 0.5, "lambda={}", wide.lambda);
309
310 let narrow = boxcox_capability(&data, Some(40.0), None, (-2.0, 2.0)).unwrap();
311 assert!(narrow.lambda_at_bound);
312 assert_eq!(narrow.lambda, 2.0);
313 // The indices are still computed — at the bound — and they differ from
314 // the ones at the data's own lambda.
315 assert_ne!(indices(&narrow).ppk, indices(&wide).ppk);
316 }
317
318 #[test]
319 fn boxcox_capability_without_limits_estimates_lambda_only() {
320 let data: Vec<f64> = (1..=25).map(|i| (i as f64 * 0.2).exp()).collect();
321 let bare = boxcox_capability(&data, None, None, R).unwrap();
322 let with = boxcox_capability(&data, Some(150.0), Some(1.0), R).unwrap();
323 assert!(bare.indices.is_none());
324 assert_eq!(bare.lambda, with.lambda);
325 assert_eq!(bare.lambda_at_bound, with.lambda_at_bound);
326 }
327
328 #[test]
329 fn boxcox_capability_rejects_an_invalid_lambda_range() {
330 let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
331 for range in [(2.0, -2.0), (1.0, 1.0), (f64::NAN, 2.0)] {
332 assert_eq!(
333 boxcox_capability(&data, Some(20.0), None, range).unwrap_err(),
334 NonNormalCapabilityError::InvalidLambdaRange,
335 "{range:?}"
336 );
337 }
338 }
339
340 #[test]
341 fn boxcox_capability_non_finite_error() {
342 let data = vec![1.0, f64::NAN, 2.0, 3.0, 4.0];
343 assert_eq!(
344 boxcox_capability(&data, Some(10.0), None, R).unwrap_err(),
345 NonNormalCapabilityError::NonFiniteData
346 );
347 }
348
349 #[test]
350 fn boxcox_capability_non_positive_error() {
351 let data = vec![1.0, -1.0, 2.0, 3.0, 4.0, 5.0];
352 assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
353 }
354
355 #[test]
356 fn boxcox_capability_insufficient_data() {
357 let data = vec![1.0, 2.0, 3.0]; // < 4 points
358 assert!(boxcox_capability(&data, Some(10.0), None, R).is_err());
359 }
360
361 #[test]
362 fn boxcox_capability_lambda_in_range() {
363 let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
364 let result = boxcox_capability(&data, Some(25.0), Some(0.5), R).unwrap();
365 assert!(result.lambda >= R.0 && result.lambda <= R.1);
366 }
367
368 #[test]
369 fn boxcox_capability_usl_only() {
370 let data: Vec<f64> = (1..=20).map(|i| i as f64 * 0.5).collect();
371 let result = boxcox_capability(&data, Some(20.0), None, R).unwrap();
372 // With USL only: pp is None (needs both limits), ppk should be Some
373 assert!(indices(&result).ppk.is_some());
374 assert!(indices(&result).pp.is_none());
375 }
376
377 #[test]
378 fn boxcox_capability_lsl_only() {
379 let data: Vec<f64> = (1..=20).map(|i| i as f64).collect();
380 let result = boxcox_capability(&data, None, Some(0.5), R).unwrap();
381 assert!(indices(&result).ppk.is_some());
382 assert!(indices(&result).pp.is_none());
383 }
384
385 #[test]
386 fn boxcox_capability_two_sided() {
387 // Both limits present → the long-term pair is reported. The short-term
388 // pair is not: this assertion used to require `cp`/`cpk` to be `Some`,
389 // which pinned the very behaviour that made Cp equal Pp for every
390 // input. See `boxcox_capability_reports_no_short_term_indices`.
391 let data: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.1).exp()).collect();
392 let result = boxcox_capability(&data, Some(20.0), Some(1.0), R).unwrap();
393 let i = indices(&result);
394 assert!(i.pp.is_some());
395 assert!(i.ppk.is_some());
396 assert!(i.cp.is_none());
397 assert!(i.cpk.is_none());
398 }
399
400 #[test]
401 fn boxcox_capability_non_positive_spec_error() {
402 let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
403 // LSL = -1 is non-positive → SpecTransformError
404 assert!(boxcox_capability(&data, Some(20.0), Some(-1.0), R).is_err());
405 }
406
407 #[test]
408 fn boxcox_capability_result_has_valid_lambda() {
409 let data: Vec<f64> = (1..=15).map(|i| (i as f64).powi(2)).collect();
410 let result = boxcox_capability(&data, Some(250.0), Some(0.5), R).unwrap();
411 assert!(result.lambda.is_finite());
412 assert!(result.lambda >= R.0 && result.lambda <= R.1);
413 }
414}