fix: rename HeartRateExtractor.extract() weights param to phases

This commit is contained in:
Matt Van Horn
2026-07-08 20:10:46 -07:00
committed by ruv
parent 82c1b8fdf8
commit 8c232d0894
3 changed files with 66 additions and 10 deletions
+4 -4
View File
@@ -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)
+27 -5
View File
@@ -242,7 +242,22 @@ impl PyBreathingExtractor {
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection.
/// residuals and per-subcarrier unwrapped phases (radians) via
/// 0.82.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<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
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<f64>,
phases: Vec<f64>,
) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
est.map(PyVitalEstimate::from_rust)
}
+35 -1
View File
@@ -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 ──────────────────────────────────────────────