From 5eac273630ed34d528b88cf485c020a24be98c49 Mon Sep 17 00:00:00 2001 From: arsen Date: Mon, 18 May 2026 18:49:45 +0700 Subject: [PATCH] fix(mmwave): override distance to "<70 cm" in radar near-field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HLK-LD2402's antenna near-zone (0–70 cm) is a dead spot for its internal distance algorithm: gate-0 micromotion energy collapses to zero, and the firmware falls back to a sidelobe pick that lands at 1.5–2 m. Operator sitting 40 cm away saw "180 cm" jumping ±10 cm. Detect the near-field state from the gate snapshot: motion[0] > 5k AND motion[0] >= peak_motion_mid AND micro[0] < 3k Debounce across the last 6 frames (≈1 s) so a single jittery frame doesn't toggle the UI — gate energies swing 5–30k frame-to-frame when the target is breathing right against the module. When the flag is set, the distance pill renders "<70 cm" with a tooltip explaining that vitals are unreliable at this range; the recommended sweet-spot is 0.7–2 m. Co-Authored-By: Claude Opus 4.7 --- ui/raw.html | 13 ++++- .../wifi-densepose-sensing-server/src/main.rs | 1 + .../src/mmwave.rs | 56 +++++++++++++++++++ .../static/raw.html | 13 ++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/ui/raw.html b/ui/raw.html index 514b04d9..9c47054f 100644 --- a/ui/raw.html +++ b/ui/raw.html @@ -589,7 +589,18 @@ async function pollMmwave() { const j = await r.json(); if (j && j.available) { mmwavePill.style.display = ''; - mmwaveDist.textContent = j.distance_cm + ' cm'; + // ADR-122 hardware quirk: at <70 cm the radar's gate-0 micro is + // dead and its distance algorithm returns a sidelobe pick + // (~1.5–2 m). The server flags this as `near_field`; we + // override the display so the operator doesn't read the bogus + // 161 cm reading as truth. + if (j.near_field) { + mmwaveDist.textContent = '<70 cm'; + mmwavePill.title = 'Цель в ближней зоне антенны (<70 cm). Радар тебя видит, но точная дистанция и пульс/дыхание ненадёжны на таком расстоянии. Отодвинься на 0.7–2 м для рабочего диапазона.'; + } else { + mmwaveDist.textContent = j.distance_cm + ' cm'; + mmwavePill.title = 'HLK-LD2402 24 GHz radar — distance to closest target'; + } const age = Math.round(j.age_ms || 0); mmwaveAge.textContent = '· ' + age + ' ms'; // Fade pill if stale (>1.5 s) before server hides at 2 s. diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 9170f316..7a6a3334 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -5092,6 +5092,7 @@ async fn mmwave_latest() -> Json { Some(r) => Json(serde_json::json!({ "available": true, "distance_cm": r.distance_cm, + "near_field": r.near_field, "age_ms": r.at.elapsed().as_millis() as u64, })), None => Json(serde_json::json!({ diff --git a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs index e620c6aa..596d5791 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs @@ -95,9 +95,18 @@ const GATE_HISTORY_LEN: usize = 180; const PRESENCE_MICRO_THRESHOLD: u32 = 20_000; /// Latest mmWave reading + when it landed. +/// +/// `near_field` is set when the gate-0 motion energy dominates the +/// mid-range gates (operator is closer than ~70 cm). In that regime +/// the radar's internal distance algorithm returns nonsense +/// (typically 1.5–2 m), because gate-0 micromotion is in the +/// antenna's dead zone and the algorithm falls back to a sidelobe +/// pick. We surface the flag so the UI can render "<70 cm" instead +/// of the bogus number. #[derive(Debug, Clone, Copy)] pub struct MmwaveReading { pub distance_cm: u32, + pub near_field: bool, pub at: Instant, } @@ -437,6 +446,29 @@ fn parse_engineering_payload(payload: &[u8]) { .unwrap_or(1) }; + // Near-field detection: motion[0] is in the antenna's near-zone. + // When the operator is closer than ~70 cm, two telltale things + // happen in the gate snapshot: + // + // 1. motion[0] dominates gates 1..14 (the body is at 0–70 cm + // so the strongest reflector is in the first bin). + // 2. micro[0] is ~0 because the antenna's near-zone phase + // pattern can't resolve sub-cm chest movement. + // + // We require *both* conditions, then debounce across 6 frames + // (≈1 s @ 6 Hz) so a single jitter doesn't toggle the flag — the + // gate energies are noisy and motion[0] swings 5k–30k frame to + // frame when the operator is breathing right against the module. + let peak_motion_mid = motion[1..GATE_COUNT.saturating_sub(1)] + .iter() + .copied() + .max() + .unwrap_or(0); + let near_field_now = motion[0] > 5_000 + && motion[0] >= peak_motion_mid + && micro[0] < 3_000; + let near_field = update_near_field_state(near_field_now); + let now = Instant::now(); *gates_slot().lock().unwrap() = Some(GateSnapshot { motion, @@ -446,6 +478,7 @@ fn parse_engineering_payload(payload: &[u8]) { }); *latest().lock().unwrap() = Some(MmwaveReading { distance_cm, + near_field, at: now, }); @@ -630,10 +663,33 @@ fn fft_inplace(re: &mut [f64], im: &mut [f64]) { } } +/// Sliding-window debounce for the near-field flag. Tracks how many +/// of the last 6 frames met the raw condition; toggles to true once +/// ≥4/6 agree, falls back to false once ≤1/6 do. This prevents UI +/// flicker when motion[0] briefly dips during a breath cycle. +fn update_near_field_state(near_now: bool) -> bool { + use std::sync::atomic::{AtomicU32, Ordering}; + // Bit 0..6 = last 6 frames (1 = near), bit 7 = current debounced state. + static STATE: AtomicU32 = AtomicU32::new(0); + let mut s = STATE.load(Ordering::Relaxed); + let history = ((s & 0x3F) << 1) & 0x3F | (near_now as u32); + let mut latched = (s >> 7) & 1 == 1; + let count = history.count_ones(); + if !latched && count >= 4 { + latched = true; + } else if latched && count <= 1 { + latched = false; + } + s = history | ((latched as u32) << 7); + STATE.store(s, Ordering::Relaxed); + latched +} + fn ingest_distance(cm: u32) { let now = Instant::now(); *latest().lock().unwrap() = Some(MmwaveReading { distance_cm: cm, + near_field: false, // ASCII fallback has no gate info at: now, }); // Breathing-only update on ASCII fallback (no per-gate data). diff --git a/v2/crates/wifi-densepose-sensing-server/static/raw.html b/v2/crates/wifi-densepose-sensing-server/static/raw.html index 514b04d9..9c47054f 100644 --- a/v2/crates/wifi-densepose-sensing-server/static/raw.html +++ b/v2/crates/wifi-densepose-sensing-server/static/raw.html @@ -589,7 +589,18 @@ async function pollMmwave() { const j = await r.json(); if (j && j.available) { mmwavePill.style.display = ''; - mmwaveDist.textContent = j.distance_cm + ' cm'; + // ADR-122 hardware quirk: at <70 cm the radar's gate-0 micro is + // dead and its distance algorithm returns a sidelobe pick + // (~1.5–2 m). The server flags this as `near_field`; we + // override the display so the operator doesn't read the bogus + // 161 cm reading as truth. + if (j.near_field) { + mmwaveDist.textContent = '<70 cm'; + mmwavePill.title = 'Цель в ближней зоне антенны (<70 cm). Радар тебя видит, но точная дистанция и пульс/дыхание ненадёжны на таком расстоянии. Отодвинься на 0.7–2 м для рабочего диапазона.'; + } else { + mmwaveDist.textContent = j.distance_cm + ' cm'; + mmwavePill.title = 'HLK-LD2402 24 GHz radar — distance to closest target'; + } const age = Math.round(j.age_ms || 0); mmwaveAge.textContent = '· ' + age + ' ms'; // Fade pill if stale (>1.5 s) before server hides at 2 s.