From 08ceab4f166046c4a036db975cc77cff76fef40d Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:24:42 +0530 Subject: [PATCH 1/2] Document and test what the XQRS backsearch skip relies on The skip added in #596 is only correct while _update_noise leaves qrs_thr alone and _update_qrs moves last_qrs_peak_num to the current peak. Name both in the comment and add a test that fails if either changes. --- src/wfdb/processing/qrs.py | 7 ++++--- tests/test_processing.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/wfdb/processing/qrs.py b/src/wfdb/processing/qrs.py index 022ac219..38c7f255 100644 --- a/src/wfdb/processing/qrs.py +++ b/src/wfdb/processing/qrs.py @@ -610,9 +610,10 @@ def _backsearch(self): """ if self.last_qrs_peak_num is not None: - # Skip peaks a previous backsearch already rejected. Their result - # can only change after a QRS update, which moves - # last_qrs_peak_num past them. + # Skip peaks a previous backsearch already rejected. A backsearch result + # depends only on qrs_thr and last_qrs_ind, which change only in + # _update_qrs, and _update_qrs moves last_qrs_peak_num to the current + # peak. If either of these stops being true, remove this skip. start = max(self.last_qrs_peak_num, self._backsearch_peak_num) + 1 for peak_num in range(start, self.peak_num + 1): if self._is_qrs(peak_num=peak_num, backsearch=True): diff --git a/tests/test_processing.py b/tests/test_processing.py index fe1ca4c9..141d24b6 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -221,6 +221,39 @@ def spy(peak_num, backsearch=False): n_after = np.sum(xqrs.qrs_inds > 150 * fs) assert n_before > 30 and n_after > 30 + def test_xqrs_backsearch_skip_assumptions(self): + """The backsearch skip relies on how XQRS updates its state (#597).""" + sig, fields = wfdb.rdsamp("sample-data/100", channels=[0]) + fs = fields["fs"] + x = sig[:, 0] + rng = np.random.default_rng(0) + gap = 0.01 * rng.standard_normal(120 * fs) + # Scaled-down beats after the gap are only found by backsearch + y = np.concatenate([x[: 30 * fs], gap, 0.4 * x[30 * fs : 60 * fs]]) + + xqrs = processing.XQRS(sig=y, fs=fs) + update_noise = xqrs._update_noise + update_qrs = xqrs._update_qrs + + def noise_spy(peak_num): + qrs_thr = xqrs.qrs_thr + update_noise(peak_num) + # A noise update must not change the result of a backsearch + assert xqrs.qrs_thr == qrs_thr + + def qrs_spy(peak_num, backsearch=False): + update_qrs(peak_num, backsearch=backsearch) + # Peaks up to the current one count as already searched + assert xqrs.last_qrs_peak_num == xqrs.peak_num + + with ( + mock.patch.object(xqrs, "_update_noise", side_effect=noise_spy), + mock.patch.object(xqrs, "_update_qrs", side_effect=qrs_spy), + ): + xqrs.detect(verbose=False) + + assert len(xqrs.backsearch_qrs_inds) > 0 + class TestXQRSDetect: """Tests for the xqrs_detect convenience wrapper.""" From e531bffc718cab6809b19f4afb122735c2e548f7 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:50:21 +0530 Subject: [PATCH 2/2] Also check last_qrs_ind in the noise update test --- tests/test_processing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_processing.py b/tests/test_processing.py index 141d24b6..2641b20b 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -236,10 +236,10 @@ def test_xqrs_backsearch_skip_assumptions(self): update_qrs = xqrs._update_qrs def noise_spy(peak_num): - qrs_thr = xqrs.qrs_thr + qrs_thr, last_qrs_ind = xqrs.qrs_thr, xqrs.last_qrs_ind update_noise(peak_num) # A noise update must not change the result of a backsearch - assert xqrs.qrs_thr == qrs_thr + assert (xqrs.qrs_thr, xqrs.last_qrs_ind) == (qrs_thr, last_qrs_ind) def qrs_spy(peak_num, backsearch=False): update_qrs(peak_num, backsearch=backsearch)