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