From e998472c822ffbc2ab1e75cb518c8c8f0bd808de Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:37:27 +0530 Subject: [PATCH 1/2] Plot annotations outside the signal at zero plot_items and plot_wfdb raised an exception for annotations after the end of the signal, suggesting shift_samps=True, and plotted annotations before the start at values taken from the end of the signal through negative indexing. Plot both at zero instead, as is already done for annotation channels without a signal. Fixes #344. --- docs/changes.rst | 3 +++ src/wfdb/plot/plot.py | 26 +++++++++++--------------- tests/test_plot.py | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index db184f8d..66523a94 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -74,6 +74,9 @@ Unreleased **Allow None for annotations without notes in wrann** ``wrann`` now accepts ``None`` in ``aux_note`` for annotations without a note, as the docstring says, and writes it the same way as an empty string. It used to raise a ``TypeError``. +**Plot annotations outside the signal** + ``plot_items`` and ``plot_wfdb`` now plot annotations before the start or after the end of the signal at zero, instead of raising an exception for annotations after the end and plotting annotations before the start at values taken from the end of the signal. + **Read and write .wfdb archive files** A record can be stored in a single ``.wfdb`` archive file (a ZIP file containing the header and signal files). ``rdrecord``, ``rdsamp``, ``rdheader`` and ``rdann`` read a record or its annotations from an archive when given the path of the archive, and ``wrsamp`` writes a record into an archive with the new ``wfdb_archive`` argument. diff --git a/src/wfdb/plot/plot.py b/src/wfdb/plot/plot.py index 3fd6336a..a1ff450e 100644 --- a/src/wfdb/plot/plot.py +++ b/src/wfdb/plot/plot.py @@ -152,7 +152,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 +585,15 @@ 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: + index = (sfreq / afreq * index).astype("int") + inside = (index >= 0) & (index < len(signal[ch])) + y[inside] = signal[ch][index[inside]] 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..ccf2b10f 100644 --- a/tests/test_plot.py +++ b/tests/test_plot.py @@ -130,6 +130,21 @@ 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") + 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]) + + class TestPlotInternal: """Unit tests for internal wfdb.plot.plot functions""" From 3ad33490f233720d3f5b0f5dbcd3b4b53f5e04bc Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:23:45 +0530 Subject: [PATCH 2/2] Warn about annotations outside the signal and floor resampled indices Suggested in review: warn when annotations fall outside the signal, pointing to shift_samps=True, and round resampled indices down so negative samples stay outside the signal. --- src/wfdb/plot/plot.py | 11 ++++++++++- tests/test_plot.py | 42 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/wfdb/plot/plot.py b/src/wfdb/plot/plot.py index a1ff450e..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 @@ -591,9 +592,17 @@ def _plot_annotation( if n_sig > ch: index = np.asarray(ann_samp[ch]) if sfreq != afreq: - index = (sfreq / afreq * index).astype("int") + # 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 ccf2b10f..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 @@ -133,18 +136,45 @@ 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") - fig = wfdb.plot_items( - signal, - ann_samp=[np.array([-5, 10, 99, 100, 250])], - ann_sym=[["a", "b", "c", "d", "e"]], - return_fig=True, - ) + 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"""