feat(adr-104): per-sub drift in WS + raw.html sparkline + staleness watch
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 <ruv@ruv.net>
This commit is contained in:
parent
598a4b2f6b
commit
eec3ca6ce2
|
|
@ -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<f32>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
/// 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<Instant> = 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" => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<span class="stat">rssi <b id="n${nodeId}-rssi">--</b> dBm</span>
|
||||
<span class="stat">mean A <b id="n${nodeId}-meanA">0</b></span>
|
||||
<span class="stat">peak A <b id="n${nodeId}-peakA">0</b></span>
|
||||
<span class="stat">drift <b id="n${nodeId}-drift">--</b></span>
|
||||
<span class="stat">node fps <b id="n${nodeId}-fps">0</b></span>
|
||||
</h2>
|
||||
<div class="row">
|
||||
|
|
@ -115,6 +118,8 @@ function ensureNodeBlock(nodeId) {
|
|||
<div>
|
||||
<canvas class="trace" id="n${nodeId}-trace"></canvas>
|
||||
<p class="lbl"><span style="color:#8b949e">RSSI</span> <span style="color:#3fb950">broadband mean amplitude</span> (last ${TRACE_SEC}s)</p>
|
||||
<canvas class="spark" id="n${nodeId}-driftSpark"></canvas>
|
||||
<p class="lbl"><span style="color:#d29922">per-sub drift</span> — off-axis presence channel (ADR-104); dashed line = presence threshold 0.10</p>
|
||||
</div>
|
||||
</div>`;
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue