mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +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
|
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
|
||||||
///
|
///
|
||||||
/// # Feed residuals and matching unwrapped phases from your preprocessor.
|
/// # Feed residuals and matching unwrapped phases from your preprocessor.
|
||||||
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
|
/// # Like BreathingExtractor's weights, phases=[] means "no per-subcarrier
|
||||||
/// # extraction because the Rust core requires phase data for each 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, …])
|
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
|
||||||
/// if est is not None:
|
/// if est is not None:
|
||||||
/// print(est.value_bpm, est.confidence)
|
/// print(est.value_bpm, est.confidence)
|
||||||
@@ -281,9 +282,11 @@ impl PyHeartRateExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract heart rate from per-subcarrier residuals and matching
|
/// Extract heart rate from per-subcarrier residuals and matching
|
||||||
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
|
/// per-subcarrier unwrapped phases (radians). A short or empty `phases`
|
||||||
/// and return `None` because the Rust extractor requires phase data.
|
/// slice falls back to equal weighting for any subcarrier missing phase
|
||||||
/// GIL released during DSP.
|
/// 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(
|
fn extract(
|
||||||
&mut self,
|
&mut self,
|
||||||
py: Python<'_>,
|
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 ──────────────────────────────────────────────
|
# ─── Build feature flag ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,17 @@ impl HeartRateExtractor {
|
|||||||
/// Returns a `VitalEstimate` with heart rate in BPM, or `None`
|
/// Returns a `VitalEstimate` with heart rate in BPM, or `None`
|
||||||
/// if insufficient data or too few subcarriers.
|
/// if insufficient data or too few subcarriers.
|
||||||
pub fn extract(&mut self, residuals: &[f64], phases: &[f64]) -> Option<VitalEstimate> {
|
pub fn extract(&mut self, residuals: &[f64], phases: &[f64]) -> Option<VitalEstimate> {
|
||||||
let n = residuals.len().min(self.n_subcarriers).min(phases.len());
|
// `n` is driven by `residuals` (and the subcarrier cap) only, NOT by
|
||||||
|
// `phases.len()`. Before this fix, a missing/short `phases` slice
|
||||||
|
// (e.g. `phases=[]`, documented as meaning "equal weighting", mirroring
|
||||||
|
// `BreathingExtractor`'s `weights=[]`) truncated `n` down to
|
||||||
|
// `phases.len()`, so `phases=[]` forced `n == 0` and `extract()`
|
||||||
|
// silently returned `None` for every frame (issue #1423). Missing
|
||||||
|
// phase entries are now treated by `compute_phase_coherence_signal`
|
||||||
|
// as "no coherence information available" and fused with equal
|
||||||
|
// weight, exactly like `breathing::fuse_weighted_residuals`'s
|
||||||
|
// uniform-weight fallback for a missing/partial `weights` slice.
|
||||||
|
let n = residuals.len().min(self.n_subcarriers);
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -249,29 +259,44 @@ impl HeartRateExtractor {
|
|||||||
/// Combines amplitude residuals with inter-subcarrier phase coherence
|
/// Combines amplitude residuals with inter-subcarrier phase coherence
|
||||||
/// to enhance the cardiac signal. Subcarriers with similar phase
|
/// to enhance the cardiac signal. Subcarriers with similar phase
|
||||||
/// derivatives are likely sensing the same body surface.
|
/// derivatives are likely sensing the same body surface.
|
||||||
|
///
|
||||||
|
/// `phases` may be shorter than `n` (including empty). Missing entries mean
|
||||||
|
/// the caller has no per-subcarrier phase measurement to weight by, so that
|
||||||
|
/// subcarrier is fused with full coherence (weight 1.0) instead of being
|
||||||
|
/// excluded -- i.e. `phases=[]` degrades gracefully to equal weighting
|
||||||
|
/// across all `n` residuals, exactly like `breathing::fuse_weighted_residuals`
|
||||||
|
/// falling back to a uniform weight for a missing/partial `weights` slice
|
||||||
|
/// (issue #1423; `phases=[]` is documented as meaning equal weights, the
|
||||||
|
/// same as `BreathingExtractor`'s `weights=[]`).
|
||||||
fn compute_phase_coherence_signal(residuals: &[f64], phases: &[f64], n: usize) -> f64 {
|
fn compute_phase_coherence_signal(residuals: &[f64], phases: &[f64], n: usize) -> f64 {
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
return residuals.first().copied().unwrap_or(0.0);
|
return residuals.first().copied().unwrap_or(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let phase_at = |i: usize| phases.get(i).copied();
|
||||||
|
|
||||||
// Compute inter-subcarrier phase differences as coherence weights.
|
// Compute inter-subcarrier phase differences as coherence weights.
|
||||||
// Adjacent subcarriers with small phase differences are more coherent.
|
// Adjacent subcarriers with small phase differences are more coherent.
|
||||||
|
// If either phase value needed for a pair is missing, treat the pair as
|
||||||
|
// fully coherent (weight 1.0) rather than panicking or excluding it.
|
||||||
let mut weighted_sum = 0.0;
|
let mut weighted_sum = 0.0;
|
||||||
let mut weight_total = 0.0;
|
let mut weight_total = 0.0;
|
||||||
|
|
||||||
for i in 0..n {
|
for (i, &r) in residuals.iter().enumerate().take(n) {
|
||||||
let coherence = if i + 1 < n {
|
let neighbor = if i + 1 < n {
|
||||||
let phase_diff = (phases[i + 1] - phases[i]).abs();
|
Some(i + 1)
|
||||||
// Higher coherence when phase difference is small
|
|
||||||
(-phase_diff).exp()
|
|
||||||
} else if i > 0 {
|
} else if i > 0 {
|
||||||
let phase_diff = (phases[i] - phases[i - 1]).abs();
|
Some(i - 1)
|
||||||
(-phase_diff).exp()
|
|
||||||
} else {
|
} else {
|
||||||
1.0
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
weighted_sum += residuals[i] * coherence;
|
let coherence = match neighbor.and_then(|j| phase_at(i).zip(phase_at(j))) {
|
||||||
|
Some((a, b)) => (-(b - a).abs()).exp(),
|
||||||
|
None => 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
weighted_sum += r * coherence;
|
||||||
weight_total += coherence;
|
weight_total += coherence;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,4 +586,95 @@ mod tests {
|
|||||||
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
|
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #1423 bug-catching test.
|
||||||
|
///
|
||||||
|
/// Reproduces the exact GH-issue repro: 56 subcarriers @ 100 Hz (ESP32
|
||||||
|
/// defaults), a noiseless 1.2 Hz (72 BPM) sine identical across every
|
||||||
|
/// subcarrier, fed frame-by-frame with an **empty** `phases` slice.
|
||||||
|
///
|
||||||
|
/// Before the fix, `extract()`'s `n` was
|
||||||
|
/// `residuals.len().min(n_subcarriers).min(phases.len())`, so
|
||||||
|
/// `phases = []` forced `n == 0` and every single frame returned `None`
|
||||||
|
/// (0/4000 estimates) even though the identical call with
|
||||||
|
/// `phases = [1.0; 56]` produced thousands of valid estimates. `phases=[]`
|
||||||
|
/// must mean "no coherence weighting available", i.e. equal weighting --
|
||||||
|
/// the same documented fallback `BreathingExtractor` already honors for
|
||||||
|
/// `weights=[]` -- not "invalid input, refuse everything".
|
||||||
|
#[test]
|
||||||
|
fn issue_1423_empty_phases_yields_equal_weighting_not_silent_none() {
|
||||||
|
let n = 56usize;
|
||||||
|
let sample_rate = 100.0;
|
||||||
|
let heart_freq = 1.2; // 72 BPM
|
||||||
|
|
||||||
|
let mut ext = HeartRateExtractor::esp32_default();
|
||||||
|
let mut estimates_with_empty_phases = 0usize;
|
||||||
|
for i in 0..4000usize {
|
||||||
|
let t = i as f64 / sample_rate;
|
||||||
|
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
|
||||||
|
let residuals = vec![base; n];
|
||||||
|
if ext.extract(&residuals, &[]).is_some() {
|
||||||
|
estimates_with_empty_phases += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
estimates_with_empty_phases > 0,
|
||||||
|
"HeartRateExtractor::extract() with phases=[] must not silently return None for \
|
||||||
|
every frame of a clean 72 BPM signal (issue #1423); got 0/4000 estimates",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity check against the issue's own comparison point: an
|
||||||
|
// equivalent uniform, non-empty `phases` slice (all subcarriers at
|
||||||
|
// the same phase, i.e. zero pairwise difference => full coherence,
|
||||||
|
// exactly what the equal-weighting fallback now produces for the
|
||||||
|
// empty case too) must yield a comparable number of estimates --
|
||||||
|
// the two should behave the same, not `0` vs `thousands`.
|
||||||
|
let mut ext2 = HeartRateExtractor::esp32_default();
|
||||||
|
let uniform_phases = vec![1.0_f64; n];
|
||||||
|
let mut estimates_with_uniform_phases = 0usize;
|
||||||
|
for i in 0..4000usize {
|
||||||
|
let t = i as f64 / sample_rate;
|
||||||
|
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
|
||||||
|
let residuals = vec![base; n];
|
||||||
|
if ext2.extract(&residuals, &uniform_phases).is_some() {
|
||||||
|
estimates_with_uniform_phases += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
estimates_with_empty_phases, estimates_with_uniform_phases,
|
||||||
|
"phases=[] (equal-weight fallback) must behave identically to a uniform, \
|
||||||
|
non-empty phases slice of the same length -- both represent 'no differential \
|
||||||
|
coherence information', got {estimates_with_empty_phases} vs \
|
||||||
|
{estimates_with_uniform_phases}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #1423 companion: a `phases` slice *shorter* than `residuals`
|
||||||
|
/// (partial coverage) must fall back to equal weighting for the missing
|
||||||
|
/// tail rather than truncating the whole fusion down to the phases that
|
||||||
|
/// happen to be present -- mirrors
|
||||||
|
/// `breathing::partial_weights_are_renormalized_not_scale_mixed`.
|
||||||
|
#[test]
|
||||||
|
fn partial_phases_do_not_truncate_subcarrier_count() {
|
||||||
|
// 8 residuals, only 2 phase values supplied.
|
||||||
|
let residuals = [1.0_f64; 8];
|
||||||
|
let phases = [0.0_f64, 0.0];
|
||||||
|
let fused = compute_phase_coherence_signal(&residuals, &phases, 8);
|
||||||
|
|
||||||
|
// All residuals are identical (1.0), so regardless of how coherence
|
||||||
|
// weights are distributed the fused value must still be 1.0 -- but
|
||||||
|
// this only holds if all 8 residuals are actually used. Before the
|
||||||
|
// fix, `n` would have been truncated to `phases.len() == 2`, so the
|
||||||
|
// caller-facing `extract()` never even reached this function with
|
||||||
|
// `n == 8`; this test pins `compute_phase_coherence_signal` itself
|
||||||
|
// to handle a short `phases` slice safely and correctly when called
|
||||||
|
// with the full `n`.
|
||||||
|
assert!(
|
||||||
|
(fused - 1.0).abs() < 1e-12,
|
||||||
|
"fusion with a partial phases slice must still average all {} residuals, got {fused}",
|
||||||
|
residuals.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user