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:
parent
81e848ef2a
commit
ee6d9dfa80
29
ui/raw.html
29
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 = '·';
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<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 ~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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '·';
|
||||
|
|
|
|||
Loading…
Reference in New Issue