feat(mmwave): dual-source vital signs (WiFi-CSI 📶 vs mmWave 📡)

Previously only WiFi-CSI produced breathing/HR estimates. With the
HLK-LD2402 radar wired up we can compute a second, physically
independent breathing estimate from chest-induced cm flicker in the
distance time-series — a useful cross-check that catches the case
when one modality is blind (e.g. WiFi-CSI when nodes are offline,
or mmWave when nothing's in the radar's field of view).

mmwave.rs:
- Plumb a per-reading VitalSignDetector tuned for the module's 6 Hz
  Normal-Mode cadence (Nyquist 3 Hz comfortably covers the 0.1-0.5
  Hz breathing band).
- Distance (cm) feeds the detector as the "amplitude" channel;
  phase is empty so heartbeat falls back to amplitude residual.
- Gate `current_vitals()` on data freshness so a disconnected radar
  doesn't return stale cached BPMs.

main.rs:
- New GET /api/v1/mmwave/vitals returning the same shape as
  /api/v1/vital-signs plus buffer status for UI warm-up feedback.

ui/raw.html:
- Each vital pill now shows both 📶 (WiFi-CSI) and 📡 (mmWave)
  values side-by-side, separated by `|`. mmWave HR is labelled
  "n/a" — cm precision at 6 Hz puts heartbeat below the noise
  floor. Buffer fill (e.g. "120/180") shown while detector is
  warming up so the operator knows BPM is on the way.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-18 13:02:30 +07:00
parent 26d47a9533
commit b9d1f6361e
4 changed files with 215 additions and 22 deletions

View File

@ -48,22 +48,25 @@
<span class="pill" id="lastTs">last: --</span>
<span class="badge absent" id="globalBadge" style="font-size:13px;padding:4px 12px;">absent</span>
<span class="pill" id="globalCV">CV 0%</span>
<!-- ADR-021: WiFi-CSI vital signs — breathing + heart rate (computed server-side).
Norm tags: adult-at-rest reference ranges shown next to the live value so
the operator can tell healthy vs. anomalous at a glance. -->
<!-- ADR-021 + ADR-121: dual-source vital signs.
Each pill shows WiFi-CSI (📶) and mmWave (📡) side-by-side so
the operator can spot disagreement between modalities. Norms
are adult-at-rest ranges. -->
<span class="pill" id="brPill"
style="background:rgba(63,185,80,0.18); color:rgb(63,185,80); border:1px solid rgb(63,185,80);"
title="WiFi-CSI breathing rate (bandpass 0.1-0.5 Hz, ADR-021). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
🫁 <b id="brBpm">— BPM</b>
<span style="opacity:0.55;font-size:11px">норма 1220</span>
<span id="brConf" style="opacity:0.7;font-size:11px">·</span>
title="Breathing rate, two sources: 📶 = WiFi-CSI bandpass 0.1-0.5 Hz (ADR-021). 📡 = HLK-LD2402 mmWave distance bandpass (ADR-121). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
🫁 <span style="opacity:0.6;font-size:11px">📶</span><b id="brBpm">— BPM</b><span id="brConf" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.4">|</span>
<span style="opacity:0.6;font-size:11px">📡</span><b id="brBpmMm">— BPM</b><span id="brConfMm" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 1220</span>
</span>
<span class="pill" id="hrPill"
style="background:rgba(248,81,73,0.18); color:rgb(248,81,73); border:1px solid rgb(248,81,73);"
title="WiFi-CSI heart rate (bandpass 0.8-2.0 Hz, ADR-021). Adult-at-rest norm: 60-100 BPM. Below 60 = bradycardia, above 100 = tachycardia.">
💓 <b id="hrBpm">— BPM</b>
<span style="opacity:0.55;font-size:11px">норма 60100</span>
<span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
title="Heart rate, two sources: 📶 = WiFi-CSI bandpass 0.8-2.0 Hz (ADR-021). 📡 = HLK-LD2402 mmWave (cm precision and 6 Hz cadence put heartbeat below the noise floor — value shown for transparency, expect low confidence). Adult-at-rest norm: 60-100 BPM.">
💓 <span style="opacity:0.6;font-size:11px">📶</span><b id="hrBpm">— BPM</b><span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.4">|</span>
<span style="opacity:0.6;font-size:11px">📡</span><b id="hrBpmMm">— BPM</b><span id="hrConfMm" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 60100</span>
</span>
<!-- ADR-121: HLK-LD2402 24 GHz mmWave radar pill — hidden until first reading. -->
<span class="pill" id="mmwavePill" style="display:none; background:rgba(33,150,243,0.18);
@ -600,5 +603,57 @@ async function pollMmwave() {
}
pollMmwave();
setInterval(pollMmwave, 200);
// ── ADR-121 follow-up: poll mmWave-derived vital signs @ 1 Hz ────────
// Slower than the distance poll (5 Hz) — BPM only meaningfully updates
// once per breath cycle, and the FFT runs on a 30-s buffer so faster
// polling just wastes cycles.
const brBpmMm = document.getElementById('brBpmMm');
const brConfMm = document.getElementById('brConfMm');
const hrBpmMm = document.getElementById('hrBpmMm');
const hrConfMm = document.getElementById('hrConfMm');
let mmVitalsBusy = false;
async function pollMmwaveVitals() {
if (mmVitalsBusy) return; mmVitalsBusy = true;
try {
const r = await fetch('/api/v1/mmwave/vitals', { cache: 'no-store' });
if (!r.ok) throw new Error('http ' + r.status);
const j = await r.json();
const vs = (j && j.available) ? (j.vital_signs || {}) : {};
const BR_MIN = 12, BR_MAX = 20, HR_MIN = 60, HR_MAX = 100;
if (typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
const v = vs.breathing_rate_bpm;
const out = v < BR_MIN || v > BR_MAX;
brBpmMm.textContent = ' ' + v.toFixed(1) + ' BPM ';
brBpmMm.style.color = out ? '#f0a020' : '';
brConfMm.textContent = '· ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
} 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 : '·';
}
if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
const v = vs.heart_rate_bpm;
const out = v < HR_MIN || v > HR_MAX;
hrBpmMm.textContent = ' ' + v.toFixed(0) + ' BPM ';
hrBpmMm.style.color = out ? '#f0a020' : '';
hrConfMm.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
} else {
// mmWave at 6 Hz / cm precision can't resolve heartbeat —
// expect this to stay "n/a" in practice.
hrBpmMm.textContent = ' n/a ';
hrBpmMm.style.color = '';
hrConfMm.textContent = '·';
}
} catch (_) {
brBpmMm.textContent = ' — BPM '; brConfMm.textContent = '·';
hrBpmMm.textContent = ' n/a '; hrConfMm.textContent = '·';
} finally { mmVitalsBusy = false; }
}
pollMmwaveVitals();
setInterval(pollMmwaveVitals, 1000);
</script>
</body></html>

View File

@ -5100,6 +5100,36 @@ async fn mmwave_latest() -> Json<serde_json::Value> {
}
}
/// 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
/// detector buffer is still warming up.
///
/// Same shape as `/api/v1/vital-signs` so the UI can render both
/// sources with a single code path.
async fn mmwave_vitals() -> Json<serde_json::Value> {
use wifi_densepose_sensing_server::mmwave;
let stale = std::time::Duration::from_secs(2);
match mmwave::current_vitals(stale) {
Some(vs) => {
let (br_samples, br_cap) = mmwave::buffer_status();
Json(serde_json::json!({
"available": true,
"source": "mmwave:hlk-ld2402",
"vital_signs": vs,
"buffer_status": {
"breathing_samples": br_samples,
"breathing_capacity": br_cap,
},
}))
}
None => Json(serde_json::json!({
"available": false,
"source": "mmwave:offline",
})),
}
}
/// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds).
async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> {
let mut s = state.write().await;
@ -7685,6 +7715,7 @@ async fn main() {
.route("/api/v1/adaptive/status", get(adaptive_status))
.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/adaptive/unload", post(adaptive_unload))
// Field model calibration (eigenvalue-based person counting)
.route("/api/v1/calibration/start", post(calibration_start))

View File

@ -18,6 +18,13 @@ use std::io::Read;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::vital_signs::{VitalSignDetector, VitalSigns};
/// HLK-LD2402 Normal-Mode cadence: factory firmware emits ~6 distance
/// lines/sec. We pick 6.0 Hz as the detector's nominal sample_rate so
/// the breathing band (0.10.5 Hz) sits comfortably inside Nyquist.
const MMWAVE_SAMPLE_RATE_HZ: f64 = 6.0;
/// Latest mmWave reading + when it landed.
#[derive(Debug, Clone, Copy)]
pub struct MmwaveReading {
@ -26,11 +33,21 @@ pub struct MmwaveReading {
}
static LATEST: OnceLock<Mutex<Option<MmwaveReading>>> = OnceLock::new();
static VITALS: OnceLock<Mutex<Option<VitalSigns>>> = OnceLock::new();
static DETECTOR: OnceLock<Mutex<VitalSignDetector>> = OnceLock::new();
fn latest() -> &'static Mutex<Option<MmwaveReading>> {
LATEST.get_or_init(|| Mutex::new(None))
}
fn vitals_slot() -> &'static Mutex<Option<VitalSigns>> {
VITALS.get_or_init(|| Mutex::new(None))
}
fn detector_slot() -> &'static Mutex<VitalSignDetector> {
DETECTOR.get_or_init(|| Mutex::new(VitalSignDetector::new(MMWAVE_SAMPLE_RATE_HZ)))
}
/// Returns the most recent reading if it landed within `staleness`.
pub fn current(staleness: Duration) -> Option<MmwaveReading> {
let g = latest().lock().unwrap();
@ -38,6 +55,28 @@ pub fn current(staleness: Duration) -> Option<MmwaveReading> {
if r.at.elapsed() <= staleness { Some(r) } else { None }
}
/// Returns the latest mmWave-derived VitalSigns. Breathing is
/// computed from a 30-s buffer of distance samples (chest movement
/// modulates range by 510 mm — visible as flicker between adjacent
/// cm bins). Heart rate at 6 Hz / cm precision is essentially below
/// the noise floor; we surface it but expect very low confidence.
///
/// Returns `None` if no recent mmWave reading exists within
/// `staleness` (so the UI can show "—" when the radar is unplugged).
pub fn current_vitals(staleness: Duration) -> Option<VitalSigns> {
// Gate on data freshness: if no recent distance reading, vitals
// are stale — return None rather than the last cached estimate.
current(staleness)?;
vitals_slot().lock().unwrap().clone()
}
/// Buffer fill stats for UI ("12/180 samples").
pub fn buffer_status() -> (usize, usize) {
let det = detector_slot().lock().unwrap();
let (br_samples, br_cap, _hr_samples, _hr_cap) = det.buffer_status();
(br_samples, br_cap)
}
/// 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.
@ -84,6 +123,19 @@ fn run(port: String, baud: u32) {
distance_cm: cm,
at: Instant::now(),
});
// Feed the same value into a VitalSignDetector tuned for
// mmWave's 6 Hz cadence so we can publish a
// breathing-rate estimate from chest-induced cm
// flicker. Phase is empty (radar gives no phase here)
// — extract_heartbeat falls back to amplitude residual
// which is mostly noise at cm precision, but we
// surface it anyway for transparency.
let cm_f = cm as f64;
let vs = detector_slot()
.lock()
.unwrap()
.process_frame(&[cm_f], &[]);
*vitals_slot().lock().unwrap() = Some(vs);
} else if !line.is_empty() {
tracing::trace!("mmwave non-distance line: {line:?}");
}

