From eec3ca6ce2a716d6dda9a75da9c455634a13765f Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 14:14:13 +0700 Subject: [PATCH] feat(adr-104): per-sub drift in WS + raw.html sparkline + staleness watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related ADR-104 follow-ups: 1. Expose per-node drift_score on PerNodeFeatureInfo (skip-if-none so legacy v1 baseline.json — no per_subcarrier_mean — emits nothing instead of misleading 0.0). 2. raw.html drift sparkline below the RSSI/broadband trace, fixed Y range [0, 0.30] with dashed presence (0.10) + warning (0.15) thresholds so operators can read off-axis presence across nodes without re-scaling. Stat pill "drift" shows the live numeric. 3. baseline_staleness_watch background task: when the on-disk baseline is older than --baseline-stale-age-sec (default 4 h) AND drift > 1.5× presence threshold for ≥3 consecutive 5-min ticks while the classifier reports `absent`, logs a warning suggesting recalibration. Rate-limited via --baseline-stale-warn-cooldown-sec (default 1 h). Independent from auto-recalibrate: that one needs a quiet room; this one fires when the operator is *in* the room while the channel itself has physically shifted (AP moved, furniture, etc.). Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/src/main.rs | 148 ++++++++++++++++++ .../static/raw.html | 76 +++++++++ 2 files changed, 224 insertions(+) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 5d4fdf93..c84d6d22 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -964,6 +964,20 @@ struct Args { #[arg(long, default_value = "3600")] auto_recalibrate_min_age_sec: f64, + /// ADR-104: warn when the on-disk baseline is older than this many + /// seconds AND the per-subcarrier drift channel has been firing while + /// the classifier reports `absent`. Default 14400 = 4 h. Set to 0 to + /// disable the watcher. Independent from --auto-recalibrate-*: that + /// path needs a quiet room, this one flags channels that *can't* get + /// quiet (operator working in the room while the AP physically moved). + #[arg(long, default_value = "14400")] + baseline_stale_age_sec: f64, + + /// ADR-104: cool-down (seconds) between baseline-stale warnings. + /// Default 3600 = at most once per hour. + #[arg(long, default_value = "3600")] + baseline_stale_warn_cooldown_sec: f64, + /// Path to UI static files #[arg(long, default_value = "../../ui")] ui_path: PathBuf, @@ -1406,6 +1420,13 @@ struct PerNodeFeatureInfo { /// emit, UI heatmap) read this to decide whether to escalate. #[serde(skip_serializing_if = "Option::is_none")] novelty_score: Option, + /// ADR-104 per-subcarrier drift score = mean |Δ amp / baseline| + /// over subcarriers with baseline > 1.0. `None` if no per-sub + /// baseline is loaded for this node (legacy v1 baseline.json or no + /// `per_subcarrier_mean` field). Operators read this in raw.html + /// to see the off-axis presence channel firing in real time. + #[serde(skip_serializing_if = "Option::is_none")] + drift_score: Option, } /// Build a per-node feature snapshot for the WebSocket envelope. @@ -1459,6 +1480,13 @@ fn build_node_features( confidence: ns.smoothed_person_score.clamp(0.0, 1.0), }, }; + // ADR-104: surface the per-subcarrier drift score for this + // node (None if no per-sub baseline is loaded — distinguishes + // "channel unknown" from "channel known and stable at 0.0"). + let drift_score = { + let m = amp_drift_init().lock().unwrap(); + m.get(&node_id).copied() + }; PerNodeFeatureInfo { node_id, features, @@ -1468,6 +1496,7 @@ fn build_node_features( frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here. stale, novelty_score: ns.last_novelty_score, + drift_score, } }) .collect(); @@ -5069,6 +5098,117 @@ async fn auto_recalibrate_task( } } +/// ADR-104: background watch — when the per-subcarrier drift channel is +/// consistently above the presence threshold AND the on-disk baseline is +/// older than `stale_age_sec`, log a warning suggesting recalibration. +/// Independent from `auto_recalibrate_task`: that one needs a quiet room +/// (no person), this one fires when the operator is *in* the room but +/// the channel itself has shifted (AP moved, furniture, etc.) so a real +/// stillness window won't be reached and silent re-cal can't help. +/// +/// Rate-limited to one warning per `warn_cooldown_sec` to avoid log spam. +async fn baseline_staleness_watch( + state: SharedState, + stale_age_sec: f64, + warn_cooldown_sec: f64, +) { + use std::time::{Duration, Instant}; + + if stale_age_sec <= 0.0 { + info!("Baseline staleness watch disabled (--baseline-stale-age 0)"); + return; + } + + // Drift must exceed this fraction of subcarriers (1.5× the presence + // trigger) for the streak to count. Empirical: at the presence-trigger + // level (0.10) we can be misclassifying real motion; at 1.5× the + // signal is unambiguously channel-level drift. + let drift_warn_thresh = AMP_DRIFT_PRESENCE_THRESH * 1.5; + + // How many consecutive 5-min ticks of `quiet+drift-high` are needed + // before we warn. 3 → 15 minutes of persistent symptom. + const REQUIRED_STREAK: u32 = 3; + + info!( + "Baseline staleness watch enabled: warn when baseline age > {:.0}s AND per-sub drift > {:.2} for ≥{} consecutive ticks (cooldown {:.0}s)", + stale_age_sec, drift_warn_thresh, REQUIRED_STREAK, warn_cooldown_sec + ); + + let mut tick = tokio::time::interval(Duration::from_secs(300)); // 5 min + let mut streak: u32 = 0; + let mut last_warn: Option = None; + + loop { + tick.tick().await; + + // Skip if no baseline ever loaded (BASELINE_LAST_WRITTEN == UNIX_EPOCH). + let age_sec = { + let t = baseline_last_written_init().lock().unwrap(); + match t.elapsed() { + Ok(d) => d.as_secs_f64(), + Err(_) => continue, + } + }; + // No persistent baseline yet — staleness doesn't apply. + let have_baseline = !amp_baseline_per_sub_init().lock().unwrap().is_empty(); + if !have_baseline { + streak = 0; + continue; + } + + let drift = amp_drift_max(); + + // We can't tell stale-channel from real-presence on drift alone. + // If classifier currently reports presence, this tick is inconclusive + // (presence naturally drives drift up). Don't reset streak so a + // transient walk-through doesn't erase prior evidence, but also + // don't increment it. + let presence_now = { + let s = state.read().await; + s.latest_update + .as_ref() + .map(|u| u.classification.presence) + .unwrap_or(false) + }; + + if presence_now { + // Inconclusive tick — don't touch streak. + continue; + } + + // Room is reported empty AND drift is high → suspect stale baseline. + if age_sec > stale_age_sec && drift > drift_warn_thresh { + streak = streak.saturating_add(1); + } else { + streak = 0; + } + + if streak < REQUIRED_STREAK { + continue; + } + + let cooldown_ok = match last_warn { + None => true, + Some(t) => t.elapsed().as_secs_f64() >= warn_cooldown_sec, + }; + if !cooldown_ok { + continue; + } + + warn!( + "baseline-stale: per-sub drift {:.3} (>{:.2}) for {}× 5-min ticks while room reports `absent` and baseline is {:.1} h old — recommend recalibration (POST /api/v1/baseline/calibrate or `python scripts/record-baseline.py`)", + drift, + drift_warn_thresh, + streak, + age_sec / 3600.0, + ); + last_warn = Some(Instant::now()); + // Don't reset streak; if conditions persist, the cooldown alone + // throttles further warnings, and we want to keep counting so an + // operator who clears one warning still gets re-warned eventually. + } +} + async fn udp_receiver_task(state: SharedState, udp_port: u16) { let addr = format!("0.0.0.0:{udp_port}"); let socket = match UdpSocket::bind(&addr).await { @@ -6471,6 +6611,14 @@ async fn main() { args.auto_recalibrate_min_age_sec, 90.0, // capture window )); + // ADR-104: warn when baseline is stale AND drift channel is + // firing in `absent` periods (channel-level shift the auto + // path can't fix because room never goes quiet). + tokio::spawn(baseline_staleness_watch( + state.clone(), + args.baseline_stale_age_sec, + args.baseline_stale_warn_cooldown_sec, + )); tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); } "wifi" => { diff --git a/v2/crates/wifi-densepose-sensing-server/static/raw.html b/v2/crates/wifi-densepose-sensing-server/static/raw.html index b97ee904..20cf8249 100644 --- a/v2/crates/wifi-densepose-sensing-server/static/raw.html +++ b/v2/crates/wifi-densepose-sensing-server/static/raw.html @@ -32,6 +32,7 @@ canvas { display:block; width:100%; background:#0a0e13; border-radius:3px; } canvas.bars { height: 130px; } canvas.trace { height: 130px; } + canvas.spark { height: 48px; margin-top: 6px; } .lbl { color:#666; font-size:10px; font-family:JetBrains Mono,monospace; margin:2px 0 0; } .controls { display:flex; gap:8px; margin-left:auto; } .controls label { font-size:11px; color:#aaa; } @@ -86,6 +87,7 @@ function ensureNodeBlock(nodeId) { peak: [], rssiHist: [], // { t, v } meanAmpHist: [], + driftHist: [], // { t, v } — ADR-104 per-sub drift score lastTs: 0, frames: 0, lastFrameWall: performance.now(), @@ -105,6 +107,7 @@ function ensureNodeBlock(nodeId) { rssi -- dBm mean A 0 peak A 0 + drift -- node fps 0
@@ -115,6 +118,8 @@ function ensureNodeBlock(nodeId) {

RSSI   broadband mean amplitude   (last ${TRACE_SEC}s)

+ +

per-sub drift — off-axis presence channel (ADR-104); dashed line = presence threshold 0.10

`; document.getElementById('nodes').appendChild(wrap); @@ -215,6 +220,52 @@ function drawTrace(canvas, rssiHist, meanAmpHist) { ctx.moveTo(w - 1, 0); ctx.lineTo(w - 1, h); ctx.stroke(); } +// ADR-104: per-sub drift sparkline. Fixed Y range [0, 0.30] so the +// presence threshold (0.10, dashed) and warning threshold (0.15) are +// directly readable across nodes — re-scaling per node would make it +// impossible to tell "Node 0 fired" from "Node 1 fired" at a glance. +const DRIFT_PRESENCE_THRESH = 0.10; +const DRIFT_WARN_THRESH = 0.15; +const DRIFT_MAX = 0.30; + +function drawDriftSpark(canvas, hist) { + const w = canvas.clientWidth, h = canvas.clientHeight; + if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h); + + const now = performance.now() / 1000; + const t0 = now - TRACE_SEC; + const yOf = v => h - (Math.min(v, DRIFT_MAX) / DRIFT_MAX) * (h - 4) - 2; + + // Threshold lines. + ctx.setLineDash([3, 3]); + ctx.strokeStyle = '#5a4a1a'; ctx.lineWidth = 1; ctx.beginPath(); + ctx.moveTo(0, yOf(DRIFT_PRESENCE_THRESH)); ctx.lineTo(w, yOf(DRIFT_PRESENCE_THRESH)); + ctx.stroke(); + ctx.strokeStyle = '#7a3030'; ctx.beginPath(); + ctx.moveTo(0, yOf(DRIFT_WARN_THRESH)); ctx.lineTo(w, yOf(DRIFT_WARN_THRESH)); + ctx.stroke(); + ctx.setLineDash([]); + + const visible = hist.filter(p => p.t >= t0); + if (visible.length >= 2) { + ctx.strokeStyle = '#d29922'; ctx.lineWidth = 1.5; ctx.beginPath(); + for (let i = 0; i < visible.length; i++) { + const p = visible[i]; + const x = ((p.t - t0) / TRACE_SEC) * w; + const y = yOf(p.v); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + // Axis text. + ctx.fillStyle = '#666'; ctx.font = '9px monospace'; + ctx.fillText('0', 2, h - 2); + ctx.fillText(DRIFT_MAX.toFixed(2), 2, 10); +} + // ── Frame ingestion ──────────────────────────────────────────────── function handleSensingUpdate(d) { const nodes = d.nodes || []; @@ -290,6 +341,7 @@ function handleSensingUpdate(d) { if (gcv) gcv.textContent = 'CV ' + ((gcl.confidence || 0) * 100).toFixed(1) + '%'; // Per-node level badge from node_features[i].classification (ADR-101). + const nfNow = performance.now() / 1000; const nf = d.node_features || []; for (const f of nf) { const id = f.node_id; @@ -302,6 +354,28 @@ function handleSensingUpdate(d) { } const cvEl = document.getElementById(`n${id}-cv`); if (cvEl) cvEl.textContent = ((cls.confidence || 0) * 100).toFixed(1) + '%'; + + // ADR-104 per-sub drift score (off-axis presence). May be absent + // when no per-sub baseline is loaded for this node — show '--' + // instead of '0.000' so the operator can tell the channel is + // unknown vs. known and stable. + const driftEl = document.getElementById(`n${id}-drift`); + const driftLive = state.get(id); + if (typeof f.drift_score === 'number' && Number.isFinite(f.drift_score)) { + if (driftEl) driftEl.textContent = f.drift_score.toFixed(3); + if (driftLive) { + driftLive.driftHist.push({ t: nfNow, v: f.drift_score }); + const cutoff = nfNow - TRACE_SEC; + while (driftLive.driftHist.length && driftLive.driftHist[0].t < cutoff) { + driftLive.driftHist.shift(); + } + if (driftLive.driftHist.length > TRACE_MAX_PTS) { + driftLive.driftHist.splice(0, driftLive.driftHist.length - TRACE_MAX_PTS); + } + } + } else if (driftEl) { + driftEl.textContent = '--'; + } } frameCount++; } @@ -310,8 +384,10 @@ function renderTick() { for (const [id, ent] of state) { const bars = document.getElementById('n' + id + '-bars'); const trace = document.getElementById('n' + id + '-trace'); + const spark = document.getElementById('n' + id + '-driftSpark'); if (bars) drawBars(bars, ent.amp, ent.peak); if (trace) drawTrace(trace, ent.rssiHist, ent.meanAmpHist); + if (spark) drawDriftSpark(spark, ent.driftHist); } // fps pill const now = performance.now();