fix(mmwave): override distance to "<70 cm" in radar near-field
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 <noreply@anthropic.com>
This commit is contained in:
parent
ee6d9dfa80
commit
5eac273630
13
ui/raw.html
13
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.
|
||||
|
|
|
|||
|
|
@ -5092,6 +5092,7 @@ async fn mmwave_latest() -> Json<serde_json::Value> {
|
|||
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!({
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue