From ed9b4684e0a398caa1f0bc6c9f47c72e118eeaa0 Mon Sep 17 00:00:00 2001 From: Stuart Kerr Date: 2026年6月11日 13:50:13 -0400 Subject: [PATCH] =?UTF-8?q?fix(calibration):=20ADR-151=20presence=20statis?= =?UTF-8?q?tic=20=E2=80=94=20per-frame=20p90|z|=20replaces=20floored=20med?= =?UTF-8?q?ian|z|?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enrollment presence gate computed presence_z as the mean over frames of the per-frame MEDIAN of |z| across all subcarriers. A person perturbs only a minority of subcarriers, so that median floors at median(|N(0,1)|) ≈ 0.674 and the gates (empty_max_z=1.0, min_presence_z=1.5) were unreachable: on real ESP32-C6 HE20 captures an occupied room measured 0.74–0.75 — the 'enroll accepts an occupied room as empty' flatline. - CalibrationDeviationScore gains amplitude_z_p90 (the upper tail is where the body's multipath perturbation lives); the median stays as a secondary diagnostic. Bench separation on real node-8 captures vs a same-epoch baseline: empty 1.27–1.33 vs person-moving 2.26–2.29. - AnchorRecorder gates presence on p90; gates re-derived from the bench: EMPTY_MAX_Z = 1.6, MIN_PRESENCE_Z = 1.9. - Unsanitised phase excluded: scores carry phase_usable (baseline median Von Mises dispersion ≤ 0.5); when false the phase channel is dropped from motion_flagged and from the recorder's |Δφ| motion term (the CLI UDP path skips PhaseSanitizer; its baseline dispersion ≈ 0.965 made phase drift ≈ π/2 noise that inflated motion rates to ~60–70 % on a still room). - Tests: enrollment suite updated + p90/median-floor and phase-noise regression tests; full_loop sim models the measured empty z-scale (0.8). Co-Authored-By: claude-flow --- .../wifi-densepose-calibration/src/anchor.rs | 4 +- .../src/enrollment.rs | 142 +++++++++++++++--- .../tests/full_loop.rs | 48 ++++-- v2/crates/wifi-densepose-cli/src/calibrate.rs | 5 +- v2/crates/wifi-densepose-signal/src/lib.rs | 2 +- .../src/ruvsense/calibration.rs | 88 ++++++++++- 6 files changed, 250 insertions(+), 39 deletions(-) diff --git a/v2/crates/wifi-densepose-calibration/src/anchor.rs b/v2/crates/wifi-densepose-calibration/src/anchor.rs index ae91ecfb31..667cc48286 100644 --- a/v2/crates/wifi-densepose-calibration/src/anchor.rs +++ b/v2/crates/wifi-densepose-calibration/src/anchor.rs @@ -127,7 +127,9 @@ impl AnchorLabel { /// Quality assessment of a captured anchor (from the enrollment quality gate). #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct AnchorQuality { - /// Median amplitude z-score vs the empty-room baseline (presence strength). + /// Mean per-frame p90 amplitude z-score vs the empty-room baseline + /// (presence strength — ADR-151; the median floors at ~0.674 with a + /// person present and is kept only as a recorder diagnostic). pub presence_z: f32, /// Fraction of frames flagged as motion. pub motion_rate: f32, diff --git a/v2/crates/wifi-densepose-calibration/src/enrollment.rs b/v2/crates/wifi-densepose-calibration/src/enrollment.rs index 813762d4ee..5581318876 100644 --- a/v2/crates/wifi-densepose-calibration/src/enrollment.rs +++ b/v2/crates/wifi-densepose-calibration/src/enrollment.rs @@ -8,8 +8,16 @@ //! //! Quality is measured against the ADR-135 empty-room baseline via //! [`wifi_densepose_signal::BaselineCalibration::deviation`], whose -//! `CalibrationDeviationScore` gives a per-frame amplitude z-score (presence -//! strength). +//! `CalibrationDeviationScore` gives per-frame amplitude z statistics +//! (presence strength). +//! +//! **Presence is the per-frame p90 of |z|, NOT the median** (ADR-151 +//! presence-flatline bench, 2026年06月11日): a person perturbs a *minority* of +//! subcarriers strongly, so the median of |z| over all bins floors at +//! `median(|N(0,1)|) ≈ 0.674` and never reaches presence gates — on real +//! ESP32-C6 HE20 captures the median read empty ≈ 0.40–0.42 vs person-moving +//! ≈ 0.74–0.75 (both below every gate), while the p90 separated cleanly: +//! empty ≈ 1.27–1.33 vs person-moving ≈ 2.26–2.29. //! //! **Motion is NOT taken from the score's `motion_flagged`** (ADR-152 finding, //! "z-band squeeze"): that flag fires on `amplitude_z_median> 2.0` — deviation @@ -18,19 +26,41 @@ //! would be rejected as "too much motion". Instead the recorder derives motion //! from the frame-to-frame *change* in the deviation series (|Δz| and |Δφ|), //! which is presence-independent: a still strong reflector has high z but a -//! flat z-series; a moving person has a jittery one. +//! flat z-series; a moving person has a jittery one. The |Δφ| term only +//! counts when the score reports `phase_usable` — an unsanitised phase +//! channel (no `PhaseSanitizer` in the path) makes |Δφ| pure noise that +//! inflates the motion rate to ~60–70 % on a still room (same bench). use wifi_densepose_core::types::CsiFrame; use wifi_densepose_signal::{BaselineCalibration, CalibrationDeviationScore}; use crate::anchor::{Anchor, AnchorLabel, AnchorQuality}; +/// Default `empty` gate: maximum mean per-frame p90|z| for an empty room. +/// +/// Re-derived from the ADR-151 presence-flatline bench (2026年06月11日, real +/// ESP32-C6 HE20 node-8 captures replayed against a same-epoch baseline): +/// empty segments measured p90|z| ≈ 1.27–1.33; a person moving on the plate +/// measured ≈ 2.26–2.29. 1.6 sits above the empty band with margin while +/// rejecting the occupied band decisively. (The old gate of 1.0 was tuned to +/// the median statistic, which floored at ≈ 0.674 and could never separate.) +pub const EMPTY_MAX_Z: f32 = 1.6; + +/// Default presence gate: minimum mean per-frame p90|z| to call a person +/// present. Same bench as [`EMPTY_MAX_Z`]: occupied ≈ 2.26–2.29, so 1.9 +/// detects with margin while staying above the empty band (≤ 1.33). (The old +/// gate of 1.5 was unreachable by the median statistic — observed occupied +/// values were ≈ 0.74–0.75.) +pub const MIN_PRESENCE_Z: f32 = 1.9; + /// Thresholds for accepting an anchor. #[derive(Debug, Clone, Copy)] pub struct AnchorQualityGate { - /// Minimum mean amplitude z-score to consider a person present. + /// Minimum mean per-frame p90 amplitude z-score to consider a person + /// present. pub min_presence_z: f32, - /// For `empty`: maximum mean z-score to consider the room truly empty. + /// For `empty`: maximum mean per-frame p90 z-score to consider the room + /// truly empty. pub empty_max_z: f32, /// For "still" anchors: maximum motion-flag rate tolerated. pub max_still_motion: f32, @@ -43,8 +73,8 @@ pub struct AnchorQualityGate { impl Default for AnchorQualityGate { fn default() -> Self { Self { - min_presence_z: 1.5, - empty_max_z: 1.0, + min_presence_z: MIN_PRESENCE_Z, + empty_max_z: EMPTY_MAX_Z, max_still_motion: 0.6, min_move_motion: 0.3, min_frames: 60, @@ -126,10 +156,14 @@ pub const PHASE_DELTA_MOTION: f32 = std::f32::consts::PI / 6.0; /// Accumulates per-frame deviation statistics for a single anchor capture. pub struct AnchorRecorder { label: AnchorLabel, + /// Sum of per-frame p90|z| (the ADR-151 presence statistic). z_sum: f64, + /// Sum of per-frame median|z| (legacy statistic, kept as a secondary + /// diagnostic — see [`Self::presence_z_median`]). + z_median_sum: f64, motion_count: u32, frames: u32, - /// Previous frame's (amplitude_z_median, phase_drift_median) for the + /// Previous frame's (amplitude_z_p90, phase_drift_median) for the /// delta-based motion measure (ADR-152 z-band-squeeze fix). prev: Option<(f32, f32)>, } @@ -140,6 +174,7 @@ impl AnchorRecorder { Self { label, z_sum: 0.0, + z_median_sum: 0.0, motion_count: 0, frames: 0, prev: None, @@ -158,20 +193,27 @@ impl AnchorRecorder { /// Record a pre-computed deviation score (caller runs `baseline.deviation`). /// + /// Presence accumulates the per-frame **p90** of |z| (ADR-151 — the + /// median floors at ≈ 0.674 and cannot separate; see module docs). + /// /// Motion is derived from the frame-to-frame change of the deviation /// series, NOT from `score.motion_flagged` — the flag conflates presence /// strength with motion (z-band squeeze, see module docs / ADR-152). The - /// first frame of a capture is never motion (no predecessor). + /// |Δφ| term only counts when `score.phase_usable` (unsanitised phase is + /// noise, ADR-151 bench). The first frame of a capture is never motion + /// (no predecessor). pub fn record_score(&mut self, score: &CalibrationDeviationScore) { - let z = score.amplitude_z_median; + let z = score.amplitude_z_p90; let phase = score.phase_drift_median; if let Some((pz, pp)) = self.prev { - if (z - pz).abs()> Z_DELTA_MOTION || (phase - pp).abs()> PHASE_DELTA_MOTION { + let phase_motion = score.phase_usable && (phase - pp).abs()> PHASE_DELTA_MOTION; + if (z - pz).abs()> Z_DELTA_MOTION || phase_motion { self.motion_count += 1; } } self.prev = Some((z, phase)); self.z_sum += z as f64; + self.z_median_sum += score.amplitude_z_median as f64; self.frames += 1; } @@ -183,7 +225,7 @@ impl AnchorRecorder { } } - /// Mean presence z-score over the capture. + /// Mean presence z-score over the capture (per-frame p90|z|, ADR-151). pub fn presence_z(&self) -> f32 { if self.frames == 0 { 0.0 @@ -192,6 +234,17 @@ impl AnchorRecorder { } } + /// Mean of the legacy per-frame median|z| over the capture. Diagnostic + /// only — floors at ≈ 0.674 with a person present (ADR-151); never gate + /// on it. + pub fn presence_z_median(&self) -> f32 { + if self.frames == 0 { + 0.0 + } else { + (self.z_median_sum / self.frames as f64) as f32 + } + } + /// Fraction of frames flagged as motion. pub fn motion_rate(&self) -> f32 { if self.frames == 0 { @@ -226,15 +279,22 @@ mod tests { use super::*; /// Build a score the way `BaselineCalibration::deviation` actually would: - /// `motion_flagged` is DERIVED from z (z> 2.0 ⇒ flagged), never free. - /// The old tests mocked `(z=3.0, motion=false)` — a combination the real - /// producer can never emit, which is exactly how the z-band squeeze hid. + /// `motion_flagged` is DERIVED from the median (z_med> 2.0 ⇒ flagged), + /// never free. The old tests mocked `(z=3.0, motion=false)` — a + /// combination the real producer can never emit, which is exactly how the + /// z-band squeeze hid. `z` parameterises the p90 (the presence statistic); + /// the median rides along at the real-data ratio (×ばつ, ADR-151 bench: + /// person p90 ≈ 2.26 with median ≈ 0.75) so any code that accidentally + /// gates on the median fails loudly. fn score(z: f32) -> CalibrationDeviationScore { + let z_med = z / 3.0; CalibrationDeviationScore { - amplitude_z_median: z, + amplitude_z_median: z_med, + amplitude_z_p90: z, amplitude_z_max: z + 1.0, phase_drift_median: 0.05, - motion_flagged: z> 2.0, + phase_usable: true, + motion_flagged: z_med> 2.0, } } @@ -300,7 +360,7 @@ mod tests { // every frame — motion must be detected from the phase channel alone. let mut r = AnchorRecorder::new(AnchorLabel::LieDown); for i in 0..400 { - let mut s = score(1.8); + let mut s = score(2.5); s.phase_drift_median = if i % 2 == 0 { 0.0 } else { PHASE_DELTA_MOTION * 1.5 }; r.record_score(&s); } @@ -309,6 +369,52 @@ mod tests { assert!(reason.unwrap().contains("motion")); } + /// ADR-151: an UNSANITISED phase channel (`phase_usable = false`) must be + /// excluded from motion — the same swinging phase series as above, but + /// flagged unusable, must read still. (Bench: noise phase inflated the + /// motion rate to ~60–70 % on a still room.) + #[test] + fn unusable_phase_does_not_create_motion() { + let mut r = AnchorRecorder::new(AnchorLabel::LieDown); + for i in 0..400 { + let mut s = score(2.5); + s.phase_usable = false; + s.phase_drift_median = if i % 2 == 0 { 0.0 } else { PHASE_DELTA_MOTION * 1.5 }; + r.record_score(&s); + } + let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100); + assert!(a.quality.accepted, "noise phase counted as motion: {reason:?}"); + assert!(a.quality.motion_rate < 0.05); + } + + /// ADR-151 median-floor regression: presence must gate on the per-frame + /// p90, not the median. A real person reads p90 ≈ 2.26 with median ≈ 0.75 + /// (bench 2026-06-11); the median is below EVERY gate, so gating on it + /// flatlines presence detection. + #[test] + fn presence_uses_p90_not_median_floor() { + // score(2.26) ⇒ p90 = 2.26, median = 0.753 — the bench's occupied frame. + let mut r = AnchorRecorder::new(AnchorLabel::StandStill); + for _ in 0..400 { + r.record_score(&score(2.26)); + } + assert!((r.presence_z() - 2.26).abs() < 1e-3, "presence_z must be the p90 mean"); + assert!( + (r.presence_z_median() - 2.26 / 3.0).abs() < 1e-3, + "median kept as a secondary diagnostic" + ); + let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100); + assert!(a.quality.accepted, "bench occupied level must pass presence: {reason:?}"); + + // The same person fed to the EMPTY anchor must be rejected... + let (occupied, reason) = run_still(AnchorLabel::Empty, 2.26, 400); + assert!(!occupied.quality.accepted, "occupied room accepted as empty"); + assert!(reason.unwrap().contains("not empty")); + // ...while the bench's empty band still passes. + let (empty, reason) = run_still(AnchorLabel::Empty, 1.33, 400); + assert!(empty.quality.accepted, "bench empty level rejected: {reason:?}"); + } + #[test] fn empty_anchor_rejects_when_occupied() { let (occupied, reason) = run_still(AnchorLabel::Empty, 3.0, 400); diff --git a/v2/crates/wifi-densepose-calibration/tests/full_loop.rs b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs index e518ddaaba..3c0c60a089 100644 --- a/v2/crates/wifi-densepose-calibration/tests/full_loop.rs +++ b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs @@ -68,6 +68,17 @@ const N_SC: usize = 52; const FS_HZ: f32 = 20.0; /// Complex-noise std per quadrature ⇒ amplitude noise std ≈ NOISE_STD. const NOISE_STD: f32 = 0.01; +/// Noise multiplier applied ONLY while capturing the empty-room baseline. +/// +/// Real ESP32 baselines overestimate the instantaneous noise: the Welford +/// variance integrates slow gain/thermal drift across the 30 s capture, so +/// runtime z-scores are deflated relative to ideal i.i.d. noise. The ADR-151 +/// bench (2026-06-11) measured the empty-room per-frame p90|z| at 1.27–1.33, +/// vs 1.645 for ideal N(0,1) — an effective z-scale of ≈ 0.8. Modelling that +/// here (baseline noise ×ばつ runtime noise ⇒ runtime z ~ N(0, 0.82)) keeps +/// the synthetic room consistent with the measured gates (EMPTY_MAX_Z = 1.6, +/// MIN_PRESENCE_Z = 1.9). +const BASELINE_NOISE_SCALE: f32 = 1.25; /// Capture length per enrollment anchor (20 s @ 20 Hz; gate needs ≥ 60). const ANCHOR_FRAMES: usize = 400; /// Baseline / runtime window length (30 s @ 20 Hz; recorder needs ≥ 600). @@ -77,9 +88,12 @@ const WINDOW_FRAMES: usize = 600; #[derive(Clone, Copy, Default)] struct Person { /// Common amplitude offset in units of NOISE_STD (presence strength). - /// Anything ≥ 1.5 reads as present; values above 2.0 are explicitly - /// exercised to guard the ADR-152 z-band-squeeze fix (presence strength - /// must not read as motion). + /// With the baseline captured at BASELINE_NOISE_SCALE, the measured + /// per-frame p90|z| ≈ presence_z / 1.25 + 1.03, so the 1.6–1.7 values + /// here read ≈ 2.3 — matching the bench's occupied band (2.26–2.29) and + /// clearing the MIN_PRESENCE_Z = 1.9 gate. Values above 2.0 are + /// explicitly exercised to guard the ADR-152 z-band-squeeze fix + /// (presence strength must not read as motion). presence_z: f32, /// Per-frame common amplitude jitter (body sway / fidgeting), in NOISE_STD. sway_z: f32, @@ -102,6 +116,9 @@ struct RoomSim { phase: Vec, /// Frame counter (continuous room clock). t: u64, + /// Noise multiplier (1.0 at runtime; [`BASELINE_NOISE_SCALE`] while the + /// empty-room baseline is being captured — see that constant). + noise_scale: f32, } impl RoomSim { @@ -113,7 +130,7 @@ impl RoomSim { let phase = (0..N_SC) .map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI) .collect(); - Self { rng: Rng::new(seed), amp, phase, t: 0 } + Self { rng: Rng::new(seed), amp, phase, t: 0, noise_scale: 1.0 } } /// Generate the next CSI frame for the given occupancy. @@ -139,8 +156,9 @@ impl RoomSim { } } let th = self.phase[k] + wobble; - let re = a * th.cos() + NOISE_STD * self.rng.next_normal(); - let im = a * th.sin() + NOISE_STD * self.rng.next_normal(); + let noise = NOISE_STD * self.noise_scale; + let re = a * th.cos() + noise * self.rng.next_normal(); + let im = a * th.sin() + noise * self.rng.next_normal(); data[(0, k)] = Complex64::new(re as f64, im as f64); } @@ -240,6 +258,9 @@ fn full_loop_baseline_enroll_extract_train_infer() { let mut sim = RoomSim::new(42); // -- Stage 1: clean empty-room baseline capture (ADR-135) ---------------- + // Baseline capture sees slightly noisier frames than runtime (drift + // integrated into the Welford variance — see BASELINE_NOISE_SCALE). + sim.noise_scale = BASELINE_NOISE_SCALE; let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20()); let mut flagged_after_warmup = 0u32; for i in 0..WINDOW_FRAMES { @@ -250,6 +271,7 @@ fn full_loop_baseline_enroll_extract_train_infer() { flagged_after_warmup += 1; } } + sim.noise_scale = 1.0; assert_eq!(recorder.frames_recorded(), WINDOW_FRAMES as u32); assert_eq!( flagged_after_warmup, 0, @@ -288,9 +310,10 @@ fn full_loop_baseline_enroll_extract_train_infer() { ); match label { AnchorLabel::Empty => assert!( - anchor.quality.presence_z < 1.0, - "empty room must read empty, got z {}", - anchor.quality.presence_z + anchor.quality.presence_z < gate.empty_max_z, + "empty room must read empty, got z {} (gate {})", + anchor.quality.presence_z, + gate.empty_max_z ), AnchorLabel::SmallMove => assert!( anchor.quality.motion_rate>= 0.3, @@ -298,10 +321,11 @@ fn full_loop_baseline_enroll_extract_train_infer() { anchor.quality.motion_rate ), _ => assert!( - anchor.quality.presence_z>= 1.5, - "{} presence_z {} below gate", + anchor.quality.presence_z>= gate.min_presence_z, + "{} presence_z {} below gate {}", label.as_str(), - anchor.quality.presence_z + anchor.quality.presence_z, + gate.min_presence_z ), } features.push(feat.expect("accepted anchor yields a feature")); diff --git a/v2/crates/wifi-densepose-cli/src/calibrate.rs b/v2/crates/wifi-densepose-cli/src/calibrate.rs index d4c261ff23..a52add4ae6 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate.rs @@ -196,8 +196,9 @@ fn print_banner(frames: usize, target: usize, score: &CalibrationDeviationScore) "no" }; eprintln!( - "[calibrate] {}/{} frames | z_med={:.2} z_max={:.2} | motion: {}", - frames, target, score.amplitude_z_median, score.amplitude_z_max, motion_str + "[calibrate] {}/{} frames | z_med={:.2} z_p90={:.2} z_max={:.2} | motion: {}", + frames, target, score.amplitude_z_median, score.amplitude_z_p90, score.amplitude_z_max, + motion_str ); } diff --git a/v2/crates/wifi-densepose-signal/src/lib.rs b/v2/crates/wifi-densepose-signal/src/lib.rs index 1b56f4a63a..48e3b9544a 100644 --- a/v2/crates/wifi-densepose-signal/src/lib.rs +++ b/v2/crates/wifi-densepose-signal/src/lib.rs @@ -71,7 +71,7 @@ pub use ruvsense::cir::{Cir, CirConfig, CirError, CirEstimator}; pub use ruvsense::calibration; pub use ruvsense::calibration::{ BaselineCalibration, CalibrationConfig, CalibrationDeviationScore, CalibrationError, - CalibrationRecorder, PhyTier, SubcarrierBaseline, + CalibrationRecorder, PhyTier, SubcarrierBaseline, PHASE_DISPERSION_USABLE_MAX, }; /// Library version diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs index c525cf2468..f83e5c10b1 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs @@ -40,6 +40,18 @@ const VERSION: u8 = 1; const HEADER_LEN: usize = 16; // magic(4) + version(1) + tier(1) + reserved(2) + unix_s(8) const SUBCARRIER_RECORD_LEN: usize = 16; // 4 ×ばつ f32 +/// Baseline phase channel is considered usable only when the median Von Mises +/// dispersion across subcarriers is at or below this value. +/// +/// A sanitised pipeline (`PhaseSanitizer` + `phase_align.rs`) lands well under +/// the `CalibrationConfig::max_phase_variance` default of 0.3. The CLI UDP +/// path feeds *unsanitised* phase: the ADR-151 presence-flatline bench +/// (2026年06月11日, real ESP32-C6 HE20 captures) measured baseline +/// `phase_dispersion` ≈ 0.965 — i.e. uniformly random phase — which makes +/// `phase_drift_median` ≈ π/2 pure noise on every frame. Gating on this +/// constant keeps the noise out of `motion_flagged`. +pub const PHASE_DISPERSION_USABLE_MAX: f32 = 0.5; + // --------------------------------------------------------------------------- // PHY tier // --------------------------------------------------------------------------- @@ -254,10 +266,30 @@ impl BaselineCalibration { phase_drift.push(drift); } let amplitude_z_median = median_abs(&z_amp); + let amplitude_z_p90 = percentile_abs(&z_amp, 0.90); let amplitude_z_max = z_amp.iter().map(|v| v.abs()).fold(0.0_f32, f32::max); let phase_drift_median = median_slice(&phase_drift); - let motion_flagged = amplitude_z_median> 2.0 || phase_drift_median> std::f32::consts::PI / 6.0; - Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged }) + let phase_usable = self.phase_usable(); + let motion_flagged = amplitude_z_median> 2.0 + || (phase_usable && phase_drift_median> std::f32::consts::PI / 6.0); + Ok(CalibrationDeviationScore { + amplitude_z_median, + amplitude_z_p90, + amplitude_z_max, + phase_drift_median, + phase_usable, + motion_flagged, + }) + } + + /// Whether this baseline's phase channel carries signal (median Von Mises + /// dispersion ≤ [`PHASE_DISPERSION_USABLE_MAX`]). False when the capture + /// pipeline skipped `PhaseSanitizer` / `phase_align.rs` — see the constant's + /// doc for the bench evidence. + #[must_use] + pub fn phase_usable(&self) -> bool { + let disp: Vec = self.subcarriers.iter().map(|b| b.phase_dispersion).collect(); + median_slice(&disp) <= PHASE_DISPERSION_USABLE_MAX } /// Deterministic calibration epoch id (ADR-137 `CalibrationId`), derived @@ -409,12 +441,31 @@ impl BaselineCalibration { #[derive(Debug, Clone, Copy)] pub struct CalibrationDeviationScore { /// Median of `|z_amp[k]|` across active subcarriers. + /// + /// **Not a presence statistic.** A person perturbs a *minority* of + /// subcarriers strongly; the median of |z| over all bins floors at + /// `median(|N(0,1)|) ≈ 0.674` and barely moves when someone enters the + /// room (ADR-151 presence-flatline bench, 2026-06-11: empty ≈ 0.40–0.42 + /// vs person-moving ≈ 0.74–0.75 on real ESP32-C6 HE20 captures). Kept for + /// drift scoring and backwards compatibility; presence gating uses + /// [`Self::amplitude_z_p90`]. pub amplitude_z_median: f32, + /// 90th percentile of `|z_amp[k]|` across active subcarriers — the + /// ADR-151 presence statistic. The upper tail is where a body's multipath + /// perturbation lives: the same bench measured empty ≈ 1.27–1.33 vs + /// person-moving ≈ 2.26–2.29 (same-epoch baseline), a clean separation + /// the median never shows. + pub amplitude_z_p90: f32, /// Max single-subcarrier `|z_amp[k]|`. pub amplitude_z_max: f32, /// Median circular distance (radians) between live and baseline phase. pub phase_drift_median: f32, - /// Heuristic: `amplitude_z_median> 2.0 || phase_drift_median> π/6`. + /// Whether the baseline's phase channel carries signal (see + /// [`PHASE_DISPERSION_USABLE_MAX`]). When false, `phase_drift_median` is + /// noise and is excluded from `motion_flagged`. + pub phase_usable: bool, + /// Heuristic: `amplitude_z_median> 2.0 || (phase_usable && + /// phase_drift_median> π/6)`. pub motion_flagged: bool, } @@ -467,10 +518,21 @@ impl CalibrationRecorder { phase_drift.push(circular_distance(c.arg(), st.phase_mean() as f32)); } let amplitude_z_median = median_slice(&z_amp_abs); + let amplitude_z_p90 = percentile_abs(&z_amp_abs, 0.90); let amplitude_z_max = z_amp_abs.iter().copied().fold(0.0_f32, f32::max); let phase_drift_median = median_slice(&phase_drift); - let motion_flagged = amplitude_z_median> 2.0 || phase_drift_median> std::f32::consts::PI / 6.0; - Ok(CalibrationDeviationScore { amplitude_z_median, amplitude_z_max, phase_drift_median, motion_flagged }) + let disp: Vec = self.stats.iter().map(|st| st.phase_dispersion() as f32).collect(); + let phase_usable = median_slice(&disp) <= PHASE_DISPERSION_USABLE_MAX; + let motion_flagged = amplitude_z_median> 2.0 + || (phase_usable && phase_drift_median> std::f32::consts::PI / 6.0); + Ok(CalibrationDeviationScore { + amplitude_z_median, + amplitude_z_p90, + amplitude_z_max, + phase_drift_median, + phase_usable, + motion_flagged, + }) } /// Number of frames recorded so far. @@ -535,6 +597,22 @@ fn median_abs(v: &[f32]) -> f32 { median_in_place(&mut abs) } +/// `p`-quantile (`0.0..=1.0`) of the absolute values of a slice, with linear +/// interpolation between order statistics (numpy's default convention — the +/// ADR-151 bench thresholds were derived with it). +fn percentile_abs(v: &[f32], p: f32) -> f32 { + if v.is_empty() { + return 0.0; + } + let mut abs: Vec = v.iter().map(|x| x.abs()).collect(); + abs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let idx = p.clamp(0.0, 1.0) * (abs.len() - 1) as f32; + let lo = idx.floor() as usize; + let hi = idx.ceil() as usize; + let frac = idx - lo as f32; + abs[lo] + (abs[hi] - abs[lo]) * frac +} + /// Median of a slice (non-destructive clone). fn median_slice(v: &[f32]) -> f32 { let mut c = v.to_vec();

AltStyle によって変換されたページ (->オリジナル) /