Skip to content

Commit a4f4a14

Browse files
authored
Fix rr2ann with times and with a symbol column (#612)
`rr2ann` has two problems: ```python import numpy as np, wfdb from wfdb.processing.hr import rr2ann rr2ann(np.array([0.29, 0.57, 1.13]), "t", "atr", fs=100, as_time=True) wfdb.rdann("t", "atr").sample # array([ 28, 56, 112]) (expected [29, 57, 113]) rr2ann(np.array([[4, 17], ["V", "N"]]).T, "t", "atr") # TypeError: the resolved dtypes are not compatible with add.accumulate. Resolved # (dtype('<U21'), dtype('<U21'), dtype('<U42')) ``` 1. With `as_time=True`, times are converted to samples with `.astype(np.int64)`, which truncates. `0.29 * 100` is `28.999999999999996`, so the annotation lands one sample early. This now rounds to the nearest sample. 2. The docstring says a second column can give the annotation symbols. That makes the array hold strings, and without `as_time` the intervals go straight into `np.cumsum`, which fails on strings. They are now converted to integers first (the `as_time` path already converted to float). Tests: times that truncate wrongly, intervals with a symbol column (both fail on `main`), and the docstring example with times and symbols. `pytest -m "not network"` passes (99 tests), and the docs build passes.
2 parents a141be1 + 641976c commit a4f4a14

3 files changed

Lines changed: 50 additions & 2 deletions

File tree

‎docs/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Unreleased
5050
**Check that QRS indices are increasing in compute_hr**
5151
``compute_hr`` now raises a ``ValueError`` if ``qrs_inds`` is not strictly increasing. Repeated beats used to give a ``ZeroDivisionError`` (or ``inf`` for NumPy arrays), and beats out of order gave a negative heart rate.
5252

53+
**Fix rr2ann with times and with a symbol column**
54+
``rr2ann`` with ``as_time=True`` now rounds times to the nearest sample instead of truncating them, so (for example) 0.29 seconds at 100 Hz gives sample 29 instead of 28. Intervals in samples with a second column of symbols no longer raise an error, and fractional intervals are summed before rounding.
55+
5356
**Use Ruff for formatting and linting**
5457
The ``dev`` extra now includes ``ruff`` instead of ``black`` and ``pylint``, and the code is formatted with a line length of 88 (the default).
5558

‎src/wfdb/processing/hr.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,12 @@ def rr2ann(rr_array, record_name, extension, fs=250, as_time=False):
305305
except IndexError:
306306
ann_sample = rr_array
307307

308+
# With a symbol column, the array holds strings, so convert the numbers.
308309
if as_time:
309-
ann_sample = (fs * ann_sample.astype(np.float64)).astype(np.int64)
310+
ann_sample = np.round(fs * ann_sample.astype(np.float64)).astype(np.int64)
310311
else:
311-
ann_sample = np.cumsum(ann_sample).astype(np.int64)
312+
# Sum as floats and round once, so fractional intervals don't drift.
313+
ann_sample = np.round(np.cumsum(ann_sample.astype(np.float64))).astype(np.int64)
312314

313315
try:
314316
ann_symbol = rr_array[:, 1].tolist()

‎tests/test_processing.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,46 @@ def fake_detect(self, *args, **kwargs):
372372

373373
assert mock_detect.call_count == 1
374374
assert mock_detect.call_args.kwargs["learn"] is False
375+
376+
377+
class TestRr2Ann:
378+
"""Tests for rr2ann."""
379+
380+
def test_times_are_rounded(self, tmp_path, monkeypatch):
381+
"""Times are rounded to the nearest sample, not truncated."""
382+
monkeypatch.chdir(tmp_path)
383+
times = np.array([0.29, 0.57, 1.13])
384+
processing.hr.rr2ann(times, "t", "atr", fs=100, as_time=True)
385+
386+
ann = wfdb.rdann("t", "atr")
387+
np.testing.assert_array_equal(ann.sample, [29, 57, 113])
388+
389+
def test_intervals_with_symbols(self, tmp_path, monkeypatch):
390+
"""Intervals with a column of symbols are summed into samples."""
391+
monkeypatch.chdir(tmp_path)
392+
rr_array = np.array([[4, 17, 18], ["V", "N", "V"]]).T
393+
processing.hr.rr2ann(rr_array, "t", "atr")
394+
395+
ann = wfdb.rdann("t", "atr")
396+
np.testing.assert_array_equal(ann.sample, [4, 21, 39])
397+
assert ann.symbol == ["V", "N", "V"]
398+
399+
def test_times_with_symbols(self, tmp_path, monkeypatch):
400+
"""Times with a column of symbols, as in the docstring example."""
401+
monkeypatch.chdir(tmp_path)
402+
rr_array = np.array([[0.2, 0.6, 1.3], ["V", "N", "V"]]).T
403+
processing.hr.rr2ann(rr_array, "t", "atr", fs=100, as_time=True)
404+
405+
ann = wfdb.rdann("t", "atr")
406+
np.testing.assert_array_equal(ann.sample, [20, 60, 130])
407+
assert ann.symbol == ["V", "N", "V"]
408+
409+
def test_fractional_intervals(self, tmp_path, monkeypatch):
410+
"""Fractional intervals are summed before rounding, so they don't drift."""
411+
monkeypatch.chdir(tmp_path)
412+
processing.hr.rr2ann(np.array([4.5, 4.5, 4.5]), "t", "atr")
413+
np.testing.assert_array_equal(wfdb.rdann("t", "atr").sample, [4, 9, 14])
414+
415+
rr_array = np.array([[4.5, 4.5, 4.5], ["V", "N", "V"]]).T
416+
processing.hr.rr2ann(rr_array, "t", "atr")
417+
np.testing.assert_array_equal(wfdb.rdann("t", "atr").sample, [4, 9, 14])

0 commit comments

Comments
 (0)