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