Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ jobs:
- run: pip install --upgrade towncrier pygithub gitpython numpy
- run: python ./.github/actions/rename_towncrier/rename_towncrier.py
- run: python ./tools/dev/ensure_headers.py
- uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef
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 (:attr:`raw.first_time <mne.io.Raw.first_time>`) when exporting Raw instances to EDF or EEGLAB formats, by `Qian Chu`_.
7 changes: 7 additions & 0 deletions mne/export/_brainvision.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ def _export_mne_raw(*, raw, fname, events=None, overwrite=False):

def _mne_annots2pybv_events(raw):
"""Convert mne Annotations to pybv events."""
# check that raw.annotations.orig_time is the same as raw.info["meas_date"]
# so that onsets are relative to the first sample
# (after further correction for first_time)
if raw.annotations and raw.info["meas_date"] != raw.annotations.orig_time:
raise ValueError(
"Annotations must have the same orig_time as raw.info['meas_date']"
)
events = []
for annot in raw.annotations:
# handle onset and duration: seconds to sample, relative to
Expand Down
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),
raw.annotations.duration,
raw.annotations.ch_names,
):
Expand Down
16 changes: 11 additions & 5 deletions 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 @@ -24,11 +25,16 @@ def _export_raw(fname, raw):
ch_names = [ch for ch in raw.ch_names if ch not in drop_chs]
cart_coords = _get_als_coords_from_chs(raw.info["chs"], drop_chs)

annotations = [
raw.annotations.description,
raw.annotations.onset,
raw.annotations.duration,
]
if raw.annotations:
annotations = [
raw.annotations.description,
# 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,
]
else:
annotations = None
eeglabio.raw.export_set(
fname,
data=raw.get_data(picks=ch_names),
Expand Down
8 changes: 8 additions & 0 deletions mne/export/_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ def export_raw(

%(export_warning)s

.. warning::
When exporting ``Raw`` with annotations, ``raw.info["meas_date"]`` must be the
same as ``raw.annotations.orig_time``. This guarantees that the annotations are
in the same reference frame as the samples. When
:attr:`Raw.first_time <mne.io.Raw.first_time>` is not zero (e.g., after
cropping), the onsets are automatically corrected so that onsets are always
relative to the first sample.

Parameters
----------
%(fname_export_params)s
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
2 changes: 1 addition & 1 deletion mne/forward/tests/test_make_forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ def test_make_forward_solution_openmeeg(n_layers):
eeg_atol=100,
meg_corr_tol=0.98,
eeg_corr_tol=0.98,
meg_rdm_tol=0.1,
meg_rdm_tol=0.11,
eeg_rdm_tol=0.2,
)

Expand Down
2 changes: 1 addition & 1 deletion mne/preprocessing/tests/test_fine_cal.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def test_fine_cal_systems(system, tmp_path):
err_limit = 6000
n_ref = 28
corrs = (0.19, 0.41, 0.49)
sfs = [0.5, 0.7, 0.9, 1.5]
sfs = [0.5, 0.7, 0.9, 1.55]
corr_tol = 0.55
elif system == "fil":
raw = read_raw_fil(fil_fname, verbose="error")
Expand Down
13 changes: 8 additions & 5 deletions mne/utils/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1494,19 +1494,22 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):

docdict["export_fmt_support_epochs"] = """\
Supported formats:
- EEGLAB (``.set``, uses :mod:`eeglabio`)

- EEGLAB (``.set``, uses :mod:`eeglabio`)
"""

docdict["export_fmt_support_evoked"] = """\
Supported formats:
- MFF (``.mff``, uses :func:`mne.export.export_evokeds_mff`)

- MFF (``.mff``, uses :func:`mne.export.export_evokeds_mff`)
"""

docdict["export_fmt_support_raw"] = """\
Supported formats:
- BrainVision (``.vhdr``, ``.vmrk``, ``.eeg``, uses `pybv <https://github.com/bids-standard/pybv>`_)
- EEGLAB (``.set``, uses :mod:`eeglabio`)
- EDF (``.edf``, uses `edfio <https://github.com/the-siesta-group/edfio>`_)

- BrainVision (``.vhdr``, ``.vmrk``, ``.eeg``, uses `pybv <https://github.com/bids-standard/pybv>`_)
- EEGLAB (``.set``, uses :mod:`eeglabio`)
- EDF (``.edf``, uses `edfio <https://github.com/the-siesta-group/edfio>`_)
""" # noqa: E501

docdict["export_warning"] = """\
Expand Down
2 changes: 1 addition & 1 deletion tools/github_actions_dependencies.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ if [ ! -z "$CONDA_ENV" ]; then
elif [[ "${MNE_CI_KIND}" == "pip" ]]; then
# Only used for 3.13 at the moment, just get test deps plus a few extras
# that we know are available
INSTALL_ARGS="nibabel scikit-learn numpydoc PySide6 mne-qt-browser pandas h5io mffpy defusedxml"
INSTALL_ARGS="nibabel scikit-learn numpydoc PySide6 mne-qt-browser pandas h5io mffpy defusedxml numba"
INSTALL_KIND="test"
else
test "${MNE_CI_KIND}" == "pip-pre"
Expand Down
2 changes: 1 addition & 1 deletion tools/github_actions_env_vars.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ else # conda-like
echo "MNE_LOGGING_LEVEL=warning" | tee -a $GITHUB_ENV
echo "MNE_QT_BACKEND=PySide6" | tee -a $GITHUB_ENV
# TODO: Also need "|unreliable on GitHub Actions conda" on macOS, but omit for now to make sure the failure actually shows up
echo "MNE_TEST_ALLOW_SKIP=.*(Requires (spm|brainstorm) dataset|CUDA not|PySide6 causes segfaults).*" | tee -a $GITHUB_ENV
echo "MNE_TEST_ALLOW_SKIP=.*(Requires (spm|brainstorm) dataset|CUDA not|PySide6 causes segfaults|Accelerate|Flakey verbose behavior).*" | tee -a $GITHUB_ENV
fi
fi
set +x
Loading