diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b9816114f..cbdec4e56 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -102,6 +102,7 @@ jobs: python -c 'import mne_bids; print(mne_bids.__version__)' python -c 'import nibabel; print(nibabel.__version__)' python -c 'import pybv; print(pybv.__version__)' + python -c 'import eeglabio; print(eeglabio.__version__)' python -c 'import pymatreader; print(pymatreader.__version__)' python -c 'import matplotlib; print(matplotlib.__version__)' python -c 'import pandas; print(pandas.__version__)' diff --git a/doc/install.rst b/doc/install.rst index 76a5769ea..3bc6c1cef 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -20,7 +20,8 @@ Optional: * ``nibabel`` (>=3.2.1, for processing MRI data) * ``pybv`` (>=0.7.5, for writing BrainVision data) -* ``pymatreader`` (>=0.0.30 , for operations with EEGLAB data) +* ``eeglabio`` (>=0.0.2, for writing EEGLAB data) +* ``pymatreader`` (>=0.0.30, for other operations with EEGLAB data) * ``matplotlib`` (>=3.4.0, for using the interactive data inspector) * ``pandas`` (>=1.2.4, for generating event statistics) * ``EDFlib-Python`` (>=1.0.6, for writing EDF data) diff --git a/doc/whats_new.rst b/doc/whats_new.rst index 42054aeb0..176976dfc 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -30,7 +30,7 @@ Detailed list of changes 🚀 Enhancements ^^^^^^^^^^^^^^^ -- nothing yet +- Enable exporting to the EEGLAB data format (``.set``), by `Laetitia Fesselier`_ and `Stefan Appelhoff`_ (:gh:`1187`) 🧐 API and behavior changes ^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -40,7 +40,7 @@ Detailed list of changes 🛠 Requirements ^^^^^^^^^^^^^^^ -- nothing yet +- ``eeglabio`` is now a required package if you want to export to the EEGLAB data format. 🪲 Bug fixes ^^^^^^^^^^^^ diff --git a/mne_bids/config.py b/mne_bids/config.py index b101e4f2a..de07f9c3d 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -6,6 +6,7 @@ BIDS_VERSION = "1.7.0" PYBV_VERSION = "0.7.3" +EEGLABIO_VERSION = "0.0.2" DOI = """https://doi.org/10.21105/joss.01896""" diff --git a/mne_bids/tests/test_dig.py b/mne_bids/tests/test_dig.py index 9f37b1a76..0295ebe00 100644 --- a/mne_bids/tests/test_dig.py +++ b/mne_bids/tests/test_dig.py @@ -109,7 +109,7 @@ def test_dig_pixels(tmp_path): op.join(bids_root, "sub-01", "ses-01", bids_path.datatype), exist_ok=True ) raw = _load_raw() - raw.pick_types(eeg=True) + raw.pick(["eeg"]) raw.del_proj() raw.set_channel_types({ch: "ecog" for ch in raw.ch_names}) @@ -145,7 +145,7 @@ def test_dig_template(tmp_path): bids_path = _bids_path.copy().update(root=bids_root, datatype=datatype) for coord_frame in BIDS_STANDARD_TEMPLATE_COORDINATE_SYSTEMS: raw = _load_raw() - raw.pick_types(eeg=True) + raw.pick(["eeg"]) bids_path.update(space=coord_frame) montage = raw.get_montage() pos = montage.get_positions() @@ -251,7 +251,7 @@ def test_template_to_head(): # test all coordinate frames raw = _load_raw() raw.set_montage(None) - raw.pick_types(eeg=True) + raw.pick(["eeg"]) raw.drop_channels(raw.ch_names[3:]) montage = mne.channels.make_dig_montage( ch_pos={ @@ -365,7 +365,7 @@ def test_convert_montage(): def test_electrodes_io(tmp_path): """Ensure only electrodes end up in *_electrodes.json.""" raw = _load_raw() - raw.pick_types(eeg=True, stim=True) # we don't need meg channels + raw.pick(["eeg", "stim"]) # we don't need meg channels bids_root = tmp_path / "bids1" bids_path = _bids_path.copy().update(root=bids_root, datatype="eeg") write_raw_bids(raw=raw, bids_path=bids_path) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index 195363789..cd9c86851 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -59,7 +59,12 @@ from mne_bids.sidecar_updates import _update_sidecar, update_sidecar_json from mne_bids.path import _find_matching_sidecar, _parse_ext from mne_bids.pick import coil_type -from mne_bids.config import REFERENCES, BIDS_COORD_FRAME_DESCRIPTIONS, PYBV_VERSION +from mne_bids.config import ( + REFERENCES, + BIDS_COORD_FRAME_DESCRIPTIONS, + PYBV_VERSION, + EEGLABIO_VERSION, +) base_path = op.join(op.dirname(mne.__file__), "io") subject_id = "01" @@ -148,6 +153,7 @@ def fn(fname, *args, **kwargs): # parametrization for testing converting file formats for EEG/iEEG test_converteeg_data = [ + ("EEGLAB", "EEGLAB", "test_raw.set", _read_raw_eeglab), # noqa ( "Persyst", "BrainVision", @@ -564,7 +570,7 @@ def test_fif(_bids_validate, tmp_path): bids_path.update(root=bids_root) raw = _read_raw_fif(raw_fname) raw.load_data() - raw2 = raw.pick_types(meg=False, eeg=True, stim=True, eog=True, ecg=True) + raw2 = raw.pick(["eeg", "stim", "eog", "ecg"]) raw2.save(bids_root / "test-raw.fif", overwrite=True) raw2 = mne.io.Raw(op.join(bids_root, "test-raw.fif"), preload=False) events = mne.find_events(raw2) @@ -3328,6 +3334,7 @@ def test_sidecar_encoding(_bids_validate, tmp_path): def test_convert_eeg_formats(dir_name, format, fname, reader, tmp_path): """Test conversion of EEG/iEEG manufacturer fmt to BrainVision/EDF.""" pytest.importorskip("pybv", PYBV_VERSION) + pytest.importorskip("eeglabio", EEGLABIO_VERSION) bids_root = tmp_path / format raw_fname = data_path / dir_name / fname @@ -3336,7 +3343,7 @@ def test_convert_eeg_formats(dir_name, format, fname, reader, tmp_path): raw = reader(raw_fname) # drop 'misc' type channels when exporting - raw = raw.pick_types(eeg=True) + raw = raw.pick(["eeg"]) kwargs = dict( raw=raw, format=format, bids_path=bids_path, overwrite=True, verbose=False ) @@ -3366,11 +3373,13 @@ def test_convert_eeg_formats(dir_name, format, fname, reader, tmp_path): ): bids_output_path = write_raw_bids(**kwargs) else: - with pytest.warns(RuntimeWarning, match="Converting data files to EDF format"): + with pytest.warns( + RuntimeWarning, match=f"Converting data files to {format} format" + ): bids_output_path = write_raw_bids(**kwargs) # channel units should stay the same - raw2 = read_raw_bids(bids_output_path) + raw2 = read_raw_bids(bids_output_path, extra_params=dict(preload=True)) assert all( [ ch1["unit"] == ch2["unit"] @@ -3413,6 +3422,7 @@ def test_convert_eeg_formats(dir_name, format, fname, reader, tmp_path): def test_format_conversion_overwrite(dir_name, format, fname, reader, tmp_path): """Test that overwrite works when format is passed to write_raw_bids.""" pytest.importorskip("pybv", PYBV_VERSION) + pytest.importorskip("eeglabio", EEGLABIO_VERSION) bids_root = tmp_path / format raw_fname = data_path / dir_name / fname @@ -3421,7 +3431,7 @@ def test_format_conversion_overwrite(dir_name, format, fname, reader, tmp_path): raw = reader(raw_fname) # drop 'misc' type channels when exporting - raw = raw.pick_types(eeg=True) + raw = raw.pick(["eeg"]) kwargs = dict(raw=raw, format=format, bids_path=bids_path, verbose=False) with warnings.catch_warnings(): diff --git a/mne_bids/write.py b/mne_bids/write.py index c9cd4bfa3..1923906f2 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -1156,6 +1156,22 @@ def _write_raw_edf(raw, bids_fname, overwrite): raw.export(bids_fname, overwrite=overwrite) +def _write_raw_eeglab(raw, bids_fname, overwrite): + """Store data as EEGLAB. + + Parameters + ---------- + raw : mne.io.Raw + Raw data to save. + bids_fname : str + The output filename. + overwrite : bool + Whether to overwrite an existing file or not. + """ + assert str(bids_fname).endswith(".set") + raw.export(bids_fname, overwrite=overwrite) + + @verbose def make_dataset_description( *, @@ -1467,14 +1483,14 @@ def write_raw_bids( ``source`` column of ``scans.tsv``. By default, this information is not stored. - format : 'auto' | 'BrainVision' | 'EDF' | 'FIF' + format : 'auto' | 'BrainVision' | 'EDF' | 'FIF' | 'EEGLAB' Controls the file format of the data after BIDS conversion. If ``'auto'``, MNE-BIDS will attempt to convert the input data to BIDS without a change of the original file format. A conversion to a - different file format (BrainVision, EDF, or FIF) will only take place - when the original file format lacks some necessary features. Conversion - can be forced to BrainVision or EDF for (i)EEG, and to FIF for MEG - data. + different file format will then only take place if the original file + format lacks some necessary features. + Conversion may be forced to BrainVision, EDF, or EEGLAB for (i)EEG, + and to FIF for MEG data. symlink : bool Instead of copying the source files, only create symbolic links to preserve storage space. This is only allowed when not anonymizing the @@ -1716,6 +1732,8 @@ def write_raw_bids( ext = ".vhdr" elif format == "EDF": ext = ".edf" + elif format == "EEGLAB": + ext = ".set" elif format == "FIF": ext = ".fif" else: @@ -2029,6 +2047,9 @@ def write_raw_bids( elif format == "EDF" and bids_path.datatype in ["ieeg", "eeg"]: convert = True bids_path.update(extension=".edf") + elif format == "EEGLAB" and bids_path.datatype in ["ieeg", "eeg"]: + convert = True + bids_path.update(extension=".set") elif format == "FIF" and bids_path.datatype == "meg": convert = True bids_path.update(extension=".fif") @@ -2089,6 +2110,9 @@ def write_raw_bids( elif bids_path.datatype in ["eeg", "ieeg"] and format == "EDF": warn("Converting data files to EDF format") _write_raw_edf(raw, bids_path.fpath, overwrite=overwrite) + elif bids_path.datatype in ["eeg", "ieeg"] and format == "EEGLAB": + warn("Converting data files to EEGLAB format") + _write_raw_eeglab(raw, bids_path.fpath, overwrite=overwrite) else: warn("Converting data files to BrainVision format") bids_path.update(suffix=bids_path.datatype, extension=".vhdr") diff --git a/setup.cfg b/setup.cfg index 849bfc63b..b5e22b99d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,7 @@ include_package_data = True full = nibabel >= 3.2.1 pybv >= 0.7.5 + eeglabio >= 0.0.2 pymatreader >= 0.0.30 matplotlib >= 3.4.0 pandas >= 1.2.4 diff --git a/test_requirements.txt b/test_requirements.txt index dcc89b613..3b89f1383 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -3,6 +3,7 @@ numpy>=1.20.2 scipy>=1.6.3 nibabel>=3.2.1 pybv>=0.7.5 +eeglabio>=0.0.2 pymatreader>=0.0.30 matplotlib>=3.4.0 pandas>=1.2.4