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.
|
||||
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 {
|
||||
short: 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();
|
||||
|
|
@ -218,19 +297,45 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo
|
|||
if amplitudes.is_empty() {
|
||||
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 st = map.entry(node_id).or_insert_with(|| AmpState {
|
||||
short: VecDeque::with_capacity(AMP_SHORT_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);
|
||||
while st.short.len() > AMP_SHORT_WIN { st.short.pop_front(); }
|
||||
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
|
||||
/// 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) {
|
||||
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)
|
||||
} else if cv >= 0.15 {
|
||||
} else if cv >= 0.10 {
|
||||
("present_moving", true)
|
||||
} else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
|
||||
("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)
|
||||
});
|
||||
|
||||
let candidate = if max_cv >= 0.30 {
|
||||
let candidate = if max_cv >= 0.22 {
|
||||
"active"
|
||||
} else if max_cv >= 0.15 {
|
||||
} else if max_cv >= 0.10 {
|
||||
"present_moving"
|
||||
} else if any_baseline_drop {
|
||||
"present_still"
|
||||
|
|
|
|||
Loading…
Reference in New Issue