Skip to main content

dsfb_robotics/datasets/
cwru.rs

1//! CWRU Bearing Data Center adapter.
2//!
3//! **Provenance.** Case Western Reserve University Bearing Data Center,
4//! <https://engineering.case.edu/bearingdatacenter>. A 2 HP Reliance
5//! Electric motor rig with seeded faults on the drive-end and
6//! fan-end bearings at four fault diameters (0.007, 0.014, 0.021,
7//! 0.028 in) and four loads (0–3 HP). Vibration data captured at 12
8//! kHz and 48 kHz.
9//!
10//! **Residual DSFB structures.** The adapter assumes the caller has
11//! already extracted the **BPFI (ball-pass frequency, inner race)
12//! envelope-spectrum amplitude** at each timestep — this is the
13//! standard bearing-PHM scalar that threshold alarms compare against
14//! a fixed cutoff. DSFB reads the *trajectory* of this amplitude:
15//!
16//! ```text
17//! r(k) = |E_{BPFI}(k) − μ_healthy|
18//! ```
19//!
20//! where `μ_healthy` is the mean BPFI amplitude over a healthy-window
21//! calibration slice. The incumbent threshold alarm discards this
22//! trajectory — it keeps only a boolean "above / below cutoff" each
23//! sample. DSFB structures the residual trajectory into a grammar.
24
25use crate::math;
26
27/// Per-timestep envelope-spectrum amplitude sample.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct Sample {
30    /// Envelope-spectrum amplitude at the BPFI harmonics at time k.
31    ///
32    /// Caller computes this via standard bearing-envelope-analysis
33    /// signal processing (Hilbert-envelope demodulation → band-pass
34    /// around BPFI harmonics → RMS). This adapter does not care how
35    /// the amplitude was computed — only that it is a scalar
36    /// representative of bearing-inner-race fault energy.
37    pub bpfi_amplitude: f64,
38}
39
40/// Calibrated BPFI baseline.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Baseline {
43    /// Mean BPFI amplitude over the healthy calibration window.
44    pub mu_healthy: f64,
45}
46
47impl Baseline {
48    /// Calibrate the baseline from a healthy-window slice.
49    ///
50    /// Returns `None` if the slice has no finite samples.
51    #[must_use]
52    pub fn from_healthy(healthy: &[f64]) -> Option<Self> {
53        debug_assert!(healthy.len() <= 1_000_000, "healthy window unreasonably large");
54        let mu = math::finite_mean(healthy)?;
55        debug_assert!(mu.is_finite(), "finite_mean returns Some only for finite values");
56        Some(Self { mu_healthy: mu })
57    }
58
59    /// `|amplitude − μ_healthy|` for one sample.
60    #[inline]
61    #[must_use]
62    pub fn residual(&self, sample: Sample) -> f64 {
63        debug_assert!(self.mu_healthy.is_finite(), "calibrated baseline must be finite");
64        let r = math::abs_f64(sample.bpfi_amplitude - self.mu_healthy);
65        debug_assert!(r >= 0.0 || !r.is_finite(), "residual is non-negative or non-finite");
66        r
67    }
68}
69
70/// Stream a per-sample amplitude slice into a residual buffer given a
71/// baseline. Returns the number of residuals written.
72pub fn residual_stream(samples: &[Sample], baseline: Baseline, out: &mut [f64]) -> usize {
73    debug_assert!(baseline.mu_healthy.is_finite(), "baseline must be calibrated");
74    let n = samples.len().min(out.len());
75    debug_assert!(n <= out.len() && n <= samples.len(), "n respects both bounds");
76    let mut i = 0_usize;
77    while i < n {
78        out[i] = baseline.residual(samples[i]);
79        i += 1;
80    }
81    debug_assert_eq!(i, n, "loop must run exactly n iterations");
82    n
83}
84
85/// Healthy-window calibration slice for smoke-test reproductions.
86pub const HEALTHY_FIXTURE: [f64; 6] = [0.10, 0.11, 0.09, 0.10, 0.10, 0.11];
87
88/// Faulted-sample trajectory for smoke-test reproductions.
89pub const FAULTED_FIXTURE: [Sample; 5] = [
90    Sample { bpfi_amplitude: 0.10 },
91    Sample { bpfi_amplitude: 0.12 },
92    Sample { bpfi_amplitude: 0.20 },
93    Sample { bpfi_amplitude: 0.35 },
94    Sample { bpfi_amplitude: 0.15 },
95];
96
97/// Calibrate from [`HEALTHY_FIXTURE`] and stream [`FAULTED_FIXTURE`]
98/// residuals into `out`. Returns the number written. Used by
99/// `paper-lock --fixture`.
100pub fn fixture_residuals(out: &mut [f64]) -> usize {
101    let Some(baseline) = Baseline::from_healthy(&HEALTHY_FIXTURE) else {
102        debug_assert!(false, "HEALTHY_FIXTURE is non-empty + finite — calibration must succeed");
103        return 0;
104    };
105    residual_stream(&FAULTED_FIXTURE, baseline, out)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn baseline_from_empty_is_none() {
114        assert!(Baseline::from_healthy(&[]).is_none());
115    }
116
117    #[test]
118    fn healthy_window_gives_near_zero_residual_for_nominal_sample() {
119        let baseline = Baseline::from_healthy(&HEALTHY_FIXTURE).expect("finite");
120        let r = baseline.residual(Sample { bpfi_amplitude: 0.10 });
121        assert!(r < 0.02);
122    }
123
124    #[test]
125    fn faulted_sample_has_elevated_residual() {
126        let baseline = Baseline::from_healthy(&HEALTHY_FIXTURE).expect("finite");
127        let r = baseline.residual(Sample { bpfi_amplitude: 0.35 });
128        assert!(r > 0.20);
129    }
130
131    #[test]
132    fn stream_trajectory_peaks_at_fault_sample() {
133        let baseline = Baseline::from_healthy(&HEALTHY_FIXTURE).expect("finite");
134        let mut out = [0.0_f64; 5];
135        let n = residual_stream(&FAULTED_FIXTURE, baseline, &mut out);
136        assert_eq!(n, 5);
137        let peak = out.iter().copied().fold(0.0_f64, f64::max);
138        assert!((peak - out[3]).abs() < 1e-12, "peak should be index 3");
139    }
140}