diff --git a/docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md b/docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md new file mode 100644 index 00000000..374ed76d --- /dev/null +++ b/docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0ac71281..95a200e4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md b/docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md new file mode 100644 index 00000000..ccc49b5f --- /dev/null +++ b/docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md @@ -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. diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index ee1dff16..6f4f5bfd 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -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 diff --git a/firmware/esp32-csi-node/main/adaptive_controller.c b/firmware/esp32-csi-node/main/adaptive_controller.c index 99272ee9..fe4bf9d5 100644 --- a/firmware/esp32-csi-node/main/adaptive_controller.c +++ b/firmware/esp32-csi-node/main/adaptive_controller.c @@ -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); diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 9387a6a0..6a15443f 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -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 diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index 580b32c8..da67c147 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -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); diff --git a/firmware/esp32-csi-node/main/edge_processing.h b/firmware/esp32-csi-node/main/edge_processing.h index 249a78c7..cb53ad66 100644 --- a/firmware/esp32-csi-node/main/edge_processing.h +++ b/firmware/esp32-csi-node/main/edge_processing.h @@ -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. * diff --git a/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 b/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 index b6bda708..e67ee9ad 100644 --- a/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 +++ b/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 @@ -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 diff --git a/firmware/esp32-csi-node/test/test_vitals_count_presence.c b/firmware/esp32-csi-node/test/test_vitals_count_presence.c index db5f92fe..69556302 100644 --- a/firmware/esp32-csi-node/test/test_vitals_count_presence.c +++ b/firmware/esp32-csi-node/test/test_vitals_count_presence.c @@ -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; } diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index b60d7196..6201b5f7 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.4 +0.8.8