mirror of
https://github.com/ruvnet/RuView
synced 2026-06-14 11:03:18 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 261ce80a72 | |||
| 0c2b1c16cc |
@@ -16,6 +16,15 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
# ESP-IDF set-target backup (local only)
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Host-built firmware test binaries (compiled from test/*.c, not source)
|
||||
firmware/esp32-csi-node/test/test_adr110
|
||||
firmware/esp32-csi-node/test/test_vitals
|
||||
firmware/esp32-csi-node/test/fuzz_serialize
|
||||
firmware/esp32-csi-node/test/fuzz_edge
|
||||
firmware/esp32-csi-node/test/fuzz_nvs
|
||||
firmware/esp32-csi-node/test/*.exe
|
||||
firmware/esp32-csi-node/test/*.obj
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
|
||||
@@ -18,3 +18,6 @@
|
||||
path = v2/crates/ruv-neural
|
||||
url = https://github.com/ruvnet/ruv-neural.git
|
||||
branch = main
|
||||
[submodule "vendor/rufield"]
|
||||
path = vendor/rufield
|
||||
url = https://github.com/ruvnet/rufield
|
||||
|
||||
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **ADR-260: RuField MFS — the open specification for camera-free multimodal field sensing.** A common event / tensor / calibration / privacy / provenance model that sits *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and future quantum sensors (each modality emits a normalized `FieldEvent` → `FieldTensor` → `FusionGraph` → `PrivacyClass` → `ProvenanceReceipt`). Published as a **standalone repo** [`ruvnet/rufield`](https://github.com/ruvnet/rufield) and vendored here as the `vendor/rufield` submodule (the `vendor/rvcsi` pattern — not a `v2/` workspace member). The v0.1 reference stack is a self-contained 6-crate Rust workspace (`rufield-core`, `-provenance` [sha256 + ed25519], `-privacy` [P0–P5 guard], `-adapters` [deterministic `SyntheticSim` across wifi_csi/mmwave_radar/infrared_thermal], `-fusion` [graph + TOML weighted-Bayes rules → 7 room-state inferences], `-bench` [deterministic runner + the §31 acceptance test]). **60 tests / 0 failed, clippy-clean.** §27 acceptance criteria 1–8 and 10 PASS; the live dashboard (9) is deferred. **All benchmark metrics are SYNTHETIC** (scored against the simulator's own ground truth — presence/breathing/bed_exit/room_transition F1 = 1.000, nocturnal_scratch 0.923 reported honestly, p95 latency ~0.01 ms, provenance coverage 100%, 0 privacy violations) — they prove the pipeline recovers known truth, **not** field accuracy; real hardware adapters (ESP32 CSI, mmWave, thermal IR) are a documented roadmap item, none validated in v0.1. The Python deterministic proof is unchanged (rufield is off the signal-processing proof path).
|
||||
|
||||
### Security
|
||||
- **ADR-157 Milestone-1 B4 - constant-time HMAC sync-beacon tag compare (`wifi-densepose-hardware`).** `AuthenticatedBeacon::verify` compared the 8-byte HMAC-SHA256 tag with `self.hmac_tag == expected`, which short-circuits on the first differing byte and leaks, through verification latency, how many leading bytes an attacker's forged tag matched - a byte-by-byte tag-recovery oracle (~256*N trials instead of 256^N). Replaced with a hand-rolled branch-free `constant_time_tag_eq` (XOR-accumulate every byte difference into a single `u8`, no early exit, `#[inline(never)]` + `core::hint::black_box` to stop the optimizer reintroducing a short-circuit or a non-constant-time `memcmp`). **No new dependency** - ADR-157 had deferred this only to avoid adding the `subtle` crate; a fixed 8-byte compare needs none. Grade MEASURED (constant-time *construction*; micro-timing on a noisy host is a smoke check only, gated `#[ignore]`). Pinned by `tag_compare_is_constant_time_shape` (equal/first-differ/last-differ/all-differ/length-mismatch + an end-to-end `verify()` last-byte tamper), proven to fail on a last-byte-skipping constant-time bug. ADR-157 §8 B4 -> RESOLVED.
|
||||
- **ADR-080 open HIGH findings closed on the Rust `wifi-densepose-sensing-server` boundary (ADR-164 G11).** The QE sweep's three HIGH findings — XFF-spoofing bypass, leaked stack traces, JWT-in-URL (CWE-598) — were logged against the Python v1 API and never re-verified against the shipped Rust sensing-server; the HOMECORE/M7 sweep (ADR-161) covered `homecore-server`, not this crate.
|
||||
@@ -15,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **#3 JWT-in-URL (CWE-598) — VERIFIED ABSENT, regression-pinned.** `require_bearer` reads the token only from the `Authorization` header; the WebSocket handlers take no token query param and the sole `Query` extractor (`EdgeRegistryParams`) is a non-secret `refresh` flag. Added a regression proving `?token=`/`?access_token=` in the URL never authenticates while the header path still does.
|
||||
|
||||
### Fixed
|
||||
- **ESP32 vitals: `n_persons` over-counted (reported 4 for one person) + presence flag flickered at close range (#998, #996).** Two firmware logic bugs in `firmware/esp32-csi-node/main/edge_processing.c`, both robustness/logic fixes — **not** validated-accuracy claims (true count/PCK vs labelled ground truth stays hardware/data-gated on the COM9 ESP32-S3).
|
||||
- **#998 over-count — root cause + fix.** `update_multi_person_vitals()` split the top-K subcarriers into `top_k_count/2` groups and marked **every** group `active` unconditionally, so one body's multipath always reported the full `EDGE_MAX_PERSONS` (=4). New pure, host-testable `count_distinct_persons()` gates each candidate group: (1) **energy gate** — a group's phase variance must be ≥ `EDGE_PERSON_MIN_ENERGY_RATIO` (0.35) × the strongest group's, so weak multipath echoes don't count; (2) **spatial dedup** — groups whose representative subcarriers sit within `EDGE_PERSON_MIN_SC_SEP` (4) of each other are the same body. A `person_count_debounce()` then requires the gated count to hold `EDGE_PERSON_PERSIST_FRAMES` (3) consecutive frames before it's emitted, so a single noisy frame can't promote a phantom. The strongest group always counts (a present body yields ≥1). All thresholds are named, documented constants in `edge_processing.h`.
|
||||
- **#996 presence flicker — root cause + fix.** Presence was a bare `score > threshold` compare on a noisy `presence_score` (field-observed 2.6–26.7 frame-to-frame for one stationary person), so the boolean chattered at the boundary while the score clearly indicated a person. New pure `presence_flag_update()` is a Schmitt trigger + clear-debounce: assert above `threshold`, **hold** in the dead band down to `threshold × EDGE_PRESENCE_HYST_RATIO` (0.5), and only clear after the score stays below the low threshold for `EDGE_PRESENCE_CLEAR_FRAMES` (5) consecutive frames. The score itself is unchanged (and still emitted at packet offset 20 for consumer-side thresholding). Constants named/documented in `edge_processing.h`.
|
||||
- **Tests:** `firmware/esp32-csi-node/test/test_vitals_count_presence.c` (host C99, `make run_vitals`) — 13 cases / 22 assertions, all passing under gcc 13 `-Wall -Wextra`. Pins: single-strong-signature + multipath → count==1; two well-separated → count==2; two strong-but-adjacent → 1 (dedup); transient count spike rejected; sustained change accepted; dithering presence trace → stable flag (no flicker); genuine departure → clears within hold window. The named tuning constants are `#include`d from the real header so the test and firmware can't disagree. **Hardware-gated caveat:** these pin the decision *logic*; the exact energy/separation/hysteresis values that best match a real room vs labelled occupancy remain on-device tuning (COM9 ESP32-S3 + ground truth).
|
||||
- **Observatory 3D figure never animated — `/ws/sensing` omitted per-person `position`/`motion_score`/`pose` (#1050).** The `sensing_update` frame shipped `nodes`/`features`/`classification`/`signal_field` and a `persons[]` carrying only image-space `keypoints`/`bbox`/`zone`; the Observatory's `FigurePool`/`PoseSystem` (and `demo-data.js`'s own contract) animate each figure from `persons[i].position` (room-world `[x,y,z]`), `persons[i].motion_score` (0..100), and `persons[i].pose`, none of which the live stream emitted — so the figure sat static while signal metrics updated. **Honest scope (Case 2 — no calibrated per-person localizer exists):** a single ESP32 link does not produce calibrated room-coordinate localization or per-person skeletal pose, so the fix emits only what is *truthfully derivable*. New `field_localize` module reads the **strongest peak(s)** out of the frame's real `signal_field` grid (already built from measured subcarrier variances × measured motion-band power) and maps the peak cell to Observatory world coordinates with the **exact** `_buildSignalField` transform (`x=(ix−nx/2)·0.6`, `z=(iz−nz/2)·0.5`, `y=0`), so the figure lands on the field hotspot it stands on. `motion_score` is the measured `motion_band_power` passed through (clamped 0..100); `pose` is set **only** from a real aggregate `posture` estimate when one exists, else `None` (never a fabricated skeleton — per-person pose keypoints in room coordinates stay gated on the pose model + ADR-079 paired data). An empty / below-threshold field yields `persons: []` (no phantom person); a present person on a field with no resolvable peak keeps `position=[0,0,0]` (not invented coords) while `motion_score` stays real. `attach_field_positions` runs after the tracker step at all five broadcast sites. **No UI change required** — the Observatory already reads these fields and defaults `pose`→`'standing'` when absent. New `PersonDetection.position`/`motion_score`/`pose` fields added to both the `main.rs`-local and `types.rs` structs. Pinned by 10 tests: `field_localize` peak-extraction/coordinate-mapping/empty-field/separation unit tests + `observatory_persons_field_position_tests` (`sensing_update_emits_persons_with_field_derived_position` feeds a synthetic field with a known peak at cell (15,4) and asserts the emitted `position` = `[3.0, 0, −3.0]` within tolerance; `empty_room_yields_no_phantom_person`; `pose_is_real_when_posture_present_and_absent_otherwise`; `present_but_below_threshold_field_keeps_position_at_origin_not_fabricated`). `wifi-densepose-sensing-server --no-default-features`: bin **441→451**, 0 failed; workspace green; Python proof unchanged (off the deterministic proof path).
|
||||
- **ADR-155 Milestone-1b — metric-definition unification, the §8 backlog subset (Goals A/B/C).** Closed the two §8 metric-integrity items; every change pinned by a test, graded MEASURED. The audit (Goal A) also surfaced findings the §1 table under-counted — recorded honestly in ADR-155 §8.1, not hidden. Workspace stays green; Python proof unchanged (metrics are not on the deterministic proof's signal path).
|
||||
- **Goal B — `test_metrics.rs` now validates the production metric, not a reimplementation.** The integration test previously asserted properties of its OWN local `compute_pck`/`compute_oks` (a test that can't catch a canonical-impl bug — both could be wrong the same way). Hoisted the canonical core (`pck_canonical`/`oks_canonical`/`canonical_torso_size`/sigmas/`bounding_box_diagonal`) into a new **un-gated** `metrics_core` module so the single definition is reachable under `cargo test --no-default-features` (the `metrics` module is `tch-backend`-gated); `metrics` re-exports it → still exactly ONE implementation. Rewrote the test to assert the production `pck_canonical`/`oks_canonical` equal **hand-computed** fixtures (`canonical_pck_matches_hand_computed_fixture` = 3/4 correct ⇒ 0.75; hip↔hip normalizer pin; zero-visible⇒0.0; OKS perfect⇒1.0; fake-Gold pin) plus a differential cross-check (`test_kernel_agrees_with_canonical`: an independent raw-threshold kernel must AGREE with canonical where torso==1.0). `wifi-densepose-train --no-default-features`: test_metrics **10→12**, 0 failed.
|
||||
- **Goal C — divergent live-server PCK/OKS relabelled so they're never conflated with canonical.** Goal C named `training_api.rs:804` (torso-HEIGHT PCK); the audit found that file is an **orphan (not `mod`-declared, does not compile)** and the **real** live `best_pck`/`best_oks` come from `trainer.rs` — a **raw, unnormalized** `pck_at_threshold` and an **`area=1.0` fake-Gold** `oks_map` (both MISSED by ADR-155 §1, both on the claim-inflating side, both serialized as bare "PCK@0.2"/"OKS"). Torso-height/raw math is load-bearing (pixel-space, different scale axis, no `ndarray`/train dep), so the honest fix is **relabel, not force-unify**: `training_api.rs` `compute_pck` → `compute_pck_torso_height` + field/log docs; `trainer.rs` kernels documented raw/fake-Gold; `main.rs` prints `pck_raw@0.2` / `oks_map(area=1.0 proxy)`. No wire-format field or `pub`-fn renames (no silent API break). Pinned by `torso_pck_is_labelled_distinctly_from_canonical` + `pck_at_threshold_is_raw_unnormalized_not_canonical`. `wifi-densepose-sensing-server --no-default-features`: lib **450→451**, 0 failed. True unification onto `pck_canonical`/`oks_canonical` remains a tracked ADR-155 §8 item.
|
||||
|
||||
@@ -22,6 +22,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 6 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`), 60 tests/0 failed; all benchmark metrics are **SYNTHETIC** (simulator ground truth, no hardware — real adapters are roadmap). |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
|
||||
@@ -1081,6 +1081,17 @@ The `wifi-densepose-vitals` crate (ESP32 CSI-grade vital signs) has not yet been
|
||||
- SONA-based environment adaptation
|
||||
- VitalSignStore with tiered temporal compression
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### 2026-06 — ESP32 edge vitals: person-count over-count + presence flicker (#998, #996)
|
||||
|
||||
Two robustness bugs were fixed in the on-device edge path (`firmware/esp32-csi-node/main/edge_processing.c`, the ADR-039 packet `0xC5110002`). These touch the *boolean/count emission logic*, not the underlying CSI signal-processing math, and do **not** constitute a validated-accuracy claim — true occupancy-count and presence accuracy vs labelled ground truth remain hardware/data-gated (COM9 ESP32-S3 + labelled capture).
|
||||
|
||||
- **#998 `n_persons` over-count (reported 4 for one person).** `update_multi_person_vitals()` divided the 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 an energy gate (`EDGE_PERSON_MIN_ENERGY_RATIO`), spatial dedup (`EDGE_PERSON_MIN_SC_SEP`), and a persistence debounce (`EDGE_PERSON_PERSIST_FRAMES`) via two pure functions `count_distinct_persons()` / `person_count_debounce()`.
|
||||
- **#996 presence flag flicker at ~50 cm.** Single-threshold compare on a noisy `presence_score` chattered at the boundary. Replaced with a Schmitt trigger + clear-debounce (`presence_flag_update()`, constants `EDGE_PRESENCE_HYST_RATIO` / `EDGE_PRESENCE_CLEAR_FRAMES`); `presence_score` is unchanged and still emitted for consumer-side thresholding.
|
||||
|
||||
Both are pinned by host-buildable C99 tests in `firmware/esp32-csi-node/test/test_vitals_count_presence.c` (`make run_vitals`). The exact thresholds are documented constants pending on-device calibration against ground truth.
|
||||
|
||||
## References
|
||||
|
||||
- Ramsauer et al. (2020). "Hopfield Networks is All You Need." ICLR 2021. (ModernHopfield formulation)
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# ADR 260: RuField Multimodal Field Sensing Specification
|
||||
|
||||
Status: Accepted — v0.1 reference stack
|
||||
|
||||
Date: 2026 06 14
|
||||
|
||||
Deciders: rUv
|
||||
|
||||
Tags: sensing, rf, csi, cir, bfld, radar, ultrasonic, infrared, quantum sensing, privacy, provenance, ruvector, ruview
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView proved that commodity wireless signals can be used as a practical sensing substrate. The next opportunity is larger: define a common specification for multimodal ambient sensing across RF, ultrasonic, subsonic, infrared, radar, and future quantum sensors.
|
||||
|
||||
Existing standards are valuable but fragmented.
|
||||
|
||||
IEEE 802.11bf 2025 standardizes WLAN sensing at the WiFi MAC and PHY layers and was published on September 26, 2025. It is important, but it is WiFi specific.
|
||||
|
||||
Bluetooth Channel Sounding standardizes techniques for obtaining phase and time delay information, but Bluetooth SIG explicitly does not define the distance algorithm. That leaves application level interpretation open.
|
||||
|
||||
IEEE 802.15.4z HRP UWB supports secure ranging using scrambled timestamp sequence waveforms, but UWB remains one modality rather than a universal sensing grammar.
|
||||
|
||||
Matter is a useful smart home interoperability protocol, but it is a device connectivity layer, not a multimodal field sensing specification.
|
||||
|
||||
The gap is clear: there is no open specification that normalizes sensor observations across CSI, CIR, BFLD, radar, ultrasound, subsonic vibration, thermal infrared, and quantum field sensing into one privacy aware, provenance rich, fusion ready event model.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Create **RuField MFS**, the RuField Multimodal Field Sensing Specification.
|
||||
|
||||
RuField MFS will define a common event, tensor, calibration, confidence, privacy, and provenance model for ambient field sensing.
|
||||
|
||||
It will not replace IEEE 802.11bf, Bluetooth Channel Sounding, UWB, Matter, radar protocols, or device vendor APIs.
|
||||
|
||||
It will sit above them.
|
||||
|
||||
```text
|
||||
WiFi CSI
|
||||
WiFi CIR
|
||||
WiFi BFLD
|
||||
UWB
|
||||
Bluetooth Channel Sounding
|
||||
mmWave radar
|
||||
Ultrasonic
|
||||
Subsonic
|
||||
Infrared
|
||||
Quantum magnetic sensing
|
||||
Quantum inertial sensing
|
||||
|
||||
all emit
|
||||
|
||||
RuField Field Event
|
||||
RuField Field Tensor
|
||||
RuField Fusion Graph
|
||||
RuField Privacy Class
|
||||
RuField Provenance Receipt
|
||||
```
|
||||
|
||||
## 3. Name
|
||||
|
||||
Preferred name: `RuField MFS`
|
||||
|
||||
Full name: `RuField Multimodal Field Sensing Specification`
|
||||
|
||||
Public positioning: `The open specification for camera free field intelligence.`
|
||||
|
||||
## 4. Problem Statement
|
||||
|
||||
Modern sensing systems are locked into modality specific silos: CSI systems produce channel matrices; radar produces range Doppler bins; UWB produces range and time of flight; Bluetooth Channel Sounding produces phase and timing primitives; infrared produces thermal arrays; ultrasonic produces acoustic echoes; subsonic produces structural vibration signatures; quantum sensors produce magnetic, inertial, or optical field traces.
|
||||
|
||||
Each has different sampling, calibration, confidence, privacy, and provenance semantics. This prevents reliable fusion and makes governance weak because raw sensing, derived sensing, biometric inference, and anonymous occupancy are often mixed without explicit boundaries.
|
||||
|
||||
## 5. Goals
|
||||
|
||||
1. Define a common multimodal sensing event format.
|
||||
2. Define a field tensor format spanning time, frequency, phase, amplitude, range, velocity, angle, temperature, vibration, and uncertainty.
|
||||
3. Define a modality registry for RF, acoustic, infrared, radar, and quantum sensing.
|
||||
4. Define privacy classes for raw waveforms, derived features, occupancy, anonymized aggregate state, and biometric inference.
|
||||
5. Define calibration receipts and provenance hashes.
|
||||
6. Define fusion rules for multimodal inference.
|
||||
7. Provide a Rust reference implementation.
|
||||
8. Provide benchmark tasks for camera free room intelligence.
|
||||
9. Make RuView one adapter inside a larger open sensing architecture.
|
||||
|
||||
## 6. Non Goals
|
||||
|
||||
1. Do not define a new wireless PHY.
|
||||
2. Do not replace IEEE 802.11bf.
|
||||
3. Do not replace Bluetooth Channel Sounding.
|
||||
4. Do not replace UWB secure ranging.
|
||||
5. Do not define medical diagnosis.
|
||||
6. Do not transmit speech, images, or raw biometric identity by default.
|
||||
7. Do not require cloud inference.
|
||||
8. Do not require expensive hardware.
|
||||
|
||||
## 7. Core Abstraction — the Field Event
|
||||
|
||||
A Field Event is a timestamped observation from any ambient field sensor.
|
||||
|
||||
```json
|
||||
{
|
||||
"spec": "rufield.mfs.v0.1",
|
||||
"event_id": "01J00000000000000000000000",
|
||||
"timestamp_ns": 1791986400000000000,
|
||||
"sensor": {
|
||||
"modality": "wifi_csi",
|
||||
"vendor": "esp32_c6",
|
||||
"device_id": "sensor_room_01",
|
||||
"placement": "ceiling_corner",
|
||||
"clock_domain": "local_ptp"
|
||||
},
|
||||
"field": {
|
||||
"carrier_hz": 5805000000,
|
||||
"bandwidth_hz": 80000000,
|
||||
"sample_rate_hz": 100,
|
||||
"channels": 234,
|
||||
"features": ["amplitude", "phase", "doppler", "range_proxy"]
|
||||
},
|
||||
"observation": {
|
||||
"space_cell": [4, 2, 1],
|
||||
"range_m": 3.42,
|
||||
"velocity_mps": 0.18,
|
||||
"motion_vector": [0.12, -0.03, 0.00],
|
||||
"confidence": 0.87,
|
||||
"privacy_class": "P2"
|
||||
},
|
||||
"provenance": {
|
||||
"raw_hash": "sha256:raw_measurement_hash",
|
||||
"firmware_hash": "sha256:firmware_hash",
|
||||
"model_id": "ruvector_field_encoder_v1",
|
||||
"calibration_id": "room_cal_2026_06_14"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Modality Registry
|
||||
|
||||
| Code | Modality | Example source |
|
||||
| ---: | -------------------- | --------------------------------------- |
|
||||
| 1 | wifi_csi | ESP32 C6, Intel BE200, AP CSI |
|
||||
| 2 | wifi_cir | channel impulse response |
|
||||
| 3 | wifi_bfld | beamforming feedback |
|
||||
| 4 | uwb_hrp | IEEE 802.15.4z ranging |
|
||||
| 5 | ble_channel_sounding | phase and timing primitives |
|
||||
| 6 | mmwave_radar | range Doppler radar |
|
||||
| 7 | ultrasonic | echo and time of flight |
|
||||
| 8 | subsonic | structural vibration and room resonance |
|
||||
| 9 | infrared_thermal | thermal array or passive IR |
|
||||
| 10 | active_infrared | reflected IR |
|
||||
| 11 | lidar_phase | phase based optical range |
|
||||
| 12 | quantum_magnetic | NV diamond or OPM field trace |
|
||||
| 13 | quantum_inertial | atom interferometer or precision IMU |
|
||||
| 14 | event_camera | optional visual event stream |
|
||||
| 15 | synthetic_sim | simulator or replay source |
|
||||
|
||||
## 9. Field Tensor
|
||||
|
||||
The normalized numeric container (`Modality`, `FieldAxis`, `FieldTensor`) as specified in the implementation crate `rufield-core`.
|
||||
|
||||
## 10. Privacy Classes
|
||||
|
||||
| Class | Description | Example |
|
||||
| ----- | -------------------------------- | ------------------------------- |
|
||||
| P0 | Raw waveform or raw sensor frame | raw CSI, raw radar cube |
|
||||
| P1 | Derived non identity features | Doppler peak, thermal blob |
|
||||
| P2 | Occupancy and motion only | person present, bed exit |
|
||||
| P3 | Anonymous aggregate state | room count, zone activity |
|
||||
| P4 | Biometric or health inference | breathing, gait, sleep, scratch |
|
||||
| P5 | Identity linked inference | named person state |
|
||||
|
||||
Default system policy: edge storage may retain P0 only temporarily; network transmission defaults to P2 or lower; P4 requires explicit consent; P5 requires explicit identity binding and audit log.
|
||||
|
||||
## 11. Provenance Receipt
|
||||
|
||||
Every event must be auditable (`ProvenanceReceipt`). Acceptance invariant: **No fused inference is valid unless every contributing event has a provenance receipt or is explicitly marked synthetic.**
|
||||
|
||||
## 12. Fusion Graph
|
||||
|
||||
Nodes: sensor, event, field_tensor, feature, object, zone, state, inference, receipt.
|
||||
Edges: observed_by, derived_from, calibrated_by, supports, contradicts, fused_into, expires_at, requires_consent.
|
||||
|
||||
## 13. Fusion Rule Format
|
||||
|
||||
Human readable TOML rules (`rule.person_present`, `rule.bed_exit`, `rule.nocturnal_scratch`) with `inputs`, `method`, `threshold`, `privacy_max`, optional `window_ms` and `requires_consent`.
|
||||
|
||||
## 14. Reference Architecture
|
||||
|
||||
Layer 0 physical sensors; Layer 1 native adapters; Layer 2 field tensor normalization; Layer 3 RuVector field embeddings; Layer 4 fusion graph; Layer 5 policy and privacy guard; Layer 6 application event stream; Layer 7 dashboard, API, MCP, Matter bridge.
|
||||
|
||||
## 15. Rust Crate Layout
|
||||
|
||||
`rufield-core`, `rufield-schema`, `rufield-adapters`, `rufield-fusion`, `rufield-privacy`, `rufield-provenance`, `rufield-bench`, `rufield-viewer`.
|
||||
|
||||
## 16. Core Rust Interfaces
|
||||
|
||||
`FieldAdapter`, `FieldEncoder`, `FusionEngine`, `PrivacyGuard` traits as specified in `rufield-core`.
|
||||
|
||||
## 17. MVP Adapters
|
||||
|
||||
v0.1 must support three real modalities: WiFi CSI, mmWave radar, Infrared thermal. Optional: ultrasonic, subsonic, synthetic simulator.
|
||||
|
||||
## 18. Benchmark Suite
|
||||
|
||||
| Task | Metric | Target |
|
||||
| ----------------------- | -------: | -----------: |
|
||||
| Presence detection | F1 | 0.90 |
|
||||
| Room transition | F1 | 0.85 |
|
||||
| Bed exit | F1 | 0.90 |
|
||||
| Breathing detected | F1 | 0.80 |
|
||||
| Nocturnal scratch | F1 | 0.75 |
|
||||
| Fall like event | Recall | 0.95 |
|
||||
| False alarm rate | per hour | below 0.10 |
|
||||
| Event latency | p95 | below 100 ms |
|
||||
| Provenance coverage | percent | 100 |
|
||||
| Privacy violation count | count | 0 |
|
||||
|
||||
## 19. First Viral Demo
|
||||
|
||||
Camera free room intelligence: person enters, sits, breathing detected, sleeps, scratches arm, exits bed, leaves room — no camera, no identity, signed field receipts, live fusion graph, privacy class visible per event.
|
||||
|
||||
## 20. Data Model
|
||||
|
||||
`FieldEvent { spec_version, event_id, timestamp_ns, sensor, tensor, observation, provenance }` and `Observation { zone_id, space_cell, range_m, velocity_mps, motion_vector, confidence, labels, privacy_class }`.
|
||||
|
||||
## 21. Decision Matrix
|
||||
|
||||
| Option | Interop | Novelty | Buildability | Business value | Risk | Score |
|
||||
| --------------------------------------------- | ------: | ------: | -----------: | -------------: | ---: | ----: |
|
||||
| Extend RuView only | 2 | 2 | 5 | 3 | 2 | 14 |
|
||||
| Build proprietary fusion engine | 3 | 3 | 4 | 4 | 3 | 17 |
|
||||
| Create open RuField spec plus reference stack | 5 | 5 | 4 | 5 | 3 | 22 |
|
||||
| Attempt new hardware standard | 5 | 5 | 1 | 4 | 5 | 20 |
|
||||
|
||||
Decision: **Create open RuField spec plus reference stack.** It maximizes credibility, extensibility, and ecosystem pull while avoiding the impossible burden of defining a new physical layer.
|
||||
|
||||
## 22. Security Model
|
||||
|
||||
| Threat | Impact | Mitigation |
|
||||
| ----------------------------------- | ------------------------------- | -------------------------------------- |
|
||||
| Raw waveform leakage | privacy breach | P0 edge only by default |
|
||||
| Biometric inference without consent | legal and trust risk | P4 consent gate |
|
||||
| Sensor spoofing | false occupancy or safety event | signed sensor receipts |
|
||||
| Replay attack | forged event stream | nonce plus timestamp plus hash chain |
|
||||
| Model drift | wrong inference | calibration expiry and benchmark gates |
|
||||
| Overfitting to one room | weak generalization | room split benchmark |
|
||||
| Vendor firmware change | silent degradation | firmware hash in receipt |
|
||||
|
||||
## 23. Calibration Model
|
||||
|
||||
`CalibrationReceipt` is first class. Required calibration tasks: empty room baseline, single person walk path, sit and stand, bed or couch transition, breathing reference, no motion stability period.
|
||||
|
||||
## 24. Inference Semantics
|
||||
|
||||
Every inference must include: label, confidence, supporting events, contradicting events, privacy class, calibration id, model id, expiry time.
|
||||
|
||||
## 25. Consequences
|
||||
|
||||
Positive: RuView becomes part of a larger sensing ecosystem; the spec creates a standards-style wedge without waiting for silicon vendors; multimodal fusion becomes portable; privacy and provenance become differentiators; enterprise deployment becomes easier to justify; benchmark receipts reduce skepticism.
|
||||
|
||||
Negative: broad scope can dilute execution; hardware variability will be painful; calibration is the hardest practical problem; some will claim existing standards already solve parts of this; medical and biometric use cases require careful governance.
|
||||
|
||||
Mitigation: keep v0.1 narrow; ship real adapters; publish benchmark receipts; do not claim medical diagnosis; position RuField above existing standards.
|
||||
|
||||
## 26. Implementation Plan
|
||||
|
||||
Phase 1 spec skeleton; Phase 2 Rust core; Phase 3 adapters; Phase 4 fusion graph; Phase 5 dashboard; Phase 6 benchmark.
|
||||
|
||||
## 27. Acceptance Criteria
|
||||
|
||||
v0.1 is accepted when:
|
||||
|
||||
1. Three modalities stream into one event graph.
|
||||
2. Every event has a privacy class.
|
||||
3. Every event has a provenance receipt.
|
||||
4. Fusion produces at least five room state inferences.
|
||||
5. p95 event pipeline latency is below 100 ms.
|
||||
6. Benchmark runner produces deterministic reports.
|
||||
7. Raw waveform storage is disabled by default.
|
||||
8. P4 inference requires consent policy approval.
|
||||
9. Dashboard shows live camera free room intelligence.
|
||||
10. Spec is readable enough for external implementers.
|
||||
|
||||
## 28. Reference Repository Structure
|
||||
|
||||
Crates under `v2/crates/rufield-*` (workspace members), spec under `docs/rufield/`, benches under `rufield-bench`.
|
||||
|
||||
## 29. Open Questions
|
||||
|
||||
1. JSON Schema first, Protobuf first, or both?
|
||||
2. Default transport: MQTT, NATS, WebSocket, or MCP?
|
||||
3. Matter integration: bridge or first class target?
|
||||
4. P4 health inference disabled by default in public demos?
|
||||
5. Benchmark datasets synthetic first, then real world?
|
||||
6. Include quantum modality IDs even if adapters are synthetic only?
|
||||
|
||||
## 30. Recommendation
|
||||
|
||||
Proceed. Publish RuField as an open specification with a working Rust reference stack and a viral camera free room intelligence demo.
|
||||
|
||||
## 31. Benchmark Acceptance Test
|
||||
|
||||
```text
|
||||
Given a room with WiFi CSI, mmWave radar, and thermal IR sensors
|
||||
When a person enters, sits, breathes, exits bed, and leaves
|
||||
Then RuField emits signed events
|
||||
And classifies room state without a camera
|
||||
And keeps all default network events at P2 or below
|
||||
And produces p95 latency below 100 ms
|
||||
And produces a deterministic benchmark report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status (v0.1 reference stack)
|
||||
|
||||
The v0.1 reference stack is implemented as a **standalone Cargo workspace**
|
||||
(`rufield/`, published as `github.com/ruvnet/rufield` and vendored into RuView
|
||||
as a submodule — the `vendor/rvcsi` pattern). It is pure Rust, builds and tests
|
||||
on Windows with no native deps (`ndarray`/`tch`/`openblas` are not used), and
|
||||
depends only on `serde`, `serde_json`, `toml`, `sha2`, and `ed25519-dalek`.
|
||||
|
||||
**All metrics below are SYNTHETIC.** They are scored against the simulator's own
|
||||
ground-truth labels. They demonstrate the pipeline recovers known truth and runs
|
||||
within latency/privacy/provenance budgets — they are **not** field-validated
|
||||
accuracy. There is no hardware in v0.1; real adapters (ESP32 CSI, mmWave, thermal
|
||||
IR) are a documented follow-up (see the repo README "Firmware" section).
|
||||
|
||||
### Crates delivered
|
||||
|
||||
| Crate | Implements |
|
||||
|-------|-----------|
|
||||
| `rufield-core` | §7/§9/§16/§20 data model: `Modality` (15), `FieldAxis`, `FieldTensor` (shape↔values validated), `PrivacyClass` (P0–P5), `SensorDescriptor`, `Observation`, `FieldEvent`, `CalibrationReceipt`, `InferenceQuery`, `FieldInference`, `FieldEmbedding`; `FieldAdapter`/`FieldEncoder`/`FusionEngine`/`PrivacyGuard` traits. §7 JSON example round-trips. |
|
||||
| `rufield-provenance` | Real `sha256` content hashing + deterministic `ed25519` sign/verify; §11 `is_fusable` invariant. Tests: tamper → verify fails; synthetic event fusable without signer. |
|
||||
| `rufield-privacy` | §10 default policy + `DefaultPrivacyGuard` (`authorize` → Allow/Deny/RequiresConsent). Tests: P0 transmit denied; P4 no-consent → RequiresConsent; P4 consent → Allow; P2 → Allow; P5 needs identity binding. |
|
||||
| `rufield-adapters` | Deterministic seeded `SyntheticSim` emitting the §19 sequence across 3 modalities (wifi_csi, mmwave_radar, infrared_thermal). Same seed ⇒ identical signed event stream with ground-truth labels. |
|
||||
| `rufield-fusion` | `FusionGraph` (§12) + `RuFieldFusion` engine; TOML rules (§13, ≥5 inferences: person_present, sitting, sleeping, breathing, nocturnal_scratch, bed_exit, room_transition); weighted-Bayes + temporal-window; rejects non-fusable events; `FieldInference` with §24 fields. |
|
||||
| `rufield-bench` | Deterministic runner: F1 per task (SYNTHETIC), p95 latency, provenance coverage, privacy violations; JSON + human table; §31 acceptance test as `#[test]`. |
|
||||
|
||||
Total test count across the workspace: **60 tests, 0 failed**.
|
||||
`cargo clippy --workspace` is clean.
|
||||
|
||||
### §27 acceptance-criteria scorecard
|
||||
|
||||
| # | Criterion | Status |
|
||||
|---|-----------|--------|
|
||||
| 1 | Three modalities stream into one event graph | **PASS** — wifi_csi, mmwave_radar, infrared_thermal |
|
||||
| 2 | Every event has a privacy class | **PASS** — `Observation.privacy_class` (non-optional), default ≤ P2 |
|
||||
| 3 | Every event has a provenance receipt | **PASS** — every event is ed25519-signed and verifies; coverage 100% |
|
||||
| 4 | Fusion produces ≥ 5 room-state inferences | **PASS** — 7 distinct inferences produced |
|
||||
| 5 | p95 event pipeline latency < 100 ms | **PASS** — p95 ≈ 0.01 ms (in-process) |
|
||||
| 6 | Benchmark runner produces deterministic reports | **PASS** — identical report across runs (latency is the only wall-clock field) |
|
||||
| 7 | Raw waveform storage disabled by default | **PASS** — P0 network transmission denied by default policy |
|
||||
| 8 | P4 inference requires consent policy approval | **PASS** — P4 without consent → RequiresConsent; breathing/scratch rules carry `requires_consent = true` |
|
||||
| 9 | Dashboard shows live camera-free room intelligence | **DEFERRED** — no `rufield-viewer` dashboard in v0.1; the benchmark + `room_intelligence` example provide a CLI view. Follow-up. |
|
||||
| 10 | Spec readable for external implementers | **PASS** — ADR-260 + detailed standalone README with compiling usage examples |
|
||||
|
||||
**Decision:** §27 criteria 1–8 and 10 PASS; criterion 9 (live dashboard) is
|
||||
**deferred** to a follow-up. Per the acceptance rule (1–8, 10 pass; 9 may be
|
||||
deferred), Status is set to **Accepted — v0.1 reference stack**.
|
||||
|
||||
### Deterministic benchmark report (SYNTHETIC, seed = 2026)
|
||||
|
||||
```text
|
||||
TASK (SYNTHETIC) METRIC VALUE TARGET MEETS
|
||||
presence f1 1.000 0.900 yes
|
||||
breathing f1 1.000 0.800 yes
|
||||
nocturnal_scratch f1 0.923 0.750 yes
|
||||
bed_exit f1 1.000 0.900 yes
|
||||
room_transition f1 1.000 0.850 yes
|
||||
-----------------------------------------------------------------------------------
|
||||
p50 latency: 0.0097 ms
|
||||
p95 latency: 0.0123 ms (target < 100 ms: PASS)
|
||||
provenance coverage: 100.0 % (target 100%: PASS)
|
||||
privacy violations: 0 (target 0: PASS)
|
||||
events=216 modalities=3 distinct_inferences=7
|
||||
```
|
||||
|
||||
All five scored §18 tasks meet their F1 targets **on synthetic ground truth**.
|
||||
`nocturnal_scratch` is 0.923 (one borderline noise tick at this seed) — reported
|
||||
honestly rather than tuned to 1.0. The fall-like / false-alarm-rate §18 rows are
|
||||
not scored in v0.1 (no fall is in the demo sequence) and are a follow-up. These
|
||||
numbers prove the fusion pipeline scores correctly against known truth; they say
|
||||
**nothing** about real-world accuracy, which requires the hardware adapters that
|
||||
v0.1 deliberately does not ship.
|
||||
|
||||
### Honest statement
|
||||
|
||||
Every metric here is simulator-based. No ESP32 CSI, mmWave, or thermal capture
|
||||
was used. RuField v0.1 is a working, honestly-measured reference pipeline —
|
||||
data model, provenance, privacy, fusion, and a deterministic benchmark — pending
|
||||
real hardware adapters.
|
||||
@@ -367,6 +367,7 @@ static float s_heartrate_bpm;
|
||||
static float s_motion_energy;
|
||||
static float s_presence_score;
|
||||
static bool s_presence_detected;
|
||||
static uint8_t s_presence_below_count; /**< Consecutive frames below low thresh (issue #996). */
|
||||
static bool s_fall_detected;
|
||||
static int8_t s_latest_rssi;
|
||||
static uint32_t s_frame_count;
|
||||
@@ -398,6 +399,11 @@ static uint16_t s_feature_seq;
|
||||
|
||||
/** Multi-person vitals state. */
|
||||
static edge_person_vitals_t s_persons[EDGE_MAX_PERSONS];
|
||||
|
||||
/** Person-count persistence debounce (issue #998). */
|
||||
static uint8_t s_person_count_candidate; /**< Last raw (gated) candidate count. */
|
||||
static uint8_t s_person_count_streak; /**< Consecutive frames at the candidate. */
|
||||
static uint8_t s_person_count_stable; /**< Emitted (debounced) count. */
|
||||
static edge_biquad_t s_person_bq_br[EDGE_MAX_PERSONS];
|
||||
static edge_biquad_t s_person_bq_hr[EDGE_MAX_PERSONS];
|
||||
static float s_person_br_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
|
||||
@@ -446,6 +452,61 @@ static void update_top_k(uint16_t n_subcarriers)
|
||||
s_top_k_count = k;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Presence Flag Hysteresis + Debounce (issue #996)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Schmitt-trigger presence decision with a clear-debounce.
|
||||
*
|
||||
* Pure function (no globals) so it is host-testable: feed a presence_score
|
||||
* trace and assert the boolean flag is stable. Replaces the old single-
|
||||
* threshold `score > threshold` compare that chattered when a noisy score
|
||||
* dithered around the boundary (observed 2.6-26.7 for one stationary person).
|
||||
*
|
||||
* - score > threshold → assert presence (enter immediately)
|
||||
* - score >= threshold * HYST_RATIO → hold current state (dead band)
|
||||
* - score < threshold * HYST_RATIO → count toward clearing; only clear
|
||||
* after CLEAR_FRAMES consecutive frames
|
||||
*
|
||||
* @param prev Current presence flag (in/out via return + below_count).
|
||||
* @param score Latest presence score.
|
||||
* @param threshold High (enter) threshold.
|
||||
* @param below_count In/out: consecutive frames the score has been below the
|
||||
* low threshold. Reset to 0 whenever the score recovers.
|
||||
* @return New presence flag.
|
||||
*/
|
||||
static bool presence_flag_update(bool prev, float score, float threshold,
|
||||
uint8_t *below_count)
|
||||
{
|
||||
float low_thresh = threshold * EDGE_PRESENCE_HYST_RATIO;
|
||||
|
||||
if (score > threshold) {
|
||||
/* Clearly present — assert and reset the clear debounce. */
|
||||
*below_count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (score >= low_thresh) {
|
||||
/* Dead band: hold whatever we had, no flicker. Recovery above the low
|
||||
* threshold also resets the clear debounce so a brief dip doesn't
|
||||
* accumulate toward a false clear. */
|
||||
*below_count = 0;
|
||||
return prev;
|
||||
}
|
||||
|
||||
/* Below the low threshold — candidate for clearing. */
|
||||
if (*below_count < 0xFF) (*below_count)++;
|
||||
if (!prev) {
|
||||
return false; /* Already cleared. */
|
||||
}
|
||||
if (*below_count >= EDGE_PRESENCE_CLEAR_FRAMES) {
|
||||
*below_count = 0;
|
||||
return false; /* Sustained absence — clear. */
|
||||
}
|
||||
return true; /* Still within the hold window — keep asserting. */
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Adaptive Presence Calibration
|
||||
* ====================================================================== */
|
||||
@@ -581,6 +642,112 @@ store_prev:
|
||||
* Multi-Person Vitals
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Count distinct persons from per-group energy + representative subcarrier (issue #998).
|
||||
*
|
||||
* Pure function (no globals) so it is host-testable. Each of the `n_groups`
|
||||
* subcarrier groups is a *candidate* person. A candidate is counted only if:
|
||||
* 1. Energy gate — its energy >= EDGE_PERSON_MIN_ENERGY_RATIO * max energy.
|
||||
* One body's multipath spreads energy unevenly across the
|
||||
* groups; weak groups are reflections, not extra people.
|
||||
* 2. Spatial dedup — its representative subcarrier is at least
|
||||
* EDGE_PERSON_MIN_SC_SEP away from every already-counted
|
||||
* person. Adjacent subcarriers see the same reflection, so
|
||||
* a near-duplicate group is the same body.
|
||||
*
|
||||
* The strongest group is always counted (so a present body yields >= 1).
|
||||
*
|
||||
* @param energy Per-group energy (e.g. phase variance), length n_groups.
|
||||
* @param sc_idx Per-group representative subcarrier index, length n_groups.
|
||||
* @param n_groups Number of candidate groups (<= EDGE_MAX_PERSONS).
|
||||
* @return Distinct person count in [0, n_groups].
|
||||
*/
|
||||
static uint8_t count_distinct_persons(const float *energy, const uint8_t *sc_idx,
|
||||
uint8_t n_groups)
|
||||
{
|
||||
if (n_groups == 0) return 0;
|
||||
|
||||
/* Strongest group sets the reference energy. */
|
||||
float max_energy = 0.0f;
|
||||
for (uint8_t g = 0; g < n_groups; g++) {
|
||||
if (energy[g] > max_energy) max_energy = energy[g];
|
||||
}
|
||||
/* No real signal anywhere → no persons. */
|
||||
if (max_energy <= 0.0f) return 0;
|
||||
|
||||
float min_energy = max_energy * EDGE_PERSON_MIN_ENERGY_RATIO;
|
||||
|
||||
uint8_t counted_sc[EDGE_MAX_PERSONS];
|
||||
uint8_t count = 0;
|
||||
|
||||
/* Greedy by descending energy: take the strongest unclaimed group that is
|
||||
* spatially separated from everything already counted. */
|
||||
bool used[EDGE_MAX_PERSONS];
|
||||
for (uint8_t g = 0; g < n_groups && g < EDGE_MAX_PERSONS; g++) used[g] = false;
|
||||
|
||||
for (uint8_t iter = 0; iter < n_groups && iter < EDGE_MAX_PERSONS; iter++) {
|
||||
/* Find the strongest still-unused group above the energy gate. */
|
||||
int best = -1;
|
||||
float best_e = min_energy; /* must beat the gate */
|
||||
for (uint8_t g = 0; g < n_groups && g < EDGE_MAX_PERSONS; g++) {
|
||||
if (used[g]) continue;
|
||||
if (energy[g] >= best_e) { best_e = energy[g]; best = g; }
|
||||
}
|
||||
if (best < 0) break; /* nothing left above the gate */
|
||||
used[best] = true;
|
||||
|
||||
/* Spatial dedup against already-counted persons. */
|
||||
bool duplicate = false;
|
||||
for (uint8_t c = 0; c < count; c++) {
|
||||
int sep = (int)sc_idx[best] - (int)counted_sc[c];
|
||||
if (sep < 0) sep = -sep;
|
||||
if (sep < EDGE_PERSON_MIN_SC_SEP) { duplicate = true; break; }
|
||||
}
|
||||
if (duplicate) continue;
|
||||
|
||||
counted_sc[count++] = sc_idx[best];
|
||||
}
|
||||
|
||||
/* The strongest group always represents at least one body. */
|
||||
if (count == 0) count = 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce a raw person count so a single noisy frame can't change the emitted
|
||||
* value (issue #998). A new candidate must hold for EDGE_PERSON_PERSIST_FRAMES
|
||||
* consecutive frames before it replaces the stable count.
|
||||
*
|
||||
* Pure function (state passed by pointer) → host-testable.
|
||||
*
|
||||
* @param raw Raw (gated) count this frame.
|
||||
* @param candidate In/out: the candidate being accumulated.
|
||||
* @param streak In/out: consecutive frames the candidate has held.
|
||||
* @param stable In/out: the currently emitted count.
|
||||
* @return The (possibly updated) stable count.
|
||||
*/
|
||||
static uint8_t person_count_debounce(uint8_t raw, uint8_t *candidate,
|
||||
uint8_t *streak, uint8_t *stable)
|
||||
{
|
||||
if (raw == *stable) {
|
||||
/* Agrees with what we emit — reset any pending change. */
|
||||
*candidate = raw;
|
||||
*streak = 0;
|
||||
return *stable;
|
||||
}
|
||||
if (raw == *candidate) {
|
||||
if (*streak < 0xFF) (*streak)++;
|
||||
} else {
|
||||
*candidate = raw;
|
||||
*streak = 1;
|
||||
}
|
||||
if (*streak >= EDGE_PERSON_PERSIST_FRAMES) {
|
||||
*stable = *candidate;
|
||||
*streak = 0;
|
||||
}
|
||||
return *stable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multi-person vitals by assigning top-K subcarriers to person groups.
|
||||
*
|
||||
@@ -600,10 +767,25 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
|
||||
uint8_t subs_per_person = s_top_k_count / n_persons;
|
||||
|
||||
/* Per-group energy + representative subcarrier, for the #998 person gate. */
|
||||
float group_energy[EDGE_MAX_PERSONS] = {0};
|
||||
uint8_t group_sc[EDGE_MAX_PERSONS] = {0};
|
||||
|
||||
for (uint8_t p = 0; p < n_persons; p++) {
|
||||
edge_person_vitals_t *pv = &s_persons[p];
|
||||
pv->active = true;
|
||||
pv->subcarrier_idx = s_top_k[p * subs_per_person];
|
||||
group_sc[p] = s_top_k[p * subs_per_person];
|
||||
|
||||
/* Group energy = max Welford variance over its subcarriers. This is the
|
||||
* same variance used for top-K selection, so a multipath group (weak,
|
||||
* adjacent to the strong one) registers low energy and gets gated out. */
|
||||
float energy = 0.0f;
|
||||
for (uint8_t s = 0; s < subs_per_person; s++) {
|
||||
uint8_t sc = s_top_k[p * subs_per_person + s];
|
||||
float v = (float)welford_variance(&s_subcarrier_var[sc]);
|
||||
if (v > energy) energy = v;
|
||||
}
|
||||
group_energy[p] = energy;
|
||||
|
||||
/* Average phase across this person's subcarrier group. */
|
||||
float avg_phase = 0.0f;
|
||||
@@ -662,10 +844,32 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
}
|
||||
}
|
||||
|
||||
/* Mark remaining persons as inactive. */
|
||||
for (uint8_t p = n_persons; p < EDGE_MAX_PERSONS; p++) {
|
||||
/* --- Issue #998: gate phantom persons by energy + spatial dedup,
|
||||
* then debounce so a single noisy frame can't change the count. --- */
|
||||
uint8_t raw_count = count_distinct_persons(group_energy, group_sc, n_persons);
|
||||
uint8_t stable_count = person_count_debounce(raw_count,
|
||||
&s_person_count_candidate,
|
||||
&s_person_count_streak,
|
||||
&s_person_count_stable);
|
||||
|
||||
/* Mark the strongest `stable_count` groups active (descending energy); the
|
||||
* rest — including phantom multipath groups — are inactive. */
|
||||
bool used[EDGE_MAX_PERSONS];
|
||||
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
|
||||
used[p] = false;
|
||||
s_persons[p].active = false;
|
||||
}
|
||||
for (uint8_t n = 0; n < stable_count && n < n_persons; n++) {
|
||||
int best = -1;
|
||||
float best_e = -1.0f;
|
||||
for (uint8_t p = 0; p < n_persons; p++) {
|
||||
if (used[p]) continue;
|
||||
if (group_energy[p] > best_e) { best_e = group_energy[p]; best = p; }
|
||||
}
|
||||
if (best < 0) break;
|
||||
used[best] = true;
|
||||
s_persons[best].active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
@@ -960,7 +1164,12 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
} else if (threshold == 0.0f) {
|
||||
threshold = 0.05f; /* Default until calibrated. */
|
||||
}
|
||||
s_presence_detected = (s_presence_score > threshold);
|
||||
/* Issue #996: hysteresis + clear-debounce instead of a bare threshold
|
||||
* compare, so a noisy score dithering around the boundary doesn't flicker
|
||||
* the boolean flag. */
|
||||
s_presence_detected = presence_flag_update(s_presence_detected,
|
||||
s_presence_score, threshold,
|
||||
&s_presence_below_count);
|
||||
|
||||
/* --- Step 10: Fall detection (phase acceleration + debounce, issue #263) --- */
|
||||
if (s_history_len >= 3) {
|
||||
@@ -1160,6 +1369,7 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
s_motion_energy = 0.0f;
|
||||
s_presence_score = 0.0f;
|
||||
s_presence_detected = false;
|
||||
s_presence_below_count = 0;
|
||||
s_fall_detected = false;
|
||||
s_latest_rssi = 0;
|
||||
s_frame_count = 0;
|
||||
@@ -1183,6 +1393,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
|
||||
s_persons[p].active = false;
|
||||
}
|
||||
s_person_count_candidate = 0;
|
||||
s_person_count_streak = 0;
|
||||
s_person_count_stable = 0;
|
||||
|
||||
/* Design biquad bandpass filters.
|
||||
* Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @file test_vitals_count_presence.c
|
||||
* @brief Host-side unit tests for the issue #998 / #996 vitals logic fixes.
|
||||
*
|
||||
* Covers two pure decision functions extracted from edge_processing.c:
|
||||
* 1. count_distinct_persons() — issue #998 person over-count gate
|
||||
* (energy gate + spatial dedup).
|
||||
* 2. person_count_debounce() — issue #998 count persistence debounce.
|
||||
* 3. presence_flag_update() — issue #996 presence hysteresis + clear
|
||||
* debounce (Schmitt trigger).
|
||||
*
|
||||
* Build (Linux/macOS/Windows with any C99 compiler):
|
||||
* cc -std=c99 -Wall -I../main -o test_vitals \
|
||||
* test_vitals_count_presence.c && ./test_vitals
|
||||
*
|
||||
* Exits 0 on all-pass, prints which assertion failed otherwise.
|
||||
*
|
||||
* Why a separate host test file: these are deterministic logic checks for the
|
||||
* exact boundary behaviour the issues describe; libFuzzer adds no signal here.
|
||||
*
|
||||
* IMPORTANT — these three functions are copied VERBATIM from
|
||||
* firmware/esp32-csi-node/main/edge_processing.c. They are pure (no globals,
|
||||
* no ESP-IDF). If the firmware copy changes, update the copy here and re-run
|
||||
* this test before the firmware change merges. The named tuning constants are
|
||||
* pulled from the real header so the test and firmware can never disagree on
|
||||
* thresholds.
|
||||
*
|
||||
* HARDWARE-GATED CAVEAT: these tests pin the *logic* (no flicker / no
|
||||
* over-count for the synthetic traces). True count accuracy and the exact
|
||||
* energy/separation/hysteresis thresholds that best match a real room vs
|
||||
* labelled ground truth remain hardware- and data-gated (COM9 ESP32-S3 +
|
||||
* labelled occupancy). This is a robustness/logic fix, not a validated
|
||||
* accuracy claim.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* Named tuning constants come from the real firmware header so the test can
|
||||
* never silently diverge from the constants the firmware compiles with. */
|
||||
#include "edge_processing.h"
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* System under test — copied VERBATIM from edge_processing.c.
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* count_distinct_persons() — issue #998 energy gate + spatial dedup. */
|
||||
static uint8_t count_distinct_persons(const float *energy, const uint8_t *sc_idx,
|
||||
uint8_t n_groups)
|
||||
{
|
||||
if (n_groups == 0) return 0;
|
||||
|
||||
float max_energy = 0.0f;
|
||||
for (uint8_t g = 0; g < n_groups; g++) {
|
||||
if (energy[g] > max_energy) max_energy = energy[g];
|
||||
}
|
||||
if (max_energy <= 0.0f) return 0;
|
||||
|
||||
float min_energy = max_energy * EDGE_PERSON_MIN_ENERGY_RATIO;
|
||||
|
||||
uint8_t counted_sc[EDGE_MAX_PERSONS];
|
||||
uint8_t count = 0;
|
||||
|
||||
bool used[EDGE_MAX_PERSONS];
|
||||
for (uint8_t g = 0; g < n_groups && g < EDGE_MAX_PERSONS; g++) used[g] = false;
|
||||
|
||||
for (uint8_t iter = 0; iter < n_groups && iter < EDGE_MAX_PERSONS; iter++) {
|
||||
int best = -1;
|
||||
float best_e = min_energy;
|
||||
for (uint8_t g = 0; g < n_groups && g < EDGE_MAX_PERSONS; g++) {
|
||||
if (used[g]) continue;
|
||||
if (energy[g] >= best_e) { best_e = energy[g]; best = g; }
|
||||
}
|
||||
if (best < 0) break;
|
||||
used[best] = true;
|
||||
|
||||
bool duplicate = false;
|
||||
for (uint8_t c = 0; c < count; c++) {
|
||||
int sep = (int)sc_idx[best] - (int)counted_sc[c];
|
||||
if (sep < 0) sep = -sep;
|
||||
if (sep < EDGE_PERSON_MIN_SC_SEP) { duplicate = true; break; }
|
||||
}
|
||||
if (duplicate) continue;
|
||||
|
||||
counted_sc[count++] = sc_idx[best];
|
||||
}
|
||||
|
||||
if (count == 0) count = 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/* person_count_debounce() — issue #998 count persistence. */
|
||||
static uint8_t person_count_debounce(uint8_t raw, uint8_t *candidate,
|
||||
uint8_t *streak, uint8_t *stable)
|
||||
{
|
||||
if (raw == *stable) {
|
||||
*candidate = raw;
|
||||
*streak = 0;
|
||||
return *stable;
|
||||
}
|
||||
if (raw == *candidate) {
|
||||
if (*streak < 0xFF) (*streak)++;
|
||||
} else {
|
||||
*candidate = raw;
|
||||
*streak = 1;
|
||||
}
|
||||
if (*streak >= EDGE_PERSON_PERSIST_FRAMES) {
|
||||
*stable = *candidate;
|
||||
*streak = 0;
|
||||
}
|
||||
return *stable;
|
||||
}
|
||||
|
||||
/* presence_flag_update() — issue #996 hysteresis + clear debounce. */
|
||||
static bool presence_flag_update(bool prev, float score, float threshold,
|
||||
uint8_t *below_count)
|
||||
{
|
||||
float low_thresh = threshold * EDGE_PRESENCE_HYST_RATIO;
|
||||
|
||||
if (score > threshold) {
|
||||
*below_count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (score >= low_thresh) {
|
||||
*below_count = 0;
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (*below_count < 0xFF) (*below_count)++;
|
||||
if (!prev) {
|
||||
return false;
|
||||
}
|
||||
if (*below_count >= EDGE_PRESENCE_CLEAR_FRAMES) {
|
||||
*below_count = 0;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* Test harness
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
static int g_failed = 0;
|
||||
static int g_passed = 0;
|
||||
|
||||
#define CHECK_EQ_U8(label, got, expected) do { \
|
||||
if ((uint8_t)(got) == (uint8_t)(expected)) { g_passed++; } \
|
||||
else { \
|
||||
g_failed++; \
|
||||
printf("FAIL: %s — got=%u expected=%u\n", \
|
||||
(label), (unsigned)(uint8_t)(got), \
|
||||
(unsigned)(uint8_t)(expected)); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_TRUE(label, cond) do { \
|
||||
if (cond) { g_passed++; } \
|
||||
else { g_failed++; printf("FAIL: %s — expected true\n", (label)); } \
|
||||
} while (0)
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* #998 — count_distinct_persons: single body must NOT report EDGE_MAX_PERSONS
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* One strong signature + weak multipath echoes in adjacent subcarrier groups.
|
||||
* This is exactly the field report: one person ~50 cm → persons=4. The energy
|
||||
* gate + spatial dedup must collapse this to 1. */
|
||||
static void test_count_single_strong_signature(void)
|
||||
{
|
||||
/* 4 groups: one dominant, three weak multipath (below the energy gate),
|
||||
* representative subcarriers clustered (adjacent → one body). */
|
||||
float energy[EDGE_MAX_PERSONS] = {10.0f, 0.6f, 0.4f, 0.3f};
|
||||
uint8_t sc[EDGE_MAX_PERSONS] = {20, 21, 22, 23};
|
||||
CHECK_EQ_U8("single strong signature → 1",
|
||||
count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 1);
|
||||
}
|
||||
|
||||
/* Even if the weak echoes are spatially spread, they're still below the energy
|
||||
* gate, so they don't count. */
|
||||
static void test_count_single_spread_multipath(void)
|
||||
{
|
||||
float energy[EDGE_MAX_PERSONS] = {10.0f, 1.0f, 0.8f, 0.5f};
|
||||
uint8_t sc[EDGE_MAX_PERSONS] = {10, 40, 70, 100};
|
||||
CHECK_EQ_U8("single body spread multipath → 1",
|
||||
count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 1);
|
||||
}
|
||||
|
||||
/* Two genuine, well-separated, comparably-strong signatures → 2. */
|
||||
static void test_count_two_well_separated(void)
|
||||
{
|
||||
float energy[EDGE_MAX_PERSONS] = {10.0f, 9.0f, 0.3f, 0.2f};
|
||||
uint8_t sc[EDGE_MAX_PERSONS] = {10, 90, 11, 12};
|
||||
CHECK_EQ_U8("two well-separated strong → 2",
|
||||
count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 2);
|
||||
}
|
||||
|
||||
/* Two strong but spatially ADJACENT signatures collapse to 1 (same body):
|
||||
* spatial dedup prevents double-counting one person's two strong subcarriers. */
|
||||
static void test_count_two_strong_adjacent_dedup(void)
|
||||
{
|
||||
float energy[EDGE_MAX_PERSONS] = {10.0f, 9.0f, 0.3f, 0.2f};
|
||||
uint8_t sc[EDGE_MAX_PERSONS] = {20, 21, 60, 61}; /* 20 & 21 adjacent */
|
||||
CHECK_EQ_U8("two strong but adjacent → 1 (dedup)",
|
||||
count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 1);
|
||||
}
|
||||
|
||||
/* No signal at all → 0 persons (empty room). */
|
||||
static void test_count_no_signal(void)
|
||||
{
|
||||
float energy[EDGE_MAX_PERSONS] = {0.0f, 0.0f, 0.0f, 0.0f};
|
||||
uint8_t sc[EDGE_MAX_PERSONS] = {10, 30, 50, 70};
|
||||
CHECK_EQ_U8("no signal → 0", count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 0);
|
||||
}
|
||||
|
||||
/* Three genuine well-separated strong signatures → 3 (gate doesn't under-count). */
|
||||
static void test_count_three_well_separated(void)
|
||||
{
|
||||
float energy[EDGE_MAX_PERSONS] = {10.0f, 9.0f, 8.0f, 0.2f};
|
||||
uint8_t sc[EDGE_MAX_PERSONS] = {10, 50, 90, 11};
|
||||
CHECK_EQ_U8("three well-separated strong → 3",
|
||||
count_distinct_persons(energy, sc, EDGE_MAX_PERSONS), 3);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* #998 — person_count_debounce: a single noisy frame can't change the count
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
static void test_debounce_rejects_transient_spike(void)
|
||||
{
|
||||
uint8_t candidate = 1, streak = 0, stable = 1; /* settled on 1 person */
|
||||
|
||||
/* One spurious frame reports 4 — must NOT promote. */
|
||||
uint8_t out = person_count_debounce(4, &candidate, &streak, &stable);
|
||||
CHECK_EQ_U8("transient spike held at 1", out, 1);
|
||||
|
||||
/* Back to 1 — resets pending change. */
|
||||
out = person_count_debounce(1, &candidate, &streak, &stable);
|
||||
CHECK_EQ_U8("recovered to 1", out, 1);
|
||||
CHECK_EQ_U8("streak reset", streak, 0);
|
||||
}
|
||||
|
||||
static void test_debounce_accepts_sustained_change(void)
|
||||
{
|
||||
uint8_t candidate = 1, streak = 0, stable = 1;
|
||||
|
||||
uint8_t out = 1;
|
||||
/* A genuine 2-person arrival must hold EDGE_PERSON_PERSIST_FRAMES frames. */
|
||||
for (int i = 0; i < EDGE_PERSON_PERSIST_FRAMES; i++) {
|
||||
out = person_count_debounce(2, &candidate, &streak, &stable);
|
||||
}
|
||||
CHECK_EQ_U8("sustained 2 promoted", out, 2);
|
||||
CHECK_EQ_U8("stable now 2", stable, 2);
|
||||
}
|
||||
|
||||
/* A flapping count (2,1,2,1,...) never accumulates a streak → stays at stable. */
|
||||
static void test_debounce_flapping_stays_stable(void)
|
||||
{
|
||||
uint8_t candidate = 1, streak = 0, stable = 1;
|
||||
uint8_t out = 1;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
out = person_count_debounce((i & 1) ? 1 : 2, &candidate, &streak, &stable);
|
||||
}
|
||||
CHECK_EQ_U8("flapping count stays at 1", out, 1);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* #996 — presence_flag_update: dithering score must NOT flicker the flag
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Field trace dithers around the OLD single threshold while the person is
|
||||
* clearly present. With T_high=10, T_low=5, a score sequence that crosses 10
|
||||
* up and down must produce a STABLE flag (no per-frame flicker). */
|
||||
static void test_presence_no_flicker_on_dither(void)
|
||||
{
|
||||
const float threshold = 10.0f; /* high threshold */
|
||||
/* Observed-style trace (issue evidence: 2.6-26.7), but here we model the
|
||||
* realistic "person present" case where the score mostly sits in/above the
|
||||
* dead band and only briefly dips. */
|
||||
float trace[] = {5.6f, 23.0f, 6.8f, 12.0f, 8.0f, 26.7f, 7.0f, 11.0f, 9.0f, 24.0f};
|
||||
int n = (int)(sizeof(trace) / sizeof(trace[0]));
|
||||
|
||||
bool flag = false;
|
||||
uint8_t below = 0;
|
||||
int flips = 0;
|
||||
bool prev = flag;
|
||||
for (int i = 0; i < n; i++) {
|
||||
flag = presence_flag_update(flag, trace[i], threshold, &below);
|
||||
if (i > 0 && flag != prev) flips++;
|
||||
prev = flag;
|
||||
}
|
||||
/* First sample (5.6) is below T_low=5? No, 5.6 >= 5 → dead band, holds
|
||||
* initial false until 23.0 asserts. After that, dips to 6.8/8.0/7.0/9.0 are
|
||||
* all >= T_low (5), so they HOLD true. The only transition is the initial
|
||||
* false→true. No flicker. */
|
||||
CHECK_TRUE("presence asserted by end", flag);
|
||||
CHECK_TRUE("at most one transition (no flicker)", flips <= 1);
|
||||
}
|
||||
|
||||
/* Hard dither straddling T_low must still not flicker frame-to-frame because of
|
||||
* the clear debounce: brief sub-T_low dips don't immediately clear. */
|
||||
static void test_presence_clear_debounce_holds(void)
|
||||
{
|
||||
const float threshold = 10.0f; /* T_low = 5.0 */
|
||||
bool flag = false;
|
||||
uint8_t below = 0;
|
||||
|
||||
/* Assert. */
|
||||
flag = presence_flag_update(flag, 20.0f, threshold, &below);
|
||||
CHECK_TRUE("asserted on strong score", flag);
|
||||
|
||||
/* A few brief dips below T_low (< CLEAR_FRAMES) must NOT clear. */
|
||||
for (int i = 0; i < EDGE_PRESENCE_CLEAR_FRAMES - 1; i++) {
|
||||
flag = presence_flag_update(flag, 1.0f, threshold, &below);
|
||||
}
|
||||
CHECK_TRUE("brief dips below T_low still present", flag);
|
||||
|
||||
/* Recovery resets the debounce. */
|
||||
flag = presence_flag_update(flag, 20.0f, threshold, &below);
|
||||
CHECK_TRUE("recovered", flag);
|
||||
CHECK_EQ_U8("below_count reset on recovery", below, 0);
|
||||
}
|
||||
|
||||
/* A genuine departure (score drops and STAYS low) clears within the hold window. */
|
||||
static void test_presence_genuine_departure_clears(void)
|
||||
{
|
||||
const float threshold = 10.0f;
|
||||
bool flag = false;
|
||||
uint8_t below = 0;
|
||||
|
||||
flag = presence_flag_update(flag, 20.0f, threshold, &below);
|
||||
CHECK_TRUE("asserted", flag);
|
||||
|
||||
/* Person leaves: score stays well below T_low for CLEAR_FRAMES frames. */
|
||||
for (int i = 0; i < EDGE_PRESENCE_CLEAR_FRAMES; i++) {
|
||||
flag = presence_flag_update(flag, 0.5f, threshold, &below);
|
||||
}
|
||||
CHECK_TRUE("cleared after sustained low", !flag);
|
||||
}
|
||||
|
||||
/* Schmitt gap: a score in the dead band (between T_low and T_high) holds state,
|
||||
* it neither asserts from false nor clears from true. */
|
||||
static void test_presence_dead_band_holds_state(void)
|
||||
{
|
||||
const float threshold = 10.0f; /* dead band 5..10 */
|
||||
uint8_t below = 0;
|
||||
|
||||
/* From false, a dead-band score does not assert. */
|
||||
bool flag = presence_flag_update(false, 7.0f, threshold, &below);
|
||||
CHECK_TRUE("dead band does not assert from false", !flag);
|
||||
|
||||
/* From true, a dead-band score does not clear. */
|
||||
below = 0;
|
||||
flag = presence_flag_update(true, 7.0f, threshold, &below);
|
||||
CHECK_TRUE("dead band does not clear from true", flag);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* main
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* #998 person count gate */
|
||||
test_count_single_strong_signature();
|
||||
test_count_single_spread_multipath();
|
||||
test_count_two_well_separated();
|
||||
test_count_two_strong_adjacent_dedup();
|
||||
test_count_no_signal();
|
||||
test_count_three_well_separated();
|
||||
|
||||
/* #998 count debounce */
|
||||
test_debounce_rejects_transient_spike();
|
||||
test_debounce_accepts_sustained_change();
|
||||
test_debounce_flapping_stays_stable();
|
||||
|
||||
/* #996 presence hysteresis */
|
||||
test_presence_no_flicker_on_dither();
|
||||
test_presence_clear_debounce_holds();
|
||||
test_presence_genuine_departure_clears();
|
||||
test_presence_dead_band_holds_state();
|
||||
|
||||
printf("\n%d passed, %d failed\n", g_passed, g_failed);
|
||||
return g_failed == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Field-peak localization for the Observatory 3D view (issue #1050).
|
||||
//!
|
||||
//! ## What this is (and is not)
|
||||
//!
|
||||
//! The `/ws/sensing` `sensing_update` frame already carries a real `signal_field`
|
||||
//! — a 20×20 grid built by `generate_signal_field()` from **measured subcarrier
|
||||
//! variances** weighted by the **measured motion-band power**. The grid's hot
|
||||
//! cells are the strongest scatterers in that field representation; as the CSI
|
||||
//! changes (a person moving through the link), the peak cell moves with it.
|
||||
//!
|
||||
//! This module reads the **strongest peak(s)** out of that real field and maps
|
||||
//! the peak cell to the Observatory room's world coordinates. That gives the
|
||||
//! 3D figure a position + motion magnitude that are **derived from real signal
|
||||
//! data**, so the figure now tracks where the field energy concentrates.
|
||||
//!
|
||||
//! ### Honesty caveat (do not over-claim)
|
||||
//!
|
||||
//! The field's subcarrier→angle mapping in `generate_signal_field()` is a
|
||||
//! *representation*, not calibrated multistatic triangulation in metric room
|
||||
//! coordinates. A single ESP32 link cannot resolve a true (x, z) room position.
|
||||
//! So the emitted `position` is **"strongest field peak in the room model"**,
|
||||
//! not survey-grade localization. It is real (a function of live CSI), it moves
|
||||
//! with real motion, and it is honest about its source — but it is NOT a
|
||||
//! calibrated person fix. Per-person skeletal `pose` keypoints in room
|
||||
//! coordinates remain gated on the pose model + paired ground-truth data
|
||||
//! (ADR-079), so `pose` here is only ever set from a real aggregate posture
|
||||
//! estimate when one exists, and is `None` otherwise (never fabricated).
|
||||
//!
|
||||
//! ## Coordinate mapping
|
||||
//!
|
||||
//! The Observatory builds its field point cloud (see `ui/observatory/js/main.js`
|
||||
//! `_buildSignalField`) as, for grid cell `(ix, iz)` of a `20×20` grid:
|
||||
//!
|
||||
//! ```text
|
||||
//! world_x = (ix - gridSize/2) * 0.6
|
||||
//! world_z = (iz - gridSize/2) * 0.5
|
||||
//! world_y = 0 (floor)
|
||||
//! ```
|
||||
//!
|
||||
//! and indexes the field as `idx = iz * gridSize + ix` — identical to the
|
||||
//! server's `generate_signal_field()` layout (`values[z * grid + x]`). We map
|
||||
//! the peak cell with the **same** transform so the figure lands exactly on the
|
||||
//! field hotspot it is standing on.
|
||||
|
||||
/// World-space scale factor for the X (width) axis, matching the Observatory's
|
||||
/// `_buildSignalField`: `world_x = (ix - nx/2) * X_SCALE`.
|
||||
pub const X_SCALE: f64 = 0.6;
|
||||
/// World-space scale factor for the Z (depth) axis, matching the Observatory's
|
||||
/// `_buildSignalField`: `world_z = (iz - nz/2) * Z_SCALE`.
|
||||
pub const Z_SCALE: f64 = 0.5;
|
||||
|
||||
/// Minimum normalized field value (`signal_field.values` are normalized to
|
||||
/// `[0, 1]`) for a cell to be considered a real peak rather than background
|
||||
/// attenuation. Below this we treat the field as having no localizable hotspot.
|
||||
pub const PEAK_THRESHOLD: f64 = 0.35;
|
||||
|
||||
/// A localized field peak in Observatory world coordinates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FieldPeak {
|
||||
/// World position `[x, y, z]` in Observatory scene units (meters). `y` is
|
||||
/// always `0.0` — the field is a floor-plane grid with no height info.
|
||||
pub position: [f64; 3],
|
||||
/// Normalized field intensity at the peak cell, in `[0, 1]`.
|
||||
pub intensity: f64,
|
||||
/// Source grid cell `(ix, iz)` the peak was read from (for tests/debug).
|
||||
pub cell: (usize, usize),
|
||||
}
|
||||
|
||||
/// Map a grid cell `(ix, iz)` of an `nx × nz` field to Observatory world
|
||||
/// coordinates, matching `ui/observatory/js/main.js::_buildSignalField`.
|
||||
#[must_use]
|
||||
pub fn cell_to_world(ix: usize, iz: usize, nx: usize, nz: usize) -> [f64; 3] {
|
||||
let wx = (ix as f64 - nx as f64 / 2.0) * X_SCALE;
|
||||
let wz = (iz as f64 - nz as f64 / 2.0) * Z_SCALE;
|
||||
[wx, 0.0, wz]
|
||||
}
|
||||
|
||||
/// Extract up to `max_peaks` strongest, spatially-separated peaks from a
|
||||
/// `signal_field` grid.
|
||||
///
|
||||
/// * `values` — row-major field grid, `values[iz * nx + ix]`, normalized to
|
||||
/// `[0, 1]` (as produced by `generate_signal_field`).
|
||||
/// * `nx`, `nz` — grid dimensions (the field's `grid_size` is `[nx, 1, nz]`).
|
||||
/// * `max_peaks` — how many person positions to extract (≥ 1).
|
||||
///
|
||||
/// Returns peaks sorted strongest-first. Each successive peak is forced to be
|
||||
/// at least `min_separation_cells` away from all previously selected peaks so
|
||||
/// two persons don't collapse onto the same hotspot. Returns an **empty**
|
||||
/// vector when no cell exceeds [`PEAK_THRESHOLD`] — an empty / no-presence
|
||||
/// field yields no phantom person.
|
||||
#[must_use]
|
||||
pub fn extract_peaks(
|
||||
values: &[f64],
|
||||
nx: usize,
|
||||
nz: usize,
|
||||
max_peaks: usize,
|
||||
min_separation_cells: f64,
|
||||
) -> Vec<FieldPeak> {
|
||||
if nx == 0 || nz == 0 || values.len() < nx * nz || max_peaks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Collect all cells above threshold, strongest first.
|
||||
let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
|
||||
for iz in 0..nz {
|
||||
for ix in 0..nx {
|
||||
let v = values[iz * nx + ix];
|
||||
if v >= PEAK_THRESHOLD {
|
||||
candidates.push((ix, iz, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_by(|a, b| b.2.total_cmp(&a.2));
|
||||
|
||||
let mut peaks: Vec<FieldPeak> = Vec::new();
|
||||
for (ix, iz, v) in candidates {
|
||||
if peaks.len() >= max_peaks {
|
||||
break;
|
||||
}
|
||||
// Enforce spatial separation from already-chosen peaks (in cell units).
|
||||
let too_close = peaks.iter().any(|p| {
|
||||
let dx = p.cell.0 as f64 - ix as f64;
|
||||
let dz = p.cell.1 as f64 - iz as f64;
|
||||
(dx * dx + dz * dz).sqrt() < min_separation_cells
|
||||
});
|
||||
if too_close {
|
||||
continue;
|
||||
}
|
||||
peaks.push(FieldPeak {
|
||||
position: cell_to_world(ix, iz, nx, nz),
|
||||
intensity: v,
|
||||
cell: (ix, iz),
|
||||
});
|
||||
}
|
||||
peaks
|
||||
}
|
||||
|
||||
/// Convert measured `motion_band_power` to the `motion_score` scale the
|
||||
/// Observatory UI expects.
|
||||
///
|
||||
/// The UI compares `motion_score > 50` to switch between calm and energetic
|
||||
/// emission (see `_updateDotMatrixMist` / `_updateParticleTrail`). The raw
|
||||
/// `motion_band_power` is already in roughly that band for live ESP32 data
|
||||
/// (the issue reports `motion_band_power: 63.3` while moving), so we pass it
|
||||
/// through directly, clamped to a sane `[0, 100]` display range. This keeps the
|
||||
/// emitted value a **direct, real** function of measured motion energy rather
|
||||
/// than a re-scaled invention.
|
||||
#[must_use]
|
||||
pub fn motion_score_from_power(motion_band_power: f64) -> f64 {
|
||||
motion_band_power.clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cell_to_world_matches_observatory_layout() {
|
||||
// Center cell of a 20×20 grid maps near origin.
|
||||
let c = cell_to_world(10, 10, 20, 20);
|
||||
assert!((c[0] - 0.0).abs() < 1e-9);
|
||||
assert_eq!(c[1], 0.0);
|
||||
assert!((c[2] - 0.0).abs() < 1e-9);
|
||||
|
||||
// Corner cell (0,0) maps to the room's near-left corner.
|
||||
let corner = cell_to_world(0, 0, 20, 20);
|
||||
assert!((corner[0] - (-6.0)).abs() < 1e-9); // (0-10)*0.6
|
||||
assert!((corner[2] - (-5.0)).abs() < 1e-9); // (0-10)*0.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_peaks_finds_known_hotspot() {
|
||||
// 20×20 field, all background, single strong peak at cell (15, 4).
|
||||
let nx = 20;
|
||||
let nz = 20;
|
||||
let mut values = vec![0.05; nx * nz];
|
||||
let peak_ix = 15;
|
||||
let peak_iz = 4;
|
||||
values[peak_iz * nx + peak_ix] = 1.0;
|
||||
|
||||
let peaks = extract_peaks(&values, nx, nz, 1, 3.0);
|
||||
assert_eq!(peaks.len(), 1);
|
||||
assert_eq!(peaks[0].cell, (peak_ix, peak_iz));
|
||||
|
||||
// Position must match the Observatory cell→world transform within tol.
|
||||
let expected = cell_to_world(peak_ix, peak_iz, nx, nz);
|
||||
assert!((peaks[0].position[0] - expected[0]).abs() < 1e-9);
|
||||
assert!((peaks[0].position[2] - expected[2]).abs() < 1e-9);
|
||||
// Sanity: (15-10)*0.6 = 3.0, (4-10)*0.5 = -3.0
|
||||
assert!((peaks[0].position[0] - 3.0).abs() < 1e-9);
|
||||
assert!((peaks[0].position[2] - (-3.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_field_yields_no_peaks() {
|
||||
let nx = 20;
|
||||
let nz = 20;
|
||||
// All cells below PEAK_THRESHOLD — no presence.
|
||||
let values = vec![0.10; nx * nz];
|
||||
let peaks = extract_peaks(&values, nx, nz, 3, 3.0);
|
||||
assert!(
|
||||
peaks.is_empty(),
|
||||
"below-threshold field must not produce a phantom peak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_separated_peaks_do_not_collapse() {
|
||||
let nx = 20;
|
||||
let nz = 20;
|
||||
let mut values = vec![0.05; nx * nz];
|
||||
values[2 * nx + 3] = 0.95; // peak A at (3, 2)
|
||||
values[15 * nx + 17] = 0.90; // peak B at (17, 15)
|
||||
|
||||
let peaks = extract_peaks(&values, nx, nz, 2, 3.0);
|
||||
assert_eq!(peaks.len(), 2);
|
||||
// Strongest first.
|
||||
assert_eq!(peaks[0].cell, (3, 2));
|
||||
assert_eq!(peaks[1].cell, (17, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_secondary_peak_is_suppressed() {
|
||||
let nx = 20;
|
||||
let nz = 20;
|
||||
let mut values = vec![0.05; nx * nz];
|
||||
values[10 * nx + 10] = 1.00; // primary
|
||||
values[10 * nx + 11] = 0.99; // adjacent — should be suppressed (sep 3.0)
|
||||
|
||||
let peaks = extract_peaks(&values, nx, nz, 2, 3.0);
|
||||
assert_eq!(peaks.len(), 1, "adjacent cell must not become a 2nd person");
|
||||
assert_eq!(peaks[0].cell, (10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_score_passthrough_and_clamp() {
|
||||
assert!((motion_score_from_power(63.3) - 63.3).abs() < 1e-9);
|
||||
assert_eq!(motion_score_from_power(-5.0), 0.0);
|
||||
assert_eq!(motion_score_from_power(250.0), 100.0);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod cli;
|
||||
pub mod csi;
|
||||
mod engine_bridge;
|
||||
mod field_bridge;
|
||||
mod field_localize;
|
||||
mod model_format;
|
||||
mod multistatic_bridge;
|
||||
pub mod pose;
|
||||
@@ -406,6 +407,24 @@ struct PersonDetection {
|
||||
keypoints: Vec<PoseKeypoint>,
|
||||
bbox: BoundingBox,
|
||||
zone: String,
|
||||
/// Room-world position `[x, y, z]` (Observatory scene units / meters),
|
||||
/// derived from the strongest `signal_field` peak this person sits on
|
||||
/// (issue #1050). `y` is `0.0` — the field is a floor-plane grid. This is
|
||||
/// a real field-peak readout, not calibrated triangulation; see
|
||||
/// `field_localize` for the honesty caveat. Defaults to `[0,0,0]` until
|
||||
/// field positions are attached by `attach_field_positions`.
|
||||
#[serde(default)]
|
||||
position: [f64; 3],
|
||||
/// Motion magnitude on the Observatory's `0..100` scale, passed through
|
||||
/// from the measured `motion_band_power` (issue #1050).
|
||||
#[serde(default)]
|
||||
motion_score: f64,
|
||||
/// Coarse posture label (`"standing"`/`"lying"`/…) when a **real** aggregate
|
||||
/// posture estimate exists, else `None`. Never fabricated — per-person
|
||||
/// skeletal pose in room coordinates remains gated on the pose model
|
||||
/// (ADR-079). The Observatory defaults to `'standing'` when this is absent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pose: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -2572,6 +2591,8 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -2725,6 +2746,8 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -3163,12 +3186,21 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
|
||||
x: kp[0], y: kp[1], z: kp[2], confidence: kp[3],
|
||||
})
|
||||
.collect();
|
||||
let [nx, _ny, nz] = sensing.signal_field.grid_size;
|
||||
let peak = field_localize::extract_peaks(
|
||||
&sensing.signal_field.values, nx, nz, 1, 3.0,
|
||||
).into_iter().next();
|
||||
vec![PersonDetection {
|
||||
id: 1,
|
||||
confidence: sensing.classification.confidence,
|
||||
bbox: BoundingBox { x: 260.0, y: 150.0, width: 120.0, height: 220.0 },
|
||||
keypoints,
|
||||
zone: "zone_1".into(),
|
||||
position: peak.map_or([0.0, 0.0, 0.0], |p| p.position),
|
||||
motion_score: field_localize::motion_score_from_power(
|
||||
sensing.features.motion_band_power,
|
||||
),
|
||||
pose: sensing.posture.clone(),
|
||||
}]
|
||||
}).unwrap_or_else(|| {
|
||||
// Prefer tracked persons from broadcast if available
|
||||
@@ -3947,6 +3979,53 @@ fn derive_single_person_pose(
|
||||
height: (max_y - min_y).max(160.0),
|
||||
},
|
||||
zone: format!("zone_{}", person_idx + 1),
|
||||
// Position/motion_score/pose are attached from the real signal_field
|
||||
// peaks by `attach_field_positions` after the tracker step (#1050);
|
||||
// default here so the synthetic-skeleton geometry stays unchanged.
|
||||
position: [0.0, 0.0, 0.0],
|
||||
motion_score: 0.0,
|
||||
pose: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach real, field-derived per-person world positions to a `SensingUpdate`'s
|
||||
/// `persons` (issue #1050).
|
||||
///
|
||||
/// For each detected person we read a strongest-peak position out of the frame's
|
||||
/// real `signal_field` (the same grid the Observatory already renders) and map
|
||||
/// it to room-world coordinates via `field_localize::cell_to_world`. `motion_score`
|
||||
/// is passed through from the measured `motion_band_power`; `pose` is taken from
|
||||
/// the real aggregate `posture` estimate when present, else left `None` (never
|
||||
/// fabricated). Persons beyond the number of resolvable field peaks fall back to
|
||||
/// the strongest peak so they remain co-located with real energy rather than at
|
||||
/// a fake origin; if the field has no peak above threshold the position stays at
|
||||
/// `[0,0,0]` and `motion_score` still reflects real motion power.
|
||||
fn attach_field_positions(update: &mut SensingUpdate) {
|
||||
let Some(persons) = update.persons.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if persons.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let [nx, _ny, nz] = update.signal_field.grid_size;
|
||||
let peaks = field_localize::extract_peaks(
|
||||
&update.signal_field.values,
|
||||
nx,
|
||||
nz,
|
||||
persons.len().max(1),
|
||||
3.0,
|
||||
);
|
||||
|
||||
let motion_score = field_localize::motion_score_from_power(update.features.motion_band_power);
|
||||
let pose_label = update.posture.clone();
|
||||
|
||||
for (i, person) in persons.iter_mut().enumerate() {
|
||||
if let Some(peak) = peaks.get(i).or_else(|| peaks.first()) {
|
||||
person.position = peak.position;
|
||||
}
|
||||
person.motion_score = motion_score;
|
||||
person.pose = pose_label.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5473,6 +5552,8 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -5903,6 +5984,8 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -6076,6 +6159,8 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
if update.classification.presence {
|
||||
s.total_detections += 1;
|
||||
@@ -8220,3 +8305,171 @@ mod export_rvf_mode_tests {
|
||||
assert!(!export_emits_placeholder_demo(false, true, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod observatory_persons_field_position_tests {
|
||||
//! Issue #1050 — the Observatory 3D figure animates from per-person
|
||||
//! `position` / `motion_score` / `pose` carried on `sensing_update.persons`.
|
||||
//!
|
||||
//! These tests pin the public WS contract: a frame that detects a person on
|
||||
//! a known signal_field peak must emit a `persons` array whose first entry
|
||||
//! carries a `position` derived from that peak (matching the Observatory's
|
||||
//! cell→world transform), a real `motion_score`, and a serialized frame
|
||||
//! that round-trips. An empty / no-presence field must emit `persons: []`
|
||||
//! (or no person), never a phantom person at a fabricated origin.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a 20×20 signal_field that is background everywhere except a single
|
||||
/// strong normalized peak at grid cell `(ix, iz)`.
|
||||
fn field_with_peak(ix: usize, iz: usize) -> SignalField {
|
||||
let nx = 20usize;
|
||||
let nz = 20usize;
|
||||
let mut values = vec![0.05f64; nx * nz];
|
||||
values[iz * nx + ix] = 1.0;
|
||||
SignalField {
|
||||
grid_size: [nx, 1, nz],
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an all-background (below-threshold) 20×20 field — no localizable
|
||||
/// hotspot, modelling an empty / no-presence room.
|
||||
fn empty_field() -> SignalField {
|
||||
SignalField {
|
||||
grid_size: [20, 1, 20],
|
||||
values: vec![0.05f64; 20 * 20],
|
||||
}
|
||||
}
|
||||
|
||||
fn base_update(signal_field: SignalField, presence: bool, motion_band_power: f64) -> SensingUpdate {
|
||||
SensingUpdate {
|
||||
msg_type: "sensing_update".to_string(),
|
||||
timestamp: 1.0,
|
||||
source: "test".to_string(),
|
||||
tick: 1,
|
||||
nodes: vec![],
|
||||
features: FeatureInfo {
|
||||
mean_rssi: -60.0,
|
||||
variance: 48.6,
|
||||
motion_band_power,
|
||||
breathing_band_power: 0.0,
|
||||
dominant_freq_hz: 1.0,
|
||||
change_points: 0,
|
||||
spectral_power: 0.0,
|
||||
},
|
||||
classification: ClassificationInfo {
|
||||
motion_level: if presence { "present_moving".to_string() } else { "absent".to_string() },
|
||||
presence,
|
||||
confidence: 0.8,
|
||||
},
|
||||
signal_field,
|
||||
vital_signs: None,
|
||||
enhanced_motion: None,
|
||||
enhanced_breathing: None,
|
||||
posture: None,
|
||||
signal_quality_score: None,
|
||||
quality_verdict: None,
|
||||
bssid_count: None,
|
||||
pose_keypoints: None,
|
||||
model_status: None,
|
||||
persons: None,
|
||||
estimated_persons: Some(1),
|
||||
node_features: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensing_update_emits_persons_with_field_derived_position() {
|
||||
// Person present, motion energy 63.3, a hotspot at cell (15, 4).
|
||||
let peak_ix = 15;
|
||||
let peak_iz = 4;
|
||||
let mut update = base_update(field_with_peak(peak_ix, peak_iz), true, 63.3);
|
||||
|
||||
// Pipeline order: derive raw skeleton, then attach real field positions.
|
||||
update.persons = Some(derive_pose_from_sensing(&update));
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
let persons = update.persons.as_ref().expect("persons should be Some");
|
||||
assert!(!persons.is_empty(), "a present person must be emitted");
|
||||
|
||||
// Position must match the Observatory cell→world transform for (15, 4):
|
||||
// x = (15-10)*0.6 = 3.0 ; z = (4-10)*0.5 = -3.0 ; y = 0.
|
||||
let p0 = &persons[0];
|
||||
assert!((p0.position[0] - 3.0).abs() < 1e-6, "x={}", p0.position[0]);
|
||||
assert!((p0.position[1] - 0.0).abs() < 1e-9);
|
||||
assert!((p0.position[2] - (-3.0)).abs() < 1e-6, "z={}", p0.position[2]);
|
||||
|
||||
// motion_score is the measured motion_band_power passed through (≤100).
|
||||
assert!((p0.motion_score - 63.3).abs() < 1e-6, "motion_score={}", p0.motion_score);
|
||||
|
||||
// The serialized WS frame must carry the new fields by their exact
|
||||
// contract names the Observatory UI reads.
|
||||
let v = serde_json::to_value(&update).unwrap();
|
||||
let arr = v["persons"].as_array().expect("persons must be a JSON array");
|
||||
assert_eq!(arr.len(), persons.len());
|
||||
let pj = &arr[0];
|
||||
assert!(pj.get("position").is_some(), "person.position missing from WS frame");
|
||||
assert!(pj.get("motion_score").is_some(), "person.motion_score missing from WS frame");
|
||||
assert!((pj["position"][0].as_f64().unwrap() - 3.0).abs() < 1e-6);
|
||||
assert!((pj["position"][2].as_f64().unwrap() - (-3.0)).abs() < 1e-6);
|
||||
assert!((pj["motion_score"].as_f64().unwrap() - 63.3).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pose_is_real_when_posture_present_and_absent_otherwise() {
|
||||
// No aggregate posture estimate → pose is None (never fabricated).
|
||||
let mut no_posture = base_update(field_with_peak(10, 10), true, 40.0);
|
||||
no_posture.persons = Some(derive_pose_from_sensing(&no_posture));
|
||||
attach_field_positions(&mut no_posture);
|
||||
let p = &no_posture.persons.as_ref().unwrap()[0];
|
||||
assert!(p.pose.is_none(), "pose must stay None when no real posture exists");
|
||||
// skip_serializing_if drops the key entirely (UI defaults to 'standing').
|
||||
let v = serde_json::to_value(&no_posture).unwrap();
|
||||
assert!(v["persons"][0].get("pose").is_none());
|
||||
|
||||
// Real aggregate posture present → pose is carried through verbatim.
|
||||
let mut with_posture = base_update(field_with_peak(10, 10), true, 40.0);
|
||||
with_posture.posture = Some("lying".to_string());
|
||||
with_posture.persons = Some(derive_pose_from_sensing(&with_posture));
|
||||
attach_field_positions(&mut with_posture);
|
||||
let p2 = &with_posture.persons.as_ref().unwrap()[0];
|
||||
assert_eq!(p2.pose.as_deref(), Some("lying"));
|
||||
let v2 = serde_json::to_value(&with_posture).unwrap();
|
||||
assert_eq!(v2["persons"][0]["pose"], "lying");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_room_yields_no_phantom_person() {
|
||||
// No presence → derive_pose_from_sensing returns no persons at all.
|
||||
let mut update = base_update(empty_field(), false, 2.0);
|
||||
update.persons = Some(derive_pose_from_sensing(&update));
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
let persons = update.persons.as_ref().unwrap();
|
||||
assert!(
|
||||
persons.is_empty(),
|
||||
"no-presence frame must not emit a phantom person, got {} persons",
|
||||
persons.len()
|
||||
);
|
||||
|
||||
// And in the serialized frame the array is empty (no fake origin person).
|
||||
let v = serde_json::to_value(&update).unwrap();
|
||||
assert_eq!(v["persons"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_but_below_threshold_field_keeps_position_at_origin_not_fabricated() {
|
||||
// Presence is true but the field has no peak above PEAK_THRESHOLD — we
|
||||
// must NOT invent a position; it stays at the [0,0,0] default while
|
||||
// motion_score still reflects the real measured motion power. This is
|
||||
// the honest degenerate case (no localizable hotspot to report).
|
||||
let mut update = base_update(empty_field(), true, 55.0);
|
||||
update.persons = Some(derive_pose_from_sensing(&update));
|
||||
attach_field_positions(&mut update);
|
||||
|
||||
let p = &update.persons.as_ref().unwrap()[0];
|
||||
assert_eq!(p.position, [0.0, 0.0, 0.0], "no peak → default origin, not fabricated coords");
|
||||
assert!((p.motion_score - 55.0).abs() < 1e-6, "motion_score stays real");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,11 @@ pub fn derive_single_person_pose(
|
||||
height: (max_y - min_y).max(160.0),
|
||||
},
|
||||
zone: format!("zone_{}", person_idx + 1),
|
||||
// Field-derived fields (#1050) — defaulted here; the live `/ws/sensing`
|
||||
// path attaches real positions via `attach_field_positions`.
|
||||
position: [0.0, 0.0, 0.0],
|
||||
motion_score: 0.0,
|
||||
pose: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,13 @@ pub fn tracker_to_person_detections(tracker: &PoseTracker) -> Vec<PersonDetectio
|
||||
keypoints,
|
||||
bbox,
|
||||
zone: "tracked".to_string(),
|
||||
// Field-derived position/motion_score/pose are (re)attached from
|
||||
// the live signal_field by `attach_field_positions` after this
|
||||
// tracker step (#1050); the Kalman tracker smooths keypoints only,
|
||||
// so we default here and let the field readout fill them in.
|
||||
position: [0.0, 0.0, 0.0],
|
||||
motion_score: 0.0,
|
||||
pose: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -329,6 +336,9 @@ mod tests {
|
||||
height: 1.0,
|
||||
},
|
||||
zone: "test".to_string(),
|
||||
position: [0.0, 0.0, 0.0],
|
||||
motion_score: 0.0,
|
||||
pose: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,21 @@ pub struct PersonDetection {
|
||||
pub keypoints: Vec<PoseKeypoint>,
|
||||
pub bbox: BoundingBox,
|
||||
pub zone: String,
|
||||
/// Room-world position `[x, y, z]` (Observatory scene units / meters),
|
||||
/// derived from the strongest `signal_field` peak (issue #1050). `y` is
|
||||
/// `0.0` — the field is a floor-plane grid. Real field-peak readout, not
|
||||
/// calibrated triangulation. Defaults to `[0,0,0]`.
|
||||
#[serde(default)]
|
||||
pub position: [f64; 3],
|
||||
/// Motion magnitude on the Observatory's `0..100` scale, passed through
|
||||
/// from the measured `motion_band_power` (issue #1050).
|
||||
#[serde(default)]
|
||||
pub motion_score: f64,
|
||||
/// Coarse posture label when a real aggregate posture estimate exists,
|
||||
/// else `None`. Never fabricated; per-person skeletal pose remains gated
|
||||
/// on the pose model (ADR-079).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pose: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+1
Submodule vendor/rufield added at c6abe92746
Reference in New Issue
Block a user