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/// A centre line and control limits for one of the study's two charts.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct GageChartLimits {
40    /// Centre line: R̄ for the range chart, X̿ for the average chart.
41    pub center: f64,
42    /// Upper control limit.
43    pub ucl: f64,
44    /// Lower control limit.
45    pub lcl: f64,
46}
47
48/// Results from a Gage R&R study (X̄-R or ANOVA method).
49#[derive(Debug, Clone)]
50pub struct GageRRResult {
51    /// Equipment Variation (Repeatability).
52    pub ev: f64,
53    /// Appraiser Variation (Reproducibility).
54    pub av: f64,
55    /// Gage R&R = √(EV² + AV²).
56    pub grr: f64,
57    /// Part Variation.
58    pub pv: f64,
59    /// Total Variation = √(GRR² + PV²).
60    pub tv: f64,
61
62    /// %EV = EV / TV × 100.
63    pub percent_ev: f64,
64    /// %AV = AV / TV × 100.
65    pub percent_av: f64,
66    /// %GRR = GRR / TV × 100.
67    pub percent_grr: f64,
68    /// %PV = PV / TV × 100.
69    pub percent_pv: f64,
70    /// %Tolerance = 6 × GRR / tolerance × 100 (if tolerance provided).
71    pub percent_tolerance: Option<f64>,
72
73    /// The range chart the method plots: R̄ and its limits, over the
74    /// operator×part cell ranges the variance components are built from.
75    pub range_chart: GageChartLimits,
76    /// The average chart the method plots: X̿ and its limits, over the
77    /// operator×part cell averages.
78    ///
79    /// Unlike a process chart, points here are *expected* to fall outside the
80    /// limits -- that is how the study reads whether the parts vary enough to
81    /// be told apart.
82    pub average_chart: GageChartLimits,
83
84    /// Number of Distinct Categories = floor(1.41 × PV / GRR), minimum 1.
85    pub ndc: u32,
86    /// Acceptability status based on %GRR.
87    pub status: GrrStatus,
88}
89
90/// ANOVA-based Gage R&R result with full ANOVA table and variance components.
91#[derive(Debug, Clone)]
92pub struct GageRRAnovaResult {
93    /// Two-factor crossed ANOVA table.
94    pub anova_table: AnovaTable,
95    /// Variance components extracted from expected mean squares.
96    pub variance_components: VarianceComponents,
97    /// Equipment Variation (Repeatability) = √σ²_repeatability.
98    pub ev: f64,
99    /// Appraiser Variation (Reproducibility) = √σ²_reproducibility.
100    pub av: f64,
101    /// Gage R&R = √(EV² + AV²).
102    pub grr: f64,
103    /// Part Variation = √σ²_part.
104    pub pv: f64,
105    /// Total Variation = √σ²_total.
106    pub tv: f64,
107    /// %GRR = GRR / TV × 100.
108    pub percent_grr: f64,
109    /// %Tolerance = 6 × GRR / tolerance × 100 (if tolerance provided).
110    pub percent_tolerance: Option<f64>,
111    /// Number of Distinct Categories.
112    pub ndc: u32,
113    /// Acceptability status.
114    pub status: GrrStatus,
115    /// Whether the Part×Operator interaction is significant (p ≤ 0.25).
116    pub interaction_significant: bool,
117    /// Whether the interaction term was pooled into error.
118    pub interaction_pooled: bool,
119}
120
121/// ANOVA table for a two-factor crossed design.
122#[derive(Debug, Clone)]
123pub struct AnovaTable {
124    /// Rows of the ANOVA table.
125    pub rows: Vec<AnovaRow>,
126}
127
128/// A single row in the ANOVA table.
129#[derive(Debug, Clone)]
130pub struct AnovaRow {
131    /// Source of variation: "Part", "Operator", "Part×Operator", "Repeatability", "Total".
132    pub source: String,
133    /// Degrees of freedom.
134    pub df: f64,
135    /// Sum of squares.
136    pub ss: f64,
137    /// Mean square (SS / DF).
138    pub ms: f64,
139    /// F statistic (None for Error/Total rows).
140    pub f_value: Option<f64>,
141    /// p-value from F distribution (None for Error/Total rows).
142    pub p_value: Option<f64>,
143}
144
145/// Variance components from ANOVA expected mean squares.
146#[derive(Debug, Clone)]
147pub struct VarianceComponents {
148    /// σ²_part — true part-to-part variation.
149    pub part: f64,
150    /// σ²_operator — operator main effect.
151    pub operator: f64,
152    /// σ²_interaction — part×operator interaction.
153    pub interaction: f64,
154    /// σ²_repeatability — within-cell (equipment) variation.
155    pub repeatability: f64,
156    /// σ²_reproducibility = σ²_operator + σ²_interaction.
157    pub reproducibility: f64,
158    /// σ²_total = σ²_part + σ²_grr.
159    pub total: f64,
160}
161
162/// Acceptability status based on %GRR (AIAG guidelines).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum GrrStatus {
165    /// %GRR ≤ 10% — measurement system is acceptable.
166    Acceptable,
167    /// 10% < %GRR ≤ 30% — may be acceptable depending on application.
168    Marginal,
169    /// %GRR > 30% — measurement system needs improvement.
170    Unacceptable,
171}
172
173// ---------------------------------------------------------------------------
174// K-factor constants (AIAG MSA 4th Ed.)
175// ---------------------------------------------------------------------------
176
177/// K1 constants: 1/d2* for the number of trials.
178/// Index: K1[trials - 2] (trials = 2..=3).
179///
180/// Values from AIAG MSA 4th Edition, Table III-B-1.
181#[allow(clippy::approx_constant)]
182const K1: [f64; 2] = [0.8862, 0.5908];
183
184/// K2 constants: 1/d2* for the number of operators.
185/// Index: K2[operators - 2] (operators = 2..=3).
186///
187/// Values from AIAG MSA 4th Edition, Table III-B-1.
188#[allow(clippy::approx_constant)]
189const K2: [f64; 2] = [0.7071, 0.5231];
190
191/// K3 constants: 1/d2* for the number of parts.
192/// Index: K3[parts - 2] (parts = 2..=10).
193///
194/// Values from AIAG MSA 4th Edition, Table III-B-1.
195#[allow(clippy::approx_constant)]
196const K3: [f64; 9] = [
197    0.7071, 0.5231, 0.4467, 0.4030, 0.3742, 0.3534, 0.3375, 0.3249, 0.3146,
198];
199
200// ---------------------------------------------------------------------------
201// Helpers
202// ---------------------------------------------------------------------------
203
204/// Determine GRR status from %GRR.
205fn grr_status(percent_grr: f64) -> GrrStatus {
206    if percent_grr <= 10.0 {
207        GrrStatus::Acceptable
208    } else if percent_grr <= 30.0 {
209        GrrStatus::Marginal
210    } else {
211        GrrStatus::Unacceptable
212    }
213}
214
215/// Compute NDC = floor(1.41 × PV / GRR), minimum 1.
216fn compute_ndc(pv: f64, grr: f64) -> u32 {
217    if grr < 1e-300 {
218        return 1;
219    }
220    let ndc = (1.41 * pv / grr).floor() as i64;
221    ndc.max(1) as u32
222}
223
224/// Validate the 3D measurement array. Returns `(n_parts, n_operators, n_trials)`
225/// or an error message.
226fn validate_measurements(
227    measurements: &[Vec<Vec<f64>>],
228) -> Result<(usize, usize, usize), &'static str> {
229    let n_parts = measurements.len();
230    if n_parts < 2 {
231        return Err("at least 2 parts are required");
232    }
233
234    let n_operators = measurements[0].len();
235    if n_operators < 2 {
236        return Err("at least 2 operators are required");
237    }
238
239    let n_trials = measurements[0][0].len();
240    if n_trials < 2 {
241        return Err("at least 2 trials are required");
242    }
243
244    for part in measurements {
245        if part.len() != n_operators {
246            return Err("all parts must have the same number of operators");
247        }
248        for trials in part {
249            if trials.len() != n_trials {
250                return Err("all operator×part cells must have the same number of trials");
251            }
252            for &v in trials {
253                if !v.is_finite() {
254                    return Err("all measurements must be finite");
255                }
256            }
257        }
258    }
259
260    Ok((n_parts, n_operators, n_trials))
261}
262
263// ---------------------------------------------------------------------------
264// X̄-R Method
265// ---------------------------------------------------------------------------
266
267/// Gage R&R using the X̄-R (Average & Range) method (AIAG MSA 4th Edition).
268///
269/// # Algorithm
270///
271/// 1. For each operator×part cell, compute the range across trials.
272/// 2. R̄ = grand mean of all ranges.
273/// 3. For each operator, compute part averages → operator grand averages → X̄_diff.
274/// 4. EV = R̄ × K1(trials), AV = √((X̄_diff × K2)² − EV²/(n×r)), GRR = √(EV² + AV²).
275/// 5. Part means → Rp → PV = Rp × K3(parts), TV = √(GRR² + PV²).
276/// 6. NDC = floor(1.41 × PV / GRR).
277///
278/// # Arguments
279///
280/// * `input` — Measurement data and optional tolerance.
281///
282/// # Returns
283///
284/// `Err` if input dimensions are invalid or out of supported range
285/// (parts 2..=10, operators 2..=3, trials 2..=3).
286///
287/// # References
288///
289/// AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III.
290pub fn gage_rr_xbar_r(input: &GageRRInput) -> Result<GageRRResult, &'static str> {
291    let (n_parts, n_operators, n_trials) = validate_measurements(&input.measurements)?;
292
293    // Validate supported ranges for K-factor tables
294    if !(2..=3).contains(&n_trials) {
295        return Err("X̄-R method supports 2 or 3 trials");
296    }
297    if !(2..=3).contains(&n_operators) {
298        return Err("X̄-R method supports 2 or 3 operators");
299    }
300    if !(2..=10).contains(&n_parts) {
301        return Err("X̄-R method supports 2 to 10 parts");
302    }
303
304    // Step 1: Compute range for each operator×part cell
305    let mut ranges: Vec<f64> = Vec::with_capacity(n_parts * n_operators);
306    for part in &input.measurements {
307        for trials in part {
308            let min = trials.iter().copied().fold(f64::INFINITY, f64::min);
309            let max = trials.iter().copied().fold(f64::NEG_INFINITY, f64::max);
310            ranges.push(max - min);
311        }
312    }
313
314    // Step 2: R̄ = grand mean of all ranges
315    let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
316
317    // Step 3: Operator averages and X̄_diff
318    // Compute the average measurement for each operator across all parts and trials
319    let mut operator_avgs: Vec<f64> = Vec::with_capacity(n_operators);
320    for op in 0..n_operators {
321        let mut sum = 0.0;
322        let mut count = 0usize;
323        for part in &input.measurements {
324            for &v in &part[op] {
325                sum += v;
326                count += 1;
327            }
328        }
329        operator_avgs.push(sum / count as f64);
330    }
331
332    let x_diff = operator_avgs
333        .iter()
334        .copied()
335        .fold(f64::NEG_INFINITY, f64::max)
336        - operator_avgs.iter().copied().fold(f64::INFINITY, f64::min);
337
338    // Step 4: EV and AV
339    let k1 = K1[n_trials - 2];
340    let k2 = K2[n_operators - 2];
341
342    let ev = r_bar * k1;
343
344    let av_squared = (x_diff * k2).powi(2) - ev.powi(2) / (n_parts * n_trials) as f64;
345    let av = if av_squared > 0.0 {
346        av_squared.sqrt()
347    } else {
348        0.0
349    };
350
351    // Step 5: GRR
352    let grr = (ev.powi(2) + av.powi(2)).sqrt();
353
354    // Step 6: Part means across all operators/trials → PV
355    let mut part_means: Vec<f64> = Vec::with_capacity(n_parts);
356    for part in &input.measurements {
357        let mut sum = 0.0;
358        let mut count = 0usize;
359        for trials in part {
360            for &v in trials {
361                sum += v;
362                count += 1;
363            }
364        }
365        part_means.push(sum / count as f64);
366    }
367
368    let rp = part_means.iter().copied().fold(f64::NEG_INFINITY, f64::max)
369        - part_means.iter().copied().fold(f64::INFINITY, f64::min);
370
371    let k3 = K3[n_parts - 2];
372    let pv = rp * k3;
373
374    // Step 7: TV
375    let tv = (grr.powi(2) + pv.powi(2)).sqrt();
376
377    // Percentages
378    let (percent_ev, percent_av, percent_grr, percent_pv) = if tv > 1e-300 {
379        (
380            ev / tv * 100.0,
381            av / tv * 100.0,
382            grr / tv * 100.0,
383            pv / tv * 100.0,
384        )
385    } else {
386        (0.0, 0.0, 0.0, 0.0)
387    };
388
389    let percent_tolerance = input.tolerance.and_then(|tol| {
390        if tol > 1e-300 {
391            Some(grr / tol * 600.0)
392        } else {
393            None
394        }
395    });
396
397    // The two charts the method plots, from the same R̄ the components use --
398    // computing them anywhere else invites a second, slightly different R̄.
399    // The subgroup for both is one operator×part cell, so n is the trial count,
400    // which this function has already narrowed to 2 or 3.
401    let (a2, d3, d4) = crate::spc::range_chart_factors(n_trials)
402        .expect("n_trials is 2 or 3, inside the factor tables");
403    let cell_means: Vec<f64> = input
404        .measurements
405        .iter()
406        .flat_map(|part| part.iter().map(|trials| stats::mean(trials).unwrap_or(0.0)))
407        .collect();
408    let x_double_bar = stats::mean(&cell_means).expect("cell_means is non-empty");
409    let range_chart = GageChartLimits {
410        center: r_bar,
411        ucl: d4 * r_bar,
412        lcl: d3 * r_bar,
413    };
414    let average_chart = GageChartLimits {
415        center: x_double_bar,
416        ucl: x_double_bar + a2 * r_bar,
417        lcl: x_double_bar - a2 * r_bar,
418    };
419
420    let ndc = compute_ndc(pv, grr);
421    let status = grr_status(percent_grr);
422
423    Ok(GageRRResult {
424        range_chart,
425        average_chart,
426        ev,
427        av,
428        grr,
429        pv,
430        tv,
431        percent_ev,
432        percent_av,
433        percent_grr,
434        percent_pv,
435        percent_tolerance,
436        ndc,
437        status,
438    })
439}
440
441// ---------------------------------------------------------------------------
442// ANOVA Method
443// ---------------------------------------------------------------------------
444
445/// Gage R&R using the two-factor crossed ANOVA method (AIAG MSA 4th Edition).
446///
447/// # Algorithm
448///
449/// Performs a Part × Operator crossed ANOVA with replications (trials).
450/// Computes SS for Part, Operator, Interaction, and Error (Repeatability).
451/// Extracts variance components from expected mean squares.
452/// If the interaction p-value > 0.25, pools the interaction into error.
453///
454/// # Arguments
455///
456/// * `input` — Measurement data and optional tolerance.
457///
458/// # Returns
459///
460/// `Err` if input dimensions are invalid (need ≥ 2 parts, ≥ 2 operators, ≥ 2 trials).
461///
462/// # References
463///
464/// - AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III, Section D.
465/// - Montgomery (2019). *Introduction to Statistical Quality Control*, 8th ed., §5.4.
466pub fn gage_rr_anova(input: &GageRRInput) -> Result<GageRRAnovaResult, &'static str> {
467    let (p, o, r) = validate_measurements(&input.measurements)?;
468    let n_total = p * o * r;
469
470    // Compute grand mean
471    let mut grand_sum = 0.0;
472    for part in &input.measurements {
473        for trials in part {
474            for &v in trials {
475                grand_sum += v;
476            }
477        }
478    }
479    let grand_mean = grand_sum / n_total as f64;
480
481    // Part means (across all operators and trials)
482    let mut part_means: Vec<f64> = Vec::with_capacity(p);
483    for part in &input.measurements {
484        let mut sum = 0.0;
485        for trials in part {
486            for &v in trials {
487                sum += v;
488            }
489        }
490        part_means.push(sum / (o * r) as f64);
491    }
492
493    // Operator means (across all parts and trials)
494    let mut operator_means: Vec<f64> = Vec::with_capacity(o);
495    for op in 0..o {
496        let mut sum = 0.0;
497        for part in &input.measurements {
498            for &v in &part[op] {
499                sum += v;
500            }
501        }
502        operator_means.push(sum / (p * r) as f64);
503    }
504
505    // Cell means (part × operator, averaged over trials)
506    let mut cell_means: Vec<Vec<f64>> = Vec::with_capacity(p);
507    for part in &input.measurements {
508        let mut row: Vec<f64> = Vec::with_capacity(o);
509        for trials in part {
510            let cell_sum: f64 = trials.iter().sum();
511            row.push(cell_sum / r as f64);
512        }
513        cell_means.push(row);
514    }
515
516    // SS_Part = o * r * Σ(part_mean - grand_mean)²
517    let ss_part: f64 = part_means
518        .iter()
519        .map(|&pm| (pm - grand_mean).powi(2))
520        .sum::<f64>()
521        * (o * r) as f64;
522
523    // SS_Operator = p * r * Σ(operator_mean - grand_mean)²
524    let ss_operator: f64 = operator_means
525        .iter()
526        .map(|&om| (om - grand_mean).powi(2))
527        .sum::<f64>()
528        * (p * r) as f64;
529
530    // SS_Interaction = r * Σ_ij (cell_mean_ij - part_mean_i - operator_mean_j + grand_mean)²
531    let mut ss_interaction = 0.0;
532    for (i, row) in cell_means.iter().enumerate() {
533        for (j, &cm) in row.iter().enumerate() {
534            let residual = cm - part_means[i] - operator_means[j] + grand_mean;
535            ss_interaction += residual.powi(2);
536        }
537    }
538    ss_interaction *= r as f64;
539
540    // SS_Total = Σ(x_ijk - grand_mean)²
541    let mut ss_total = 0.0;
542    for part in &input.measurements {
543        for trials in part {
544            for &v in trials {
545                ss_total += (v - grand_mean).powi(2);
546            }
547        }
548    }
549
550    // SS_Error = SS_Total - SS_Part - SS_Operator - SS_Interaction
551    let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
552
553    // Degrees of freedom
554    let df_part = (p - 1) as f64;
555    let df_operator = (o - 1) as f64;
556    let df_interaction = ((p - 1) * (o - 1)) as f64;
557    let df_error = (p * o * (r - 1)) as f64;
558    let df_total = (n_total - 1) as f64;
559
560    // Mean squares
561    let ms_part = ss_part / df_part;
562    let ms_operator = ss_operator / df_operator;
563    let ms_interaction = if df_interaction > 0.0 {
564        ss_interaction / df_interaction
565    } else {
566        0.0
567    };
568    let ms_error = if df_error > 0.0 {
569        ss_error / df_error
570    } else {
571        0.0
572    };
573
574    // A mean-square denominator is numerically *degenerate* — indistinguishable
575    // from zero variance — when it collapses to floating-point noise relative to
576    // the total variation (e.g. every trial within each cell identical, so the
577    // within-cell repeatability SS is 0 up to rounding: ms ≈ ±1e-17). A *relative*
578    // floor, with a backstop in proportion to the measurements' own size (a
579    // variation below 1e-8 of the grand mean is rounding) for the all-identical
580    // case where the total MS is itself ~0 -- a fixed number there would call a
581    // study of micrometres, written in metres, degenerate -- keeps the boundary
582    // sign-independent: a repeatability
583    // MS of +2e-17 and one that dips to −4e-18 under rounding are BOTH reported as
584    // "not computable" (F/p = None), rather than flipping between a spurious ~1e12
585    // finite F ("very significant") and None depending on the noise sign.
586    let ms_total = ss_total / df_total;
587    let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-16 * grand_mean * grand_mean);
588    let is_valid_denom = |ms: f64| ms > degenerate_floor;
589
590    // F statistics and p-values
591    let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
592        let f_val = ms_interaction / ms_error;
593        let p_val = special::f_distribution_sf(f_val, df_interaction, df_error);
594        (Some(f_val), Some(p_val))
595    } else {
596        (None, None)
597    };
598
599    // Determine if interaction should be pooled (p > 0.25)
600    let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
601    let interaction_pooled = !interaction_significant;
602
603    // Pooled error term (AIAG "without interaction" model): interaction SS/df is
604    // folded into the error term, yielding a single pooled MS that serves as BOTH
605    // the common F-test denominator AND the repeatability variance component.
606    // Computed once here so every consumer (F-tests, ANOVA table, variance
607    // components) uses the identical value.
608    let pooled_ss = ss_error + ss_interaction;
609    let pooled_df = df_error + df_interaction;
610    let ms_pooled = if pooled_df > 0.0 {
611        pooled_ss / pooled_df
612    } else {
613        ms_error
614    };
615
616    // Determine the denominator for Part and Operator F-tests
617    let (denom_ms, denom_df) = if interaction_pooled {
618        (ms_pooled, pooled_df)
619    } else {
620        (ms_interaction, df_interaction)
621    };
622
623    let (f_part, p_part) = if is_valid_denom(denom_ms) {
624        let f_val = ms_part / denom_ms;
625        let p_val = special::f_distribution_sf(f_val, df_part, denom_df);
626        (Some(f_val), Some(p_val))
627    } else {
628        (None, None)
629    };
630
631    let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
632        let f_val = ms_operator / denom_ms;
633        let p_val = special::f_distribution_sf(f_val, df_operator, denom_df);
634        (Some(f_val), Some(p_val))
635    } else {
636        (None, None)
637    };
638
639    // Build ANOVA table. The error/repeatability row must reflect the SAME model
640    // the F-tests and variance components use. When the interaction is pooled we
641    // report the pooled error term (df = df_error + df_interaction) and drop the
642    // Part×Operator row (it has been folded into error), so that (a) the row df's
643    // sum to df_total and (b) the returned f_value for Part/Operator is
644    // reproducible from the table's own MS values (f_part = ms_part / ms_pooled).
645    // Previously the flag said "pooled" while the table still showed the unpooled
646    // Error row, making the reported F un-reproducible from the displayed numbers.
647    let mut rows = vec![
648        AnovaRow {
649            source: "Part".to_owned(),
650            df: df_part,
651            ss: ss_part,
652            ms: ms_part,
653            f_value: f_part,
654            p_value: p_part,
655        },
656        AnovaRow {
657            source: "Operator".to_owned(),
658            df: df_operator,
659            ss: ss_operator,
660            ms: ms_operator,
661            f_value: f_operator,
662            p_value: p_operator,
663        },
664    ];
665    if !interaction_pooled {
666        rows.push(AnovaRow {
667            source: "Part×Operator".to_owned(),
668            df: df_interaction,
669            ss: ss_interaction,
670            ms: ms_interaction,
671            f_value: f_interaction,
672            p_value: p_interaction,
673        });
674    }
675    rows.push(AnovaRow {
676        source: "Repeatability".to_owned(),
677        df: if interaction_pooled {
678            pooled_df
679        } else {
680            df_error
681        },
682        ss: if interaction_pooled {
683            pooled_ss
684        } else {
685            ss_error
686        },
687        ms: if interaction_pooled {
688            ms_pooled
689        } else {
690            ms_error
691        },
692        f_value: None,
693        p_value: None,
694    });
695    rows.push(AnovaRow {
696        source: "Total".to_owned(),
697        df: df_total,
698        ss: ss_total,
699        ms: ss_total / df_total,
700        f_value: None,
701        p_value: None,
702    });
703    let anova_table = AnovaTable { rows };
704
705    // Variance components from expected mean squares. When the interaction is
706    // pooled, EVERY component — repeatability included — is derived from the
707    // single pooled error MS. Previously repeatability alone leaked the un-pooled
708    // raw MS_error while operator/part used the pooled MS, mixing two models in
709    // one result and inflating GRR / %GRR. `.max(0.0)` guards against a negative
710    // std-dev (NaN) when rounding pushes the (already ~0) MS slightly below zero.
711    let sigma2_repeatability = (if interaction_pooled {
712        ms_pooled
713    } else {
714        ms_error
715    })
716    .max(0.0);
717
718    let sigma2_interaction = if interaction_pooled {
719        0.0
720    } else {
721        let val = (ms_interaction - ms_error) / r as f64;
722        val.max(0.0)
723    };
724
725    let sigma2_operator = if interaction_pooled {
726        let val = (ms_operator - ms_pooled) / (p * r) as f64;
727        val.max(0.0)
728    } else {
729        let val = (ms_operator - ms_interaction) / (p * r) as f64;
730        val.max(0.0)
731    };
732
733    let sigma2_part = if interaction_pooled {
734        let val = (ms_part - ms_pooled) / (o * r) as f64;
735        val.max(0.0)
736    } else {
737        let val = (ms_part - ms_interaction) / (o * r) as f64;
738        val.max(0.0)
739    };
740
741    let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
742    let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
743    let sigma2_total = sigma2_part + sigma2_grr;
744
745    let variance_components = VarianceComponents {
746        part: sigma2_part,
747        operator: sigma2_operator,
748        interaction: sigma2_interaction,
749        repeatability: sigma2_repeatability,
750        reproducibility: sigma2_reproducibility,
751        total: sigma2_total,
752    };
753
754    // Convert variance components to standard deviations (study variation)
755    let ev = sigma2_repeatability.sqrt();
756    let av = sigma2_reproducibility.sqrt();
757    let grr = sigma2_grr.sqrt();
758    let pv = sigma2_part.sqrt();
759    let tv = sigma2_total.sqrt();
760
761    let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
762
763    let percent_tolerance = input.tolerance.and_then(|tol| {
764        if tol > 1e-300 {
765            Some(grr / tol * 600.0)
766        } else {
767            None
768        }
769    });
770
771    let ndc = compute_ndc(pv, grr);
772    let status = grr_status(percent_grr);
773
774    Ok(GageRRAnovaResult {
775        anova_table,
776        variance_components,
777        ev,
778        av,
779        grr,
780        pv,
781        tv,
782        percent_grr,
783        percent_tolerance,
784        ndc,
785        status,
786        interaction_significant,
787        interaction_pooled,
788    })
789}
790
791// ---------------------------------------------------------------------------
792// Tests
793// ---------------------------------------------------------------------------
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    /// Generate a simple balanced dataset for testing.
800    /// 3 operators, 10 parts, 3 trials.
801    /// Based on AIAG MSA 4th Edition reference data.
802    fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
803        // measurements[part][operator][trial]
804        vec![
805            // Part 1
806            vec![
807                vec![0.29, 0.41, 0.64],  // Operator A
808                vec![0.08, 0.25, 0.07],  // Operator B
809                vec![0.04, -0.11, 0.75], // Operator C
810            ],
811            // Part 2
812            vec![
813                vec![-0.56, -0.68, -0.58],
814                vec![-0.47, -1.22, -0.68],
815                vec![-0.49, -0.56, -0.49],
816            ],
817            // Part 3
818            vec![
819                vec![1.34, 1.17, 1.27],
820                vec![1.19, 0.94, 1.34],
821                vec![1.02, 0.82, 0.90],
822            ],
823            // Part 4
824            vec![
825                vec![0.47, 0.50, 0.64],
826                vec![0.01, 0.14, 0.43],
827                vec![0.12, 0.22, 0.31],
828            ],
829            // Part 5
830            vec![
831                vec![-0.80, -0.92, -0.84],
832                vec![-0.56, -1.20, -1.28],
833                vec![-0.44, -0.21, -0.17],
834            ],
835            // Part 6
836            vec![
837                vec![0.02, 0.16, -0.10],
838                vec![0.01, -0.10, 0.07],
839                vec![-0.14, -0.46, 0.18],
840            ],
841            // Part 7
842            vec![
843                vec![0.59, 0.75, 0.66],
844                vec![0.55, 0.36, 0.51],
845                vec![0.47, 0.63, 0.34],
846            ],
847            // Part 8
848            vec![
849                vec![-0.31, -0.20, 0.17],
850                vec![0.02, -0.09, 0.12],
851                vec![-0.24, 0.04, -0.19],
852            ],
853            // Part 9
854            vec![
855                vec![2.26, 1.99, 2.01],
856                vec![1.80, 2.12, 2.19],
857                vec![1.80, 1.71, 2.29],
858            ],
859            // Part 10
860            vec![
861                vec![-1.36, -1.14, -1.30],
862                vec![-1.34, -1.11, -1.42],
863                vec![-1.13, -1.13, -0.96],
864            ],
865        ]
866    }
867
868    // -----------------------------------------------------------------------
869    // X̄-R method tests
870    // -----------------------------------------------------------------------
871
872    /// The study's two charts come back with it, from the same R̄ the variance
873    /// components are built from.
874    ///
875    /// A consumer that draws them otherwise re-derives R̄ from the raw
876    /// measurements and copies the D3/D4/A2 tables out of a manual -- two
877    /// chances for its numbers to disagree with the ones beside them.
878    #[test]
879    fn the_result_carries_the_charts_the_method_plots() {
880        // 3 parts x 2 operators x 3 trials.
881        let measurements = vec![
882            vec![vec![10.0, 10.2, 10.1], vec![10.1, 10.0, 10.2]],
883            vec![vec![12.0, 12.1, 12.3], vec![12.2, 12.0, 12.1]],
884            vec![vec![14.0, 14.1, 14.2], vec![14.1, 14.3, 14.0]],
885        ];
886        let input = GageRRInput {
887            measurements: measurements.clone(),
888            tolerance: None,
889        };
890        let r = gage_rr_xbar_r(&input).expect("3 parts, 2 operators, 3 trials");
891
892        // R̄ computed independently from the cell ranges.
893        let cell_ranges: Vec<f64> = measurements
894            .iter()
895            .flat_map(|part| {
896                part.iter().map(|trials| {
897                    trials.iter().copied().fold(f64::NEG_INFINITY, f64::max)
898                        - trials.iter().copied().fold(f64::INFINITY, f64::min)
899                })
900            })
901            .collect();
902        let r_bar = cell_ranges.iter().sum::<f64>() / cell_ranges.len() as f64;
903        assert!(
904            (r.range_chart.center - r_bar).abs() < 1e-12,
905            "R̄ = {} vs {r_bar}",
906            r.range_chart.center
907        );
908
909        // n = 3 trials: AIAG D3 = 0, D4 = 2.575, A2 = 1.023.
910        assert!((r.range_chart.ucl - 2.575 * r_bar).abs() < 1e-12);
911        assert_eq!(r.range_chart.lcl, 0.0, "D3 is 0 at n = 3");
912
913        // X̿ over the cell averages, and the average chart around it.
914        let cell_means: Vec<f64> = measurements
915            .iter()
916            .flat_map(|part| {
917                part.iter()
918                    .map(|trials| trials.iter().sum::<f64>() / trials.len() as f64)
919            })
920            .collect();
921        let x_double_bar = cell_means.iter().sum::<f64>() / cell_means.len() as f64;
922        assert!((r.average_chart.center - x_double_bar).abs() < 1e-12);
923        assert!((r.average_chart.ucl - (x_double_bar + 1.023 * r_bar)).abs() < 1e-12);
924        assert!((r.average_chart.lcl - (x_double_bar - 1.023 * r_bar)).abs() < 1e-12);
925    }
926
927    /// The factor tables are the crate's, and the subgroup sizes this method
928    /// can reach are only 2 and 3 -- so a caller never meets a size the tables
929    /// do not cover, and never needs a fallback for one.
930    #[test]
931    fn the_method_only_ever_asks_for_factors_it_has() {
932        let two = vec![
933            vec![vec![10.0, 10.2], vec![10.1, 10.0]],
934            vec![vec![12.0, 12.1], vec![12.2, 12.0]],
935        ];
936        let r = gage_rr_xbar_r(&GageRRInput {
937            measurements: two,
938            tolerance: None,
939        })
940        .expect("2 trials");
941        // n = 2: D4 = 3.267, A2 = 1.880.
942        assert!((r.range_chart.ucl / r.range_chart.center - 3.267).abs() < 1e-12);
943
944        // Four trials is refused by the method itself, before any factor is
945        // looked up: there is no path to an uncovered subgroup size.
946        let four = vec![
947            vec![vec![10.0, 10.2, 10.1, 10.3], vec![10.1, 10.0, 10.2, 10.1]],
948            vec![vec![12.0, 12.1, 12.3, 12.2], vec![12.2, 12.0, 12.1, 12.3]],
949        ];
950        assert!(gage_rr_xbar_r(&GageRRInput {
951            measurements: four,
952            tolerance: None,
953        })
954        .is_err());
955    }
956
957    #[test]
958    fn xbar_r_basic_computation() {
959        let data = aiag_reference_data();
960        let input = GageRRInput {
961            measurements: data,
962            tolerance: Some(4.0),
963        };
964        let result = gage_rr_xbar_r(&input).expect("should compute");
965
966        // EV, AV, GRR should be positive
967        assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
968        assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
969        assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
970        assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
971
972        // GRR = sqrt(EV² + AV²)
973        let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
974        assert!(
975            (result.grr - expected_grr).abs() < 1e-10,
976            "GRR identity failed: {} vs {}",
977            result.grr,
978            expected_grr
979        );
980
981        // TV = sqrt(GRR² + PV²)
982        let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
983        assert!(
984            (result.tv - expected_tv).abs() < 1e-10,
985            "TV identity failed: {} vs {}",
986            result.tv,
987            expected_tv
988        );
989
990        // Percentages should sum close to 100% (via Pythagorean: %EV² + %AV² + %PV² ≈ 10000)
991        // Actually: %GRR² + %PV² = 10000 since TV is the hypotenuse
992        let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
993        assert!(
994            (pct_check - 10000.0).abs() < 1.0,
995            "percentage identity: {} should be ~10000",
996            pct_check
997        );
998
999        // %Tolerance should be present when tolerance is provided
1000        assert!(result.percent_tolerance.is_some());
1001
1002        // NDC should be at least 1
1003        assert!(result.ndc >= 1);
1004    }
1005
1006    #[test]
1007    fn xbar_r_ndc_minimum_one() {
1008        // Create data where GRR >> PV (bad measurement system)
1009        let data = vec![
1010            vec![vec![1.0, 5.0], vec![0.0, 6.0]],
1011            vec![vec![1.5, 4.5], vec![0.5, 5.5]],
1012        ];
1013        let input = GageRRInput {
1014            measurements: data,
1015            tolerance: None,
1016        };
1017        let result = gage_rr_xbar_r(&input).expect("should compute");
1018        assert!(result.ndc >= 1, "NDC should be at least 1");
1019    }
1020
1021    #[test]
1022    fn xbar_r_status_classification() {
1023        let data = aiag_reference_data();
1024        let input = GageRRInput {
1025            measurements: data,
1026            tolerance: None,
1027        };
1028        let result = gage_rr_xbar_r(&input).expect("should compute");
1029
1030        // The status should match the percent_grr
1031        match result.status {
1032            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1033            GrrStatus::Marginal => {
1034                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1035            }
1036            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1037        }
1038    }
1039
1040    #[test]
1041    fn xbar_r_rejects_invalid_dimensions() {
1042        // Only 1 part
1043        let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
1044        let input = GageRRInput {
1045            measurements: data,
1046            tolerance: None,
1047        };
1048        assert!(gage_rr_xbar_r(&input).is_err());
1049
1050        // Only 1 operator
1051        let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
1052        let input = GageRRInput {
1053            measurements: data,
1054            tolerance: None,
1055        };
1056        assert!(gage_rr_xbar_r(&input).is_err());
1057
1058        // Only 1 trial
1059        let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
1060        let input = GageRRInput {
1061            measurements: data,
1062            tolerance: None,
1063        };
1064        assert!(gage_rr_xbar_r(&input).is_err());
1065    }
1066
1067    #[test]
1068    fn xbar_r_rejects_non_finite() {
1069        let data = vec![
1070            vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
1071            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
1072        ];
1073        let input = GageRRInput {
1074            measurements: data,
1075            tolerance: None,
1076        };
1077        assert!(gage_rr_xbar_r(&input).is_err());
1078    }
1079
1080    #[test]
1081    fn xbar_r_two_operators_two_trials() {
1082        // Minimal case: 2 parts, 2 operators, 2 trials
1083        let data = vec![
1084            vec![vec![10.0, 10.2], vec![10.1, 10.3]],
1085            vec![vec![20.0, 20.1], vec![19.9, 20.2]],
1086        ];
1087        let input = GageRRInput {
1088            measurements: data,
1089            tolerance: Some(2.0),
1090        };
1091        let result = gage_rr_xbar_r(&input).expect("should compute");
1092        assert!(result.ev > 0.0);
1093        assert!(result.pv > 0.0);
1094        assert!(result.percent_tolerance.is_some());
1095    }
1096
1097    // -----------------------------------------------------------------------
1098    // ANOVA method tests
1099    // -----------------------------------------------------------------------
1100
1101    #[test]
1102    fn anova_basic_computation() {
1103        let data = aiag_reference_data();
1104        let input = GageRRInput {
1105            measurements: data,
1106            tolerance: Some(4.0),
1107        };
1108        let result = gage_rr_anova(&input).expect("should compute");
1109
1110        // Variance components should be non-negative
1111        assert!(
1112            result.variance_components.repeatability >= 0.0,
1113            "σ²_repeatability should be non-negative"
1114        );
1115        assert!(
1116            result.variance_components.part >= 0.0,
1117            "σ²_part should be non-negative"
1118        );
1119        assert!(
1120            result.variance_components.operator >= 0.0,
1121            "σ²_operator should be non-negative"
1122        );
1123        assert!(
1124            result.variance_components.interaction >= 0.0,
1125            "σ²_interaction should be non-negative"
1126        );
1127
1128        // σ²_reproducibility = σ²_operator + σ²_interaction
1129        let expected_repro =
1130            result.variance_components.operator + result.variance_components.interaction;
1131        assert!(
1132            (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
1133            "σ²_reproducibility identity failed"
1134        );
1135
1136        // σ²_total = σ²_part + σ²_repeatability + σ²_reproducibility
1137        let expected_total = result.variance_components.part
1138            + result.variance_components.repeatability
1139            + result.variance_components.reproducibility;
1140        assert!(
1141            (result.variance_components.total - expected_total).abs() < 1e-10,
1142            "σ²_total identity failed"
1143        );
1144
1145        // ANOVA table should have 5 rows
1146        assert_eq!(result.anova_table.rows.len(), 5);
1147
1148        // Check source names
1149        assert_eq!(result.anova_table.rows[0].source, "Part");
1150        assert_eq!(result.anova_table.rows[1].source, "Operator");
1151        assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1152        assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1153        assert_eq!(result.anova_table.rows[4].source, "Total");
1154
1155        // EV, GRR, PV, TV should be consistent with variance components
1156        assert!(
1157            (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1158            "EV should be sqrt(σ²_repeatability)"
1159        );
1160        assert!(
1161            (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1162            "PV should be sqrt(σ²_part)"
1163        );
1164    }
1165
1166    #[test]
1167    fn anova_ss_decomposition() {
1168        let data = aiag_reference_data();
1169        let input = GageRRInput {
1170            measurements: data,
1171            tolerance: None,
1172        };
1173        let result = gage_rr_anova(&input).expect("should compute");
1174
1175        // SS_Part + SS_Operator + SS_Interaction + SS_Error = SS_Total
1176        let rows = &result.anova_table.rows;
1177        let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1178        let ss_total = rows[4].ss;
1179        assert!(
1180            (ss_sum - ss_total).abs() < 1e-8,
1181            "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1182            rows[0].ss,
1183            rows[1].ss,
1184            rows[2].ss,
1185            rows[3].ss,
1186            ss_sum,
1187            ss_total
1188        );
1189
1190        // DF decomposition
1191        let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1192        let df_total = rows[4].df;
1193        assert!(
1194            (df_sum - df_total).abs() < 1e-10,
1195            "DF decomposition failed: {} vs {}",
1196            df_sum,
1197            df_total
1198        );
1199    }
1200
1201    #[test]
1202    fn anova_interaction_pooling() {
1203        // Create data with negligible interaction (operators measure similarly)
1204        let data = vec![
1205            vec![
1206                vec![10.0, 10.1, 10.0],
1207                vec![10.0, 10.0, 10.1],
1208                vec![10.1, 10.0, 10.0],
1209            ],
1210            vec![
1211                vec![20.0, 20.1, 20.0],
1212                vec![20.0, 20.0, 20.1],
1213                vec![20.1, 20.0, 20.0],
1214            ],
1215            vec![
1216                vec![15.0, 15.1, 15.0],
1217                vec![15.0, 15.0, 15.1],
1218                vec![15.1, 15.0, 15.0],
1219            ],
1220        ];
1221        let input = GageRRInput {
1222            measurements: data,
1223            tolerance: None,
1224        };
1225        let result = gage_rr_anova(&input).expect("should compute");
1226
1227        // With no real interaction, it should likely be pooled
1228        if result.interaction_pooled {
1229            assert_eq!(result.variance_components.interaction, 0.0);
1230        }
1231    }
1232
1233    /// Regression (upstream-013): when the interaction is pooled, EVERY variance
1234    /// component — repeatability included — must be derived from the single pooled
1235    /// error MS, and the returned ANOVA table's Repeatability row must report that
1236    /// same pooled term so the Part/Operator F is reproducible from the table.
1237    ///
1238    /// The discriminating invariant: the denominator actually used for the Part
1239    /// F-test is `ms_part / f_part`. Before the fix σ²_repeatability leaked the raw
1240    /// (un-pooled) MS_error while f_part used the pooled MS, so they disagreed.
1241    #[test]
1242    fn anova_pooled_repeatability_uses_pooled_ms() {
1243        // 3 parts × 3 operators × 3 trials with a dominant part effect and
1244        // negligible operator/interaction — this pools (interaction p > 0.25).
1245        let data = vec![
1246            vec![
1247                vec![10.0, 10.1, 10.0],
1248                vec![10.0, 10.0, 10.1],
1249                vec![10.1, 10.0, 10.0],
1250            ],
1251            vec![
1252                vec![20.0, 20.1, 20.0],
1253                vec![20.0, 20.0, 20.1],
1254                vec![20.1, 20.0, 20.0],
1255            ],
1256            vec![
1257                vec![15.0, 15.1, 15.0],
1258                vec![15.0, 15.0, 15.1],
1259                vec![15.1, 15.0, 15.0],
1260            ],
1261        ];
1262        let input = GageRRInput {
1263            measurements: data,
1264            tolerance: None,
1265        };
1266        let result = gage_rr_anova(&input).expect("should compute");
1267        assert!(result.interaction_pooled, "this dataset should pool");
1268
1269        let rep_row = result
1270            .anova_table
1271            .rows
1272            .iter()
1273            .find(|r| r.source == "Repeatability")
1274            .expect("Repeatability row present");
1275        let part = result
1276            .anova_table
1277            .rows
1278            .iter()
1279            .find(|r| r.source == "Part")
1280            .expect("Part row");
1281
1282        // The pooled denominator actually used for f_part.
1283        let pooled_denom = part.ms / part.f_value.expect("Part F present");
1284
1285        // (a) σ²_repeatability equals that pooled denominator — NOT the raw MS.
1286        assert!(
1287            (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1288            "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1289            result.variance_components.repeatability
1290        );
1291        // (b) The Repeatability row MS equals it too — F reproducible from table.
1292        assert!(
1293            (rep_row.ms - pooled_denom).abs() < 1e-10,
1294            "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1295            rep_row.ms
1296        );
1297        // (c) When pooled, no standalone interaction row; df's sum to df_total.
1298        assert!(
1299            !result
1300                .anova_table
1301                .rows
1302                .iter()
1303                .any(|r| r.source == "Part×Operator"),
1304            "pooled table must not carry a separate interaction row"
1305        );
1306        let df_sum: f64 = result
1307            .anova_table
1308            .rows
1309            .iter()
1310            .filter(|r| r.source != "Total")
1311            .map(|r| r.df)
1312            .sum();
1313        let df_total = result
1314            .anova_table
1315            .rows
1316            .iter()
1317            .find(|r| r.source == "Total")
1318            .expect("Total row")
1319            .df;
1320        assert!(
1321            (df_sum - df_total).abs() < 1e-9,
1322            "component df ({df_sum}) must sum to total df ({df_total})"
1323        );
1324    }
1325
1326    /// Regression (upstream-011): a degenerate denominator (every trial within
1327    /// each cell identical → repeatability MS ≈ floating-point noise) must yield
1328    /// F/p = None, not a spurious ~1e12 finite F, and must do so regardless of the
1329    /// sign of the rounding noise.
1330    #[test]
1331    fn anova_degenerate_denominator_returns_none() {
1332        // Take the AIAG reference layout (10×3×3) and collapse every trial within
1333        // a cell to that cell's first value, so the within-cell repeatability SS is
1334        // 0. At this scale/count the SS *decomposition* residual does not cancel to
1335        // exact 0 — it lands on surviving floating-point noise (ms_error ≈ 1.85e-16,
1336        // reproducing the report's ~1e-17 case). The OLD absolute `> 1e-300` guard
1337        // divides the (real, ~0.08) interaction MS by that noise, yielding a
1338        // spurious F ≈ 4.4e14 that renders as "very significant"; the relative
1339        // degeneracy floor must instead report F/p as None.
1340        let mut data = aiag_reference_data();
1341        for part in data.iter_mut() {
1342            for op in part.iter_mut() {
1343                let first = op[0];
1344                for trial in op.iter_mut() {
1345                    *trial = first;
1346                }
1347            }
1348        }
1349        let input = GageRRInput {
1350            measurements: data,
1351            tolerance: None,
1352        };
1353        let result = gage_rr_anova(&input).expect("should compute");
1354
1355        for row in &result.anova_table.rows {
1356            if let Some(f) = row.f_value {
1357                assert!(
1358                    f.is_finite() && f < 1e6,
1359                    "degenerate denominator must not produce a divergent F for {}: {}",
1360                    row.source,
1361                    f
1362                );
1363            }
1364        }
1365        // Repeatability is ~0, so GRR-derived std devs must stay finite (no NaN).
1366        assert!(
1367            result.ev.is_finite(),
1368            "EV must be finite, got {}",
1369            result.ev
1370        );
1371        assert!(
1372            result.percent_grr.is_finite(),
1373            "%GRR must be finite, got {}",
1374            result.percent_grr
1375        );
1376    }
1377
1378    #[test]
1379    fn anova_rejects_invalid_input() {
1380        let data = vec![vec![vec![1.0, 2.0]]];
1381        let input = GageRRInput {
1382            measurements: data,
1383            tolerance: None,
1384        };
1385        assert!(gage_rr_anova(&input).is_err());
1386    }
1387
1388    #[test]
1389    fn anova_status_matches_percent_grr() {
1390        let data = aiag_reference_data();
1391        let input = GageRRInput {
1392            measurements: data,
1393            tolerance: None,
1394        };
1395        let result = gage_rr_anova(&input).expect("should compute");
1396
1397        match result.status {
1398            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1399            GrrStatus::Marginal => {
1400                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1401            }
1402            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1403        }
1404    }
1405
1406    #[test]
1407    fn anova_p_values_bounded() {
1408        let data = aiag_reference_data();
1409        let input = GageRRInput {
1410            measurements: data,
1411            tolerance: None,
1412        };
1413        let result = gage_rr_anova(&input).expect("should compute");
1414
1415        for row in &result.anova_table.rows {
1416            if let Some(p) = row.p_value {
1417                assert!(
1418                    (0.0..=1.0).contains(&p),
1419                    "p-value for {} out of range: {}",
1420                    row.source,
1421                    p
1422                );
1423            }
1424            if let Some(f) = row.f_value {
1425                assert!(
1426                    f >= 0.0,
1427                    "F-value for {} should be non-negative: {}",
1428                    row.source,
1429                    f
1430                );
1431            }
1432        }
1433    }
1434
1435    // -----------------------------------------------------------------------
1436    // Consistency between methods
1437    // -----------------------------------------------------------------------
1438
1439    #[test]
1440    fn both_methods_detect_same_dominant_variation() {
1441        let data = aiag_reference_data();
1442        let input_xr = GageRRInput {
1443            measurements: data.clone(),
1444            tolerance: None,
1445        };
1446        let input_anova = GageRRInput {
1447            measurements: data,
1448            tolerance: None,
1449        };
1450
1451        let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1452        let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1453
1454        // Both methods should agree on whether PV dominates over GRR
1455        let xr_pv_dominant = xr.pv > xr.grr;
1456        let anova_pv_dominant = anova.pv > anova.grr;
1457        assert_eq!(
1458            xr_pv_dominant, anova_pv_dominant,
1459            "X̄-R and ANOVA should agree on PV vs GRR dominance"
1460        );
1461    }
1462}
1463
1464/// The two-factor crossed ANOVA Gage R&R against the sums of squares, degrees
1465/// of freedom and p-values recomputed in Python 3 with statsmodels 0.15.0
1466/// (`ols("y ~ C(part) * C(op)")`, `anova_lm(typ=1)`) and SciPy 1.17.1
1467/// (`f.sf`), and the AIAG expected-mean-square formulas applied to them:
1468/// `s2_e = MSE`, `s2_po = (MS_po - MSE) / r`, `s2_o = (MS_o - MS_po) / (p r)`,
1469/// `s2_p = (MS_p - MS_po) / (o r)`; with the interaction pooled into the error
1470/// (p > 0.25) the pooled mean square replaces both `MSE` and `MS_po`.
1471#[cfg(test)]
1472mod statsmodels_reference {
1473    use super::*;
1474
1475    fn close(got: f64, want: f64, rel: f64, what: &str) {
1476        assert!(
1477            (got - want).abs() <= rel * want.abs().max(1e-300),
1478            "{what}: got {got:e}, reference {want:e}"
1479        );
1480    }
1481
1482    fn row<'a>(r: &'a GageRRAnovaResult, source: &str) -> &'a AnovaRow {
1483        r.anova_table
1484            .rows
1485            .iter()
1486            .find(|row| row.source == source)
1487            .unwrap_or_else(|| panic!("no {source} row"))
1488    }
1489
1490    /// Five parts, three operators, three trials; the operators differ part by part.
1491    const WITH_INTERACTION: [[[f64; 3]; 3]; 5] = [
1492        [
1493            [9.175, 9.464, 9.278],
1494            [10.431, 10.411, 10.349],
1495            [9.97, 10.326, 10.414],
1496        ],
1497        [
1498            [10.372, 10.076, 10.265],
1499            [10.461, 10.492, 10.828],
1500            [10.447, 10.587, 10.752],
1501        ],
1502        [
1503            [9.525, 9.61, 9.65],
1504            [9.866, 9.634, 9.868],
1505            [8.981, 8.458, 8.891],
1506        ],
1507        [
1508            [8.226, 8.089, 8.564],
1509            [9.054, 8.701, 8.93],
1510            [7.401, 7.263, 7.42],
1511        ],
1512        [
1513            [8.85, 8.982, 9.121],
1514            [8.368, 8.527, 8.407],
1515            [8.219, 7.982, 8.092],
1516        ],
1517    ];
1518
1519    /// The same design with no interaction (p = 0.69): the term is pooled.
1520    const NO_INTERACTION: [[[f64; 3]; 3]; 5] = [
1521        [
1522            [9.912, 9.796, 10.139],
1523            [10.298, 10.073, 10.272],
1524            [9.734, 9.604, 9.55],
1525        ],
1526        [
1527            [12.726, 12.881, 12.678],
1528            [12.936, 13.139, 13.199],
1529            [12.415, 12.568, 12.657],
1530        ],
1531        [
1532            [12.412, 12.303, 12.512],
1533            [12.844, 12.997, 12.568],
1534            [12.08, 12.049, 11.887],
1535        ],
1536        [
1537            [9.002, 9.074, 8.846],
1538            [9.579, 9.477, 9.442],
1539            [8.802, 8.901, 8.49],
1540        ],
1541        [
1542            [9.515, 9.513, 9.086],
1543            [9.817, 9.709, 9.895],
1544            [9.075, 9.151, 9.216],
1545        ],
1546    ];
1547
1548    fn study(data: &[[[f64; 3]; 3]; 5]) -> GageRRAnovaResult {
1549        let measurements = data
1550            .iter()
1551            .map(|part| part.iter().map(|trials| trials.to_vec()).collect())
1552            .collect();
1553        gage_rr_anova(&GageRRInput {
1554            measurements,
1555            tolerance: None,
1556        })
1557        .expect("a valid balanced study")
1558    }
1559
1560    #[test]
1561    fn a_significant_interaction_keeps_its_own_row_and_tests_against_it() {
1562        let r = study(&WITH_INTERACTION);
1563        assert!(r.interaction_significant && !r.interaction_pooled);
1564        for (source, df, ss, f, p) in [
1565            (
1566                "Part",
1567                4.0,
1568                33.57309724444448,
1569                Some(11.06391548380456),
1570                Some(0.0024102170377080408),
1571            ),
1572            (
1573                "Operator",
1574                2.0,
1575                2.786838044444448,
1576                Some(1.8367885671249295),
1577                Some(0.22056881947791407),
1578            ),
1579            (
1580                "Part×Operator",
1581                8.0,
1582                6.068935955555522,
1583                Some(28.125305658219634),
1584                Some(6.579056051488694e-12),
1585            ),
1586            ("Repeatability", 30.0, 0.809182666666665, None, None),
1587        ] {
1588            let row = row(&r, source);
1589            assert_eq!(row.df, df, "{source} df");
1590            close(row.ss, ss, 1e-9, &format!("{source} SS"));
1591            close(row.ms, ss / df, 1e-9, &format!("{source} MS"));
1592            assert_eq!(row.f_value.is_some(), f.is_some(), "{source} F present");
1593            if let (Some(got), Some(want)) = (row.f_value, f) {
1594                close(got, want, 1e-9, &format!("{source} F"));
1595            }
1596            if let (Some(got), Some(want)) = (row.p_value, p) {
1597                close(got, want, 1e-8, &format!("{source} p"));
1598            }
1599        }
1600        let c = &r.variance_components;
1601        close(c.part, 0.848295257407409, 1e-9, "sigma2 part");
1602        close(c.operator, 0.04232013518518559, 1e-9, "sigma2 operator");
1603        close(
1604            c.interaction,
1605            0.2438814129629616,
1606            1e-9,
1607            "sigma2 interaction",
1608        );
1609        close(
1610            c.repeatability,
1611            0.026972755555555502,
1612            1e-9,
1613            "sigma2 repeatability",
1614        );
1615        close(
1616            c.reproducibility,
1617            0.2862015481481472,
1618            1e-9,
1619            "sigma2 reproducibility",
1620        );
1621        close(c.total, 1.1614695611111117, 1e-9, "sigma2 total");
1622        close(r.ev, 0.16423384412341904, 1e-9, "EV");
1623        close(r.av, 0.5349780819324724, 1e-9, "AV");
1624        close(r.grr, 0.5596197849466213, 1e-9, "GRR");
1625        close(r.pv, 0.9210294552333324, 1e-9, "PV");
1626        close(r.tv, 1.0777149721104888, 1e-9, "TV");
1627        close(r.percent_grr, 51.926511130370415, 1e-9, "%GRR");
1628        assert_eq!(r.ndc, 2);
1629    }
1630
1631    #[test]
1632    fn a_negligible_interaction_is_pooled_and_the_tail_p_values_survive() {
1633        let r = study(&NO_INTERACTION);
1634        assert!(!r.interaction_significant && r.interaction_pooled);
1635        assert!(r
1636            .anova_table
1637            .rows
1638            .iter()
1639            .all(|row| row.source != "Part×Operator"));
1640        let part = row(&r, "Part");
1641        close(part.f_value.expect("F"), 1422.410771043164, 1e-9, "Part F");
1642        // statsmodels / SciPy f.sf: 8.18e-41 -- 1 - cdf cannot represent it.
1643        close(
1644            part.p_value.expect("p"),
1645            8.177666854037245e-41,
1646            1e-8,
1647            "Part p",
1648        );
1649        let operator = row(&r, "Operator");
1650        close(
1651            operator.f_value.expect("F"),
1652            88.62224500212629,
1653            1e-9,
1654            "Operator F",
1655        );
1656        close(
1657            operator.p_value.expect("p"),
1658            4.8998131777748826e-15,
1659            1e-8,
1660            "Operator p",
1661        );
1662        let error = row(&r, "Repeatability");
1663        assert_eq!(error.df, 38.0);
1664        close(
1665            error.ss,
1666            0.11419240000000108 + 0.6162759999999995,
1667            1e-9,
1668            "pooled SS",
1669        );
1670        let c = &r.variance_components;
1671        close(c.part, 3.0359521978557518, 1e-9, "sigma2 part");
1672        close(c.operator, 0.11228996686159866, 1e-9, "sigma2 operator");
1673        assert_eq!(c.interaction, 0.0);
1674        close(
1675            c.repeatability,
1676            0.01922285263157896,
1677            1e-9,
1678            "sigma2 repeatability",
1679        );
1680        close(c.total, 3.1674650173489294, 1e-9, "sigma2 total");
1681        close(r.ev, 0.1386465024137968, 1e-9, "EV");
1682        close(r.av, 0.3350969514358474, 1e-9, "AV");
1683        close(r.grr, 0.3626469626140244, 1e-9, "GRR");
1684        close(r.pv, 1.7423984038834952, 1e-9, "PV");
1685        close(r.tv, 1.7797373450453102, 1e-9, "TV");
1686        close(r.percent_grr, 20.37643159107794, 1e-9, "%GRR");
1687        assert_eq!(r.ndc, 6);
1688    }
1689}
1690
1691/// The K factors are `1 / d2*`, and `d2*` is a moment of the range of `m`
1692/// standard normal values -- so each table entry is derived here from the
1693/// definition, by integrating the joint density of the minimum and maximum
1694/// (`n (n-1) phi(x) phi(y) [Phi(y) - Phi(x)]^(n-2)`, `x < y`), and not read
1695/// from a table.
1696///
1697/// - `K1` (trials): the mean range `d2(m)`, since the average of many
1698///   ranges (parts x operators of them) is what R-bar estimates;
1699/// - `K2` (operators), `K3` (parts): one range, whose estimate of sigma is
1700///   `sqrt(E[R^2])` -- the root mean square of the range, `d2*(m, 1)`.
1701#[cfg(test)]
1702mod k_factor_definitions {
1703    use super::*;
1704
1705    /// `(E[R], E[R^2])` of the range of `n` standard normal values, by the
1706    /// midpoint rule on a grid of 0.02 over [-7, 7] squared.
1707    fn range_moments(n: usize) -> (f64, f64) {
1708        let (h, lo, steps) = (0.02_f64, -7.0_f64, 700_usize);
1709        let phi: Vec<f64> = (0..steps)
1710            .map(|i| special::standard_normal_pdf(lo + (i as f64 + 0.5) * h))
1711            .collect();
1712        let cdf: Vec<f64> = (0..steps)
1713            .map(|i| special::standard_normal_cdf(lo + (i as f64 + 0.5) * h))
1714            .collect();
1715        let (mut m1, mut m2) = (0.0, 0.0);
1716        let c = (n * (n - 1)) as f64;
1717        for i in 0..steps {
1718            for j in i + 1..steps {
1719                let density = c * phi[i] * phi[j] * (cdf[j] - cdf[i]).powi(n as i32 - 2);
1720                let r = (j - i) as f64 * h;
1721                m1 += r * density;
1722                m2 += r * r * density;
1723            }
1724        }
1725        (m1 * h * h, m2 * h * h)
1726    }
1727
1728    #[test]
1729    fn the_k_tables_are_one_over_the_range_moments_of_the_normal() {
1730        // Four decimals, as the table prints them: half a unit in the last place.
1731        let tol = 6e-5;
1732        for (i, &k1) in K1.iter().enumerate() {
1733            let (mean, _) = range_moments(i + 2);
1734            assert!(
1735                (k1 - 1.0 / mean).abs() <= tol,
1736                "K1[{} trials] = {k1}, 1/d2 = {}",
1737                i + 2,
1738                1.0 / mean
1739            );
1740        }
1741        for (i, &k2) in K2.iter().enumerate() {
1742            let (_, second) = range_moments(i + 2);
1743            assert!(
1744                (k2 - 1.0 / second.sqrt()).abs() <= tol,
1745                "K2[{} operators] = {k2}, 1/d2* = {}",
1746                i + 2,
1747                1.0 / second.sqrt()
1748            );
1749        }
1750        for (i, &k3) in K3.iter().enumerate() {
1751            let (_, second) = range_moments(i + 2);
1752            assert!(
1753                (k3 - 1.0 / second.sqrt()).abs() <= tol,
1754                "K3[{} parts] = {k3}, 1/d2* = {}",
1755                i + 2,
1756                1.0 / second.sqrt()
1757            );
1758        }
1759    }
1760}