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
287                .iter()
288                .copied()
289                .fold(f64::INFINITY, f64::min);
290            let max = trials
291                .iter()
292                .copied()
293                .fold(f64::NEG_INFINITY, f64::max);
294            ranges.push(max - min);
295        }
296    }
297
298    // Step 2: R̄ = grand mean of all ranges
299    let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
300
301    // Step 3: Operator averages and X̄_diff
302    // Compute the average measurement for each operator across all parts and trials
303    let mut operator_avgs: Vec<f64> = Vec::with_capacity(n_operators);
304    for op in 0..n_operators {
305        let mut sum = 0.0;
306        let mut count = 0usize;
307        for part in &input.measurements {
308            for &v in &part[op] {
309                sum += v;
310                count += 1;
311            }
312        }
313        operator_avgs.push(sum / count as f64);
314    }
315
316    let x_diff = operator_avgs
317        .iter()
318        .copied()
319        .fold(f64::NEG_INFINITY, f64::max)
320        - operator_avgs
321            .iter()
322            .copied()
323            .fold(f64::INFINITY, f64::min);
324
325    // Step 4: EV and AV
326    let k1 = K1[n_trials - 2];
327    let k2 = K2[n_operators - 2];
328
329    let ev = r_bar * k1;
330
331    let av_squared = (x_diff * k2).powi(2) - ev.powi(2) / (n_parts * n_trials) as f64;
332    let av = if av_squared > 0.0 {
333        av_squared.sqrt()
334    } else {
335        0.0
336    };
337
338    // Step 5: GRR
339    let grr = (ev.powi(2) + av.powi(2)).sqrt();
340
341    // Step 6: Part means across all operators/trials → PV
342    let mut part_means: Vec<f64> = Vec::with_capacity(n_parts);
343    for part in &input.measurements {
344        let mut sum = 0.0;
345        let mut count = 0usize;
346        for trials in part {
347            for &v in trials {
348                sum += v;
349                count += 1;
350            }
351        }
352        part_means.push(sum / count as f64);
353    }
354
355    let rp = part_means
356        .iter()
357        .copied()
358        .fold(f64::NEG_INFINITY, f64::max)
359        - part_means
360            .iter()
361            .copied()
362            .fold(f64::INFINITY, f64::min);
363
364    let k3 = K3[n_parts - 2];
365    let pv = rp * k3;
366
367    // Step 7: TV
368    let tv = (grr.powi(2) + pv.powi(2)).sqrt();
369
370    // Percentages
371    let (percent_ev, percent_av, percent_grr, percent_pv) = if tv > 1e-300 {
372        (
373            ev / tv * 100.0,
374            av / tv * 100.0,
375            grr / tv * 100.0,
376            pv / tv * 100.0,
377        )
378    } else {
379        (0.0, 0.0, 0.0, 0.0)
380    };
381
382    let percent_tolerance = input.tolerance.and_then(|tol| {
383        if tol > 1e-300 {
384            Some(grr / tol * 600.0)
385        } else {
386            None
387        }
388    });
389
390    let ndc = compute_ndc(pv, grr);
391    let status = grr_status(percent_grr);
392
393    Ok(GageRRResult {
394        ev,
395        av,
396        grr,
397        pv,
398        tv,
399        percent_ev,
400        percent_av,
401        percent_grr,
402        percent_pv,
403        percent_tolerance,
404        ndc,
405        status,
406    })
407}
408
409// ---------------------------------------------------------------------------
410// ANOVA Method
411// ---------------------------------------------------------------------------
412
413/// Gage R&R using the two-factor crossed ANOVA method (AIAG MSA 4th Edition).
414///
415/// # Algorithm
416///
417/// Performs a Part × Operator crossed ANOVA with replications (trials).
418/// Computes SS for Part, Operator, Interaction, and Error (Repeatability).
419/// Extracts variance components from expected mean squares.
420/// If the interaction p-value > 0.25, pools the interaction into error.
421///
422/// # Arguments
423///
424/// * `input` — Measurement data and optional tolerance.
425///
426/// # Returns
427///
428/// `Err` if input dimensions are invalid (need ≥ 2 parts, ≥ 2 operators, ≥ 2 trials).
429///
430/// # References
431///
432/// - AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III, Section D.
433/// - Montgomery (2019). *Introduction to Statistical Quality Control*, 8th ed., §5.4.
434pub fn gage_rr_anova(input: &GageRRInput) -> Result<GageRRAnovaResult, &'static str> {
435    let (p, o, r) = validate_measurements(&input.measurements)?;
436    let n_total = p * o * r;
437
438    // Compute grand mean
439    let mut grand_sum = 0.0;
440    for part in &input.measurements {
441        for trials in part {
442            for &v in trials {
443                grand_sum += v;
444            }
445        }
446    }
447    let grand_mean = grand_sum / n_total as f64;
448
449    // Part means (across all operators and trials)
450    let mut part_means: Vec<f64> = Vec::with_capacity(p);
451    for part in &input.measurements {
452        let mut sum = 0.0;
453        for trials in part {
454            for &v in trials {
455                sum += v;
456            }
457        }
458        part_means.push(sum / (o * r) as f64);
459    }
460
461    // Operator means (across all parts and trials)
462    let mut operator_means: Vec<f64> = Vec::with_capacity(o);
463    for op in 0..o {
464        let mut sum = 0.0;
465        for part in &input.measurements {
466            for &v in &part[op] {
467                sum += v;
468            }
469        }
470        operator_means.push(sum / (p * r) as f64);
471    }
472
473    // Cell means (part × operator, averaged over trials)
474    let mut cell_means: Vec<Vec<f64>> = Vec::with_capacity(p);
475    for part in &input.measurements {
476        let mut row: Vec<f64> = Vec::with_capacity(o);
477        for trials in part {
478            let cell_sum: f64 = trials.iter().sum();
479            row.push(cell_sum / r as f64);
480        }
481        cell_means.push(row);
482    }
483
484    // SS_Part = o * r * Σ(part_mean - grand_mean)²
485    let ss_part: f64 = part_means
486        .iter()
487        .map(|&pm| (pm - grand_mean).powi(2))
488        .sum::<f64>()
489        * (o * r) as f64;
490
491    // SS_Operator = p * r * Σ(operator_mean - grand_mean)²
492    let ss_operator: f64 = operator_means
493        .iter()
494        .map(|&om| (om - grand_mean).powi(2))
495        .sum::<f64>()
496        * (p * r) as f64;
497
498    // SS_Interaction = r * Σ_ij (cell_mean_ij - part_mean_i - operator_mean_j + grand_mean)²
499    let mut ss_interaction = 0.0;
500    for (i, row) in cell_means.iter().enumerate() {
501        for (j, &cm) in row.iter().enumerate() {
502            let residual = cm - part_means[i] - operator_means[j] + grand_mean;
503            ss_interaction += residual.powi(2);
504        }
505    }
506    ss_interaction *= r as f64;
507
508    // SS_Total = Σ(x_ijk - grand_mean)²
509    let mut ss_total = 0.0;
510    for part in &input.measurements {
511        for trials in part {
512            for &v in trials {
513                ss_total += (v - grand_mean).powi(2);
514            }
515        }
516    }
517
518    // SS_Error = SS_Total - SS_Part - SS_Operator - SS_Interaction
519    let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
520
521    // Degrees of freedom
522    let df_part = (p - 1) as f64;
523    let df_operator = (o - 1) as f64;
524    let df_interaction = ((p - 1) * (o - 1)) as f64;
525    let df_error = (p * o * (r - 1)) as f64;
526    let df_total = (n_total - 1) as f64;
527
528    // Mean squares
529    let ms_part = ss_part / df_part;
530    let ms_operator = ss_operator / df_operator;
531    let ms_interaction = if df_interaction > 0.0 {
532        ss_interaction / df_interaction
533    } else {
534        0.0
535    };
536    let ms_error = if df_error > 0.0 {
537        ss_error / df_error
538    } else {
539        0.0
540    };
541
542    // F statistics and p-values
543    let (f_interaction, p_interaction) = if ms_error > 1e-300 && df_interaction > 0.0 {
544        let f_val = ms_interaction / ms_error;
545        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_interaction, df_error);
546        (Some(f_val), Some(p_val))
547    } else {
548        (None, None)
549    };
550
551    // Determine if interaction should be pooled (p > 0.25)
552    let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
553    let interaction_pooled = !interaction_significant;
554
555    // Determine the denominator for Part and Operator F-tests
556    let (denom_ms, denom_df) = if interaction_pooled {
557        // Pool interaction into error
558        let pooled_ss = ss_error + ss_interaction;
559        let pooled_df = df_error + df_interaction;
560        (pooled_ss / pooled_df, pooled_df)
561    } else {
562        (ms_interaction, df_interaction)
563    };
564
565    let (f_part, p_part) = if denom_ms > 1e-300 {
566        let f_val = ms_part / denom_ms;
567        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_part, denom_df);
568        (Some(f_val), Some(p_val))
569    } else {
570        (None, None)
571    };
572
573    let (f_operator, p_operator) = if denom_ms > 1e-300 {
574        let f_val = ms_operator / denom_ms;
575        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_operator, denom_df);
576        (Some(f_val), Some(p_val))
577    } else {
578        (None, None)
579    };
580
581    // Build ANOVA table
582    let anova_table = AnovaTable {
583        rows: vec![
584            AnovaRow {
585                source: "Part".to_owned(),
586                df: df_part,
587                ss: ss_part,
588                ms: ms_part,
589                f_value: f_part,
590                p_value: p_part,
591            },
592            AnovaRow {
593                source: "Operator".to_owned(),
594                df: df_operator,
595                ss: ss_operator,
596                ms: ms_operator,
597                f_value: f_operator,
598                p_value: p_operator,
599            },
600            AnovaRow {
601                source: "Part×Operator".to_owned(),
602                df: df_interaction,
603                ss: ss_interaction,
604                ms: ms_interaction,
605                f_value: f_interaction,
606                p_value: p_interaction,
607            },
608            AnovaRow {
609                source: "Repeatability".to_owned(),
610                df: df_error,
611                ss: ss_error,
612                ms: ms_error,
613                f_value: None,
614                p_value: None,
615            },
616            AnovaRow {
617                source: "Total".to_owned(),
618                df: df_total,
619                ss: ss_total,
620                ms: ss_total / df_total,
621                f_value: None,
622                p_value: None,
623            },
624        ],
625    };
626
627    // Variance components from expected mean squares
628    let sigma2_repeatability = ms_error;
629
630    let sigma2_interaction = if interaction_pooled {
631        0.0
632    } else {
633        let val = (ms_interaction - ms_error) / r as f64;
634        val.max(0.0)
635    };
636
637    let sigma2_operator = if interaction_pooled {
638        let pooled_ms = (ss_error + ss_interaction) / (df_error + df_interaction);
639        let val = (ms_operator - pooled_ms) / (p * r) as f64;
640        val.max(0.0)
641    } else {
642        let val = (ms_operator - ms_interaction) / (p * r) as f64;
643        val.max(0.0)
644    };
645
646    let sigma2_part = if interaction_pooled {
647        let pooled_ms = (ss_error + ss_interaction) / (df_error + df_interaction);
648        let val = (ms_part - pooled_ms) / (o * r) as f64;
649        val.max(0.0)
650    } else {
651        let val = (ms_part - ms_interaction) / (o * r) as f64;
652        val.max(0.0)
653    };
654
655    let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
656    let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
657    let sigma2_total = sigma2_part + sigma2_grr;
658
659    let variance_components = VarianceComponents {
660        part: sigma2_part,
661        operator: sigma2_operator,
662        interaction: sigma2_interaction,
663        repeatability: sigma2_repeatability,
664        reproducibility: sigma2_reproducibility,
665        total: sigma2_total,
666    };
667
668    // Convert variance components to standard deviations (study variation)
669    let ev = sigma2_repeatability.sqrt();
670    let av = sigma2_reproducibility.sqrt();
671    let grr = sigma2_grr.sqrt();
672    let pv = sigma2_part.sqrt();
673    let tv = sigma2_total.sqrt();
674
675    let percent_grr = if tv > 1e-300 {
676        grr / tv * 100.0
677    } else {
678        0.0
679    };
680
681    let percent_tolerance = input.tolerance.and_then(|tol| {
682        if tol > 1e-300 {
683            Some(grr / tol * 600.0)
684        } else {
685            None
686        }
687    });
688
689    let ndc = compute_ndc(pv, grr);
690    let status = grr_status(percent_grr);
691
692    Ok(GageRRAnovaResult {
693        anova_table,
694        variance_components,
695        ev,
696        av,
697        grr,
698        pv,
699        tv,
700        percent_grr,
701        percent_tolerance,
702        ndc,
703        status,
704        interaction_significant,
705        interaction_pooled,
706    })
707}
708
709// ---------------------------------------------------------------------------
710// Tests
711// ---------------------------------------------------------------------------
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    /// Generate a simple balanced dataset for testing.
718    /// 3 operators, 10 parts, 3 trials.
719    /// Based on AIAG MSA 4th Edition reference data.
720    fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
721        // measurements[part][operator][trial]
722        vec![
723            // Part 1
724            vec![
725                vec![0.29, 0.41, 0.64],  // Operator A
726                vec![0.08, 0.25, 0.07],  // Operator B
727                vec![0.04, -0.11, 0.75], // Operator C
728            ],
729            // Part 2
730            vec![
731                vec![-0.56, -0.68, -0.58],
732                vec![-0.47, -1.22, -0.68],
733                vec![-0.49, -0.56, -0.49],
734            ],
735            // Part 3
736            vec![
737                vec![1.34, 1.17, 1.27],
738                vec![1.19, 0.94, 1.34],
739                vec![1.02, 0.82, 0.90],
740            ],
741            // Part 4
742            vec![
743                vec![0.47, 0.50, 0.64],
744                vec![0.01, 0.14, 0.43],
745                vec![0.12, 0.22, 0.31],
746            ],
747            // Part 5
748            vec![
749                vec![-0.80, -0.92, -0.84],
750                vec![-0.56, -1.20, -1.28],
751                vec![-0.44, -0.21, -0.17],
752            ],
753            // Part 6
754            vec![
755                vec![0.02, 0.16, -0.10],
756                vec![0.01, -0.10, 0.07],
757                vec![-0.14, -0.46, 0.18],
758            ],
759            // Part 7
760            vec![
761                vec![0.59, 0.75, 0.66],
762                vec![0.55, 0.36, 0.51],
763                vec![0.47, 0.63, 0.34],
764            ],
765            // Part 8
766            vec![
767                vec![-0.31, -0.20, 0.17],
768                vec![0.02, -0.09, 0.12],
769                vec![-0.24, 0.04, -0.19],
770            ],
771            // Part 9
772            vec![
773                vec![2.26, 1.99, 2.01],
774                vec![1.80, 2.12, 2.19],
775                vec![1.80, 1.71, 2.29],
776            ],
777            // Part 10
778            vec![
779                vec![-1.36, -1.14, -1.30],
780                vec![-1.34, -1.11, -1.42],
781                vec![-1.13, -1.13, -0.96],
782            ],
783        ]
784    }
785
786    // -----------------------------------------------------------------------
787    // X̄-R method tests
788    // -----------------------------------------------------------------------
789
790    #[test]
791    fn xbar_r_basic_computation() {
792        let data = aiag_reference_data();
793        let input = GageRRInput {
794            measurements: data,
795            tolerance: Some(4.0),
796        };
797        let result = gage_rr_xbar_r(&input).expect("should compute");
798
799        // EV, AV, GRR should be positive
800        assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
801        assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
802        assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
803        assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
804
805        // GRR = sqrt(EV² + AV²)
806        let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
807        assert!(
808            (result.grr - expected_grr).abs() < 1e-10,
809            "GRR identity failed: {} vs {}",
810            result.grr,
811            expected_grr
812        );
813
814        // TV = sqrt(GRR² + PV²)
815        let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
816        assert!(
817            (result.tv - expected_tv).abs() < 1e-10,
818            "TV identity failed: {} vs {}",
819            result.tv,
820            expected_tv
821        );
822
823        // Percentages should sum close to 100% (via Pythagorean: %EV² + %AV² + %PV² ≈ 10000)
824        // Actually: %GRR² + %PV² = 10000 since TV is the hypotenuse
825        let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
826        assert!(
827            (pct_check - 10000.0).abs() < 1.0,
828            "percentage identity: {} should be ~10000",
829            pct_check
830        );
831
832        // %Tolerance should be present when tolerance is provided
833        assert!(result.percent_tolerance.is_some());
834
835        // NDC should be at least 1
836        assert!(result.ndc >= 1);
837    }
838
839    #[test]
840    fn xbar_r_ndc_minimum_one() {
841        // Create data where GRR >> PV (bad measurement system)
842        let data = vec![
843            vec![vec![1.0, 5.0], vec![0.0, 6.0]],
844            vec![vec![1.5, 4.5], vec![0.5, 5.5]],
845        ];
846        let input = GageRRInput {
847            measurements: data,
848            tolerance: None,
849        };
850        let result = gage_rr_xbar_r(&input).expect("should compute");
851        assert!(result.ndc >= 1, "NDC should be at least 1");
852    }
853
854    #[test]
855    fn xbar_r_status_classification() {
856        let data = aiag_reference_data();
857        let input = GageRRInput {
858            measurements: data,
859            tolerance: None,
860        };
861        let result = gage_rr_xbar_r(&input).expect("should compute");
862
863        // The status should match the percent_grr
864        match result.status {
865            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
866            GrrStatus::Marginal => {
867                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
868            }
869            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
870        }
871    }
872
873    #[test]
874    fn xbar_r_rejects_invalid_dimensions() {
875        // Only 1 part
876        let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
877        let input = GageRRInput {
878            measurements: data,
879            tolerance: None,
880        };
881        assert!(gage_rr_xbar_r(&input).is_err());
882
883        // Only 1 operator
884        let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
885        let input = GageRRInput {
886            measurements: data,
887            tolerance: None,
888        };
889        assert!(gage_rr_xbar_r(&input).is_err());
890
891        // Only 1 trial
892        let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
893        let input = GageRRInput {
894            measurements: data,
895            tolerance: None,
896        };
897        assert!(gage_rr_xbar_r(&input).is_err());
898    }
899
900    #[test]
901    fn xbar_r_rejects_non_finite() {
902        let data = vec![
903            vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
904            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
905        ];
906        let input = GageRRInput {
907            measurements: data,
908            tolerance: None,
909        };
910        assert!(gage_rr_xbar_r(&input).is_err());
911    }
912
913    #[test]
914    fn xbar_r_two_operators_two_trials() {
915        // Minimal case: 2 parts, 2 operators, 2 trials
916        let data = vec![
917            vec![vec![10.0, 10.2], vec![10.1, 10.3]],
918            vec![vec![20.0, 20.1], vec![19.9, 20.2]],
919        ];
920        let input = GageRRInput {
921            measurements: data,
922            tolerance: Some(2.0),
923        };
924        let result = gage_rr_xbar_r(&input).expect("should compute");
925        assert!(result.ev > 0.0);
926        assert!(result.pv > 0.0);
927        assert!(result.percent_tolerance.is_some());
928    }
929
930    // -----------------------------------------------------------------------
931    // ANOVA method tests
932    // -----------------------------------------------------------------------
933
934    #[test]
935    fn anova_basic_computation() {
936        let data = aiag_reference_data();
937        let input = GageRRInput {
938            measurements: data,
939            tolerance: Some(4.0),
940        };
941        let result = gage_rr_anova(&input).expect("should compute");
942
943        // Variance components should be non-negative
944        assert!(
945            result.variance_components.repeatability >= 0.0,
946            "σ²_repeatability should be non-negative"
947        );
948        assert!(
949            result.variance_components.part >= 0.0,
950            "σ²_part should be non-negative"
951        );
952        assert!(
953            result.variance_components.operator >= 0.0,
954            "σ²_operator should be non-negative"
955        );
956        assert!(
957            result.variance_components.interaction >= 0.0,
958            "σ²_interaction should be non-negative"
959        );
960
961        // σ²_reproducibility = σ²_operator + σ²_interaction
962        let expected_repro = result.variance_components.operator + result.variance_components.interaction;
963        assert!(
964            (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
965            "σ²_reproducibility identity failed"
966        );
967
968        // σ²_total = σ²_part + σ²_repeatability + σ²_reproducibility
969        let expected_total = result.variance_components.part
970            + result.variance_components.repeatability
971            + result.variance_components.reproducibility;
972        assert!(
973            (result.variance_components.total - expected_total).abs() < 1e-10,
974            "σ²_total identity failed"
975        );
976
977        // ANOVA table should have 5 rows
978        assert_eq!(result.anova_table.rows.len(), 5);
979
980        // Check source names
981        assert_eq!(result.anova_table.rows[0].source, "Part");
982        assert_eq!(result.anova_table.rows[1].source, "Operator");
983        assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
984        assert_eq!(result.anova_table.rows[3].source, "Repeatability");
985        assert_eq!(result.anova_table.rows[4].source, "Total");
986
987        // EV, GRR, PV, TV should be consistent with variance components
988        assert!(
989            (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
990            "EV should be sqrt(σ²_repeatability)"
991        );
992        assert!(
993            (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
994            "PV should be sqrt(σ²_part)"
995        );
996    }
997
998    #[test]
999    fn anova_ss_decomposition() {
1000        let data = aiag_reference_data();
1001        let input = GageRRInput {
1002            measurements: data,
1003            tolerance: None,
1004        };
1005        let result = gage_rr_anova(&input).expect("should compute");
1006
1007        // SS_Part + SS_Operator + SS_Interaction + SS_Error = SS_Total
1008        let rows = &result.anova_table.rows;
1009        let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1010        let ss_total = rows[4].ss;
1011        assert!(
1012            (ss_sum - ss_total).abs() < 1e-8,
1013            "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1014            rows[0].ss,
1015            rows[1].ss,
1016            rows[2].ss,
1017            rows[3].ss,
1018            ss_sum,
1019            ss_total
1020        );
1021
1022        // DF decomposition
1023        let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1024        let df_total = rows[4].df;
1025        assert!(
1026            (df_sum - df_total).abs() < 1e-10,
1027            "DF decomposition failed: {} vs {}",
1028            df_sum,
1029            df_total
1030        );
1031    }
1032
1033    #[test]
1034    fn anova_interaction_pooling() {
1035        // Create data with negligible interaction (operators measure similarly)
1036        let data = vec![
1037            vec![vec![10.0, 10.1, 10.0], vec![10.0, 10.0, 10.1], vec![10.1, 10.0, 10.0]],
1038            vec![vec![20.0, 20.1, 20.0], vec![20.0, 20.0, 20.1], vec![20.1, 20.0, 20.0]],
1039            vec![vec![15.0, 15.1, 15.0], vec![15.0, 15.0, 15.1], vec![15.1, 15.0, 15.0]],
1040        ];
1041        let input = GageRRInput {
1042            measurements: data,
1043            tolerance: None,
1044        };
1045        let result = gage_rr_anova(&input).expect("should compute");
1046
1047        // With no real interaction, it should likely be pooled
1048        if result.interaction_pooled {
1049            assert_eq!(result.variance_components.interaction, 0.0);
1050        }
1051    }
1052
1053    #[test]
1054    fn anova_rejects_invalid_input() {
1055        let data = vec![vec![vec![1.0, 2.0]]];
1056        let input = GageRRInput {
1057            measurements: data,
1058            tolerance: None,
1059        };
1060        assert!(gage_rr_anova(&input).is_err());
1061    }
1062
1063    #[test]
1064    fn anova_status_matches_percent_grr() {
1065        let data = aiag_reference_data();
1066        let input = GageRRInput {
1067            measurements: data,
1068            tolerance: None,
1069        };
1070        let result = gage_rr_anova(&input).expect("should compute");
1071
1072        match result.status {
1073            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1074            GrrStatus::Marginal => {
1075                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1076            }
1077            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1078        }
1079    }
1080
1081    #[test]
1082    fn anova_p_values_bounded() {
1083        let data = aiag_reference_data();
1084        let input = GageRRInput {
1085            measurements: data,
1086            tolerance: None,
1087        };
1088        let result = gage_rr_anova(&input).expect("should compute");
1089
1090        for row in &result.anova_table.rows {
1091            if let Some(p) = row.p_value {
1092                assert!(
1093                    (0.0..=1.0).contains(&p),
1094                    "p-value for {} out of range: {}",
1095                    row.source,
1096                    p
1097                );
1098            }
1099            if let Some(f) = row.f_value {
1100                assert!(f >= 0.0, "F-value for {} should be non-negative: {}", row.source, f);
1101            }
1102        }
1103    }
1104
1105    // -----------------------------------------------------------------------
1106    // Consistency between methods
1107    // -----------------------------------------------------------------------
1108
1109    #[test]
1110    fn both_methods_detect_same_dominant_variation() {
1111        let data = aiag_reference_data();
1112        let input_xr = GageRRInput {
1113            measurements: data.clone(),
1114            tolerance: None,
1115        };
1116        let input_anova = GageRRInput {
1117            measurements: data,
1118            tolerance: None,
1119        };
1120
1121        let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1122        let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1123
1124        // Both methods should agree on whether PV dominates over GRR
1125        let xr_pv_dominant = xr.pv > xr.grr;
1126        let anova_pv_dominant = anova.pv > anova.grr;
1127        assert_eq!(
1128            xr_pv_dominant, anova_pv_dominant,
1129            "X̄-R and ANOVA should agree on PV vs GRR dominance"
1130        );
1131    }
1132}