//! ADR-117 P3.5 — Beamforming Feedback Loop Data (BFLD) bindings. //! //! BFLD is the transmitter-side, AP-station-loop view of the WiFi //! channel — compressed beamforming feedback frames that 802.11ac/ax/be //! stations send to the AP per sounding cycle. See ADR-117 §5.7a for //! the design rationale and ADR-117 §11.11/12 for open questions. //! //! **Important**: there is NO Rust ingestion crate for BFLD yet. The //! Python types in this module ship with a **stub Rust impl** that //! accepts pre-parsed feedback matrices via numpy. When the future //! `wifi-densepose-bfld` crate lands, it plugs in here without changing //! the Python API. //! //! Today's user path: //! //! 1. Capture BFR frames with `tcpdump` / Wireshark + the BFR dissector //! (or via `mac80211` debugfs on Linux 6.10+) //! 2. Parse the compressed feedback into a numpy Complex64 ndarray //! `[Nr × Nc × Nsc]` using your favourite Python BFR parser //! 3. Construct `BfldFrame.from_compressed_feedback(...)` to hand the //! matrix to RuView //! //! Tomorrow (post-v2.0): `wifi-densepose-bfld` does steps 1+2 for you. use pyo3::prelude::*; use numpy::{Complex64, PyArray3, PyUntypedArrayMethods, PyReadonlyArray3}; // ─── BfldKind ──────────────────────────────────────────────────────── /// 802.11 PHY variant of the captured BFR frame. Determines the /// expected matrix dimensions + the quantization step of the /// compressed angles. /// /// Python: /// ```python /// from wifi_densepose import BfldKind /// BfldKind.CompressedHE80 # 802.11ax 80 MHz compressed BFR /// ``` #[pyclass(eq, eq_int, hash, frozen, name = "BfldKind")] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum PyBfldKind { CompressedHE20 = 0, CompressedHE40 = 1, CompressedHE80 = 2, CompressedHE160 = 3, UncompressedHT20 = 4, UncompressedHT40 = 5, } #[pymethods] impl PyBfldKind { /// Expected number of subcarriers for this BFLD variant. #[getter] fn n_subcarriers(&self) -> usize { match self { Self::CompressedHE20 => 242, Self::CompressedHE40 => 484, Self::CompressedHE80 => 996, Self::CompressedHE160 => 1992, Self::UncompressedHT20 => 52, Self::UncompressedHT40 => 108, } } /// Bandwidth in MHz for this BFLD variant. #[getter] fn bandwidth_mhz(&self) -> u16 { match self { Self::CompressedHE20 | Self::UncompressedHT20 => 20, Self::CompressedHE40 | Self::UncompressedHT40 => 40, Self::CompressedHE80 => 80, Self::CompressedHE160 => 160, } } /// True for 802.11ax (HE) variants, false for legacy HT. #[getter] fn is_he(&self) -> bool { matches!( self, Self::CompressedHE20 | Self::CompressedHE40 | Self::CompressedHE80 | Self::CompressedHE160 ) } fn __repr__(&self) -> String { let name = match self { Self::CompressedHE20 => "CompressedHE20", Self::CompressedHE40 => "CompressedHE40", Self::CompressedHE80 => "CompressedHE80", Self::CompressedHE160 => "CompressedHE160", Self::UncompressedHT20 => "UncompressedHT20", Self::UncompressedHT40 => "UncompressedHT40", }; format!("BfldKind.{}", name) } } // ─── BfldFrame ─────────────────────────────────────────────────────── /// One BFR snapshot: a compressed beamforming feedback matrix tagged /// with metadata (timestamp, sounding sequence, source MAC, kind). /// /// Backing storage: a numpy Complex64 ndarray `[Nr × Nc × Nsc]`. The /// Python constructor accepts the ndarray directly; under the hood we /// hold a `Vec` in row-major order. /// /// Python: /// ```python /// import numpy as np /// from wifi_densepose import BfldFrame, BfldKind /// /// fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2, Nc=1, Nsc=996 /// frame = BfldFrame.from_compressed_feedback( /// timestamp_ms=1234, /// sounding_index=42, /// sta_mac="aa:bb:cc:dd:ee:ff", /// kind=BfldKind.CompressedHE80, /// feedback_matrix=fb, /// ) /// print(frame.n_subcarriers, frame.kind, frame.n_rows, frame.n_cols) /// ``` #[pyclass(frozen, name = "BfldFrame")] pub struct PyBfldFrame { timestamp_ms: i64, sounding_index: u32, sta_mac: String, kind: PyBfldKind, n_rows: usize, n_cols: usize, n_subcarriers: usize, // Row-major storage of the [Nr × Nc × Nsc] complex matrix. // Length = n_rows * n_cols * n_subcarriers. matrix: Vec, } #[pymethods] impl PyBfldFrame { /// Construct from a pre-parsed Complex64 ndarray of shape /// `[n_rows, n_cols, n_subcarriers]`. The last dimension MUST /// match `kind.n_subcarriers`. #[staticmethod] fn from_compressed_feedback<'py>( timestamp_ms: i64, sounding_index: u32, sta_mac: &str, kind: PyBfldKind, feedback_matrix: PyReadonlyArray3<'py, Complex64>, ) -> PyResult { let shape = feedback_matrix.shape(); let n_rows = shape[0]; let n_cols = shape[1]; let n_subcarriers = shape[2]; let expected = kind.n_subcarriers(); if n_subcarriers != expected { return Err(pyo3::exceptions::PyValueError::new_err(format!( "feedback_matrix subcarrier dim {} does not match {:?}.n_subcarriers={}", n_subcarriers, kind, expected ))); } // Copy into row-major Vec. This is the safe path; PyArray3 is // also row-major by default. let matrix: Vec = feedback_matrix .as_array() .iter() .copied() .collect(); Ok(Self { timestamp_ms, sounding_index, sta_mac: sta_mac.to_string(), kind, n_rows, n_cols, n_subcarriers, matrix, }) } #[getter] fn timestamp_ms(&self) -> i64 { self.timestamp_ms } #[getter] fn sounding_index(&self) -> u32 { self.sounding_index } #[getter] fn sta_mac(&self) -> &str { &self.sta_mac } #[getter] fn kind(&self) -> PyBfldKind { self.kind } #[getter] fn n_rows(&self) -> usize { self.n_rows } #[getter] fn n_cols(&self) -> usize { self.n_cols } #[getter] fn n_subcarriers(&self) -> usize { self.n_subcarriers } /// Mean amplitude across the entire matrix (sanity-check metric; /// production-grade sensing pipelines look at per-subcarrier or /// per-row stats instead). #[getter] fn mean_amplitude(&self) -> f64 { if self.matrix.is_empty() { return 0.0; } let sum: f64 = self.matrix.iter().map(|c| c.norm()).sum(); sum / self.matrix.len() as f64 } /// Return the feedback matrix as a numpy Complex64 ndarray of /// shape `[n_rows, n_cols, n_subcarriers]`. Allocates a fresh /// Python-owned array; the BfldFrame keeps its own copy. fn feedback_matrix<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray3> { PyArray3::from_vec3_bound( py, &self.reshape_to_vec3(), ) .expect("Vec dimensions match the matrix shape — invariant of from_compressed_feedback") } fn __repr__(&self) -> String { format!( "BfldFrame(kind={:?}, nr={}, nc={}, nsc={}, sta={}, idx={}, mean_amp={:.4})", self.kind, self.n_rows, self.n_cols, self.n_subcarriers, self.sta_mac, self.sounding_index, self.mean_amplitude(), ) } } impl PyBfldFrame { fn reshape_to_vec3(&self) -> Vec>> { let mut out = Vec::with_capacity(self.n_rows); for r in 0..self.n_rows { let mut row = Vec::with_capacity(self.n_cols); for c in 0..self.n_cols { let start = (r * self.n_cols + c) * self.n_subcarriers; let end = start + self.n_subcarriers; row.push(self.matrix[start..end].to_vec()); } out.push(row); } out } } // ─── BfldReport ────────────────────────────────────────────────────── /// Aggregator over a window of `BfldFrame`s — the natural "all BFR /// data in this 60-second scan" container. Mirrors how `VitalReading` /// aggregates `VitalEstimate`s in the vitals pipeline. #[pyclass(name = "BfldReport")] pub struct PyBfldReport { frames: Vec, // sounding indices we hold (don't deep-copy the matrices) timestamp_first: Option, timestamp_last: Option, kind: Option, mean_amplitudes: Vec, // one per frame } #[pymethods] impl PyBfldReport { #[new] fn new() -> Self { Self { frames: Vec::new(), timestamp_first: None, timestamp_last: None, kind: None, mean_amplitudes: Vec::new(), } } /// Add a frame to the report. All frames must share the same /// `kind`; the call errors if they don't. fn add_frame(&mut self, frame: &PyBfldFrame) -> PyResult<()> { if let Some(k) = self.kind { if k != frame.kind { return Err(pyo3::exceptions::PyValueError::new_err(format!( "frame kind {:?} does not match report kind {:?}", frame.kind, k ))); } } else { self.kind = Some(frame.kind); } self.frames.push(frame.sounding_index); self.timestamp_first = Some(self.timestamp_first.unwrap_or(frame.timestamp_ms).min(frame.timestamp_ms)); self.timestamp_last = Some(self.timestamp_last.unwrap_or(frame.timestamp_ms).max(frame.timestamp_ms)); self.mean_amplitudes.push(frame.mean_amplitude()); Ok(()) } #[getter] fn n_frames(&self) -> usize { self.frames.len() } #[getter] fn timestamp_first(&self) -> Option { self.timestamp_first } #[getter] fn timestamp_last(&self) -> Option { self.timestamp_last } #[getter] fn kind(&self) -> Option { self.kind } /// Mean of the per-frame mean amplitudes — coarse sanity metric /// for "the scan captured a stable signal over the window". #[getter] fn coherence_score(&self) -> f64 { if self.mean_amplitudes.is_empty() { return 0.0; } let mean = self.mean_amplitudes.iter().sum::() / self.mean_amplitudes.len() as f64; if mean == 0.0 { return 0.0; } // Inverse coefficient of variation, clamped to [0, 1]. let var = self.mean_amplitudes.iter() .map(|m| (m - mean).powi(2)) .sum::() / self.mean_amplitudes.len() as f64; let cv = var.sqrt() / mean; (1.0 - cv.min(1.0)).max(0.0) } fn __repr__(&self) -> String { format!( "BfldReport(n_frames={}, kind={:?}, coherence={:.3})", self.frames.len(), self.kind, self.coherence_score(), ) } } pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; Ok(()) }