From ee6d9dfa806e83630e1d9f68ab511ef7ec595345 Mon Sep 17 00:00:00 2001 From: arsen Date: Mon, 18 May 2026 18:39:03 +0700 Subject: [PATCH] fix(mmwave): presence-gate vitals so empty beam doesn't show a number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FFT will always find *some* peak in 0.8-2.0 Hz, even on pure clutter, and the peak-to-mean ratio frequently lands at 0.5-0.7 "confidence" from noise alone. Net result: the HR pill showed 75-97 BPM with 60%+ confidence while the operator was across the room with their back to the radar. Add a presence gate based on the target gate's micromotion energy: empty room peak_micro_mid 1k-3k person nearby peak_micro_mid 10k-20k person in beam peak_micro_mid 40k-80k Threshold at 20k. Below it we null both BR and HR (the breathing detector's internal buffer is still fed so it stays warm for instant re-acquisition). New diagnostic endpoint GET /api/v1/mmwave/gates dumps current motion/micro arrays + the target gate so we can re-calibrate the threshold on new firmware. UI: pill now shows "· нет цели" (no target) when presence=false, so the operator can tell "buffer warming up" from "nobody in beam" from "module fell back to Normal Mode". Co-Authored-By: Claude Opus 4.7 --- ui/raw.html | 29 ++++++--- .../wifi-densepose-sensing-server/src/main.rs | 27 +++++++++ .../src/mmwave.rs | 60 +++++++++++++++++-- .../static/raw.html | 29 ++++++--- 4 files changed, 123 insertions(+), 22 deletions(-) diff --git a/ui/raw.html b/ui/raw.html index 91ea8374..514b04d9 100644 --- a/ui/raw.html +++ b/ui/raw.html @@ -630,10 +630,17 @@ async function pollMmwaveVitals() { } else { brBpmMm.textContent = ' — BPM '; brBpmMm.style.color = ''; - // Buffer-fill hint: at 6 Hz × 30 s = 180 samples needed. - const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0; - const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180; - brConfMm.textContent = cap > 0 ? '· ' + fill + '/' + cap : '·'; + // Three reasons to show "—": + // 1. no presence (radar sees no body in beam) → "нет цели" + // 2. buffer warming up (first 30 s) → "NN/180" + // 3. presence ok but FFT failed (rare) → "·" + if (j && j.available && j.presence === false) { + brConfMm.textContent = '· нет цели'; + } else { + const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0; + const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180; + brConfMm.textContent = cap > 0 && fill < cap ? '· ' + fill + '/' + cap : '·'; + } } if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) { const v = vs.heart_rate_bpm; @@ -642,13 +649,17 @@ async function pollMmwaveVitals() { hrBpmMm.style.color = out ? '#f0a020' : ''; hrConfMm.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%'; } else { - // ADR-122: heart rate becomes available after ~30 s buffer fill, - // when the radar is in Engineering Mode (per-gate energies). If - // the module fell back to Normal Mode (ASCII distance) HR will - // stay missing. + // ADR-122: HR only published when presence-gate passes (target + // micromotion energy above clutter threshold). Show *why* the + // value is missing so the operator can tell "warming up" from + // "nobody in beam" from "module fell back to Normal Mode". hrBpmMm.textContent = ' — BPM '; hrBpmMm.style.color = ''; - hrConfMm.textContent = '·'; + if (j && j.available && j.presence === false) { + hrConfMm.textContent = '· нет цели'; + } else { + hrConfMm.textContent = '·'; + } } } catch (_) { brBpmMm.textContent = ' — BPM '; brConfMm.textContent = '·'; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 71c2f15a..9170f316 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -5100,6 +5100,30 @@ async fn mmwave_latest() -> Json { } } +/// ADR-122 diagnostic: GET /api/v1/mmwave/gates — current per-gate +/// energies + target-gate selection. Lets the operator see whether +/// the radar is actually picking up a body or just background noise. +async fn mmwave_gates() -> Json { + use wifi_densepose_sensing_server::mmwave; + let stale = std::time::Duration::from_secs(2); + match mmwave::current_gates(stale) { + Some(snap) => { + let peak_motion_mid = snap.motion[1..14].iter().copied().max().unwrap_or(0); + let peak_micro_mid = snap.micro[1..14].iter().copied().max().unwrap_or(0); + Json(serde_json::json!({ + "available": true, + "motion": snap.motion, + "micro": snap.micro, + "target_gate": snap.target_gate, + "peak_motion_mid": peak_motion_mid, + "peak_micro_mid": peak_micro_mid, + "age_ms": snap.at.elapsed().as_millis() as u64, + })) + } + None => Json(serde_json::json!({"available": false})), + } +} + /// ADR-121 follow-up: GET /api/v1/mmwave/vitals — breathing + (best- /// effort) heart-rate computed from the mmWave distance time-series. /// Returns `{ available: false }` if no recent reading or if the @@ -5113,9 +5137,11 @@ async fn mmwave_vitals() -> Json { match mmwave::current_vitals(stale) { Some(vs) => { let (br_samples, br_cap) = mmwave::buffer_status(); + let presence = mmwave::current_presence(stale).unwrap_or(false); Json(serde_json::json!({ "available": true, "source": "mmwave:hlk-ld2402", + "presence": presence, "vital_signs": vs, "buffer_status": { "breathing_samples": br_samples, @@ -7716,6 +7742,7 @@ async fn main() { .route("/api/v1/adaptive/debug", get(adaptive_debug)) .route("/api/v1/mmwave/latest", get(mmwave_latest)) .route("/api/v1/mmwave/vitals", get(mmwave_vitals)) + .route("/api/v1/mmwave/gates", get(mmwave_gates)) .route("/api/v1/adaptive/unload", post(adaptive_unload)) // Field model calibration (eigenvalue-based person counting) .route("/api/v1/calibration/start", post(calibration_start)) diff --git a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs index f9e6a079..e620c6aa 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs @@ -80,6 +80,20 @@ const FRAME_TYPE_ENGINEERING: u8 = 0x01; /// heartbeat band (0.8–2.0 Hz) to resolve cleanly. const GATE_HISTORY_LEN: usize = 180; +/// Presence threshold on the target gate's *micromotion* energy. The +/// micro-channel responds to small chest-wall displacement; when no +/// body is in the radar's main beam this stays in the 1k–5k clutter +/// range. When a torso is on-axis it climbs above 30k. We use 20k as +/// a conservative gate — below this we refuse to publish HR (and +/// flag breathing as low-confidence) so the operator doesn't read a +/// random FFT peak as a vital sign. +/// +/// Empirically observed on this exact module: +/// * empty room : peak_micro_mid ~1k–3k +/// * person nearby : peak_micro_mid ~10k–20k +/// * person in beam: peak_micro_mid ~40k–80k +const PRESENCE_MICRO_THRESHOLD: u32 = 20_000; + /// Latest mmWave reading + when it landed. #[derive(Debug, Clone, Copy)] pub struct MmwaveReading { @@ -165,6 +179,20 @@ pub fn buffer_status() -> (usize, usize) { (br_samples, br_cap) } +/// True if the latest gate snapshot has enough on-axis micromotion +/// energy to plausibly be a body — i.e. vitals are meaningful right +/// now, not a noise hallucination. +pub fn current_presence(staleness: Duration) -> Option { + let snap = current_gates(staleness)?; + let peak_micro_mid = snap.micro[1..GATE_COUNT.saturating_sub(1)] + .iter() + .copied() + .max() + .unwrap_or(0); + let target_micro = snap.micro.get(snap.target_gate).copied().unwrap_or(0); + Some(peak_micro_mid >= PRESENCE_MICRO_THRESHOLD || target_micro >= PRESENCE_MICRO_THRESHOLD) +} + /// Spawn the blocking serial reader thread. Returns immediately. /// `port` example: `/dev/cu.usbserial-1140` (macOS) or `/dev/ttyUSB0` /// (Linux). `baud` should be 115200 for HLK-LD2402 default firmware. @@ -439,14 +467,38 @@ fn parse_engineering_payload(payload: &[u8]) { } } + // Presence gate: peak micromotion in mid-range gates. If nobody + // is in the beam this is dominated by clutter at ~1–5k. We refuse + // to publish vitals below the threshold so the operator stops + // seeing a confident-looking HR number from pure noise. + let peak_micro_mid = micro[1..GATE_COUNT.saturating_sub(1)] + .iter() + .copied() + .max() + .unwrap_or(0); + let target_micro = micro.get(target_gate).copied().unwrap_or(0); + let present = peak_micro_mid >= PRESENCE_MICRO_THRESHOLD + || target_micro >= PRESENCE_MICRO_THRESHOLD; + // ── breathing: distance time-series via existing detector ────── let cm_f = distance_cm as f64; let mut vs = br_detector().lock().unwrap().process_frame(&[cm_f], &[]); - // ── heart rate: per-gate micromotion FFT in HR band ──────────── - let (hr_bpm, hr_conf) = compute_heart_rate(target_gate); - vs.heart_rate_bpm = hr_bpm; - vs.heartbeat_confidence = hr_conf; + if !present { + // Keep the breathing detector's buffer warm so we don't lose + // 30 s of warm-up the moment a target reappears, but null the + // *output* so the UI shows "—" instead of a confident-looking + // hallucination of vitals. + vs.breathing_rate_bpm = None; + vs.breathing_confidence = 0.0; + vs.heart_rate_bpm = None; + vs.heartbeat_confidence = 0.0; + } else { + // ── heart rate: per-gate micromotion FFT in HR band ──────── + let (hr_bpm, hr_conf) = compute_heart_rate(target_gate); + vs.heart_rate_bpm = hr_bpm; + vs.heartbeat_confidence = hr_conf; + } *vitals_slot().lock().unwrap() = Some(vs); } diff --git a/v2/crates/wifi-densepose-sensing-server/static/raw.html b/v2/crates/wifi-densepose-sensing-server/static/raw.html index 91ea8374..514b04d9 100644 --- a/v2/crates/wifi-densepose-sensing-server/static/raw.html +++ b/v2/crates/wifi-densepose-sensing-server/static/raw.html @@ -630,10 +630,17 @@ async function pollMmwaveVitals() { } else { brBpmMm.textContent = ' — BPM '; brBpmMm.style.color = ''; - // Buffer-fill hint: at 6 Hz × 30 s = 180 samples needed. - const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0; - const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180; - brConfMm.textContent = cap > 0 ? '· ' + fill + '/' + cap : '·'; + // Three reasons to show "—": + // 1. no presence (radar sees no body in beam) → "нет цели" + // 2. buffer warming up (first 30 s) → "NN/180" + // 3. presence ok but FFT failed (rare) → "·" + if (j && j.available && j.presence === false) { + brConfMm.textContent = '· нет цели'; + } else { + const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0; + const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180; + brConfMm.textContent = cap > 0 && fill < cap ? '· ' + fill + '/' + cap : '·'; + } } if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) { const v = vs.heart_rate_bpm; @@ -642,13 +649,17 @@ async function pollMmwaveVitals() { hrBpmMm.style.color = out ? '#f0a020' : ''; hrConfMm.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%'; } else { - // ADR-122: heart rate becomes available after ~30 s buffer fill, - // when the radar is in Engineering Mode (per-gate energies). If - // the module fell back to Normal Mode (ASCII distance) HR will - // stay missing. + // ADR-122: HR only published when presence-gate passes (target + // micromotion energy above clutter threshold). Show *why* the + // value is missing so the operator can tell "warming up" from + // "nobody in beam" from "module fell back to Normal Mode". hrBpmMm.textContent = ' — BPM '; hrBpmMm.style.color = ''; - hrConfMm.textContent = '·'; + if (j && j.available && j.presence === false) { + hrConfMm.textContent = '· нет цели'; + } else { + hrConfMm.textContent = '·'; + } } } catch (_) { brBpmMm.textContent = ' — BPM '; brConfMm.textContent = '·';