mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
fix(rvcsi): scale-relative baseline-drift thresholds + ESP32 end-to-end validation
BaselineDriftDetector compared `mean_amplitude` against its EWMA baseline with *absolute* thresholds (anomaly 1.0, drift 0.15). Fine for the synthetic unit tests (amplitudes ~1.0), but raw ESP32 CSI is int8 I/Q with amplitudes up to ~128, so window-to-window RMS distance is routinely 5-50 >> 1.0 and AnomalyDetected fired on ~96% of windows (319/331 on a real node-1 capture). Drift is now `||current - baseline||2 / ||baseline||2` (a fraction, with an eps floor that falls back to absolute for a degenerate near-zero baseline), so one tuning is valid across raw-int8 ESP32, int16-scaled Nexmon, and baseline-subtracted streams. AnomalyDetected drops to 40/331 on the same data; the existing detector tests still pass (their explicit configs are valid relative thresholds too); added baseline_drift_is_scale_invariant_ no_anomaly_storm. rvcsi-events 18 -> 19 tests; 162 rvcsi tests, 0 failures, clippy-clean. Surfaced by an end-to-end test against real ESP32 CSI on COM7: the device (ESP32-S3, node 1, ADR-018 firmware, WiFi "ruv.net" ch5 RSSI -39, CSI cb only because nothing listens at .156). rvcsi has no ESP32 adapter yet, so a 7,000-frame node-1 recording was transcoded to .rvcsi via the new scripts/esp32_jsonl_to_rvcsi.py (stand-in for `record --source esp32-jsonl`) and run through `rvcsi inspect`/`replay`/`calibrate`/`events` end-to-end. ADR-095 D13 and ADR-096 sections 2.1/5 updated; CHANGELOG entry added; rvcsi-adapter-esp32 (live serial/UDP source) noted as a follow-up. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -425,15 +425,24 @@ impl EventDetector for QualityDetector {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tunables for [`BaselineDriftDetector`].
|
||||
///
|
||||
/// `drift_threshold` and `anomaly_threshold` are **relative** — they are
|
||||
/// fractions of the running baseline's RMS magnitude, not absolute amplitude
|
||||
/// units. This keeps the detector source-agnostic: ESP32 emits raw `int8` I/Q
|
||||
/// (amplitudes up to ~128), Nexmon emits `int16`-scaled CSI, and a
|
||||
/// baseline-subtracted pipeline emits values near zero — an *absolute* threshold
|
||||
/// can only ever be right for one of them, a *relative* one is right for all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BaselineDriftConfig {
|
||||
/// Per-window drift `||mean_amplitude - baseline||_2 / sqrt(n)` above this
|
||||
/// for `drift_windows` windows in a row triggers [`CsiEventKind::BaselineChanged`].
|
||||
/// Relative per-window drift `||mean_amplitude - baseline||_2 / ||baseline||_2`
|
||||
/// above this for `drift_windows` windows in a row triggers
|
||||
/// [`CsiEventKind::BaselineChanged`]. `0.15` ≈ "the room moved ~15 %".
|
||||
pub drift_threshold: f32,
|
||||
/// Consecutive drifting windows before [`CsiEventKind::BaselineChanged`] fires.
|
||||
pub drift_windows: u32,
|
||||
/// A single window with drift above this (much larger) value triggers
|
||||
/// [`CsiEventKind::AnomalyDetected`].
|
||||
/// A single window whose relative drift exceeds this (much larger) value
|
||||
/// triggers [`CsiEventKind::AnomalyDetected`]. `1.0` ≈ "this window differs
|
||||
/// from the baseline by as much as the baseline's own magnitude".
|
||||
pub anomaly_threshold: f32,
|
||||
/// EWMA smoothing factor for the running baseline (`baseline = a*current + (1-a)*baseline`).
|
||||
pub ewma_alpha: f32,
|
||||
@@ -503,6 +512,37 @@ impl BaselineDriftDetector {
|
||||
(sq.sqrt() / (n as f64).sqrt()) as f32
|
||||
}
|
||||
|
||||
/// Root-mean-square magnitude of a vector (`0.0` for an empty one).
|
||||
fn rms(v: &[f32]) -> f32 {
|
||||
let n = v.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let sq: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
|
||||
(sq.sqrt() / (n as f64).sqrt()) as f32
|
||||
}
|
||||
|
||||
/// Drift of `current` from `baseline` as a fraction of the baseline's RMS
|
||||
/// magnitude. Source-agnostic (see [`BaselineDriftConfig`]). The `eps` floor
|
||||
/// keeps a near-zero baseline (e.g. just after a baseline-subtraction stage)
|
||||
/// from blowing the ratio up to infinity — when the baseline carries
|
||||
/// essentially no energy there is nothing to drift *relative to*, so the
|
||||
/// detector treats it as quiet.
|
||||
fn relative_drift(current: &[f32], baseline: &[f32]) -> f32 {
|
||||
let abs_drift = Self::rms_distance(current, baseline);
|
||||
let baseline_rms = Self::rms(baseline);
|
||||
// 1e-3 is well below any real CSI amplitude scale (ESP32 int8 ⇒ O(10),
|
||||
// Nexmon int16 ⇒ O(100s)) yet above f32 noise.
|
||||
const EPS: f32 = 1e-3;
|
||||
if baseline_rms <= EPS {
|
||||
// Degenerate baseline: fall back to an absolute reading so a sudden
|
||||
// jump away from a flat-zero baseline still registers.
|
||||
abs_drift
|
||||
} else {
|
||||
abs_drift / baseline_rms
|
||||
}
|
||||
}
|
||||
|
||||
fn update_ewma(&mut self, current: &[f32]) {
|
||||
match &mut self.baseline {
|
||||
None => self.baseline = Some(current.to_vec()),
|
||||
@@ -537,7 +577,7 @@ impl EventDetector for BaselineDriftDetector {
|
||||
Some(b) => b.clone(),
|
||||
};
|
||||
|
||||
let drift = Self::rms_distance(current, &baseline);
|
||||
let drift = Self::relative_drift(current, &baseline);
|
||||
let mut out = Vec::new();
|
||||
|
||||
if drift > self.cfg.anomaly_threshold {
|
||||
@@ -767,6 +807,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_drift_is_scale_invariant_no_anomaly_storm() {
|
||||
// Regression for the ESP32 live-capture finding: raw int8 CSI amplitudes
|
||||
// are O(10–128), so an *absolute* anomaly_threshold of 1.0 fired on
|
||||
// essentially every window. With a *relative* threshold a few-percent
|
||||
// wobble around a large baseline must stay quiet.
|
||||
let g = IdGenerator::new();
|
||||
let mut d = BaselineDriftDetector::new(); // defaults: drift 0.15, anomaly 1.0
|
||||
// A realistic ESP32-ish window: two big "DC/pilot" subcarriers plus a
|
||||
// band of small data subcarriers; ±3 % jitter window to window.
|
||||
let base: Vec<f32> = {
|
||||
let mut v = vec![128.0, 110.0];
|
||||
v.extend(std::iter::repeat(15.0).take(68));
|
||||
v
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
for k in 0..40u64 {
|
||||
// deterministic small wobble in [-0.03, +0.03] * value
|
||||
let f = 1.0 + 0.03 * (((k * 2654435761) % 7) as f32 / 3.0 - 1.0);
|
||||
let w: Vec<f32> = base.iter().map(|x| x * f).collect();
|
||||
events.extend(d.on_window(&window_amp(k, (k + 1) * 1_000, w), &g));
|
||||
}
|
||||
assert!(
|
||||
!events.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected),
|
||||
"a ±3% wobble around a large baseline must not be an anomaly; got {events:?}"
|
||||
);
|
||||
// A 5x jump on the data subcarriers (a person walks in) *is* an anomaly.
|
||||
let spike: Vec<f32> = {
|
||||
let mut v = vec![128.0, 110.0];
|
||||
v.extend(std::iter::repeat(75.0).take(68));
|
||||
v
|
||||
};
|
||||
let ev = d.on_window(&window_amp(99, 100_000, spike), &g);
|
||||
assert!(
|
||||
ev.iter().any(|e| e.kind == CsiEventKind::AnomalyDetected),
|
||||
"a 5x jump on the data band should register; got {ev:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_drift_resets_on_subcarrier_change() {
|
||||
let g = IdGenerator::new();
|
||||
|
||||
Reference in New Issue
Block a user