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:
parent
6604adae18
commit
c3126c39a3
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue