Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

fix(calibration): ADR-151 presence flatline — per-frame p90|z| replaces floored median|z|; gates re-derived from bench; unsanitised phase excluded from motion #1015

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
stuinfla wants to merge 1 commit into ruvnet:main
base: main
Choose a base branch
Loading
from stuinfla:fix/adr-151-presence-p90
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion v2/crates/wifi-densepose-calibration/src/anchor.rs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
142 changes: 124 additions & 18 deletions v2/crates/wifi-densepose-calibration/src/enrollment.rs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)>,
}
Expand All @@ -140,6 +174,7 @@ impl AnchorRecorder {
Self {
label,
z_sum: 0.0,
z_median_sum: 0.0,
motion_count: 0,
frames: 0,
prev: None,
Expand All @@ -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;
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down
48 changes: 36 additions & 12 deletions v2/crates/wifi-densepose-calibration/tests/full_loop.rs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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,
Expand All @@ -102,6 +116,9 @@ struct RoomSim {
phase: Vec<f32>,
/// 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 {
Expand All @@ -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.
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -288,20 +310,22 @@ 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,
"small-move motion {} too low",
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"));
Expand Down
5 changes: 3 additions & 2 deletions v2/crates/wifi-densepose-cli/src/calibrate.rs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
2 changes: 1 addition & 1 deletion v2/crates/wifi-densepose-signal/src/lib.rs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

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