//! 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.1–0.5 Hz → respiratory rate //! - `HeartRateExtractor` — bandpass 0.8–2.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 (6–30 BPM) from per-subcarrier amplitude /// residuals via 0.1–0.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, weights: Vec) -> Option { // 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.1–0.5 Hz bandpass)") } } // ─── HeartRateExtractor ────────────────────────────────────────────── /// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude /// residuals via 0.8–2.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, weights: Vec) -> Option { let est = py.allow_threads(|| self.inner.extract(&residuals, &weights)); est.map(PyVitalEstimate::from_rust) } fn __repr__(&self) -> String { format!("HeartRateExtractor(0.8–2.0 Hz bandpass)") } } pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; Ok(()) }