dsfb_robotics/datasets/
ims.rs1#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Sample {
28 pub health_index: f64,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct Baseline {
37 pub hi_nominal: f64,
40}
41
42impl Baseline {
43 #[must_use]
45 pub fn from_healthy(healthy: &[f64]) -> Option<Self> {
46 debug_assert!(healthy.len() <= 1_000_000, "calibration window unreasonably large");
47 let mu = crate::math::finite_mean(healthy)?;
48 debug_assert!(mu.is_finite(), "finite_mean returns Some only for finite values");
49 Some(Self { hi_nominal: mu })
50 }
51
52 #[inline]
54 #[must_use]
55 pub fn residual(&self, sample: Sample) -> f64 {
56 debug_assert!(self.hi_nominal.is_finite(), "calibrated nominal must be finite");
57 sample.health_index - self.hi_nominal
58 }
59
60 #[inline]
63 #[must_use]
64 pub fn residual_norm(&self, sample: Sample) -> f64 {
65 debug_assert!(self.hi_nominal.is_finite(), "calibrated nominal must be finite");
66 let r = crate::math::abs_f64(self.residual(sample));
67 debug_assert!(r >= 0.0 || !r.is_finite(), "norm is non-negative or non-finite");
68 r
69 }
70}
71
72pub fn residual_stream(samples: &[Sample], baseline: Baseline, out: &mut [f64]) -> usize {
74 debug_assert!(baseline.hi_nominal.is_finite(), "baseline must be calibrated");
75 let n = samples.len().min(out.len());
76 debug_assert!(n <= out.len() && n <= samples.len(), "n respects both bounds");
77 let mut i = 0_usize;
78 while i < n {
79 out[i] = baseline.residual_norm(samples[i]);
80 i += 1;
81 }
82 debug_assert_eq!(i, n, "loop must run exactly n iterations");
83 n
84}
85
86pub const HEALTHY_FIXTURE: [f64; 5] = [0.05, 0.06, 0.05, 0.05, 0.06];
88
89pub const RUN_TO_FAILURE_FIXTURE: [Sample; 6] = [
91 Sample { health_index: 0.05 },
92 Sample { health_index: 0.06 },
93 Sample { health_index: 0.08 },
94 Sample { health_index: 0.12 },
95 Sample { health_index: 0.20 },
96 Sample { health_index: 0.35 },
97];
98
99pub fn fixture_residuals(out: &mut [f64]) -> usize {
103 let Some(baseline) = Baseline::from_healthy(&HEALTHY_FIXTURE) else {
104 debug_assert!(false, "HEALTHY_FIXTURE is non-empty + finite — calibration must succeed");
105 return 0;
106 };
107 residual_stream(&RUN_TO_FAILURE_FIXTURE, baseline, out)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn signed_residual_preserves_direction() {
116 let b = Baseline::from_healthy(&HEALTHY_FIXTURE).expect("finite");
117 let above = Sample { health_index: 0.10 };
118 let below = Sample { health_index: 0.01 };
119 assert!(b.residual(above) > 0.0);
120 assert!(b.residual(below) < 0.0);
121 }
122
123 #[test]
124 fn magnitude_residual_is_non_negative() {
125 let b = Baseline::from_healthy(&HEALTHY_FIXTURE).expect("finite");
126 for h in [0.01, 0.05, 0.10, 0.35] {
127 assert!(b.residual_norm(Sample { health_index: h }) >= 0.0);
128 }
129 }
130
131 #[test]
132 fn run_to_failure_residuals_are_monotone() {
133 let b = Baseline::from_healthy(&HEALTHY_FIXTURE).expect("finite");
134 let mut out = [0.0_f64; 6];
135 let n = residual_stream(&RUN_TO_FAILURE_FIXTURE, b, &mut out);
136 assert_eq!(n, 6);
137 for i in 1..n {
139 assert!(out[i] >= out[i - 1], "expected non-decreasing, got {out:?}");
140 }
141 }
142}