diff --git a/docs/changes.rst b/docs/changes.rst index 8c63c074..3aec3501 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -35,6 +35,9 @@ Unreleased **Check that QRS indices are increasing in compute_hr** ``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. +**Fix rr2ann with times and with a symbol column** + ``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. + **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/hr.py b/src/wfdb/processing/hr.py index 004401b0..a96bc748 100644 --- a/src/wfdb/processing/hr.py +++ b/src/wfdb/processing/hr.py @@ -305,10 +305,12 @@ def rr2ann(rr_array, record_name, extension, fs=250, as_time=False): except IndexError: ann_sample = rr_array + # With a symbol column, the array holds strings, so convert the numbers. if as_time: - ann_sample = (fs * ann_sample.astype(np.float64)).astype(np.int64) + ann_sample = np.round(fs * ann_sample.astype(np.float64)).astype(np.int64) else: - ann_sample = np.cumsum(ann_sample).astype(np.int64) + # Sum as floats and round once, so fractional intervals don't drift. + ann_sample = np.round(np.cumsum(ann_sample.astype(np.float64))).astype(np.int64) try: ann_symbol = rr_array[:, 1].tolist() diff --git a/tests/test_processing.py b/tests/test_processing.py index 60bb7bd0..84fd1cfe 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -343,3 +343,46 @@ def fake_detect(self, *args, **kwargs): assert mock_detect.call_count == 1 assert mock_detect.call_args.kwargs["learn"] is False + + +class TestRr2Ann: + """Tests for rr2ann.""" + + def test_times_are_rounded(self, tmp_path, monkeypatch): + """Times are rounded to the nearest sample, not truncated.""" + monkeypatch.chdir(tmp_path) + times = np.array([0.29, 0.57, 1.13]) + processing.hr.rr2ann(times, "t", "atr", fs=100, as_time=True) + + ann = wfdb.rdann("t", "atr") + np.testing.assert_array_equal(ann.sample, [29, 57, 113]) + + def test_intervals_with_symbols(self, tmp_path, monkeypatch): + """Intervals with a column of symbols are summed into samples.""" + monkeypatch.chdir(tmp_path) + rr_array = np.array([[4, 17, 18], ["V", "N", "V"]]).T + processing.hr.rr2ann(rr_array, "t", "atr") + + ann = wfdb.rdann("t", "atr") + np.testing.assert_array_equal(ann.sample, [4, 21, 39]) + assert ann.symbol == ["V", "N", "V"] + + def test_times_with_symbols(self, tmp_path, monkeypatch): + """Times with a column of symbols, as in the docstring example.""" + monkeypatch.chdir(tmp_path) + rr_array = np.array([[0.2, 0.6, 1.3], ["V", "N", "V"]]).T + processing.hr.rr2ann(rr_array, "t", "atr", fs=100, as_time=True) + + ann = wfdb.rdann("t", "atr") + np.testing.assert_array_equal(ann.sample, [20, 60, 130]) + assert ann.symbol == ["V", "N", "V"] + + def test_fractional_intervals(self, tmp_path, monkeypatch): + """Fractional intervals are summed before rounding, so they don't drift.""" + monkeypatch.chdir(tmp_path) + processing.hr.rr2ann(np.array([4.5, 4.5, 4.5]), "t", "atr") + np.testing.assert_array_equal(wfdb.rdann("t", "atr").sample, [4, 9, 14]) + + rr_array = np.array([[4.5, 4.5, 4.5], ["V", "N", "V"]]).T + processing.hr.rr2ann(rr_array, "t", "atr") + np.testing.assert_array_equal(wfdb.rdann("t", "atr").sample, [4, 9, 14])