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
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 let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
300
301 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 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 let grr = (ev.powi(2) + av.powi(2)).sqrt();
340
341 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 let tv = (grr.powi(2) + pv.powi(2)).sqrt();
369
370 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
409pub 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 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 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 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 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 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 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 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 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 let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
520
521 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 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 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 let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
553 let interaction_pooled = !interaction_significant;
554
555 let (denom_ms, denom_df) = if interaction_pooled {
557 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 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 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 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#[cfg(test)]
714mod tests {
715 use super::*;
716
717 fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
721 vec![
723 vec![
725 vec![0.29, 0.41, 0.64], vec![0.08, 0.25, 0.07], vec![0.04, -0.11, 0.75], ],
729 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 assert!(result.percent_tolerance.is_some());
834
835 assert!(result.ndc >= 1);
837 }
838
839 #[test]
840 fn xbar_r_ndc_minimum_one() {
841 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 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 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 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 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 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 #[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 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 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 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 assert_eq!(result.anova_table.rows.len(), 5);
979
980 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 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 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 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 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 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 #[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 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}