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:
arsen 2026-05-17 02:44:16 +07:00
parent c22dfcd256
commit 2f12a2236b
1 changed files with 121 additions and 12 deletions

View File

@ -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"