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    // F statistics and p-values
528    let (f_interaction, p_interaction) = if ms_error > 1e-300 && df_interaction > 0.0 {
529        let f_val = ms_interaction / ms_error;
530        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_interaction, df_error);
531        (Some(f_val), Some(p_val))
532    } else {
533        (None, None)
534    };
535
536    // Determine if interaction should be pooled (p > 0.25)
537    let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
538    let interaction_pooled = !interaction_significant;
539
540    // Determine the denominator for Part and Operator F-tests
541    let (denom_ms, denom_df) = if interaction_pooled {
542        // Pool interaction into error
543        let pooled_ss = ss_error + ss_interaction;
544        let pooled_df = df_error + df_interaction;
545        (pooled_ss / pooled_df, pooled_df)
546    } else {
547        (ms_interaction, df_interaction)
548    };
549
550    let (f_part, p_part) = if denom_ms > 1e-300 {
551        let f_val = ms_part / denom_ms;
552        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_part, denom_df);
553        (Some(f_val), Some(p_val))
554    } else {
555        (None, None)
556    };
557
558    let (f_operator, p_operator) = if denom_ms > 1e-300 {
559        let f_val = ms_operator / denom_ms;
560        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_operator, denom_df);
561        (Some(f_val), Some(p_val))
562    } else {
563        (None, None)
564    };
565
566    // Build ANOVA table
567    let anova_table = AnovaTable {
568        rows: vec![
569            AnovaRow {
570                source: "Part".to_owned(),
571                df: df_part,
572                ss: ss_part,
573                ms: ms_part,
574                f_value: f_part,
575                p_value: p_part,
576            },
577            AnovaRow {
578                source: "Operator".to_owned(),
579                df: df_operator,
580                ss: ss_operator,
581                ms: ms_operator,
582                f_value: f_operator,
583                p_value: p_operator,
584            },
585            AnovaRow {
586                source: "Part×Operator".to_owned(),
587                df: df_interaction,
588                ss: ss_interaction,
589                ms: ms_interaction,
590                f_value: f_interaction,
591                p_value: p_interaction,
592            },
593            AnovaRow {
594                source: "Repeatability".to_owned(),
595                df: df_error,
596                ss: ss_error,
597                ms: ms_error,
598                f_value: None,
599                p_value: None,
600            },
601            AnovaRow {
602                source: "Total".to_owned(),
603                df: df_total,
604                ss: ss_total,
605                ms: ss_total / df_total,
606                f_value: None,
607                p_value: None,
608            },
609        ],
610    };
611
612    // Variance components from expected mean squares
613    let sigma2_repeatability = ms_error;
614
615    let sigma2_interaction = if interaction_pooled {
616        0.0
617    } else {
618        let val = (ms_interaction - ms_error) / r as f64;
619        val.max(0.0)
620    };
621
622    let sigma2_operator = if interaction_pooled {
623        let pooled_ms = (ss_error + ss_interaction) / (df_error + df_interaction);
624        let val = (ms_operator - pooled_ms) / (p * r) as f64;
625        val.max(0.0)
626    } else {
627        let val = (ms_operator - ms_interaction) / (p * r) as f64;
628        val.max(0.0)
629    };
630
631    let sigma2_part = if interaction_pooled {
632        let pooled_ms = (ss_error + ss_interaction) / (df_error + df_interaction);
633        let val = (ms_part - pooled_ms) / (o * r) as f64;
634        val.max(0.0)
635    } else {
636        let val = (ms_part - ms_interaction) / (o * r) as f64;
637        val.max(0.0)
638    };
639
640    let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
641    let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
642    let sigma2_total = sigma2_part + sigma2_grr;
643
644    let variance_components = VarianceComponents {
645        part: sigma2_part,
646        operator: sigma2_operator,
647        interaction: sigma2_interaction,
648        repeatability: sigma2_repeatability,
649        reproducibility: sigma2_reproducibility,
650        total: sigma2_total,
651    };
652
653    // Convert variance components to standard deviations (study variation)
654    let ev = sigma2_repeatability.sqrt();
655    let av = sigma2_reproducibility.sqrt();
656    let grr = sigma2_grr.sqrt();
657    let pv = sigma2_part.sqrt();
658    let tv = sigma2_total.sqrt();
659
660    let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
661
662    let percent_tolerance = input.tolerance.and_then(|tol| {
663        if tol > 1e-300 {
664            Some(grr / tol * 600.0)
665        } else {
666            None
667        }
668    });
669
670    let ndc = compute_ndc(pv, grr);
671    let status = grr_status(percent_grr);
672
673    Ok(GageRRAnovaResult {
674        anova_table,
675        variance_components,
676        ev,
677        av,
678        grr,
679        pv,
680        tv,
681        percent_grr,
682        percent_tolerance,
683        ndc,
684        status,
685        interaction_significant,
686        interaction_pooled,
687    })
688}
689
690// ---------------------------------------------------------------------------
691// Tests
692// ---------------------------------------------------------------------------
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    /// Generate a simple balanced dataset for testing.
699    /// 3 operators, 10 parts, 3 trials.
700    /// Based on AIAG MSA 4th Edition reference data.
701    fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
702        // measurements[part][operator][trial]
703        vec![
704            // Part 1
705            vec![
706                vec![0.29, 0.41, 0.64],  // Operator A
707                vec![0.08, 0.25, 0.07],  // Operator B
708                vec![0.04, -0.11, 0.75], // Operator C
709            ],
710            // Part 2
711            vec![
712                vec![-0.56, -0.68, -0.58],
713                vec![-0.47, -1.22, -0.68],
714                vec![-0.49, -0.56, -0.49],
715            ],
716            // Part 3
717            vec![
718                vec![1.34, 1.17, 1.27],
719                vec![1.19, 0.94, 1.34],
720                vec![1.02, 0.82, 0.90],
721            ],
722            // Part 4
723            vec![
724                vec![0.47, 0.50, 0.64],
725                vec![0.01, 0.14, 0.43],
726                vec![0.12, 0.22, 0.31],
727            ],
728            // Part 5
729            vec![
730                vec![-0.80, -0.92, -0.84],
731                vec![-0.56, -1.20, -1.28],
732                vec![-0.44, -0.21, -0.17],
733            ],
734            // Part 6
735            vec![
736                vec![0.02, 0.16, -0.10],
737                vec![0.01, -0.10, 0.07],
738                vec![-0.14, -0.46, 0.18],
739            ],
740            // Part 7
741            vec![
742                vec![0.59, 0.75, 0.66],
743                vec![0.55, 0.36, 0.51],
744                vec![0.47, 0.63, 0.34],
745            ],
746            // Part 8
747            vec![
748                vec![-0.31, -0.20, 0.17],
749                vec![0.02, -0.09, 0.12],
750                vec![-0.24, 0.04, -0.19],
751            ],
752            // Part 9
753            vec![
754                vec![2.26, 1.99, 2.01],
755                vec![1.80, 2.12, 2.19],
756                vec![1.80, 1.71, 2.29],
757            ],
758            // Part 10
759            vec![
760                vec![-1.36, -1.14, -1.30],
761                vec![-1.34, -1.11, -1.42],
762                vec![-1.13, -1.13, -0.96],
763            ],
764        ]
765    }
766
767    // -----------------------------------------------------------------------
768    // X̄-R method tests
769    // -----------------------------------------------------------------------
770
771    #[test]
772    fn xbar_r_basic_computation() {
773        let data = aiag_reference_data();
774        let input = GageRRInput {
775            measurements: data,
776            tolerance: Some(4.0),
777        };
778        let result = gage_rr_xbar_r(&input).expect("should compute");
779
780        // EV, AV, GRR should be positive
781        assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
782        assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
783        assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
784        assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
785
786        // GRR = sqrt(EV² + AV²)
787        let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
788        assert!(
789            (result.grr - expected_grr).abs() < 1e-10,
790            "GRR identity failed: {} vs {}",
791            result.grr,
792            expected_grr
793        );
794
795        // TV = sqrt(GRR² + PV²)
796        let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
797        assert!(
798            (result.tv - expected_tv).abs() < 1e-10,
799            "TV identity failed: {} vs {}",
800            result.tv,
801            expected_tv
802        );
803
804        // Percentages should sum close to 100% (via Pythagorean: %EV² + %AV² + %PV² ≈ 10000)
805        // Actually: %GRR² + %PV² = 10000 since TV is the hypotenuse
806        let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
807        assert!(
808            (pct_check - 10000.0).abs() < 1.0,
809            "percentage identity: {} should be ~10000",
810            pct_check
811        );
812
813        // %Tolerance should be present when tolerance is provided
814        assert!(result.percent_tolerance.is_some());
815
816        // NDC should be at least 1
817        assert!(result.ndc >= 1);
818    }
819
820    #[test]
821    fn xbar_r_ndc_minimum_one() {
822        // Create data where GRR >> PV (bad measurement system)
823        let data = vec![
824            vec![vec![1.0, 5.0], vec![0.0, 6.0]],
825            vec![vec![1.5, 4.5], vec![0.5, 5.5]],
826        ];
827        let input = GageRRInput {
828            measurements: data,
829            tolerance: None,
830        };
831        let result = gage_rr_xbar_r(&input).expect("should compute");
832        assert!(result.ndc >= 1, "NDC should be at least 1");
833    }
834
835    #[test]
836    fn xbar_r_status_classification() {
837        let data = aiag_reference_data();
838        let input = GageRRInput {
839            measurements: data,
840            tolerance: None,
841        };
842        let result = gage_rr_xbar_r(&input).expect("should compute");
843
844        // The status should match the percent_grr
845        match result.status {
846            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
847            GrrStatus::Marginal => {
848                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
849            }
850            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
851        }
852    }
853
854    #[test]
855    fn xbar_r_rejects_invalid_dimensions() {
856        // Only 1 part
857        let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
858        let input = GageRRInput {
859            measurements: data,
860            tolerance: None,
861        };
862        assert!(gage_rr_xbar_r(&input).is_err());
863
864        // Only 1 operator
865        let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
866        let input = GageRRInput {
867            measurements: data,
868            tolerance: None,
869        };
870        assert!(gage_rr_xbar_r(&input).is_err());
871
872        // Only 1 trial
873        let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
874        let input = GageRRInput {
875            measurements: data,
876            tolerance: None,
877        };
878        assert!(gage_rr_xbar_r(&input).is_err());
879    }
880
881    #[test]
882    fn xbar_r_rejects_non_finite() {
883        let data = vec![
884            vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
885            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
886        ];
887        let input = GageRRInput {
888            measurements: data,
889            tolerance: None,
890        };
891        assert!(gage_rr_xbar_r(&input).is_err());
892    }
893
894    #[test]
895    fn xbar_r_two_operators_two_trials() {
896        // Minimal case: 2 parts, 2 operators, 2 trials
897        let data = vec![
898            vec![vec![10.0, 10.2], vec![10.1, 10.3]],
899            vec![vec![20.0, 20.1], vec![19.9, 20.2]],
900        ];
901        let input = GageRRInput {
902            measurements: data,
903            tolerance: Some(2.0),
904        };
905        let result = gage_rr_xbar_r(&input).expect("should compute");
906        assert!(result.ev > 0.0);
907        assert!(result.pv > 0.0);
908        assert!(result.percent_tolerance.is_some());
909    }
910
911    // -----------------------------------------------------------------------
912    // ANOVA method tests
913    // -----------------------------------------------------------------------
914
915    #[test]
916    fn anova_basic_computation() {
917        let data = aiag_reference_data();
918        let input = GageRRInput {
919            measurements: data,
920            tolerance: Some(4.0),
921        };
922        let result = gage_rr_anova(&input).expect("should compute");
923
924        // Variance components should be non-negative
925        assert!(
926            result.variance_components.repeatability >= 0.0,
927            "σ²_repeatability should be non-negative"
928        );
929        assert!(
930            result.variance_components.part >= 0.0,
931            "σ²_part should be non-negative"
932        );
933        assert!(
934            result.variance_components.operator >= 0.0,
935            "σ²_operator should be non-negative"
936        );
937        assert!(
938            result.variance_components.interaction >= 0.0,
939            "σ²_interaction should be non-negative"
940        );
941
942        // σ²_reproducibility = σ²_operator + σ²_interaction
943        let expected_repro =
944            result.variance_components.operator + result.variance_components.interaction;
945        assert!(
946            (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
947            "σ²_reproducibility identity failed"
948        );
949
950        // σ²_total = σ²_part + σ²_repeatability + σ²_reproducibility
951        let expected_total = result.variance_components.part
952            + result.variance_components.repeatability
953            + result.variance_components.reproducibility;
954        assert!(
955            (result.variance_components.total - expected_total).abs() < 1e-10,
956            "σ²_total identity failed"
957        );
958
959        // ANOVA table should have 5 rows
960        assert_eq!(result.anova_table.rows.len(), 5);
961
962        // Check source names
963        assert_eq!(result.anova_table.rows[0].source, "Part");
964        assert_eq!(result.anova_table.rows[1].source, "Operator");
965        assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
966        assert_eq!(result.anova_table.rows[3].source, "Repeatability");
967        assert_eq!(result.anova_table.rows[4].source, "Total");
968
969        // EV, GRR, PV, TV should be consistent with variance components
970        assert!(
971            (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
972            "EV should be sqrt(σ²_repeatability)"
973        );
974        assert!(
975            (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
976            "PV should be sqrt(σ²_part)"
977        );
978    }
979
980    #[test]
981    fn anova_ss_decomposition() {
982        let data = aiag_reference_data();
983        let input = GageRRInput {
984            measurements: data,
985            tolerance: None,
986        };
987        let result = gage_rr_anova(&input).expect("should compute");
988
989        // SS_Part + SS_Operator + SS_Interaction + SS_Error = SS_Total
990        let rows = &result.anova_table.rows;
991        let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
992        let ss_total = rows[4].ss;
993        assert!(
994            (ss_sum - ss_total).abs() < 1e-8,
995            "SS decomposition: {} + {} + {} + {} = {} vs total {}",
996            rows[0].ss,
997            rows[1].ss,
998            rows[2].ss,
999            rows[3].ss,
1000            ss_sum,
1001            ss_total
1002        );
1003
1004        // DF decomposition
1005        let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1006        let df_total = rows[4].df;
1007        assert!(
1008            (df_sum - df_total).abs() < 1e-10,
1009            "DF decomposition failed: {} vs {}",
1010            df_sum,
1011            df_total
1012        );
1013    }
1014
1015    #[test]
1016    fn anova_interaction_pooling() {
1017        // Create data with negligible interaction (operators measure similarly)
1018        let data = vec![
1019            vec![
1020                vec![10.0, 10.1, 10.0],
1021                vec![10.0, 10.0, 10.1],
1022                vec![10.1, 10.0, 10.0],
1023            ],
1024            vec![
1025                vec![20.0, 20.1, 20.0],
1026                vec![20.0, 20.0, 20.1],
1027                vec![20.1, 20.0, 20.0],
1028            ],
1029            vec![
1030                vec![15.0, 15.1, 15.0],
1031                vec![15.0, 15.0, 15.1],
1032                vec![15.1, 15.0, 15.0],
1033            ],
1034        ];
1035        let input = GageRRInput {
1036            measurements: data,
1037            tolerance: None,
1038        };
1039        let result = gage_rr_anova(&input).expect("should compute");
1040
1041        // With no real interaction, it should likely be pooled
1042        if result.interaction_pooled {
1043            assert_eq!(result.variance_components.interaction, 0.0);
1044        }
1045    }
1046
1047    #[test]
1048    fn anova_rejects_invalid_input() {
1049        let data = vec![vec![vec![1.0, 2.0]]];
1050        let input = GageRRInput {
1051            measurements: data,
1052            tolerance: None,
1053        };
1054        assert!(gage_rr_anova(&input).is_err());
1055    }
1056
1057    #[test]
1058    fn anova_status_matches_percent_grr() {
1059        let data = aiag_reference_data();
1060        let input = GageRRInput {
1061            measurements: data,
1062            tolerance: None,
1063        };
1064        let result = gage_rr_anova(&input).expect("should compute");
1065
1066        match result.status {
1067            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1068            GrrStatus::Marginal => {
1069                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1070            }
1071            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1072        }
1073    }
1074
1075    #[test]
1076    fn anova_p_values_bounded() {
1077        let data = aiag_reference_data();
1078        let input = GageRRInput {
1079            measurements: data,
1080            tolerance: None,
1081        };
1082        let result = gage_rr_anova(&input).expect("should compute");
1083
1084        for row in &result.anova_table.rows {
1085            if let Some(p) = row.p_value {
1086                assert!(
1087                    (0.0..=1.0).contains(&p),
1088                    "p-value for {} out of range: {}",
1089                    row.source,
1090                    p
1091                );
1092            }
1093            if let Some(f) = row.f_value {
1094                assert!(
1095                    f >= 0.0,
1096                    "F-value for {} should be non-negative: {}",
1097                    row.source,
1098                    f
1099                );
1100            }
1101        }
1102    }
1103
1104    // -----------------------------------------------------------------------
1105    // Consistency between methods
1106    // -----------------------------------------------------------------------
1107
1108    #[test]
1109    fn both_methods_detect_same_dominant_variation() {
1110        let data = aiag_reference_data();
1111        let input_xr = GageRRInput {
1112            measurements: data.clone(),
1113            tolerance: None,
1114        };
1115        let input_anova = GageRRInput {
1116            measurements: data,
1117            tolerance: None,
1118        };
1119
1120        let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1121        let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1122
1123        // Both methods should agree on whether PV dominates over GRR
1124        let xr_pv_dominant = xr.pv > xr.grr;
1125        let anova_pv_dominant = anova.pv > anova.grr;
1126        assert_eq!(
1127            xr_pv_dominant, anova_pv_dominant,
1128            "X̄-R and ANOVA should agree on PV vs GRR dominance"
1129        );
1130    }
1131}