1use u_numflow::special;
19use u_numflow::stats;
20
21pub struct GageRRInput {
31 pub measurements: Vec<Vec<Vec<f64>>>,
33 pub tolerance: Option<f64>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct GageChartLimits {
40 pub center: f64,
42 pub ucl: f64,
44 pub lcl: f64,
46}
47
48#[derive(Debug, Clone)]
50pub struct GageRRResult {
51 pub ev: f64,
53 pub av: f64,
55 pub grr: f64,
57 pub pv: f64,
59 pub tv: f64,
61
62 pub percent_ev: f64,
64 pub percent_av: f64,
66 pub percent_grr: f64,
68 pub percent_pv: f64,
70 pub percent_tolerance: Option<f64>,
72
73 pub range_chart: GageChartLimits,
76 pub average_chart: GageChartLimits,
83
84 pub ndc: u32,
86 pub status: GrrStatus,
88}
89
90#[derive(Debug, Clone)]
92pub struct GageRRAnovaResult {
93 pub anova_table: AnovaTable,
95 pub variance_components: VarianceComponents,
97 pub ev: f64,
99 pub av: f64,
101 pub grr: f64,
103 pub pv: f64,
105 pub tv: f64,
107 pub percent_grr: f64,
109 pub percent_tolerance: Option<f64>,
111 pub ndc: u32,
113 pub status: GrrStatus,
115 pub interaction_significant: bool,
117 pub interaction_pooled: bool,
119}
120
121#[derive(Debug, Clone)]
123pub struct AnovaTable {
124 pub rows: Vec<AnovaRow>,
126}
127
128#[derive(Debug, Clone)]
130pub struct AnovaRow {
131 pub source: String,
133 pub df: f64,
135 pub ss: f64,
137 pub ms: f64,
139 pub f_value: Option<f64>,
141 pub p_value: Option<f64>,
143}
144
145#[derive(Debug, Clone)]
147pub struct VarianceComponents {
148 pub part: f64,
150 pub operator: f64,
152 pub interaction: f64,
154 pub repeatability: f64,
156 pub reproducibility: f64,
158 pub total: f64,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum GrrStatus {
165 Acceptable,
167 Marginal,
169 Unacceptable,
171}
172
173#[allow(clippy::approx_constant)]
182const K1: [f64; 2] = [0.8862, 0.5908];
183
184#[allow(clippy::approx_constant)]
189const K2: [f64; 2] = [0.7071, 0.5231];
190
191#[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
200fn 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
215fn 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
224fn 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
263pub 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 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 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 let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
316
317 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 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 let grr = (ev.powi(2) + av.powi(2)).sqrt();
353
354 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 let tv = (grr.powi(2) + pv.powi(2)).sqrt();
376
377 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 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
441pub 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 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 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 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 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 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 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 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 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 let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
552
553 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 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 let ms_total = ss_total / df_total;
584 let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-12);
585 let is_valid_denom = |ms: f64| ms > degenerate_floor;
586
587 let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
589 let f_val = ms_interaction / ms_error;
590 let p_val = special::f_distribution_sf(f_val, df_interaction, df_error);
591 (Some(f_val), Some(p_val))
592 } else {
593 (None, None)
594 };
595
596 let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
598 let interaction_pooled = !interaction_significant;
599
600 let pooled_ss = ss_error + ss_interaction;
606 let pooled_df = df_error + df_interaction;
607 let ms_pooled = if pooled_df > 0.0 {
608 pooled_ss / pooled_df
609 } else {
610 ms_error
611 };
612
613 let (denom_ms, denom_df) = if interaction_pooled {
615 (ms_pooled, pooled_df)
616 } else {
617 (ms_interaction, df_interaction)
618 };
619
620 let (f_part, p_part) = if is_valid_denom(denom_ms) {
621 let f_val = ms_part / denom_ms;
622 let p_val = special::f_distribution_sf(f_val, df_part, denom_df);
623 (Some(f_val), Some(p_val))
624 } else {
625 (None, None)
626 };
627
628 let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
629 let f_val = ms_operator / denom_ms;
630 let p_val = special::f_distribution_sf(f_val, df_operator, denom_df);
631 (Some(f_val), Some(p_val))
632 } else {
633 (None, None)
634 };
635
636 let mut rows = vec![
645 AnovaRow {
646 source: "Part".to_owned(),
647 df: df_part,
648 ss: ss_part,
649 ms: ms_part,
650 f_value: f_part,
651 p_value: p_part,
652 },
653 AnovaRow {
654 source: "Operator".to_owned(),
655 df: df_operator,
656 ss: ss_operator,
657 ms: ms_operator,
658 f_value: f_operator,
659 p_value: p_operator,
660 },
661 ];
662 if !interaction_pooled {
663 rows.push(AnovaRow {
664 source: "Part×Operator".to_owned(),
665 df: df_interaction,
666 ss: ss_interaction,
667 ms: ms_interaction,
668 f_value: f_interaction,
669 p_value: p_interaction,
670 });
671 }
672 rows.push(AnovaRow {
673 source: "Repeatability".to_owned(),
674 df: if interaction_pooled {
675 pooled_df
676 } else {
677 df_error
678 },
679 ss: if interaction_pooled {
680 pooled_ss
681 } else {
682 ss_error
683 },
684 ms: if interaction_pooled {
685 ms_pooled
686 } else {
687 ms_error
688 },
689 f_value: None,
690 p_value: None,
691 });
692 rows.push(AnovaRow {
693 source: "Total".to_owned(),
694 df: df_total,
695 ss: ss_total,
696 ms: ss_total / df_total,
697 f_value: None,
698 p_value: None,
699 });
700 let anova_table = AnovaTable { rows };
701
702 let sigma2_repeatability = (if interaction_pooled {
709 ms_pooled
710 } else {
711 ms_error
712 })
713 .max(0.0);
714
715 let sigma2_interaction = if interaction_pooled {
716 0.0
717 } else {
718 let val = (ms_interaction - ms_error) / r as f64;
719 val.max(0.0)
720 };
721
722 let sigma2_operator = if interaction_pooled {
723 let val = (ms_operator - ms_pooled) / (p * r) as f64;
724 val.max(0.0)
725 } else {
726 let val = (ms_operator - ms_interaction) / (p * r) as f64;
727 val.max(0.0)
728 };
729
730 let sigma2_part = if interaction_pooled {
731 let val = (ms_part - ms_pooled) / (o * r) as f64;
732 val.max(0.0)
733 } else {
734 let val = (ms_part - ms_interaction) / (o * r) as f64;
735 val.max(0.0)
736 };
737
738 let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
739 let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
740 let sigma2_total = sigma2_part + sigma2_grr;
741
742 let variance_components = VarianceComponents {
743 part: sigma2_part,
744 operator: sigma2_operator,
745 interaction: sigma2_interaction,
746 repeatability: sigma2_repeatability,
747 reproducibility: sigma2_reproducibility,
748 total: sigma2_total,
749 };
750
751 let ev = sigma2_repeatability.sqrt();
753 let av = sigma2_reproducibility.sqrt();
754 let grr = sigma2_grr.sqrt();
755 let pv = sigma2_part.sqrt();
756 let tv = sigma2_total.sqrt();
757
758 let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
759
760 let percent_tolerance = input.tolerance.and_then(|tol| {
761 if tol > 1e-300 {
762 Some(grr / tol * 600.0)
763 } else {
764 None
765 }
766 });
767
768 let ndc = compute_ndc(pv, grr);
769 let status = grr_status(percent_grr);
770
771 Ok(GageRRAnovaResult {
772 anova_table,
773 variance_components,
774 ev,
775 av,
776 grr,
777 pv,
778 tv,
779 percent_grr,
780 percent_tolerance,
781 ndc,
782 status,
783 interaction_significant,
784 interaction_pooled,
785 })
786}
787
788#[cfg(test)]
793mod tests {
794 use super::*;
795
796 fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
800 vec![
802 vec![
804 vec![0.29, 0.41, 0.64], vec![0.08, 0.25, 0.07], vec![0.04, -0.11, 0.75], ],
808 vec![
810 vec![-0.56, -0.68, -0.58],
811 vec![-0.47, -1.22, -0.68],
812 vec![-0.49, -0.56, -0.49],
813 ],
814 vec![
816 vec![1.34, 1.17, 1.27],
817 vec![1.19, 0.94, 1.34],
818 vec![1.02, 0.82, 0.90],
819 ],
820 vec![
822 vec![0.47, 0.50, 0.64],
823 vec![0.01, 0.14, 0.43],
824 vec![0.12, 0.22, 0.31],
825 ],
826 vec![
828 vec![-0.80, -0.92, -0.84],
829 vec![-0.56, -1.20, -1.28],
830 vec![-0.44, -0.21, -0.17],
831 ],
832 vec![
834 vec![0.02, 0.16, -0.10],
835 vec![0.01, -0.10, 0.07],
836 vec![-0.14, -0.46, 0.18],
837 ],
838 vec![
840 vec![0.59, 0.75, 0.66],
841 vec![0.55, 0.36, 0.51],
842 vec![0.47, 0.63, 0.34],
843 ],
844 vec![
846 vec![-0.31, -0.20, 0.17],
847 vec![0.02, -0.09, 0.12],
848 vec![-0.24, 0.04, -0.19],
849 ],
850 vec![
852 vec![2.26, 1.99, 2.01],
853 vec![1.80, 2.12, 2.19],
854 vec![1.80, 1.71, 2.29],
855 ],
856 vec![
858 vec![-1.36, -1.14, -1.30],
859 vec![-1.34, -1.11, -1.42],
860 vec![-1.13, -1.13, -0.96],
861 ],
862 ]
863 }
864
865 #[test]
876 fn the_result_carries_the_charts_the_method_plots() {
877 let measurements = vec![
879 vec![vec![10.0, 10.2, 10.1], vec![10.1, 10.0, 10.2]],
880 vec![vec![12.0, 12.1, 12.3], vec![12.2, 12.0, 12.1]],
881 vec![vec![14.0, 14.1, 14.2], vec![14.1, 14.3, 14.0]],
882 ];
883 let input = GageRRInput {
884 measurements: measurements.clone(),
885 tolerance: None,
886 };
887 let r = gage_rr_xbar_r(&input).expect("3 parts, 2 operators, 3 trials");
888
889 let cell_ranges: Vec<f64> = measurements
891 .iter()
892 .flat_map(|part| {
893 part.iter().map(|trials| {
894 trials.iter().copied().fold(f64::NEG_INFINITY, f64::max)
895 - trials.iter().copied().fold(f64::INFINITY, f64::min)
896 })
897 })
898 .collect();
899 let r_bar = cell_ranges.iter().sum::<f64>() / cell_ranges.len() as f64;
900 assert!(
901 (r.range_chart.center - r_bar).abs() < 1e-12,
902 "R̄ = {} vs {r_bar}",
903 r.range_chart.center
904 );
905
906 assert!((r.range_chart.ucl - 2.575 * r_bar).abs() < 1e-12);
908 assert_eq!(r.range_chart.lcl, 0.0, "D3 is 0 at n = 3");
909
910 let cell_means: Vec<f64> = measurements
912 .iter()
913 .flat_map(|part| {
914 part.iter()
915 .map(|trials| trials.iter().sum::<f64>() / trials.len() as f64)
916 })
917 .collect();
918 let x_double_bar = cell_means.iter().sum::<f64>() / cell_means.len() as f64;
919 assert!((r.average_chart.center - x_double_bar).abs() < 1e-12);
920 assert!((r.average_chart.ucl - (x_double_bar + 1.023 * r_bar)).abs() < 1e-12);
921 assert!((r.average_chart.lcl - (x_double_bar - 1.023 * r_bar)).abs() < 1e-12);
922 }
923
924 #[test]
928 fn the_method_only_ever_asks_for_factors_it_has() {
929 let two = vec![
930 vec![vec![10.0, 10.2], vec![10.1, 10.0]],
931 vec![vec![12.0, 12.1], vec![12.2, 12.0]],
932 ];
933 let r = gage_rr_xbar_r(&GageRRInput {
934 measurements: two,
935 tolerance: None,
936 })
937 .expect("2 trials");
938 assert!((r.range_chart.ucl / r.range_chart.center - 3.267).abs() < 1e-12);
940
941 let four = vec![
944 vec![vec![10.0, 10.2, 10.1, 10.3], vec![10.1, 10.0, 10.2, 10.1]],
945 vec![vec![12.0, 12.1, 12.3, 12.2], vec![12.2, 12.0, 12.1, 12.3]],
946 ];
947 assert!(gage_rr_xbar_r(&GageRRInput {
948 measurements: four,
949 tolerance: None,
950 })
951 .is_err());
952 }
953
954 #[test]
955 fn xbar_r_basic_computation() {
956 let data = aiag_reference_data();
957 let input = GageRRInput {
958 measurements: data,
959 tolerance: Some(4.0),
960 };
961 let result = gage_rr_xbar_r(&input).expect("should compute");
962
963 assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
965 assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
966 assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
967 assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
968
969 let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
971 assert!(
972 (result.grr - expected_grr).abs() < 1e-10,
973 "GRR identity failed: {} vs {}",
974 result.grr,
975 expected_grr
976 );
977
978 let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
980 assert!(
981 (result.tv - expected_tv).abs() < 1e-10,
982 "TV identity failed: {} vs {}",
983 result.tv,
984 expected_tv
985 );
986
987 let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
990 assert!(
991 (pct_check - 10000.0).abs() < 1.0,
992 "percentage identity: {} should be ~10000",
993 pct_check
994 );
995
996 assert!(result.percent_tolerance.is_some());
998
999 assert!(result.ndc >= 1);
1001 }
1002
1003 #[test]
1004 fn xbar_r_ndc_minimum_one() {
1005 let data = vec![
1007 vec![vec![1.0, 5.0], vec![0.0, 6.0]],
1008 vec![vec![1.5, 4.5], vec![0.5, 5.5]],
1009 ];
1010 let input = GageRRInput {
1011 measurements: data,
1012 tolerance: None,
1013 };
1014 let result = gage_rr_xbar_r(&input).expect("should compute");
1015 assert!(result.ndc >= 1, "NDC should be at least 1");
1016 }
1017
1018 #[test]
1019 fn xbar_r_status_classification() {
1020 let data = aiag_reference_data();
1021 let input = GageRRInput {
1022 measurements: data,
1023 tolerance: None,
1024 };
1025 let result = gage_rr_xbar_r(&input).expect("should compute");
1026
1027 match result.status {
1029 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1030 GrrStatus::Marginal => {
1031 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1032 }
1033 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1034 }
1035 }
1036
1037 #[test]
1038 fn xbar_r_rejects_invalid_dimensions() {
1039 let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
1041 let input = GageRRInput {
1042 measurements: data,
1043 tolerance: None,
1044 };
1045 assert!(gage_rr_xbar_r(&input).is_err());
1046
1047 let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
1049 let input = GageRRInput {
1050 measurements: data,
1051 tolerance: None,
1052 };
1053 assert!(gage_rr_xbar_r(&input).is_err());
1054
1055 let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
1057 let input = GageRRInput {
1058 measurements: data,
1059 tolerance: None,
1060 };
1061 assert!(gage_rr_xbar_r(&input).is_err());
1062 }
1063
1064 #[test]
1065 fn xbar_r_rejects_non_finite() {
1066 let data = vec![
1067 vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
1068 vec![vec![1.0, 2.0], vec![3.0, 4.0]],
1069 ];
1070 let input = GageRRInput {
1071 measurements: data,
1072 tolerance: None,
1073 };
1074 assert!(gage_rr_xbar_r(&input).is_err());
1075 }
1076
1077 #[test]
1078 fn xbar_r_two_operators_two_trials() {
1079 let data = vec![
1081 vec![vec![10.0, 10.2], vec![10.1, 10.3]],
1082 vec![vec![20.0, 20.1], vec![19.9, 20.2]],
1083 ];
1084 let input = GageRRInput {
1085 measurements: data,
1086 tolerance: Some(2.0),
1087 };
1088 let result = gage_rr_xbar_r(&input).expect("should compute");
1089 assert!(result.ev > 0.0);
1090 assert!(result.pv > 0.0);
1091 assert!(result.percent_tolerance.is_some());
1092 }
1093
1094 #[test]
1099 fn anova_basic_computation() {
1100 let data = aiag_reference_data();
1101 let input = GageRRInput {
1102 measurements: data,
1103 tolerance: Some(4.0),
1104 };
1105 let result = gage_rr_anova(&input).expect("should compute");
1106
1107 assert!(
1109 result.variance_components.repeatability >= 0.0,
1110 "σ²_repeatability should be non-negative"
1111 );
1112 assert!(
1113 result.variance_components.part >= 0.0,
1114 "σ²_part should be non-negative"
1115 );
1116 assert!(
1117 result.variance_components.operator >= 0.0,
1118 "σ²_operator should be non-negative"
1119 );
1120 assert!(
1121 result.variance_components.interaction >= 0.0,
1122 "σ²_interaction should be non-negative"
1123 );
1124
1125 let expected_repro =
1127 result.variance_components.operator + result.variance_components.interaction;
1128 assert!(
1129 (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
1130 "σ²_reproducibility identity failed"
1131 );
1132
1133 let expected_total = result.variance_components.part
1135 + result.variance_components.repeatability
1136 + result.variance_components.reproducibility;
1137 assert!(
1138 (result.variance_components.total - expected_total).abs() < 1e-10,
1139 "σ²_total identity failed"
1140 );
1141
1142 assert_eq!(result.anova_table.rows.len(), 5);
1144
1145 assert_eq!(result.anova_table.rows[0].source, "Part");
1147 assert_eq!(result.anova_table.rows[1].source, "Operator");
1148 assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1149 assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1150 assert_eq!(result.anova_table.rows[4].source, "Total");
1151
1152 assert!(
1154 (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1155 "EV should be sqrt(σ²_repeatability)"
1156 );
1157 assert!(
1158 (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1159 "PV should be sqrt(σ²_part)"
1160 );
1161 }
1162
1163 #[test]
1164 fn anova_ss_decomposition() {
1165 let data = aiag_reference_data();
1166 let input = GageRRInput {
1167 measurements: data,
1168 tolerance: None,
1169 };
1170 let result = gage_rr_anova(&input).expect("should compute");
1171
1172 let rows = &result.anova_table.rows;
1174 let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1175 let ss_total = rows[4].ss;
1176 assert!(
1177 (ss_sum - ss_total).abs() < 1e-8,
1178 "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1179 rows[0].ss,
1180 rows[1].ss,
1181 rows[2].ss,
1182 rows[3].ss,
1183 ss_sum,
1184 ss_total
1185 );
1186
1187 let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1189 let df_total = rows[4].df;
1190 assert!(
1191 (df_sum - df_total).abs() < 1e-10,
1192 "DF decomposition failed: {} vs {}",
1193 df_sum,
1194 df_total
1195 );
1196 }
1197
1198 #[test]
1199 fn anova_interaction_pooling() {
1200 let data = vec![
1202 vec![
1203 vec![10.0, 10.1, 10.0],
1204 vec![10.0, 10.0, 10.1],
1205 vec![10.1, 10.0, 10.0],
1206 ],
1207 vec![
1208 vec![20.0, 20.1, 20.0],
1209 vec![20.0, 20.0, 20.1],
1210 vec![20.1, 20.0, 20.0],
1211 ],
1212 vec![
1213 vec![15.0, 15.1, 15.0],
1214 vec![15.0, 15.0, 15.1],
1215 vec![15.1, 15.0, 15.0],
1216 ],
1217 ];
1218 let input = GageRRInput {
1219 measurements: data,
1220 tolerance: None,
1221 };
1222 let result = gage_rr_anova(&input).expect("should compute");
1223
1224 if result.interaction_pooled {
1226 assert_eq!(result.variance_components.interaction, 0.0);
1227 }
1228 }
1229
1230 #[test]
1239 fn anova_pooled_repeatability_uses_pooled_ms() {
1240 let data = vec![
1243 vec![
1244 vec![10.0, 10.1, 10.0],
1245 vec![10.0, 10.0, 10.1],
1246 vec![10.1, 10.0, 10.0],
1247 ],
1248 vec![
1249 vec![20.0, 20.1, 20.0],
1250 vec![20.0, 20.0, 20.1],
1251 vec![20.1, 20.0, 20.0],
1252 ],
1253 vec![
1254 vec![15.0, 15.1, 15.0],
1255 vec![15.0, 15.0, 15.1],
1256 vec![15.1, 15.0, 15.0],
1257 ],
1258 ];
1259 let input = GageRRInput {
1260 measurements: data,
1261 tolerance: None,
1262 };
1263 let result = gage_rr_anova(&input).expect("should compute");
1264 assert!(result.interaction_pooled, "this dataset should pool");
1265
1266 let rep_row = result
1267 .anova_table
1268 .rows
1269 .iter()
1270 .find(|r| r.source == "Repeatability")
1271 .expect("Repeatability row present");
1272 let part = result
1273 .anova_table
1274 .rows
1275 .iter()
1276 .find(|r| r.source == "Part")
1277 .expect("Part row");
1278
1279 let pooled_denom = part.ms / part.f_value.expect("Part F present");
1281
1282 assert!(
1284 (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1285 "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1286 result.variance_components.repeatability
1287 );
1288 assert!(
1290 (rep_row.ms - pooled_denom).abs() < 1e-10,
1291 "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1292 rep_row.ms
1293 );
1294 assert!(
1296 !result
1297 .anova_table
1298 .rows
1299 .iter()
1300 .any(|r| r.source == "Part×Operator"),
1301 "pooled table must not carry a separate interaction row"
1302 );
1303 let df_sum: f64 = result
1304 .anova_table
1305 .rows
1306 .iter()
1307 .filter(|r| r.source != "Total")
1308 .map(|r| r.df)
1309 .sum();
1310 let df_total = result
1311 .anova_table
1312 .rows
1313 .iter()
1314 .find(|r| r.source == "Total")
1315 .expect("Total row")
1316 .df;
1317 assert!(
1318 (df_sum - df_total).abs() < 1e-9,
1319 "component df ({df_sum}) must sum to total df ({df_total})"
1320 );
1321 }
1322
1323 #[test]
1328 fn anova_degenerate_denominator_returns_none() {
1329 let mut data = aiag_reference_data();
1338 for part in data.iter_mut() {
1339 for op in part.iter_mut() {
1340 let first = op[0];
1341 for trial in op.iter_mut() {
1342 *trial = first;
1343 }
1344 }
1345 }
1346 let input = GageRRInput {
1347 measurements: data,
1348 tolerance: None,
1349 };
1350 let result = gage_rr_anova(&input).expect("should compute");
1351
1352 for row in &result.anova_table.rows {
1353 if let Some(f) = row.f_value {
1354 assert!(
1355 f.is_finite() && f < 1e6,
1356 "degenerate denominator must not produce a divergent F for {}: {}",
1357 row.source,
1358 f
1359 );
1360 }
1361 }
1362 assert!(
1364 result.ev.is_finite(),
1365 "EV must be finite, got {}",
1366 result.ev
1367 );
1368 assert!(
1369 result.percent_grr.is_finite(),
1370 "%GRR must be finite, got {}",
1371 result.percent_grr
1372 );
1373 }
1374
1375 #[test]
1376 fn anova_rejects_invalid_input() {
1377 let data = vec![vec![vec![1.0, 2.0]]];
1378 let input = GageRRInput {
1379 measurements: data,
1380 tolerance: None,
1381 };
1382 assert!(gage_rr_anova(&input).is_err());
1383 }
1384
1385 #[test]
1386 fn anova_status_matches_percent_grr() {
1387 let data = aiag_reference_data();
1388 let input = GageRRInput {
1389 measurements: data,
1390 tolerance: None,
1391 };
1392 let result = gage_rr_anova(&input).expect("should compute");
1393
1394 match result.status {
1395 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1396 GrrStatus::Marginal => {
1397 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1398 }
1399 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1400 }
1401 }
1402
1403 #[test]
1404 fn anova_p_values_bounded() {
1405 let data = aiag_reference_data();
1406 let input = GageRRInput {
1407 measurements: data,
1408 tolerance: None,
1409 };
1410 let result = gage_rr_anova(&input).expect("should compute");
1411
1412 for row in &result.anova_table.rows {
1413 if let Some(p) = row.p_value {
1414 assert!(
1415 (0.0..=1.0).contains(&p),
1416 "p-value for {} out of range: {}",
1417 row.source,
1418 p
1419 );
1420 }
1421 if let Some(f) = row.f_value {
1422 assert!(
1423 f >= 0.0,
1424 "F-value for {} should be non-negative: {}",
1425 row.source,
1426 f
1427 );
1428 }
1429 }
1430 }
1431
1432 #[test]
1437 fn both_methods_detect_same_dominant_variation() {
1438 let data = aiag_reference_data();
1439 let input_xr = GageRRInput {
1440 measurements: data.clone(),
1441 tolerance: None,
1442 };
1443 let input_anova = GageRRInput {
1444 measurements: data,
1445 tolerance: None,
1446 };
1447
1448 let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1449 let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1450
1451 let xr_pv_dominant = xr.pv > xr.grr;
1453 let anova_pv_dominant = anova.pv > anova.grr;
1454 assert_eq!(
1455 xr_pv_dominant, anova_pv_dominant,
1456 "X̄-R and ANOVA should agree on PV vs GRR dominance"
1457 );
1458 }
1459}
1460
1461#[cfg(test)]
1469mod statsmodels_reference {
1470 use super::*;
1471
1472 fn close(got: f64, want: f64, rel: f64, what: &str) {
1473 assert!(
1474 (got - want).abs() <= rel * want.abs().max(1e-300),
1475 "{what}: got {got:e}, reference {want:e}"
1476 );
1477 }
1478
1479 fn row<'a>(r: &'a GageRRAnovaResult, source: &str) -> &'a AnovaRow {
1480 r.anova_table
1481 .rows
1482 .iter()
1483 .find(|row| row.source == source)
1484 .unwrap_or_else(|| panic!("no {source} row"))
1485 }
1486
1487 const WITH_INTERACTION: [[[f64; 3]; 3]; 5] = [
1489 [
1490 [9.175, 9.464, 9.278],
1491 [10.431, 10.411, 10.349],
1492 [9.97, 10.326, 10.414],
1493 ],
1494 [
1495 [10.372, 10.076, 10.265],
1496 [10.461, 10.492, 10.828],
1497 [10.447, 10.587, 10.752],
1498 ],
1499 [
1500 [9.525, 9.61, 9.65],
1501 [9.866, 9.634, 9.868],
1502 [8.981, 8.458, 8.891],
1503 ],
1504 [
1505 [8.226, 8.089, 8.564],
1506 [9.054, 8.701, 8.93],
1507 [7.401, 7.263, 7.42],
1508 ],
1509 [
1510 [8.85, 8.982, 9.121],
1511 [8.368, 8.527, 8.407],
1512 [8.219, 7.982, 8.092],
1513 ],
1514 ];
1515
1516 const NO_INTERACTION: [[[f64; 3]; 3]; 5] = [
1518 [
1519 [9.912, 9.796, 10.139],
1520 [10.298, 10.073, 10.272],
1521 [9.734, 9.604, 9.55],
1522 ],
1523 [
1524 [12.726, 12.881, 12.678],
1525 [12.936, 13.139, 13.199],
1526 [12.415, 12.568, 12.657],
1527 ],
1528 [
1529 [12.412, 12.303, 12.512],
1530 [12.844, 12.997, 12.568],
1531 [12.08, 12.049, 11.887],
1532 ],
1533 [
1534 [9.002, 9.074, 8.846],
1535 [9.579, 9.477, 9.442],
1536 [8.802, 8.901, 8.49],
1537 ],
1538 [
1539 [9.515, 9.513, 9.086],
1540 [9.817, 9.709, 9.895],
1541 [9.075, 9.151, 9.216],
1542 ],
1543 ];
1544
1545 fn study(data: &[[[f64; 3]; 3]; 5]) -> GageRRAnovaResult {
1546 let measurements = data
1547 .iter()
1548 .map(|part| part.iter().map(|trials| trials.to_vec()).collect())
1549 .collect();
1550 gage_rr_anova(&GageRRInput {
1551 measurements,
1552 tolerance: None,
1553 })
1554 .expect("a valid balanced study")
1555 }
1556
1557 #[test]
1558 fn a_significant_interaction_keeps_its_own_row_and_tests_against_it() {
1559 let r = study(&WITH_INTERACTION);
1560 assert!(r.interaction_significant && !r.interaction_pooled);
1561 for (source, df, ss, f, p) in [
1562 (
1563 "Part",
1564 4.0,
1565 33.57309724444448,
1566 Some(11.06391548380456),
1567 Some(0.0024102170377080408),
1568 ),
1569 (
1570 "Operator",
1571 2.0,
1572 2.786838044444448,
1573 Some(1.8367885671249295),
1574 Some(0.22056881947791407),
1575 ),
1576 (
1577 "Part×Operator",
1578 8.0,
1579 6.068935955555522,
1580 Some(28.125305658219634),
1581 Some(6.579056051488694e-12),
1582 ),
1583 ("Repeatability", 30.0, 0.809182666666665, None, None),
1584 ] {
1585 let row = row(&r, source);
1586 assert_eq!(row.df, df, "{source} df");
1587 close(row.ss, ss, 1e-9, &format!("{source} SS"));
1588 close(row.ms, ss / df, 1e-9, &format!("{source} MS"));
1589 assert_eq!(row.f_value.is_some(), f.is_some(), "{source} F present");
1590 if let (Some(got), Some(want)) = (row.f_value, f) {
1591 close(got, want, 1e-9, &format!("{source} F"));
1592 }
1593 if let (Some(got), Some(want)) = (row.p_value, p) {
1594 close(got, want, 1e-8, &format!("{source} p"));
1595 }
1596 }
1597 let c = &r.variance_components;
1598 close(c.part, 0.848295257407409, 1e-9, "sigma2 part");
1599 close(c.operator, 0.04232013518518559, 1e-9, "sigma2 operator");
1600 close(
1601 c.interaction,
1602 0.2438814129629616,
1603 1e-9,
1604 "sigma2 interaction",
1605 );
1606 close(
1607 c.repeatability,
1608 0.026972755555555502,
1609 1e-9,
1610 "sigma2 repeatability",
1611 );
1612 close(
1613 c.reproducibility,
1614 0.2862015481481472,
1615 1e-9,
1616 "sigma2 reproducibility",
1617 );
1618 close(c.total, 1.1614695611111117, 1e-9, "sigma2 total");
1619 close(r.ev, 0.16423384412341904, 1e-9, "EV");
1620 close(r.av, 0.5349780819324724, 1e-9, "AV");
1621 close(r.grr, 0.5596197849466213, 1e-9, "GRR");
1622 close(r.pv, 0.9210294552333324, 1e-9, "PV");
1623 close(r.tv, 1.0777149721104888, 1e-9, "TV");
1624 close(r.percent_grr, 51.926511130370415, 1e-9, "%GRR");
1625 assert_eq!(r.ndc, 2);
1626 }
1627
1628 #[test]
1629 fn a_negligible_interaction_is_pooled_and_the_tail_p_values_survive() {
1630 let r = study(&NO_INTERACTION);
1631 assert!(!r.interaction_significant && r.interaction_pooled);
1632 assert!(r
1633 .anova_table
1634 .rows
1635 .iter()
1636 .all(|row| row.source != "Part×Operator"));
1637 let part = row(&r, "Part");
1638 close(part.f_value.expect("F"), 1422.410771043164, 1e-9, "Part F");
1639 close(
1641 part.p_value.expect("p"),
1642 8.177666854037245e-41,
1643 1e-8,
1644 "Part p",
1645 );
1646 let operator = row(&r, "Operator");
1647 close(
1648 operator.f_value.expect("F"),
1649 88.62224500212629,
1650 1e-9,
1651 "Operator F",
1652 );
1653 close(
1654 operator.p_value.expect("p"),
1655 4.8998131777748826e-15,
1656 1e-8,
1657 "Operator p",
1658 );
1659 let error = row(&r, "Repeatability");
1660 assert_eq!(error.df, 38.0);
1661 close(
1662 error.ss,
1663 0.11419240000000108 + 0.6162759999999995,
1664 1e-9,
1665 "pooled SS",
1666 );
1667 let c = &r.variance_components;
1668 close(c.part, 3.0359521978557518, 1e-9, "sigma2 part");
1669 close(c.operator, 0.11228996686159866, 1e-9, "sigma2 operator");
1670 assert_eq!(c.interaction, 0.0);
1671 close(
1672 c.repeatability,
1673 0.01922285263157896,
1674 1e-9,
1675 "sigma2 repeatability",
1676 );
1677 close(c.total, 3.1674650173489294, 1e-9, "sigma2 total");
1678 close(r.ev, 0.1386465024137968, 1e-9, "EV");
1679 close(r.av, 0.3350969514358474, 1e-9, "AV");
1680 close(r.grr, 0.3626469626140244, 1e-9, "GRR");
1681 close(r.pv, 1.7423984038834952, 1e-9, "PV");
1682 close(r.tv, 1.7797373450453102, 1e-9, "TV");
1683 close(r.percent_grr, 20.37643159107794, 1e-9, "%GRR");
1684 assert_eq!(r.ndc, 6);
1685 }
1686}