diff --git a/doc/changes/dev/14067.bugfix.rst b/doc/changes/dev/14067.bugfix.rst new file mode 100644 index 00000000000..6fbd0cff41d --- /dev/null +++ b/doc/changes/dev/14067.bugfix.rst @@ -0,0 +1 @@ +Fix :meth:`mne.Epochs.export` producing invalid EEGLAB event latencies and dummy events after epochs were dropped, by :newcontrib:`Daria Agafonova`. diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 114cfb6999e..ed7305e9eae 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -72,6 +72,7 @@ .. _Daniel McCloy: https://dan.mccloy.info .. _Daniel Strohmeier: https://github.com/joewalter .. _Daniel Tse: https://github.com/Xiezhibin +.. _Daria Agafonova: https://github.com/viranovskaya .. _Darin Erat Sleiter: https://github.com/dsleiter .. _David Haslacher: https://github.com/davidhaslacher .. _David Julien: https://github.com/Swy7ch diff --git a/mne/export/_eeglab.py b/mne/export/_eeglab.py index c73243b0311..094e5e50440 100644 --- a/mne/export/_eeglab.py +++ b/mne/export/_eeglab.py @@ -66,16 +66,24 @@ def _export_epochs(fname, epochs): else: annot = None + # eeglabio uses column 0 as latency in the concatenated epochs array and + # column 2 as event type; the original recording sample is not stored here. + events = epochs.events.copy() + events[:, 0] = ( + np.arange(len(epochs)) * len(epochs.times) + epochs.time_as_index(0)[0] + ) + # https://github.com/jackz314/eeglabio/pull/18 kwargs = dict() if "epoch_indices" in getfullargspec(eeglabio.epochs.export_set).kwonlyargs: - kwargs["epoch_indices"] = epochs.selection + # This also handles zero time falling on the final sample (tmax=0). + kwargs["epoch_indices"] = np.arange(1, len(epochs) + 1) eeglabio.epochs.export_set( fname, data=epochs.get_data(picks=ch_names), sfreq=epochs.info["sfreq"], - events=epochs.events, + events=events, tmin=epochs.tmin, tmax=epochs.tmax, ch_names=ch_names, diff --git a/mne/export/tests/test_export.py b/mne/export/tests/test_export.py index 4651123d499..7c21624fc7d 100644 --- a/mne/export/tests/test_export.py +++ b/mne/export/tests/test_export.py @@ -15,6 +15,7 @@ from mne import ( Annotations, Epochs, + EpochsArray, create_info, read_epochs_eeglab, read_evokeds, @@ -560,7 +561,10 @@ def test_export_epochs_eeglab(tmp_path, preload): cart_coords = np.array([d["loc"][:3] for d in epochs.info["chs"]]) # just xyz cart_coords_read = np.array([d["loc"][:3] for d in epochs_read.info["chs"]]) assert_allclose(cart_coords, cart_coords_read) - assert_array_equal(epochs.events[:, 0], epochs_read.events[:, 0]) # latency + event_samples = ( + np.arange(len(epochs)) * len(epochs.times) + epochs.time_as_index(0)[0] + ) + assert_array_equal(event_samples, epochs_read.events[:, 0]) assert epochs.event_id.keys() == epochs_read.event_id.keys() # just keys assert_allclose(epochs.times, epochs_read.times) assert_allclose(epochs.get_data(), epochs_read.get_data()) @@ -583,6 +587,43 @@ def test_export_epochs_eeglab(tmp_path, preload): epochs.export(Path(temp_fname), overwrite=True) +def test_export_epochs_eeglab_after_drop(tmp_path): + """Test EEGLAB event mapping after dropping epochs with tmax=0.""" + pytest.importorskip("eeglabio", minversion="0.1.2") + sfreq = 100.0 + n_epochs, n_times = 5, 11 + info = create_info(["Cz"], sfreq, "eeg") + data = np.zeros((n_epochs, 1, n_times)) + events = np.column_stack( + ( + np.arange(1, n_epochs + 1) * 100, + np.zeros(n_epochs, int), + [1, 2, 3, 4, 5], + ) + ) + event_id = {f"event-{code}": code for code in events[:, 2]} + epochs = EpochsArray( + data, info, events=events, event_id=event_id, tmin=-0.1, verbose=False + ) + assert epochs.tmax == 0 + epochs.drop([1, 3]) + assert_array_equal(epochs.selection, [0, 2, 4]) + + fname = tmp_path / "dropped.set" + epochs.export(fname) + epochs_read = read_epochs_eeglab(fname, verbose="error") + + event_samples = ( + np.arange(len(epochs)) * len(epochs.times) + epochs.time_as_index(0)[0] + ) + assert_array_equal(event_samples, epochs_read.events[:, 0]) + kept_codes = set(epochs.events[:, 2]) + kept_names = {name for name, code in epochs.event_id.items() if code in kept_codes} + assert set(epochs_read.event_id) == kept_names + assert_allclose(epochs.times, epochs_read.times) + assert_allclose(epochs.get_data(), epochs_read.get_data()) + + @testing.requires_testing_data @pytest.mark.parametrize("fmt", ("auto", "mff")) @pytest.mark.parametrize("do_history", (True, False))