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();
|
const j = await r.json();
|
||||||
if (j && j.available) {
|
if (j && j.available) {
|
||||||
mmwavePill.style.display = '';
|
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);
|
const age = Math.round(j.age_ms || 0);
|
||||||
mmwaveAge.textContent = '· ' + age + ' ms';
|
mmwaveAge.textContent = '· ' + age + ' ms';
|
||||||
// Fade pill if stale (>1.5 s) before server hides at 2 s.
|
// 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!({
|
Some(r) => Json(serde_json::json!({
|
||||||
"available": true,
|
"available": true,
|
||||||
"distance_cm": r.distance_cm,
|
"distance_cm": r.distance_cm,
|
||||||
|
"near_field": r.near_field,
|
||||||
"age_ms": r.at.elapsed().as_millis() as u64,
|
"age_ms": r.at.elapsed().as_millis() as u64,
|
||||||
})),
|
})),
|
||||||
None => Json(serde_json::json!({
|
None => Json(serde_json::json!({
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,18 @@ const GATE_HISTORY_LEN: usize = 180;
|
||||||
const PRESENCE_MICRO_THRESHOLD: u32 = 20_000;
|
const PRESENCE_MICRO_THRESHOLD: u32 = 20_000;
|
||||||
|
|
||||||
/// Latest mmWave reading + when it landed.
|
/// 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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct MmwaveReading {
|
pub struct MmwaveReading {
|
||||||
pub distance_cm: u32,
|
pub distance_cm: u32,
|
||||||
|
pub near_field: bool,
|
||||||
pub at: Instant,
|
pub at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,6 +446,29 @@ fn parse_engineering_payload(payload: &[u8]) {
|
||||||
.unwrap_or(1)
|
.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();
|
let now = Instant::now();
|
||||||
*gates_slot().lock().unwrap() = Some(GateSnapshot {
|
*gates_slot().lock().unwrap() = Some(GateSnapshot {
|
||||||
motion,
|
motion,
|
||||||
|
|
@ -446,6 +478,7 @@ fn parse_engineering_payload(payload: &[u8]) {
|
||||||
});
|
});
|
||||||
*latest().lock().unwrap() = Some(MmwaveReading {
|
*latest().lock().unwrap() = Some(MmwaveReading {
|
||||||
distance_cm,
|
distance_cm,
|
||||||
|
near_field,
|
||||||
at: now,
|
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) {
|
fn ingest_distance(cm: u32) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
*latest().lock().unwrap() = Some(MmwaveReading {
|
*latest().lock().unwrap() = Some(MmwaveReading {
|
||||||
distance_cm: cm,
|
distance_cm: cm,
|
||||||
|
near_field: false, // ASCII fallback has no gate info
|
||||||
at: now,
|
at: now,
|
||||||
});
|
});
|
||||||
// Breathing-only update on ASCII fallback (no per-gate data).
|
// Breathing-only update on ASCII fallback (no per-gate data).
|
||||||
|
|
|
||||||
|
|
@ -589,7 +589,18 @@ async function pollMmwave() {
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j && j.available) {
|
if (j && j.available) {
|
||||||
mmwavePill.style.display = '';
|
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);
|
const age = Math.round(j.age_ms || 0);
|
||||||
mmwaveAge.textContent = '· ' + age + ' ms';
|
mmwaveAge.textContent = '· ' + age + ' ms';
|
||||||
// Fade pill if stale (>1.5 s) before server hides at 2 s.
|
// Fade pill if stale (>1.5 s) before server hides at 2 s.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue