Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BUG] Correct annotation onset for exportation to EDF and EEGLAB #12656

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 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/devel/12656.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix bug where :func:`mne.export.export_raw` does not correct for recording start time (`raw.first_time`) when exporting Raw instances to EDF or EEGLAB formats, by `Qian Chu`_.
5 changes: 4 additions & 1 deletion mne/export/_edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import numpy as np

from ..annotations import _sync_onset
from ..utils import _check_edfio_installed, warn

_check_edfio_installed()
Expand Down Expand Up @@ -204,7 +205,9 @@ def _export_raw(fname, raw, physical_range, add_ch_type):

for desc, onset, duration, ch_names in zip(
raw.annotations.description,
raw.annotations.onset,
# subtract raw.first_time because EDF marks events starting from the first
# available data point and ignores raw.first_time
_sync_onset(raw, raw.annotations.onset, inverse=False),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also re-wrote the tests and they are passed without an issue now :D From my side it's in principle good to go.

One final note though is I'm using _sync_onset, which in addition to performing annot_start = onset - raw._first_time also assert raw.info["meas_date"] == raw.annotations.orig_time. Therefore, this would enforce users to export only Raw that has identical meas_date and orig_time.

raw.annotations.duration,
raw.annotations.ch_names,
):
Expand Down
5 changes: 4 additions & 1 deletion mne/export/_eeglab.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import numpy as np

from ..annotations import _sync_onset
from ..utils import _check_eeglabio_installed

_check_eeglabio_installed()
Expand All @@ -26,7 +27,9 @@ def _export_raw(fname, raw):

annotations = [
raw.annotations.description,
raw.annotations.onset,
# subtract raw.first_time because EEGLAB marks events starting from the first
# available data point and ignores raw.first_time
_sync_onset(raw, raw.annotations.onset, inverse=False),
raw.annotations.duration,
]
eeglabio.raw.export_set(
Expand Down
89 changes: 82 additions & 7 deletions mne/export/tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,49 @@ def test_export_raw_eeglab(tmp_path):
raw.export(temp_fname, overwrite=True)


@pytest.mark.parametrize("tmin", (0, 1, 5, 10))
def test_export_raw_eeglab_annotations(tmp_path, tmin):
"""Test annotations in the exported EEGLAB file.

All annotations should be preserved and onset corrected.
"""
pytest.importorskip("eeglabio")
raw = read_raw_fif(fname_raw, preload=True)
raw.apply_proj()
annotations = Annotations(
onset=[0.01, 0.05, 0.90, 1.05],
duration=[0, 1, 0, 0],
description=["test1", "test2", "test3", "test4"],
ch_names=[["MEG 0113"], ["MEG 0113", "MEG 0132"], [], ["MEG 0143"]],
)
raw.set_annotations(annotations)
raw.crop(tmin)

# export
temp_fname = tmp_path / "test.set"
raw.export(temp_fname)

# read in the file
with pytest.warns(RuntimeWarning, match="is above the 99th percentile"):
raw_read = read_raw_eeglab(temp_fname, preload=True, montage_units="m")
assert raw_read.first_time == 0 # exportation resets first_time
valid_annot = (
raw.annotations.onset >= tmin
) # only annotations in the cropped range gets exported

# compare annotations before and after export
assert_array_almost_equal(
raw.annotations.onset[valid_annot] - raw.first_time,
raw_read.annotations.onset,
)
assert_array_equal(
raw.annotations.duration[valid_annot], raw_read.annotations.duration
)
assert_array_equal(
raw.annotations.description[valid_annot], raw_read.annotations.description
)


def _create_raw_for_edf_tests(stim_channel_index=None):
rng = np.random.RandomState(12345)
ch_types = [
Expand Down Expand Up @@ -154,6 +197,7 @@ def test_double_export_edf(tmp_path):
"""Test exporting an EDF file multiple times."""
raw = _create_raw_for_edf_tests(stim_channel_index=2)
raw.info.set_meas_date("2023-09-04 14:53:09.000")
raw.set_annotations(Annotations(onset=[1], duration=[0], description=["test"]))

# include subject info and measurement date
raw.info["subject_info"] = dict(
Expand Down Expand Up @@ -258,8 +302,12 @@ def test_edf_padding(tmp_path, pad_width):


@edfio_mark()
def test_export_edf_annotations(tmp_path):
"""Test that exporting EDF preserves annotations."""
@pytest.mark.parametrize("tmin", (0, 0.005, 0.03, 1))
def test_export_edf_annotations(tmp_path, tmin):
"""Test annotations in the exported EDF file.

All annotations should be preserved and onset corrected.
"""
raw = _create_raw_for_edf_tests()
annotations = Annotations(
onset=[0.01, 0.05, 0.90, 1.05],
Expand All @@ -268,17 +316,44 @@ def test_export_edf_annotations(tmp_path):
ch_names=[["0"], ["0", "1"], [], ["1"]],
)
raw.set_annotations(annotations)
raw.crop(tmin)
assert raw.first_time == tmin

if raw.n_times % raw.info["sfreq"] == 0:
expectation = nullcontext()
else:
expectation = pytest.warns(
RuntimeWarning, match="EDF format requires equal-length data blocks"
)

# export
temp_fname = tmp_path / "test.edf"
raw.export(temp_fname)
with expectation:
raw.export(temp_fname)

# read in the file
raw_read = read_raw_edf(temp_fname, preload=True)
assert_array_equal(raw.annotations.onset, raw_read.annotations.onset)
assert_array_equal(raw.annotations.duration, raw_read.annotations.duration)
assert_array_equal(raw.annotations.description, raw_read.annotations.description)
assert_array_equal(raw.annotations.ch_names, raw_read.annotations.ch_names)
assert raw_read.first_time == 0 # exportation resets first_time
bad_annot = raw_read.annotations.description == "BAD_ACQ_SKIP"
if bad_annot.any():
raw_read.annotations.delete(bad_annot)
valid_annot = (
raw.annotations.onset >= tmin
) # only annotations in the cropped range gets exported

# compare annotations before and after export
assert_array_almost_equal(
raw.annotations.onset[valid_annot] - raw.first_time, raw_read.annotations.onset
)
assert_array_equal(
raw.annotations.duration[valid_annot], raw_read.annotations.duration
)
assert_array_equal(
raw.annotations.description[valid_annot], raw_read.annotations.description
)
assert_array_equal(
raw.annotations.ch_names[valid_annot], raw_read.annotations.ch_names
)


@edfio_mark()
Expand Down
Loading