feat(raw.html): per-node classification badges (ADR-101)

Surfaces the raw-amplitude classifier's per-node decision in
node_features[].classification so the UI can show which sensor is
actually seeing motion at any moment. Lets the operator visually find
the best sensor placement without physically moving things — just walk
around and watch which badge lights up.

Server side: adds amp_node_level() pure helper + amp_node_snapshot()
that reads AMP_LATEST, then plugs it into build_node_features so the
existing PerNodeFeatureInfo.classification carries the new labels.

UI: adds a global badge in the top bar and a per-node badge inline in
each h2, color-coded (grey/absent, blue/present_still, green/moving,
red/active) plus the live per-node CV %.
This commit is contained in:
arsen 2026-05-17 01:01:10 +07:00
parent 6604adae18
commit c3126c39a3
2 changed files with 70 additions and 4 deletions

View File

@ -268,6 +268,29 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo
amp_classify_from_latest()
}
/// Classify a single node's recent state — used both inside the global
/// 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 {
("active", true)
} else if cv >= 0.15 {
("present_moving", true)
} else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) {
("present_still", true)
} else {
("absent", false)
}
}
/// Per-node snapshot exposed to `build_node_features`.
fn amp_node_snapshot(node_id: u8) -> Option<(String, bool, f64)> {
let latest = amp_latest_init().lock().unwrap();
let (cv, mean_short, baseline) = latest.get(&node_id).copied()?;
let (lvl, pres) = amp_node_level(cv, mean_short, baseline);
Some((lvl.to_string(), pres, cv))
}
/// Read-only classifier: returns `(level, presence, confidence)` based on
/// whatever `amp_presence_override` has stashed for the active nodes.
/// Returns None until at least one node has reported.
@ -864,14 +887,25 @@ fn build_node_features(
change_points: 0,
spectral_power: 0.0,
});
PerNodeFeatureInfo {
node_id,
features,
classification: ClassificationInfo {
// ADR-101: prefer the raw-amplitude classifier per node when
// available. Falls back to legacy current_motion_level for
// older paths that haven't reported amplitudes yet.
let classification = match amp_node_snapshot(node_id) {
Some((level, presence, conf)) => ClassificationInfo {
motion_level: level,
presence,
confidence: conf,
},
None => ClassificationInfo {
motion_level: ns.current_motion_level.clone(),
presence: !matches!(ns.current_motion_level.as_str(), "absent"),
confidence: ns.smoothed_person_score.clamp(0.0, 1.0),
},
};
PerNodeFeatureInfo {
node_id,
features,
classification,
rssi_dbm: ns.rssi_history.back().copied().unwrap_or(0.0),
last_seen_ms,
frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here.

View File

@ -22,6 +22,11 @@
font-family:JetBrains Mono,monospace; display:flex; gap:14px; align-items:baseline; }
.node h2 .stat { color:#888; font-weight:normal; font-size:11px; }
.node h2 .stat b { color:#e6edf3; font-weight:600; }
.badge { font-family:JetBrains Mono,monospace; font-size:11px; padding:2px 8px; border-radius:3px; }
.badge.absent { background:#21262d; color:#888; }
.badge.present_still { background:#1c3a55; color:#7cb6ff; }
.badge.present_moving{ background:#3a5520; color:#90d36b; }
.badge.active { background:#552020; color:#ff7a7a; }
.row { display:grid; grid-template-columns: 1fr 360px; gap:10px; }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
canvas { display:block; width:100%; background:#0a0e13; border-radius:3px; }
@ -40,6 +45,8 @@
<span id="status" class="pill dis">disconnected</span>
<span class="pill" id="rate">0 fps</span>
<span class="pill" id="lastTs">last: --</span>
<span class="badge absent" id="globalBadge" style="font-size:13px;padding:4px 12px;">absent</span>
<span class="pill" id="globalCV">CV 0%</span>
<div class="controls">
<label>peak-hold <input type="checkbox" id="peakHold" checked></label>
<label>log-y <input type="checkbox" id="logY"></label>
@ -90,6 +97,8 @@ function ensureNodeBlock(nodeId) {
wrap.innerHTML = `
<h2>
Node ${nodeId}
<span class="badge absent" id="n${nodeId}-badge">absent</span>
<span class="stat">CV <b id="n${nodeId}-cv">0%</b></span>
<span class="stat">subc <b id="n${nodeId}-sub">0</b></span>
<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>
@ -246,6 +255,29 @@ function handleSensingUpdate(d) {
}
document.getElementById('lastTs').textContent = 'last: ' + new Date(ts * 1000).toLocaleTimeString();
// Global classification badge (ADR-101 fused).
const gcl = d.classification || {};
const glvl = gcl.motion_level || 'absent';
const gb = document.getElementById('globalBadge');
if (gb) { gb.textContent = glvl; gb.className = 'badge ' + glvl; gb.style.fontSize = '13px'; gb.style.padding = '4px 12px'; }
const gcv = document.getElementById('globalCV');
if (gcv) gcv.textContent = 'CV ' + ((gcl.confidence || 0) * 100).toFixed(1) + '%';
// Per-node level badge from node_features[i].classification (ADR-101).
const nf = d.node_features || [];
for (const f of nf) {
const id = f.node_id;
const cls = f.classification || {};
const lvl = cls.motion_level || 'absent';
const badge = document.getElementById(`n${id}-badge`);
if (badge) {
badge.textContent = lvl;
badge.className = 'badge ' + lvl;
}
const cvEl = document.getElementById(`n${id}-cv`);
if (cvEl) cvEl.textContent = ((cls.confidence || 0) * 100).toFixed(1) + '%';
}
frameCount++;
}