Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14067.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix :meth:`mne.Epochs.export` producing invalid EEGLAB event latencies and dummy events after epochs were dropped, by :newcontrib:`Daria Agafonova`.
1 change: 1 addition & 0 deletions doc/changes/names.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions mne/export/_eeglab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is odd... if you do this there is no longer any reference to the absolute time in the original recording. It suggests events[:, 2] is the only thing correctly used by eeglabio.epochs.export_set. Is it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the absolute sample from the original continuous recording is intentionally not retained in this field. For epoched EEGLAB files, eeglabio uses column 0 as latency in the concatenated epochs array and column 2 as the event type; column 1 is unused. Keeping the original sample numbers is what caused events to point to the wrong epochs after dropping epochs. I clarified this in the code comment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh.... so I think this fix actually belongs in eeglabio. It should handle the translation from MNE-Python events (which it currently takes) to whatever eeglab wants.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I moved the translation into eeglabio and added tests for dropped epochs and the tmax=0 boundary: jackz314/eeglabio#26. I’ll update this PR once the upstream behavior is settled.


# 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)
Comment on lines 78 to +80

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason to pass this at all anymore? I assume if you don't pass it, it defaults to np.arange, no?

@viranovskaya viranovskaya Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. In most cases, omitting epoch_indices gives the same result. But when zero time is the final sample (tmax=0), eeglabio assigns the event to the next epoch and resets the latencies. I updated the regression test to cover this case; it fails without the explicit indices and passes with them. So I kept the argument and added a short comment.


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,
Expand Down
43 changes: 42 additions & 1 deletion mne/export/tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from mne import (
Annotations,
Epochs,
EpochsArray,
create_info,
read_epochs_eeglab,
read_evokeds,
Expand Down Expand Up @@ -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())
Expand All @@ -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))
Expand Down
Loading