feat(adr-117/p3+p3.5): vitals + BFLD bindings

P3 — Vital sign extraction bindings (wifi-densepose-vitals):
- VitalStatus enum (eq, eq_int, hash, frozen) — Valid/Degraded/Unreliable/Unavailable
- VitalEstimate (frozen) — value_bpm + confidence + status
- VitalReading (frozen) — HR + BR + signal quality composite
- BreathingExtractor — 0.1–0.5 Hz bandpass + zero-crossing
- HeartRateExtractor — 0.8–2.0 Hz bandpass + autocorrelation
- py.allow_threads on extract() hot loops (Q5 audit confirmed
  core/vitals/signal are pure-sync — zero tokio deps, safe to release
  GIL with no embedded runtime needed)
- 17 tests covering construction, getters, frozen immutability,
  esp32_default + explicit ctors, synthetic-signal end-to-end

P3.5 — BFLD bindings (forward-compat surface, stub Rust):
- BfldKind enum — CompressedHE20/40/80/160 + UncompressedHT20/40
  with n_subcarriers, bandwidth_mhz, is_he metadata getters
- BfldFrame (frozen) — from_compressed_feedback() accepts numpy
  Complex64 ndarray [Nr x Nc x Nsc], validates dims against kind,
  feedback_matrix() returns lossless roundtrip ndarray
- BfldReport — aggregates frames, rejects mismatched kinds,
  computes inverse-CV coherence score
- 19 tests covering all 6 PHY variants + numpy roundtrip +
  dim-mismatch error + aggregation
- Real Rust ingestion (wifi-densepose-bfld crate) lands post-v2.0
  per ADR-117 §11.11/12 — Python API will not change

