mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
fix(vitals): HeartRateExtractor silently dropped every frame with phases=[]
HeartRateExtractor::extract() computed n = residuals.len().min(n_subcarriers).min(phases.len()), so an empty (or short) phases slice truncated n to 0 and every single frame was rejected via the n == 0 guard -- silently, with no way to distinguish it from any other None. BreathingExtractor documents weights=[] as "equal weighting"; HeartRateExtractor's phases=[] must mean the same thing (no per-subcarrier coherence data available), not "refuse all input". Fix: n now derives from residuals/n_subcarriers only. compute_phase_coherence_signal looks up phases via .get() and treats any missing entry as full coherence (weight 1.0), mirroring breathing::fuse_weighted_residuals's uniform-weight fallback for a missing/partial weights slice. Updated the now-inaccurate PyO3 docstrings that claimed phases=[] was invalid. Fixes #1423 Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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<'_>,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user