From a2c662f9f734ad4df2b3a3cd028b719b38dca120 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 5 Sep 2026 10:28:19 -0400 Subject: [PATCH] fix: harden CSI timing and baseline reliability --- ...ul-csi-and-reliability-gated-adaptation.md | 151 +++++++++++++++++ docs/adr/README.md | 1 + firmware/esp32-csi-node/README.md | 9 +- firmware/esp32-csi-node/main/csi_collector.c | 17 +- firmware/esp32-csi-node/main/csi_collector.h | 3 + firmware/esp32-csi-node/test/Makefile | 21 ++- .../esp32-csi-node/test/fuzz_csi_serialize.c | 12 ++ .../esp32-csi-node/test/stubs/esp_stubs.h | 1 + .../esp32-csi-node/test/test_csi_sanitize.c | 56 +++++++ .../wifi-densepose-hardware/src/csi_frame.rs | 7 +- .../src/esp32_parser.rs | 7 +- .../src/adaptive_classifier.rs | 51 ++++++ .../wifi-densepose-sensing-server/src/csi.rs | 155 ++++++++++++++++-- .../src/field_bridge.rs | 10 ++ .../wifi-densepose-sensing-server/src/main.rs | 99 ++++++++--- .../src/vital_signs.rs | 41 +++++ .../src/ruvsense/field_model.rs | 97 ++++++++++- 17 files changed, 690 insertions(+), 48 deletions(-) create mode 100644 docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md create mode 100644 firmware/esp32-csi-node/test/test_csi_sanitize.c diff --git a/docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md b/docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md new file mode 100644 index 000000000..9562ce1a2 --- /dev/null +++ b/docs/adr/ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md @@ -0,0 +1,151 @@ +# ADR 356: Rate truthful CSI and reliability gated room adaptation + +Status: Accepted + +Date: 2026-09-05 + +## Context + +RuView mixed three different notions of time. ESP32 firmware requested a packet +rate, the server observed a delivered rate, and several server paths still used +fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named +`dominant_freq_hz` was not a temporal frequency at all. It was the index of the +strongest subcarrier multiplied by 0.05. A second defect compared a newly +inserted frame with itself, forcing that motion delta to zero. + +These errors can move a spectral peak into the breathing or heartbeat band and +produce plausible but false values. They also make models sensitive to WiFi +traffic and processor scheduling rather than physical motion. + +ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four +CSI bytes invalid because of a hardware limitation. It also documents that the +CSI receive callback runs in the WiFi task and should hand work to a lower +priority task rather than perform lengthy processing. RuView serialized the +invalid bytes unchanged and fed the original buffer to edge processing. + +Recent work suggests four useful directions with different maturity: + +1. OpenCSI represents each link as a dimensionless residual against its own + quiet period temporal standard deviation and exposes baseline maturity. Its + reported transfer results are limited to binary presence. +2. MORIC derives motion centered delay and Doppler views to reduce dependence + on static environment specific CSI structure. +3. Parameter efficient CSI crowd counting combines self supervised pretraining, + small room adapters, and a stateful counting machine. +4. WiFi JEPA masks complete radio links and predicts their latent embeddings, + using real and ray traced CSI for pose representation learning. + +The papers report promising external results. None is measured RuView evidence, +and none authorizes deployment without leakage free occupied and empty held out +evaluation. + +## Decision + +### Firmware ingestion + +1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized + CSI bytes while retaining the fixed packet shape. +2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred. +3. Feed edge DSP from the sanitized serialized payload, never the original + invalid driver buffer. +4. Keep callback work bounded to sanitation, serialization, and the existing + queue handoff. Do not add transforms to the WiFi task. + +### Server time model + +1. Estimate delivered CSI rate from accepted monotonic frame timestamps after + five samples. Use 20 Hz only during startup. +2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or + timestamp artifact cannot retune downstream filters above their supported + range. +3. Reconfigure the vital detector only after a 20 percent rate change. Clear + temporal and smoothing state whenever its clock changes. Samples calculated + with different clocks must never be blended. +4. Compute temporal dominant frequency from the recent mean amplitude series + with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to + the smaller of 4 Hz or 45 percent of sample rate. +5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows. +6. Compare the current frame with the prior frame, not itself, for motion delta. +7. Export zero sample rate until the estimate is mature. Never present a startup + fallback as a measured node property. + +### Model compatibility and room reliability + +1. Preserve the historical fifth input feature for version 1 adaptive models at + the model boundary. Version 2 and later models consume the true temporal + frequency. This prevents an existing model from silently receiving a + different input distribution after a server update. +2. A background model is unreliable until it contains at least ten finite, + nonnegative residual references. An unreliable model cannot suppress a + runtime change and exposes no conformance score. +3. Expose background maturity, reliability, and a robust residual Z score based + on the median and median absolute deviation. +4. Keep the persisted empty home prior negative only. It cannot authorize + calibrated presence, person count, pose, or numeric vitals. + +### Model roadmap + +The following work remains proposal only: + +1. Evaluate an OpenCSI style per link quiet residual normalization for binary + presence without replacing absolute magnitude features used for motion. +2. Evaluate delay and Doppler views after phase sanitation for activity. +3. Pretrain a frozen multilink encoder and adapt small room specific modules for + counting. +4. Evaluate link masked self supervision for pose only when at least three + spatially distinct links and synchronized positive pose labels exist. + +Every candidate uses immutable manifests and splits by room, subject, +recording, and time. It is compared with the frozen baseline under identical +seeds. Counting includes confusion matrices and mean absolute error. Pose +includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled +label control must fail. + +Promotion requires at least 10 percent held out lift, less than 10 milliseconds +added p95 inference latency, no more than 20 percent peak memory overhead, zero +leakage or secret findings, frozen anchor retention, and no regression in empty +rejection or occupied recall. Promotion remains an explicit maintainer action. + +## Consequences + +The firmware no longer preserves a known invalid prefix as sensor evidence. The +server uses an observed physical clock for spectral features and vital filters. +Existing version 1 adaptive models remain compatible. Sparse background samples +fail open to observation and fail closed for suppression. + +The direct Fourier estimator is intentionally small and bounded to 128 temporal +samples. It is portable and deterministic, but it is not a replacement for a +phase sanitized delay and Doppler pipeline. + +The largest remaining uncertainty is human recall. Empty home evidence can +measure false presence but cannot show whether weak occupied signals are being +suppressed. The fix path is a consented, leakage separated occupied holdout with +multiple spatial links and synchronized reference labels. + +## Research basis + +1. Espressif ESP-IDF WiFi CSI programming guide: + https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html +2. Espressif ESP CSI reference implementation: + https://github.com/espressif/esp-csi +3. OpenCSI: + https://arxiv.org/abs/2607.26665 +4. MORIC: + https://arxiv.org/abs/2506.12997 +5. Parameter efficient CSI crowd counting: + https://arxiv.org/abs/2601.02203 +6. WiFi JEPA: + https://arxiv.org/abs/2607.11064 + +## Acceptance test + +Run the firmware serializer host tests and the focused hardware, signal, and +sensing server Rust suites. The deterministic serializer test must prove the +first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must +round trip through the Rust parser. The temporal estimator must recover a +1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and +remain below 10 milliseconds p95 on the target Mac. Existing version 1 model +input must retain the legacy proxy. A background model with nine references +must never report a match or suppress a runtime change. Physical firmware is +not qualified until an S3 and C6 boot and stream the sanitized flag without +watchdog, memory, or transport regressions during a five minute burn in. diff --git a/docs/adr/README.md b/docs/adr/README.md index 84b858b22..095c4bccf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -97,6 +97,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed | | [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted | | [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted | +| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted | ### Platform and UI diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index ebdddc2d0..2469814f7 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -257,10 +257,17 @@ Offset Size Field 12 4 Sequence number (LE u32) 16 1 RSSI (i8) 17 1 Noise floor (i8) -18 2 Reserved +18 1 PPDU type (ADR-110; zero when tagging is disabled) +19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid, + bit5=invalid first CSI word zeroed by firmware 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes) ``` +When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry, +zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes +feed the edge DSP path. This prevents a documented hardware artifact from being +learned as motion while keeping older readers wire compatible. + ### Vitals Packet (32 bytes) ``` diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 6a15443fa..dfa6398bb 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -263,8 +263,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf buf[19] = 0; #endif - /* I/Q data */ + /* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid + * when first_word_invalid is set. Never let those hardware artifacts feed + * either the host or edge DSP path. Preserve the fixed ADR-018 geometry by + * zeroing rather than removing bytes, and mark the sanitation in bit 5. */ memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len); + if (info->first_word_invalid && iq_len > 0) { + size_t invalid_len = iq_len < 4 ? iq_len : 4; + memset(&buf[CSI_HEADER_SIZE], 0, invalid_len); + buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED; + } return frame_size; } @@ -330,13 +338,16 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info) * 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) { + if (frame_len > CSI_HEADER_SIZE) { 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, + /* Reuse the sanitized ADR-018 payload. Feeding info->buf here + * would reintroduce first_word_invalid artifacts on device. */ + (void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE], + (uint16_t)(frame_len - CSI_HEADER_SIZE), (int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel); /* Preserve the configured sample clock instead of resetting it to diff --git a/firmware/esp32-csi-node/main/csi_collector.h b/firmware/esp32-csi-node/main/csi_collector.h index 92f431053..cce99393c 100644 --- a/firmware/esp32-csi-node/main/csi_collector.h +++ b/firmware/esp32-csi-node/main/csi_collector.h @@ -17,6 +17,9 @@ /** ADR-018 header size in bytes. */ #define CSI_HEADER_SIZE 20 +/** ADR-018 byte 19: ESP-IDF reported and firmware sanitized invalid CSI prefix. */ +#define CSI_FLAG_FIRST_WORD_SANITIZED (1U << 5) + /** Maximum frame buffer size (header + 4 antennas * 256 subcarriers * 2 bytes). */ #define CSI_MAX_FRAME_SIZE (CSI_HEADER_SIZE + 4 * 256 * 2) diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index e2b46699f..19c82dbec 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -45,10 +45,20 @@ FUZZ_JOBS ?= 1 .PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \ test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \ - test_thermal run_thermal + test_thermal run_thermal test_csi_sanitize run_csi_sanitize all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ - test_thermal + test_thermal test_csi_sanitize + +# --- ESP-IDF invalid first-word sanitation --- +# Links the production serializer. Function sections and dead stripping keep +# unrelated device-only callback code out of this host executable. +test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC) + cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \ + $^ -Wl,-dead_strip -o $@ -lm + +run_csi_sanitize: test_csi_sanitize + ./test_csi_sanitize # --- ADR-110 encoding unit tests --- # Host-side, no libFuzzer needed — plain C99 deterministic table tests @@ -93,8 +103,8 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h run_thermal: test_thermal ./test_thermal -host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal - @echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)" +host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize + @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal)" # --- Serialize fuzzer --- # Tests csi_serialize_frame() with random wifi_csi_info_t inputs. @@ -130,5 +140,6 @@ run_nvs: fuzz_nvs run_all: run_serialize run_edge run_nvs clean: - rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals + rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ + test_thermal test_csi_sanitize rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/fuzz_csi_serialize.c b/firmware/esp32-csi-node/test/fuzz_csi_serialize.c index 1eb3af855..4aade0421 100644 --- a/firmware/esp32-csi-node/test/fuzz_csi_serialize.c +++ b/firmware/esp32-csi-node/test/fuzz_csi_serialize.c @@ -90,6 +90,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) info.rx_ctrl.sig_mode = legacy_inputs & 0x03; info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01; info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01; + info.first_word_invalid = (test_case & 0x80) != 0; /* Use remaining fuzz data as I/Q buffer content. */ uint16_t iq_len; @@ -136,6 +137,17 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) if (result > out_len) { __builtin_trap(); /* Buffer overflow detected. */ } + if (result >= CSI_HEADER_SIZE + iq_len && info.first_word_invalid && iq_len > 0) { + size_t invalid_len = iq_len < 4 ? iq_len : 4; + for (size_t i = 0; i < invalid_len; i++) { + if (out_buf[CSI_HEADER_SIZE + i] != 0) { + __builtin_trap(); /* Invalid hardware prefix escaped sanitation. */ + } + } + if ((out_buf[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0) { + __builtin_trap(); /* Sanitized payload must carry provenance. */ + } + } /* --- Test case 1: NULL info pointer --- */ if (test_case & 0x01) { diff --git a/firmware/esp32-csi-node/test/stubs/esp_stubs.h b/firmware/esp32-csi-node/test/stubs/esp_stubs.h index 3d849a89b..e2dc08abc 100644 --- a/firmware/esp32-csi-node/test/stubs/esp_stubs.h +++ b/firmware/esp32-csi-node/test/stubs/esp_stubs.h @@ -90,6 +90,7 @@ typedef struct { typedef struct { wifi_pkt_rx_ctrl_t rx_ctrl; uint8_t mac[6]; + bool first_word_invalid; int16_t len; /**< Length of the I/Q buffer in bytes. */ int8_t *buf; /**< Pointer to I/Q data. */ } wifi_csi_info_t; diff --git a/firmware/esp32-csi-node/test/test_csi_sanitize.c b/firmware/esp32-csi-node/test/test_csi_sanitize.c new file mode 100644 index 000000000..f0776a76a --- /dev/null +++ b/firmware/esp32-csi-node/test/test_csi_sanitize.c @@ -0,0 +1,56 @@ +/** Deterministic host test for ESP-IDF first_word_invalid sanitation. */ + +#include "esp_stubs.h" +#include "nvs_config.h" +#include "csi_collector.h" + +#include +#include +#include + +nvs_config_t g_nvs_config; + +static void make_info(wifi_csi_info_t *info, int8_t *iq, uint16_t len, bool invalid) +{ + memset(info, 0, sizeof(*info)); + info->rx_ctrl.channel = 6; + info->rx_ctrl.rssi = -42; + info->rx_ctrl.noise_floor = -91; + info->len = (int16_t)len; + info->buf = iq; + info->first_word_invalid = invalid; +} + +int main(void) +{ + int8_t iq[] = {1, 2, 3, 4, 5, 6, 7, 8}; + uint8_t frame[CSI_MAX_FRAME_SIZE]; + wifi_csi_info_t info; + + make_info(&info, iq, sizeof(iq), true); + size_t len = csi_serialize_frame(&info, frame, sizeof(frame)); + assert(len == CSI_HEADER_SIZE + sizeof(iq)); + assert(frame[CSI_HEADER_SIZE] == 0); + assert(frame[CSI_HEADER_SIZE + 1] == 0); + assert(frame[CSI_HEADER_SIZE + 2] == 0); + assert(frame[CSI_HEADER_SIZE + 3] == 0); + assert(frame[CSI_HEADER_SIZE + 4] == 5); + assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0); + + make_info(&info, iq, sizeof(iq), false); + len = csi_serialize_frame(&info, frame, sizeof(frame)); + assert(len == CSI_HEADER_SIZE + sizeof(iq)); + assert(memcmp(&frame[CSI_HEADER_SIZE], iq, sizeof(iq)) == 0); + assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0); + + int8_t short_iq[] = {9, 10}; + make_info(&info, short_iq, sizeof(short_iq), true); + len = csi_serialize_frame(&info, frame, sizeof(frame)); + assert(len == CSI_HEADER_SIZE + sizeof(short_iq)); + assert(frame[CSI_HEADER_SIZE] == 0); + assert(frame[CSI_HEADER_SIZE + 1] == 0); + assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0); + + puts("CSI invalid-prefix sanitation: PASS"); + return 0; +} diff --git a/v2/crates/wifi-densepose-hardware/src/csi_frame.rs b/v2/crates/wifi-densepose-hardware/src/csi_frame.rs index 412240eca..19cfa0d34 100644 --- a/v2/crates/wifi-densepose-hardware/src/csi_frame.rs +++ b/v2/crates/wifi-densepose-hardware/src/csi_frame.rs @@ -145,13 +145,16 @@ impl PpduType { /// bit 2 : STBC /// bit 3 : LDPC (reserved — not yet populated by firmware) /// bit 4 : 802.15.4 time-sync valid (C6 only) -/// bit 5-7 : reserved +/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware +/// bit 6-7 : reserved #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Adr018Flags { pub bw40: bool, pub stbc: bool, pub ldpc: bool, pub ieee802154_sync_valid: bool, + #[serde(default)] + pub first_word_sanitized: bool, } impl Adr018Flags { @@ -161,6 +164,7 @@ impl Adr018Flags { stbc: (b & 0x04) != 0, ldpc: (b & 0x08) != 0, ieee802154_sync_valid: (b & 0x10) != 0, + first_word_sanitized: (b & 0x20) != 0, } } pub fn to_byte(self) -> u8 { @@ -169,6 +173,7 @@ impl Adr018Flags { if self.stbc { b |= 0x04; } if self.ldpc { b |= 0x08; } if self.ieee802154_sync_valid { b |= 0x10; } + if self.first_word_sanitized { b |= 0x20; } b } } diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index fffe3a22f..20d060666 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -413,15 +413,16 @@ mod tests { #[test] fn adr110_flags_round_trip_all_bits() { - // All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D - let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D); + // All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D. + let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D); let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap(); assert!(frame.metadata.adr018_flags.bw40); assert!(frame.metadata.adr018_flags.stbc); assert!(frame.metadata.adr018_flags.ldpc); assert!(frame.metadata.adr018_flags.ieee802154_sync_valid); + assert!(frame.metadata.adr018_flags.first_word_sanitized); // Round-trip the encoder - assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D); + assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D); } #[test] diff --git a/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs b/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs index 3a662d07a..f5baecbf7 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs @@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_ ] } +/// Preserve the fifth feature's historical meaning for models trained before +/// the server began measuring temporal frequency from a frame window. +/// +/// Version 1 models learned a proxy equal to the strongest subcarrier index +/// multiplied by 0.05. Version 2 and later models consume the measured +/// temporal frequency in hertz. Keeping this translation at the model boundary +/// prevents a server upgrade from silently changing an existing model's input +/// distribution. +pub fn compatible_dominant_frequency( + model_version: u32, + temporal_frequency_hz: f64, + amplitudes: &[f64], +) -> f64 { + if model_version >= 2 { + return if temporal_frequency_hz.is_finite() { + temporal_frequency_hz.max(0.0) + } else { + 0.0 + }; + } + + amplitudes + .iter() + .enumerate() + .filter(|(_, amplitude)| amplitude.is_finite()) + .max_by(|(_, left), (_, right)| { + left.partial_cmp(right) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(index, _)| index as f64 * 0.05) + .unwrap_or(0.0) +} + /// Compute statistical features from raw subcarrier amplitudes. fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) { if amps.is_empty() { @@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result PathBuf { PathBuf::from("data/adaptive_model.json") } + +#[cfg(test)] +mod compatibility_tests { + use super::compatible_dominant_frequency; + + #[test] + fn version_one_keeps_legacy_subcarrier_proxy() { + let amplitudes = [1.0, 2.0, 8.0, 3.0]; + assert_eq!(compatible_dominant_frequency(1, 1.25, &litudes), 0.10); + } + + #[test] + fn version_two_uses_measured_temporal_frequency() { + let amplitudes = [1.0, 20.0, 2.0]; + assert_eq!(compatible_dominant_frequency(2, 1.25, &litudes), 1.25); + assert_eq!(compatible_dominant_frequency(2, f64::NAN, &litudes), 0.0); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index 6d0cd9004..a49e97c16 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -11,6 +11,141 @@ use crate::vital_signs::VitalSigns; const EDGE_MAX_PERSONS: u8 = 4; +/// Estimate a real temporal motion frequency from recent CSI amplitude +/// history. Each frame is reduced to its mean amplitude, detrended, Hann +/// windowed, and evaluated on exact DFT bins. +pub fn estimate_temporal_dominant_frequency_hz( + frame_history: &VecDeque>, + sample_rate_hz: f64, +) -> f64 { + if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 { + return 0.0; + } + + let recent_frames: Vec<&Vec> = frame_history + .iter() + .rev() + .take(128) + .collect(); + if recent_frames + .iter() + .any(|frame| frame.is_empty() || frame.iter().any(|value| !value.is_finite())) + { + return 0.0; + } + let samples: Vec = recent_frames + .into_iter() + .rev() + .map(|frame| frame.iter().sum::() / frame.len() as f64) + .collect(); + let n = samples.len(); + if n < 16 { + return 0.0; + } + + let mean = samples.iter().sum::() / n as f64; + let upper_hz = 4.0_f64.min(sample_rate_hz * 0.45); + if upper_hz < 0.1 { + return 0.0; + } + + let mut powers = Vec::new(); + for bin in 1..=(n / 2) { + let frequency_hz = bin as f64 * sample_rate_hz / n as f64; + if !(0.1..=upper_hz).contains(&frequency_hz) { + continue; + } + let mut real = 0.0; + let mut imag = 0.0; + let angle_step = 2.0 * std::f64::consts::PI * bin as f64 / n as f64; + let (step_sin, step_cos) = angle_step.sin_cos(); + let mut angle_cos = 1.0; + let mut angle_sin = 0.0; + for (index, sample) in samples.iter().enumerate() { + let window = + 0.5 * (1.0 - (2.0 * std::f64::consts::PI * index as f64 / (n - 1) as f64).cos()); + let value = (sample - mean) * window; + real += value * angle_cos; + imag -= value * angle_sin; + let next_cos = angle_cos * step_cos - angle_sin * step_sin; + angle_sin = angle_sin * step_cos + angle_cos * step_sin; + angle_cos = next_cos; + } + powers.push((frequency_hz, real * real + imag * imag)); + } + if powers.is_empty() { + return 0.0; + } + let mean_power = powers.iter().map(|(_, power)| power).sum::() / powers.len() as f64; + let (peak_hz, peak_power) = powers + .into_iter() + .max_by(|left, right| { + left.1 + .partial_cmp(&right.1) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or((0.0, 0.0)); + if mean_power <= f64::EPSILON || peak_power / mean_power < 2.0 { + 0.0 + } else { + peak_hz + } +} + +#[cfg(test)] +mod temporal_frequency_tests { + use super::*; + + #[test] + fn recovers_temporal_frequency_in_hertz() { + let sample_rate_hz = 20.0; + let target_hz = 1.2; + let mut history = VecDeque::new(); + for sample in 0..100 { + let t = sample as f64 / sample_rate_hz; + let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * target_hz * t).sin(); + history.push_back(vec![value; 56]); + } + let measured = estimate_temporal_dominant_frequency_hz(&history, sample_rate_hz); + assert!((measured - target_hz).abs() <= 0.21, "measured {measured} Hz"); + } + + #[test] + fn abstains_on_short_or_flat_history() { + let short = VecDeque::from(vec![vec![20.0; 56]; 15]); + assert_eq!(estimate_temporal_dominant_frequency_hz(&short, 20.0), 0.0); + let flat = VecDeque::from(vec![vec![20.0; 56]; 100]); + assert_eq!(estimate_temporal_dominant_frequency_hz(&flat, 20.0), 0.0); + let mut corrupt = flat; + corrupt[50][4] = f64::NAN; + assert_eq!(estimate_temporal_dominant_frequency_hz(&corrupt, 20.0), 0.0); + } + + #[test] + fn temporal_frequency_p95_stays_below_server_budget() { + let sample_rate_hz = 50.0; + let mut history = VecDeque::new(); + for sample in 0..128 { + let t = sample as f64 / sample_rate_hz; + let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * 1.2 * t).sin(); + history.push_back(vec![value; 256]); + } + let mut timings = Vec::with_capacity(1_000); + for _ in 0..1_000 { + let start = std::time::Instant::now(); + std::hint::black_box(estimate_temporal_dominant_frequency_hz( + std::hint::black_box(&history), + sample_rate_hz, + )); + timings.push(start.elapsed()); + } + timings.sort_unstable(); + let p95 = timings[949]; + eprintln!("temporal dominant frequency p95: {p95:?}"); + assert!(p95 < std::time::Duration::from_millis(10), "p95 {p95:?}"); + } +} + /// Person count is supporting evidence, never an independent occupancy claim. /// Return zero and mark invalid for contradictory or out-of-range firmware. fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) { @@ -469,14 +604,7 @@ pub fn extract_features_from_frame( 0.0 }; - let peak_idx = frame - .amplitudes - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i) - .unwrap_or(0); - let dominant_freq_hz = peak_idx as f64 * 0.05; + let dominant_freq_hz = estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz); let threshold = mean_amp * 1.2; let change_points = frame @@ -485,7 +613,9 @@ pub fn extract_features_from_frame( .filter(|w| (w[0] < threshold) != (w[1] < threshold)) .count(); - let temporal_motion_score = if let Some(prev_frame) = frame_history.back() { + // The caller appends the current frame before extraction, so the true + // predecessor is the second item from the back. + let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) { let n_cmp = n_sub.min(prev_frame.len()); if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) @@ -636,13 +766,18 @@ pub fn adaptive_override( .back() .map(|v| v.as_slice()) .unwrap_or(&[]); + let dominant_frequency = adaptive_classifier::compatible_dominant_frequency( + model.version, + features.dominant_freq_hz, + amps, + ); let feat_arr = adaptive_classifier::features_from_runtime( &serde_json::json!({ "variance": features.variance, "motion_band_power": features.motion_band_power, "breathing_band_power": features.breathing_band_power, "spectral_power": features.spectral_power, - "dominant_freq_hz": features.dominant_freq_hz, + "dominant_freq_hz": dominant_frequency, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), diff --git a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs index a60c266ca..980f04cfb 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -47,6 +47,9 @@ const MAX_SINGLE_LINK_OCCUPANCY: usize = 3; pub struct BootstrapBackgroundMatch { pub matches_empty: bool, pub score: Option, + pub normalized_residual_z: Option, + pub maturity: f64, + pub reliable: bool, pub residual_energy: f64, pub residual_energy_threshold: f64, pub window_size: usize, @@ -227,6 +230,9 @@ pub fn bootstrap_background_match( Some(BootstrapBackgroundMatch { matches_empty: result.matches_empty, score: result.score, + normalized_residual_z: result.normalized_residual_z, + maturity: result.maturity, + reliable: result.reliable, residual_energy: result.residual_energy, residual_energy_threshold: result.residual_energy_threshold, window_size: result.window_size, @@ -502,6 +508,9 @@ mod tests { .expect("background score"); assert!(background.matches_empty); assert_eq!(background.reference_window_count, 12); + assert!(background.reliable); + assert_eq!(background.maturity, 0.6); + assert!(background.normalized_residual_z.is_some()); assert!(background .score .is_some_and(|score| (0.5..=1.0).contains(&score))); @@ -530,5 +539,6 @@ mod tests { .expect("shifted background result"); assert!(!shifted_background.matches_empty); assert_eq!(shifted_background.score, Some(0.0)); + assert!(shifted_background.reliable); } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index cbbed1461..5c159c417 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -915,10 +915,10 @@ const NOVELTY_SKETCH_VERSION: u16 = 1; /// bursts whose intra-burst arrival deltas are tens of microseconds apart — /// a 36 µs delta yields `1/dt ≈ 27 kHz`, which the old `< 1 s` guard let /// straight into the EMA and inflated `csi_fps_ema` by 1–3 orders of -/// magnitude (issue #1180). We reject any delta implying more than 200 fps -/// (4× the physical ceiling, leaving slack for benign arrival jitter); such -/// deltas are burst artifacts, not distinct production intervals. +/// magnitude (issue #1180). We reject sub-5 ms deltas as burst artifacts and +/// cap accepted estimates to the firmware's 50 fps physical ceiling. pub(crate) const MIN_PLAUSIBLE_CSI_DT_SEC: f64 = 0.005; +pub(crate) const MAX_PHYSICAL_CSI_FPS: f64 = 50.0; /// ADR-110 iter 18 — EMA update for per-node CSI fps tracking. /// @@ -934,7 +934,7 @@ pub(crate) fn update_csi_fps_ema(prev_fps: f64, dt_sec: f64) -> Option { if !(dt_sec >= MIN_PLAUSIBLE_CSI_DT_SEC && dt_sec < 1.0) { return None; } - let instantaneous = 1.0 / dt_sec; + let instantaneous = (1.0 / dt_sec).min(MAX_PHYSICAL_CSI_FPS); // y[n] = y[n-1] + (x - y[n-1]) / 8 Some(prev_fps + (instantaneous - prev_fps) / 8.0) } @@ -983,6 +983,15 @@ mod fps_ema_tests { assert!(update_csi_fps_ema(40.0, 0.005).is_some()); } + #[test] + fn accepted_burst_edge_is_capped_to_firmware_ceiling() { + let mut fps = 50.0; + for _ in 0..32 { + fps = update_csi_fps_ema(fps, 0.005).unwrap(); + } + assert!(fps <= 50.0, "reported {fps} Hz above firmware ceiling"); + } + #[test] fn burst_interleaved_with_nominal_stays_in_band() { // A true ~40 fps node whose frames arrive in sub-ms bursts: feeding @@ -1004,6 +1013,18 @@ mod fps_ema_tests { } impl NodeState { + /// Measured sample rate only after enough inter-frame deltas have warmed + /// the EMA. The physical ceiling is enforced again at the consumption + /// boundary so restored or malformed state cannot overclock DSP math. + fn measured_sample_rate_hz(&self) -> Option { + (self.csi_fps_samples >= 5 && self.csi_fps_ema.is_finite()) + .then(|| self.csi_fps_ema.clamp(1.0, MAX_PHYSICAL_CSI_FPS)) + } + + fn effective_sample_rate_hz(&self) -> f64 { + self.measured_sample_rate_hz().unwrap_or(20.0) + } + /// ADR-110 §A0.12 timestamp recovery: given a CSI frame's node-local /// `esp_timer_get_time()` snapshot, return the mesh-aligned epoch /// computed from this node's most recent sync packet — or `None` @@ -1051,7 +1072,7 @@ impl NodeState { // samples; until then fall back to the 20 Hz firmware ceiling. The // §A0.12 capture showed real bench fps ≈ 10, so the measured value // is significantly more accurate than the constant fallback. - let fps = if self.csi_fps_samples >= 5 { self.csi_fps_ema } else { 20.0 }; + let fps = self.effective_sample_rate_hz(); Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps)) } @@ -1099,7 +1120,7 @@ impl NodeState { is_valid: sync.flags.is_valid, smoothed: sync.flags.smoothed_used, sequence: sync.sequence, - csi_fps_ema: self.csi_fps_ema, + csi_fps_ema: self.effective_sample_rate_hz(), csi_fps_samples: self.csi_fps_samples, staleness_ms: self.latest_sync_at.map(|t| t.elapsed().as_millis() as u64), }) @@ -1163,7 +1184,7 @@ impl NodeState { hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), rssi_history: VecDeque::new(), - vital_detector: VitalSignDetector::new(10.0), + vital_detector: VitalSignDetector::new(20.0), latest_vitals: VitalSigns::default(), last_frame_time: None, edge_vitals: None, @@ -1349,7 +1370,7 @@ fn build_node_features( }, rssi_dbm: ns.rssi_history.back().copied().unwrap_or(0.0), last_seen_ms, - frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here. + frame_rate_hz: ns.measured_sample_rate_hz().unwrap_or(0.0), stale, novelty_score: ns.last_novelty_score, } @@ -2768,15 +2789,9 @@ fn extract_features_from_frame( 0.0 }; - // ── Dominant frequency via peak subcarrier index ── - let peak_idx = frame - .amplitudes - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i) - .unwrap_or(0); - let dominant_freq_hz = peak_idx as f64 * 0.05; + // A subcarrier index is a spatial-frequency bin, not temporal hertz. + let dominant_freq_hz = + csi::estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz); // ── Change point detection (threshold-crossing count in current frame) ── let threshold = mean_amp * 1.2; @@ -2789,7 +2804,9 @@ fn extract_features_from_frame( // ── Motion score: sliding-window temporal difference ── // Compare current frame against the most recent historical frame. // The difference is normalised by the mean amplitude to be scale-invariant. - let temporal_motion_score = if let Some(prev_frame) = frame_history.back() { + // Callers append the current frame first, making the true predecessor the + // second item from the back. + let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) { let n_cmp = n_sub.min(prev_frame.len()); if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) @@ -3022,13 +3039,18 @@ fn adaptive_override( .back() .map(|v| v.as_slice()) .unwrap_or(&[]); + let dominant_frequency = adaptive_classifier::compatible_dominant_frequency( + model.version, + features.dominant_freq_hz, + amps, + ); let feat_arr = adaptive_classifier::features_from_runtime( &serde_json::json!({ "variance": features.variance, "motion_band_power": features.motion_band_power, "breathing_band_power": features.breathing_band_power, "spectral_power": features.spectral_power, - "dominant_freq_hz": features.dominant_freq_hz, + "dominant_freq_hz": dominant_frequency, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), @@ -6452,6 +6474,9 @@ async fn calibration_status(State(state): State) -> Json= 5 + && ns.vital_detector.reconfigure_sample_rate(sample_rate_hz) + { + // Never smooth estimates computed against two clocks. + ns.smoothed_hr = 0.0; + ns.smoothed_br = 0.0; + ns.smoothed_hr_conf = 0.0; + ns.smoothed_br_conf = 0.0; + ns.hr_buffer.clear(); + ns.br_buffer.clear(); + } + let raw_vitals = ns .vital_detector .process_frame(&frame.amplitudes, &frame.phases); @@ -10620,6 +10663,20 @@ mod sync_snapshot_helper_tests { assert_eq!(snap.csi_fps_samples, 42); } + #[test] + fn exported_node_rate_is_mature_and_physically_bounded() { + let mut ns = NodeState::new(); + assert_eq!(ns.measured_sample_rate_hz(), None); + + ns.csi_fps_ema = 74.0; + ns.csi_fps_samples = 5; + assert_eq!(ns.measured_sample_rate_hz(), Some(50.0)); + + ns.latest_sync = Some(populated_sync(9)); + ns.latest_sync_at = Some(std::time::Instant::now()); + assert_eq!(ns.sync_snapshot().unwrap().csi_fps_ema, 50.0); + } + #[test] fn observe_csi_frame_arrival_ignores_subms_bursts() { // Issue #1180 regression: a ~40 fps node whose frames are delivered diff --git a/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs b/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs index 10558c6cd..d6a310685 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/vital_signs.rs @@ -346,6 +346,31 @@ impl VitalSignDetector { self.frame_count = 0; } + /// Retune temporal windows to an observed CSI clock. Changes smaller than + /// twenty percent are ignored to avoid resetting on normal arrival jitter. + /// A real clock change clears history so samples from different clocks are + /// never interpreted in one spectrum. + pub fn reconfigure_sample_rate(&mut self, sample_rate: f64) -> bool { + if !sample_rate.is_finite() || sample_rate < 1.0 { + return false; + } + let relative_change = (sample_rate - self.sample_rate).abs() / self.sample_rate.max(1.0); + if relative_change < 0.20 { + return false; + } + self.sample_rate = sample_rate; + self.breathing_capacity = + ((sample_rate * self.breathing_window_secs) as usize).max(1); + self.heartbeat_capacity = + ((sample_rate * self.heartbeat_window_secs) as usize).max(1); + self.reset(); + true + } + + pub fn sample_rate_hz(&self) -> f64 { + self.sample_rate + } + /// Current buffer fill levels for diagnostics. /// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity). pub fn buffer_status(&self) -> (usize, usize, usize, usize) { @@ -842,6 +867,22 @@ mod tests { assert_eq!(hb_len, 0); } + #[test] + fn measured_clock_reconfigures_once_and_clears_mixed_history() { + let mut detector = VitalSignDetector::new(20.0); + let amp = vec![10.0; 56]; + let phase = vec![0.0; 56]; + for _ in 0..32 { + detector.process_frame(&, &phase); + } + assert!(!detector.reconfigure_sample_rate(18.0)); + assert_eq!(detector.sample_rate_hz(), 20.0); + assert!(detector.reconfigure_sample_rate(12.0)); + assert_eq!(detector.sample_rate_hz(), 12.0); + assert_eq!(detector.buffer_status(), (0, 360, 0, 180)); + assert!(!detector.reconfigure_sample_rate(f64::NAN)); + } + #[test] fn test_vital_signs_default() { let vs = VitalSigns::default(); diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 7bba49eff..41596d179 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -380,6 +380,12 @@ pub struct FieldNormalMode { pub struct EmptyRoomMatch { pub matches_empty: bool, pub score: Option, + /// Robust residual distance from the quiet reference median. + pub normalized_residual_z: Option, + /// Reference coverage relative to the normal calibration target. + pub maturity: f64, + /// Whether the reference set is large and valid enough to suppress change. + pub reliable: bool, pub residual_energy: f64, pub residual_energy_threshold: f64, pub window_size: usize, @@ -644,6 +650,27 @@ fn percentile_95(mut values: Vec) -> Option { values.get(index).copied() } +fn robust_residual_z(reference: &[f64], residual: f64) -> Option { + if !residual.is_finite() || reference.is_empty() { + return None; + } + let mut sorted: Vec = reference + .iter() + .copied() + .filter(|value| value.is_finite() && *value >= 0.0) + .collect(); + if sorted.len() != reference.len() { + return None; + } + sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); + let median = sorted[sorted.len() / 2]; + let mut deviations: Vec = sorted.iter().map(|value| (value - median).abs()).collect(); + deviations.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); + let mad = deviations[deviations.len() / 2]; + let robust_scale = (1.4826 * mad).max((median.abs() * 0.01).max(f64::EPSILON)); + Some((residual - median) / robust_scale) +} + #[cfg(feature = "eigenvalue")] fn percentile_95_usize(mut values: Vec) -> Option { values.sort_unstable(); @@ -1315,12 +1342,20 @@ impl FieldModel { let residual_energy_threshold = modes .empty_room_residual_energy_threshold? .max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX); - let matches_empty = residual_energy <= residual_energy_threshold; let reference_window_count = modes.empty_room_residual_energy_reference.len(); - let score = if !matches_empty { - Some(0.0) - } else if reference_window_count == 0 { + let maturity = (reference_window_count as f64 + / MIN_BACKGROUND_REFERENCE_WINDOWS as f64) + .clamp(0.0, 1.0); + let reliable = reference_window_count >= 10 + && modes + .empty_room_residual_energy_reference + .iter() + .all(|value| value.is_finite() && *value >= 0.0); + let matches_empty = reliable && residual_energy <= residual_energy_threshold; + let score = if !reliable { None + } else if !matches_empty { + Some(0.0) } else { let at_or_below = modes .empty_room_residual_energy_reference @@ -1331,6 +1366,12 @@ impl FieldModel { Some(EmptyRoomMatch { matches_empty, score, + normalized_residual_z: robust_residual_z( + &modes.empty_room_residual_energy_reference, + residual_energy, + ), + maturity, + reliable, residual_energy, residual_energy_threshold, window_size, @@ -2149,6 +2190,9 @@ mod tests { .expect("background match"); assert!(background.matches_empty); assert_eq!(background.reference_window_count, 12); + assert!(background.reliable); + assert_eq!(background.maturity, 0.6); + assert!(background.normalized_residual_z.is_some()); assert!(background .score .is_some_and(|score| (0.5..=1.0).contains(&score))); @@ -2237,6 +2281,51 @@ mod tests { let background = model.empty_room_match(&shifted).expect("shifted match"); assert!(!background.matches_empty); assert_eq!(background.score, Some(0.0)); + assert!(background.reliable); + assert!(background.normalized_residual_z.is_some()); + } + + #[cfg(feature = "eigenvalue")] + #[test] + fn sparse_background_reference_cannot_suppress_runtime_change() { + let config = FieldModelConfig { + n_links: 1, + n_subcarriers: 56, + n_modes: 3, + min_calibration_frames: 500, + min_calibration_duration_s: 0.0, + baseline_expiry_s: 86_400.0, + }; + let mut model = FieldModel::new(config).unwrap(); + let mut calibration = Vec::new(); + for frame_index in 0..600 { + let time = frame_index as f64 * 0.071; + let frame: Vec = (0..56) + .map(|subcarrier| { + let carrier = subcarrier as f64; + 18.0 + carrier * 0.08 + + (time + carrier * 0.13).sin() * 0.8 + + (time * 0.37 + carrier * 0.031).cos() * 0.35 + }) + .collect(); + model.feed_calibration(&[frame.clone()]).unwrap(); + calibration.push(frame); + } + model.finalize_calibration(1_000_000, 0).unwrap(); + model + .modes + .as_mut() + .unwrap() + .empty_room_residual_energy_reference + .truncate(9); + + let result = model + .empty_room_match(&calibration[550..600]) + .expect("background comparison"); + assert!(!result.reliable); + assert!(!result.matches_empty); + assert_eq!(result.score, None); + assert_eq!(result.maturity, 0.45); } #[test]