Total Python test count: 93 (was 57, +36 P3+P3.5). All passing.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-24 11:21:58 -04:00
parent 4ac0a4d52b
commit 2d29359809
9 changed files with 2065 additions and 5 deletions
+344
View File
@@ -0,0 +1,344 @@
//! 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<Complex64>` 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<Complex64>,
}
#[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<Self> {
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<Complex64> = 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<Complex64>> {
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<Vec<Vec<Complex64>>> {
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<u32>, // sounding indices we hold (don't deep-copy the matrices)
timestamp_first: Option<i64>,
timestamp_last: Option<i64>,
kind: Option<PyBfldKind>,
mean_amplitudes: Vec<f64>, // 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<i64> { self.timestamp_first }
#[getter]
fn timestamp_last(&self) -> Option<i64> { self.timestamp_last }
#[getter]
fn kind(&self) -> Option<PyBfldKind> { 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::<f64>()
/ 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::<f64>()
/ 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::<PyBfldKind>()?;
m.add_class::<PyBfldFrame>()?;
m.add_class::<PyBfldReport>()?;
Ok(())
}
+287
View File
@@ -0,0 +1,287 @@
//! ADR-117 P3 — PyO3 bindings for `wifi_densepose_vitals`.
//!
//! Surfaces:
//!
//! - `VitalStatus` enum — clinical-grade / degraded / unreliable / unavailable
//! - `VitalEstimate` — single BPM estimate + confidence + status
//! - `VitalReading` — combined HR + BR + signal quality snapshot
//! - `BreathingExtractor` — bandpass 0.10.5 Hz → respiratory rate
//! - `HeartRateExtractor` — bandpass 0.82.0 Hz + autocorrelation → HR
//!
//! ## GIL release strategy (per ADR-117 §7 and the Q5 audit on
//! 2026-05-24)
//!
//! `wifi-densepose-vitals` has zero tokio deps and the extract loops
//! are pure-sync DSP. Wrap the `.extract(...)` calls in
//! `py.allow_threads(|| ...)` so Python users can run inference in a
//! tokio-backed web server without GIL contention starving the
//! event loop.
use pyo3::prelude::*;
use wifi_densepose_vitals::{
BreathingExtractor, HeartRateExtractor, VitalEstimate, VitalReading, VitalStatus,
};
// ─── VitalStatus enum ────────────────────────────────────────────────
/// Status of a vital sign measurement.
///
/// Python:
/// ```python
/// from wifi_densepose import VitalStatus
/// VitalStatus.Valid # clinical-grade
/// VitalStatus.Degraded # reduced confidence
/// VitalStatus.Unreliable # single RSSI source / low quality
/// VitalStatus.Unavailable # no measurement possible
/// ```
#[pyclass(eq, eq_int, hash, frozen, name = "VitalStatus")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyVitalStatus {
Valid = 0,
Degraded = 1,
Unreliable = 2,
Unavailable = 3,
}
#[pymethods]
impl PyVitalStatus {
fn __repr__(&self) -> String {
format!("VitalStatus.{:?}", self.as_rust())
}
}
impl PyVitalStatus {
fn as_rust(&self) -> VitalStatus {
match self {
Self::Valid => VitalStatus::Valid,
Self::Degraded => VitalStatus::Degraded,
Self::Unreliable => VitalStatus::Unreliable,
Self::Unavailable => VitalStatus::Unavailable,
}
}
fn from_rust(s: VitalStatus) -> Self {
match s {
VitalStatus::Valid => Self::Valid,
VitalStatus::Degraded => Self::Degraded,
VitalStatus::Unreliable => Self::Unreliable,
VitalStatus::Unavailable => Self::Unavailable,
}
}
}
// ─── VitalEstimate ───────────────────────────────────────────────────
/// A single vital-sign estimate (BPM + confidence + status).
///
/// Python:
/// ```python
/// from wifi_densepose import VitalEstimate, VitalStatus
/// est = VitalEstimate(72.4, confidence=0.9, status=VitalStatus.Valid)
/// print(est.value_bpm, est.confidence, est.status)
/// ```
#[pyclass(frozen, name = "VitalEstimate")]
#[derive(Clone)]
pub struct PyVitalEstimate {
inner: VitalEstimate,
}
#[pymethods]
impl PyVitalEstimate {
#[new]
fn new(value_bpm: f64, confidence: f64, status: PyVitalStatus) -> Self {
Self {
inner: VitalEstimate {
value_bpm,
confidence,
status: status.as_rust(),
},
}
}
#[getter]
fn value_bpm(&self) -> f64 { self.inner.value_bpm }
#[getter]
fn confidence(&self) -> f64 { self.inner.confidence }
#[getter]
fn status(&self) -> PyVitalStatus { PyVitalStatus::from_rust(self.inner.status) }
fn __repr__(&self) -> String {
format!(
"VitalEstimate(value_bpm={:.2}, confidence={:.3}, status={:?})",
self.inner.value_bpm, self.inner.confidence, self.inner.status,
)
}
}
impl PyVitalEstimate {
fn from_rust(e: VitalEstimate) -> Self {
Self { inner: e }
}
}
// ─── VitalReading ────────────────────────────────────────────────────
/// Combined HR + BR snapshot from one window of CSI data.
#[pyclass(frozen, name = "VitalReading")]
pub struct PyVitalReading {
inner: VitalReading,
}
#[pymethods]
impl PyVitalReading {
#[new]
fn new(
respiratory_rate: PyVitalEstimate,
heart_rate: PyVitalEstimate,
subcarrier_count: usize,
signal_quality: f64,
timestamp_secs: f64,
) -> Self {
Self {
inner: VitalReading {
respiratory_rate: respiratory_rate.inner,
heart_rate: heart_rate.inner,
subcarrier_count,
signal_quality,
timestamp_secs,
},
}
}
#[getter]
fn respiratory_rate(&self) -> PyVitalEstimate {
PyVitalEstimate::from_rust(self.inner.respiratory_rate.clone())
}
#[getter]
fn heart_rate(&self) -> PyVitalEstimate {
PyVitalEstimate::from_rust(self.inner.heart_rate.clone())
}
#[getter]
fn subcarrier_count(&self) -> usize { self.inner.subcarrier_count }
#[getter]
fn signal_quality(&self) -> f64 { self.inner.signal_quality }
#[getter]
fn timestamp_secs(&self) -> f64 { self.inner.timestamp_secs }
fn __repr__(&self) -> String {
format!(
"VitalReading(br={:.1}, hr={:.1}, subcarriers={}, quality={:.3})",
self.inner.respiratory_rate.value_bpm,
self.inner.heart_rate.value_bpm,
self.inner.subcarrier_count,
self.inner.signal_quality,
)
}
}
// ─── BreathingExtractor ──────────────────────────────────────────────
/// Extracts respiratory rate (630 BPM) from per-subcarrier amplitude
/// residuals via 0.10.5 Hz bandpass + zero-crossing analysis.
///
/// Python:
/// ```python
/// from wifi_densepose import BreathingExtractor
///
/// br = BreathingExtractor.esp32_default() # 56 subcarriers, 100 Hz, 30s window
/// # or: BreathingExtractor(n_subcarriers=56, sample_rate=100.0, window_secs=30.0)
///
/// # Feed residuals from your preprocessor (one frame at a time)
/// est = br.extract(residuals=[0.01, -0.02, …], weights=[]) # equal weights
/// if est is not None:
/// print(est.value_bpm, est.confidence)
/// ```
#[pyclass(name = "BreathingExtractor")]
pub struct PyBreathingExtractor {
inner: BreathingExtractor,
}
#[pymethods]
impl PyBreathingExtractor {
/// Construct with explicit parameters.
#[new]
#[pyo3(signature = (n_subcarriers, sample_rate, window_secs=30.0))]
fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self {
Self {
inner: BreathingExtractor::new(n_subcarriers, sample_rate, window_secs),
}
}
/// ESP32 defaults: 56 subcarriers, 100 Hz, 30-second window.
#[staticmethod]
fn esp32_default() -> Self {
Self { inner: BreathingExtractor::esp32_default() }
}
/// Extract respiratory rate from a vector of per-subcarrier
/// residuals + per-subcarrier weights. GIL is released during the
/// DSP loop so Python threads can do other work concurrently.
///
/// Returns `None` if insufficient history has been accumulated.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
// GIL release: see ADR-117 §7 and the Q5 tokio audit. The DSP
// loop is pure sync, no Python objects touched, safe to run
// without the GIL.
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
est.map(PyVitalEstimate::from_rust)
}
fn __repr__(&self) -> String {
format!("BreathingExtractor(0.10.5 Hz bandpass)")
}
}
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection.
#[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor {
inner: HeartRateExtractor,
}
#[pymethods]
impl PyHeartRateExtractor {
/// Construct with explicit parameters.
#[new]
#[pyo3(signature = (n_subcarriers, sample_rate, window_secs=15.0))]
fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self {
Self {
inner: HeartRateExtractor::new(n_subcarriers, sample_rate, window_secs),
}
}
/// ESP32 defaults: 56 subcarriers, 100 Hz, 15-second window.
#[staticmethod]
fn esp32_default() -> Self {
Self { inner: HeartRateExtractor::esp32_default() }
}
/// Extract heart rate from per-subcarrier residuals. GIL released
/// during DSP.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
est.map(PyVitalEstimate::from_rust)
}
fn __repr__(&self) -> String {
format!("HeartRateExtractor(0.82.0 Hz bandpass)")
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyVitalStatus>()?;
m.add_class::<PyVitalEstimate>()?;
m.add_class::<PyVitalReading>()?;
m.add_class::<PyBreathingExtractor>()?;
m.add_class::<PyHeartRateExtractor>()?;
Ok(())
}