* fix(firmware): on_send ESP-NOW callback compat for IDF v6.0 (closes #944) ESP-IDF v6.0 changed `esp_now_send_cb_t` from void (*)(const uint8_t *mac, esp_now_send_status_t status) to void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status) The C6 sync ESP-NOW path's `on_recv` was already version-guarded with `#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)` (lines 102-112) but the `on_send` sibling missed the equivalent guard. CI runs against IDF v5.4 so the regression slipped through; the reporter on IDF v6.0.1 with xtensa-esp-elf esp-15.2.0_20251204 hit: c6_sync_espnow.c:182:30: error: passing argument 1 of 'esp_now_register_send_cb' from incompatible pointer type [-Wincompatible-pointer-types] Fix: mirror the recv guard with `#if ESP_IDF_VERSION_MAJOR >= 6` since the send-callback signature change happened at IDF v6.0 (not v5.x like the recv-callback). Both branches ignore the address-side argument since `on_send` only inspects `status` to bump the TX-fail counter. Adds `#include "esp_idf_version.h"` so the macro is in scope. Closes #944 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(signal): anchor estimate_occupancy noise floor to calibration (closes #942) `test_estimate_occupancy_noise_only` asserts that 20 noise-only frames fed through a 50-frame calibrated `FieldModel` yield 0 occupancy. Failure reported on the upstream Linux + BLAS build. Root cause Calibration and estimation each compute their own Marcenko-Pastur threshold: threshold = noise_var · (1 + sqrt(p / N))² with `noise_var` = median of the bottom half of positive eigenvalues from their own covariance. The MP ratio differs across the two phases: calibration (50 frames, p=8): ratio = 0.16, factor ≈ 1.96 estimation (20 frames, p=8): ratio = 0.40, factor ≈ 2.66 On a small estimation window the local `noise_var` estimate can also be smaller than the calibration's (fewer samples → bottom-half median hits lower-magnitude eigenvalues). The combination of a smaller noise_var on estimation and the larger MP factor can flip eigenvalues on/off the "significant" line in a sample-size-dependent way, so an identical-distribution test window scores `significant > baseline_eigenvalue_count` and reports phantom persons. Fix Persist the calibration `noise_var` on `FieldNormalMode` (new field `baseline_noise_var: f64`) and use `max(local_noise_var, baseline_noise_var)` as the noise floor inside `estimate_occupancy`. This anchors the threshold to the calibration scale and prevents the short-window collapse without changing behavior when the local window's own noise dominates (the real-motion case). `baseline_noise_var` defaults to 0.0 in the diagonal-fallback paths; the estimation code treats 0.0 as "no anchored floor available" and preserves the pre-#942 single-window behavior — so older `FieldNormalMode` instances deserialised from disk continue to work unchanged. Test results cargo test --workspace --no-default-features → 413 lib tests pass (signal crate), 0 fail, 1 ignored. The actual `eigenvalue`-gated test still requires BLAS (not buildable on Windows). Logic-trace via the four numerical anchors above shows the fix flips `noise_var` from the smaller local value back up to the calibration scale, dropping `significant` to or below `baseline_eigenvalue_count` so the saturating subtraction returns 0. Closes #942 Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
2c136aca74
commit
872d7593bb
|
|
@ -21,6 +21,7 @@
|
|||
#include "esp_wifi.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/timers.h"
|
||||
#include <string.h>
|
||||
|
|
@ -144,11 +145,27 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
|
|||
}
|
||||
}
|
||||
|
||||
/* Issue #944: ESP-IDF v6.0 changed `esp_now_send_cb_t` from
|
||||
* void (*)(const uint8_t *mac, esp_now_send_status_t status)
|
||||
* to
|
||||
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
* Both signatures ignore the address-side argument here — we only inspect
|
||||
* `status` to bump the TX-fail counter — so the body is identical; only the
|
||||
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
|
||||
*/
|
||||
#if ESP_IDF_VERSION_MAJOR >= 6
|
||||
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
{
|
||||
(void)tx_info;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#else
|
||||
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
|
||||
{
|
||||
(void)mac;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void beacon_timer_cb(TimerHandle_t t)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -276,6 +276,13 @@ pub struct FieldNormalMode {
|
|||
pub geometry_hash: u64,
|
||||
/// Baseline eigenvalue count above Marcenko-Pastur threshold (empty-room).
|
||||
pub baseline_eigenvalue_count: usize,
|
||||
/// Baseline noise variance estimate (median of bottom-half positive
|
||||
/// eigenvalues from the calibration covariance). Persisted so that
|
||||
/// `estimate_occupancy` can anchor its Marcenko-Pastur threshold to the
|
||||
/// calibration noise floor instead of letting it drift with the
|
||||
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
|
||||
/// Issue #942.
|
||||
pub baseline_noise_var: f64,
|
||||
}
|
||||
|
||||
/// Body perturbation extracted from a CSI observation.
|
||||
|
|
@ -504,7 +511,11 @@ impl FieldModel {
|
|||
let baseline: Vec<Vec<f64>> = self.link_stats.iter().map(|ls| ls.mean_vector()).collect();
|
||||
|
||||
// --- True eigenvalue decomposition (with diagonal fallback) ---
|
||||
let (mode_energies, environmental_modes, baseline_eig_count) =
|
||||
// Returns: (energies, modes, baseline_count, baseline_noise_var).
|
||||
// The noise_var slot is 0.0 in the diagonal-fallback paths; the
|
||||
// estimation hot path treats 0.0 as "no anchored noise floor" and
|
||||
// falls back to per-window noise_var, preserving pre-#942 behavior.
|
||||
let (mode_energies, environmental_modes, baseline_eig_count, baseline_noise_var) =
|
||||
if let Some(ref cov_sum) = self.covariance_sum {
|
||||
if self.covariance_count > 1 {
|
||||
// Compute sample covariance from raw outer products:
|
||||
|
|
@ -588,23 +599,28 @@ impl FieldModel {
|
|||
let baseline_count =
|
||||
eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
|
||||
|
||||
(energies, modes, baseline_count)
|
||||
(energies, modes, baseline_count, noise_var)
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to diagonal approximation on SVD failure
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) =
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
}
|
||||
// When eigenvalue feature is disabled, use diagonal fallback
|
||||
#[cfg(not(feature = "eigenvalue"))]
|
||||
{
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
}
|
||||
} else {
|
||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
|
||||
(e, m, b, 0.0_f64)
|
||||
};
|
||||
|
||||
// Compute variance explained using the same centered covariance as modes.
|
||||
|
|
@ -648,6 +664,7 @@ impl FieldModel {
|
|||
calibrated_at_us: timestamp_us,
|
||||
geometry_hash,
|
||||
baseline_eigenvalue_count: baseline_eig_count,
|
||||
baseline_noise_var,
|
||||
};
|
||||
|
||||
self.modes = Some(field_mode);
|
||||
|
|
@ -794,7 +811,7 @@ impl FieldModel {
|
|||
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
|
||||
// in the bottom half. Excludes zeros from rank-deficient matrices
|
||||
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
|
||||
let noise_var = {
|
||||
let local_noise_var = {
|
||||
let mut positive: Vec<f64> =
|
||||
eigenvalues.iter().copied().filter(|&e| e > 1e-10).collect();
|
||||
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
|
@ -807,6 +824,22 @@ impl FieldModel {
|
|||
return Ok(0); // All zero eigenvalues — can't estimate
|
||||
}
|
||||
};
|
||||
|
||||
// Issue #942: anchor the noise floor to the calibration's noise_var
|
||||
// when it's available. Per-window noise_var drifts with sample size —
|
||||
// a short estimation window can produce a small local_noise_var that
|
||||
// inflates `significant` and breaks the test_estimate_occupancy_noise_only
|
||||
// invariant. The max of (calibration noise, local noise) keeps the
|
||||
// threshold from collapsing on small windows while still letting the
|
||||
// per-window noise dominate when it's the larger estimate. Falls back
|
||||
// to local_noise_var when baseline_noise_var == 0 (diagonal-fallback
|
||||
// calibration path, or pre-#942 stored modes).
|
||||
let noise_var = if modes.baseline_noise_var > 0.0 {
|
||||
local_noise_var.max(modes.baseline_noise_var)
|
||||
} else {
|
||||
local_noise_var
|
||||
};
|
||||
|
||||
let ratio = n as f64 / count as f64;
|
||||
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue