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)]
39pub struct GageRRResult {
40 pub ev: f64,
42 pub av: f64,
44 pub grr: f64,
46 pub pv: f64,
48 pub tv: f64,
50
51 pub percent_ev: f64,
53 pub percent_av: f64,
55 pub percent_grr: f64,
57 pub percent_pv: f64,
59 pub percent_tolerance: Option<f64>,
61
62 pub ndc: u32,
64 pub status: GrrStatus,
66}
67
68#[derive(Debug, Clone)]
70pub struct GageRRAnovaResult {
71 pub anova_table: AnovaTable,
73 pub variance_components: VarianceComponents,
75 pub ev: f64,
77 pub av: f64,
79 pub grr: f64,
81 pub pv: f64,
83 pub tv: f64,
85 pub percent_grr: f64,
87 pub percent_tolerance: Option<f64>,
89 pub ndc: u32,
91 pub status: GrrStatus,
93 pub interaction_significant: bool,
95 pub interaction_pooled: bool,
97}
98
99#[derive(Debug, Clone)]
101pub struct AnovaTable {
102 pub rows: Vec<AnovaRow>,
104}
105
106#[derive(Debug, Clone)]
108pub struct AnovaRow {
109 pub source: String,
111 pub df: f64,
113 pub ss: f64,
115 pub ms: f64,
117 pub f_value: Option<f64>,
119 pub p_value: Option<f64>,
121}
122
123#[derive(Debug, Clone)]
125pub struct VarianceComponents {
126 pub part: f64,
128 pub operator: f64,
130 pub interaction: f64,
132 pub repeatability: f64,
134 pub reproducibility: f64,
136 pub total: f64,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum GrrStatus {
143 Acceptable,
145 Marginal,
147 Unacceptable,
149}
150
151#[allow(clippy::approx_constant)]
160const K1: [f64; 2] = [0.8862, 0.5908];
161
162#[allow(clippy::approx_constant)]
167const K2: [f64; 2] = [0.7071, 0.5231];
168
169#[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
178fn 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
193fn 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
202fn 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
241pub 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 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 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 let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
294
295 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 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 let grr = (ev.powi(2) + av.powi(2)).sqrt();
331
332 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 let tv = (grr.powi(2) + pv.powi(2)).sqrt();
354
355 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
394pub 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 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 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 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 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 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 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 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 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 let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
505
506 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 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 let ms_total = ss_total / df_total;
537 let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-12);
538 let is_valid_denom = |ms: f64| ms > degenerate_floor;
539
540 let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
542 let f_val = ms_interaction / ms_error;
543 let p_val = 1.0 - special::f_distribution_cdf(f_val, df_interaction, df_error);
544 (Some(f_val), Some(p_val))
545 } else {
546 (None, None)
547 };
548
549 let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
551 let interaction_pooled = !interaction_significant;
552
553 let pooled_ss = ss_error + ss_interaction;
559 let pooled_df = df_error + df_interaction;
560 let ms_pooled = if pooled_df > 0.0 {
561 pooled_ss / pooled_df
562 } else {
563 ms_error
564 };
565
566 let (denom_ms, denom_df) = if interaction_pooled {
568 (ms_pooled, pooled_df)
569 } else {
570 (ms_interaction, df_interaction)
571 };
572
573 let (f_part, p_part) = if is_valid_denom(denom_ms) {
574 let f_val = ms_part / denom_ms;
575 let p_val = 1.0 - special::f_distribution_cdf(f_val, df_part, denom_df);
576 (Some(f_val), Some(p_val))
577 } else {
578 (None, None)
579 };
580
581 let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
582 let f_val = ms_operator / denom_ms;
583 let p_val = 1.0 - special::f_distribution_cdf(f_val, df_operator, denom_df);
584 (Some(f_val), Some(p_val))
585 } else {
586 (None, None)
587 };
588
589 let mut rows = vec![
598 AnovaRow {
599 source: "Part".to_owned(),
600 df: df_part,
601 ss: ss_part,
602 ms: ms_part,
603 f_value: f_part,
604 p_value: p_part,
605 },
606 AnovaRow {
607 source: "Operator".to_owned(),
608 df: df_operator,
609 ss: ss_operator,
610 ms: ms_operator,
611 f_value: f_operator,
612 p_value: p_operator,
613 },
614 ];
615 if !interaction_pooled {
616 rows.push(AnovaRow {
617 source: "Part×Operator".to_owned(),
618 df: df_interaction,
619 ss: ss_interaction,
620 ms: ms_interaction,
621 f_value: f_interaction,
622 p_value: p_interaction,
623 });
624 }
625 rows.push(AnovaRow {
626 source: "Repeatability".to_owned(),
627 df: if interaction_pooled {
628 pooled_df
629 } else {
630 df_error
631 },
632 ss: if interaction_pooled {
633 pooled_ss
634 } else {
635 ss_error
636 },
637 ms: if interaction_pooled {
638 ms_pooled
639 } else {
640 ms_error
641 },
642 f_value: None,
643 p_value: None,
644 });
645 rows.push(AnovaRow {
646 source: "Total".to_owned(),
647 df: df_total,
648 ss: ss_total,
649 ms: ss_total / df_total,
650 f_value: None,
651 p_value: None,
652 });
653 let anova_table = AnovaTable { rows };
654
655 let sigma2_repeatability = (if interaction_pooled {
662 ms_pooled
663 } else {
664 ms_error
665 })
666 .max(0.0);
667
668 let sigma2_interaction = if interaction_pooled {
669 0.0
670 } else {
671 let val = (ms_interaction - ms_error) / r as f64;
672 val.max(0.0)
673 };
674
675 let sigma2_operator = if interaction_pooled {
676 let val = (ms_operator - ms_pooled) / (p * r) as f64;
677 val.max(0.0)
678 } else {
679 let val = (ms_operator - ms_interaction) / (p * r) as f64;
680 val.max(0.0)
681 };
682
683 let sigma2_part = if interaction_pooled {
684 let val = (ms_part - ms_pooled) / (o * r) as f64;
685 val.max(0.0)
686 } else {
687 let val = (ms_part - ms_interaction) / (o * r) as f64;
688 val.max(0.0)
689 };
690
691 let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
692 let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
693 let sigma2_total = sigma2_part + sigma2_grr;
694
695 let variance_components = VarianceComponents {
696 part: sigma2_part,
697 operator: sigma2_operator,
698 interaction: sigma2_interaction,
699 repeatability: sigma2_repeatability,
700 reproducibility: sigma2_reproducibility,
701 total: sigma2_total,
702 };
703
704 let ev = sigma2_repeatability.sqrt();
706 let av = sigma2_reproducibility.sqrt();
707 let grr = sigma2_grr.sqrt();
708 let pv = sigma2_part.sqrt();
709 let tv = sigma2_total.sqrt();
710
711 let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
712
713 let percent_tolerance = input.tolerance.and_then(|tol| {
714 if tol > 1e-300 {
715 Some(grr / tol * 600.0)
716 } else {
717 None
718 }
719 });
720
721 let ndc = compute_ndc(pv, grr);
722 let status = grr_status(percent_grr);
723
724 Ok(GageRRAnovaResult {
725 anova_table,
726 variance_components,
727 ev,
728 av,
729 grr,
730 pv,
731 tv,
732 percent_grr,
733 percent_tolerance,
734 ndc,
735 status,
736 interaction_significant,
737 interaction_pooled,
738 })
739}
740
741#[cfg(test)]
746mod tests {
747 use super::*;
748
749 fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
753 vec![
755 vec![
757 vec![0.29, 0.41, 0.64], vec![0.08, 0.25, 0.07], vec![0.04, -0.11, 0.75], ],
761 vec![
763 vec![-0.56, -0.68, -0.58],
764 vec![-0.47, -1.22, -0.68],
765 vec![-0.49, -0.56, -0.49],
766 ],
767 vec![
769 vec![1.34, 1.17, 1.27],
770 vec![1.19, 0.94, 1.34],
771 vec![1.02, 0.82, 0.90],
772 ],
773 vec![
775 vec![0.47, 0.50, 0.64],
776 vec![0.01, 0.14, 0.43],
777 vec![0.12, 0.22, 0.31],
778 ],
779 vec![
781 vec![-0.80, -0.92, -0.84],
782 vec![-0.56, -1.20, -1.28],
783 vec![-0.44, -0.21, -0.17],
784 ],
785 vec![
787 vec![0.02, 0.16, -0.10],
788 vec![0.01, -0.10, 0.07],
789 vec![-0.14, -0.46, 0.18],
790 ],
791 vec![
793 vec![0.59, 0.75, 0.66],
794 vec![0.55, 0.36, 0.51],
795 vec![0.47, 0.63, 0.34],
796 ],
797 vec![
799 vec![-0.31, -0.20, 0.17],
800 vec![0.02, -0.09, 0.12],
801 vec![-0.24, 0.04, -0.19],
802 ],
803 vec![
805 vec![2.26, 1.99, 2.01],
806 vec![1.80, 2.12, 2.19],
807 vec![1.80, 1.71, 2.29],
808 ],
809 vec![
811 vec![-1.36, -1.14, -1.30],
812 vec![-1.34, -1.11, -1.42],
813 vec![-1.13, -1.13, -0.96],
814 ],
815 ]
816 }
817
818 #[test]
823 fn xbar_r_basic_computation() {
824 let data = aiag_reference_data();
825 let input = GageRRInput {
826 measurements: data,
827 tolerance: Some(4.0),
828 };
829 let result = gage_rr_xbar_r(&input).expect("should compute");
830
831 assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
833 assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
834 assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
835 assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
836
837 let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
839 assert!(
840 (result.grr - expected_grr).abs() < 1e-10,
841 "GRR identity failed: {} vs {}",
842 result.grr,
843 expected_grr
844 );
845
846 let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
848 assert!(
849 (result.tv - expected_tv).abs() < 1e-10,
850 "TV identity failed: {} vs {}",
851 result.tv,
852 expected_tv
853 );
854
855 let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
858 assert!(
859 (pct_check - 10000.0).abs() < 1.0,
860 "percentage identity: {} should be ~10000",
861 pct_check
862 );
863
864 assert!(result.percent_tolerance.is_some());
866
867 assert!(result.ndc >= 1);
869 }
870
871 #[test]
872 fn xbar_r_ndc_minimum_one() {
873 let data = vec![
875 vec![vec![1.0, 5.0], vec![0.0, 6.0]],
876 vec![vec![1.5, 4.5], vec![0.5, 5.5]],
877 ];
878 let input = GageRRInput {
879 measurements: data,
880 tolerance: None,
881 };
882 let result = gage_rr_xbar_r(&input).expect("should compute");
883 assert!(result.ndc >= 1, "NDC should be at least 1");
884 }
885
886 #[test]
887 fn xbar_r_status_classification() {
888 let data = aiag_reference_data();
889 let input = GageRRInput {
890 measurements: data,
891 tolerance: None,
892 };
893 let result = gage_rr_xbar_r(&input).expect("should compute");
894
895 match result.status {
897 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
898 GrrStatus::Marginal => {
899 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
900 }
901 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
902 }
903 }
904
905 #[test]
906 fn xbar_r_rejects_invalid_dimensions() {
907 let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
909 let input = GageRRInput {
910 measurements: data,
911 tolerance: None,
912 };
913 assert!(gage_rr_xbar_r(&input).is_err());
914
915 let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
917 let input = GageRRInput {
918 measurements: data,
919 tolerance: None,
920 };
921 assert!(gage_rr_xbar_r(&input).is_err());
922
923 let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
925 let input = GageRRInput {
926 measurements: data,
927 tolerance: None,
928 };
929 assert!(gage_rr_xbar_r(&input).is_err());
930 }
931
932 #[test]
933 fn xbar_r_rejects_non_finite() {
934 let data = vec![
935 vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
936 vec![vec![1.0, 2.0], vec![3.0, 4.0]],
937 ];
938 let input = GageRRInput {
939 measurements: data,
940 tolerance: None,
941 };
942 assert!(gage_rr_xbar_r(&input).is_err());
943 }
944
945 #[test]
946 fn xbar_r_two_operators_two_trials() {
947 let data = vec![
949 vec![vec![10.0, 10.2], vec![10.1, 10.3]],
950 vec![vec![20.0, 20.1], vec![19.9, 20.2]],
951 ];
952 let input = GageRRInput {
953 measurements: data,
954 tolerance: Some(2.0),
955 };
956 let result = gage_rr_xbar_r(&input).expect("should compute");
957 assert!(result.ev > 0.0);
958 assert!(result.pv > 0.0);
959 assert!(result.percent_tolerance.is_some());
960 }
961
962 #[test]
967 fn anova_basic_computation() {
968 let data = aiag_reference_data();
969 let input = GageRRInput {
970 measurements: data,
971 tolerance: Some(4.0),
972 };
973 let result = gage_rr_anova(&input).expect("should compute");
974
975 assert!(
977 result.variance_components.repeatability >= 0.0,
978 "σ²_repeatability should be non-negative"
979 );
980 assert!(
981 result.variance_components.part >= 0.0,
982 "σ²_part should be non-negative"
983 );
984 assert!(
985 result.variance_components.operator >= 0.0,
986 "σ²_operator should be non-negative"
987 );
988 assert!(
989 result.variance_components.interaction >= 0.0,
990 "σ²_interaction should be non-negative"
991 );
992
993 let expected_repro =
995 result.variance_components.operator + result.variance_components.interaction;
996 assert!(
997 (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
998 "σ²_reproducibility identity failed"
999 );
1000
1001 let expected_total = result.variance_components.part
1003 + result.variance_components.repeatability
1004 + result.variance_components.reproducibility;
1005 assert!(
1006 (result.variance_components.total - expected_total).abs() < 1e-10,
1007 "σ²_total identity failed"
1008 );
1009
1010 assert_eq!(result.anova_table.rows.len(), 5);
1012
1013 assert_eq!(result.anova_table.rows[0].source, "Part");
1015 assert_eq!(result.anova_table.rows[1].source, "Operator");
1016 assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1017 assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1018 assert_eq!(result.anova_table.rows[4].source, "Total");
1019
1020 assert!(
1022 (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1023 "EV should be sqrt(σ²_repeatability)"
1024 );
1025 assert!(
1026 (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1027 "PV should be sqrt(σ²_part)"
1028 );
1029 }
1030
1031 #[test]
1032 fn anova_ss_decomposition() {
1033 let data = aiag_reference_data();
1034 let input = GageRRInput {
1035 measurements: data,
1036 tolerance: None,
1037 };
1038 let result = gage_rr_anova(&input).expect("should compute");
1039
1040 let rows = &result.anova_table.rows;
1042 let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1043 let ss_total = rows[4].ss;
1044 assert!(
1045 (ss_sum - ss_total).abs() < 1e-8,
1046 "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1047 rows[0].ss,
1048 rows[1].ss,
1049 rows[2].ss,
1050 rows[3].ss,
1051 ss_sum,
1052 ss_total
1053 );
1054
1055 let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1057 let df_total = rows[4].df;
1058 assert!(
1059 (df_sum - df_total).abs() < 1e-10,
1060 "DF decomposition failed: {} vs {}",
1061 df_sum,
1062 df_total
1063 );
1064 }
1065
1066 #[test]
1067 fn anova_interaction_pooling() {
1068 let data = vec![
1070 vec![
1071 vec![10.0, 10.1, 10.0],
1072 vec![10.0, 10.0, 10.1],
1073 vec![10.1, 10.0, 10.0],
1074 ],
1075 vec![
1076 vec![20.0, 20.1, 20.0],
1077 vec![20.0, 20.0, 20.1],
1078 vec![20.1, 20.0, 20.0],
1079 ],
1080 vec![
1081 vec![15.0, 15.1, 15.0],
1082 vec![15.0, 15.0, 15.1],
1083 vec![15.1, 15.0, 15.0],
1084 ],
1085 ];
1086 let input = GageRRInput {
1087 measurements: data,
1088 tolerance: None,
1089 };
1090 let result = gage_rr_anova(&input).expect("should compute");
1091
1092 if result.interaction_pooled {
1094 assert_eq!(result.variance_components.interaction, 0.0);
1095 }
1096 }
1097
1098 #[test]
1107 fn anova_pooled_repeatability_uses_pooled_ms() {
1108 let data = vec![
1111 vec![
1112 vec![10.0, 10.1, 10.0],
1113 vec![10.0, 10.0, 10.1],
1114 vec![10.1, 10.0, 10.0],
1115 ],
1116 vec![
1117 vec![20.0, 20.1, 20.0],
1118 vec![20.0, 20.0, 20.1],
1119 vec![20.1, 20.0, 20.0],
1120 ],
1121 vec![
1122 vec![15.0, 15.1, 15.0],
1123 vec![15.0, 15.0, 15.1],
1124 vec![15.1, 15.0, 15.0],
1125 ],
1126 ];
1127 let input = GageRRInput {
1128 measurements: data,
1129 tolerance: None,
1130 };
1131 let result = gage_rr_anova(&input).expect("should compute");
1132 assert!(result.interaction_pooled, "this dataset should pool");
1133
1134 let rep_row = result
1135 .anova_table
1136 .rows
1137 .iter()
1138 .find(|r| r.source == "Repeatability")
1139 .expect("Repeatability row present");
1140 let part = result
1141 .anova_table
1142 .rows
1143 .iter()
1144 .find(|r| r.source == "Part")
1145 .expect("Part row");
1146
1147 let pooled_denom = part.ms / part.f_value.expect("Part F present");
1149
1150 assert!(
1152 (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1153 "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1154 result.variance_components.repeatability
1155 );
1156 assert!(
1158 (rep_row.ms - pooled_denom).abs() < 1e-10,
1159 "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1160 rep_row.ms
1161 );
1162 assert!(
1164 !result
1165 .anova_table
1166 .rows
1167 .iter()
1168 .any(|r| r.source == "Part×Operator"),
1169 "pooled table must not carry a separate interaction row"
1170 );
1171 let df_sum: f64 = result
1172 .anova_table
1173 .rows
1174 .iter()
1175 .filter(|r| r.source != "Total")
1176 .map(|r| r.df)
1177 .sum();
1178 let df_total = result
1179 .anova_table
1180 .rows
1181 .iter()
1182 .find(|r| r.source == "Total")
1183 .expect("Total row")
1184 .df;
1185 assert!(
1186 (df_sum - df_total).abs() < 1e-9,
1187 "component df ({df_sum}) must sum to total df ({df_total})"
1188 );
1189 }
1190
1191 #[test]
1196 fn anova_degenerate_denominator_returns_none() {
1197 let mut data = aiag_reference_data();
1206 for part in data.iter_mut() {
1207 for op in part.iter_mut() {
1208 let first = op[0];
1209 for trial in op.iter_mut() {
1210 *trial = first;
1211 }
1212 }
1213 }
1214 let input = GageRRInput {
1215 measurements: data,
1216 tolerance: None,
1217 };
1218 let result = gage_rr_anova(&input).expect("should compute");
1219
1220 for row in &result.anova_table.rows {
1221 if let Some(f) = row.f_value {
1222 assert!(
1223 f.is_finite() && f < 1e6,
1224 "degenerate denominator must not produce a divergent F for {}: {}",
1225 row.source,
1226 f
1227 );
1228 }
1229 }
1230 assert!(
1232 result.ev.is_finite(),
1233 "EV must be finite, got {}",
1234 result.ev
1235 );
1236 assert!(
1237 result.percent_grr.is_finite(),
1238 "%GRR must be finite, got {}",
1239 result.percent_grr
1240 );
1241 }
1242
1243 #[test]
1244 fn anova_rejects_invalid_input() {
1245 let data = vec![vec![vec![1.0, 2.0]]];
1246 let input = GageRRInput {
1247 measurements: data,
1248 tolerance: None,
1249 };
1250 assert!(gage_rr_anova(&input).is_err());
1251 }
1252
1253 #[test]
1254 fn anova_status_matches_percent_grr() {
1255 let data = aiag_reference_data();
1256 let input = GageRRInput {
1257 measurements: data,
1258 tolerance: None,
1259 };
1260 let result = gage_rr_anova(&input).expect("should compute");
1261
1262 match result.status {
1263 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1264 GrrStatus::Marginal => {
1265 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1266 }
1267 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1268 }
1269 }
1270
1271 #[test]
1272 fn anova_p_values_bounded() {
1273 let data = aiag_reference_data();
1274 let input = GageRRInput {
1275 measurements: data,
1276 tolerance: None,
1277 };
1278 let result = gage_rr_anova(&input).expect("should compute");
1279
1280 for row in &result.anova_table.rows {
1281 if let Some(p) = row.p_value {
1282 assert!(
1283 (0.0..=1.0).contains(&p),
1284 "p-value for {} out of range: {}",
1285 row.source,
1286 p
1287 );
1288 }
1289 if let Some(f) = row.f_value {
1290 assert!(
1291 f >= 0.0,
1292 "F-value for {} should be non-negative: {}",
1293 row.source,
1294 f
1295 );
1296 }
1297 }
1298 }
1299
1300 #[test]
1305 fn both_methods_detect_same_dominant_variation() {
1306 let data = aiag_reference_data();
1307 let input_xr = GageRRInput {
1308 measurements: data.clone(),
1309 tolerance: None,
1310 };
1311 let input_anova = GageRRInput {
1312 measurements: data,
1313 tolerance: None,
1314 };
1315
1316 let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1317 let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1318
1319 let xr_pv_dominant = xr.pv > xr.grr;
1321 let anova_pv_dominant = anova.pv > anova.grr;
1322 assert_eq!(
1323 xr_pv_dominant, anova_pv_dominant,
1324 "X̄-R and ANOVA should agree on PV vs GRR dominance"
1325 );
1326 }
1327}