@@ -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+
5273impl 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