Skip to content

fix(firmware): normalize presence_score and motion_score before transmit#1286

Open
macraj wants to merge 6 commits into
ruvnet:mainfrom
macraj:fix/presence-score-normalization
Open

fix(firmware): normalize presence_score and motion_score before transmit#1286
macraj wants to merge 6 commits into
ruvnet:mainfrom
macraj:fix/presence-score-normalization

Conversation

@macraj

@macraj macraj commented Jul 8, 2026

Copy link
Copy Markdown

Summary

  • presence_score and motion_score in collect_observation() (firmware/esp32-csi-node/main/adaptive_controller.c) are both derived from the same raw, unbounded CSI phase-variance quantity (edge_processing.c sets s_presence_score = s_motion_energy directly), but were normalized inconsistently: presence_score passed through with no clamp at all, while motion_score was clamped straight to [0,1] without first scaling — since raw values routinely exceed 1.0, this saturated motion_score at 1.0 almost unconditionally.
  • Net effect: presence/motion read as "detected" essentially regardless of actual occupancy, since downstream thresholds (this file's own 0.5f quality-valid gate, and consumers like scripts/c6-presence-watcher.py's 0.40/0.20 and 0.30/0.15 hysteresis thresholds) all assume a genuine 0..1 scale.
  • The correct normalization already exists elsewhere in the codebase, in edge_processing.c's send_feature_vector() (a separate ADR-069 feature-vector wire path): p > 10.0f ? 1.0f : p / 10.0f. This PR applies the same scale-then-clamp to both fields in collect_observation().

Test plan

  • Rebuilt firmware for ESP32-C6 (idf.py build), no errors.
  • Reflashed onto a physical ESP32-C6 CSI node via USB.
  • Verified via raw serial log that motion and presence now report identical, fluctuating values (e.g. motion=0.18 presence=0.18, motion=1.00 presence=1.00) instead of motion being pinned at 1.00.
  • Verified via host-side watcher (c6-presence-watcher.py) that avg_motion/avg_presence now match in every window and vary meaningfully (0.5–1.0 while near the device).
  • Verified real empty-room behavior: left the room, avg_presence dropped to 0.00 and motion/occupancy both went to False, holding steady for 2.5+ minutes — confirming the original false-positive ("presence detected" in an empty room) is resolved.
  • Existing host unit tests (tests/host/test_adaptive_controller.c) don't exercise collect_observation()'s normalization path directly (they set presence_score on the observation struct directly), so this change doesn't touch tested behavior — no regressions expected there, but flagging that this code path had no direct unit coverage before or after this change.

macraj added 6 commits July 8, 2026 22:19
Both fields carried the same raw, unbounded phase-variance value
(edge_processing.c sets s_presence_score = s_motion_energy directly) but
were handled inconsistently downstream: presence_score passed through
uncapped, motion_score was bare-clamped to [0,1] without first scaling,
saturating at 1.0 almost unconditionally since raw values routinely
exceed 1.0. Net effect: presence/motion read as "detected" regardless of
actual occupancy. Apply the same divide-by-10-then-clamp normalization
send_feature_vector() already uses for its equivalent dimensions.
None of the existing ui/*.html pages match the running bridge server
(they expect a FastAPI backend on :8000 or a WebSocket that doesn't
exist here). Add a minimal polling page against the working
/api/v1/sensing/latest endpoint. Requires CORS since the page loads
from file:// / a different origin than the server.
…rmalization

CONFIG_LED_MOTION_FULLSCALE_MILLI defaulted to 250 (0.25), but the raw
motion_energy it colors (via the viridis LUT in main.c's 40Hz gamma
callback) commonly runs 4-14 in practice — same unnormalized-scale bug
as the presence/motion fix in adaptive_controller.c. The LED was
saturating to yellow almost unconditionally instead of tracking real
motion. Set to 10000 (10.0) to match the saturation point
collect_observation() now uses, so yellow means the same thing here as
motion_score == 1.0 does elsewhere.
Poll /api/v1/edge/registry for the set of currently-registered nodes,
then /api/v1/vitals/<node_id>/latest for each, rendering one card per
node instead of assuming a single fixed node. Each card has an
editable, localStorage-persisted floor label. Shows exactly what the
backend actually reports (one node today, since only one
c6-presence-watcher.py instance is running) rather than mocking
additional nodes — more cards appear automatically once a second
device + watcher process exists.
…alid ones

write_state()/write_feature() only ran inside the presence_valid branch,
so /tmp/ruview-last-feature.json only got a fresh ts during "confident"
readings (firmware presence_score >= 0.5). Before the presence/motion
normalization fix, presence_score was almost always saturated near max,
so this was effectively "write every packet" and the bug stayed hidden.
Now that presence_score correctly drops below 0.5 in a genuinely calm
room, long stretches of presence_valid=False meant the feature file
never got rewritten, its ts aged past ruview-sensing-server.py's 10s
staleness gate, and consumers (edge/registry, sensing/latest) reported
the node as gone — even though the device/watcher were completely
healthy and just reporting "empty room" correctly. Write on every
privacy-eligible packet regardless of presence_valid; the presence_valid
gate still controls which readings drive the motion/occupancy toggles.
ruview-sensing-server.py: background thread samples the feature file
every 30s and appends (ts, presence) to a per-node JSONL file, kept as
a rolling 24h window in memory too. New endpoint
GET /api/v1/sensing/history/<node_id>?hours=24&bucket_min=10 returns
144 ten-minute buckets (occupied fraction each) plus exact
state-transition timestamps. No backfill — history only exists from
whenever the sampler was first started.

ui/live-status.html: each node card gets an SVG timeline strip below
the stat grid — gray for no data, dark gray for confirmed-empty, green
(intensity = occupied fraction) for occupied, with thin lines marking
exact become-present/become-absent transitions. Polls the history
endpoint every 30s, independent of the 1s vitals poll.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant