diff --git a/doc/changes/dev/14004.bugfix.rst b/doc/changes/dev/14004.bugfix.rst new file mode 100644 index 00000000000..52434aaea21 --- /dev/null +++ b/doc/changes/dev/14004.bugfix.rst @@ -0,0 +1 @@ +When creating :class:`mne.Epochs`, a warning is now emitted if any ``events`` have sample numbers outside the recorded data, since the corresponding epochs are otherwise silently dropped; this is configurable via the new ``on_outside`` parameter (``'raise'`` | ``'warn'`` | ``'ignore'``), by `Cedric Conday`_. diff --git a/mne/epochs.py b/mne/epochs.py index 442359f3d65..04231f73dae 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -418,6 +418,13 @@ class BaseEpochs( .. versionadded:: 0.16 %(event_repeated_epochs)s + on_outside : 'raise' | 'warn' | 'ignore' + What to do if an event's sample number falls outside the recorded data + range. Such events yield no epoch and are dropped. Can be ``'raise'`` + to raise an error, ``'warn'`` (default) to emit a warning, or + ``'ignore'`` to do nothing. + + .. versionadded:: 1.13 %(raw_sfreq)s annotations : instance of mne.Annotations | None Annotations to set. @@ -462,6 +469,7 @@ def __init__( filename=None, metadata=None, event_repeated="error", + on_outside="warn", *, raw_sfreq=None, annotations=None, @@ -575,6 +583,7 @@ def __init__( self.detrend = detrend self._raw = raw + self._oob_check(on_outside) info._check_consistency() self.picks = _picks_to_idx( info, picks, none="all", exclude=(), allow_empty=False @@ -691,6 +700,27 @@ def __init__( self._check_consistency() self.set_annotations(annotations, on_missing="ignore") + def _oob_check(self, on_outside): + """Warn when event sample numbers fall outside recorded data (gh-12989).""" + if ( + self._raw is not None + and len(self.events) > 0 + and hasattr(self._raw, "first_samp") + ): + lo = self._raw.first_samp + hi = lo + self._raw.n_times + n_oob = int(((self.events[:, 0] < lo) | (self.events[:, 0] >= hi)).sum()) + if n_oob: + _on_missing( + on_outside, + f"{n_oob} event{_pl(n_oob)} {'has' if n_oob == 1 else 'have'} a " + "sample number outside the recorded data; the corresponding " + f"epoch{_pl(n_oob)} will be dropped. This can happen if the " + "events were created at a different sampling frequency, or " + "contain sample numbers before first_samp.", + name="on_outside", + ) + def _check_consistency(self): """Check invariants of epochs object.""" if hasattr(self, "events"): @@ -3487,6 +3517,13 @@ class Epochs(BaseEpochs): .. versionadded:: 0.16 %(event_repeated_epochs)s + on_outside : 'raise' | 'warn' | 'ignore' + What to do if an event's sample number falls outside the recorded data + range. Such events yield no epoch and are dropped. Can be ``'raise'`` + to raise an error, ``'warn'`` (default) to emit a warning, or + ``'ignore'`` to do nothing. + + .. versionadded:: 1.13 %(verbose)s Attributes @@ -3587,6 +3624,8 @@ def __init__( reject_by_annotation=True, metadata=None, event_repeated="error", + *, + on_outside="warn", verbose=None, ): from .io import BaseRaw @@ -3646,6 +3685,7 @@ def __init__( on_missing=on_missing, preload_at_end=preload, event_repeated=event_repeated, + on_outside=on_outside, verbose=verbose, raw_sfreq=raw_sfreq, annotations=annotations, @@ -3728,6 +3768,13 @@ class EpochsArray(BaseEpochs): .. versionadded:: 0.16 %(selection)s + on_outside : 'raise' | 'warn' | 'ignore' + What to do if an event's sample number falls outside the recorded data + range. Such events yield no epoch and are dropped. Can be ``'raise'`` + to raise an error, ``'warn'`` (default) to emit a warning, or + ``'ignore'`` to do nothing. + + .. versionadded:: 1.13 %(drop_log)s .. versionadded:: 1.3 @@ -3777,6 +3824,7 @@ def __init__( metadata=None, selection=None, *, + on_outside="warn", drop_log=None, raw_sfreq=None, verbose=None, @@ -3813,6 +3861,7 @@ def __init__( selection=selection, proj=proj, on_missing=on_missing, + on_outside=on_outside, drop_log=drop_log, raw_sfreq=raw_sfreq, verbose=verbose, diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py index 7ca5bfc31e3..ec53f214a7d 100644 --- a/mne/preprocessing/tests/test_ica.py +++ b/mne/preprocessing/tests/test_ica.py @@ -188,7 +188,9 @@ def test_warnings(): """Test that ICA warns on certain input data conditions.""" raw = read_raw_fif(raw_fname).crop(0, 5).load_data() events = read_events(event_name) - epochs = Epochs(raw, events=events, baseline=None, preload=True) + epochs = Epochs( + raw, events=events, baseline=None, preload=True, on_outside="ignore" + ) ica = ICA(n_components=2, max_iter=1, method="infomax", random_state=0) # not high-passed @@ -1348,6 +1350,7 @@ def test_eog_channel(method): baseline=None, preload=True, proj=False, + on_outside="ignore", ) n_components = 0.9 ica = ICA(n_components=n_components, method=method) diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py index 2eb55d8ed62..9412a2ae8ea 100644 --- a/mne/tests/test_epochs.py +++ b/mne/tests/test_epochs.py @@ -3,6 +3,7 @@ # Copyright the MNE-Python contributors. import pickle +import warnings from copy import deepcopy from datetime import timedelta from functools import partial @@ -5278,3 +5279,25 @@ def test_empty_error(method, epochs_empty): pytest.importorskip("pandas") with pytest.raises(RuntimeError, match="is empty."): getattr(epochs_empty.copy(), method[0])(**method[1]) + + +def test_epochs_warn_out_of_bounds_events(): + """Warn when event sample numbers fall outside the recorded data (gh-12989).""" + sfreq = 100.0 + info = create_info(3, sfreq, "eeg") + raw = RawArray(np.random.default_rng(0).standard_normal((3, 1000)), info) + oob = np.array([[2000, 0, 1]]) + # event sample 2000 is past the 1000-sample recording -> warn (default) + with pytest.warns(RuntimeWarning, match="outside the recorded data"): + mne.Epochs(raw, oob, tmin=-0.2, tmax=0.5, baseline=None) + # on_outside="raise" turns it into an error + with pytest.raises(ValueError, match="outside the recorded data"): + mne.Epochs(raw, oob, tmin=-0.2, tmax=0.5, baseline=None, on_outside="raise") + # on_outside="ignore" stays silent + with warnings.catch_warnings(): + warnings.simplefilter("error") + mne.Epochs(raw, oob, tmin=-0.2, tmax=0.5, baseline=None, on_outside="ignore") + # an in-range event whose epoch window merely clips the edge must NOT warn + with warnings.catch_warnings(): + warnings.simplefilter("error") + mne.Epochs(raw, np.array([[990, 0, 1]]), tmin=0, tmax=0.5, baseline=None)