fix: rename HeartRateExtractor.extract() weights param to phases
This commit is contained in:
parent
82c1b8fdf8
commit
8c232d0894
|
|
@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
|
||||||
hr = HeartRateExtractor.esp32_default()
|
hr = HeartRateExtractor.esp32_default()
|
||||||
rng = Random(43)
|
rng = Random(43)
|
||||||
for i in range(1500):
|
for i in range(1500):
|
||||||
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
||||||
hr.extract(residuals=residuals, weights=weights)
|
hr.extract(residuals=residuals, phases=phases)
|
||||||
|
|
||||||
def _one_frame():
|
def _one_frame():
|
||||||
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
||||||
return hr.extract(residuals=residuals, weights=weights)
|
return hr.extract(residuals=residuals, phases=phases)
|
||||||
|
|
||||||
benchmark(_one_frame)
|
benchmark(_one_frame)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,22 @@ impl PyBreathingExtractor {
|
||||||
// ─── HeartRateExtractor ──────────────────────────────────────────────
|
// ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude
|
/// 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")]
|
#[pyclass(name = "HeartRateExtractor")]
|
||||||
pub struct PyHeartRateExtractor {
|
pub struct PyHeartRateExtractor {
|
||||||
inner: HeartRateExtractor,
|
inner: HeartRateExtractor,
|
||||||
|
|
@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
|
||||||
Self { inner: HeartRateExtractor::esp32_default() }
|
Self { inner: HeartRateExtractor::esp32_default() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract heart rate from per-subcarrier residuals. GIL released
|
/// Extract heart rate from per-subcarrier residuals and matching
|
||||||
/// during DSP.
|
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
|
||||||
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
|
/// and return `None` because the Rust extractor requires phase data.
|
||||||
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
|
/// 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)
|
est.map(PyVitalEstimate::from_rust)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None:
|
||||||
|
|
||||||
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
|
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
|
||||||
hr = HeartRateExtractor.esp32_default()
|
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
|
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 ──────────────────────────────────────────────
|
# ─── Build feature flag ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue