fix(sensing-server): canonicalize calibration frames to 56-tone grid (real HT40 fix)

Follow-up to the calibration deadlock fix. With the status gate unstuck,
maybe_feed_calibration reached feed_calibration, but a real ESP32 HT40 node
streams 128-wide amplitude frames while the single-link FieldModel is the
canonical 56-tone grid. LinkStats::update returned DimensionMismatch,
feed_calibration bubbled it, and maybe_feed_calibration swallowed it at debug
level — so frame_count stayed pinned at 0 on live hardware (presence/vitals,
which read the global history, were unaffected).

Resample each frame onto the model's canonical 56-tone grid via
HardwareNormalizer::resample_to_canonical before feeding — the same length-only
canonicalization the multistatic fusion path uses (#1170).

Pinned by maybe_feed_calibration_resamples_wide_frames_and_accumulates
(128-wide -> Collecting + count 1). sensing-server bin: 173 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rjperry36
2026-07-17 11:59:27 +01:00
committed by ruv
parent 4bab48e15f
commit 1692b16f6e
2 changed files with 46 additions and 2 deletions
@@ -7,12 +7,22 @@
//! score-based heuristic in `score_to_person_count`.
use std::collections::VecDeque;
use std::sync::LazyLock;
use wifi_densepose_signal::hardware_norm::HardwareNormalizer;
use wifi_densepose_signal::ruvsense::field_model::{
CalibrationStatus, FieldModel, FieldModelConfig,
};
use super::score_to_person_count;
/// Length-only canonicalizer for calibration frames (issue #1170 pattern,
/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the
/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is
/// configured for the canonical 56-tone grid, and `feed_calibration` rejects
/// any other width with `DimensionMismatch`. Resampling here (default 56)
/// lets real HT40 nodes actually calibrate instead of silently feeding nothing.
static CALIB_NORMALIZER: LazyLock<HardwareNormalizer> = LazyLock::new(HardwareNormalizer::new);
/// Number of recent frames to feed into perturbation extraction.
const OCCUPANCY_WINDOW: usize = 50;
@@ -113,8 +123,13 @@ pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<V
return;
}
if let Some(latest) = frame_history.back() {
// Single-link observation: [1][n_subcarriers]
let observations = vec![latest.clone()];
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(latest);
let observations = vec![canonical];
if let Err(e) = field.feed_calibration(&observations) {
tracing::debug!("FieldModel calibration feed: {e}");
}
@@ -220,4 +235,32 @@ mod tests {
maybe_feed_calibration(&mut field, &history);
assert_eq!(field.calibration_frame_count(), 2);
}
/// Regression (#1170 pattern): a real HT40 node streams 128-wide amplitude
/// frames, but the FieldModel is a 56-tone grid. Before canonicalization,
/// `feed_calibration` rejected every frame with DimensionMismatch (swallowed
/// at debug), so frame_count stayed 0 even with the deadlock fixed. The feed
/// must resample 128 → 56 and actually accumulate.
#[test]
fn maybe_feed_calibration_resamples_wide_frames_and_accumulates() {
let mut field = FieldModel::new(single_link_config()).expect("field model");
// 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw.
let wide = vec![0.5_f64; 128];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(wide);
maybe_feed_calibration(&mut field, &history);
assert_eq!(
field.status(),
CalibrationStatus::Collecting,
"128-wide frame must resample to 56 and be accepted"
);
assert_eq!(
field.calibration_frame_count(),
1,
"wide frame must accumulate, not be silently dropped"
);
}
}