288 lines
9.3 KiB
Rust
288 lines
9.3 KiB
Rust
//! 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<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.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<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.8–2.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(())
|
||
}
|