Skip to content

Fix antenna-regime presence over-read: de-saturate motion_score + correct CSI header offsets#923

Open
williammalone wants to merge 1 commit into
ruvnet:mainfrom
williammalone:presence-adaptive-p95
Open

Fix antenna-regime presence over-read: de-saturate motion_score + correct CSI header offsets#923
williammalone wants to merge 1 commit into
ruvnet:mainfrom
williammalone:presence-adaptive-p95

Conversation

@williammalone
Copy link
Copy Markdown

Summary

Fixes a presence over-read that appeared on an ESP32-S3 CSI mesh after external IPEX antennas were added: a confirmed-empty room read "present" indefinitely. Two independent root-cause bugs in wifi-densepose-sensing-server:

1. motion_score saturation (presence path never migrated off fixed divisors)

variance_motion and mbp_motion in extract_features_from_frame used hardcoded divisors (/10, /25) tuned for the antenna-less regime. Antennas raised amplitudes ~5× and these amplitude² energies ~30×, pinning both terms at the 1.0 clamp. A saturated term carries no information, so raw_motion could never fall near the presence floor and the adaptive baseline subtraction in smooth_and_classify was defeated.

Fix: normalize both by signal power (mean_amp²) — the same dimensionless sqrt-of-power-ratio form already used by temporal_motion_score in the same function — making motion_score amplitude-scale-invariant. The dominant temporal_motion_score term already did this, which is why only the other two terms saturated. This fixes the single shared extract_features_from_frame used by both the aggregate and per-node classification paths.

2. parse_esp32_frame header offsets off by 2 bytes vs firmware

The parser read sequence/rssi/noise_floor two bytes early relative to the firmware layout (firmware/esp32-csi-node/main/csi_collector.ccsi_serialize_frame: seq @ [12..16), rssi @ [16], noise_floor @ [17]). rssi was decoded from sequence-counter byte 2 — which stays 0 for the first 65,536 frames — yielding an impossible rssi = 0 dBm that zeroed the RSSI fusion weights and the SNR-based signal_quality. The I/Q payload at byte 20 (CSI_HEADER_SIZE) was already correct, so amplitude-derived features were unaffected.

Tests

  • New regression tests: motion_score is amplitude-scale-invariant (1× vs 5×), and a quiet high-amplitude signal does not saturate while a moving one scores higher (dynamic range preserved).
  • Full binary unit suite green: 103 passed.

Validation (live, 2-node mesh, real CSI)

  • RSSI now reports real values (−28…−74 dBm); the rssi=0 sentinel is gone.
  • An empty room now produces genuine low-motion frames (mbp ~4), impossible before — the ceiling clamp previously made every frame look like ~180.

Known residual (tracked separately)

Genuine multi-subcarrier CSI still reads elevated (mbp ~180) in an empty room — intrinsic antenna multipath noise where empty-but-noisy and motionless-person overlap. This needs a learned empty-room reference (adaptive-classifier retrain on de-saturated features), not threshold math. These two fixes are the necessary prerequisite for that work.

🤖 Generated with Claude Code

…rect CSI header offsets

After external IPEX antennas were added to the ESP32-S3 mesh nodes, a confirmed-empty
room read "present" indefinitely. Two root-cause bugs:

1. motion_score saturation. `variance_motion` and `mbp_motion` used fixed divisors
   (/10, /25) calibrated for the antenna-less regime. Antennas raised amplitudes ~5x
   and these amplitude^2 energies ~30x, pinning both terms at the 1.0 clamp — so
   raw_motion could not fall near the presence floor and the adaptive baseline
   subtraction in smooth_and_classify was defeated. Normalize both by signal power
   (mean_amp^2) — the same dimensionless sqrt-of-power-ratio form already used by
   temporal_motion_score — making motion_score amplitude-scale-invariant. This fixes
   the single shared extract_features_from_frame used by BOTH the aggregate and the
   per-node paths, so room-level presence benefits too. (csi.rs carries the identical
   change in its dead mirror copy to keep the two in sync.)

2. parse_esp32_frame header offsets were 2 bytes early vs the firmware layout
   (csi_collector.c csi_serialize_frame: seq @ [12..16), rssi @ [16], noise_floor @
   [17]). rssi was decoded from sequence-counter byte 2 — which stays 0 for the first
   65,536 frames — yielding an impossible rssi=0 dBm that zeroed the RSSI fusion
   weights and the SNR-based signal_quality. The I/Q payload at byte 20 was already
   correct (CSI_HEADER_SIZE == 20), so amplitude-derived features were unaffected.

Adds regression tests asserting motion_score is amplitude-scale-invariant and that a
quiet high-amplitude signal does not saturate. Full binary suite green (103 tests).

Validated live on the 2-node mesh: RSSI now reports real values (-28..-74 dBm, was 0)
and an empty room now produces genuine low-motion frames. A residual over-read remains
(real multi-subcarrier CSI reads elevated even when empty) — that intrinsic empty-vs-
still ambiguity needs a learned reference (adaptive classifier retrain), tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ruvnet
Copy link
Copy Markdown
Owner

ruvnet commented Jun 3, 2026

Reviewed — both fixes are correct. 👍

CSI header offsets: verified against the firmware serializer on main (firmware/esp32-csi-node/main/csi_collector.c):

  • memcpy(&buf[12], &seq, 4) → seq at [12..16) (line 170)
  • buf[16] = rssi (line 173)
  • buf[17] = noise_floor (line 176)

The old parser read seq at [10..14), rssi at buf[14], noise at buf[15] — 2 bytes early, contradicting even its own layout comment. Because freq_mhz occupies the full [8..12) (4-byte memcpy), the old rssi read landed on the sequence counter's 3rd byte, which stays 0 for the first 65 536 frames → rssi = 0. Your [12..16)/[16]/[17] exactly matches the firmware. Correct.

motion_score de-saturation: normalizing variance_motion/mbp_motion by mean_amp² (the same dimensionless power-ratio form temporal_motion_score already uses) is the right call — it makes the term amplitude-scale-invariant so external-antenna gain no longer pins it at the 1.0 clamp. Sound, and it correctly lands in the shared extract_features_from_frame used by both the aggregate and per-node paths.

Heads-up — needs a rebase (my fault). GitHub now shows this CONFLICTING/DIRTY. The conflict is not in the fix itself; it's the new tests you append at EOF (@@ -6787 … mod rolling_p95_tests). Three PRs I merged after you opened this (#918 per-node MQTT, #919 model-load diagnostic, #920 export-rvf guard) each added a test module at that same spot, plus shifted main.rs line numbers. A git rebase origin/main should be clean — just move your new test module(s) after the now-present mqtt_bridge_tests / model_load_diagnostic_tests / export_rvf_mode_tests, and re-anchor the parse_esp32_frame / extract_features_from_frame hunks (their function bodies are unchanged by my PRs, so the offset/motion edits apply as-is).

LGTM to merge once rebased.

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.

2 participants