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;
587 let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-16 * grand_mean * grand_mean);
588 let is_valid_denom = |ms: f64| ms > degenerate_floor;
589
590 let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
592 let f_val = ms_interaction / ms_error;
593 let p_val = special::f_distribution_sf(f_val, df_interaction, df_error);
594 (Some(f_val), Some(p_val))
595 } else {
596 (None, None)
597 };
598
599 let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
601 let interaction_pooled = !interaction_significant;
602
603 let pooled_ss = ss_error + ss_interaction;
609 let pooled_df = df_error + df_interaction;
610 let ms_pooled = if pooled_df > 0.0 {
611 pooled_ss / pooled_df
612 } else {
613 ms_error
614 };
615
616 let (denom_ms, denom_df) = if interaction_pooled {
618 (ms_pooled, pooled_df)
619 } else {
620 (ms_interaction, df_interaction)
621 };
622
623 let (f_part, p_part) = if is_valid_denom(denom_ms) {
624 let f_val = ms_part / denom_ms;
625 let p_val = special::f_distribution_sf(f_val, df_part, denom_df);
626 (Some(f_val), Some(p_val))
627 } else {
628 (None, None)
629 };
630
631 let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
632 let f_val = ms_operator / denom_ms;
633 let p_val = special::f_distribution_sf(f_val, df_operator, denom_df);
634 (Some(f_val), Some(p_val))
635 } else {
636 (None, None)
637 };
638
639 let mut rows = vec![
648 AnovaRow {
649 source: "Part".to_owned(),
650 df: df_part,
651 ss: ss_part,
652 ms: ms_part,
653 f_value: f_part,
654 p_value: p_part,
655 },
656 AnovaRow {
657 source: "Operator".to_owned(),
658 df: df_operator,
659 ss: ss_operator,
660 ms: ms_operator,
661 f_value: f_operator,
662 p_value: p_operator,
663 },
664 ];
665 if !interaction_pooled {
666 rows.push(AnovaRow {
667 source: "Part×Operator".to_owned(),
668 df: df_interaction,
669 ss: ss_interaction,
670 ms: ms_interaction,
671 f_value: f_interaction,
672 p_value: p_interaction,
673 });
674 }
675 rows.push(AnovaRow {
676 source: "Repeatability".to_owned(),
677 df: if interaction_pooled {
678 pooled_df
679 } else {
680 df_error
681 },
682 ss: if interaction_pooled {
683 pooled_ss
684 } else {
685 ss_error
686 },
687 ms: if interaction_pooled {
688 ms_pooled
689 } else {
690 ms_error
691 },
692 f_value: None,
693 p_value: None,
694 });
695 rows.push(AnovaRow {
696 source: "Total".to_owned(),
697 df: df_total,
698 ss: ss_total,
699 ms: ss_total / df_total,
700 f_value: None,
701 p_value: None,
702 });
703 let anova_table = AnovaTable { rows };
704
705 let sigma2_repeatability = (if interaction_pooled {
712 ms_pooled
713 } else {
714 ms_error
715 })
716 .max(0.0);
717
718 let sigma2_interaction = if interaction_pooled {
719 0.0
720 } else {
721 let val = (ms_interaction - ms_error) / r as f64;
722 val.max(0.0)
723 };
724
725 let sigma2_operator = if interaction_pooled {
726 let val = (ms_operator - ms_pooled) / (p * r) as f64;
727 val.max(0.0)
728 } else {
729 let val = (ms_operator - ms_interaction) / (p * r) as f64;
730 val.max(0.0)
731 };
732
733 let sigma2_part = if interaction_pooled {
734 let val = (ms_part - ms_pooled) / (o * r) as f64;
735 val.max(0.0)
736 } else {
737 let val = (ms_part - ms_interaction) / (o * r) as f64;
738 val.max(0.0)
739 };
740
741 let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
742 let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
743 let sigma2_total = sigma2_part + sigma2_grr;
744
745 let variance_components = VarianceComponents {
746 part: sigma2_part,
747 operator: sigma2_operator,
748 interaction: sigma2_interaction,
749 repeatability: sigma2_repeatability,
750 reproducibility: sigma2_reproducibility,
751 total: sigma2_total,
752 };
753
754 let ev = sigma2_repeatability.sqrt();
756 let av = sigma2_reproducibility.sqrt();
757 let grr = sigma2_grr.sqrt();
758 let pv = sigma2_part.sqrt();
759 let tv = sigma2_total.sqrt();
760
761 let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
762
763 let percent_tolerance = input.tolerance.and_then(|tol| {
764 if tol > 1e-300 {
765 Some(grr / tol * 600.0)
766 } else {
767 None
768 }
769 });
770
771 let ndc = compute_ndc(pv, grr);
772 let status = grr_status(percent_grr);
773
774 Ok(GageRRAnovaResult {
775 anova_table,
776 variance_components,
777 ev,
778 av,
779 grr,
780 pv,
781 tv,
782 percent_grr,
783 percent_tolerance,
784 ndc,
785 status,
786 interaction_significant,
787 interaction_pooled,
788 })
789}
790
791#[cfg(test)]
796mod tests {
797 use super::*;
798
799 fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
803 vec![
805 vec![
807 vec![0.29, 0.41, 0.64], vec![0.08, 0.25, 0.07], vec![0.04, -0.11, 0.75], ],
811 vec![
813 vec![-0.56, -0.68, -0.58],
814 vec![-0.47, -1.22, -0.68],
815 vec![-0.49, -0.56, -0.49],
816 ],
817 vec![
819 vec![1.34, 1.17, 1.27],
820 vec![1.19, 0.94, 1.34],
821 vec![1.02, 0.82, 0.90],
822 ],
823 vec![
825 vec![0.47, 0.50, 0.64],
826 vec![0.01, 0.14, 0.43],
827 vec![0.12, 0.22, 0.31],
828 ],
829 vec![
831 vec![-0.80, -0.92, -0.84],
832 vec![-0.56, -1.20, -1.28],
833 vec![-0.44, -0.21, -0.17],
834 ],
835 vec![
837 vec![0.02, 0.16, -0.10],
838 vec![0.01, -0.10, 0.07],
839 vec![-0.14, -0.46, 0.18],
840 ],
841 vec![
843 vec![0.59, 0.75, 0.66],
844 vec![0.55, 0.36, 0.51],
845 vec![0.47, 0.63, 0.34],
846 ],
847 vec![
849 vec![-0.31, -0.20, 0.17],
850 vec![0.02, -0.09, 0.12],
851 vec![-0.24, 0.04, -0.19],
852 ],
853 vec![
855 vec![2.26, 1.99, 2.01],
856 vec![1.80, 2.12, 2.19],
857 vec![1.80, 1.71, 2.29],
858 ],
859 vec![
861 vec![-1.36, -1.14, -1.30],
862 vec![-1.34, -1.11, -1.42],
863 vec![-1.13, -1.13, -0.96],
864 ],
865 ]
866 }
867
868 #[test]
879 fn the_result_carries_the_charts_the_method_plots() {
880 let measurements = vec![
882 vec![vec![10.0, 10.2, 10.1], vec![10.1, 10.0, 10.2]],
883 vec![vec![12.0, 12.1, 12.3], vec![12.2, 12.0, 12.1]],
884 vec![vec![14.0, 14.1, 14.2], vec![14.1, 14.3, 14.0]],
885 ];
886 let input = GageRRInput {
887 measurements: measurements.clone(),
888 tolerance: None,
889 };
890 let r = gage_rr_xbar_r(&input).expect("3 parts, 2 operators, 3 trials");
891
892 let cell_ranges: Vec<f64> = measurements
894 .iter()
895 .flat_map(|part| {
896 part.iter().map(|trials| {
897 trials.iter().copied().fold(f64::NEG_INFINITY, f64::max)
898 - trials.iter().copied().fold(f64::INFINITY, f64::min)
899 })
900 })
901 .collect();
902 let r_bar = cell_ranges.iter().sum::<f64>() / cell_ranges.len() as f64;
903 assert!(
904 (r.range_chart.center - r_bar).abs() < 1e-12,
905 "R̄ = {} vs {r_bar}",
906 r.range_chart.center
907 );
908
909 assert!((r.range_chart.ucl - 2.575 * r_bar).abs() < 1e-12);
911 assert_eq!(r.range_chart.lcl, 0.0, "D3 is 0 at n = 3");
912
913 let cell_means: Vec<f64> = measurements
915 .iter()
916 .flat_map(|part| {
917 part.iter()
918 .map(|trials| trials.iter().sum::<f64>() / trials.len() as f64)
919 })
920 .collect();
921 let x_double_bar = cell_means.iter().sum::<f64>() / cell_means.len() as f64;
922 assert!((r.average_chart.center - x_double_bar).abs() < 1e-12);
923 assert!((r.average_chart.ucl - (x_double_bar + 1.023 * r_bar)).abs() < 1e-12);
924 assert!((r.average_chart.lcl - (x_double_bar - 1.023 * r_bar)).abs() < 1e-12);
925 }
926
927 #[test]
931 fn the_method_only_ever_asks_for_factors_it_has() {
932 let two = vec![
933 vec![vec![10.0, 10.2], vec![10.1, 10.0]],
934 vec![vec![12.0, 12.1], vec![12.2, 12.0]],
935 ];
936 let r = gage_rr_xbar_r(&GageRRInput {
937 measurements: two,
938 tolerance: None,
939 })
940 .expect("2 trials");
941 assert!((r.range_chart.ucl / r.range_chart.center - 3.267).abs() < 1e-12);
943
944 let four = vec![
947 vec![vec![10.0, 10.2, 10.1, 10.3], vec![10.1, 10.0, 10.2, 10.1]],
948 vec![vec![12.0, 12.1, 12.3, 12.2], vec![12.2, 12.0, 12.1, 12.3]],
949 ];
950 assert!(gage_rr_xbar_r(&GageRRInput {
951 measurements: four,
952 tolerance: None,
953 })
954 .is_err());
955 }
956
957 #[test]
958 fn xbar_r_basic_computation() {
959 let data = aiag_reference_data();
960 let input = GageRRInput {
961 measurements: data,
962 tolerance: Some(4.0),
963 };
964 let result = gage_rr_xbar_r(&input).expect("should compute");
965
966 assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
968 assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
969 assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
970 assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
971
972 let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
974 assert!(
975 (result.grr - expected_grr).abs() < 1e-10,
976 "GRR identity failed: {} vs {}",
977 result.grr,
978 expected_grr
979 );
980
981 let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
983 assert!(
984 (result.tv - expected_tv).abs() < 1e-10,
985 "TV identity failed: {} vs {}",
986 result.tv,
987 expected_tv
988 );
989
990 let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
993 assert!(
994 (pct_check - 10000.0).abs() < 1.0,
995 "percentage identity: {} should be ~10000",
996 pct_check
997 );
998
999 assert!(result.percent_tolerance.is_some());
1001
1002 assert!(result.ndc >= 1);
1004 }
1005
1006 #[test]
1007 fn xbar_r_ndc_minimum_one() {
1008 let data = vec![
1010 vec![vec![1.0, 5.0], vec![0.0, 6.0]],
1011 vec![vec![1.5, 4.5], vec![0.5, 5.5]],
1012 ];
1013 let input = GageRRInput {
1014 measurements: data,
1015 tolerance: None,
1016 };
1017 let result = gage_rr_xbar_r(&input).expect("should compute");
1018 assert!(result.ndc >= 1, "NDC should be at least 1");
1019 }
1020
1021 #[test]
1022 fn xbar_r_status_classification() {
1023 let data = aiag_reference_data();
1024 let input = GageRRInput {
1025 measurements: data,
1026 tolerance: None,
1027 };
1028 let result = gage_rr_xbar_r(&input).expect("should compute");
1029
1030 match result.status {
1032 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1033 GrrStatus::Marginal => {
1034 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1035 }
1036 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1037 }
1038 }
1039
1040 #[test]
1041 fn xbar_r_rejects_invalid_dimensions() {
1042 let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
1044 let input = GageRRInput {
1045 measurements: data,
1046 tolerance: None,
1047 };
1048 assert!(gage_rr_xbar_r(&input).is_err());
1049
1050 let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
1052 let input = GageRRInput {
1053 measurements: data,
1054 tolerance: None,
1055 };
1056 assert!(gage_rr_xbar_r(&input).is_err());
1057
1058 let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
1060 let input = GageRRInput {
1061 measurements: data,
1062 tolerance: None,
1063 };
1064 assert!(gage_rr_xbar_r(&input).is_err());
1065 }
1066
1067 #[test]
1068 fn xbar_r_rejects_non_finite() {
1069 let data = vec![
1070 vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
1071 vec![vec![1.0, 2.0], vec![3.0, 4.0]],
1072 ];
1073 let input = GageRRInput {
1074 measurements: data,
1075 tolerance: None,
1076 };
1077 assert!(gage_rr_xbar_r(&input).is_err());
1078 }
1079
1080 #[test]
1081 fn xbar_r_two_operators_two_trials() {
1082 let data = vec![
1084 vec![vec![10.0, 10.2], vec![10.1, 10.3]],
1085 vec![vec![20.0, 20.1], vec![19.9, 20.2]],
1086 ];
1087 let input = GageRRInput {
1088 measurements: data,
1089 tolerance: Some(2.0),
1090 };
1091 let result = gage_rr_xbar_r(&input).expect("should compute");
1092 assert!(result.ev > 0.0);
1093 assert!(result.pv > 0.0);
1094 assert!(result.percent_tolerance.is_some());
1095 }
1096
1097 #[test]
1102 fn anova_basic_computation() {
1103 let data = aiag_reference_data();
1104 let input = GageRRInput {
1105 measurements: data,
1106 tolerance: Some(4.0),
1107 };
1108 let result = gage_rr_anova(&input).expect("should compute");
1109
1110 assert!(
1112 result.variance_components.repeatability >= 0.0,
1113 "σ²_repeatability should be non-negative"
1114 );
1115 assert!(
1116 result.variance_components.part >= 0.0,
1117 "σ²_part should be non-negative"
1118 );
1119 assert!(
1120 result.variance_components.operator >= 0.0,
1121 "σ²_operator should be non-negative"
1122 );
1123 assert!(
1124 result.variance_components.interaction >= 0.0,
1125 "σ²_interaction should be non-negative"
1126 );
1127
1128 let expected_repro =
1130 result.variance_components.operator + result.variance_components.interaction;
1131 assert!(
1132 (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
1133 "σ²_reproducibility identity failed"
1134 );
1135
1136 let expected_total = result.variance_components.part
1138 + result.variance_components.repeatability
1139 + result.variance_components.reproducibility;
1140 assert!(
1141 (result.variance_components.total - expected_total).abs() < 1e-10,
1142 "σ²_total identity failed"
1143 );
1144
1145 assert_eq!(result.anova_table.rows.len(), 5);
1147
1148 assert_eq!(result.anova_table.rows[0].source, "Part");
1150 assert_eq!(result.anova_table.rows[1].source, "Operator");
1151 assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1152 assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1153 assert_eq!(result.anova_table.rows[4].source, "Total");
1154
1155 assert!(
1157 (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1158 "EV should be sqrt(σ²_repeatability)"
1159 );
1160 assert!(
1161 (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1162 "PV should be sqrt(σ²_part)"
1163 );
1164 }
1165
1166 #[test]
1167 fn anova_ss_decomposition() {
1168 let data = aiag_reference_data();
1169 let input = GageRRInput {
1170 measurements: data,
1171 tolerance: None,
1172 };
1173 let result = gage_rr_anova(&input).expect("should compute");
1174
1175 let rows = &result.anova_table.rows;
1177 let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1178 let ss_total = rows[4].ss;
1179 assert!(
1180 (ss_sum - ss_total).abs() < 1e-8,
1181 "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1182 rows[0].ss,
1183 rows[1].ss,
1184 rows[2].ss,
1185 rows[3].ss,
1186 ss_sum,
1187 ss_total
1188 );
1189
1190 let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1192 let df_total = rows[4].df;
1193 assert!(
1194 (df_sum - df_total).abs() < 1e-10,
1195 "DF decomposition failed: {} vs {}",
1196 df_sum,
1197 df_total
1198 );
1199 }
1200
1201 #[test]
1202 fn anova_interaction_pooling() {
1203 let data = vec![
1205 vec![
1206 vec![10.0, 10.1, 10.0],
1207 vec![10.0, 10.0, 10.1],
1208 vec![10.1, 10.0, 10.0],
1209 ],
1210 vec![
1211 vec![20.0, 20.1, 20.0],
1212 vec![20.0, 20.0, 20.1],
1213 vec![20.1, 20.0, 20.0],
1214 ],
1215 vec![
1216 vec![15.0, 15.1, 15.0],
1217 vec![15.0, 15.0, 15.1],
1218 vec![15.1, 15.0, 15.0],
1219 ],
1220 ];
1221 let input = GageRRInput {
1222 measurements: data,
1223 tolerance: None,
1224 };
1225 let result = gage_rr_anova(&input).expect("should compute");
1226
1227 if result.interaction_pooled {
1229 assert_eq!(result.variance_components.interaction, 0.0);
1230 }
1231 }
1232
1233 #[test]
1242 fn anova_pooled_repeatability_uses_pooled_ms() {
1243 let data = vec![
1246 vec![
1247 vec![10.0, 10.1, 10.0],
1248 vec![10.0, 10.0, 10.1],
1249 vec![10.1, 10.0, 10.0],
1250 ],
1251 vec![
1252 vec![20.0, 20.1, 20.0],
1253 vec![20.0, 20.0, 20.1],
1254 vec![20.1, 20.0, 20.0],
1255 ],
1256 vec![
1257 vec![15.0, 15.1, 15.0],
1258 vec![15.0, 15.0, 15.1],
1259 vec![15.1, 15.0, 15.0],
1260 ],
1261 ];
1262 let input = GageRRInput {
1263 measurements: data,
1264 tolerance: None,
1265 };
1266 let result = gage_rr_anova(&input).expect("should compute");
1267 assert!(result.interaction_pooled, "this dataset should pool");
1268
1269 let rep_row = result
1270 .anova_table
1271 .rows
1272 .iter()
1273 .find(|r| r.source == "Repeatability")
1274 .expect("Repeatability row present");
1275 let part = result
1276 .anova_table
1277 .rows
1278 .iter()
1279 .find(|r| r.source == "Part")
1280 .expect("Part row");
1281
1282 let pooled_denom = part.ms / part.f_value.expect("Part F present");
1284
1285 assert!(
1287 (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1288 "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1289 result.variance_components.repeatability
1290 );
1291 assert!(
1293 (rep_row.ms - pooled_denom).abs() < 1e-10,
1294 "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1295 rep_row.ms
1296 );
1297 assert!(
1299 !result
1300 .anova_table
1301 .rows
1302 .iter()
1303 .any(|r| r.source == "Part×Operator"),
1304 "pooled table must not carry a separate interaction row"
1305 );
1306 let df_sum: f64 = result
1307 .anova_table
1308 .rows
1309 .iter()
1310 .filter(|r| r.source != "Total")
1311 .map(|r| r.df)
1312 .sum();
1313 let df_total = result
1314 .anova_table
1315 .rows
1316 .iter()
1317 .find(|r| r.source == "Total")
1318 .expect("Total row")
1319 .df;
1320 assert!(
1321 (df_sum - df_total).abs() < 1e-9,
1322 "component df ({df_sum}) must sum to total df ({df_total})"
1323 );
1324 }
1325
1326 #[test]
1331 fn anova_degenerate_denominator_returns_none() {
1332 let mut data = aiag_reference_data();
1341 for part in data.iter_mut() {
1342 for op in part.iter_mut() {
1343 let first = op[0];
1344 for trial in op.iter_mut() {
1345 *trial = first;
1346 }
1347 }
1348 }
1349 let input = GageRRInput {
1350 measurements: data,
1351 tolerance: None,
1352 };
1353 let result = gage_rr_anova(&input).expect("should compute");
1354
1355 for row in &result.anova_table.rows {
1356 if let Some(f) = row.f_value {
1357 assert!(
1358 f.is_finite() && f < 1e6,
1359 "degenerate denominator must not produce a divergent F for {}: {}",
1360 row.source,
1361 f
1362 );
1363 }
1364 }
1365 assert!(
1367 result.ev.is_finite(),
1368 "EV must be finite, got {}",
1369 result.ev
1370 );
1371 assert!(
1372 result.percent_grr.is_finite(),
1373 "%GRR must be finite, got {}",
1374 result.percent_grr
1375 );
1376 }
1377
1378 #[test]
1379 fn anova_rejects_invalid_input() {
1380 let data = vec![vec![vec![1.0, 2.0]]];
1381 let input = GageRRInput {
1382 measurements: data,
1383 tolerance: None,
1384 };
1385 assert!(gage_rr_anova(&input).is_err());
1386 }
1387
1388 #[test]
1389 fn anova_status_matches_percent_grr() {
1390 let data = aiag_reference_data();
1391 let input = GageRRInput {
1392 measurements: data,
1393 tolerance: None,
1394 };
1395 let result = gage_rr_anova(&input).expect("should compute");
1396
1397 match result.status {
1398 GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1399 GrrStatus::Marginal => {
1400 assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1401 }
1402 GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1403 }
1404 }
1405
1406 #[test]
1407 fn anova_p_values_bounded() {
1408 let data = aiag_reference_data();
1409 let input = GageRRInput {
1410 measurements: data,
1411 tolerance: None,
1412 };
1413 let result = gage_rr_anova(&input).expect("should compute");
1414
1415 for row in &result.anova_table.rows {
1416 if let Some(p) = row.p_value {
1417 assert!(
1418 (0.0..=1.0).contains(&p),
1419 "p-value for {} out of range: {}",
1420 row.source,
1421 p
1422 );
1423 }
1424 if let Some(f) = row.f_value {
1425 assert!(
1426 f >= 0.0,
1427 "F-value for {} should be non-negative: {}",
1428 row.source,
1429 f
1430 );
1431 }
1432 }
1433 }
1434
1435 #[test]
1440 fn both_methods_detect_same_dominant_variation() {
1441 let data = aiag_reference_data();
1442 let input_xr = GageRRInput {
1443 measurements: data.clone(),
1444 tolerance: None,
1445 };
1446 let input_anova = GageRRInput {
1447 measurements: data,
1448 tolerance: None,
1449 };
1450
1451 let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1452 let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1453
1454 let xr_pv_dominant = xr.pv > xr.grr;
1456 let anova_pv_dominant = anova.pv > anova.grr;
1457 assert_eq!(
1458 xr_pv_dominant, anova_pv_dominant,
1459 "X̄-R and ANOVA should agree on PV vs GRR dominance"
1460 );
1461 }
1462}
1463
1464#[cfg(test)]
1472mod statsmodels_reference {
1473 use super::*;
1474
1475 fn close(got: f64, want: f64, rel: f64, what: &str) {
1476 assert!(
1477 (got - want).abs() <= rel * want.abs().max(1e-300),
1478 "{what}: got {got:e}, reference {want:e}"
1479 );
1480 }
1481
1482 fn row<'a>(r: &'a GageRRAnovaResult, source: &str) -> &'a AnovaRow {
1483 r.anova_table
1484 .rows
1485 .iter()
1486 .find(|row| row.source == source)
1487 .unwrap_or_else(|| panic!("no {source} row"))
1488 }
1489
1490 const WITH_INTERACTION: [[[f64; 3]; 3]; 5] = [
1492 [
1493 [9.175, 9.464, 9.278],
1494 [10.431, 10.411, 10.349],
1495 [9.97, 10.326, 10.414],
1496 ],
1497 [
1498 [10.372, 10.076, 10.265],
1499 [10.461, 10.492, 10.828],
1500 [10.447, 10.587, 10.752],
1501 ],
1502 [
1503 [9.525, 9.61, 9.65],
1504 [9.866, 9.634, 9.868],
1505 [8.981, 8.458, 8.891],
1506 ],
1507 [
1508 [8.226, 8.089, 8.564],
1509 [9.054, 8.701, 8.93],
1510 [7.401, 7.263, 7.42],
1511 ],
1512 [
1513 [8.85, 8.982, 9.121],
1514 [8.368, 8.527, 8.407],
1515 [8.219, 7.982, 8.092],
1516 ],
1517 ];
1518
1519 const NO_INTERACTION: [[[f64; 3]; 3]; 5] = [
1521 [
1522 [9.912, 9.796, 10.139],
1523 [10.298, 10.073, 10.272],
1524 [9.734, 9.604, 9.55],
1525 ],
1526 [
1527 [12.726, 12.881, 12.678],
1528 [12.936, 13.139, 13.199],
1529 [12.415, 12.568, 12.657],
1530 ],
1531 [
1532 [12.412, 12.303, 12.512],
1533 [12.844, 12.997, 12.568],
1534 [12.08, 12.049, 11.887],
1535 ],
1536 [
1537 [9.002, 9.074, 8.846],
1538 [9.579, 9.477, 9.442],
1539 [8.802, 8.901, 8.49],
1540 ],
1541 [
1542 [9.515, 9.513, 9.086],
1543 [9.817, 9.709, 9.895],
1544 [9.075, 9.151, 9.216],
1545 ],
1546 ];
1547
1548 fn study(data: &[[[f64; 3]; 3]; 5]) -> GageRRAnovaResult {
1549 let measurements = data
1550 .iter()
1551 .map(|part| part.iter().map(|trials| trials.to_vec()).collect())
1552 .collect();
1553 gage_rr_anova(&GageRRInput {
1554 measurements,
1555 tolerance: None,
1556 })
1557 .expect("a valid balanced study")
1558 }
1559
1560 #[test]
1561 fn a_significant_interaction_keeps_its_own_row_and_tests_against_it() {
1562 let r = study(&WITH_INTERACTION);
1563 assert!(r.interaction_significant && !r.interaction_pooled);
1564 for (source, df, ss, f, p) in [
1565 (
1566 "Part",
1567 4.0,
1568 33.57309724444448,
1569 Some(11.06391548380456),
1570 Some(0.0024102170377080408),
1571 ),
1572 (
1573 "Operator",
1574 2.0,
1575 2.786838044444448,
1576 Some(1.8367885671249295),
1577 Some(0.22056881947791407),
1578 ),
1579 (
1580 "Part×Operator",
1581 8.0,
1582 6.068935955555522,
1583 Some(28.125305658219634),
1584 Some(6.579056051488694e-12),
1585 ),
1586 ("Repeatability", 30.0, 0.809182666666665, None, None),
1587 ] {
1588 let row = row(&r, source);
1589 assert_eq!(row.df, df, "{source} df");
1590 close(row.ss, ss, 1e-9, &format!("{source} SS"));
1591 close(row.ms, ss / df, 1e-9, &format!("{source} MS"));
1592 assert_eq!(row.f_value.is_some(), f.is_some(), "{source} F present");
1593 if let (Some(got), Some(want)) = (row.f_value, f) {
1594 close(got, want, 1e-9, &format!("{source} F"));
1595 }
1596 if let (Some(got), Some(want)) = (row.p_value, p) {
1597 close(got, want, 1e-8, &format!("{source} p"));
1598 }
1599 }
1600 let c = &r.variance_components;
1601 close(c.part, 0.848295257407409, 1e-9, "sigma2 part");
1602 close(c.operator, 0.04232013518518559, 1e-9, "sigma2 operator");
1603 close(
1604 c.interaction,
1605 0.2438814129629616,
1606 1e-9,
1607 "sigma2 interaction",
1608 );
1609 close(
1610 c.repeatability,
1611 0.026972755555555502,
1612 1e-9,
1613 "sigma2 repeatability",
1614 );
1615 close(
1616 c.reproducibility,
1617 0.2862015481481472,
1618 1e-9,
1619 "sigma2 reproducibility",
1620 );
1621 close(c.total, 1.1614695611111117, 1e-9, "sigma2 total");
1622 close(r.ev, 0.16423384412341904, 1e-9, "EV");
1623 close(r.av, 0.5349780819324724, 1e-9, "AV");
1624 close(r.grr, 0.5596197849466213, 1e-9, "GRR");
1625 close(r.pv, 0.9210294552333324, 1e-9, "PV");
1626 close(r.tv, 1.0777149721104888, 1e-9, "TV");
1627 close(r.percent_grr, 51.926511130370415, 1e-9, "%GRR");
1628 assert_eq!(r.ndc, 2);
1629 }
1630
1631 #[test]
1632 fn a_negligible_interaction_is_pooled_and_the_tail_p_values_survive() {
1633 let r = study(&NO_INTERACTION);
1634 assert!(!r.interaction_significant && r.interaction_pooled);
1635 assert!(r
1636 .anova_table
1637 .rows
1638 .iter()
1639 .all(|row| row.source != "Part×Operator"));
1640 let part = row(&r, "Part");
1641 close(part.f_value.expect("F"), 1422.410771043164, 1e-9, "Part F");
1642 close(
1644 part.p_value.expect("p"),
1645 8.177666854037245e-41,
1646 1e-8,
1647 "Part p",
1648 );
1649 let operator = row(&r, "Operator");
1650 close(
1651 operator.f_value.expect("F"),
1652 88.62224500212629,
1653 1e-9,
1654 "Operator F",
1655 );
1656 close(
1657 operator.p_value.expect("p"),
1658 4.8998131777748826e-15,
1659 1e-8,
1660 "Operator p",
1661 );
1662 let error = row(&r, "Repeatability");
1663 assert_eq!(error.df, 38.0);
1664 close(
1665 error.ss,
1666 0.11419240000000108 + 0.6162759999999995,
1667 1e-9,
1668 "pooled SS",
1669 );
1670 let c = &r.variance_components;
1671 close(c.part, 3.0359521978557518, 1e-9, "sigma2 part");
1672 close(c.operator, 0.11228996686159866, 1e-9, "sigma2 operator");
1673 assert_eq!(c.interaction, 0.0);
1674 close(
1675 c.repeatability,
1676 0.01922285263157896,
1677 1e-9,
1678 "sigma2 repeatability",
1679 );
1680 close(c.total, 3.1674650173489294, 1e-9, "sigma2 total");
1681 close(r.ev, 0.1386465024137968, 1e-9, "EV");
1682 close(r.av, 0.3350969514358474, 1e-9, "AV");
1683 close(r.grr, 0.3626469626140244, 1e-9, "GRR");
1684 close(r.pv, 1.7423984038834952, 1e-9, "PV");
1685 close(r.tv, 1.7797373450453102, 1e-9, "TV");
1686 close(r.percent_grr, 20.37643159107794, 1e-9, "%GRR");
1687 assert_eq!(r.ndc, 6);
1688 }
1689}
1690
1691#[cfg(test)]
1702mod k_factor_definitions {
1703 use super::*;
1704
1705 fn range_moments(n: usize) -> (f64, f64) {
1708 let (h, lo, steps) = (0.02_f64, -7.0_f64, 700_usize);
1709 let phi: Vec<f64> = (0..steps)
1710 .map(|i| special::standard_normal_pdf(lo + (i as f64 + 0.5) * h))
1711 .collect();
1712 let cdf: Vec<f64> = (0..steps)
1713 .map(|i| special::standard_normal_cdf(lo + (i as f64 + 0.5) * h))
1714 .collect();
1715 let (mut m1, mut m2) = (0.0, 0.0);
1716 let c = (n * (n - 1)) as f64;
1717 for i in 0..steps {
1718 for j in i + 1..steps {
1719 let density = c * phi[i] * phi[j] * (cdf[j] - cdf[i]).powi(n as i32 - 2);
1720 let r = (j - i) as f64 * h;
1721 m1 += r * density;
1722 m2 += r * r * density;
1723 }
1724 }
1725 (m1 * h * h, m2 * h * h)
1726 }
1727
1728 #[test]
1729 fn the_k_tables_are_one_over_the_range_moments_of_the_normal() {
1730 let tol = 6e-5;
1732 for (i, &k1) in K1.iter().enumerate() {
1733 let (mean, _) = range_moments(i + 2);
1734 assert!(
1735 (k1 - 1.0 / mean).abs() <= tol,
1736 "K1[{} trials] = {k1}, 1/d2 = {}",
1737 i + 2,
1738 1.0 / mean
1739 );
1740 }
1741 for (i, &k2) in K2.iter().enumerate() {
1742 let (_, second) = range_moments(i + 2);
1743 assert!(
1744 (k2 - 1.0 / second.sqrt()).abs() <= tol,
1745 "K2[{} operators] = {k2}, 1/d2* = {}",
1746 i + 2,
1747 1.0 / second.sqrt()
1748 );
1749 }
1750 for (i, &k3) in K3.iter().enumerate() {
1751 let (_, second) = range_moments(i + 2);
1752 assert!(
1753 (k3 - 1.0 / second.sqrt()).abs() <= tol,
1754 "K3[{} parts] = {k3}, 1/d2* = {}",
1755 i + 2,
1756 1.0 / second.sqrt()
1757 );
1758 }
1759 }
1760}