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."""