feat(firmware): stabilize rate-aware ESP32 sensing

This commit is contained in:
ruv
2026-08-31 12:56:26 -04:00
parent 0a0b3411f8
commit f85896cccb
11 changed files with 413 additions and 28 deletions

View 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.

View File

@@ -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 |

View 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.

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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.
*

View File

@@ -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

View File

@@ -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;
}

View File

@@ -1 +1 @@
0.8.4
0.8.8