Skip to content

Commit 13015c9

Browse files
authored
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.
1 parent 931a38a commit 13015c9

4 files changed

Lines changed: 420 additions & 20 deletions

File tree

python/src/bindings/vitals.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,9 @@ impl PyBreathingExtractor {
252252
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
253253
///
254254
/// # Feed residuals and matching unwrapped phases from your preprocessor.
255-
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
256-
/// # extraction because the Rust core requires phase data for each subcarrier.
255+
/// # Like BreathingExtractor's weights, phases=[] means "no per-subcarrier
256+
/// # coherence information available" and falls back to equal weighting
257+
/// # across all subcarriers -- it does NOT silently drop every frame.
257258
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
258259
/// if est is not None:
259260
/// print(est.value_bpm, est.confidence)
@@ -281,9 +282,11 @@ impl PyHeartRateExtractor {
281282
}
282283

283284
/// Extract heart rate from per-subcarrier residuals and matching
284-
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
285-
/// and return `None` because the Rust extractor requires phase data.
286-
/// GIL released during DSP.
285+
/// per-subcarrier unwrapped phases (radians). A short or empty `phases`
286+
/// slice falls back to equal weighting for any subcarrier missing phase
287+
/// data (issue #1423) -- it does not truncate the number of subcarriers
288+
/// fused, and does not silently return `None` for every frame. GIL
289+
/// released during DSP.
287290
fn extract(
288291
&mut self,
289292
py: Python<'_>,

python/tests/test_vitals.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,38 @@ def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
223223
)
224224

225225

226+
def test_heart_rate_extract_with_empty_phases_produces_estimates() -> None:
227+
"""Issue #1423 regression: `phases=[]` must fall back to equal
228+
weighting (mirroring `BreathingExtractor`'s `weights=[]`), not silently
229+
return `None` for every frame.
230+
231+
Reproduces the GH-issue repro: a noiseless 1.2 Hz (72 BPM) sine
232+
identical across all 56 subcarriers, fed frame-by-frame with an empty
233+
`phases` list. Before the fix this produced 0/4000 estimates; the same
234+
signal with `phases=[1.0] * 56` already produced thousands.
235+
"""
236+
hr = HeartRateExtractor.esp32_default()
237+
sample_rate = 100.0
238+
target_freq = 1.2 # 72 BPM
239+
n_samples = 4000
240+
241+
produced = 0
242+
for i in range(n_samples):
243+
t = i / sample_rate
244+
base = math.sin(2.0 * math.pi * target_freq * t)
245+
residuals = [base] * 56
246+
est = hr.extract(residuals=residuals, phases=[])
247+
if est is not None:
248+
produced += 1
249+
assert math.isfinite(est.value_bpm)
250+
assert 0.0 <= est.confidence <= 1.0
251+
252+
assert produced > 0, (
253+
"HeartRateExtractor.extract(residuals=..., phases=[]) must not silently "
254+
"return None for every frame of a clean 72 BPM signal (issue #1423)"
255+
)
256+
257+
226258
# ─── Build feature flag ──────────────────────────────────────────────
227259

228260

v2/crates/wifi-densepose-vitals/src/breathing.rs

Lines changed: 254 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,29 @@ pub struct BreathingExtractor {
4747
freq_high: f64,
4848
/// IIR filter state.
4949
filter_state: IirState,
50+
/// Count of consecutive `extract()` calls that rejected the estimated
51+
/// frequency as out of the breathing band (i.e. "no periodic signal
52+
/// detected"), since the last accepted estimate or reset.
53+
///
54+
/// Without this, a subject leaving mid-lock only clears via the
55+
/// `filtered_history` ring buffer passively flushing over the full
56+
/// `window_secs` (up to 30s) — during which a stale, decreasingly
57+
/// accurate estimate keeps being emitted, and any *new* subject
58+
/// arriving mid-flush gets fused with leftover stale samples instead
59+
/// of starting from a clean window (issue #1422). Once
60+
/// `consecutive_rejections` reaches `STALE_RESET_REJECTIONS`, `reset()`
61+
/// is called so the next accepted estimate is built from fresh data
62+
/// only, instead of waiting out the old window.
63+
consecutive_rejections: usize,
5064
}
5165

66+
/// Number of consecutive out-of-band rejections after which the sliding
67+
/// window and filter state are cleared (ADR-157-adjacent fix, issue #1422).
68+
/// One rejection is enough: once the estimated frequency has left the
69+
/// breathing band, the same rejected estimate must never be echoed again as
70+
/// a stale "lock" while the window slowly flushes over up to `window_secs`.
71+
const STALE_RESET_REJECTIONS: usize = 1;
72+
5273
impl BreathingExtractor {
5374
/// Create a new breathing extractor.
5475
///
@@ -67,6 +88,7 @@ impl BreathingExtractor {
6788
freq_low: 0.1,
6889
freq_high: 0.5,
6990
filter_state: IirState::default(),
91+
consecutive_rejections: 0,
7092
}
7193
}
7294

@@ -127,10 +149,29 @@ impl BreathingExtractor {
127149
let duration_s = history.len() as f64 / self.sample_rate;
128150
let frequency_hz = crossings as f64 / (2.0 * duration_s);
129151

130-
// Validate frequency is within the breathing band
152+
// Validate frequency is within the breathing band. An out-of-band
153+
// estimate means no periodic breathing signal was found in the
154+
// current window (e.g. the subject left, or noise dominates).
155+
//
156+
// Without an active reset here, the stale `filtered_history` window
157+
// only clears by passively flushing over the full `window_secs`
158+
// (up to 30s) as new samples evict old ones. During that flush a
159+
// transiently *accepted* estimate can keep climbing toward
160+
// `freq_high` before the frequency finally leaves the band (issue
161+
// #1422) — and once rejected, any real signal that resumes would
162+
// otherwise have to wait out the rest of that stale window before
163+
// it can dominate a fresh, accurate estimate again. Resetting on
164+
// rejection makes both directions fast: reject-and-forget instead
165+
// of reject-then-linger, and reacquire-from-scratch instead of
166+
// reacquire-diluted-by-ghosts.
131167
if frequency_hz < self.freq_low || frequency_hz > self.freq_high {
168+
self.consecutive_rejections += 1;
169+
if self.consecutive_rejections >= STALE_RESET_REJECTIONS {
170+
self.reset();
171+
}
132172
return None;
133173
}
174+
self.consecutive_rejections = 0;
134175

135176
let bpm = frequency_hz * 60.0;
136177
let confidence = compute_confidence(history);
@@ -200,6 +241,7 @@ impl BreathingExtractor {
200241
pub fn reset(&mut self) {
201242
self.filtered_history.clear();
202243
self.filter_state = IirState::default();
244+
self.consecutive_rejections = 0;
203245
}
204246

205247
/// Current number of samples in the history buffer.
@@ -479,6 +521,197 @@ mod tests {
479521
);
480522
}
481523

524+
/// Deterministic small PRNG (LCG) for reproducible synthetic-signal
525+
/// tests -- mirrors the style already used in
526+
/// `heartrate::tests::pure_noise_is_never_reported_valid`. Returns a
527+
/// value in roughly `[-1, 1)`.
528+
fn lcg_next(seed: &mut u64) -> f64 {
529+
*seed = seed
530+
.wrapping_mul(6_364_136_223_846_793_005)
531+
.wrapping_add(1_442_695_040_888_963_407);
532+
((*seed >> 33) as f64 / (1u64 << 31) as f64) - 1.0
533+
}
534+
535+
/// Issue #1422 bug-catching test.
536+
///
537+
/// Reproduces the reported scenario at ESP32 defaults (56 subcarriers,
538+
/// 100 Hz, 30s window): 60s of a real 0.25 Hz (15 BPM) signal with
539+
/// per-subcarrier gain/phase (lock-on), then broadband noise-floor-only
540+
/// residuals (the "empty room").
541+
///
542+
/// `extract()` already returned `None` once the estimated frequency left
543+
/// the breathing band (that part was not silently broken) -- the actual
544+
/// defect was that the 30s `filtered_history` window only cleared by
545+
/// *passively* flushing sample-by-sample, so a locked-on extractor kept
546+
/// re-fusing whatever noise trickled in with a shrinking-but-still-large
547+
/// fraction of stale real signal, letting a decreasingly-accurate
548+
/// estimate keep being accepted right up to the range ceiling (30 BPM --
549+
/// the exact value from the GH issue) before it finally rejected. Once
550+
/// rejected, the *next* real subject would then have to wait out the
551+
/// rest of that same stale window before a fresh, undiluted estimate
552+
/// could dominate again (see `issue_1422_recovery_after_dropout_is_fast`
553+
/// for that half of the regression).
554+
///
555+
/// This test pins the fix at its source: the very first out-of-band
556+
/// rejection after a real signal disappears must actively clear
557+
/// `filtered_history` (not wait for it to passively drain), so no
558+
/// stale majority-real-signal window can ever linger and re-validate a
559+
/// ghost estimate near the ceiling.
560+
#[test]
561+
fn issue_1422_stale_lock_does_not_persist_after_subject_leaves() {
562+
let n = 56usize;
563+
let fs = 100.0;
564+
let weights = vec![1.0f64; n];
565+
let mut seed: u64 = 0x1422_1422;
566+
let gain: Vec<f64> = (0..n).map(|_| 0.4 + 0.6 * lcg_next(&mut seed).abs()).collect();
567+
let phase: Vec<f64> = (0..n)
568+
.map(|_| lcg_next(&mut seed).abs() * 2.0 * std::f64::consts::PI)
569+
.collect();
570+
let mut ext = BreathingExtractor::esp32_default();
571+
572+
// 60s of a strong, real 15 BPM (0.25 Hz) breathing signal -- lock on.
573+
let mut got_valid_lock = false;
574+
for i in 0..6000usize {
575+
let t = i as f64 / fs;
576+
let residuals: Vec<f64> = (0..n)
577+
.map(|c| {
578+
0.6 * gain[c] * (2.0 * std::f64::consts::PI * 0.25 * t + phase[c]).sin()
579+
+ lcg_next(&mut seed) * 0.05
580+
})
581+
.collect();
582+
if let Some(est) = ext.extract(&residuals, &weights) {
583+
assert!(
584+
(est.value_bpm - 15.0).abs() < 5.0,
585+
"should track ~15 BPM while the subject is present, got {}",
586+
est.value_bpm
587+
);
588+
got_valid_lock = true;
589+
}
590+
}
591+
assert!(got_valid_lock, "extractor must lock onto the real breathing signal first");
592+
assert!(
593+
ext.history_len() > 0,
594+
"a locked-on extractor must carry a non-empty window into the empty-room phase"
595+
);
596+
597+
// Empty room: feed noise-floor-only residuals one at a time until the
598+
// *first* rejection (the first `None` here can only come from the
599+
// frequency-band check, never "insufficient history", since the
600+
// window is already far past `min_samples` from the lock-on phase).
601+
let mut first_reject_at: Option<usize> = None;
602+
let mut history_len_at_reject: Option<usize> = None;
603+
for i in 0..12000usize {
604+
let residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
605+
let outcome = ext.extract(&residuals, &weights);
606+
if outcome.is_none() {
607+
first_reject_at = Some(i);
608+
history_len_at_reject = Some(ext.history_len());
609+
break;
610+
}
611+
}
612+
let first_reject_at =
613+
first_reject_at.expect("pure noise must eventually be rejected as out of band");
614+
615+
// The fix: the window must be actively cleared in the *same* call
616+
// that rejected the frequency -- not left to drain passively over
617+
// the remaining ~30s - first_reject_at samples. Before the fix,
618+
// `history_len()` here was still the full ~3000-sample stale window.
619+
assert_eq!(
620+
history_len_at_reject,
621+
Some(0),
622+
"the first out-of-band rejection (at sample {first_reject_at}) must reset the \
623+
history window immediately, not leave the stale lock-on window in place \
624+
(issue #1422)",
625+
);
626+
627+
// And the window must not be allowed to silently regrow back into a
628+
// large, majority-noise "lock" while noise keeps arriving: each
629+
// rebuild-to-`min_samples` cycle must itself reject and reset, so
630+
// `history_len()` never creeps back up toward the full window.
631+
let min_samples = (fs * 10.0) as usize;
632+
let mut max_history_len_after_reject = 0usize;
633+
for _ in 0..(12000 - first_reject_at - 1) {
634+
let residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
635+
ext.extract(&residuals, &weights);
636+
max_history_len_after_reject = max_history_len_after_reject.max(ext.history_len());
637+
}
638+
assert!(
639+
max_history_len_after_reject <= min_samples,
640+
"history window regrew to {max_history_len_after_reject} samples while fed pure \
641+
noise -- a stale majority-noise window should never be allowed to accumulate \
642+
past the minimum warm-up size without being rejected and reset (issue #1422)",
643+
);
644+
645+
// Finally: the extractor must be silent at the very end of the long
646+
// empty-room period, not just momentarily quiet.
647+
let final_residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
648+
assert!(
649+
ext.extract(&final_residuals, &weights).is_none(),
650+
"BreathingExtractor must report no signal at the end of a long empty-room period",
651+
);
652+
}
653+
654+
/// Issue #1422 companion regression: once the subject leaves (and the
655+
/// extractor has rejected/reset), a *returning* subject must be
656+
/// reacquired quickly -- not have to wait out the full stale 30s window
657+
/// passively flushing via FIFO eviction, which is what produced the
658+
/// reported "stayed at 30.0 BPM for roughly another 30s before starting
659+
/// to track again" secondary symptom.
660+
#[test]
661+
fn issue_1422_recovery_after_dropout_is_fast() {
662+
let n = 56usize;
663+
let fs = 100.0;
664+
let weights = vec![1.0f64; n];
665+
let mut seed: u64 = 0xFEED_1422;
666+
let gain: Vec<f64> = (0..n).map(|_| 0.4 + 0.6 * lcg_next(&mut seed).abs()).collect();
667+
let phase: Vec<f64> = (0..n)
668+
.map(|_| lcg_next(&mut seed).abs() * 2.0 * std::f64::consts::PI)
669+
.collect();
670+
let mut ext = BreathingExtractor::esp32_default();
671+
672+
let signal_residuals = |t: f64, seed: &mut u64| -> Vec<f64> {
673+
(0..n)
674+
.map(|c| {
675+
0.6 * gain[c] * (2.0 * std::f64::consts::PI * 0.25 * t + phase[c]).sin()
676+
+ lcg_next(seed) * 0.05
677+
})
678+
.collect()
679+
};
680+
let noise_residuals = |seed: &mut u64| -> Vec<f64> {
681+
(0..n).map(|_| lcg_next(seed) * 0.05).collect()
682+
};
683+
684+
// Lock on.
685+
for i in 0..6000usize {
686+
ext.extract(&signal_residuals(i as f64 / fs, &mut seed), &weights);
687+
}
688+
// Long empty-room period.
689+
for _ in 0..6000usize {
690+
ext.extract(&noise_residuals(&mut seed), &weights);
691+
}
692+
// Subject returns.
693+
let mut recovered_at = None;
694+
for i in 0..6000usize {
695+
let t = (12000 + i) as f64 / fs;
696+
if ext.extract(&signal_residuals(t, &mut seed), &weights).is_some() {
697+
recovered_at = Some(i);
698+
break;
699+
}
700+
}
701+
702+
let recovered_at = recovered_at.expect("extractor must reacquire the returning subject");
703+
// A passive-flush-only window (pre-fix) needs on the order of the
704+
// full 30s window to dilute stale noise (measured ~29s); the active
705+
// reset-on-rejection fix reacquires close to the 10s minimum warm-up
706+
// instead (measured ~6s). Generous bound: well under half the window.
707+
assert!(
708+
recovered_at < 1500,
709+
"recovery after a dropout took {recovered_at} samples (~{:.1}s) -- expected fast \
710+
reacquisition (issue #1422 secondary symptom: slow recovery after a transient)",
711+
recovered_at as f64 / fs,
712+
);
713+
}
714+
482715
/// ADR-157 §A3 bug-catching test. Divergence needs the pole magnitude
483716
/// `|r| >= 1`, i.e. `bw >= 4`. At `fs = 0.5` Hz with the band widened to
484717
/// 0.1-0.9 Hz, `bw = 2*pi*(0.9-0.1)/0.5 = 10.05`, so the OLD pole radius
@@ -493,12 +726,28 @@ mod tests {
493726
ext.freq_high = 0.9;
494727
// Feed a unit step for 600 frames — enough for the un-clamped resonator
495728
// to overflow to inf.
729+
//
730+
// A constant unit step has essentially no periodic content once the
731+
// resonator settles, so with the issue #1422 reset-on-rejection fix
732+
// this can legitimately cycle `filtered_history` back to empty
733+
// between checks (build up to `min_samples`, get rejected as
734+
// out-of-band, reset, rebuild...). That's an intentional, separate
735+
// behavior change -- this test's actual purpose (ADR-157 §A3) is the
736+
// *filter's* numerical stability under extreme parameters, so it
737+
// tracks the max history length reached and checks finiteness on
738+
// every iteration instead of asserting a nonzero count only at the
739+
// very end.
740+
let mut max_history_len = 0usize;
496741
for _ in 0..600 {
497742
ext.extract(&[1.0, 1.0, 1.0, 1.0], &[0.25, 0.25, 0.25, 0.25]);
743+
max_history_len = max_history_len.max(ext.history_len());
744+
for (i, &v) in ext.filtered_history.iter().enumerate() {
745+
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
746+
}
498747
}
499-
assert!(ext.history_len() > 0, "history should accumulate");
500-
for (i, &v) in ext.filtered_history.iter().enumerate() {
501-
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
502-
}
748+
assert!(
749+
max_history_len > 0,
750+
"history should have accumulated samples at some point during the run"
751+
);
503752
}
504753
}

0 commit comments

Comments
 (0)