diff --git a/python/bench/test_bench_vitals.py b/python/bench/test_bench_vitals.py index 06bccb62cf..3e018a2d4d 100644 --- a/python/bench/test_bench_vitals.py +++ b/python/bench/test_bench_vitals.py @@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None: hr = HeartRateExtractor.esp32_default() rng = Random(43) for i in range(1500): - residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng) - hr.extract(residuals=residuals, weights=weights) + residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng) + hr.extract(residuals=residuals, phases=phases) def _one_frame(): - residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng) - return hr.extract(residuals=residuals, weights=weights) + residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng) + return hr.extract(residuals=residuals, phases=phases) benchmark(_one_frame) diff --git a/python/src/bindings/vitals.rs b/python/src/bindings/vitals.rs index ce9613b7c9..c30844a05e 100644 --- a/python/src/bindings/vitals.rs +++ b/python/src/bindings/vitals.rs @@ -242,7 +242,22 @@ impl PyBreathingExtractor { // ─── HeartRateExtractor ────────────────────────────────────────────── /// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude -/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection. +/// residuals and per-subcarrier unwrapped phases (radians) via +/// 0.8–2.0 Hz bandpass + autocorrelation peak detection. +/// +/// Python: +/// ```python +/// from wifi_densepose import HeartRateExtractor +/// +/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window +/// +/// # Feed residuals and matching unwrapped phases from your preprocessor. +/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate +/// # extraction because the Rust core requires phase data for each subcarrier. +/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …]) +/// if est is not None: +/// print(est.value_bpm, est.confidence) +/// ``` #[pyclass(name = "HeartRateExtractor")] pub struct PyHeartRateExtractor { inner: HeartRateExtractor, @@ -265,10 +280,17 @@ impl PyHeartRateExtractor { 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)); + /// Extract heart rate from per-subcarrier residuals and matching + /// per-subcarrier unwrapped phases (radians). Empty phases are invalid + /// and return `None` because the Rust extractor requires phase data. + /// GIL released during DSP. + fn extract( + &mut self, + py: Python<'_>, + residuals: Vec, + phases: Vec, + ) -> Option { + let est = py.allow_threads(|| self.inner.extract(&residuals, &phases)); est.map(PyVitalEstimate::from_rust) } diff --git a/python/tests/test_vitals.py b/python/tests/test_vitals.py index a195d89aae..942d29602a 100644 --- a/python/tests/test_vitals.py +++ b/python/tests/test_vitals.py @@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None: def test_heart_rate_extract_returns_none_with_too_few_samples() -> None: hr = HeartRateExtractor.esp32_default() - out = hr.extract(residuals=[0.0] * 56, weights=[]) + out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56) assert out is None +def test_heart_rate_extract_rejects_old_weights_keyword() -> None: + hr = HeartRateExtractor.esp32_default() + with pytest.raises(TypeError): + hr.extract(residuals=[0.0] * 56, weights=[0.0] * 56) + + +def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None: + """Drive the extractor with a synthetic 1.2 Hz sine (72 BPM) plus + same-length phase data. This proves the binding feeds Rust's required + `phases` slice instead of an empty vector that would keep returning None.""" + hr = HeartRateExtractor.esp32_default() + sample_rate = 100.0 + target_freq = 1.2 # 72 BPM + n_samples = int(60 * sample_rate) + phases = [i * 0.01 for i in range(56)] + + produced_estimate = False + rng = Random(43) + for i in range(n_samples): + t = i / sample_rate + base = math.sin(2.0 * math.pi * target_freq * t) + residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)] + est = hr.extract(residuals=residuals, phases=phases) + if est is not None: + produced_estimate = True + assert math.isfinite(est.value_bpm) + assert 0.0 <= est.confidence <= 1.0 + break + + assert produced_estimate, ( + "HeartRateExtractor never produced an estimate after 60s of synthetic data" + ) + + # ─── Build feature flag ──────────────────────────────────────────────