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 { pooled_df } else { df_error },
628        ss: if interaction_pooled { pooled_ss } else { ss_error },
629        ms: if interaction_pooled { ms_pooled } else { ms_error },
630        f_value: None,
631        p_value: None,
632    });
633    rows.push(AnovaRow {
634        source: "Total".to_owned(),
635        df: df_total,
636        ss: ss_total,
637        ms: ss_total / df_total,
638        f_value: None,
639        p_value: None,
640    });
641    let anova_table = AnovaTable { rows };
642
643    // Variance components from expected mean squares. When the interaction is
644    // pooled, EVERY component — repeatability included — is derived from the
645    // single pooled error MS. Previously repeatability alone leaked the un-pooled
646    // raw MS_error while operator/part used the pooled MS, mixing two models in
647    // one result and inflating GRR / %GRR. `.max(0.0)` guards against a negative
648    // std-dev (NaN) when rounding pushes the (already ~0) MS slightly below zero.
649    let sigma2_repeatability = (if interaction_pooled { ms_pooled } else { ms_error }).max(0.0);
650
651    let sigma2_interaction = if interaction_pooled {
652        0.0
653    } else {
654        let val = (ms_interaction - ms_error) / r as f64;
655        val.max(0.0)
656    };
657
658    let sigma2_operator = if interaction_pooled {
659        let val = (ms_operator - ms_pooled) / (p * r) as f64;
660        val.max(0.0)
661    } else {
662        let val = (ms_operator - ms_interaction) / (p * r) as f64;
663        val.max(0.0)
664    };
665
666    let sigma2_part = if interaction_pooled {
667        let val = (ms_part - ms_pooled) / (o * r) as f64;
668        val.max(0.0)
669    } else {
670        let val = (ms_part - ms_interaction) / (o * r) as f64;
671        val.max(0.0)
672    };
673
674    let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
675    let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
676    let sigma2_total = sigma2_part + sigma2_grr;
677
678    let variance_components = VarianceComponents {
679        part: sigma2_part,
680        operator: sigma2_operator,
681        interaction: sigma2_interaction,
682        repeatability: sigma2_repeatability,
683        reproducibility: sigma2_reproducibility,
684        total: sigma2_total,
685    };
686
687    // Convert variance components to standard deviations (study variation)
688    let ev = sigma2_repeatability.sqrt();
689    let av = sigma2_reproducibility.sqrt();
690    let grr = sigma2_grr.sqrt();
691    let pv = sigma2_part.sqrt();
692    let tv = sigma2_total.sqrt();
693
694    let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
695
696    let percent_tolerance = input.tolerance.and_then(|tol| {
697        if tol > 1e-300 {
698            Some(grr / tol * 600.0)
699        } else {
700            None
701        }
702    });
703
704    let ndc = compute_ndc(pv, grr);
705    let status = grr_status(percent_grr);
706
707    Ok(GageRRAnovaResult {
708        anova_table,
709        variance_components,
710        ev,
711        av,
712        grr,
713        pv,
714        tv,
715        percent_grr,
716        percent_tolerance,
717        ndc,
718        status,
719        interaction_significant,
720        interaction_pooled,
721    })
722}
723
724// ---------------------------------------------------------------------------
725// Tests
726// ---------------------------------------------------------------------------
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    /// Generate a simple balanced dataset for testing.
733    /// 3 operators, 10 parts, 3 trials.
734    /// Based on AIAG MSA 4th Edition reference data.
735    fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
736        // measurements[part][operator][trial]
737        vec![
738            // Part 1
739            vec![
740                vec![0.29, 0.41, 0.64],  // Operator A
741                vec![0.08, 0.25, 0.07],  // Operator B
742                vec![0.04, -0.11, 0.75], // Operator C
743            ],
744            // Part 2
745            vec![
746                vec![-0.56, -0.68, -0.58],
747                vec![-0.47, -1.22, -0.68],
748                vec![-0.49, -0.56, -0.49],
749            ],
750            // Part 3
751            vec![
752                vec![1.34, 1.17, 1.27],
753                vec![1.19, 0.94, 1.34],
754                vec![1.02, 0.82, 0.90],
755            ],
756            // Part 4
757            vec![
758                vec![0.47, 0.50, 0.64],
759                vec![0.01, 0.14, 0.43],
760                vec![0.12, 0.22, 0.31],
761            ],
762            // Part 5
763            vec![
764                vec![-0.80, -0.92, -0.84],
765                vec![-0.56, -1.20, -1.28],
766                vec![-0.44, -0.21, -0.17],
767            ],
768            // Part 6
769            vec![
770                vec![0.02, 0.16, -0.10],
771                vec![0.01, -0.10, 0.07],
772                vec![-0.14, -0.46, 0.18],
773            ],
774            // Part 7
775            vec![
776                vec![0.59, 0.75, 0.66],
777                vec![0.55, 0.36, 0.51],
778                vec![0.47, 0.63, 0.34],
779            ],
780            // Part 8
781            vec![
782                vec![-0.31, -0.20, 0.17],
783                vec![0.02, -0.09, 0.12],
784                vec![-0.24, 0.04, -0.19],
785            ],
786            // Part 9
787            vec![
788                vec![2.26, 1.99, 2.01],
789                vec![1.80, 2.12, 2.19],
790                vec![1.80, 1.71, 2.29],
791            ],
792            // Part 10
793            vec![
794                vec![-1.36, -1.14, -1.30],
795                vec![-1.34, -1.11, -1.42],
796                vec![-1.13, -1.13, -0.96],
797            ],
798        ]
799    }
800
801    // -----------------------------------------------------------------------
802    // X̄-R method tests
803    // -----------------------------------------------------------------------
804
805    #[test]
806    fn xbar_r_basic_computation() {
807        let data = aiag_reference_data();
808        let input = GageRRInput {
809            measurements: data,
810            tolerance: Some(4.0),
811        };
812        let result = gage_rr_xbar_r(&input).expect("should compute");
813
814        // EV, AV, GRR should be positive
815        assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
816        assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
817        assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
818        assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
819
820        // GRR = sqrt(EV² + AV²)
821        let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
822        assert!(
823            (result.grr - expected_grr).abs() < 1e-10,
824            "GRR identity failed: {} vs {}",
825            result.grr,
826            expected_grr
827        );
828
829        // TV = sqrt(GRR² + PV²)
830        let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
831        assert!(
832            (result.tv - expected_tv).abs() < 1e-10,
833            "TV identity failed: {} vs {}",
834            result.tv,
835            expected_tv
836        );
837
838        // Percentages should sum close to 100% (via Pythagorean: %EV² + %AV² + %PV² ≈ 10000)
839        // Actually: %GRR² + %PV² = 10000 since TV is the hypotenuse
840        let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
841        assert!(
842            (pct_check - 10000.0).abs() < 1.0,
843            "percentage identity: {} should be ~10000",
844            pct_check
845        );
846
847        // %Tolerance should be present when tolerance is provided
848        assert!(result.percent_tolerance.is_some());
849
850        // NDC should be at least 1
851        assert!(result.ndc >= 1);
852    }
853
854    #[test]
855    fn xbar_r_ndc_minimum_one() {
856        // Create data where GRR >> PV (bad measurement system)
857        let data = vec![
858            vec![vec![1.0, 5.0], vec![0.0, 6.0]],
859            vec![vec![1.5, 4.5], vec![0.5, 5.5]],
860        ];
861        let input = GageRRInput {
862            measurements: data,
863            tolerance: None,
864        };
865        let result = gage_rr_xbar_r(&input).expect("should compute");
866        assert!(result.ndc >= 1, "NDC should be at least 1");
867    }
868
869    #[test]
870    fn xbar_r_status_classification() {
871        let data = aiag_reference_data();
872        let input = GageRRInput {
873            measurements: data,
874            tolerance: None,
875        };
876        let result = gage_rr_xbar_r(&input).expect("should compute");
877
878        // The status should match the percent_grr
879        match result.status {
880            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
881            GrrStatus::Marginal => {
882                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
883            }
884            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
885        }
886    }
887
888    #[test]
889    fn xbar_r_rejects_invalid_dimensions() {
890        // Only 1 part
891        let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
892        let input = GageRRInput {
893            measurements: data,
894            tolerance: None,
895        };
896        assert!(gage_rr_xbar_r(&input).is_err());
897
898        // Only 1 operator
899        let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
900        let input = GageRRInput {
901            measurements: data,
902            tolerance: None,
903        };
904        assert!(gage_rr_xbar_r(&input).is_err());
905
906        // Only 1 trial
907        let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
908        let input = GageRRInput {
909            measurements: data,
910            tolerance: None,
911        };
912        assert!(gage_rr_xbar_r(&input).is_err());
913    }
914
915    #[test]
916    fn xbar_r_rejects_non_finite() {
917        let data = vec![
918            vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
919            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
920        ];
921        let input = GageRRInput {
922            measurements: data,
923            tolerance: None,
924        };
925        assert!(gage_rr_xbar_r(&input).is_err());
926    }
927
928    #[test]
929    fn xbar_r_two_operators_two_trials() {
930        // Minimal case: 2 parts, 2 operators, 2 trials
931        let data = vec![
932            vec![vec![10.0, 10.2], vec![10.1, 10.3]],
933            vec![vec![20.0, 20.1], vec![19.9, 20.2]],
934        ];
935        let input = GageRRInput {
936            measurements: data,
937            tolerance: Some(2.0),
938        };
939        let result = gage_rr_xbar_r(&input).expect("should compute");
940        assert!(result.ev > 0.0);
941        assert!(result.pv > 0.0);
942        assert!(result.percent_tolerance.is_some());
943    }
944
945    // -----------------------------------------------------------------------
946    // ANOVA method tests
947    // -----------------------------------------------------------------------
948
949    #[test]
950    fn anova_basic_computation() {
951        let data = aiag_reference_data();
952        let input = GageRRInput {
953            measurements: data,
954            tolerance: Some(4.0),
955        };
956        let result = gage_rr_anova(&input).expect("should compute");
957
958        // Variance components should be non-negative
959        assert!(
960            result.variance_components.repeatability >= 0.0,
961            "σ²_repeatability should be non-negative"
962        );
963        assert!(
964            result.variance_components.part >= 0.0,
965            "σ²_part should be non-negative"
966        );
967        assert!(
968            result.variance_components.operator >= 0.0,
969            "σ²_operator should be non-negative"
970        );
971        assert!(
972            result.variance_components.interaction >= 0.0,
973            "σ²_interaction should be non-negative"
974        );
975
976        // σ²_reproducibility = σ²_operator + σ²_interaction
977        let expected_repro =
978            result.variance_components.operator + result.variance_components.interaction;
979        assert!(
980            (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
981            "σ²_reproducibility identity failed"
982        );
983
984        // σ²_total = σ²_part + σ²_repeatability + σ²_reproducibility
985        let expected_total = result.variance_components.part
986            + result.variance_components.repeatability
987            + result.variance_components.reproducibility;
988        assert!(
989            (result.variance_components.total - expected_total).abs() < 1e-10,
990            "σ²_total identity failed"
991        );
992
993        // ANOVA table should have 5 rows
994        assert_eq!(result.anova_table.rows.len(), 5);
995
996        // Check source names
997        assert_eq!(result.anova_table.rows[0].source, "Part");
998        assert_eq!(result.anova_table.rows[1].source, "Operator");
999        assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1000        assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1001        assert_eq!(result.anova_table.rows[4].source, "Total");
1002
1003        // EV, GRR, PV, TV should be consistent with variance components
1004        assert!(
1005            (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1006            "EV should be sqrt(σ²_repeatability)"
1007        );
1008        assert!(
1009            (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1010            "PV should be sqrt(σ²_part)"
1011        );
1012    }
1013
1014    #[test]
1015    fn anova_ss_decomposition() {
1016        let data = aiag_reference_data();
1017        let input = GageRRInput {
1018            measurements: data,
1019            tolerance: None,
1020        };
1021        let result = gage_rr_anova(&input).expect("should compute");
1022
1023        // SS_Part + SS_Operator + SS_Interaction + SS_Error = SS_Total
1024        let rows = &result.anova_table.rows;
1025        let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1026        let ss_total = rows[4].ss;
1027        assert!(
1028            (ss_sum - ss_total).abs() < 1e-8,
1029            "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1030            rows[0].ss,
1031            rows[1].ss,
1032            rows[2].ss,
1033            rows[3].ss,
1034            ss_sum,
1035            ss_total
1036        );
1037
1038        // DF decomposition
1039        let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1040        let df_total = rows[4].df;
1041        assert!(
1042            (df_sum - df_total).abs() < 1e-10,
1043            "DF decomposition failed: {} vs {}",
1044            df_sum,
1045            df_total
1046        );
1047    }
1048
1049    #[test]
1050    fn anova_interaction_pooling() {
1051        // Create data with negligible interaction (operators measure similarly)
1052        let data = vec![
1053            vec![
1054                vec![10.0, 10.1, 10.0],
1055                vec![10.0, 10.0, 10.1],
1056                vec![10.1, 10.0, 10.0],
1057            ],
1058            vec![
1059                vec![20.0, 20.1, 20.0],
1060                vec![20.0, 20.0, 20.1],
1061                vec![20.1, 20.0, 20.0],
1062            ],
1063            vec![
1064                vec![15.0, 15.1, 15.0],
1065                vec![15.0, 15.0, 15.1],
1066                vec![15.1, 15.0, 15.0],
1067            ],
1068        ];
1069        let input = GageRRInput {
1070            measurements: data,
1071            tolerance: None,
1072        };
1073        let result = gage_rr_anova(&input).expect("should compute");
1074
1075        // With no real interaction, it should likely be pooled
1076        if result.interaction_pooled {
1077            assert_eq!(result.variance_components.interaction, 0.0);
1078        }
1079    }
1080
1081    /// Regression (upstream-013): when the interaction is pooled, EVERY variance
1082    /// component — repeatability included — must be derived from the single pooled
1083    /// error MS, and the returned ANOVA table's Repeatability row must report that
1084    /// same pooled term so the Part/Operator F is reproducible from the table.
1085    ///
1086    /// The discriminating invariant: the denominator actually used for the Part
1087    /// F-test is `ms_part / f_part`. Before the fix σ²_repeatability leaked the raw
1088    /// (un-pooled) MS_error while f_part used the pooled MS, so they disagreed.
1089    #[test]
1090    fn anova_pooled_repeatability_uses_pooled_ms() {
1091        // 3 parts × 3 operators × 3 trials with a dominant part effect and
1092        // negligible operator/interaction — this pools (interaction p > 0.25).
1093        let data = vec![
1094            vec![
1095                vec![10.0, 10.1, 10.0],
1096                vec![10.0, 10.0, 10.1],
1097                vec![10.1, 10.0, 10.0],
1098            ],
1099            vec![
1100                vec![20.0, 20.1, 20.0],
1101                vec![20.0, 20.0, 20.1],
1102                vec![20.1, 20.0, 20.0],
1103            ],
1104            vec![
1105                vec![15.0, 15.1, 15.0],
1106                vec![15.0, 15.0, 15.1],
1107                vec![15.1, 15.0, 15.0],
1108            ],
1109        ];
1110        let input = GageRRInput {
1111            measurements: data,
1112            tolerance: None,
1113        };
1114        let result = gage_rr_anova(&input).expect("should compute");
1115        assert!(result.interaction_pooled, "this dataset should pool");
1116
1117        let rep_row = result
1118            .anova_table
1119            .rows
1120            .iter()
1121            .find(|r| r.source == "Repeatability")
1122            .expect("Repeatability row present");
1123        let part = result
1124            .anova_table
1125            .rows
1126            .iter()
1127            .find(|r| r.source == "Part")
1128            .expect("Part row");
1129
1130        // The pooled denominator actually used for f_part.
1131        let pooled_denom = part.ms / part.f_value.expect("Part F present");
1132
1133        // (a) σ²_repeatability equals that pooled denominator — NOT the raw MS.
1134        assert!(
1135            (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1136            "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1137            result.variance_components.repeatability
1138        );
1139        // (b) The Repeatability row MS equals it too — F reproducible from table.
1140        assert!(
1141            (rep_row.ms - pooled_denom).abs() < 1e-10,
1142            "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1143            rep_row.ms
1144        );
1145        // (c) When pooled, no standalone interaction row; df's sum to df_total.
1146        assert!(
1147            !result
1148                .anova_table
1149                .rows
1150                .iter()
1151                .any(|r| r.source == "Part×Operator"),
1152            "pooled table must not carry a separate interaction row"
1153        );
1154        let df_sum: f64 = result
1155            .anova_table
1156            .rows
1157            .iter()
1158            .filter(|r| r.source != "Total")
1159            .map(|r| r.df)
1160            .sum();
1161        let df_total = result
1162            .anova_table
1163            .rows
1164            .iter()
1165            .find(|r| r.source == "Total")
1166            .expect("Total row")
1167            .df;
1168        assert!(
1169            (df_sum - df_total).abs() < 1e-9,
1170            "component df ({df_sum}) must sum to total df ({df_total})"
1171        );
1172    }
1173
1174    /// Regression (upstream-011): a degenerate denominator (every trial within
1175    /// each cell identical → repeatability MS ≈ floating-point noise) must yield
1176    /// F/p = None, not a spurious ~1e12 finite F, and must do so regardless of the
1177    /// sign of the rounding noise.
1178    #[test]
1179    fn anova_degenerate_denominator_returns_none() {
1180        // Take the AIAG reference layout (10×3×3) and collapse every trial within
1181        // a cell to that cell's first value, so the within-cell repeatability SS is
1182        // 0. At this scale/count the SS *decomposition* residual does not cancel to
1183        // exact 0 — it lands on surviving floating-point noise (ms_error ≈ 1.85e-16,
1184        // reproducing the report's ~1e-17 case). The OLD absolute `> 1e-300` guard
1185        // divides the (real, ~0.08) interaction MS by that noise, yielding a
1186        // spurious F ≈ 4.4e14 that renders as "very significant"; the relative
1187        // degeneracy floor must instead report F/p as None.
1188        let mut data = aiag_reference_data();
1189        for part in data.iter_mut() {
1190            for op in part.iter_mut() {
1191                let first = op[0];
1192                for trial in op.iter_mut() {
1193                    *trial = first;
1194                }
1195            }
1196        }
1197        let input = GageRRInput {
1198            measurements: data,
1199            tolerance: None,
1200        };
1201        let result = gage_rr_anova(&input).expect("should compute");
1202
1203        for row in &result.anova_table.rows {
1204            if let Some(f) = row.f_value {
1205                assert!(
1206                    f.is_finite() && f < 1e6,
1207                    "degenerate denominator must not produce a divergent F for {}: {}",
1208                    row.source,
1209                    f
1210                );
1211            }
1212        }
1213        // Repeatability is ~0, so GRR-derived std devs must stay finite (no NaN).
1214        assert!(result.ev.is_finite(), "EV must be finite, got {}", result.ev);
1215        assert!(
1216            result.percent_grr.is_finite(),
1217            "%GRR must be finite, got {}",
1218            result.percent_grr
1219        );
1220    }
1221
1222    #[test]
1223    fn anova_rejects_invalid_input() {
1224        let data = vec![vec![vec![1.0, 2.0]]];
1225        let input = GageRRInput {
1226            measurements: data,
1227            tolerance: None,
1228        };
1229        assert!(gage_rr_anova(&input).is_err());
1230    }
1231
1232    #[test]
1233    fn anova_status_matches_percent_grr() {
1234        let data = aiag_reference_data();
1235        let input = GageRRInput {
1236            measurements: data,
1237            tolerance: None,
1238        };
1239        let result = gage_rr_anova(&input).expect("should compute");
1240
1241        match result.status {
1242            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1243            GrrStatus::Marginal => {
1244                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1245            }
1246            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1247        }
1248    }
1249
1250    #[test]
1251    fn anova_p_values_bounded() {
1252        let data = aiag_reference_data();
1253        let input = GageRRInput {
1254            measurements: data,
1255            tolerance: None,
1256        };
1257        let result = gage_rr_anova(&input).expect("should compute");
1258
1259        for row in &result.anova_table.rows {
1260            if let Some(p) = row.p_value {
1261                assert!(
1262                    (0.0..=1.0).contains(&p),
1263                    "p-value for {} out of range: {}",
1264                    row.source,
1265                    p
1266                );
1267            }
1268            if let Some(f) = row.f_value {
1269                assert!(
1270                    f >= 0.0,
1271                    "F-value for {} should be non-negative: {}",
1272                    row.source,
1273                    f
1274                );
1275            }
1276        }
1277    }
1278
1279    // -----------------------------------------------------------------------
1280    // Consistency between methods
1281    // -----------------------------------------------------------------------
1282
1283    #[test]
1284    fn both_methods_detect_same_dominant_variation() {
1285        let data = aiag_reference_data();
1286        let input_xr = GageRRInput {
1287            measurements: data.clone(),
1288            tolerance: None,
1289        };
1290        let input_anova = GageRRInput {
1291            measurements: data,
1292            tolerance: None,
1293        };
1294
1295        let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1296        let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1297
1298        // Both methods should agree on whether PV dominates over GRR
1299        let xr_pv_dominant = xr.pv > xr.grr;
1300        let anova_pv_dominant = anova.pv > anova.grr;
1301        assert_eq!(
1302            xr_pv_dominant, anova_pv_dominant,
1303            "X̄-R and ANOVA should agree on PV vs GRR dominance"
1304        );
1305    }
1306}