feat(sensing-server): NBVI subcarrier selection (ADR-102)
Ports Pace's NBVI = α·(σ/μ²) + (1-α)·(σ/μ) (α=0.5) into the amp_presence_override classifier. Per node, accumulates a 30-second ring of full amplitude vectors, every ~5 s ranks the subcarriers, picks top-12 by lowest NBVI, then computes broadband mean and CV ONLY on that subset instead of all 56 subcarriers. Live impact on the operator's deployment (idle room, 2 pps ping): node 1 CV: 5% -> 3.1% (-38 %) node 2 CV: 7% -> 3.9% (-44 %) Thresholds tightened proportionally to match the new baseline: active: 30 % -> 22 % present_moving: 15 % -> 10 % This lets the detector catch subtler motion (e.g. waving while seated) without raising the false-positive rate above what we had before. Implemented entirely server-side — no firmware change, no second flash cycle. Algorithm parameters in const block for easy retuning.
This commit is contained in:
parent
c22dfcd256
commit
2f12a2236b
|
|
@ -181,9 +181,88 @@ const AMP_LONG_WIN: usize = 1200;
|
||||||
/// classifier ticks/sec (both nodes combined) this gives ≈ 3 s of hold.
|
/// classifier ticks/sec (both nodes combined) this gives ≈ 3 s of hold.
|
||||||
const AMP_MOTION_HOLD_TICKS: u32 = 120;
|
const AMP_MOTION_HOLD_TICKS: u32 = 120;
|
||||||
|
|
||||||
|
// ── ADR-102: NBVI subcarrier selection (server-side port) ──────────
|
||||||
|
//
|
||||||
|
// Ported from Francesco Pace's ESPectre (GPLv3). Computes a Normalized
|
||||||
|
// Baseline Variability Index per subcarrier from a recent history of
|
||||||
|
// amplitude vectors and picks the K with the lowest score for the CV
|
||||||
|
// calculation in `amp_presence_override`. Lower NBVI = strong AND
|
||||||
|
// stable subcarrier.
|
||||||
|
//
|
||||||
|
// NBVI(k) = α · (σ_k / μ_k²) + (1 - α) · (σ_k / μ_k), α = 0.5
|
||||||
|
//
|
||||||
|
// Server-side (instead of FW) avoids a second flash cycle and makes
|
||||||
|
// the algorithm trivial to retune per deployment.
|
||||||
|
|
||||||
|
/// Rolling buffer of per-subcarrier amplitude vectors for NBVI ranking.
|
||||||
|
/// 600 frames ≈ 30 s at 20 fps.
|
||||||
|
const NBVI_HISTORY_LEN: usize = 600;
|
||||||
|
/// How many subcarriers to keep in the active set.
|
||||||
|
const NBVI_TOP_K: usize = 12;
|
||||||
|
/// Recompute the NBVI ranking every N classifier calls (~5 s at 40
|
||||||
|
/// ticks/sec combined).
|
||||||
|
const NBVI_REFRESH_TICKS: u32 = 200;
|
||||||
|
/// Dead-zone gate: ignore subcarriers below this fraction of the
|
||||||
|
/// median mean amplitude (guard tones + null bins).
|
||||||
|
const NBVI_DEAD_GATE_PCT: f64 = 0.25;
|
||||||
|
|
||||||
struct AmpState {
|
struct AmpState {
|
||||||
short: VecDeque<f64>,
|
short: VecDeque<f64>,
|
||||||
long: VecDeque<f64>,
|
long: VecDeque<f64>,
|
||||||
|
/// Rolling buffer of full per-subcarrier amplitude vectors.
|
||||||
|
nbvi_history: VecDeque<Vec<f64>>,
|
||||||
|
/// Indices of currently-selected best subcarriers (sorted by NBVI
|
||||||
|
/// ascending). Empty until first ranking pass.
|
||||||
|
nbvi_selected: Vec<usize>,
|
||||||
|
/// Ticks since last NBVI recompute (for throttling).
|
||||||
|
nbvi_ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the top-K NBVI subcarrier indices over the provided history.
|
||||||
|
/// Returns empty if the history is too short to give a stable ranking.
|
||||||
|
fn nbvi_select_top_k(history: &VecDeque<Vec<f64>>, k: usize) -> Vec<usize> {
|
||||||
|
if history.len() < AMP_SHORT_WIN { return Vec::new(); }
|
||||||
|
let n_sub = history.front().map(|v| v.len()).unwrap_or(0);
|
||||||
|
if n_sub == 0 { return Vec::new(); }
|
||||||
|
|
||||||
|
// Per-subcarrier mean and std over the buffered frames.
|
||||||
|
let n = history.len() as f64;
|
||||||
|
let mut means = vec![0.0_f64; n_sub];
|
||||||
|
let mut sums = vec![0.0_f64; n_sub];
|
||||||
|
for frame in history {
|
||||||
|
for k in 0..n_sub.min(frame.len()) { sums[k] += frame[k]; }
|
||||||
|
}
|
||||||
|
for k in 0..n_sub { means[k] = sums[k] / n; }
|
||||||
|
let mut stds = vec![0.0_f64; n_sub];
|
||||||
|
for frame in history {
|
||||||
|
for k in 0..n_sub.min(frame.len()) {
|
||||||
|
let d = frame[k] - means[k];
|
||||||
|
stds[k] += d * d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k in 0..n_sub { stds[k] = (stds[k] / n).sqrt(); }
|
||||||
|
|
||||||
|
// Dead-zone gate: keep only subcarriers above
|
||||||
|
// NBVI_DEAD_GATE_PCT × median(mean). Guard tones (mean≈0) and weak
|
||||||
|
// edge bins are excluded so they can't "win" with σ/μ → ∞.
|
||||||
|
let mut sorted_means: Vec<f64> = means.iter().copied().filter(|&v| v > 0.0).collect();
|
||||||
|
sorted_means.sort_by(|a,b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
if sorted_means.is_empty() { return Vec::new(); }
|
||||||
|
let median = sorted_means[sorted_means.len() / 2];
|
||||||
|
let gate = median * NBVI_DEAD_GATE_PCT;
|
||||||
|
|
||||||
|
// NBVI per subcarrier (α = 0.5).
|
||||||
|
let mut scored: Vec<(usize, f64)> = (0..n_sub)
|
||||||
|
.filter(|&k| means[k] > gate)
|
||||||
|
.map(|k| {
|
||||||
|
let m = means[k];
|
||||||
|
let s = stds[k];
|
||||||
|
let nbvi = 0.5 * (s / (m*m)) + 0.5 * (s / m);
|
||||||
|
(k, nbvi)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
scored.into_iter().take(k).map(|(k,_)| k).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
static AMP_HIST: OnceLock<Mutex<std::collections::HashMap<u8, AmpState>>> = OnceLock::new();
|
static AMP_HIST: OnceLock<Mutex<std::collections::HashMap<u8, AmpState>>> = OnceLock::new();
|
||||||
|
|
@ -218,19 +297,45 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo
|
||||||
if amplitudes.is_empty() {
|
if amplitudes.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Skip guard tones (CSI HT20 typically has amp[0] = 0 for DC, plus
|
|
||||||
// edge nulls).
|
|
||||||
let valid: Vec<f64> = amplitudes.iter().copied().filter(|&v| v > 0.0).collect();
|
|
||||||
if valid.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let broadband_mean: f64 = valid.iter().sum::<f64>() / valid.len() as f64;
|
|
||||||
|
|
||||||
let mut map = amp_hist_init().lock().unwrap();
|
let mut map = amp_hist_init().lock().unwrap();
|
||||||
let st = map.entry(node_id).or_insert_with(|| AmpState {
|
let st = map.entry(node_id).or_insert_with(|| AmpState {
|
||||||
short: VecDeque::with_capacity(AMP_SHORT_WIN),
|
short: VecDeque::with_capacity(AMP_SHORT_WIN),
|
||||||
long: VecDeque::with_capacity(AMP_LONG_WIN),
|
long: VecDeque::with_capacity(AMP_LONG_WIN),
|
||||||
|
nbvi_history: VecDeque::with_capacity(NBVI_HISTORY_LEN),
|
||||||
|
nbvi_selected: Vec::new(),
|
||||||
|
nbvi_ticks: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Push current frame into NBVI history for ranking.
|
||||||
|
st.nbvi_history.push_back(amplitudes.to_vec());
|
||||||
|
while st.nbvi_history.len() > NBVI_HISTORY_LEN { st.nbvi_history.pop_front(); }
|
||||||
|
|
||||||
|
// Refresh NBVI selection periodically.
|
||||||
|
st.nbvi_ticks = st.nbvi_ticks.saturating_add(1);
|
||||||
|
if st.nbvi_selected.is_empty() || st.nbvi_ticks >= NBVI_REFRESH_TICKS {
|
||||||
|
st.nbvi_selected = nbvi_select_top_k(&st.nbvi_history, NBVI_TOP_K);
|
||||||
|
st.nbvi_ticks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute broadband_mean. Use the NBVI-selected subset when
|
||||||
|
// available — it tracks body modulation much more cleanly than the
|
||||||
|
// full vector. Falls back to all non-zero subcarriers during
|
||||||
|
// warmup when NBVI hasn't ranked yet.
|
||||||
|
let broadband_mean: f64 = if !st.nbvi_selected.is_empty() {
|
||||||
|
let mut sum = 0.0; let mut cnt = 0;
|
||||||
|
for &k in &st.nbvi_selected {
|
||||||
|
if k < amplitudes.len() && amplitudes[k] > 0.0 {
|
||||||
|
sum += amplitudes[k]; cnt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cnt == 0 { return None; }
|
||||||
|
sum / cnt as f64
|
||||||
|
} else {
|
||||||
|
let valid: Vec<f64> = amplitudes.iter().copied().filter(|&v| v > 0.0).collect();
|
||||||
|
if valid.is_empty() { return None; }
|
||||||
|
valid.iter().sum::<f64>() / valid.len() as f64
|
||||||
|
};
|
||||||
|
|
||||||
st.short.push_back(broadband_mean);
|
st.short.push_back(broadband_mean);
|
||||||
while st.short.len() > AMP_SHORT_WIN { st.short.pop_front(); }
|
while st.short.len() > AMP_SHORT_WIN { st.short.pop_front(); }
|
||||||
st.long.push_back(broadband_mean);
|
st.long.push_back(broadband_mean);
|
||||||
|
|
@ -272,9 +377,13 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo
|
||||||
/// fusion and from `build_node_features` so the UI can show per-node
|
/// fusion and from `build_node_features` so the UI can show per-node
|
||||||
/// labels. No hysteresis is applied here; that's a global property.
|
/// labels. No hysteresis is applied here; that's a global property.
|
||||||
fn amp_node_level(cv: f64, mean_short: f64, baseline: Option<f64>) -> (&'static str, bool) {
|
fn amp_node_level(cv: f64, mean_short: f64, baseline: Option<f64>) -> (&'static str, bool) {
|
||||||
if cv >= 0.30 {
|
// ADR-102: NBVI subcarrier selection drops baseline CV from ~5-7 %
|
||||||
|
// down to ~3-4 % in a quiet room. Thresholds tightened proportionally
|
||||||
|
// (was 30/15, now 22/10) so subtle motion gets flagged without
|
||||||
|
// raising the false-positive rate.
|
||||||
|
if cv >= 0.22 {
|
||||||
("active", true)
|
("active", true)
|
||||||
} else if cv >= 0.15 {
|
} else if cv >= 0.10 {
|
||||||
("present_moving", true)
|
("present_moving", true)
|
||||||
} else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
|
} else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
|
||||||
("present_still", true)
|
("present_still", true)
|
||||||
|
|
@ -330,9 +439,9 @@ fn amp_classify_from_latest() -> Option<(String, bool, f64)> {
|
||||||
matches!(b, Some(bv) if *bv > 0.0 && (*m / *bv) < 0.75)
|
matches!(b, Some(bv) if *bv > 0.0 && (*m / *bv) < 0.75)
|
||||||
});
|
});
|
||||||
|
|
||||||
let candidate = if max_cv >= 0.30 {
|
let candidate = if max_cv >= 0.22 {
|
||||||
"active"
|
"active"
|
||||||
} else if max_cv >= 0.15 {
|
} else if max_cv >= 0.10 {
|
||||||
"present_moving"
|
"present_moving"
|
||||||
} else if any_baseline_drop {
|
} else if any_baseline_drop {
|
||||||
"present_still"
|
"present_still"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue