From 715371f896bc00e1bd6d14b39a430072276c218a Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:05:23 +0530 Subject: [PATCH] Skip already-rejected peaks in XQRS backsearch A backsearch that finds nothing leaves last_qrs_peak_num unchanged, so the next one rescans the same peaks. Long stretches without QRS complexes were rescanned at every peak, which is quadratic. Start each backsearch after the last peak already checked; the result is unchanged because those peaks can only pass after a QRS update, which moves last_qrs_peak_num past them. --- docs/changes.rst | 3 +++ src/wfdb/processing/qrs.py | 9 ++++++++- tests/test_processing.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index fc7fc406..f27c7d49 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -20,6 +20,9 @@ Unreleased **Build with uv_build** The package is now built with ``uv_build`` instead of ``hatchling``. The version number is specified in ``pyproject.toml`` only, and ``wfdb.__version__`` is read from the installed package metadata (it is ``"unknown"`` if the package is run from a source tree without being installed). +**Speed up XQRS backsearch** + ``XQRS`` no longer rechecks peaks that an earlier backsearch already rejected. Long stretches without QRS complexes (for example a disconnected lead) made detection take quadratic time, up to hours on long records. The detected QRS locations are unchanged. + **Use Ruff for formatting and linting** The ``dev`` extra now includes ``ruff`` instead of ``black`` and ``pylint``, and the code is formatted with a line length of 88 (the default). diff --git a/src/wfdb/processing/qrs.py b/src/wfdb/processing/qrs.py index f81ec65b..022ac219 100644 --- a/src/wfdb/processing/qrs.py +++ b/src/wfdb/processing/qrs.py @@ -610,11 +610,16 @@ def _backsearch(self): """ if self.last_qrs_peak_num is not None: - for peak_num in range(self.last_qrs_peak_num + 1, self.peak_num + 1): + # Skip peaks a previous backsearch already rejected. Their result + # can only change after a QRS update, which moves + # last_qrs_peak_num past them. + 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): self._update_qrs(peak_num=peak_num, backsearch=True) # No need to update noise parameters if it was classified as # noise. It would have already been updated. + self._backsearch_peak_num = self.peak_num def _run_detection(self): """Run the QRS detection after the signals and parameters have been set. @@ -635,6 +640,8 @@ def _run_detection(self): self.qrs_inds = [] # QRS indices found via backsearch self.backsearch_qrs_inds = [] + # Last peak number checked by a backsearch + self._backsearch_peak_num = -1 # Iterate through MWI signal peak indices for self.peak_num in range(self.n_peaks_i): diff --git a/tests/test_processing.py b/tests/test_processing.py index 1d5a6379..fe1ca4c9 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -192,6 +192,35 @@ def test_xqrs(self): assert comparitor.sensitivity > 0.99 assert comparitor.positive_predictivity > 0.99 + def test_xqrs_backsearch_checks_each_peak_once(self): + """Backsearch does not recheck rejected peaks in a gap (issue #352).""" + sig, fields = wfdb.rdsamp("sample-data/100", channels=[0]) + fs = fields["fs"] + x = sig[:, 0] + # Beats, then low-amplitude noise (as from a disconnected lead), then beats + rng = np.random.default_rng(0) + gap = 0.01 * rng.standard_normal(120 * fs) + y = np.concatenate([x[: 30 * fs], gap, x[30 * fs : 60 * fs]]) + + xqrs = processing.XQRS(sig=y, fs=fs) + checked = [] + is_qrs = xqrs._is_qrs + + def spy(peak_num, backsearch=False): + if backsearch: + checked.append(peak_num) + return is_qrs(peak_num, backsearch=backsearch) + + with mock.patch.object(xqrs, "_is_qrs", side_effect=spy): + xqrs.detect(verbose=False) + + assert len(checked) > 0 + assert len(checked) == len(set(checked)) + # Beats on both sides of the gap are still found + n_before = np.sum(xqrs.qrs_inds < 30 * fs) + n_after = np.sum(xqrs.qrs_inds > 150 * fs) + assert n_before > 30 and n_after > 30 + class TestXQRSDetect: """Tests for the xqrs_detect convenience wrapper."""