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:
ruv
2026-07-27 11:10:45 -04:00
parent 2cc378c12f
commit 8043873f2e
3 changed files with 166 additions and 15 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<'_>,