diff --git a/changelog.d/plot-annotations-outside.rst b/changelog.d/plot-annotations-outside.rst new file mode 100644 index 00000000..7649bda0 --- /dev/null +++ b/changelog.d/plot-annotations-outside.rst @@ -0,0 +1,2 @@ +**Plot annotations outside the signal** + ``plot_items`` and ``plot_wfdb`` no longer fail with an ``IndexError`` when an annotation is after the end of the signal, and no longer draw annotations before the start of the signal at values from its end. These annotations are now drawn at zero, as for channels without a signal, with a warning suggesting ``shift_samps=True`` in ``rdann``. diff --git a/src/wfdb/plot/plot.py b/src/wfdb/plot/plot.py index 3fd6336a..8bb9c481 100644 --- a/src/wfdb/plot/plot.py +++ b/src/wfdb/plot/plot.py @@ -1,4 +1,5 @@ import os +import warnings import numpy as np @@ -152,7 +153,8 @@ def plot_items( If `signal` is defined, the annotation locations will be overlaid on the signals, with the list index corresponding to the signal channel. The length of `annotation` does not have to match the number of - channels of `signal`. + channels of `signal`. Annotations before the start or after the end + of the signal are plotted at zero. ann_sym: list, optional A list of annotation symbols to plot, with each list item corresponding to a different channel. List items should be lists of @@ -584,20 +586,23 @@ def _plot_annotation( if ann_samp[ch] is not None and len(ann_samp[ch]): # Figure out the y values to plot on a channel basis - # 1 dimensional signals - try: - if n_sig > ch: - if sfreq == afreq: - index = ann_samp[ch] - else: - index = (sfreq / afreq * ann_samp[ch]).astype("int") - y = signal[ch][index] - else: - y = np.zeros(len(ann_samp[ch])) - except IndexError: - raise Exception( - 'IndexError: try setting shift_samps=True in the "rdann" function?' - ) + # Annotations outside the signal are plotted at zero, like + # annotations for channels without a signal. + y = np.zeros(len(ann_samp[ch])) + if n_sig > ch: + index = np.asarray(ann_samp[ch]) + if sfreq != afreq: + # floor, so negative samples stay outside the signal + index = np.floor(sfreq / afreq * index).astype("int") + inside = (index >= 0) & (index < len(signal[ch])) + y[inside] = signal[ch][index[inside]] + if not inside.all(): + warnings.warn( + "Some annotations are outside the signal and are plotted " + 'at zero. If the annotations were read with "sampfrom", ' + 'try setting shift_samps=True in "rdann".', + stacklevel=3, + ) axes[ch].plot(ann_samp[ch] / downsample_factor, y, ann_style[ch], zorder=4) diff --git a/tests/test_plot.py b/tests/test_plot.py index 22dc5f6f..b772eed9 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -1,5 +1,8 @@ +import warnings + import matplotlib.pyplot as plt import numpy as np +import pytest import wfdb from wfdb.plot import plot @@ -130,6 +133,48 @@ def test_digital_multifrequency(self): ) +def test_annotations_outside_signal(): + """Plot annotations before and after the signal at zero""" + signal = np.arange(1, 101, dtype="float64") + with pytest.warns(UserWarning, match="shift_samps"): + fig = wfdb.plot_items( + signal, + ann_samp=[np.array([-5, 10, 99, 100, 250])], + ann_sym=[["a", "b", "c", "d", "e"]], + return_fig=True, + ) + plt.close(fig) + + y = fig.axes[0].lines[1].get_ydata() + np.testing.assert_array_equal(y, [0, 11, 100, 0, 0]) + + +def test_annotations_outside_signal_frequencies(): + """Map annotations to the signal frequency before checking the bounds""" + signal = np.arange(1, 101, dtype="float64") + with pytest.warns(UserWarning, match="shift_samps"): + fig = wfdb.plot_items( + signal, + ann_samp=[np.array([-1, 0, 199, 200])], + sampling_freq=180, + ann_freq=360, + return_fig=True, + ) + plt.close(fig) + + y = fig.axes[0].lines[1].get_ydata() + np.testing.assert_array_equal(y, [0, 1, 100, 0]) + + +def test_annotations_inside_signal_no_warning(): + """Do not warn when all annotations are inside the signal""" + signal = np.arange(1, 101, dtype="float64") + with warnings.catch_warnings(): + warnings.simplefilter("error") + fig = wfdb.plot_items(signal, ann_samp=[np.array([0, 50, 99])], return_fig=True) + plt.close(fig) + + class TestPlotInternal: """Unit tests for internal wfdb.plot.plot functions"""