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:
parent
26d47a9533
commit
b9d1f6361e
77
ui/raw.html
77
ui/raw.html
|
|
@ -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">норма 12–20</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">норма 12–20</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">норма 60–100</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">норма 60–100</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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.1–0.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 5–10 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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">норма 12–20</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">норма 12–20</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">норма 60–100</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">норма 60–100</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue