mirror of
https://github.com/ruvnet/RuView.git
synced 2026-09-01 13:07:25 +00:00
feat(firmware): stabilize rate-aware ESP32 sensing
This commit is contained in:
83
docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md
Normal file
83
docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# ADR 347: Rate aware ESP32 temporal sensing
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented in firmware 0.8.8. The timing and transport path is
|
||||
physically qualified on ESP32 C6; held out inference accuracy remains required.
|
||||
|
||||
## Context
|
||||
|
||||
The ESP32 firmware creates CSI opportunities by sending one byte ICMP probes to
|
||||
the connected access point. The traffic source is configured for 50 Hz, but the
|
||||
delivered CSI cadence varies with channel contention and callback safety gates.
|
||||
A physical ESP32 C6 produced 28 to 37 callbacks per second during the baseline
|
||||
capture. Firmware 0.8.5 then exposed that the old per-interval estimator saw
|
||||
only 12 to 16 Hz because WiFi replies arrived in short bursts separated by
|
||||
longer gaps. The filters still consumed those burst frames, so excluding them
|
||||
from the clock estimate was incorrect.
|
||||
|
||||
The edge DSP estimates its sample rate from timestamps so that breathing,
|
||||
heartbeat, motion, and future Doppler features stay in physical Hertz. That
|
||||
estimator was capped at 30 Hz. Once the actual cadence exceeded the cap, every
|
||||
temporal feature was scaled against the wrong clock.
|
||||
|
||||
Physical firmware 0.8.5 validation corrected that initial diagnosis. Although
|
||||
the callback path received 26 to 40 frames per second, Tier 2 on the unicore C6
|
||||
processed an irregular subset that converged toward the 8 Hz estimator floor.
|
||||
The right design is not to force the edge DSP to match raw capture. The paths
|
||||
need independent, explicit cadence contracts.
|
||||
|
||||
The device free gesture preprint at
|
||||
`https://www.preprints.org/manuscript/202602.0018` reinforces the importance of
|
||||
timestamp correct Doppler features, but its 100 Hz controlled link is not a
|
||||
safe firmware default for RuView. Existing S3 and C6 evidence records WiFi ISR
|
||||
and packet buffer failures under sustained callback pressure above 50 Hz.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Make the connected STA probe rate a build time setting from 10 through 50
|
||||
Hz, with a default and hard ceiling of 50 Hz.
|
||||
|
||||
2. Track the delivered DSP cadence by counting every processed frame interval
|
||||
over one second timestamp windows, then smooth successive windows in an 8
|
||||
through 60 Hz estimator range. The 60 Hz estimator ceiling accommodates
|
||||
timestamp jitter; it does not authorize more than 50 Hz callback processing.
|
||||
|
||||
3. Reject incomplete windows below one second and stalled windows above three
|
||||
seconds. Do not discard valid burst frames from the estimated clock.
|
||||
|
||||
4. Surface the DSP rate in the one second controller diagnostic so hardware
|
||||
validation can compare callback yield with the clock used by temporal
|
||||
filters.
|
||||
|
||||
5. Keep raw CSI on the wire at the independent network cadence. Rate-limit the
|
||||
C6 on-device Tier 1 and Tier 2 DSP input to a uniform 8 Hz. Physical 0.8.7
|
||||
evidence showed that a requested 10 Hz input still converged to 8.0 through
|
||||
8.4 Hz under Tier 2 load, while raw delivery remained 30 through 40 pps.
|
||||
Eight hertz retains a 4 Hz Nyquist limit for the 0.1 through 2.0 Hz vital
|
||||
bands without creating a backlog. The S3 default remains 20 Hz.
|
||||
|
||||
6. STFT, spectrogram gating, and learned temporal
|
||||
classification remain host or iPhone responsibilities where memory,
|
||||
rollback, and held out evaluation are stronger.
|
||||
|
||||
## Consequences
|
||||
|
||||
Heartbeat, respiration, and motion features receive a stable timestamped clock
|
||||
instead of an accidental subset determined by C6 backlog. Operators can lower
|
||||
the probe or DSP load for constrained networks without editing source. The host
|
||||
still receives the higher-rate raw stream for richer Doppler processing.
|
||||
|
||||
This does not prove vital sign accuracy or gesture recognition. Higher temporal
|
||||
fidelity only improves the representation available to a separately validated
|
||||
model. The 50 Hz ceiling also means the paper's 100 Hz results are not directly
|
||||
transferable.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
On a physical C6, run at least five minutes after flashing. Pass when the boot
|
||||
log reports the configured probe and DSP rates, the controller converges within
|
||||
one hertz of the configured DSP cadence, raw callback yield remains at least 20
|
||||
pps, no steady-state ENOMEM, watchdog, panic, or reboot occurs, and the fail
|
||||
closed occupancy invariant remains zero contradictions for at least 30 absent
|
||||
packets.
|
||||
@@ -106,7 +106,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
|
||||
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
|
||||
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
|
||||
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (implemented, physical qualification pending) |
|
||||
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
|
||||
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
|
||||
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
|
||||
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
|
||||
122
docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md
Normal file
122
docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# ESP32 C6 rate aware sensing qualification
|
||||
|
||||
## Scope
|
||||
|
||||
This record qualifies ADR 347 on one physically attached ESP32 C6 and verifies
|
||||
that the same source compiles for ESP32 S3. It measures transport cadence, edge
|
||||
DSP cadence, process stability, and end to end sensing delivery. It does not
|
||||
qualify heartbeat, respiration, gesture, pose, identity, or person count
|
||||
accuracy against labelled ground truth.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
| Field | Measured value |
|
||||
|---|---|
|
||||
| Board | ESP32 C6 QFN40 revision 0.2 |
|
||||
| Logical node | 4 |
|
||||
| Firmware before | 0.8.4 |
|
||||
| Firmware after | 0.8.8 development build |
|
||||
| C6 app image | 1,051,552 bytes |
|
||||
| C6 app SHA 256 | `f2ea422c9b99ec13c7a168afc2b019229642769ffabfd8f29a85978770236e87` |
|
||||
| OTA slot size | 1,900,544 bytes |
|
||||
| OTA headroom | 848,992 bytes, 45 percent |
|
||||
| S3 compile image | 1,127,104 bytes |
|
||||
| S3 compile SHA 256 | `63e4f0c484d79e7dd37eb28275951c8beb924e6908942f7fec0b90d92109129c` |
|
||||
|
||||
Only the application partition at offset `0x20000` was flashed. WiFi
|
||||
credentials, node identity, sensing server target, bootloader, partition table,
|
||||
OTA metadata, and NVS were preserved. The pre update OTA application was read
|
||||
to a private recovery file outside the repository. Its SHA 256 is
|
||||
`a2e503f1622b2f3f9c1cfce0a07ba34b9fc5d6a413b6346311622af1fe18a6d8`.
|
||||
|
||||
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` after the
|
||||
update. The sensing server health endpoint remained ready with ESP32 input.
|
||||
|
||||
## Software gates
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| Rate estimator and occupancy host tests | PASS, 30 assertions |
|
||||
| ADR 110 encoding host tests | PASS, 21 assertions |
|
||||
| mmWave frame predicate host tests | PASS, 8 assertions |
|
||||
| ESP32 C6 IDF 5.4 ARM64 build | PASS |
|
||||
| ESP32 S3 IDF 5.4 ARM64 build | PASS, compile only |
|
||||
| Image checksum and validation hash | PASS |
|
||||
| Repository diff whitespace check | PASS |
|
||||
| Local libFuzzer aggregate | NOT RUN, local Xcode toolchain lacks `libclang_rt.fuzzer_osx.a` |
|
||||
|
||||
The S3 result is source and toolchain validation only. No S3 was attached and
|
||||
no S3 runtime claim is made.
|
||||
|
||||
## Measured rate correction
|
||||
|
||||
The pre update 20 second C6 baseline delivered a mean 34.05 raw callbacks per
|
||||
second, median 34.5, and range 28 through 37. An intermediate 0.8.7 physical
|
||||
run requested 10 Hz edge DSP but converged to 8.0 through 8.4 Hz while raw CSI
|
||||
remained 30 through 40 packets per second. This proved that C6 Tier 2 compute,
|
||||
not the raw transport, was the limiting path.
|
||||
|
||||
Firmware 0.8.8 therefore keeps the 50 Hz probe and independent raw network
|
||||
path, but sets the C6 Tier 2 DSP clock to its measured sustainable 8 Hz. The
|
||||
phase preserving sampler prevents callback jitter from shifting the configured
|
||||
clock, and the filter estimator follows processed timestamps rather than raw
|
||||
probe intent.
|
||||
|
||||
## Five minute physical result
|
||||
|
||||
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
|
||||
|
||||
| Device observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.64 seconds |
|
||||
| Controller ticks | 300 |
|
||||
| Raw callback mean | 34.92 pps |
|
||||
| Raw callback range | 22 through 41 pps |
|
||||
| Edge DSP mean | 8.00 Hz |
|
||||
| Edge DSP range | 8.00 through 8.00 Hz |
|
||||
| ENOMEM events | 0 |
|
||||
| UDP send failures | 0 |
|
||||
| Other steady state errors | 0 |
|
||||
| Watchdogs, panics, or reboots | 0 |
|
||||
|
||||
| End to end WebSocket observation | Result |
|
||||
|---|---:|
|
||||
| Duration | 300.01 seconds |
|
||||
| Sensing frames | 26,786 |
|
||||
| JSON parse errors | 0 |
|
||||
| Reconnects | 0 |
|
||||
| Frames containing node 4 | 26,148 |
|
||||
| Node 4 frame coverage | 97.62 percent |
|
||||
| Node 4 stale frames | 0 |
|
||||
| Maximum node 4 inference age | 176 ms |
|
||||
| Maximum WebSocket frame gap | 110 ms |
|
||||
| Nodes per frame | 0 through 4 |
|
||||
| Fused `presence=false` with nonzero count contradictions | 0 |
|
||||
|
||||
The boot log emitted one expected iTWT negotiation error because the access
|
||||
point rejected the requested target wake time parameters. Firmware immediately
|
||||
selected its documented opportunistic CSI fallback. No iTWT or other error
|
||||
recurred during the five minute steady state window.
|
||||
|
||||
## Result and limitation
|
||||
|
||||
ADR 347 timing and transport acceptance passes on the attached C6. Raw
|
||||
throughput did not regress relative to the short baseline, the edge clock now
|
||||
matches the rate the temporal filters actually receive, and node 4 was never
|
||||
stale when present in the live sensing service. The separate occupancy
|
||||
qualification recorded 61 absent node 4 packets with zero contradictions for
|
||||
the unchanged fail closed invariant. This run did not repeat an empty room
|
||||
sequence because the room was occupied during qualification.
|
||||
|
||||
The largest remaining uncertainty is inference accuracy. Stable timing removes
|
||||
one source of feature distortion but cannot prove better heartbeat, respiration,
|
||||
gesture, or multi person classification without synchronized held out labels.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Repeat this five minute procedure after any timing, WiFi, filter, or task
|
||||
scheduling change. Pass only when raw callback yield remains at least 20 pps,
|
||||
DSP cadence remains within one hertz of the configured target, the device has
|
||||
zero steady state ENOMEM, send failure, watchdog, panic, and reboot events, the
|
||||
server has zero parse failures and reconnects, node 4 stays fresh, and fused
|
||||
presence count contradictions remain zero.
|
||||
@@ -39,6 +39,17 @@ menu "CSI Node Configuration"
|
||||
help
|
||||
WiFi channel to listen on for CSI data.
|
||||
|
||||
config CSI_SELF_PING_HZ
|
||||
int "Connected-STA CSI probe rate (Hz)"
|
||||
default 50
|
||||
range 10 50
|
||||
help
|
||||
Rate of the one-byte ICMP probes used to create a stable OFDM
|
||||
CSI source on quiet networks. Fifty hertz is the measured safety
|
||||
ceiling for the current ESP-IDF WiFi callback path. Higher rates
|
||||
are intentionally rejected because sustained callback load above
|
||||
50 Hz has caused WiFi ISR and packet-buffer failures on S3 and C6.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Edge Intelligence (ADR-039)"
|
||||
@@ -66,6 +77,18 @@ menu "Edge Intelligence (ADR-039)"
|
||||
help
|
||||
Number of highest-variance subcarriers to use for DSP.
|
||||
|
||||
config EDGE_DSP_SAMPLE_HZ
|
||||
int "On-device edge DSP sample rate (Hz)"
|
||||
default 8 if IDF_TARGET_ESP32C6
|
||||
default 20
|
||||
range 8 50
|
||||
help
|
||||
Uniform rate at which CSI callbacks enter the Tier 1 and Tier 2
|
||||
edge DSP. Raw CSI transmission keeps its independent full-rate
|
||||
path. Eight hertz is the hardware-measured sustainable C6 Tier 2
|
||||
setting and preserves a 4 Hz Nyquist limit for the 0.1-2.0 Hz
|
||||
vital bands.
|
||||
|
||||
config EDGE_FALL_THRESH
|
||||
int "Fall detection threshold (x1000)"
|
||||
default 15000
|
||||
|
||||
@@ -248,9 +248,10 @@ static void medium_loop_cb(TimerHandle_t t)
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (s_obs_valid) {
|
||||
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d",
|
||||
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps dsp=%.1fHz motion=%.2f presence=%.2f rssi=%d",
|
||||
(unsigned)s_state,
|
||||
(unsigned)obs.pkt_yield_per_sec,
|
||||
(double)edge_get_sample_rate_hz(),
|
||||
(double)obs.motion_score,
|
||||
(double)obs.presence_score,
|
||||
(int)obs.rssi_median_dbm);
|
||||
|
||||
@@ -63,6 +63,32 @@ static uint32_t s_send_ok = 0;
|
||||
static uint32_t s_send_fail = 0;
|
||||
static uint32_t s_rate_skip = 0;
|
||||
|
||||
#ifndef CONFIG_CSI_SELF_PING_HZ
|
||||
#define CONFIG_CSI_SELF_PING_HZ 50
|
||||
#endif
|
||||
|
||||
#if CONFIG_CSI_SELF_PING_HZ < 10 || CONFIG_CSI_SELF_PING_HZ > 50
|
||||
#error "CONFIG_CSI_SELF_PING_HZ must stay within the hardware-qualified 10-50 Hz range"
|
||||
#endif
|
||||
|
||||
#define CSI_SELF_PING_INTERVAL_MS (1000U / CONFIG_CSI_SELF_PING_HZ)
|
||||
|
||||
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
|
||||
#else
|
||||
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if CONFIG_EDGE_DSP_SAMPLE_HZ < 8 || CONFIG_EDGE_DSP_SAMPLE_HZ > 50
|
||||
#error "CONFIG_EDGE_DSP_SAMPLE_HZ must stay within the supported 8-50 Hz range"
|
||||
#endif
|
||||
|
||||
#define EDGE_DSP_MIN_INTERVAL_US (1000000U / CONFIG_EDGE_DSP_SAMPLE_HZ)
|
||||
static int64_t s_next_edge_enqueue_us = 0;
|
||||
static uint32_t s_edge_rate_skip = 0;
|
||||
|
||||
/**
|
||||
* Minimum interval between UDP sends in microseconds.
|
||||
* CSI callbacks can fire hundreds of times per second in promiscuous mode.
|
||||
@@ -300,10 +326,31 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */
|
||||
/* ADR-039 / ADR-347: Raw CSI stays at the independent network cadence,
|
||||
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
|
||||
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
|
||||
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
|
||||
if (info->buf && info->len > 0) {
|
||||
edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
|
||||
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
if (s_next_edge_enqueue_us == 0) {
|
||||
s_next_edge_enqueue_us = now_us;
|
||||
}
|
||||
|
||||
if (now_us >= s_next_edge_enqueue_us) {
|
||||
(void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
|
||||
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
|
||||
/* Preserve the configured sample clock instead of resetting it to
|
||||
* each irregular callback. With roughly 35 raw callbacks per
|
||||
* second, a last-seen 100 ms gate selected every fourth callback
|
||||
* and drifted to roughly 8 Hz. Advancing the deadline by complete
|
||||
* periods alternates the available callbacks around the configured
|
||||
* phase and prevents both drift and catch-up bursts. */
|
||||
int64_t periods = ((now_us - s_next_edge_enqueue_us) /
|
||||
EDGE_DSP_MIN_INTERVAL_US) + 1;
|
||||
s_next_edge_enqueue_us += periods * EDGE_DSP_MIN_INTERVAL_US;
|
||||
} else {
|
||||
s_edge_rate_skip++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-110 §A0.11/§A0.12 — Emit a sync-packet every N CSI frames so the
|
||||
@@ -411,7 +458,7 @@ static void csi_start_self_ping(void)
|
||||
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
|
||||
cfg.target_addr = target;
|
||||
cfg.count = ESP_PING_COUNT_INFINITE;
|
||||
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
|
||||
cfg.interval_ms = CSI_SELF_PING_INTERVAL_MS;
|
||||
cfg.data_size = 1;
|
||||
cfg.task_stack_size = 4096;
|
||||
|
||||
@@ -424,7 +471,8 @@ static void csi_start_self_ping(void)
|
||||
|
||||
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
|
||||
esp_ping_start(s_self_ping);
|
||||
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
|
||||
ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI OFDM source, fix #521/#954)",
|
||||
gw_str, CONFIG_CSI_SELF_PING_HZ);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
|
||||
s_self_ping = NULL;
|
||||
@@ -592,6 +640,8 @@ void csi_collector_init(void)
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
||||
(unsigned)s_node_id, (unsigned)csi_channel);
|
||||
ESP_LOGI(TAG, "edge DSP cadence=%dHz; raw CSI network cadence remains independent",
|
||||
CONFIG_EDGE_DSP_SAMPLE_HZ);
|
||||
|
||||
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
|
||||
* receives a guaranteed OFDM unicast floor even when promiscuous capture is
|
||||
|
||||
@@ -38,6 +38,16 @@ extern nvs_config_t g_nvs_config;
|
||||
|
||||
static const char *TAG = "edge_proc";
|
||||
|
||||
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
|
||||
#if CONFIG_IDF_TARGET_ESP32C6
|
||||
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
|
||||
#else
|
||||
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#define EDGE_CONFIGURED_SAMPLE_RATE_HZ ((float)CONFIG_EDGE_DSP_SAMPLE_HZ)
|
||||
|
||||
/* ======================================================================
|
||||
* SPSC Ring Buffer (lock-free, single-producer single-consumer)
|
||||
* ====================================================================== */
|
||||
@@ -355,11 +365,12 @@ static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
/** Measured CSI sample rate (Hz), smoothed from frame timestamps.
|
||||
* #985's self-ping raised the callback rate above the old ~10 Hz beacon
|
||||
* assumption and made it variable (~13-19 Hz); a fixed rate scaled BPM wrong
|
||||
* and made HR swing with CSI yield. See update in process_csi_frame(). */
|
||||
static float s_sample_rate_hz = 15.0f;
|
||||
static float s_filter_design_fs = 20.0f; /* fs the biquads were last designed at */
|
||||
static uint32_t s_last_frame_ts_us = 0;
|
||||
* assumption and made it variable. A fixed rate scales BPM and Doppler bins
|
||||
* incorrectly. Start from the filter design rate, then follow measured time. */
|
||||
static float s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
|
||||
static float s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ; /* fs the biquads were last designed at */
|
||||
static uint32_t s_rate_window_start_us = 0;
|
||||
static uint32_t s_rate_window_intervals = 0;
|
||||
|
||||
/** Latest vitals state. */
|
||||
static float s_breathing_bpm;
|
||||
@@ -1056,20 +1067,27 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
s_frame_count++;
|
||||
s_latest_rssi = slot->rssi;
|
||||
|
||||
/* Measure the REAL CSI sample rate from inter-frame timestamps. #985's
|
||||
* self-ping made the callback rate variable (~13-19 Hz); the old fixed
|
||||
* 10 Hz both scaled BPM wrong (true ~87 BPM read as ~45) and made HR swing
|
||||
* as CSI yield fluctuated. EMA-smooth and clamp to a plausible band. */
|
||||
if (s_last_frame_ts_us != 0 && slot->timestamp_us > s_last_frame_ts_us) {
|
||||
float dt = (float)(slot->timestamp_us - s_last_frame_ts_us) * 1e-6f;
|
||||
if (dt > 0.02f && dt < 0.5f) { /* 2-50 Hz plausible; reject gaps/hops */
|
||||
float inst = 1.0f / dt;
|
||||
s_sample_rate_hz += 0.05f * (inst - s_sample_rate_hz);
|
||||
if (s_sample_rate_hz < 8.0f) s_sample_rate_hz = 8.0f;
|
||||
if (s_sample_rate_hz > 30.0f) s_sample_rate_hz = 30.0f;
|
||||
/* Measure the real CSI sample rate over one-second timestamp windows. WiFi
|
||||
* replies arrive in bursts, so filtering individual short intervals made
|
||||
* a 35 pps stream look like 12-16 Hz. Counting all processed intervals in
|
||||
* the window preserves the clock actually seen by the temporal filters. */
|
||||
if (s_rate_window_start_us == 0) {
|
||||
s_rate_window_start_us = slot->timestamp_us;
|
||||
s_rate_window_intervals = 0;
|
||||
} else if (slot->timestamp_us > s_rate_window_start_us) {
|
||||
s_rate_window_intervals++;
|
||||
uint32_t elapsed_us = slot->timestamp_us - s_rate_window_start_us;
|
||||
if (elapsed_us >= EDGE_SAMPLE_RATE_WINDOW_MIN_US) {
|
||||
s_sample_rate_hz = edge_sample_rate_window_update(
|
||||
s_sample_rate_hz, s_rate_window_intervals, elapsed_us);
|
||||
s_rate_window_start_us = slot->timestamp_us;
|
||||
s_rate_window_intervals = 0;
|
||||
}
|
||||
} else {
|
||||
/* Timer wrap or reset. Start a fresh evidence window. */
|
||||
s_rate_window_start_us = slot->timestamp_us;
|
||||
s_rate_window_intervals = 0;
|
||||
}
|
||||
s_last_frame_ts_us = slot->timestamp_us;
|
||||
|
||||
/* Re-tune the biquads if the measured rate has drifted from their design fs,
|
||||
* so the breathing (0.1-0.5 Hz) and HR (0.8-2.0 Hz) passbands stay in real
|
||||
@@ -1339,6 +1357,11 @@ bool edge_get_vitals(edge_vitals_pkt_t *pkt)
|
||||
return true;
|
||||
}
|
||||
|
||||
float edge_get_sample_rate_hz(void)
|
||||
{
|
||||
return s_sample_rate_hz;
|
||||
}
|
||||
|
||||
void edge_get_multi_person(edge_person_vitals_t *persons, uint8_t *n_active)
|
||||
{
|
||||
uint8_t active = 0;
|
||||
@@ -1398,6 +1421,10 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
s_fall_detected = false;
|
||||
s_latest_rssi = 0;
|
||||
s_frame_count = 0;
|
||||
s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
|
||||
s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
|
||||
s_rate_window_start_us = 0;
|
||||
s_rate_window_intervals = 0;
|
||||
s_prev_phase_velocity = 0.0f;
|
||||
s_fall_consec_count = 0;
|
||||
s_fall_last_alert_us = 0;
|
||||
@@ -1422,9 +1449,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
|
||||
s_person_count_streak = 0;
|
||||
s_person_count_stable = 0;
|
||||
|
||||
/* Design biquad bandpass filters.
|
||||
* Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */
|
||||
const float fs = 20.0f;
|
||||
/* Design biquad bandpass filters against the configured DSP clock. The
|
||||
* measured timestamp estimator then follows sustained hardware drift. */
|
||||
const float fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
|
||||
biquad_bandpass_design(&s_bq_breathing, fs, 0.1f, 0.5f);
|
||||
biquad_bandpass_design(&s_bq_heartrate, fs, 0.8f, 2.0f);
|
||||
|
||||
|
||||
@@ -35,6 +35,39 @@
|
||||
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
|
||||
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
|
||||
|
||||
/* ---- Measured sample-rate tracking ----
|
||||
*
|
||||
* The connected-STA probe produces up to 50 CSI opportunities per second,
|
||||
* while contention and callback gating make the delivered cadence variable.
|
||||
* Temporal filters must follow measured time rather than a fixed frame-rate
|
||||
* assumption. The 60 Hz estimator ceiling leaves jitter headroom above the
|
||||
* qualified 50 Hz callback limit. A one-second frame-count window represents
|
||||
* bursty but valid WiFi arrivals more accurately than averaging only selected
|
||||
* inter-frame intervals. */
|
||||
#define EDGE_SAMPLE_RATE_MIN_HZ 8.0f
|
||||
#define EDGE_SAMPLE_RATE_MAX_HZ 60.0f
|
||||
#define EDGE_SAMPLE_RATE_EMA_ALPHA 0.25f
|
||||
#define EDGE_SAMPLE_RATE_WINDOW_MIN_US 1000000U
|
||||
#define EDGE_SAMPLE_RATE_WINDOW_MAX_US 3000000U
|
||||
|
||||
static inline float edge_sample_rate_window_update(float current_hz,
|
||||
uint32_t frame_intervals,
|
||||
uint32_t elapsed_us)
|
||||
{
|
||||
if (frame_intervals == 0 || elapsed_us < EDGE_SAMPLE_RATE_WINDOW_MIN_US ||
|
||||
elapsed_us > EDGE_SAMPLE_RATE_WINDOW_MAX_US) {
|
||||
return current_hz;
|
||||
}
|
||||
|
||||
float instant_hz = (float)frame_intervals * 1000000.0f / (float)elapsed_us;
|
||||
if (instant_hz < EDGE_SAMPLE_RATE_MIN_HZ) instant_hz = EDGE_SAMPLE_RATE_MIN_HZ;
|
||||
if (instant_hz > EDGE_SAMPLE_RATE_MAX_HZ) instant_hz = EDGE_SAMPLE_RATE_MAX_HZ;
|
||||
float next_hz = current_hz + EDGE_SAMPLE_RATE_EMA_ALPHA * (instant_hz - current_hz);
|
||||
if (next_hz < EDGE_SAMPLE_RATE_MIN_HZ) return EDGE_SAMPLE_RATE_MIN_HZ;
|
||||
if (next_hz > EDGE_SAMPLE_RATE_MAX_HZ) return EDGE_SAMPLE_RATE_MAX_HZ;
|
||||
return next_hz;
|
||||
}
|
||||
|
||||
/* ---- Multi-person ---- */
|
||||
#define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */
|
||||
|
||||
@@ -252,6 +285,12 @@ bool edge_enqueue_csi(const uint8_t *iq_data, uint16_t iq_len,
|
||||
*/
|
||||
bool edge_get_vitals(edge_vitals_pkt_t *pkt);
|
||||
|
||||
/**
|
||||
* Return the timestamp-derived CSI cadence used to design temporal filters.
|
||||
* This is diagnostic evidence, not the raw callback or network delivery rate.
|
||||
*/
|
||||
float edge_get_sample_rate_hz(void);
|
||||
|
||||
/**
|
||||
* Get multi-person vitals array.
|
||||
*
|
||||
|
||||
@@ -58,6 +58,10 @@ CONFIG_ULP_COPROC_RESERVE_MEM=8192
|
||||
# CONFIG_DISPLAY_ENABLE is not set
|
||||
# CONFIG_WASM_ENABLE is not set
|
||||
|
||||
# Physical Tier 2 qualification on ESP32-C6 rev 0.2 converges at 8 Hz while
|
||||
# leaving the raw network CSI stream independent at roughly 30-40 pps.
|
||||
CONFIG_EDGE_DSP_SAMPLE_HZ=8
|
||||
|
||||
# ── Compiler ──
|
||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
||||
|
||||
|
||||
@@ -370,6 +370,36 @@ static void test_presence_dead_band_holds_state(void)
|
||||
CHECK_TRUE("dead band does not clear from true", flag);
|
||||
}
|
||||
|
||||
/* The physical C6 delivered 28-37 CSI frames/s while the former estimator was
|
||||
* capped at 30 Hz. A 34 Hz stream must converge above that old ceiling. */
|
||||
static void test_sample_rate_tracks_above_thirty_hz(void)
|
||||
{
|
||||
float rate = 20.0f;
|
||||
for (int i = 0; i < 12; i++) {
|
||||
rate = edge_sample_rate_window_update(rate, 34U, 1000000U);
|
||||
}
|
||||
CHECK_TRUE("sample rate follows measured 34 Hz cadence", rate > 33.0f && rate < 35.0f);
|
||||
}
|
||||
|
||||
static void test_sample_rate_requires_complete_window(void)
|
||||
{
|
||||
float rate = 34.0f;
|
||||
CHECK_TRUE("short window rejected",
|
||||
edge_sample_rate_window_update(rate, 10U, 200000U) == rate);
|
||||
CHECK_TRUE("stalled window rejected",
|
||||
edge_sample_rate_window_update(rate, 10U, 4000000U) == rate);
|
||||
}
|
||||
|
||||
static void test_sample_rate_is_bounded(void)
|
||||
{
|
||||
float rate = EDGE_SAMPLE_RATE_MAX_HZ;
|
||||
CHECK_TRUE("sample rate upper bound holds",
|
||||
edge_sample_rate_window_update(rate, 1000U, 1000000U) <= EDGE_SAMPLE_RATE_MAX_HZ);
|
||||
rate = EDGE_SAMPLE_RATE_MIN_HZ;
|
||||
CHECK_TRUE("sample rate lower bound holds",
|
||||
edge_sample_rate_window_update(rate, 1U, 1000000U) >= EDGE_SAMPLE_RATE_MIN_HZ);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* main
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
@@ -396,6 +426,11 @@ int main(void)
|
||||
test_presence_genuine_departure_clears();
|
||||
test_presence_dead_band_holds_state();
|
||||
|
||||
/* Timestamp-derived temporal calibration */
|
||||
test_sample_rate_tracks_above_thirty_hz();
|
||||
test_sample_rate_requires_complete_window();
|
||||
test_sample_rate_is_bounded();
|
||||
|
||||
printf("\n%d passed, %d failed\n", g_passed, g_failed);
|
||||
return g_failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.8.4
|
||||
0.8.8
|
||||
|
||||
Reference in New Issue
Block a user