From fb69aa97a94399021ae3f40888c1612586a02926 Mon Sep 17 00:00:00 2001 From: Daria Agafonova Date: Sat, 18 Jul 2026 12:43:31 +0200 Subject: [PATCH 1/3] Fix EEGLAB events after dropping epochs --- doc/changes/dev/13535.bugfix.rst | 1 + doc/changes/names.inc | 1 + mne/export/_eeglab.py | 10 ++++++-- mne/export/tests/test_export.py | 42 +++++++++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 doc/changes/dev/13535.bugfix.rst diff --git a/doc/changes/dev/13535.bugfix.rst b/doc/changes/dev/13535.bugfix.rst new file mode 100644 index 00000000000..6fbd0cff41d --- /dev/null +++ b/doc/changes/dev/13535.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..1fb8e881ce0 100644 --- a/mne/export/_eeglab.py +++ b/mne/export/_eeglab.py @@ -66,16 +66,22 @@ def _export_epochs(fname, epochs): else: annot = None + # EEGLAB stores event latencies in the concatenated epochs array. + 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 + 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..dc479e827ad 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,42 @@ 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.""" + pytest.importorskip("eeglabio", minversion="0.1.2") + sfreq = 100.0 + n_epochs, n_times = 5, 31 + 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 + ) + 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)) From e22d06817f329d020f069c4e356f64c10f2206b7 Mon Sep 17 00:00:00 2001 From: Daria Agafonova Date: Sat, 18 Jul 2026 17:20:16 +0200 Subject: [PATCH 2/3] Clarify EEGLAB epoch event mapping --- mne/export/_eeglab.py | 4 +++- mne/export/tests/test_export.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mne/export/_eeglab.py b/mne/export/_eeglab.py index 1fb8e881ce0..094e5e50440 100644 --- a/mne/export/_eeglab.py +++ b/mne/export/_eeglab.py @@ -66,7 +66,8 @@ def _export_epochs(fname, epochs): else: annot = None - # EEGLAB stores event latencies in the concatenated epochs array. + # 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] @@ -75,6 +76,7 @@ def _export_epochs(fname, epochs): # https://github.com/jackz314/eeglabio/pull/18 kwargs = dict() if "epoch_indices" in getfullargspec(eeglabio.epochs.export_set).kwonlyargs: + # 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( diff --git a/mne/export/tests/test_export.py b/mne/export/tests/test_export.py index dc479e827ad..7c21624fc7d 100644 --- a/mne/export/tests/test_export.py +++ b/mne/export/tests/test_export.py @@ -588,10 +588,10 @@ def test_export_epochs_eeglab(tmp_path, preload): def test_export_epochs_eeglab_after_drop(tmp_path): - """Test EEGLAB event mapping after dropping epochs.""" + """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, 31 + n_epochs, n_times = 5, 11 info = create_info(["Cz"], sfreq, "eeg") data = np.zeros((n_epochs, 1, n_times)) events = np.column_stack( @@ -605,6 +605,7 @@ def test_export_epochs_eeglab_after_drop(tmp_path): 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]) From 2554ab3a16d207d6737ee1c44872224fbb8963f3 Mon Sep 17 00:00:00 2001 From: Daria Agafonova Date: Sat, 18 Jul 2026 18:15:30 +0200 Subject: [PATCH 3/3] Fix changelog entry number --- doc/changes/dev/{13535.bugfix.rst => 14067.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/changes/dev/{13535.bugfix.rst => 14067.bugfix.rst} (100%) diff --git a/doc/changes/dev/13535.bugfix.rst b/doc/changes/dev/14067.bugfix.rst similarity index 100% rename from doc/changes/dev/13535.bugfix.rst rename to doc/changes/dev/14067.bugfix.rst