From 78e64a49c29a39d87faf3cd1c345b98ea5de7243 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:30:32 +0530 Subject: [PATCH 1/3] Select ann2rr intervals by time in the record The start_time and stop_time arguments of ann2rr were compared with the length of each interval rather than with its position in the record, so start_time=0.9 dropped every interval shorter than 0.9 seconds. Select annotations between start_time and stop_time instead, as the WFDB ann2rr program's -f and -t options do, and measure the first interval from start_time. Fixes #617. --- docs/changes.rst | 3 +++ src/wfdb/processing/hr.py | 22 +++++++++++++++------- tests/test_hr.py | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index db184f8d..75fb94d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -20,6 +20,9 @@ Unreleased **Add timeouts to requests for remote files** Requests for files on PhysioNet (and other web servers) now time out if the server does not respond (10 seconds to connect, and 60 seconds to wait for data), instead of waiting indefinitely. A ``requests.exceptions.Timeout`` (``ConnectTimeout`` or ``ReadTimeout``) is raised in this case. +**Select intervals by time in the record in ann2rr** + The ``start_time`` and ``stop_time`` arguments of ``ann2rr`` now select intervals by when they end in the record, as the WFDB ``ann2rr`` program's ``-f`` and ``-t`` options do, and the first interval is measured from ``start_time``. They used to filter intervals by their length instead. + **Fix smoothing of frames with invalid samples** When a signal with more than one sample per frame is read with ``smooth_frames=True`` (the default), a frame that contains an invalid sample now gives an invalid value (NaN in physical units), as in the WFDB library. The invalid sample value used to be averaged with the other samples, which gave a large but valid-looking value. diff --git a/src/wfdb/processing/hr.py b/src/wfdb/processing/hr.py index aabce0b7..aad3ac4e 100644 --- a/src/wfdb/processing/hr.py +++ b/src/wfdb/processing/hr.py @@ -198,9 +198,12 @@ def ann2rr( directory from which to find the required annotation file. eg. For record '100' in 'http://physionet.org/content/mitdb': pn_dir='mitdb'. start_time : float, optional - The time to start the intervals in seconds. + The time in the record, in seconds, from which to start. Only + intervals ending at or after this time are returned, and the first + interval is measured from this time. stop_time : float, optional - The time to stop the intervals in seconds. + The time in the record, in seconds, at which to stop. Only + intervals ending at or before this time are returned. format : str, optional Print intervals in the specified format. By default, intervals are printed in units of sample intervals. Other formats include @@ -243,15 +246,20 @@ def ann2rr( if not all_annotations: samples = samples[np.isin(ann.label_store, np.flatnonzero(is_qrs))] + # Select annotations by their time in the record, as WFDB's ann2rr -f + # and -t do, and measure the first interval from the start time. + start = 0 + if start_time is not None: + start = round(start_time * ann.fs) + samples = samples[samples >= start] + if stop_time is not None: + samples = samples[samples <= round(stop_time * ann.fs)] + rr_interval = calc_rr(samples, fs=ann.fs) if samples.size: - rr_interval = np.insert(rr_interval, 0, samples[0]) + rr_interval = np.insert(rr_interval, 0, samples[0] - start) time_interval = rr_interval / ann.fs - if start_time is not None: - time_interval = time_interval[(time_interval > start_time).astype(bool)] - if stop_time is not None: - time_interval = time_interval[(time_interval < stop_time).astype(bool)] # Already given in seconds (format == 's') if format == "s": diff --git a/tests/test_hr.py b/tests/test_hr.py index b0f3b37b..64f49f9c 100644 --- a/tests/test_hr.py +++ b/tests/test_hr.py @@ -117,3 +117,22 @@ def test_ann2rr_sample_record(): intervals = processing.ann2rr("sample-data/100", "atr") np.testing.assert_array_equal(intervals[:3], [77, 293, 292]) assert len(intervals) == 2273 + + +@pytest.mark.parametrize( + "start_time, stop_time, expected", + [ + (None, None, [100, 100, 100]), + (1.5, None, [50, 100]), + (None, 2.5, [100, 100]), + (1.5, 3.0, [50, 100]), + (2.0, 3.0, [0, 100]), + (3.5, None, []), + ], +) +def test_ann2rr_time_range(mixed_annotations, start_time, stop_time, expected): + # Beats are at 1, 2 and 3 seconds into the record. + intervals = processing.ann2rr( + mixed_annotations, "atr", start_time=start_time, stop_time=stop_time + ) + np.testing.assert_array_equal(intervals, expected) From 5a1e0ec60f376ef16778b00409739767110c90af Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:37:24 +0530 Subject: [PATCH 2/3] Move ann2rr changelog entry next to the other ann2rr entry --- docs/changes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 75fb94d3..b531480e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -20,9 +20,6 @@ Unreleased **Add timeouts to requests for remote files** Requests for files on PhysioNet (and other web servers) now time out if the server does not respond (10 seconds to connect, and 60 seconds to wait for data), instead of waiting indefinitely. A ``requests.exceptions.Timeout`` (``ConnectTimeout`` or ``ReadTimeout``) is raised in this case. -**Select intervals by time in the record in ann2rr** - The ``start_time`` and ``stop_time`` arguments of ``ann2rr`` now select intervals by when they end in the record, as the WFDB ``ann2rr`` program's ``-f`` and ``-t`` options do, and the first interval is measured from ``start_time``. They used to filter intervals by their length instead. - **Fix smoothing of frames with invalid samples** When a signal with more than one sample per frame is read with ``smooth_frames=True`` (the default), a frame that contains an invalid sample now gives an invalid value (NaN in physical units), as in the WFDB library. The invalid sample value used to be averaged with the other samples, which gave a large but valid-looking value. @@ -83,6 +80,9 @@ Unreleased **Use only beat annotations in ann2rr** ``ann2rr`` now computes intervals between beat (QRS) annotations only, as the WFDB ``ann2rr`` program does, so rhythm changes, comments and other non-beat annotations no longer split an RR interval in two. The first interval is still measured from the start of the record. To include all annotations as before, pass ``all_annotations=True`` (the equivalent of ``ann2rr -A``). +**Select intervals by time in the record in ann2rr** + The ``start_time`` and ``stop_time`` arguments of ``ann2rr`` now select intervals by when they end in the record, as the WFDB ``ann2rr`` program's ``-f`` and ``-t`` options do, and the first interval is measured from ``start_time``. They used to filter intervals by their length instead. + **Include the right edge of the window in find_local_peaks** ``find_local_peaks`` now compares each sample with ``radius`` samples on both sides, as documented, and is about 3 times faster. The sample ``radius`` positions to the right, and the last sample of the signal, used to be left out. ``XQRS`` uses ``find_local_peaks`` to find candidate peaks, so its detections may change slightly. From 58241a3db4a8a2949a2b89d362f6bd7d8d388dc4 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:22:34 +0530 Subject: [PATCH 3/3] Reject start_time after stop_time in ann2rr and round halves up Suggested in review: raise a ValueError instead of returning nothing when the range is reversed, and round half a sample up as the WFDB library does. --- src/wfdb/processing/hr.py | 15 ++++++++++++--- tests/test_hr.py | 7 +++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/wfdb/processing/hr.py b/src/wfdb/processing/hr.py index aad3ac4e..4621346d 100644 --- a/src/wfdb/processing/hr.py +++ b/src/wfdb/processing/hr.py @@ -221,6 +221,11 @@ def ann2rr( The intervals in the requested units, or None when printing. An empty array is returned if no annotations are selected. + Raises + ------ + ValueError + If `start_time` is after `stop_time`. + Examples -------- >>> from wfdb import processing @@ -232,6 +237,9 @@ def ann2rr( 257 """ + if start_time is not None and stop_time is not None and start_time > stop_time: + raise ValueError("start_time must not be after stop_time") + if (pn_dir is not None) and ("." not in pn_dir): dir_list = pn_dir.split("/") pn_dir = posixpath.join( @@ -247,13 +255,14 @@ def ann2rr( samples = samples[np.isin(ann.label_store, np.flatnonzero(is_qrs))] # Select annotations by their time in the record, as WFDB's ann2rr -f - # and -t do, and measure the first interval from the start time. + # and -t do, and measure the first interval from the start time. Halves + # round up, as in the WFDB library. start = 0 if start_time is not None: - start = round(start_time * ann.fs) + start = int(start_time * ann.fs + 0.5) samples = samples[samples >= start] if stop_time is not None: - samples = samples[samples <= round(stop_time * ann.fs)] + samples = samples[samples <= int(stop_time * ann.fs + 0.5)] rr_interval = calc_rr(samples, fs=ann.fs) if samples.size: diff --git a/tests/test_hr.py b/tests/test_hr.py index 64f49f9c..98c6f4c2 100644 --- a/tests/test_hr.py +++ b/tests/test_hr.py @@ -128,6 +128,8 @@ def test_ann2rr_sample_record(): (1.5, 3.0, [50, 100]), (2.0, 3.0, [0, 100]), (3.5, None, []), + # Half a sample rounds up, as in WFDB. + (0.125, None, [87, 100, 100]), ], ) def test_ann2rr_time_range(mixed_annotations, start_time, stop_time, expected): @@ -136,3 +138,8 @@ def test_ann2rr_time_range(mixed_annotations, start_time, stop_time, expected): mixed_annotations, "atr", start_time=start_time, stop_time=stop_time ) np.testing.assert_array_equal(intervals, expected) + + +def test_ann2rr_start_after_stop(mixed_annotations): + with pytest.raises(ValueError, match="start_time"): + processing.ann2rr(mixed_annotations, "atr", start_time=2, stop_time=1)