fix(vitals): HeartRateExtractor weights=[] silent None + BreathingExtractor stale-lock recovery (#1422, #1423) (#1449)

Fixes #1422, Fixes #1423. HeartRateExtractor no longer truncates subcarrier count on empty phases; BreathingExtractor resets immediately on first out-of-band rejection instead of passively draining a stale window (recovery 28.6s -> 6.0s). 64 tests passing, clippy clean, zero regressions workspace-wide.
This commit is contained in:
rUv
2026-07-27 08:46:58 -07:00
committed by GitHub
parent 931a38abdb
commit 13015c9d36
4 changed files with 420 additions and 20 deletions
+8 -5
View File
@@ -252,8 +252,9 @@ impl PyBreathingExtractor {
/// 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.
/// # Like BreathingExtractor's weights, phases=[] means "no per-subcarrier
/// # coherence information available" and falls back to equal weighting
/// # across all subcarriers -- it does NOT silently drop every frame.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
@@ -281,9 +282,11 @@ impl PyHeartRateExtractor {
}
/// 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.
/// per-subcarrier unwrapped phases (radians). A short or empty `phases`
/// slice falls back to equal weighting for any subcarrier missing phase
/// data (issue #1423) -- it does not truncate the number of subcarriers
/// fused, and does not silently return `None` for every frame. GIL
/// released during DSP.
fn extract(
&mut self,
py: Python<'_>,
+32
View File
@@ -223,6 +223,38 @@ def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
)
def test_heart_rate_extract_with_empty_phases_produces_estimates() -> None:
"""Issue #1423 regression: `phases=[]` must fall back to equal
weighting (mirroring `BreathingExtractor`'s `weights=[]`), not silently
return `None` for every frame.
Reproduces the GH-issue repro: a noiseless 1.2 Hz (72 BPM) sine
identical across all 56 subcarriers, fed frame-by-frame with an empty
`phases` list. Before the fix this produced 0/4000 estimates; the same
signal with `phases=[1.0] * 56` already produced thousands.
"""
hr = HeartRateExtractor.esp32_default()
sample_rate = 100.0
target_freq = 1.2 # 72 BPM
n_samples = 4000
produced = 0
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
residuals = [base] * 56
est = hr.extract(residuals=residuals, phases=[])
if est is not None:
produced += 1
assert math.isfinite(est.value_bpm)
assert 0.0 <= est.confidence <= 1.0
assert produced > 0, (
"HeartRateExtractor.extract(residuals=..., phases=[]) must not silently "
"return None for every frame of a clean 72 BPM signal (issue #1423)"
)
# ─── Build feature flag ──────────────────────────────────────────────