4x ESP32-S3 occupancy detection - what works, what doesn't, and questions on the intende d detection methods
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 94.5k
- Forks
- 12.5k
- Avg merge
- 21h 27m
- Merged PRs (30d)
- 43
Description
Field report: 4x ESP32-S3 occupancy detection - what works, what doesn't, and questions on the intended detection methods
Following up on #1710. We ran an extended validation campaign on the
multistatic occupancy stack - including the field-model calibration path
(ADR-030/154), the coherent-cohort fusion (#1726), and our ADR-018 in-frame
timestamp extension - and hit a wall that looks architectural rather than
tuning-level. Below is the full picture with data, followed by specific
questions on the intended detection methods for count / breathing /
lying / sitting / motion. Please confirm whether we are using the system
wrong, or whether these are known gaps.
Setup
- 4x ESP32-S3 devkitc (no display, DATA capture enabled, edge_tier=2),
single channel 6. Firmware: v0.8.8 + an ADR-018 extension stamping a
u64 mesh-aligned timestamp in-frame (bytes 20..27, byte19 bit5), and our
host-side adoption of #1726 coherent-cohort fusion - Windows host + UDP relay (5005->5006), sensing-server
WDP_GUARD_INTERVAL_US=200000 WDP_SOFT_GUARD_US=100000 - Field-model calibration: 12,099 frames collected with the home empty
(both occupants out), persisted (our serialization patch) - Validation: living room, 4 nodes in corners; tests with 0, 1, and 2 people
Results matrix (probe-verified)
| Scenario | Published count | Verdict |
|---|---|---|
| Home empty (both out, settled 3+ min) | 1 (1008/1008 frames) | should be 0 |
| 1 person walking | 1 (1050/1050 frames) | OK |
| 1 person seated >2 min | 0 (absorbed into baseline) | should be 1 |
| 2 people in the room | 1 (3050/3050 frames) | should be 2 |
The system currently behaves as "presence pinned at 1": correct for
walking, blind to a second person, misses seated people, and reports
presence in an empty home.
Diagnoses (probe data, one variable at a time)
(1) The eigenvalue path is a stub in our build.
sensing-server depends on wifi-densepose-signal with
default-features = false, and the eigenvalue feature requires
openblas-src, which does not build on our Windows box (no Fortran/OpenBLAS
toolchain). So FieldModel::estimate_occupancy is the NotCalibrated stub
and occupancy runs the perturbation-energy fallback (absolute thresholds
1.0/12/25, later our adaptive variants). Question (a) below follows.
(2) AGC gain steps rescale CSI amplitude up to 29x, RSSI constant.
Frame-level capture on all 4 nodes simultaneously: per-frame mean amplitude
swings 10 to 290 within a single node while RSSI stays at -50..-55 dBm. All
nodes show the same distribution. This is receiver AGC switching discrete
gain states, and it poisons every amplitude-based channel: the perturbation
energy, the breathing-amplitude buffer, and the peak-ratio confidence all
track the gain, not bodies. Empty-home energy oscillated 13 to 190 (bimodal),
swamping the human signature entirely.
(3) Energy is INVERTED vs occupancy on this rig.
After we added AGC cancellation (rescaling the observation to the baseline
amplitude scale before subtraction), the empty home's energy
(e ~ 170) is HIGHER than the occupied-seated energy (e ~ 75): with nobody
present the standing-wave / multipath oscillation swings freely; a body
damps it. Any threshold scheme on absolute energy levels therefore cannot
simultaneously classify "empty" and "occupied" here - the classes are
inverted. We tried four threshold schemes (absolute, MAD-relative,
blindness gate at 0.3, relaxed to 0.6); each failed differently and each
revealed one more layer of this mechanism.
(4) A still person is absorbed by any quiet-period adaptation.
Our adaptive energy floor (spike-gated slow EMA) is required to hold the
zero point against drift - but it absorbs a SEATED person within minutes.
Standard background-subtraction tradeoff; we believe it is not solvable in
the energy domain alone.
(5) The score path cannot be zero-point corrected.
score_to_person_count maps a fused-feature variance score (~0.25-0.30
empty) to 1 person. It has no baseline by design, so there is nothing for a
zero-tracking mechanism to correct. We also note the score statistic
(variance/mean^2) is itself scale-invariant - the score path is AGC-immune
by construction, which is presumably why it worked at 800 ms before.
(6) Multi-person counting is pinned at 1.
With two people in the room the published count never exceeded 1 (3050/3050
frames). The second person's perturbation increment does not cross the
MAD-scaled second-person threshold, and estimate_persons_from_correlation
(DynamicMinCut) is overridden by the field-model count in the authority
policy.
Fixes we already shipped on our branch (all probe-verified)
- Runtime frames canonicalized to the same 56-tone grid the calibration was
fed (raw 64/128-wide frames tripped DimensionMismatch - the field-model
path had never executed end-to-end before) - Single-link reference node (highest-yield, frozen per calibration) for
both calibration feeding and runtime occupancy - Field-model calibration persisted (12k frames must survive restarts)
- Adaptive energy floor (spike-gated EMA + MAD-scaled thresholds)
- AGC cancellation in
extract_perturbation(observation rescaled to the
baseline amplitude scale before subtraction) - Presence/track-count authority for the calibrated instrument
Questions
(a) Multi-person counting - is the eigenvalue path REQUIRED?
Is FieldModel::estimate_occupancy (covariance eigendecomposition +
Marcenko-Pastur counting) the intended multi-person counter, and is
multi-person occupancy expected to work WITHOUT the eigenvalue feature?
If BLAS is required, is there a Windows-viable feature configuration you
recommend (or should we plan to run the server on Linux)?
(b) AGC - is per-frame amplitude normalization the intended
countermeasure, and where should it live?
Our evidence: per-frame mean amplitude 10 to 290 within one node, RSSI
constant. We rescaled the observation to the baseline scale inside
extract_perturbation. But issue #1170 deliberately made
resample_to_canonical length-only ("no z-score") because z-scoring
saturates the person-score (variance/mean^2). Where do you intend AGC
normalization to live so it fixes the field-model path without breaking
the score path's scale-dependent statistic?
(c) Static-person presence - what is the intended signal?
After quiet-period absorption a seated person vanishes from the energy
channel. Is a STILL person supposed to be detected via the breathing
channel (VitalSignDetector), via the covariance/eigenvalue path, or another
mechanism? On our rig neither works today (see (e) and (d)).
(d) VitalSignDetector - is it expected to discriminate empty vs occupied?
The per-node detector (mean-amplitude buffer, bandpass, FFT peak in
8-30 BPM) reports plausible BPM even in an EMPTY home (avg 16.7 BPM, 82%
of frames in 10-25) and its peak-ratio confidence does not separate empty
(0.32-0.36) from occupied (0.33). Is there a signal-quality gate or a
different confidence computation intended? (We also note the published
breathing_confidence in our captures tracks presence - is that
intended?)
(e) Count=1 pinning - is DynamicMinCut the intended multi-person
counter?
estimate_persons_from_correlation exists and is computed per node, but
under the field-model authority policy its output never reaches the
published count. Should MinCut be authoritative for count (with the field
model for presence), combined, or is the field model alone intended?
(f) Lying/sitting posture on the live path?
SensingUpdate.posture exists but is always None on our ESP32 captures,
and we found no live wiring of posture classification. Is posture
detection implemented for the ESP32 path, or planned?
(g) Confirm the intended pipeline.
Is our architecture - calibration, field-model authoritative count,
score fallback, publication - the intended one, or are we missing a
documented step (e.g., a normalization stage, a different feature source
for occupancy, TDM-synchronized capture) that would change the picture?
Happy to share our branch, the full probe logs, and the controlled-experiment
record. Thanks!
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing sensing-server occupancy publication through FieldModel::estimate_occupancy, extract_perturbation, score_to_person_count, and estimate_persons_from_correlation. Review VitalSignDetector and the referenced calibration and authority-policy paths, then reproduce the documented 0-, 1-, and 2-person probes. Done means the intended count, presence, breathing, and posture pipeline and its required feature configuration are documented or addressed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend, embedded-iot
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 28/100