fix: ESP32 vitals over-count + presence flicker (#998/#996) + Observatory per-person position/motion (#1050) (#1060)

* fix(firmware): gate phantom persons + add presence hysteresis (#998, #996)

Two ESP32 edge-vitals logic bugs in edge_processing.c. Both are
robustness/logic fixes — NOT validated-accuracy claims. True count/PCK
vs labelled ground truth remains hardware/data-gated (COM9 ESP32-S3).

#998 — n_persons over-counted (reported 4 for one person):
update_multi_person_vitals() split top-K subcarriers into top_k_count/2
groups and marked EVERY group active, so one body's multipath always
read the full EDGE_MAX_PERSONS. Added two pure, host-testable helpers:
  - count_distinct_persons(): per-group energy gate
    (EDGE_PERSON_MIN_ENERGY_RATIO) + spatial dedup
    (EDGE_PERSON_MIN_SC_SEP) so weak/adjacent multipath groups don't
    count as separate bodies. Strongest group always counts (>=1).
  - person_count_debounce(): a gated count must hold
    EDGE_PERSON_PERSIST_FRAMES consecutive frames before it's emitted,
    so a single noisy frame can't promote a phantom.
The active flags now mark only the strongest stable_count groups.

#996 — presence flag flickered at ~50cm despite high presence_score:
the bare `score > threshold` compare chattered on a noisy score
(field-observed 2.6-26.7 frame-to-frame). Replaced with a Schmitt
trigger + clear-debounce (presence_flag_update): assert above
threshold, hold in the dead band down to threshold *
EDGE_PRESENCE_HYST_RATIO, clear only after EDGE_PRESENCE_CLEAR_FRAMES
consecutive sub-low frames. presence_score itself is unchanged and
still emitted for consumer-side thresholding.

All thresholds are named, documented constants in edge_processing.h.
Firmware builds clean for esp32s3 (idf.py build RC=0).

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(firmware): host C99 tests for vitals count + presence logic (#998, #996)

test/test_vitals_count_presence.c pins the two fixes with deterministic
host-buildable tests (no ESP-IDF needed). 13 cases / 22 assertions, all
passing under gcc 13 -Wall -Wextra:

  #998 count gate: single strong signature + multipath -> count==1;
  two well-separated -> 2; two strong-but-adjacent -> 1 (dedup);
  no signal -> 0; three well-separated -> 3.
  #998 debounce: transient spike rejected; sustained change accepted;
  flapping count stays stable.
  #996 presence: dithering trace -> stable flag (no flicker); brief dips
  held by clear-debounce; genuine departure clears within hold window;
  dead-band holds state.

The named tuning constants are #include'd from the real
edge_processing.h so the test and firmware can never disagree on
thresholds. `make run_vitals` / `make host_tests` added; binaries
gitignored.

Hardware-gated caveat documented in the test header: these pin the
decision LOGIC; the exact energy/separation/hysteresis values that best
match a real room vs labelled occupancy remain on-device tuning.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: record ESP32 vitals count/presence fixes (#998, #996)

CHANGELOG [Unreleased] Fixed: root cause + fix + named constants + test
+ explicit hardware/data-gated caveat for both bugs.

ADR-021 Implementation Notes: dated 2026-06 entry noting the edge-path
person-count + presence-flicker fixes are boolean/count emission-logic
fixes, not a validated-accuracy claim; thresholds pending on-device
calibration.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(sensing-server): emit real field-derived person position/motion to /ws/sensing (#1050)

The Observatory 3D figure never animated because the sensing_update WS
frame carried no per-person position/motion_score/pose — only image-space
keypoints. The FigurePool/PoseSystem (and demo-data.js's own contract)
animate each figure from persons[i].position (room-world), .motion_score
(0..100), and .pose; none were on the live stream.

Honest scope (Case 2): the pipeline has no calibrated per-person room
localizer or per-person skeletal pose. New field_localize module extracts
the strongest peak(s) from the real signal_field grid (subcarrier
variances x motion-band power) and maps the peak cell to Observatory world
coords with the exact _buildSignalField transform. motion_score is the
measured motion_band_power passed through; pose is set only from a real
aggregate posture estimate, else None (never a fabricated skeleton).
Empty/below-threshold field -> persons: [] (no phantom); present person
with no resolvable peak keeps position [0,0,0], not invented coords.

attach_field_positions runs after the tracker step at all five broadcast
sites. New position/motion_score/pose fields added to both PersonDetection
structs. No UI change needed — the Observatory already reads these fields.

Tests: field_localize peak/coordinate/empty/separation units +
observatory_persons_field_position_tests (known-peak -> emitted position,
empty-room -> no phantom, pose real-or-None, below-threshold honesty).
sensing-server bin 441->451, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): record #1050 Observatory persons position/motion fix

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-06-14 00:31:30 -04:00
committed by GitHub
parent 1d12e8831a
commit 0c2b1c16cc
12 changed files with 1215 additions and 9 deletions
+17 -5
View File
@@ -43,9 +43,10 @@ MAIN_DIR = ../main
FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 host_tests
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals host_tests
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -57,8 +58,19 @@ test_adr110: test_adr110_encoding.c
run_adr110: test_adr110
./test_adr110
host_tests: run_adr110
@echo "ADR-110 host tests passed"
# --- Vitals count + presence logic unit tests (issue #998 / #996) ---
# Host-side, no libFuzzer. Pins the person-count gate (no over-count for one
# body) and the presence hysteresis (no flicker on a dithering score). Pulls
# the named tuning constants from ../main/edge_processing.h so the test and the
# firmware can never disagree on thresholds.
test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -o $@ $< -lm
run_vitals: test_vitals
./test_vitals
host_tests: run_adr110 run_vitals
@echo "Host tests passed (ADR-110 + vitals #998/#996)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -94,5 +106,5 @@ run_nvs: fuzz_nvs
run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/