Skip to main content

u_analytics/msa/
mod.rs

1//! Measurement System Analysis (MSA).
2//!
3//! Implements Gage R&R studies using both the X̄-R (Average & Range) method
4//! and the ANOVA method, following AIAG MSA 4th Edition.
5//!
6//! # Overview
7//!
8//! A Gage R&R study decomposes total measurement variation into:
9//! - **Repeatability (EV)**: Equipment variation — same operator, same part, multiple trials
10//! - **Reproducibility (AV)**: Appraiser variation — different operators, same part
11//! - **Part Variation (PV)**: True part-to-part variation
12//!
13//! # References
14//!
15//! - AIAG (2010). *Measurement Systems Analysis*, 4th ed.
16//! - Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed., §5.
17
18use u_numflow::special;
19use u_numflow::stats;
20
21// ---------------------------------------------------------------------------
22// Types
23// ---------------------------------------------------------------------------
24
25/// Input data for a Gage R&R study.
26///
27/// The `measurements` field is a 3D array indexed as `[part][operator][trial]`.
28/// All parts must have the same number of operators, and all operator×part cells
29/// must have the same number of trials.
30pub struct GageRRInput {
31    /// 3D measurement data: `measurements[part][operator][trial]`.
32    pub measurements: Vec<Vec<Vec<f64>>>,
33    /// Process tolerance (USL − LSL), optional for %Tolerance calculation.
34    pub tolerance: Option<f64>,
35}
36
37/// Results from a Gage R&R study (X̄-R or ANOVA method).
38#[derive(Debug, Clone)]
39pub struct GageRRResult {
40    /// Equipment Variation (Repeatability).
41    pub ev: f64,
42    /// Appraiser Variation (Reproducibility).
43    pub av: f64,
44    /// Gage R&R = √(EV² + AV²).
45    pub grr: f64,
46    /// Part Variation.
47    pub pv: f64,
48    /// Total Variation = √(GRR² + PV²).
49    pub tv: f64,
50
51    /// %EV = EV / TV × 100.
52    pub percent_ev: f64,
53    /// %AV = AV / TV × 100.
54    pub percent_av: f64,
55    /// %GRR = GRR / TV × 100.
56    pub percent_grr: f64,
57    /// %PV = PV / TV × 100.
58    pub percent_pv: f64,
59    /// %Tolerance = 6 × GRR / tolerance × 100 (if tolerance provided).
60    pub percent_tolerance: Option<f64>,
61
62    /// Number of Distinct Categories = floor(1.41 × PV / GRR), minimum 1.
63    pub ndc: u32,
64    /// Acceptability status based on %GRR.
65    pub status: GrrStatus,
66}
67
68/// ANOVA-based Gage R&R result with full ANOVA table and variance components.
69#[derive(Debug, Clone)]
70pub struct GageRRAnovaResult {
71    /// Two-factor crossed ANOVA table.
72    pub anova_table: AnovaTable,
73    /// Variance components extracted from expected mean squares.
74    pub variance_components: VarianceComponents,
75    /// Equipment Variation (Repeatability) = √σ²_repeatability.
76    pub ev: f64,
77    /// Appraiser Variation (Reproducibility) = √σ²_reproducibility.
78    pub av: f64,
79    /// Gage R&R = √(EV² + AV²).
80    pub grr: f64,
81    /// Part Variation = √σ²_part.
82    pub pv: f64,
83    /// Total Variation = √σ²_total.
84    pub tv: f64,
85    /// %GRR = GRR / TV × 100.
86    pub percent_grr: f64,
87    /// %Tolerance = 6 × GRR / tolerance × 100 (if tolerance provided).
88    pub percent_tolerance: Option<f64>,
89    /// Number of Distinct Categories.
90    pub ndc: u32,
91    /// Acceptability status.
92    pub status: GrrStatus,
93    /// Whether the Part×Operator interaction is significant (p ≤ 0.25).
94    pub interaction_significant: bool,
95    /// Whether the interaction term was pooled into error.
96    pub interaction_pooled: bool,
97}
98
99/// ANOVA table for a two-factor crossed design.
100#[derive(Debug, Clone)]
101pub struct AnovaTable {
102    /// Rows of the ANOVA table.
103    pub rows: Vec<AnovaRow>,
104}
105
106/// A single row in the ANOVA table.
107#[derive(Debug, Clone)]
108pub struct AnovaRow {
109    /// Source of variation: "Part", "Operator", "Part×Operator", "Repeatability", "Total".
110    pub source: String,
111    /// Degrees of freedom.
112    pub df: f64,
113    /// Sum of squares.
114    pub ss: f64,
115    /// Mean square (SS / DF).
116    pub ms: f64,
117    /// F statistic (None for Error/Total rows).
118    pub f_value: Option<f64>,
119    /// p-value from F distribution (None for Error/Total rows).
120    pub p_value: Option<f64>,
121}
122
123/// Variance components from ANOVA expected mean squares.
124#[derive(Debug, Clone)]
125pub struct VarianceComponents {
126    /// σ²_part — true part-to-part variation.
127    pub part: f64,
128    /// σ²_operator — operator main effect.
129    pub operator: f64,
130    /// σ²_interaction — part×operator interaction.
131    pub interaction: f64,
132    /// σ²_repeatability — within-cell (equipment) variation.
133    pub repeatability: f64,
134    /// σ²_reproducibility = σ²_operator + σ²_interaction.
135    pub reproducibility: f64,
136    /// σ²_total = σ²_part + σ²_grr.
137    pub total: f64,
138}
139
140/// Acceptability status based on %GRR (AIAG guidelines).
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum GrrStatus {
143    /// %GRR ≤ 10% — measurement system is acceptable.
144    Acceptable,
145    /// 10% < %GRR ≤ 30% — may be acceptable depending on application.
146    Marginal,
147    /// %GRR > 30% — measurement system needs improvement.
148    Unacceptable,
149}
150
151// ---------------------------------------------------------------------------
152// K-factor constants (AIAG MSA 4th Ed.)
153// ---------------------------------------------------------------------------
154
155/// K1 constants: 1/d2* for the number of trials.
156/// Index: K1[trials - 2] (trials = 2..=3).
157///
158/// Values from AIAG MSA 4th Edition, Table III-B-1.
159#[allow(clippy::approx_constant)]
160const K1: [f64; 2] = [0.8862, 0.5908];
161
162/// K2 constants: 1/d2* for the number of operators.
163/// Index: K2[operators - 2] (operators = 2..=3).
164///
165/// Values from AIAG MSA 4th Edition, Table III-B-1.
166#[allow(clippy::approx_constant)]
167const K2: [f64; 2] = [0.7071, 0.5231];
168
169/// K3 constants: 1/d2* for the number of parts.
170/// Index: K3[parts - 2] (parts = 2..=10).
171///
172/// Values from AIAG MSA 4th Edition, Table III-B-1.
173#[allow(clippy::approx_constant)]
174const K3: [f64; 9] = [
175    0.7071, 0.5231, 0.4467, 0.4030, 0.3742, 0.3534, 0.3375, 0.3249, 0.3146,
176];
177
178// ---------------------------------------------------------------------------
179// Helpers
180// ---------------------------------------------------------------------------
181
182/// Determine GRR status from %GRR.
183fn grr_status(percent_grr: f64) -> GrrStatus {
184    if percent_grr <= 10.0 {
185        GrrStatus::Acceptable
186    } else if percent_grr <= 30.0 {
187        GrrStatus::Marginal
188    } else {
189        GrrStatus::Unacceptable
190    }
191}
192
193/// Compute NDC = floor(1.41 × PV / GRR), minimum 1.
194fn compute_ndc(pv: f64, grr: f64) -> u32 {
195    if grr < 1e-300 {
196        return 1;
197    }
198    let ndc = (1.41 * pv / grr).floor() as i64;
199    ndc.max(1) as u32
200}
201
202/// Validate the 3D measurement array. Returns `(n_parts, n_operators, n_trials)`
203/// or an error message.
204fn validate_measurements(
205    measurements: &[Vec<Vec<f64>>],
206) -> Result<(usize, usize, usize), &'static str> {
207    let n_parts = measurements.len();
208    if n_parts < 2 {
209        return Err("at least 2 parts are required");
210    }
211
212    let n_operators = measurements[0].len();
213    if n_operators < 2 {
214        return Err("at least 2 operators are required");
215    }
216
217    let n_trials = measurements[0][0].len();
218    if n_trials < 2 {
219        return Err("at least 2 trials are required");
220    }
221
222    for part in measurements {
223        if part.len() != n_operators {
224            return Err("all parts must have the same number of operators");
225        }
226        for trials in part {
227            if trials.len() != n_trials {
228                return Err("all operator×part cells must have the same number of trials");
229            }
230            for &v in trials {
231                if !v.is_finite() {
232                    return Err("all measurements must be finite");
233                }
234            }
235        }
236    }
237
238    Ok((n_parts, n_operators, n_trials))
239}
240
241// ---------------------------------------------------------------------------
242// X̄-R Method
243// ---------------------------------------------------------------------------
244
245/// Gage R&R using the X̄-R (Average & Range) method (AIAG MSA 4th Edition).
246///
247/// # Algorithm
248///
249/// 1. For each operator×part cell, compute the range across trials.
250/// 2. R̄ = grand mean of all ranges.
251/// 3. For each operator, compute part averages → operator grand averages → X̄_diff.
252/// 4. EV = R̄ × K1(trials), AV = √((X̄_diff × K2)² − EV²/(n×r)), GRR = √(EV² + AV²).
253/// 5. Part means → Rp → PV = Rp × K3(parts), TV = √(GRR² + PV²).
254/// 6. NDC = floor(1.41 × PV / GRR).
255///
256/// # Arguments
257///
258/// * `input` — Measurement data and optional tolerance.
259///
260/// # Returns
261///
262/// `Err` if input dimensions are invalid or out of supported range
263/// (parts 2..=10, operators 2..=3, trials 2..=3).
264///
265/// # References
266///
267/// AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III.
268pub fn gage_rr_xbar_r(input: &GageRRInput) -> Result<GageRRResult, &'static str> {
269    let (n_parts, n_operators, n_trials) = validate_measurements(&input.measurements)?;
270
271    // Validate supported ranges for K-factor tables
272    if !(2..=3).contains(&n_trials) {
273        return Err("X̄-R method supports 2 or 3 trials");
274    }
275    if !(2..=3).contains(&n_operators) {
276        return Err("X̄-R method supports 2 or 3 operators");
277    }
278    if !(2..=10).contains(&n_parts) {
279        return Err("X̄-R method supports 2 to 10 parts");
280    }
281
282    // Step 1: Compute range for each operator×part cell
283    let mut ranges: Vec<f64> = Vec::with_capacity(n_parts * n_operators);
284    for part in &input.measurements {
285        for trials in part {
286            let min = trials.iter().copied().fold(f64::INFINITY, f64::min);
287            let max = trials.iter().copied().fold(f64::NEG_INFINITY, f64::max);
288            ranges.push(max - min);
289        }
290    }
291
292    // Step 2: R̄ = grand mean of all ranges
293    let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
294
295    // Step 3: Operator averages and X̄_diff
296    // Compute the average measurement for each operator across all parts and trials
297    let mut operator_avgs: Vec<f64> = Vec::with_capacity(n_operators);
298    for op in 0..n_operators {
299        let mut sum = 0.0;
300        let mut count = 0usize;
301        for part in &input.measurements {
302            for &v in &part[op] {
303                sum += v;
304                count += 1;
305            }
306        }
307        operator_avgs.push(sum / count as f64);
308    }
309
310    let x_diff = operator_avgs
311        .iter()
312        .copied()
313        .fold(f64::NEG_INFINITY, f64::max)
314        - operator_avgs.iter().copied().fold(f64::INFINITY, f64::min);
315
316    // Step 4: EV and AV
317    let k1 = K1[n_trials - 2];
318    let k2 = K2[n_operators - 2];
319
320    let ev = r_bar * k1;
321
322    let av_squared = (x_diff * k2).powi(2) - ev.powi(2) / (n_parts * n_trials) as f64;
323    let av = if av_squared > 0.0 {
324        av_squared.sqrt()
325    } else {
326        0.0
327    };
328
329    // Step 5: GRR
330    let grr = (ev.powi(2) + av.powi(2)).sqrt();
331
332    // Step 6: Part means across all operators/trials → PV
333    let mut part_means: Vec<f64> = Vec::with_capacity(n_parts);
334    for part in &input.measurements {
335        let mut sum = 0.0;
336        let mut count = 0usize;
337        for trials in part {
338            for &v in trials {
339                sum += v;
340                count += 1;
341            }
342        }
343        part_means.push(sum / count as f64);
344    }
345
346    let rp = part_means.iter().copied().fold(f64::NEG_INFINITY, f64::max)
347        - part_means.iter().copied().fold(f64::INFINITY, f64::min);
348
349    let k3 = K3[n_parts - 2];
350    let pv = rp * k3;
351
352    // Step 7: TV
353    let tv = (grr.powi(2) + pv.powi(2)).sqrt();
354
355    // Percentages
356    let (percent_ev, percent_av, percent_grr, percent_pv) = if tv > 1e-300 {
357        (
358            ev / tv * 100.0,
359            av / tv * 100.0,
360            grr / tv * 100.0,
361            pv / tv * 100.0,
362        )
363    } else {
364        (0.0, 0.0, 0.0, 0.0)
365    };
366
367    let percent_tolerance = input.tolerance.and_then(|tol| {
368        if tol > 1e-300 {
369            Some(grr / tol * 600.0)
370        } else {
371            None
372        }
373    });
374
375    let ndc = compute_ndc(pv, grr);
376    let status = grr_status(percent_grr);
377
378    Ok(GageRRResult {
379        ev,
380        av,
381        grr,
382        pv,
383        tv,
384        percent_ev,
385        percent_av,
386        percent_grr,
387        percent_pv,
388        percent_tolerance,
389        ndc,
390        status,
391    })
392}
393
394// ---------------------------------------------------------------------------
395// ANOVA Method
396// ---------------------------------------------------------------------------
397
398/// Gage R&R using the two-factor crossed ANOVA method (AIAG MSA 4th Edition).
399///
400/// # Algorithm
401///
402/// Performs a Part × Operator crossed ANOVA with replications (trials).
403/// Computes SS for Part, Operator, Interaction, and Error (Repeatability).
404/// Extracts variance components from expected mean squares.
405/// If the interaction p-value > 0.25, pools the interaction into error.
406///
407/// # Arguments
408///
409/// * `input` — Measurement data and optional tolerance.
410///
411/// # Returns
412///
413/// `Err` if input dimensions are invalid (need ≥ 2 parts, ≥ 2 operators, ≥ 2 trials).
414///
415/// # References
416///
417/// - AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III, Section D.
418/// - Montgomery (2019). *Introduction to Statistical Quality Control*, 8th ed., §5.4.
419pub fn gage_rr_anova(input: &GageRRInput) -> Result<GageRRAnovaResult, &'static str> {
420    let (p, o, r) = validate_measurements(&input.measurements)?;
421    let n_total = p * o * r;
422
423    // Compute grand mean
424    let mut grand_sum = 0.0;
425    for part in &input.measurements {
426        for trials in part {
427            for &v in trials {
428                grand_sum += v;
429            }
430        }
431    }
432    let grand_mean = grand_sum / n_total as f64;
433
434    // Part means (across all operators and trials)
435    let mut part_means: Vec<f64> = Vec::with_capacity(p);
436    for part in &input.measurements {
437        let mut sum = 0.0;
438        for trials in part {
439            for &v in trials {
440                sum += v;
441            }
442        }
443        part_means.push(sum / (o * r) as f64);
444    }
445
446    // Operator means (across all parts and trials)
447    let mut operator_means: Vec<f64> = Vec::with_capacity(o);
448    for op in 0..o {
449        let mut sum = 0.0;
450        for part in &input.measurements {
451            for &v in &part[op] {
452                sum += v;
453            }
454        }
455        operator_means.push(sum / (p * r) as f64);
456    }
457
458    // Cell means (part × operator, averaged over trials)
459    let mut cell_means: Vec<Vec<f64>> = Vec::with_capacity(p);
460    for part in &input.measurements {
461        let mut row: Vec<f64> = Vec::with_capacity(o);
462        for trials in part {
463            let cell_sum: f64 = trials.iter().sum();
464            row.push(cell_sum / r as f64);
465        }
466        cell_means.push(row);
467    }
468
469    // SS_Part = o * r * Σ(part_mean - grand_mean)²
470    let ss_part: f64 = part_means
471        .iter()
472        .map(|&pm| (pm - grand_mean).powi(2))
473        .sum::<f64>()
474        * (o * r) as f64;
475
476    // SS_Operator = p * r * Σ(operator_mean - grand_mean)²
477    let ss_operator: f64 = operator_means
478        .iter()
479        .map(|&om| (om - grand_mean).powi(2))
480        .sum::<f64>()
481        * (p * r) as f64;
482
483    // SS_Interaction = r * Σ_ij (cell_mean_ij - part_mean_i - operator_mean_j + grand_mean)²
484    let mut ss_interaction = 0.0;
485    for (i, row) in cell_means.iter().enumerate() {
486        for (j, &cm) in row.iter().enumerate() {
487            let residual = cm - part_means[i] - operator_means[j] + grand_mean;
488            ss_interaction += residual.powi(2);
489        }
490    }
491    ss_interaction *= r as f64;
492
493    // SS_Total = Σ(x_ijk - grand_mean)²
494    let mut ss_total = 0.0;
495    for part in &input.measurements {
496        for trials in part {
497            for &v in trials {
498                ss_total += (v - grand_mean).powi(2);
499            }
500        }
501    }
502
503    // SS_Error = SS_Total - SS_Part - SS_Operator - SS_Interaction
504    let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
505
506    // Degrees of freedom
507    let df_part = (p - 1) as f64;
508    let df_operator = (o - 1) as f64;
509    let df_interaction = ((p - 1) * (o - 1)) as f64;
510    let df_error = (p * o * (r - 1)) as f64;
511    let df_total = (n_total - 1) as f64;
512
513    // Mean squares
514    let ms_part = ss_part / df_part;
515    let ms_operator = ss_operator / df_operator;
516    let ms_interaction = if df_interaction > 0.0 {
517        ss_interaction / df_interaction
518    } else {
519        0.0
520    };
521    let ms_error = if df_error > 0.0 {
522        ss_error / df_error
523    } else {
524        0.0
525    };
526
527    // A mean-square denominator is numerically *degenerate* — indistinguishable
528    // from zero variance — when it collapses to floating-point noise relative to
529    // the total variation (e.g. every trial within each cell identical, so the
530    // within-cell repeatability SS is 0 up to rounding: ms ≈ ±1e-17). A *relative*
531    // floor, with a small absolute backstop for the all-identical case where the
532    // total MS is itself ~0, keeps the boundary sign-independent: a repeatability
533    // MS of +2e-17 and one that dips to −4e-18 under rounding are BOTH reported as
534    // "not computable" (F/p = None), rather than flipping between a spurious ~1e12
535    // finite F ("very significant") and None depending on the noise sign.
536    let ms_total = ss_total / df_total;
537    let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-12);
538    let is_valid_denom = |ms: f64| ms > degenerate_floor;
539
540    // F statistics and p-values
541    let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
542        let f_val = ms_interaction / ms_error;
543        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_interaction, df_error);
544        (Some(f_val), Some(p_val))
545    } else {
546        (None, None)
547    };
548
549    // Determine if interaction should be pooled (p > 0.25)
550    let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
551    let interaction_pooled = !interaction_significant;
552
553    // Pooled error term (AIAG "without interaction" model): interaction SS/df is
554    // folded into the error term, yielding a single pooled MS that serves as BOTH
555    // the common F-test denominator AND the repeatability variance component.
556    // Computed once here so every consumer (F-tests, ANOVA table, variance
557    // components) uses the identical value.
558    let pooled_ss = ss_error + ss_interaction;
559    let pooled_df = df_error + df_interaction;
560    let ms_pooled = if pooled_df > 0.0 {
561        pooled_ss / pooled_df
562    } else {
563        ms_error
564    };
565
566    // Determine the denominator for Part and Operator F-tests
567    let (denom_ms, denom_df) = if interaction_pooled {
568        (ms_pooled, pooled_df)
569    } else {
570        (ms_interaction, df_interaction)
571    };
572
573    let (f_part, p_part) = if is_valid_denom(denom_ms) {
574        let f_val = ms_part / denom_ms;
575        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_part, denom_df);
576        (Some(f_val), Some(p_val))
577    } else {
578        (None, None)
579    };
580
581    let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
582        let f_val = ms_operator / denom_ms;
583        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_operator, denom_df);
584        (Some(f_val), Some(p_val))
585    } else {
586        (None, None)
587    };
588
589    // Build ANOVA table. The error/repeatability row must reflect the SAME model
590    // the F-tests and variance components use. When the interaction is pooled we
591    // report the pooled error term (df = df_error + df_interaction) and drop the
592    // Part×Operator row (it has been folded into error), so that (a) the row df's
593    // sum to df_total and (b) the returned f_value for Part/Operator is
594    // reproducible from the table's own MS values (f_part = ms_part / ms_pooled).
595    // Previously the flag said "pooled" while the table still showed the unpooled
596    // Error row, making the reported F un-reproducible from the displayed numbers.
597    let mut rows = vec![
598        AnovaRow {
599            source: "Part".to_owned(),
600            df: df_part,
601            ss: ss_part,
602            ms: ms_part,
603            f_value: f_part,
604            p_value: p_part,
605        },
606        AnovaRow {
607            source: "Operator".to_owned(),
608            df: df_operator,
609            ss: ss_operator,
610            ms: ms_operator,
611            f_value: f_operator,
612            p_value: p_operator,
613        },
614    ];
615    if !interaction_pooled {
616        rows.push(AnovaRow {
617            source: "Part×Operator".to_owned(),
618            df: df_interaction,
619            ss: ss_interaction,
620            ms: ms_interaction,
621            f_value: f_interaction,
622            p_value: p_interaction,
623        });
624    }
625    rows.push(AnovaRow {
626        source: "Repeatability".to_owned(),
627        df: if interaction_pooled {
628            pooled_df
629        } else {
630            df_error
631        },
632        ss: if interaction_pooled {
633            pooled_ss
634        } else {
635            ss_error
636        },
637        ms: if interaction_pooled {
638            ms_pooled
639        } else {
640            ms_error
641        },
642        f_value: None,
643        p_value: None,
644    });
645    rows.push(AnovaRow {
646        source: "Total".to_owned(),
647        df: df_total,
648        ss: ss_total,
649        ms: ss_total / df_total,
650        f_value: None,
651        p_value: None,
652    });
653    let anova_table = AnovaTable { rows };
654
655    // Variance components from expected mean squares. When the interaction is
656    // pooled, EVERY component — repeatability included — is derived from the
657    // single pooled error MS. Previously repeatability alone leaked the un-pooled
658    // raw MS_error while operator/part used the pooled MS, mixing two models in
659    // one result and inflating GRR / %GRR. `.max(0.0)` guards against a negative
660    // std-dev (NaN) when rounding pushes the (already ~0) MS slightly below zero.
661    let sigma2_repeatability = (if interaction_pooled {
662        ms_pooled
663    } else {
664        ms_error
665    })
666    .max(0.0);
667
668    let sigma2_interaction = if interaction_pooled {
669        0.0
670    } else {
671        let val = (ms_interaction - ms_error) / r as f64;
672        val.max(0.0)
673    };
674
675    let sigma2_operator = if interaction_pooled {
676        let val = (ms_operator - ms_pooled) / (p * r) as f64;
677        val.max(0.0)
678    } else {
679        let val = (ms_operator - ms_interaction) / (p * r) as f64;
680        val.max(0.0)
681    };
682
683    let sigma2_part = if interaction_pooled {
684        let val = (ms_part - ms_pooled) / (o * r) as f64;
685        val.max(0.0)
686    } else {
687        let val = (ms_part - ms_interaction) / (o * r) as f64;
688        val.max(0.0)
689    };
690
691    let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
692    let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
693    let sigma2_total = sigma2_part + sigma2_grr;
694
695    let variance_components = VarianceComponents {
696        part: sigma2_part,
697        operator: sigma2_operator,
698        interaction: sigma2_interaction,
699        repeatability: sigma2_repeatability,
700        reproducibility: sigma2_reproducibility,
701        total: sigma2_total,
702    };
703
704    // Convert variance components to standard deviations (study variation)
705    let ev = sigma2_repeatability.sqrt();
706    let av = sigma2_reproducibility.sqrt();
707    let grr = sigma2_grr.sqrt();
708    let pv = sigma2_part.sqrt();
709    let tv = sigma2_total.sqrt();
710
711    let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
712
713    let percent_tolerance = input.tolerance.and_then(|tol| {
714        if tol > 1e-300 {
715            Some(grr / tol * 600.0)
716        } else {
717            None
718        }
719    });
720
721    let ndc = compute_ndc(pv, grr);
722    let status = grr_status(percent_grr);
723
724    Ok(GageRRAnovaResult {
725        anova_table,
726        variance_components,
727        ev,
728        av,
729        grr,
730        pv,
731        tv,
732        percent_grr,
733        percent_tolerance,
734        ndc,
735        status,
736        interaction_significant,
737        interaction_pooled,
738    })
739}
740
741// ---------------------------------------------------------------------------
742// Tests
743// ---------------------------------------------------------------------------
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    /// Generate a simple balanced dataset for testing.
750    /// 3 operators, 10 parts, 3 trials.
751    /// Based on AIAG MSA 4th Edition reference data.
752    fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
753        // measurements[part][operator][trial]
754        vec![
755            // Part 1
756            vec![
757                vec![0.29, 0.41, 0.64],  // Operator A
758                vec![0.08, 0.25, 0.07],  // Operator B
759                vec![0.04, -0.11, 0.75], // Operator C
760            ],
761            // Part 2
762            vec![
763                vec![-0.56, -0.68, -0.58],
764                vec![-0.47, -1.22, -0.68],
765                vec![-0.49, -0.56, -0.49],
766            ],
767            // Part 3
768            vec![
769                vec![1.34, 1.17, 1.27],
770                vec![1.19, 0.94, 1.34],
771                vec![1.02, 0.82, 0.90],
772            ],
773            // Part 4
774            vec![
775                vec![0.47, 0.50, 0.64],
776                vec![0.01, 0.14, 0.43],
777                vec![0.12, 0.22, 0.31],
778            ],
779            // Part 5
780            vec![
781                vec![-0.80, -0.92, -0.84],
782                vec![-0.56, -1.20, -1.28],
783                vec![-0.44, -0.21, -0.17],
784            ],
785            // Part 6
786            vec![
787                vec![0.02, 0.16, -0.10],
788                vec![0.01, -0.10, 0.07],
789                vec![-0.14, -0.46, 0.18],
790            ],
791            // Part 7
792            vec![
793                vec![0.59, 0.75, 0.66],
794                vec![0.55, 0.36, 0.51],
795                vec![0.47, 0.63, 0.34],
796            ],
797            // Part 8
798            vec![
799                vec![-0.31, -0.20, 0.17],
800                vec![0.02, -0.09, 0.12],
801                vec![-0.24, 0.04, -0.19],
802            ],
803            // Part 9
804            vec![
805                vec![2.26, 1.99, 2.01],
806                vec![1.80, 2.12, 2.19],
807                vec![1.80, 1.71, 2.29],
808            ],
809            // Part 10
810            vec![
811                vec![-1.36, -1.14, -1.30],
812                vec![-1.34, -1.11, -1.42],
813                vec![-1.13, -1.13, -0.96],
814            ],
815        ]
816    }
817
818    // -----------------------------------------------------------------------
819    // X̄-R method tests
820    // -----------------------------------------------------------------------
821
822    #[test]
823    fn xbar_r_basic_computation() {
824        let data = aiag_reference_data();
825        let input = GageRRInput {
826            measurements: data,
827            tolerance: Some(4.0),
828        };
829        let result = gage_rr_xbar_r(&input).expect("should compute");
830
831        // EV, AV, GRR should be positive
832        assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
833        assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
834        assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
835        assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
836
837        // GRR = sqrt(EV² + AV²)
838        let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
839        assert!(
840            (result.grr - expected_grr).abs() < 1e-10,
841            "GRR identity failed: {} vs {}",
842            result.grr,
843            expected_grr
844        );
845
846        // TV = sqrt(GRR² + PV²)
847        let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
848        assert!(
849            (result.tv - expected_tv).abs() < 1e-10,
850            "TV identity failed: {} vs {}",
851            result.tv,
852            expected_tv
853        );
854
855        // Percentages should sum close to 100% (via Pythagorean: %EV² + %AV² + %PV² ≈ 10000)
856        // Actually: %GRR² + %PV² = 10000 since TV is the hypotenuse
857        let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
858        assert!(
859            (pct_check - 10000.0).abs() < 1.0,
860            "percentage identity: {} should be ~10000",
861            pct_check
862        );
863
864        // %Tolerance should be present when tolerance is provided
865        assert!(result.percent_tolerance.is_some());
866
867        // NDC should be at least 1
868        assert!(result.ndc >= 1);
869    }
870
871    #[test]
872    fn xbar_r_ndc_minimum_one() {
873        // Create data where GRR >> PV (bad measurement system)
874        let data = vec![
875            vec![vec![1.0, 5.0], vec![0.0, 6.0]],
876            vec![vec![1.5, 4.5], vec![0.5, 5.5]],
877        ];
878        let input = GageRRInput {
879            measurements: data,
880            tolerance: None,
881        };
882        let result = gage_rr_xbar_r(&input).expect("should compute");
883        assert!(result.ndc >= 1, "NDC should be at least 1");
884    }
885
886    #[test]
887    fn xbar_r_status_classification() {
888        let data = aiag_reference_data();
889        let input = GageRRInput {
890            measurements: data,
891            tolerance: None,
892        };
893        let result = gage_rr_xbar_r(&input).expect("should compute");
894
895        // The status should match the percent_grr
896        match result.status {
897            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
898            GrrStatus::Marginal => {
899                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
900            }
901            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
902        }
903    }
904
905    #[test]
906    fn xbar_r_rejects_invalid_dimensions() {
907        // Only 1 part
908        let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
909        let input = GageRRInput {
910            measurements: data,
911            tolerance: None,
912        };
913        assert!(gage_rr_xbar_r(&input).is_err());
914
915        // Only 1 operator
916        let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
917        let input = GageRRInput {
918            measurements: data,
919            tolerance: None,
920        };
921        assert!(gage_rr_xbar_r(&input).is_err());
922
923        // Only 1 trial
924        let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
925        let input = GageRRInput {
926            measurements: data,
927            tolerance: None,
928        };
929        assert!(gage_rr_xbar_r(&input).is_err());
930    }
931
932    #[test]
933    fn xbar_r_rejects_non_finite() {
934        let data = vec![
935            vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
936            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
937        ];
938        let input = GageRRInput {
939            measurements: data,
940            tolerance: None,
941        };
942        assert!(gage_rr_xbar_r(&input).is_err());
943    }
944
945    #[test]
946    fn xbar_r_two_operators_two_trials() {
947        // Minimal case: 2 parts, 2 operators, 2 trials
948        let data = vec![
949            vec![vec![10.0, 10.2], vec![10.1, 10.3]],
950            vec![vec![20.0, 20.1], vec![19.9, 20.2]],
951        ];
952        let input = GageRRInput {
953            measurements: data,
954            tolerance: Some(2.0),
955        };
956        let result = gage_rr_xbar_r(&input).expect("should compute");
957        assert!(result.ev > 0.0);
958        assert!(result.pv > 0.0);
959        assert!(result.percent_tolerance.is_some());
960    }
961
962    // -----------------------------------------------------------------------
963    // ANOVA method tests
964    // -----------------------------------------------------------------------
965
966    #[test]
967    fn anova_basic_computation() {
968        let data = aiag_reference_data();
969        let input = GageRRInput {
970            measurements: data,
971            tolerance: Some(4.0),
972        };
973        let result = gage_rr_anova(&input).expect("should compute");
974
975        // Variance components should be non-negative
976        assert!(
977            result.variance_components.repeatability >= 0.0,
978            "σ²_repeatability should be non-negative"
979        );
980        assert!(
981            result.variance_components.part >= 0.0,
982            "σ²_part should be non-negative"
983        );
984        assert!(
985            result.variance_components.operator >= 0.0,
986            "σ²_operator should be non-negative"
987        );
988        assert!(
989            result.variance_components.interaction >= 0.0,
990            "σ²_interaction should be non-negative"
991        );
992
993        // σ²_reproducibility = σ²_operator + σ²_interaction
994        let expected_repro =
995            result.variance_components.operator + result.variance_components.interaction;
996        assert!(
997            (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
998            "σ²_reproducibility identity failed"
999        );
1000
1001        // σ²_total = σ²_part + σ²_repeatability + σ²_reproducibility
1002        let expected_total = result.variance_components.part
1003            + result.variance_components.repeatability
1004            + result.variance_components.reproducibility;
1005        assert!(
1006            (result.variance_components.total - expected_total).abs() < 1e-10,
1007            "σ²_total identity failed"
1008        );
1009
1010        // ANOVA table should have 5 rows
1011        assert_eq!(result.anova_table.rows.len(), 5);
1012
1013        // Check source names
1014        assert_eq!(result.anova_table.rows[0].source, "Part");
1015        assert_eq!(result.anova_table.rows[1].source, "Operator");
1016        assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1017        assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1018        assert_eq!(result.anova_table.rows[4].source, "Total");
1019
1020        // EV, GRR, PV, TV should be consistent with variance components
1021        assert!(
1022            (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1023            "EV should be sqrt(σ²_repeatability)"
1024        );
1025        assert!(
1026            (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1027            "PV should be sqrt(σ²_part)"
1028        );
1029    }
1030
1031    #[test]
1032    fn anova_ss_decomposition() {
1033        let data = aiag_reference_data();
1034        let input = GageRRInput {
1035            measurements: data,
1036            tolerance: None,
1037        };
1038        let result = gage_rr_anova(&input).expect("should compute");
1039
1040        // SS_Part + SS_Operator + SS_Interaction + SS_Error = SS_Total
1041        let rows = &result.anova_table.rows;
1042        let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1043        let ss_total = rows[4].ss;
1044        assert!(
1045            (ss_sum - ss_total).abs() < 1e-8,
1046            "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1047            rows[0].ss,
1048            rows[1].ss,
1049            rows[2].ss,
1050            rows[3].ss,
1051            ss_sum,
1052            ss_total
1053        );
1054
1055        // DF decomposition
1056        let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1057        let df_total = rows[4].df;
1058        assert!(
1059            (df_sum - df_total).abs() < 1e-10,
1060            "DF decomposition failed: {} vs {}",
1061            df_sum,
1062            df_total
1063        );
1064    }
1065
1066    #[test]
1067    fn anova_interaction_pooling() {
1068        // Create data with negligible interaction (operators measure similarly)
1069        let data = vec![
1070            vec![
1071                vec![10.0, 10.1, 10.0],
1072                vec![10.0, 10.0, 10.1],
1073                vec![10.1, 10.0, 10.0],
1074            ],
1075            vec![
1076                vec![20.0, 20.1, 20.0],
1077                vec![20.0, 20.0, 20.1],
1078                vec![20.1, 20.0, 20.0],
1079            ],
1080            vec![
1081                vec![15.0, 15.1, 15.0],
1082                vec![15.0, 15.0, 15.1],
1083                vec![15.1, 15.0, 15.0],
1084            ],
1085        ];
1086        let input = GageRRInput {
1087            measurements: data,
1088            tolerance: None,
1089        };
1090        let result = gage_rr_anova(&input).expect("should compute");
1091
1092        // With no real interaction, it should likely be pooled
1093        if result.interaction_pooled {
1094            assert_eq!(result.variance_components.interaction, 0.0);
1095        }
1096    }
1097
1098    /// Regression (upstream-013): when the interaction is pooled, EVERY variance
1099    /// component — repeatability included — must be derived from the single pooled
1100    /// error MS, and the returned ANOVA table's Repeatability row must report that
1101    /// same pooled term so the Part/Operator F is reproducible from the table.
1102    ///
1103    /// The discriminating invariant: the denominator actually used for the Part
1104    /// F-test is `ms_part / f_part`. Before the fix σ²_repeatability leaked the raw
1105    /// (un-pooled) MS_error while f_part used the pooled MS, so they disagreed.
1106    #[test]
1107    fn anova_pooled_repeatability_uses_pooled_ms() {
1108        // 3 parts × 3 operators × 3 trials with a dominant part effect and
1109        // negligible operator/interaction — this pools (interaction p > 0.25).
1110        let data = vec![
1111            vec![
1112                vec![10.0, 10.1, 10.0],
1113                vec![10.0, 10.0, 10.1],
1114                vec![10.1, 10.0, 10.0],
1115            ],
1116            vec![
1117                vec![20.0, 20.1, 20.0],
1118                vec![20.0, 20.0, 20.1],
1119                vec![20.1, 20.0, 20.0],
1120            ],
1121            vec![
1122                vec![15.0, 15.1, 15.0],
1123                vec![15.0, 15.0, 15.1],
1124                vec![15.1, 15.0, 15.0],
1125            ],
1126        ];
1127        let input = GageRRInput {
1128            measurements: data,
1129            tolerance: None,
1130        };
1131        let result = gage_rr_anova(&input).expect("should compute");
1132        assert!(result.interaction_pooled, "this dataset should pool");
1133
1134        let rep_row = result
1135            .anova_table
1136            .rows
1137            .iter()
1138            .find(|r| r.source == "Repeatability")
1139            .expect("Repeatability row present");
1140        let part = result
1141            .anova_table
1142            .rows
1143            .iter()
1144            .find(|r| r.source == "Part")
1145            .expect("Part row");
1146
1147        // The pooled denominator actually used for f_part.
1148        let pooled_denom = part.ms / part.f_value.expect("Part F present");
1149
1150        // (a) σ²_repeatability equals that pooled denominator — NOT the raw MS.
1151        assert!(
1152            (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1153            "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1154            result.variance_components.repeatability
1155        );
1156        // (b) The Repeatability row MS equals it too — F reproducible from table.
1157        assert!(
1158            (rep_row.ms - pooled_denom).abs() < 1e-10,
1159            "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1160            rep_row.ms
1161        );
1162        // (c) When pooled, no standalone interaction row; df's sum to df_total.
1163        assert!(
1164            !result
1165                .anova_table
1166                .rows
1167                .iter()
1168                .any(|r| r.source == "Part×Operator"),
1169            "pooled table must not carry a separate interaction row"
1170        );
1171        let df_sum: f64 = result
1172            .anova_table
1173            .rows
1174            .iter()
1175            .filter(|r| r.source != "Total")
1176            .map(|r| r.df)
1177            .sum();
1178        let df_total = result
1179            .anova_table
1180            .rows
1181            .iter()
1182            .find(|r| r.source == "Total")
1183            .expect("Total row")
1184            .df;
1185        assert!(
1186            (df_sum - df_total).abs() < 1e-9,
1187            "component df ({df_sum}) must sum to total df ({df_total})"
1188        );
1189    }
1190
1191    /// Regression (upstream-011): a degenerate denominator (every trial within
1192    /// each cell identical → repeatability MS ≈ floating-point noise) must yield
1193    /// F/p = None, not a spurious ~1e12 finite F, and must do so regardless of the
1194    /// sign of the rounding noise.
1195    #[test]
1196    fn anova_degenerate_denominator_returns_none() {
1197        // Take the AIAG reference layout (10×3×3) and collapse every trial within
1198        // a cell to that cell's first value, so the within-cell repeatability SS is
1199        // 0. At this scale/count the SS *decomposition* residual does not cancel to
1200        // exact 0 — it lands on surviving floating-point noise (ms_error ≈ 1.85e-16,
1201        // reproducing the report's ~1e-17 case). The OLD absolute `> 1e-300` guard
1202        // divides the (real, ~0.08) interaction MS by that noise, yielding a
1203        // spurious F ≈ 4.4e14 that renders as "very significant"; the relative
1204        // degeneracy floor must instead report F/p as None.
1205        let mut data = aiag_reference_data();
1206        for part in data.iter_mut() {
1207            for op in part.iter_mut() {
1208                let first = op[0];
1209                for trial in op.iter_mut() {
1210                    *trial = first;
1211                }
1212            }
1213        }
1214        let input = GageRRInput {
1215            measurements: data,
1216            tolerance: None,
1217        };
1218        let result = gage_rr_anova(&input).expect("should compute");
1219
1220        for row in &result.anova_table.rows {
1221            if let Some(f) = row.f_value {
1222                assert!(
1223                    f.is_finite() && f < 1e6,
1224                    "degenerate denominator must not produce a divergent F for {}: {}",
1225                    row.source,
1226                    f
1227                );
1228            }
1229        }
1230        // Repeatability is ~0, so GRR-derived std devs must stay finite (no NaN).
1231        assert!(
1232            result.ev.is_finite(),
1233            "EV must be finite, got {}",
1234            result.ev
1235        );
1236        assert!(
1237            result.percent_grr.is_finite(),
1238            "%GRR must be finite, got {}",
1239            result.percent_grr
1240        );
1241    }
1242
1243    #[test]
1244    fn anova_rejects_invalid_input() {
1245        let data = vec![vec![vec![1.0, 2.0]]];
1246        let input = GageRRInput {
1247            measurements: data,
1248            tolerance: None,
1249        };
1250        assert!(gage_rr_anova(&input).is_err());
1251    }
1252
1253    #[test]
1254    fn anova_status_matches_percent_grr() {
1255        let data = aiag_reference_data();
1256        let input = GageRRInput {
1257            measurements: data,
1258            tolerance: None,
1259        };
1260        let result = gage_rr_anova(&input).expect("should compute");
1261
1262        match result.status {
1263            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1264            GrrStatus::Marginal => {
1265                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1266            }
1267            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1268        }
1269    }
1270
1271    #[test]
1272    fn anova_p_values_bounded() {
1273        let data = aiag_reference_data();
1274        let input = GageRRInput {
1275            measurements: data,
1276            tolerance: None,
1277        };
1278        let result = gage_rr_anova(&input).expect("should compute");
1279
1280        for row in &result.anova_table.rows {
1281            if let Some(p) = row.p_value {
1282                assert!(
1283                    (0.0..=1.0).contains(&p),
1284                    "p-value for {} out of range: {}",
1285                    row.source,
1286                    p
1287                );
1288            }
1289            if let Some(f) = row.f_value {
1290                assert!(
1291                    f >= 0.0,
1292                    "F-value for {} should be non-negative: {}",
1293                    row.source,
1294                    f
1295                );
1296            }
1297        }
1298    }
1299
1300    // -----------------------------------------------------------------------
1301    // Consistency between methods
1302    // -----------------------------------------------------------------------
1303
1304    #[test]
1305    fn both_methods_detect_same_dominant_variation() {
1306        let data = aiag_reference_data();
1307        let input_xr = GageRRInput {
1308            measurements: data.clone(),
1309            tolerance: None,
1310        };
1311        let input_anova = GageRRInput {
1312            measurements: data,
1313            tolerance: None,
1314        };
1315
1316        let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1317        let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1318
1319        // Both methods should agree on whether PV dominates over GRR
1320        let xr_pv_dominant = xr.pv > xr.grr;
1321        let anova_pv_dominant = anova.pv > anova.grr;
1322        assert_eq!(
1323            xr_pv_dominant, anova_pv_dominant,
1324            "X̄-R and ANOVA should agree on PV vs GRR dominance"
1325        );
1326    }
1327}