1use u_numflow::special;
19use u_numflow::stats;
20
21pub struct GageRRInput {
31 pub measurements: Vec<Vec<Vec<f64>>>,
33 pub tolerance: Option<f64>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct GageChartLimits {
40 pub center: f64,
42 pub ucl: f64,
44 pub lcl: f64,
46}
47
48#[derive(Debug, Clone)]
50pub struct GageRRResult {
51 pub ev: f64,
53 pub av: f64,
55 pub grr: f64,
57 pub pv: f64,
59 pub tv: f64,
61
62 pub percent_ev: f64,
64 pub percent_av: f64,
66 pub percent_grr: f64,
68 pub percent_pv: f64,
70 pub percent_tolerance: Option<f64>,
72
73 pub range_chart: GageChartLimits,
76 pub average_chart: GageChartLimits,
83
84 pub ndc: u32,
86 pub status: GrrStatus,
88}
89
90#[derive(Debug, Clone)]
92pub struct GageRRAnovaResult {
93 pub anova_table: AnovaTable,
95 pub variance_components: VarianceComponents,
97 pub ev: f64,
99 pub av: f64,
101 pub grr: f64,
103 pub pv: f64,
105 pub tv: f64,
107 pub percent_grr: f64,
109 pub percent_tolerance: Option<f64>,
111 pub ndc: u32,
113 pub status: GrrStatus,
115 pub interaction_significant: bool,
117 pub interaction_pooled: bool,
119}
120
121#[derive(Debug, Clone)]
123pub struct AnovaTable {
124 pub rows: Vec<AnovaRow>,
126}
127
128#[derive(Debug, Clone)]
130pub struct AnovaRow {
131 pub source: String,
133 pub df: f64,
135 pub ss: f64,
137 pub ms: f64,
139 pub f_value: Option<f64>,
141 pub p_value: Option<f64>,
143}
144
145#[derive(Debug, Clone)]
147pub struct VarianceComponents {
148 pub part: f64,
150 pub operator: f64,
152 pub interaction: f64,
154 pub repeatability: f64,
156 pub reproducibility: f64,
158 pub total: f64,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum GrrStatus {
165 Acceptable,
167 Marginal,
169 Unacceptable,
171}
172
173#[allow(clippy::approx_constant)]
182const K1: [f64; 2] = [0.8862, 0.5908];
183
184#[allow(clippy::approx_constant)]
189const K2: [f64; 2] = [0.7071, 0.5231];
190
191#[allow(clippy::approx_constant)]
196const K3: [f64; 9] = [
197 0.7071, 0.5231, 0.4467, 0.4030, 0.3742, 0.3534, 0.3375, 0.3249, 0.3146,
198];
199
200fn grr_status(percent_grr: f64) -> GrrStatus {
206 if percent_grr <= 10.0 {
207 GrrStatus::Acceptable
208 } else if percent_grr <= 30.0 {
209 GrrStatus::Marginal
210 } else {
211 GrrStatus::Unacceptable
212 }
213}
214
215fn compute_ndc(pv: f64, grr: f64) -> u32 {
217 if grr < 1e-300 {
218 return 1;
219 }
220 let ndc = (1.41 * pv / grr).floor() as i64;
221 ndc.max(1) as u32
222}
223
224fn validate_measurements(
227 measurements: &[Vec<Vec<f64>>],
228) -> Result<(usize, usize, usize), &'static str> {
229 let n_parts = measurements.len();
230 if n_parts < 2 {
231 return Err("at least 2 parts are required");
232 }
233
234 let n_operators = measurements[0].len();
235 if n_operators < 2 {
236 return Err("at least 2 operators are required");
237 }
238
239 let n_trials = measurements[0][0].len();
240 if n_trials < 2 {
241 return Err("at least 2 trials are required");
242 }
243
244 for part in measurements {
245 if part.len() != n_operators {
246 return Err("all parts must have the same number of operators");
247 }
248 for trials in part {
249 if trials.len() != n_trials {
250 return Err("all operator×part cells must have the same number of trials");
251 }
252 for &v in trials {
253 if !v.is_finite() {
254 return Err("all measurements must be finite");
255 }
256 }
257 }
258 }
259
260 Ok((n_parts, n_operators, n_trials))
261}
262
263pub fn gage_rr_xbar_r(input: &GageRRInput) -> Result<GageRRResult, &'static str> {
291 let (n_parts, n_operators, n_trials) = validate_measurements(&input.measurements)?;
292
293 if !(2..=3).contains(&n_trials) {
295 return Err("X̄-R method supports 2 or 3 trials");
296 }
297 if !(2..=3).contains(&n_operators) {
298 return Err("X̄-R method supports 2 or 3 operators");
299 }
300 if !(2..=10).contains(&n_parts) {
301 return Err("X̄-R method supports 2 to 10 parts");
302 }
303
304 let mut ranges: Vec<f64> = Vec::with_capacity(n_parts * n_operators);
306 for part in &input.measurements {
307 for trials in part {
308 let min = trials.iter().copied().fold(f64::INFINITY, f64::min);
309 let max = trials.iter().copied().fold(f64::NEG_INFINITY, f64::max);
310 ranges.push(max - min);
311 }
312 }
313
314 let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
316
317 let mut operator_avgs: Vec<f64> = Vec::with_capacity(n_operators);
320 for op in 0..n_operators {
321 let mut sum = 0.0;
322 let mut count = 0usize;
323 for part in &input.measurements {
324 for &v in &part[op] {
325 sum += v;
326 count += 1;
327 }
328 }
329 operator_avgs.push(sum / count as f64);
330 }
331
332 let x_diff = operator_avgs
333 .iter()
334 .copied()
335 .fold(f64::NEG_INFINITY, f64::max)
336 - operator_avgs.iter().copied().fold(f64::INFINITY, f64::min);
337
338 let k1 = K1[n_trials - 2];
340 let k2 = K2[n_operators - 2];
341
342 let ev = r_bar * k1;
343
344 let av_squared = (x_diff * k2).powi(2) - ev.powi(2) / (n_parts * n_trials) as f64;
345 let av = if av_squared > 0.0 {
346 av_squared.sqrt()
347 } else {
348 0.0
349 };
350
351 let grr = (ev.powi(2) + av.powi(2)).sqrt();
353
354 let mut part_means: Vec<f64> = Vec::with_capacity(n_parts);
356 for part in &input.measurements {
357 let mut sum = 0.0;
358 let mut count = 0usize;
359 for trials in part {
360 for &v in trials {
361 sum += v;
362 count += 1;
363 }
364 }
365 part_means.push(sum / count as f64);
366 }
367
368 let rp = part_means.iter().copied().fold(f64::NEG_INFINITY, f64::max)
369 - part_means.iter().copied().fold(f64::INFINITY, f64::min);
370
371 let k3 = K3[n_parts - 2];
372 let pv = rp * k3;
373
374 let tv = (grr.powi(2) + pv.powi(2)).sqrt();
376
377 let (percent_ev, percent_av, percent_grr, percent_pv) = if tv > 1e-300 {
379 (
380 ev / tv * 100.0,
381 av / tv * 100.0,
382 grr / tv * 100.0,
383 pv / tv * 100.0,
384 )
385 } else {
386 (0.0, 0.0, 0.0, 0.0)
387 };
388
389 let percent_tolerance = input.tolerance.and_then(|tol| {
390 if tol > 1e-300 {
391 Some(grr / tol * 600.0)
392 } else {
393 None
394 }
395 });
396
397 let (a2, d3, d4) = crate::spc::range_chart_factors(n_trials)
402 .expect("n_trials is 2 or 3, inside the factor tables");
403 let cell_means: Vec<f64> = input
404 .measurements
405 .iter()
406 .flat_map(|part| part.iter().map(|trials| stats::mean(trials).unwrap_or(0.0)))
407 .collect();
408 let x_double_bar = stats::mean(&cell_means).expect("cell_means is non-empty");
409 let range_chart = GageChartLimits {
410 center: r_bar,
411 ucl: d4 * r_bar,
412 lcl: d3 * r_bar,
413 };
414 let average_chart = GageChartLimits {
415 center: x_double_bar,
416 ucl: x_double_bar + a2 * r_bar,
417 lcl: x_double_bar - a2 * r_bar,
418 };
419
420 let ndc = compute_ndc(pv, grr);
421 let status = grr_status(percent_grr);
422
423 Ok(GageRRResult {
424 range_chart,
425 average_chart,
426 ev,
427 av,
428 grr,
429 pv,
430 tv,
431 percent_ev,
432 percent_av,
433 percent_grr,
434 percent_pv,
435 percent_tolerance,
436 ndc,
437 status,
438 })
439}
440
441pub fn gage_rr_anova(input: &GageRRInput) -> Result<GageRRAnovaResult, &'static str> {
467 let (p, o, r) = validate_measurements(&input.measurements)?;
468 let n_total = p * o * r;
469
470 let mut grand_sum = 0.0;
472 for part in &input.measurements {
473 for trials in part {
474 for &v in trials {
475 grand_sum += v;
476 }
477 }
478 }
479 let grand_mean = grand_sum / n_total as f64;
480
481 let mut part_means: Vec<f64> = Vec::with_capacity(p);
483 for part in &input.measurements {
484 let mut sum = 0.0;
485 for trials in part {
486 for &v in trials {
487 sum += v;
488 }
489 }
490 part_means.push(sum / (o * r) as f64);
491 }
492
493 let mut operator_means: Vec<f64> = Vec::with_capacity(o);
495 for op in 0..o {
496 let mut sum = 0.0;
497 for part in &input.measurements {
498 for &v in &part[op] {
499 sum += v;
500 }
501 }
502 operator_means.push(sum / (p * r) as f64);
503 }
504
505 let mut cell_means: Vec<Vec<f64>> = Vec::with_capacity(p);
507 for part in &input.measurements {
508 let mut row: Vec<f64> = Vec::with_capacity(o);
509 for trials in part {
510 let cell_sum: f64 = trials.iter().sum();
511 row.push(cell_sum / r as f64);
512 }
513 cell_means.push(row);
514 }
515
516 let ss_part: f64 = part_means
518 .iter()
519 .map(|&pm| (pm - grand_mean).powi(2))
520 .sum::<f64>()
521 * (o * r) as f64;
522
523 let ss_operator: f64 = operator_means
525 .iter()
526 .map(|&om| (om - grand_mean).powi(2))
527 .sum::<f64>()
528 * (p * r) as f64;
529
530 let mut ss_interaction = 0.0;
532 for (i, row) in cell_means.iter().enumerate() {
533 for (j, &cm) in row.iter().enumerate() {
534 let residual = cm - part_means[i] - operator_means[j] + grand_mean;
535 ss_interaction += residual.powi(2);
536 }
537 }
538 ss_interaction *= r as f64;
539
540 let mut ss_total = 0.0;
542 for part in &input.measurements {
543 for trials in part {
544 for &v in trials {
545 ss_total += (v - grand_mean).powi(2);
546 }
547 }
548 }
549
550 let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
552
553 let df_part = (p - 1) as f64;
555 let df_operator = (o - 1) as f64;
556 let df_interaction = ((p - 1) * (o - 1)) as f64;
557 let df_error = (p * o * (r - 1)) as f64;
558 let df_total = (n_total - 1) as f64;
559
560 let ms_part = ss_part / df_part;
562 let ms_operator = ss_operator / df_operator;
563 let ms_interaction = if df_interaction > 0.0 {
564 ss_interaction / df_interaction
565 } else {
566 0.0
567 };
568 let ms_error = if df_error > 0.0 {
569 ss_error / df_error
570 } else {
571 0.0
572 };
573
574 let ms_total = ss_total / df_total;
584 let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-12);
585 let is_valid_denom = |ms: f64| ms > degenerate_floor;
586
587 let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
589 let f_val = ms_interaction / ms_error;
590 let p_val = 1.0 - special::f_distribution_cdf(f_val, df_interaction, df_error);
591 (Some(f_val), Some(p_val))
592 } else {
593 (None, None)
594 };
595
596 let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
598 let interaction_pooled = !interaction_significant;
599
600 let pooled_ss = ss_error + ss_interaction;
606 let pooled_df = df_error + df_interaction;
607 let ms_pooled = if pooled_df > 0.0 {
608 pooled_ss / pooled_df
609 } else {
610 ms_error
611 };
612
613 let (denom_ms, denom_df) = if interaction_pooled {
615 (ms_pooled, pooled_df)
616 } else {
617 (ms_interaction, df_interaction)
618 };
619
620 let (f_part, p_part) = if is_valid_denom(denom_ms) {
621 let f_val = ms_part / denom_ms;
622 let p_val = 1.0 - special::f_distribution_cdf(f_val, df_part, denom_df);
623 (Some(f_val), Some(p_val))
624 } else {
625 (None, None)
626 };
627
628 let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
629 let f_val = ms_operator / denom_ms;
630 let p_val = 1.0 - special::f_distribution_cdf(f_val, df_operator, denom_df);
631 (Some(f_val), Some(p_val))
632 } else {
633 (None, None)
634 };
635
636 let mut rows = vec![
645 AnovaRow {
646 source: "Part".to_owned(),
647 df: df_part,
648 ss: ss_part,
649 ms: ms_part,
650 f_value: f_part,
651 p_value: p_part,
652 },
653 AnovaRow {
654 source: "Operator".to_owned(),
655 df: df_operator,
656 ss: ss_operator,
657 ms: ms_operator,
658 f_value: f_operator,
659 p_value: p_operator,
660 },
661 ];
662 if !interaction_pooled {
663 rows.push(AnovaRow {
664 source: "Part×Operator".to_owned(),
665 df: df_interaction,
666 ss: ss_interaction,
667 ms: ms_interaction,
668 f_value: f_interaction,
669 p_value: p_interaction,
670 });
671 }
672 rows.push(AnovaRow {
673 source: "Repeatability".to_owned(),
674 df: if interaction_pooled {
675 pooled_df
676 } else {
677 df_error
678 },
679 ss: if interaction_pooled {
680 pooled_ss
681 } else {
682 ss_error
683 },
684 ms: if interaction_pooled {
685 ms_pooled
686 } else {
687 ms_error
688 },
689 f_value: None,
690 p_value: None,
691 });
692 rows.push(AnovaRow {
693 source: "Total".to_owned(),
694 df: df_total,
695 ss: ss_total,
696 ms: ss_total / df_total,
697 f_value: None,
698 p_value: None,
699 });
700 let anova_table = AnovaTable { rows };
701
702 let sigma2_repeatability = (if interaction_pooled {
709 ms_pooled
710 } else {
711 ms_error
712 })
713 .max(0.0);
714
715 let sigma2_interaction = if interaction_pooled {
716 0.0
717 } else {
718 let val = (ms_interaction - ms_error) / r as f64;
719 val.max(0.0)
720 };
721
722 let sigma2_operator = if interaction_pooled {
723 let val = (ms_operator - ms_pooled) / (p * r) as f64;
724 val.max(0.0)
725 } else {
726 let val = (ms_operator - ms_interaction) / (p * r) as f64;
727 val.max(0.0)
728 };
729
730 let sigma2_part = if interaction_pooled {
731 let val = (ms_part - ms_pooled) / (o * r) as f64;
732 val.max(0.0)
733 } else {
734 let val = (ms_part - ms_interaction) / (o * r) as f64;
735 val.max(0.0)
736 };
737
738 let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
739 let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
740 let sigma2_total = sigma2_part + sigma2_grr;
741
742 let variance_components = VarianceComponents {
743 part: sigma2_part,
744 operator: sigma2_operator,
745 interaction: sigma2_interaction,
746 repeatability: sigma2_repeatability,
747 reproducibility: sigma2_reproducibility,
748 total: sigma2_total,
749 };
750
751 let ev = sigma2_repeatability.sqrt();
753 let av = sigma2_reproducibility.sqrt();
754 let grr = sigma2_grr.sqrt();
755 let pv = sigma2_part.sqrt();
756 let tv = sigma2_total.sqrt();
757
758 let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
759
760 let percent_tolerance = input.tolerance.and_then(|tol| {
761 if tol > 1e-300 {
762 Some(grr / tol * 600.0)
763 } else {
764 None
765 }
766 });
767
768 let ndc = compute_ndc(pv, grr);
769 let status = grr_status(percent_grr);
770
771 Ok(GageRRAnovaResult {
772 anova_table,
773 variance_components,
774 ev,
775 av,
776 grr,
777 pv,
778 tv,
779 percent_grr,
780 percent_tolerance,
781 ndc,
782 status,
783 interaction_significant,
784 interaction_pooled,
785 })
786}
787
788#[cfg(test)]
793mod tests {
794 use super::*;
795
796 fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
800 vec![
802 vec![
804 vec![0.29, 0.41, 0.64], vec![0.08, 0.25, 0.07], vec![0.04, -0.11, 0.75], ],
808 vec![
810 vec![-0.56, -0.68, -0.58],
811 vec![-0.47, -1.22, -0.68],
812 vec![-0.49, -0.56, -0.49],
813 ],
814 vec![
816 vec![1.34, 1.17, 1.27],
817 vec![1.19, 0.94, 1.34],
818 vec![1.02, 0.82, 0.90],
819 ],
820 vec![
822 vec![0.47, 0.50, 0.64],
823 vec![0.01, 0.14, 0.43],
824 vec![0.12, 0.22, 0.31],
825 ],
826 vec![
828 vec![-0.80, -0.92, -0.84],
829 vec![-0.56, -1.20, -1.28],
830 vec![-0.44, -0.21, -0.17],
831 ],
832 vec![
834 vec![0.02, 0.16, -0.10],
835 vec![0.01, -0.10, 0.07],
836 vec![-0.14, -0.46, 0.18],
837 ],
838 vec![
840 vec![0.59, 0.75, 0.66],
841 vec![0.55, 0.36, 0.51],
842 vec![0.47, 0.63, 0.34],
843 ],
844 vec![
846 vec![-0.31, -0.20, 0.17],
847 vec![0.02, -0.09, 0.12],
848 vec![-0.24, 0.04, -0.19],
849 ],
850 vec![
852 vec![2.26, 1.99, 2.01],
853 vec![1.80, 2.12, 2.19],
854 vec![1.80, 1.71, 2.29],
855 ],
856 vec![
858 vec![-1.36, -1.14, -1.30],
859 vec![-1.34, -1.11, -1.42],
860 vec![-1.13, -1.13, -0.96],
861 ],
862 ]
863 }
864
865 #[test]
876 fn the_result_carries_the_charts_the_method_plots() {
877 let measurements = vec![
879 vec![vec![10.0, 10.2, 10.1], vec![10.1, 10.0, 10.2]],
880 vec![vec![12.0, 12.1, 12.3], vec![12.2, 12.0, 12.1]],
881 vec![vec![14.0, 14.1, 14.2], vec![14.1, 14.3, 14.0]],
882 ];
883 let input = GageRRInput {
884 measurements: measurements.clone(),
885 tolerance: None,
886 };
887 let r = gage_rr_xbar_r(&input).expect("3 parts, 2 operators, 3 trials");
888
889 let cell_ranges: Vec<f64> = measurements
891 .iter()
892 .flat_map(|part| {
893 part.iter().map(|trials| {
894 trials.iter().copied().fold(f64::NEG_INFINITY, f64::max)
895 - trials.iter().copied().fold(f64::INFINITY, f64::min)
896 })
897 })
898 .collect();
899 let r_bar = cell_ranges.iter().sum::<f64>() / cell_ranges.len() as f64;
900 assert!(
901 (r.range_chart.center - r_bar).abs() < 1e-12,
902 "R̄ = {} vs {r_bar}",
903 r.range_chart.center
904 );
905
906 assert!((r.range_chart.ucl - 2.575 * r_bar).abs() < 1e-12);
908 assert_eq!(r.range_chart.lcl, 0.0, "D3 is 0 at n = 3");
909
910 let cell_means: Vec<f64> = measurements
912 .iter()
913 .flat_map(|part| {
914 part.iter()
915 .map(|trials| trials.iter().sum::<f64>() / trials.len() as f64)
916 })
917 .collect();
918 let x_double_bar = cell_means.iter().sum::<f64>() / cell_means.len() as f64;
919 assert!((r.average_chart.center - x_double_bar).abs() < 1e-12);
920 assert!((r.average_chart.ucl - (x_double_bar + 1.023 * r_bar)).abs() < 1e-12);
921 assert!((r.average_chart.lcl - (x_double_bar - 1.023 * r_bar)).abs() < 1e-12);
922 }
923
924 #[test]
928 fn the_method_only_ever_asks_for_factors_it_has() {
929 let two = vec![
930 vec![vec![10.0, 10.2], vec![10.1, 10.0]],
931 vec![vec![12.0, 12.1], vec![12.2, 12.0]],
932 ];
933 let r = gage_rr_xbar_r(&GageRRInput {
934 measurements: two,
935 tolerance: None,
936 })
937 .expect("2 trials");
938 assert!((r.range_chart.ucl / r.range_chart.center - 3.267).abs() < 1e-12);
940
941 let four = vec![
944 vec![vec![10.0, 10.2, 10.1, 10.3], vec![10.1, 10.0, 10.2, 10.1]],
945 vec![vec![12.0, 12.1, 12.3, 12.2], vec![12.2, 12.0, 12.1, 12.3]],
946 ];
947 assert!(gage_rr_xbar_r(&GageRRInput {
948 measurements: four,
949 tolerance: None,
950 })
951 .is_err());
952 }
953
954 #[test]
955 fn xbar_r_basic_computation() {
956 let data = aiag_reference_data();
957 let input = GageRRInput {
958 measurements: data,
959 tolerance: Some(4.0),
960 };
961 let result = gage_rr_xbar_r(&input).expect("should compute");
962
963 assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
965 assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
966 assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
967 assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
968
969 let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
971 assert!(
972 (result.grr - expected_grr).abs() < 1e-10,
973 "GRR identity failed: {} vs {}",
974 result.grr,
975 expected_grr
976 );
977
978 let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
980 assert!(
981 (result.tv - expected_tv).abs() < 1e-10,
982 "TV identity failed: {} vs {}",
983 result.tv,
984 expected_tv
985 );
986
987 let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
990 assert!(
991 (pct_check - 10000.0).abs() < 1.0,
992 "percentage identity: {} should be ~10000",
993 pct_check
994 );
995
996 assert!(result.percent_tolerance.is_some());
998
999 assert!(result.ndc >= 1);
1001 }
1002
1003 #[test]
1004 fn xbar_r_ndc_minimum_one() {
1005 let data = vec![
1007 vec![vec![1.0, 5.0], vec![0.0, 6.0]],
1008 vec![vec![1.5, 4.5], vec![0.5, 5.5]],
1009 ];
1010 let input = GageRRInput {
1011 measurements: data,
1012 tolerance: None,
1013 };
1014 let result = gage_rr_xbar_r(&input).expect("should compute");
1015 assert!(result.ndc >= 1, "NDC should be at least 1");
1016 }
1017
1018 #[test]
1019 fn xbar_r_status_classification() {
1020 let data = aiag_reference_data();
1021 let input = GageRRInput {
1022 measurements: data,
1023 tolerance: None,
1024 };
1025 let result = gage_rr_xbar_r(&input).expect("should compute");
1026
1027 match result.status {
1029 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1030 GrrStatus::Marginal => {
1031 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1032 }
1033 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1034 }
1035 }
1036
1037 #[test]
1038 fn xbar_r_rejects_invalid_dimensions() {
1039 let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
1041 let input = GageRRInput {
1042 measurements: data,
1043 tolerance: None,
1044 };
1045 assert!(gage_rr_xbar_r(&input).is_err());
1046
1047 let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
1049 let input = GageRRInput {
1050 measurements: data,
1051 tolerance: None,
1052 };
1053 assert!(gage_rr_xbar_r(&input).is_err());
1054
1055 let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
1057 let input = GageRRInput {
1058 measurements: data,
1059 tolerance: None,
1060 };
1061 assert!(gage_rr_xbar_r(&input).is_err());
1062 }
1063
1064 #[test]
1065 fn xbar_r_rejects_non_finite() {
1066 let data = vec![
1067 vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
1068 vec![vec![1.0, 2.0], vec![3.0, 4.0]],
1069 ];
1070 let input = GageRRInput {
1071 measurements: data,
1072 tolerance: None,
1073 };
1074 assert!(gage_rr_xbar_r(&input).is_err());
1075 }
1076
1077 #[test]
1078 fn xbar_r_two_operators_two_trials() {
1079 let data = vec![
1081 vec![vec![10.0, 10.2], vec![10.1, 10.3]],
1082 vec![vec![20.0, 20.1], vec![19.9, 20.2]],
1083 ];
1084 let input = GageRRInput {
1085 measurements: data,
1086 tolerance: Some(2.0),
1087 };
1088 let result = gage_rr_xbar_r(&input).expect("should compute");
1089 assert!(result.ev > 0.0);
1090 assert!(result.pv > 0.0);
1091 assert!(result.percent_tolerance.is_some());
1092 }
1093
1094 #[test]
1099 fn anova_basic_computation() {
1100 let data = aiag_reference_data();
1101 let input = GageRRInput {
1102 measurements: data,
1103 tolerance: Some(4.0),
1104 };
1105 let result = gage_rr_anova(&input).expect("should compute");
1106
1107 assert!(
1109 result.variance_components.repeatability >= 0.0,
1110 "σ²_repeatability should be non-negative"
1111 );
1112 assert!(
1113 result.variance_components.part >= 0.0,
1114 "σ²_part should be non-negative"
1115 );
1116 assert!(
1117 result.variance_components.operator >= 0.0,
1118 "σ²_operator should be non-negative"
1119 );
1120 assert!(
1121 result.variance_components.interaction >= 0.0,
1122 "σ²_interaction should be non-negative"
1123 );
1124
1125 let expected_repro =
1127 result.variance_components.operator + result.variance_components.interaction;
1128 assert!(
1129 (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
1130 "σ²_reproducibility identity failed"
1131 );
1132
1133 let expected_total = result.variance_components.part
1135 + result.variance_components.repeatability
1136 + result.variance_components.reproducibility;
1137 assert!(
1138 (result.variance_components.total - expected_total).abs() < 1e-10,
1139 "σ²_total identity failed"
1140 );
1141
1142 assert_eq!(result.anova_table.rows.len(), 5);
1144
1145 assert_eq!(result.anova_table.rows[0].source, "Part");
1147 assert_eq!(result.anova_table.rows[1].source, "Operator");
1148 assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1149 assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1150 assert_eq!(result.anova_table.rows[4].source, "Total");
1151
1152 assert!(
1154 (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1155 "EV should be sqrt(σ²_repeatability)"
1156 );
1157 assert!(
1158 (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1159 "PV should be sqrt(σ²_part)"
1160 );
1161 }
1162
1163 #[test]
1164 fn anova_ss_decomposition() {
1165 let data = aiag_reference_data();
1166 let input = GageRRInput {
1167 measurements: data,
1168 tolerance: None,
1169 };
1170 let result = gage_rr_anova(&input).expect("should compute");
1171
1172 let rows = &result.anova_table.rows;
1174 let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1175 let ss_total = rows[4].ss;
1176 assert!(
1177 (ss_sum - ss_total).abs() < 1e-8,
1178 "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1179 rows[0].ss,
1180 rows[1].ss,
1181 rows[2].ss,
1182 rows[3].ss,
1183 ss_sum,
1184 ss_total
1185 );
1186
1187 let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1189 let df_total = rows[4].df;
1190 assert!(
1191 (df_sum - df_total).abs() < 1e-10,
1192 "DF decomposition failed: {} vs {}",
1193 df_sum,
1194 df_total
1195 );
1196 }
1197
1198 #[test]
1199 fn anova_interaction_pooling() {
1200 let data = vec![
1202 vec![
1203 vec![10.0, 10.1, 10.0],
1204 vec![10.0, 10.0, 10.1],
1205 vec![10.1, 10.0, 10.0],
1206 ],
1207 vec![
1208 vec![20.0, 20.1, 20.0],
1209 vec![20.0, 20.0, 20.1],
1210 vec![20.1, 20.0, 20.0],
1211 ],
1212 vec![
1213 vec![15.0, 15.1, 15.0],
1214 vec![15.0, 15.0, 15.1],
1215 vec![15.1, 15.0, 15.0],
1216 ],
1217 ];
1218 let input = GageRRInput {
1219 measurements: data,
1220 tolerance: None,
1221 };
1222 let result = gage_rr_anova(&input).expect("should compute");
1223
1224 if result.interaction_pooled {
1226 assert_eq!(result.variance_components.interaction, 0.0);
1227 }
1228 }
1229
1230 #[test]
1239 fn anova_pooled_repeatability_uses_pooled_ms() {
1240 let data = vec![
1243 vec![
1244 vec![10.0, 10.1, 10.0],
1245 vec![10.0, 10.0, 10.1],
1246 vec![10.1, 10.0, 10.0],
1247 ],
1248 vec![
1249 vec![20.0, 20.1, 20.0],
1250 vec![20.0, 20.0, 20.1],
1251 vec![20.1, 20.0, 20.0],
1252 ],
1253 vec![
1254 vec![15.0, 15.1, 15.0],
1255 vec![15.0, 15.0, 15.1],
1256 vec![15.1, 15.0, 15.0],
1257 ],
1258 ];
1259 let input = GageRRInput {
1260 measurements: data,
1261 tolerance: None,
1262 };
1263 let result = gage_rr_anova(&input).expect("should compute");
1264 assert!(result.interaction_pooled, "this dataset should pool");
1265
1266 let rep_row = result
1267 .anova_table
1268 .rows
1269 .iter()
1270 .find(|r| r.source == "Repeatability")
1271 .expect("Repeatability row present");
1272 let part = result
1273 .anova_table
1274 .rows
1275 .iter()
1276 .find(|r| r.source == "Part")
1277 .expect("Part row");
1278
1279 let pooled_denom = part.ms / part.f_value.expect("Part F present");
1281
1282 assert!(
1284 (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1285 "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1286 result.variance_components.repeatability
1287 );
1288 assert!(
1290 (rep_row.ms - pooled_denom).abs() < 1e-10,
1291 "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1292 rep_row.ms
1293 );
1294 assert!(
1296 !result
1297 .anova_table
1298 .rows
1299 .iter()
1300 .any(|r| r.source == "Part×Operator"),
1301 "pooled table must not carry a separate interaction row"
1302 );
1303 let df_sum: f64 = result
1304 .anova_table
1305 .rows
1306 .iter()
1307 .filter(|r| r.source != "Total")
1308 .map(|r| r.df)
1309 .sum();
1310 let df_total = result
1311 .anova_table
1312 .rows
1313 .iter()
1314 .find(|r| r.source == "Total")
1315 .expect("Total row")
1316 .df;
1317 assert!(
1318 (df_sum - df_total).abs() < 1e-9,
1319 "component df ({df_sum}) must sum to total df ({df_total})"
1320 );
1321 }
1322
1323 #[test]
1328 fn anova_degenerate_denominator_returns_none() {
1329 let mut data = aiag_reference_data();
1338 for part in data.iter_mut() {
1339 for op in part.iter_mut() {
1340 let first = op[0];
1341 for trial in op.iter_mut() {
1342 *trial = first;
1343 }
1344 }
1345 }
1346 let input = GageRRInput {
1347 measurements: data,
1348 tolerance: None,
1349 };
1350 let result = gage_rr_anova(&input).expect("should compute");
1351
1352 for row in &result.anova_table.rows {
1353 if let Some(f) = row.f_value {
1354 assert!(
1355 f.is_finite() && f < 1e6,
1356 "degenerate denominator must not produce a divergent F for {}: {}",
1357 row.source,
1358 f
1359 );
1360 }
1361 }
1362 assert!(
1364 result.ev.is_finite(),
1365 "EV must be finite, got {}",
1366 result.ev
1367 );
1368 assert!(
1369 result.percent_grr.is_finite(),
1370 "%GRR must be finite, got {}",
1371 result.percent_grr
1372 );
1373 }
1374
1375 #[test]
1376 fn anova_rejects_invalid_input() {
1377 let data = vec![vec![vec![1.0, 2.0]]];
1378 let input = GageRRInput {
1379 measurements: data,
1380 tolerance: None,
1381 };
1382 assert!(gage_rr_anova(&input).is_err());
1383 }
1384
1385 #[test]
1386 fn anova_status_matches_percent_grr() {
1387 let data = aiag_reference_data();
1388 let input = GageRRInput {
1389 measurements: data,
1390 tolerance: None,
1391 };
1392 let result = gage_rr_anova(&input).expect("should compute");
1393
1394 match result.status {
1395 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1396 GrrStatus::Marginal => {
1397 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1398 }
1399 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1400 }
1401 }
1402
1403 #[test]
1404 fn anova_p_values_bounded() {
1405 let data = aiag_reference_data();
1406 let input = GageRRInput {
1407 measurements: data,
1408 tolerance: None,
1409 };
1410 let result = gage_rr_anova(&input).expect("should compute");
1411
1412 for row in &result.anova_table.rows {
1413 if let Some(p) = row.p_value {
1414 assert!(
1415 (0.0..=1.0).contains(&p),
1416 "p-value for {} out of range: {}",
1417 row.source,
1418 p
1419 );
1420 }
1421 if let Some(f) = row.f_value {
1422 assert!(
1423 f >= 0.0,
1424 "F-value for {} should be non-negative: {}",
1425 row.source,
1426 f
1427 );
1428 }
1429 }
1430 }
1431
1432 #[test]
1437 fn both_methods_detect_same_dominant_variation() {
1438 let data = aiag_reference_data();
1439 let input_xr = GageRRInput {
1440 measurements: data.clone(),
1441 tolerance: None,
1442 };
1443 let input_anova = GageRRInput {
1444 measurements: data,
1445 tolerance: None,
1446 };
1447
1448 let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1449 let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1450
1451 let xr_pv_dominant = xr.pv > xr.grr;
1453 let anova_pv_dominant = anova.pv > anova.grr;
1454 assert_eq!(
1455 xr_pv_dominant, anova_pv_dominant,
1456 "X̄-R and ANOVA should agree on PV vs GRR dominance"
1457 );
1458 }
1459}