mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
fix(train): wire wifi-densepose-signal into the pipeline; correct MODEL_CARD env-sensor claim (#536)
Addresses three findings from the 2026-05-11 training-pipeline audit: #1/#2 — `wifi-densepose-signal` was a phantom dependency of `wifi-densepose-train` (listed in Cargo.toml, never imported), and vitals/CSI signal features were absent from the pipeline. New module `wifi_densepose_train::signal_features`: `extract_signal_features(&Array4<f32>, &Array4<f32>) -> Array1<f32>` (and the convenience method `CsiSample::signal_features()`) runs a windowed observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (FEATURE_LEN=12) amplitude / phase-coherence / PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and is not yet fed back into the loss; wiring it as a training target is the documented follow-up. `wifi-densepose-signal` is now an actually-used dependency. 5 new tests (2 unit in signal_features.rs, 3 integration in tests/test_dataset.rs); existing wifi-densepose-train tests unchanged and green. #3 — `docs/huggingface/MODEL_CARD.md` presented PIR/BME280 environmental-sensor weak-label fine-tuning as a current capability; there is no env-sensor ingestion in the training pipeline. Marked that path as planned/not-implemented in the training-steps list and the data-provenance section. (#5 — README's "92.9% PCK@20" overclaim — fixed separately in PR #535.) CHANGELOG updated.
This commit is contained in:
@@ -92,6 +92,23 @@ pub struct CsiSample {
|
||||
pub frame_id: u64,
|
||||
}
|
||||
|
||||
impl CsiSample {
|
||||
/// Derive the compact signal-processing feature vector for this sample
|
||||
/// via [`crate::signal_features::extract_signal_features`] (see that
|
||||
/// function for the layout, and [`crate::signal_features::FEATURE_LEN`]
|
||||
/// for its length).
|
||||
///
|
||||
/// Computed on demand from [`Self::amplitude`]/[`Self::phase`] — not
|
||||
/// cached on the struct. This is the hook for folding the SOTA
|
||||
/// signal-processing crate's amplitude/phase/PSD features (and, in a
|
||||
/// later iteration, vitals-band power) into training; the raw vector is
|
||||
/// returned here and is not yet fed back into the loss.
|
||||
#[must_use]
|
||||
pub fn signal_features(&self) -> Array1<f32> {
|
||||
crate::signal_features::extract_signal_features(&self.amplitude, &self.phase)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CsiDataset trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod eval;
|
||||
pub mod geometry;
|
||||
pub mod rapid_adapt;
|
||||
pub mod ruview_metrics;
|
||||
pub mod signal_features;
|
||||
pub mod subcarrier;
|
||||
pub mod virtual_aug;
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Hand-off layer between raw windowed CSI and the SOTA signal-processing
|
||||
//! crate ([`wifi_densepose_signal`]).
|
||||
//!
|
||||
//! Historically `wifi-densepose-signal` was listed as a dependency of this
|
||||
//! crate but never imported — the training pipeline only ever consumed the
|
||||
//! raw amplitude/phase tensors. This module wires the two together: it takes
|
||||
//! a windowed CSI observation and runs it through
|
||||
//! [`wifi_densepose_signal::features::FeatureExtractor`] to derive a compact,
|
||||
//! fixed-length feature vector (amplitude statistics, phase coherence, and a
|
||||
//! power-spectral-density summary).
|
||||
//!
|
||||
//! These derived features are the building block for a future vitals /
|
||||
//! multi-task supervision head (breathing-band and heart-rate-band power can
|
||||
//! be read off the PSD summary); for now they are produced on demand via
|
||||
//! [`extract_signal_features`] / [`crate::dataset::CsiSample::signal_features`]
|
||||
//! and are not yet fed back into the loss. Wiring them as a training target
|
||||
//! is tracked as a follow-up to the 2026-05-11 training-pipeline audit.
|
||||
|
||||
use ndarray::{s, Array1, Array4};
|
||||
use wifi_densepose_signal::csi_processor::CsiData;
|
||||
use wifi_densepose_signal::features::FeatureExtractor;
|
||||
|
||||
/// Length of the vector returned by [`extract_signal_features`].
|
||||
///
|
||||
/// The layout is:
|
||||
/// 1. amplitude peak
|
||||
/// 2. amplitude RMS
|
||||
/// 3. amplitude dynamic range (max − min)
|
||||
/// 4. mean of the per-subcarrier amplitude means
|
||||
/// 5. mean of the per-subcarrier amplitude variances
|
||||
/// 6. phase coherence
|
||||
/// 7. mean of the per-subcarrier phase variances
|
||||
/// 8. PSD total power
|
||||
/// 9. PSD peak power
|
||||
/// 10. PSD peak frequency (Hz)
|
||||
/// 11. PSD spectral centroid
|
||||
/// 12. PSD spectral bandwidth
|
||||
pub const FEATURE_LEN: usize = 12;
|
||||
|
||||
/// Default centre frequency assumed when the CSI window carries no metadata.
|
||||
const DEFAULT_CENTRE_FREQ_HZ: f64 = 2.4e9;
|
||||
|
||||
/// Default channel bandwidth (HT40) assumed when the CSI window carries no
|
||||
/// metadata.
|
||||
const DEFAULT_BANDWIDTH_HZ: f64 = 40.0e6;
|
||||
|
||||
/// Derive a compact, fixed-length ([`FEATURE_LEN`]) signal-processing feature
|
||||
/// vector from a windowed CSI observation by running its centre frame through
|
||||
/// [`wifi_densepose_signal::features::FeatureExtractor`].
|
||||
///
|
||||
/// `amplitude` and `phase` are `[window_frames, n_tx, n_rx, n_subcarriers]`
|
||||
/// tensors (the [`crate::dataset::CsiSample`] layout). The centre frame is
|
||||
/// flattened to `[n_tx · n_rx, n_subcarriers]` (the antenna-major shape the
|
||||
/// signal crate expects) and converted to `f64`.
|
||||
///
|
||||
/// The returned values are always finite for finite input: the underlying
|
||||
/// extractors clamp degenerate cases, and any non-finite result is mapped to
|
||||
/// `0.0` so callers can rely on the vector being usable as a model feature.
|
||||
pub fn extract_signal_features(amplitude: &Array4<f32>, phase: &Array4<f32>) -> Array1<f32> {
|
||||
let (n_t, n_tx, n_rx, n_sc) = amplitude.dim();
|
||||
debug_assert_eq!(amplitude.dim(), phase.dim(), "amplitude/phase shape mismatch");
|
||||
if n_t == 0 || n_tx == 0 || n_rx == 0 || n_sc == 0 {
|
||||
return Array1::zeros(FEATURE_LEN);
|
||||
}
|
||||
let n_ant = n_tx * n_rx;
|
||||
let t = n_t / 2;
|
||||
|
||||
let to_2d = |src: &Array4<f32>| -> Vec<f64> {
|
||||
src.slice(s![t, .., .., ..]).iter().map(|&v| f64::from(v)).collect()
|
||||
};
|
||||
let amp2d = match ndarray::Array2::from_shape_vec((n_ant, n_sc), to_2d(amplitude)) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return Array1::zeros(FEATURE_LEN),
|
||||
};
|
||||
let phase2d = match ndarray::Array2::from_shape_vec((n_ant, n_sc), to_2d(phase)) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Array1::zeros(FEATURE_LEN),
|
||||
};
|
||||
|
||||
let csi = match CsiData::builder()
|
||||
.amplitude(amp2d)
|
||||
.phase(phase2d)
|
||||
.frequency(DEFAULT_CENTRE_FREQ_HZ)
|
||||
.bandwidth(DEFAULT_BANDWIDTH_HZ)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return Array1::zeros(FEATURE_LEN),
|
||||
};
|
||||
|
||||
let feats = FeatureExtractor::default_config().extract(&csi);
|
||||
|
||||
let amp_mean_overall = mean_or_zero(feats.amplitude.mean.iter().copied());
|
||||
let amp_var_overall = mean_or_zero(feats.amplitude.variance.iter().copied());
|
||||
let phase_var_overall = mean_or_zero(feats.phase.variance.iter().copied());
|
||||
|
||||
let raw = [
|
||||
feats.amplitude.peak,
|
||||
feats.amplitude.rms,
|
||||
feats.amplitude.dynamic_range,
|
||||
amp_mean_overall,
|
||||
amp_var_overall,
|
||||
feats.phase.coherence,
|
||||
phase_var_overall,
|
||||
feats.psd.total_power,
|
||||
feats.psd.peak_power,
|
||||
feats.psd.peak_frequency,
|
||||
feats.psd.centroid,
|
||||
feats.psd.bandwidth,
|
||||
];
|
||||
debug_assert_eq!(raw.len(), FEATURE_LEN);
|
||||
Array1::from_iter(raw.iter().map(|&v| sanitise(v)))
|
||||
}
|
||||
|
||||
/// Mean of an iterator of `f64`, or `0.0` if it is empty or non-finite.
|
||||
fn mean_or_zero<I: Iterator<Item = f64>>(it: I) -> f64 {
|
||||
let (sum, n) = it.fold((0.0_f64, 0_usize), |(s, k), v| (s + v, k + 1));
|
||||
if n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
sum / n as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Map non-finite values to `0.0` and downcast to `f32`.
|
||||
fn sanitise(v: f64) -> f32 {
|
||||
if v.is_finite() {
|
||||
v as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ndarray::Array4;
|
||||
|
||||
#[test]
|
||||
fn zero_sized_input_yields_zero_vector() {
|
||||
let empty = Array4::<f32>::zeros((0, 0, 0, 0));
|
||||
let f = extract_signal_features(&empty, &empty);
|
||||
assert_eq!(f.len(), FEATURE_LEN);
|
||||
assert!(f.iter().all(|&v| v == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_is_finite_and_correct_length() {
|
||||
let amp = Array4::<f32>::from_elem((4, 3, 3, 56), 1.5);
|
||||
let phase = Array4::<f32>::from_elem((4, 3, 3, 56), 0.25);
|
||||
let f = extract_signal_features(&, &phase);
|
||||
assert_eq!(f.len(), FEATURE_LEN);
|
||||
assert!(f.iter().all(|v| v.is_finite()), "features must be finite: {f:?}");
|
||||
}
|
||||
}
|
||||
@@ -458,3 +458,52 @@ fn dataloader_empty_dataset_zero_batches() {
|
||||
"iterator over empty dataset must yield 0 items"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CsiSample::signal_features — the wifi-densepose-signal wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `signal_features()` must return a vector of exactly `FEATURE_LEN`, all
|
||||
/// finite, for a real (synthetic) sample.
|
||||
#[test]
|
||||
fn signal_features_have_correct_length_and_are_finite() {
|
||||
use wifi_densepose_train::signal_features::FEATURE_LEN;
|
||||
|
||||
let ds = SyntheticCsiDataset::new(8, default_cfg());
|
||||
let sample = ds.get(0).expect("sample 0 must exist");
|
||||
let feats = sample.signal_features();
|
||||
assert_eq!(
|
||||
feats.len(),
|
||||
FEATURE_LEN,
|
||||
"signal_features() must return FEATURE_LEN ({FEATURE_LEN}) values"
|
||||
);
|
||||
assert!(
|
||||
feats.iter().all(|v| v.is_finite()),
|
||||
"all signal features must be finite, got {feats:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `signal_features()` is deterministic for a given (deterministic) sample.
|
||||
#[test]
|
||||
fn signal_features_are_deterministic() {
|
||||
let ds = SyntheticCsiDataset::new(8, default_cfg());
|
||||
let a = ds.get(0).expect("sample 0").signal_features();
|
||||
let b = ds.get(0).expect("sample 0").signal_features();
|
||||
assert_eq!(
|
||||
a, b,
|
||||
"signal_features() must be deterministic for the same sample"
|
||||
);
|
||||
}
|
||||
|
||||
/// `extract_signal_features` returns the zero vector for a zero-sized window
|
||||
/// rather than panicking.
|
||||
#[test]
|
||||
fn signal_features_zero_window_is_zero_vector() {
|
||||
use ndarray::Array4;
|
||||
use wifi_densepose_train::signal_features::{extract_signal_features, FEATURE_LEN};
|
||||
|
||||
let empty = Array4::<f32>::zeros((0, 0, 0, 0));
|
||||
let feats = extract_signal_features(&empty, &empty);
|
||||
assert_eq!(feats.len(), FEATURE_LEN);
|
||||
assert!(feats.iter().all(|&v| v == 0.0));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user