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>
This commit is contained in:
ruv
2026-06-14 00:05:59 -04:00
parent 1d12e8831a
commit 8416a4d337
2 changed files with 262 additions and 4 deletions
@@ -38,6 +38,30 @@
/* ---- Multi-person ---- */
#define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */
/* ---- Multi-person counting gates (issue #998) ----
*
* Over-counting root cause: the multi-person path used to split the top-K
* subcarriers into EDGE_MAX_PERSONS groups and mark EVERY group active,
* so one body's multipath always reported the full EDGE_MAX_PERSONS. These
* gates promote a subcarrier group to a real "person" only when it carries
* genuine, distinct, persistent energy:
*
* 1. Energy gate — a group's phase variance must exceed a fraction of the
* strongest group's variance, else it is multipath/noise.
* 2. Spatial dedup — two groups whose representative subcarriers sit within
* EDGE_PERSON_MIN_SC_SEP of each other are the same body
* (adjacent subcarriers see correlated reflections), so
* the weaker one is merged away.
* 3. Persistence — a candidate count must hold for EDGE_PERSON_PERSIST_FRAMES
* consecutive decisions before it is emitted, so a single
* noisy frame cannot promote a phantom person.
*
* These are robustness gates on the existing heuristic, not a calibrated
* occupancy model — true count accuracy vs ground truth remains data-gated. */
#define EDGE_PERSON_MIN_ENERGY_RATIO 0.35f /**< Group var must be >= this * max group var to count. */
#define EDGE_PERSON_MIN_SC_SEP 4 /**< Min subcarrier separation between distinct persons. */
#define EDGE_PERSON_PERSIST_FRAMES 3 /**< Consecutive decisions a count must hold before emit. */
/* ---- Calibration ---- */
#define EDGE_CALIB_FRAMES 1200 /**< Frames for adaptive calibration (~60s at 20 Hz). */
#define EDGE_CALIB_SIGMA_MULT 3.0f /**< Threshold = mean + 3*sigma of ambient. */
@@ -46,6 +70,27 @@
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
/* ---- Presence flag hysteresis + debounce (issue #996) ----
*
* Flicker root cause: the presence flag was a single-threshold compare on a
* noisy presence_score (observed 2.6-26.7 frame-to-frame for one stationary
* person), so the boolean chattered at the boundary even while the score
* clearly indicated a person. Fix: Schmitt-trigger hysteresis plus a clear
* debounce.
*
* - Assert presence when score > threshold (enter immediately).
* - Hold presence while score >= threshold * HYST_RATIO (no flicker in the
* gap band).
* - Clear presence only after the score stays below the low threshold for
* EDGE_PRESENCE_CLEAR_FRAMES consecutive frames (genuine departure).
*
* HYST_RATIO < 1.0 sets the low threshold below the high threshold; a wider gap
* (smaller ratio) is more flicker-immune but slower to clear on real exit. The
* exact ratio that best matches a given room's score scale remains an on-device
* tuning parameter — this removes the logic bug (no hysteresis at all). */
#define EDGE_PRESENCE_HYST_RATIO 0.5f /**< Low thresh = HYST_RATIO * high thresh. */
#define EDGE_PRESENCE_CLEAR_FRAMES 5 /**< Frames below low thresh before clearing. */
/* ---- DSP task tuning ---- */
#define EDGE_BATCH_LIMIT 4 /**< Max frames per batch before longer yield. */