fix(mmwave): presence-gate vitals so empty beam doesn't show a number

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 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-18 18:39:03 +07:00
parent 81e848ef2a
commit ee6d9dfa80
4 changed files with 123 additions and 22 deletions

View File

@ -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 = '·';

View File

@ -5100,6 +5100,30 @@ async fn mmwave_latest() -> Json<serde_json::Value> {
}
}
/// 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<serde_json::Value> {
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<serde_json::Value> {
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))

View File

@ -80,6 +80,20 @@ const FRAME_TYPE_ENGINEERING: u8 = 0x01;
/// heartbeat band (0.82.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 1k5k 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 ~1k3k
/// * person nearby : peak_micro_mid ~10k20k
/// * person in beam: peak_micro_mid ~40k80k
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<bool> {
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 ~15k. 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);
}

View File

@ -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 = '·';