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 { pooled_df } else { df_error },
628 ss: if interaction_pooled { pooled_ss } else { ss_error },
629 ms: if interaction_pooled { ms_pooled } else { ms_error },
630 f_value: None,
631 p_value: None,
632 });
633 rows.push(AnovaRow {
634 source: "Total".to_owned(),
635 df: df_total,
636 ss: ss_total,
637 ms: ss_total / df_total,
638 f_value: None,
639 p_value: None,
640 });
641 let anova_table = AnovaTable { rows };
642
643 let sigma2_repeatability = (if interaction_pooled { ms_pooled } else { ms_error }).max(0.0);
650
651 let sigma2_interaction = if interaction_pooled {
652 0.0
653 } else {
654 let val = (ms_interaction - ms_error) / r as f64;
655 val.max(0.0)
656 };
657
658 let sigma2_operator = if interaction_pooled {
659 let val = (ms_operator - ms_pooled) / (p * r) as f64;
660 val.max(0.0)
661 } else {
662 let val = (ms_operator - ms_interaction) / (p * r) as f64;
663 val.max(0.0)
664 };
665
666 let sigma2_part = if interaction_pooled {
667 let val = (ms_part - ms_pooled) / (o * r) as f64;
668 val.max(0.0)
669 } else {
670 let val = (ms_part - ms_interaction) / (o * r) as f64;
671 val.max(0.0)
672 };
673
674 let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
675 let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
676 let sigma2_total = sigma2_part + sigma2_grr;
677
678 let variance_components = VarianceComponents {
679 part: sigma2_part,
680 operator: sigma2_operator,
681 interaction: sigma2_interaction,
682 repeatability: sigma2_repeatability,
683 reproducibility: sigma2_reproducibility,
684 total: sigma2_total,
685 };
686
687 let ev = sigma2_repeatability.sqrt();
689 let av = sigma2_reproducibility.sqrt();
690 let grr = sigma2_grr.sqrt();
691 let pv = sigma2_part.sqrt();
692 let tv = sigma2_total.sqrt();
693
694 let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
695
696 let percent_tolerance = input.tolerance.and_then(|tol| {
697 if tol > 1e-300 {
698 Some(grr / tol * 600.0)
699 } else {
700 None
701 }
702 });
703
704 let ndc = compute_ndc(pv, grr);
705 let status = grr_status(percent_grr);
706
707 Ok(GageRRAnovaResult {
708 anova_table,
709 variance_components,
710 ev,
711 av,
712 grr,
713 pv,
714 tv,
715 percent_grr,
716 percent_tolerance,
717 ndc,
718 status,
719 interaction_significant,
720 interaction_pooled,
721 })
722}
723
724#[cfg(test)]
729mod tests {
730 use super::*;
731
732 fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
736 vec![
738 vec![
740 vec![0.29, 0.41, 0.64], vec![0.08, 0.25, 0.07], vec![0.04, -0.11, 0.75], ],
744 vec![
746 vec![-0.56, -0.68, -0.58],
747 vec![-0.47, -1.22, -0.68],
748 vec![-0.49, -0.56, -0.49],
749 ],
750 vec![
752 vec![1.34, 1.17, 1.27],
753 vec![1.19, 0.94, 1.34],
754 vec![1.02, 0.82, 0.90],
755 ],
756 vec![
758 vec![0.47, 0.50, 0.64],
759 vec![0.01, 0.14, 0.43],
760 vec![0.12, 0.22, 0.31],
761 ],
762 vec![
764 vec![-0.80, -0.92, -0.84],
765 vec![-0.56, -1.20, -1.28],
766 vec![-0.44, -0.21, -0.17],
767 ],
768 vec![
770 vec![0.02, 0.16, -0.10],
771 vec![0.01, -0.10, 0.07],
772 vec![-0.14, -0.46, 0.18],
773 ],
774 vec![
776 vec![0.59, 0.75, 0.66],
777 vec![0.55, 0.36, 0.51],
778 vec![0.47, 0.63, 0.34],
779 ],
780 vec![
782 vec![-0.31, -0.20, 0.17],
783 vec![0.02, -0.09, 0.12],
784 vec![-0.24, 0.04, -0.19],
785 ],
786 vec![
788 vec![2.26, 1.99, 2.01],
789 vec![1.80, 2.12, 2.19],
790 vec![1.80, 1.71, 2.29],
791 ],
792 vec![
794 vec![-1.36, -1.14, -1.30],
795 vec![-1.34, -1.11, -1.42],
796 vec![-1.13, -1.13, -0.96],
797 ],
798 ]
799 }
800
801 #[test]
806 fn xbar_r_basic_computation() {
807 let data = aiag_reference_data();
808 let input = GageRRInput {
809 measurements: data,
810 tolerance: Some(4.0),
811 };
812 let result = gage_rr_xbar_r(&input).expect("should compute");
813
814 assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
816 assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
817 assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
818 assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
819
820 let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
822 assert!(
823 (result.grr - expected_grr).abs() < 1e-10,
824 "GRR identity failed: {} vs {}",
825 result.grr,
826 expected_grr
827 );
828
829 let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
831 assert!(
832 (result.tv - expected_tv).abs() < 1e-10,
833 "TV identity failed: {} vs {}",
834 result.tv,
835 expected_tv
836 );
837
838 let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
841 assert!(
842 (pct_check - 10000.0).abs() < 1.0,
843 "percentage identity: {} should be ~10000",
844 pct_check
845 );
846
847 assert!(result.percent_tolerance.is_some());
849
850 assert!(result.ndc >= 1);
852 }
853
854 #[test]
855 fn xbar_r_ndc_minimum_one() {
856 let data = vec![
858 vec![vec![1.0, 5.0], vec![0.0, 6.0]],
859 vec![vec![1.5, 4.5], vec![0.5, 5.5]],
860 ];
861 let input = GageRRInput {
862 measurements: data,
863 tolerance: None,
864 };
865 let result = gage_rr_xbar_r(&input).expect("should compute");
866 assert!(result.ndc >= 1, "NDC should be at least 1");
867 }
868
869 #[test]
870 fn xbar_r_status_classification() {
871 let data = aiag_reference_data();
872 let input = GageRRInput {
873 measurements: data,
874 tolerance: None,
875 };
876 let result = gage_rr_xbar_r(&input).expect("should compute");
877
878 match result.status {
880 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
881 GrrStatus::Marginal => {
882 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
883 }
884 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
885 }
886 }
887
888 #[test]
889 fn xbar_r_rejects_invalid_dimensions() {
890 let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
892 let input = GageRRInput {
893 measurements: data,
894 tolerance: None,
895 };
896 assert!(gage_rr_xbar_r(&input).is_err());
897
898 let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
900 let input = GageRRInput {
901 measurements: data,
902 tolerance: None,
903 };
904 assert!(gage_rr_xbar_r(&input).is_err());
905
906 let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
908 let input = GageRRInput {
909 measurements: data,
910 tolerance: None,
911 };
912 assert!(gage_rr_xbar_r(&input).is_err());
913 }
914
915 #[test]
916 fn xbar_r_rejects_non_finite() {
917 let data = vec![
918 vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
919 vec![vec![1.0, 2.0], vec![3.0, 4.0]],
920 ];
921 let input = GageRRInput {
922 measurements: data,
923 tolerance: None,
924 };
925 assert!(gage_rr_xbar_r(&input).is_err());
926 }
927
928 #[test]
929 fn xbar_r_two_operators_two_trials() {
930 let data = vec![
932 vec![vec![10.0, 10.2], vec![10.1, 10.3]],
933 vec![vec![20.0, 20.1], vec![19.9, 20.2]],
934 ];
935 let input = GageRRInput {
936 measurements: data,
937 tolerance: Some(2.0),
938 };
939 let result = gage_rr_xbar_r(&input).expect("should compute");
940 assert!(result.ev > 0.0);
941 assert!(result.pv > 0.0);
942 assert!(result.percent_tolerance.is_some());
943 }
944
945 #[test]
950 fn anova_basic_computation() {
951 let data = aiag_reference_data();
952 let input = GageRRInput {
953 measurements: data,
954 tolerance: Some(4.0),
955 };
956 let result = gage_rr_anova(&input).expect("should compute");
957
958 assert!(
960 result.variance_components.repeatability >= 0.0,
961 "σ²_repeatability should be non-negative"
962 );
963 assert!(
964 result.variance_components.part >= 0.0,
965 "σ²_part should be non-negative"
966 );
967 assert!(
968 result.variance_components.operator >= 0.0,
969 "σ²_operator should be non-negative"
970 );
971 assert!(
972 result.variance_components.interaction >= 0.0,
973 "σ²_interaction should be non-negative"
974 );
975
976 let expected_repro =
978 result.variance_components.operator + result.variance_components.interaction;
979 assert!(
980 (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
981 "σ²_reproducibility identity failed"
982 );
983
984 let expected_total = result.variance_components.part
986 + result.variance_components.repeatability
987 + result.variance_components.reproducibility;
988 assert!(
989 (result.variance_components.total - expected_total).abs() < 1e-10,
990 "σ²_total identity failed"
991 );
992
993 assert_eq!(result.anova_table.rows.len(), 5);
995
996 assert_eq!(result.anova_table.rows[0].source, "Part");
998 assert_eq!(result.anova_table.rows[1].source, "Operator");
999 assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1000 assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1001 assert_eq!(result.anova_table.rows[4].source, "Total");
1002
1003 assert!(
1005 (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1006 "EV should be sqrt(σ²_repeatability)"
1007 );
1008 assert!(
1009 (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1010 "PV should be sqrt(σ²_part)"
1011 );
1012 }
1013
1014 #[test]
1015 fn anova_ss_decomposition() {
1016 let data = aiag_reference_data();
1017 let input = GageRRInput {
1018 measurements: data,
1019 tolerance: None,
1020 };
1021 let result = gage_rr_anova(&input).expect("should compute");
1022
1023 let rows = &result.anova_table.rows;
1025 let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1026 let ss_total = rows[4].ss;
1027 assert!(
1028 (ss_sum - ss_total).abs() < 1e-8,
1029 "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1030 rows[0].ss,
1031 rows[1].ss,
1032 rows[2].ss,
1033 rows[3].ss,
1034 ss_sum,
1035 ss_total
1036 );
1037
1038 let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1040 let df_total = rows[4].df;
1041 assert!(
1042 (df_sum - df_total).abs() < 1e-10,
1043 "DF decomposition failed: {} vs {}",
1044 df_sum,
1045 df_total
1046 );
1047 }
1048
1049 #[test]
1050 fn anova_interaction_pooling() {
1051 let data = vec![
1053 vec![
1054 vec![10.0, 10.1, 10.0],
1055 vec![10.0, 10.0, 10.1],
1056 vec![10.1, 10.0, 10.0],
1057 ],
1058 vec![
1059 vec![20.0, 20.1, 20.0],
1060 vec![20.0, 20.0, 20.1],
1061 vec![20.1, 20.0, 20.0],
1062 ],
1063 vec![
1064 vec![15.0, 15.1, 15.0],
1065 vec![15.0, 15.0, 15.1],
1066 vec![15.1, 15.0, 15.0],
1067 ],
1068 ];
1069 let input = GageRRInput {
1070 measurements: data,
1071 tolerance: None,
1072 };
1073 let result = gage_rr_anova(&input).expect("should compute");
1074
1075 if result.interaction_pooled {
1077 assert_eq!(result.variance_components.interaction, 0.0);
1078 }
1079 }
1080
1081 #[test]
1090 fn anova_pooled_repeatability_uses_pooled_ms() {
1091 let data = vec![
1094 vec![
1095 vec![10.0, 10.1, 10.0],
1096 vec![10.0, 10.0, 10.1],
1097 vec![10.1, 10.0, 10.0],
1098 ],
1099 vec![
1100 vec![20.0, 20.1, 20.0],
1101 vec![20.0, 20.0, 20.1],
1102 vec![20.1, 20.0, 20.0],
1103 ],
1104 vec![
1105 vec![15.0, 15.1, 15.0],
1106 vec![15.0, 15.0, 15.1],
1107 vec![15.1, 15.0, 15.0],
1108 ],
1109 ];
1110 let input = GageRRInput {
1111 measurements: data,
1112 tolerance: None,
1113 };
1114 let result = gage_rr_anova(&input).expect("should compute");
1115 assert!(result.interaction_pooled, "this dataset should pool");
1116
1117 let rep_row = result
1118 .anova_table
1119 .rows
1120 .iter()
1121 .find(|r| r.source == "Repeatability")
1122 .expect("Repeatability row present");
1123 let part = result
1124 .anova_table
1125 .rows
1126 .iter()
1127 .find(|r| r.source == "Part")
1128 .expect("Part row");
1129
1130 let pooled_denom = part.ms / part.f_value.expect("Part F present");
1132
1133 assert!(
1135 (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1136 "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1137 result.variance_components.repeatability
1138 );
1139 assert!(
1141 (rep_row.ms - pooled_denom).abs() < 1e-10,
1142 "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1143 rep_row.ms
1144 );
1145 assert!(
1147 !result
1148 .anova_table
1149 .rows
1150 .iter()
1151 .any(|r| r.source == "Part×Operator"),
1152 "pooled table must not carry a separate interaction row"
1153 );
1154 let df_sum: f64 = result
1155 .anova_table
1156 .rows
1157 .iter()
1158 .filter(|r| r.source != "Total")
1159 .map(|r| r.df)
1160 .sum();
1161 let df_total = result
1162 .anova_table
1163 .rows
1164 .iter()
1165 .find(|r| r.source == "Total")
1166 .expect("Total row")
1167 .df;
1168 assert!(
1169 (df_sum - df_total).abs() < 1e-9,
1170 "component df ({df_sum}) must sum to total df ({df_total})"
1171 );
1172 }
1173
1174 #[test]
1179 fn anova_degenerate_denominator_returns_none() {
1180 let mut data = aiag_reference_data();
1189 for part in data.iter_mut() {
1190 for op in part.iter_mut() {
1191 let first = op[0];
1192 for trial in op.iter_mut() {
1193 *trial = first;
1194 }
1195 }
1196 }
1197 let input = GageRRInput {
1198 measurements: data,
1199 tolerance: None,
1200 };
1201 let result = gage_rr_anova(&input).expect("should compute");
1202
1203 for row in &result.anova_table.rows {
1204 if let Some(f) = row.f_value {
1205 assert!(
1206 f.is_finite() && f < 1e6,
1207 "degenerate denominator must not produce a divergent F for {}: {}",
1208 row.source,
1209 f
1210 );
1211 }
1212 }
1213 assert!(result.ev.is_finite(), "EV must be finite, got {}", result.ev);
1215 assert!(
1216 result.percent_grr.is_finite(),
1217 "%GRR must be finite, got {}",
1218 result.percent_grr
1219 );
1220 }
1221
1222 #[test]
1223 fn anova_rejects_invalid_input() {
1224 let data = vec![vec![vec![1.0, 2.0]]];
1225 let input = GageRRInput {
1226 measurements: data,
1227 tolerance: None,
1228 };
1229 assert!(gage_rr_anova(&input).is_err());
1230 }
1231
1232 #[test]
1233 fn anova_status_matches_percent_grr() {
1234 let data = aiag_reference_data();
1235 let input = GageRRInput {
1236 measurements: data,
1237 tolerance: None,
1238 };
1239 let result = gage_rr_anova(&input).expect("should compute");
1240
1241 match result.status {
1242 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1243 GrrStatus::Marginal => {
1244 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1245 }
1246 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1247 }
1248 }
1249
1250 #[test]
1251 fn anova_p_values_bounded() {
1252 let data = aiag_reference_data();
1253 let input = GageRRInput {
1254 measurements: data,
1255 tolerance: None,
1256 };
1257 let result = gage_rr_anova(&input).expect("should compute");
1258
1259 for row in &result.anova_table.rows {
1260 if let Some(p) = row.p_value {
1261 assert!(
1262 (0.0..=1.0).contains(&p),
1263 "p-value for {} out of range: {}",
1264 row.source,
1265 p
1266 );
1267 }
1268 if let Some(f) = row.f_value {
1269 assert!(
1270 f >= 0.0,
1271 "F-value for {} should be non-negative: {}",
1272 row.source,
1273 f
1274 );
1275 }
1276 }
1277 }
1278
1279 #[test]
1284 fn both_methods_detect_same_dominant_variation() {
1285 let data = aiag_reference_data();
1286 let input_xr = GageRRInput {
1287 measurements: data.clone(),
1288 tolerance: None,
1289 };
1290 let input_anova = GageRRInput {
1291 measurements: data,
1292 tolerance: None,
1293 };
1294
1295 let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1296 let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1297
1298 let xr_pv_dominant = xr.pv > xr.grr;
1300 let anova_pv_dominant = anova.pv > anova.grr;
1301 assert_eq!(
1302 xr_pv_dominant, anova_pv_dominant,
1303 "X̄-R and ANOVA should agree on PV vs GRR dominance"
1304 );
1305 }
1306}