View File

@ -48,22 +48,25 @@
<span class="pill" id="lastTs">last: --</span>
<span class="badge absent" id="globalBadge" style="font-size:13px;padding:4px 12px;">absent</span>
<span class="pill" id="globalCV">CV 0%</span>
<!-- ADR-021: WiFi-CSI vital signs — breathing + heart rate (computed server-side).
Norm tags: adult-at-rest reference ranges shown next to the live value so
the operator can tell healthy vs. anomalous at a glance. -->
<!-- ADR-021 + ADR-121: dual-source vital signs.
Each pill shows WiFi-CSI (📶) and mmWave (📡) side-by-side so
the operator can spot disagreement between modalities. Norms
are adult-at-rest ranges. -->
<span class="pill" id="brPill"
style="background:rgba(63,185,80,0.18); color:rgb(63,185,80); border:1px solid rgb(63,185,80);"
title="WiFi-CSI breathing rate (bandpass 0.1-0.5 Hz, ADR-021). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
🫁 <b id="brBpm">— BPM</b>
<span style="opacity:0.55;font-size:11px">норма 1220</span>
<span id="brConf" style="opacity:0.7;font-size:11px">·</span>
title="Breathing rate, two sources: 📶 = WiFi-CSI bandpass 0.1-0.5 Hz (ADR-021). 📡 = HLK-LD2402 mmWave distance bandpass (ADR-121). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
🫁 <span style="opacity:0.6;font-size:11px">📶</span><b id="brBpm">— BPM</b><span id="brConf" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.4">|</span>
<span style="opacity:0.6;font-size:11px">📡</span><b id="brBpmMm">— BPM</b><span id="brConfMm" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 1220</span>
</span>
<span class="pill" id="hrPill"
style="background:rgba(248,81,73,0.18); color:rgb(248,81,73); border:1px solid rgb(248,81,73);"
title="WiFi-CSI heart rate (bandpass 0.8-2.0 Hz, ADR-021). Adult-at-rest norm: 60-100 BPM. Below 60 = bradycardia, above 100 = tachycardia.">
💓 <b id="hrBpm">— BPM</b>
<span style="opacity:0.55;font-size:11px">норма 60100</span>
<span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
title="Heart rate, two sources: 📶 = WiFi-CSI bandpass 0.8-2.0 Hz (ADR-021). 📡 = HLK-LD2402 mmWave (cm precision and 6 Hz cadence put heartbeat below the noise floor — value shown for transparency, expect low confidence). Adult-at-rest norm: 60-100 BPM.">
💓 <span style="opacity:0.6;font-size:11px">📶</span><b id="hrBpm">— BPM</b><span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.4">|</span>
<span style="opacity:0.6;font-size:11px">📡</span><b id="hrBpmMm">— BPM</b><span id="hrConfMm" style="opacity:0.7;font-size:11px">·</span>
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 60100</span>
</span>
<!-- ADR-121: HLK-LD2402 24 GHz mmWave radar pill — hidden until first reading. -->
<span class="pill" id="mmwavePill" style="display:none; background:rgba(33,150,243,0.18);
@ -600,5 +603,57 @@ async function pollMmwave() {
}
pollMmwave();
setInterval(pollMmwave, 200);
// ── ADR-121 follow-up: poll mmWave-derived vital signs @ 1 Hz ────────
// Slower than the distance poll (5 Hz) — BPM only meaningfully updates
// once per breath cycle, and the FFT runs on a 30-s buffer so faster
// polling just wastes cycles.
const brBpmMm = document.getElementById('brBpmMm');
const brConfMm = document.getElementById('brConfMm');
const hrBpmMm = document.getElementById('hrBpmMm');
const hrConfMm = document.getElementById('hrConfMm');
let mmVitalsBusy = false;
async function pollMmwaveVitals() {
if (mmVitalsBusy) return; mmVitalsBusy = true;
try {
const r = await fetch('/api/v1/mmwave/vitals', { cache: 'no-store' });
if (!r.ok) throw new Error('http ' + r.status);
const j = await r.json();
const vs = (j && j.available) ? (j.vital_signs || {}) : {};
const BR_MIN = 12, BR_MAX = 20, HR_MIN = 60, HR_MAX = 100;
if (typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
const v = vs.breathing_rate_bpm;
const out = v < BR_MIN || v > BR_MAX;
brBpmMm.textContent = ' ' + v.toFixed(1) + ' BPM ';
brBpmMm.style.color = out ? '#f0a020' : '';
brConfMm.textContent = '· ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
} 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 : '·';
}
if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
const v = vs.heart_rate_bpm;
const out = v < HR_MIN || v > HR_MAX;
hrBpmMm.textContent = ' ' + v.toFixed(0) + ' BPM ';
hrBpmMm.style.color = out ? '#f0a020' : '';
hrConfMm.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
} else {
// mmWave at 6 Hz / cm precision can't resolve heartbeat —
// expect this to stay "n/a" in practice.
hrBpmMm.textContent = ' n/a ';
hrBpmMm.style.color = '';
hrConfMm.textContent = '·';
}
} catch (_) {
brBpmMm.textContent = ' — BPM '; brConfMm.textContent = '·';
hrBpmMm.textContent = ' n/a '; hrConfMm.textContent = '·';
} finally { mmVitalsBusy = false; }
}
pollMmwaveVitals();
setInterval(pollMmwaveVitals, 1000);
</script>
</body></html>