diff --git a/mne_bids/config.py b/mne_bids/config.py index 47826bac9..917459370 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -14,18 +14,20 @@ DOI = """https://doi.org/10.21105/joss.01896""" -EPHY_ALLOWED_DATATYPES = ["meg", "eeg", "ieeg", "nirs"] +EPHY_ALLOWED_DATATYPES = ["eeg", "emg", "ieeg", "meg", "nirs"] ALLOWED_DATATYPES = EPHY_ALLOWED_DATATYPES + ["anat", "beh", "motion"] MEG_CONVERT_FORMATS = ["FIF", "auto"] EEG_CONVERT_FORMATS = ["BrainVision", "auto"] +EMG_CONVERT_FORMATS = ["EDF", "BDF", "auto"] IEEG_CONVERT_FORMATS = ["BrainVision", "auto"] NIRS_CONVERT_FORMATS = ["auto"] MOTION_CONVERT_FORMATS = ["tsv", "auto"] CONVERT_FORMATS = { "meg": MEG_CONVERT_FORMATS, "eeg": EEG_CONVERT_FORMATS, + "emg": EMG_CONVERT_FORMATS, "ieeg": IEEG_CONVERT_FORMATS, "nirs": NIRS_CONVERT_FORMATS, "motion": MOTION_CONVERT_FORMATS, @@ -87,6 +89,13 @@ ".EEG": "Nihon Kohden", } +emg_manufacturers = { + ".edf": "n/a", + ".EDF": "n/a", + ".bdf": "Biosemi", + ".BDF": "Biosemi", +} + nirs_manufacturers = {".snirf": "SNIRF"} # file-extension map to mne-python readers @@ -118,6 +127,7 @@ MANUFACTURERS = dict() MANUFACTURERS.update(meg_manufacturers) MANUFACTURERS.update(eeg_manufacturers) +MANUFACTURERS.update(emg_manufacturers) MANUFACTURERS.update(ieeg_manufacturers) MANUFACTURERS.update(nirs_manufacturers) @@ -138,6 +148,11 @@ ".set", # EEGLAB, potentially accompanied by .fdt ] +allowed_extensions_emg = [ + ".edf", # European Data Format + ".bdf", # Biosemi +] + allowed_extensions_ieeg = [ ".vhdr", # BrainVision, accompanied by .vmrk, .eeg ".edf", # European Data Format @@ -158,6 +173,7 @@ ALLOWED_DATATYPE_EXTENSIONS = { "meg": allowed_extensions_meg, "eeg": allowed_extensions_eeg, + "emg": allowed_extensions_emg, "ieeg": allowed_extensions_ieeg, "nirs": allowed_extensions_nirs, "motion": allowed_extensions_motion, @@ -166,24 +182,32 @@ # allow additional extensions that are not BIDS # compliant, but we will convert to the # recommended formats -ALLOWED_INPUT_EXTENSIONS = ( - allowed_extensions_meg - + allowed_extensions_eeg - + allowed_extensions_ieeg - + allowed_extensions_nirs - + [".lay", ".EEG", ".cnt", ".CNT", ".bin", ".cdt"] +ALLOWED_INPUT_EXTENSIONS = sorted( + set( + allowed_extensions_meg + + allowed_extensions_eeg + + allowed_extensions_emg + + allowed_extensions_ieeg + + allowed_extensions_nirs + + [".lay", ".EEG", ".cnt", ".CNT", ".bin", ".cdt"] + ) ) + # allowed suffixes (i.e. last "_" delimiter in the BIDS filenames before # the extension) ALLOWED_FILENAME_SUFFIX = [ + # datatypes: "meg", "markers", "eeg", "ieeg", + "emg", + "nirs", "T1w", "T2w", - "FLASH", # datatype + "FLASH", + # sidecars: "participants", "scans", "sessions", @@ -191,14 +215,15 @@ "optodes", "channels", "coordsystem", - "events", # sidecars + "events", + # MEG-specific sidecars: "headshape", - "digitizer", # meg-specific sidecars + "digitizer", + # behavioral: "beh", "physio", - "stim", # behavioral - "nirs", - "motion", # motion + "stim", + "motion", ] # converts suffix to known path modalities @@ -209,6 +234,7 @@ "markers": "meg", "eeg": "eeg", "ieeg": "ieeg", + "emg": "emg", "T1w": "anat", "FLASH": "anat", } @@ -217,9 +243,9 @@ ALLOWED_FILENAME_EXTENSIONS = ( ALLOWED_INPUT_EXTENSIONS + [".json", ".tsv", ".tsv.gz", ".nii", ".nii.gz"] - + [".pos", ".eeg", ".vmrk"] - + [".dat", ".EEG"] # extra datatype-specific metadata files. - + [".mrk"] # extra eeg extensions # KIT/Yokogawa/Ricoh marker coil + + [".pos", ".eeg", ".vmrk"] # extra datatype-specific metadata files. + + [".dat", ".EEG"] # extra eeg extensions + + [".mrk"] # KIT/Yokogawa/Ricoh marker coil ) # allowed BIDSPath entities @@ -318,6 +344,18 @@ + coordsys_wildcard ) + +# EMG allows arbitrary (user-defined) coord frames +class EmgCoordFrames(list): + """Container for arbitrary (user-defined) coordinate frames (spaces).""" + + def __contains__(self, item): + """Pretends to contain any non-empty string.""" + return isinstance(item, str) and len(item) + + +BIDS_EMG_COORDINATE_FRAMES = EmgCoordFrames() + ALLOWED_SPACES = dict() ALLOWED_SPACES["meg"] = ALLOWED_SPACES["eeg"] = ( BIDS_SHARED_COORDINATE_FRAMES @@ -325,6 +363,7 @@ + BIDS_EEG_COORDINATE_FRAMES ) ALLOWED_SPACES["ieeg"] = BIDS_SHARED_COORDINATE_FRAMES + BIDS_IEEG_COORDINATE_FRAMES +ALLOWED_SPACES["emg"] = BIDS_EMG_COORDINATE_FRAMES ALLOWED_SPACES["anat"] = None ALLOWED_SPACES["beh"] = None ALLOWED_SPACES["motion"] = None @@ -464,9 +503,9 @@ for sym in ("Sym", "Asym"): BIDS_COORD_FRAME_DESCRIPTIONS[f"mni152nlin2009{letter}{sym}"] = ( "Also known as ICBM (non-linear coregistration with 40 iterations," + " released in 2009). It comes in three different flavours " + "each in symmetric or asymmetric version." ) - " released in 2009). It comes in either three different flavours " - "each in symmetric or asymmetric version." REFERENCES = { "mne-bids": "Appelhoff, S., Sanderson, M., Brooks, T., Vliet, M., " @@ -500,6 +539,7 @@ "Pollonini, L. (2023). fNIRS-BIDS, the Brain Imaging Data Structure " "Extended to Functional Near-Infrared Spectroscopy. PsyArXiv. " "https://doi.org/10.31219/osf.io/7nmcp", + "emg": "In preparation", } diff --git a/mne_bids/path.py b/mne_bids/path.py index 4e09bd06c..dadb59004 100644 --- a/mne_bids/path.py +++ b/mne_bids/path.py @@ -1941,7 +1941,18 @@ def get_datatypes(root, verbose=None): # Take all possible data types from "entity" table # (Appendix in BIDS spec) # https://bids-specification.readthedocs.io/en/latest/appendices/entity-table.html - datatype_list = ("anat", "func", "dwi", "fmap", "beh", "meg", "eeg", "ieeg", "nirs") + datatype_list = ( + "anat", + "beh", + "dwi", + "eeg", + "emg", + "fmap", + "func", + "ieeg", + "meg", + "nirs", + ) datatypes = list() for root, dirs, files in os.walk(root): for _dir in dirs: @@ -2303,7 +2314,7 @@ def _infer_datatype(*, root, sub, ses): modalities = _get_datatypes_for_sub(root=root, sub=sub, ses=ses) # We only want to handle electrophysiological data here. - allowed_recording_modalities = ["meg", "eeg", "ieeg"] + allowed_recording_modalities = ["eeg", "emg", "ieeg", "meg"] modalities = list(set(modalities) & set(allowed_recording_modalities)) if not modalities: raise ValueError("No electrophysiological data found.") diff --git a/mne_bids/read.py b/mne_bids/read.py index 979db4e5d..f12e30950 100644 --- a/mne_bids/read.py +++ b/mne_bids/read.py @@ -1143,14 +1143,14 @@ def read_raw_bids( bids_path, suffix="coordsystem", extension=".json", on_error=on_error ) if electrodes_fname is not None: - if coordsystem_fname is None: - raise RuntimeError( - f"BIDS mandates that the coordsystem.json " - f"should exist if electrodes.tsv does. " - f"Please create coordsystem.json for" - f"{bids_path.basename}" - ) if datatype in ["meg", "eeg", "ieeg"]: + if coordsystem_fname is None: + raise RuntimeError( + f"BIDS mandates that the coordsystem.json " + f"should exist if electrodes.tsv does. " + f"Please create coordsystem.json for " + f"{bids_path.basename}" + ) _read_dig_bids( electrodes_fname, coordsystem_fname, raw=raw, datatype=datatype ) diff --git a/mne_bids/tests/test_utils.py b/mne_bids/tests/test_utils.py index 47b4c4393..0038b7e92 100644 --- a/mne_bids/tests/test_utils.py +++ b/mne_bids/tests/test_utils.py @@ -84,6 +84,10 @@ def test_handle_datatype(): assert _handle_datatype(raw, datatype) == datatype # datatype is not given, will be inferred if possible datatype = None + # check basic inference (only one ch_type) + info = mne.create_info(n_channels, sampling_rate, ch_types="emg") + raw = mne.io.RawArray(random((2, sampling_rate)), info) + assert _handle_datatype(raw, datatype) == "emg" # check if datatype is correctly inferred (combined EEG and iEEG/MEG data) channel_types = [["grad", "eeg"], ["eeg", "mag"], ["eeg", "seeg"], ["ecog", "eeg"]] expected_modalities = ["meg", "meg", "ieeg", "ieeg"] @@ -108,7 +112,7 @@ def test_handle_datatype(): _handle_datatype(raw, datatype) # if proper channel type (iEEG, EEG or MEG) is not found, raise ValueError ch_type = ["misc"] - with pytest.raises(ValueError, match="No MEG, EEG or iEEG channels found"): + with pytest.raises(ValueError, match="No MEG, EEG, iEEG, EMG, or fNIRS channels"): info = mne.create_info(n_channels, sampling_rate, ch_types=ch_type * 2) raw = mne.io.RawArray(data, info) _handle_datatype(raw, datatype) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index 82fef4626..7c00ba0b8 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -89,7 +89,7 @@ edf_warning=r"ignore:^EDF\/EDF\+\/BDF files contain two fields .*" r":RuntimeWarning:mne", maxshield="ignore:.*Internal Active Shielding:RuntimeWarning:mne", - edfblocks="ignore:.*EDF format requires equal-length data " + edfblocks="ignore:.*[BE]DF format requires equal-length data " "blocks:RuntimeWarning:mne", brainvision_unit="ignore:Encountered unsupported non-voltage units*.:UserWarning", cnt_warning1="ignore:.*Could not parse meas date from the header. Setting to None.", @@ -99,12 +99,13 @@ no_hand=r"ignore:Unable to map.*\n.*subject handedness.*:RuntimeWarning:mne", no_montage=r"ignore:Not setting position of.*channel found in " r"montage.*:RuntimeWarning:mne", + emg_coords_missing=r"ignore:No electrode location info found.*:RuntimeWarning", + converting_to_edf=r"ignore:Converting data files to [BE]DF format:RuntimeWarning", channel_mismatch=( "ignore:Channel mismatch between .*channels\\.tsv and the raw data file " "detected\\.:RuntimeWarning:mne" ), - converting_to_edf=r"ignore:Converting data files to [BE]DF format:RuntimeWarning", - edf_date="ignore:.*limits dates to after 1985-01-01:RuntimeWarning", + edf_date="ignore:.*limits `startdate` to dates after 1985-01-01:RuntimeWarning", ) @@ -219,6 +220,11 @@ def fn(fname, *args, **kwargs): ("CNT", "EDF", "scan41_short.cnt", _read_raw_cnt), ("curry", "EDF", "test_bdf_stim_channel Curry 8.cdt", _read_raw_curry), ] +test_convertemg_data = [ + ("BDF", "EDF", "test_generator_2.bdf", _read_raw_bdf), + ("EDF", "BDF", "test_generator_2.edf", _read_raw_edf), + ("Brainvision", "BDF", "test_NO.vhdr", _read_raw_brainvision), +] data_path = testing.data_path(download=False) @@ -3170,6 +3176,7 @@ def test_coordsystem_json_compliance( "sub-pt1_ses-02_task-monitor_acq-ecog_run-01_clip2.lay", _read_raw_persyst, ), + ("01", "Brainvision", "Analyzer_nV_Export.vhdr", _read_raw_brainvision), ("03", "NihonKohden", "MB0400FU.EEG", _read_raw_nihon), ("emptyroom", "MEG/sample", "sample_audvis_trunc_raw.fif", _read_raw_fif), ], @@ -3179,10 +3186,14 @@ def test_coordsystem_json_compliance( warning_str["channel_unit_changed"], warning_str["edf_warning"], warning_str["brainvision_unit"], + warning_str["converting_to_edf"], + warning_str["edfblocks"], + warning_str["emg_coords_missing"], + warning_str["edf_date"], ) @testing.requires_testing_data def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): - """Test writing anonymized EDF data.""" + """Test writing anonymized data.""" raw_fname = op.join(data_path, dir_name, fname) bids_root = tmp_path / "bids1" @@ -3194,21 +3205,22 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): # handle different edge cases if subject == "emptyroom": bids_path.update(task="noise", session=raw_date, suffix="meg", datatype="meg") - erm = None + write_kw = dict(empty_room=None) + elif dir_name == "Brainvision": # pretend it's EMG data + pytest.importorskip("mne", minversion="1.10.2", reason="BDF export") + raw.set_channel_types({ch: "emg" for ch in raw.ch_names}) + raw.set_montage(None) + bids_path.update(task="task", suffix="emg", datatype="emg") + write_kw = dict(empty_room=None, emg_placement="Measured") else: bids_path.update(task="task", suffix="eeg", datatype="eeg") # make sure anonymization works when also writing empty room file - erm = raw.copy() + write_kw = dict(empty_room=raw.copy()) daysback_min, daysback_max = get_anonymization_daysback(raw) anonymize = dict(daysback=daysback_min + 1) orig_bids_path = bids_path.copy() bids_path = write_raw_bids( - raw, - bids_path, - overwrite=True, - anonymize=anonymize, - verbose=False, - empty_room=erm, + raw, bids_path, overwrite=True, anonymize=anonymize, verbose=False, **write_kw ) # emptyroom recordings' session should match the recording date if subject == "emptyroom": @@ -3222,7 +3234,8 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): assert _raw.info["meas_date"].year == 1985 assert _raw.info["meas_date"].month == 1 assert _raw.info["meas_date"].day == 1 - assert raw2.info["meas_date"].year < 1925 + year = 1986 if dir_name == "Brainvision" else 1925 + assert raw2.info["meas_date"].year < year # write without source scans_fname = BIDSPath( @@ -3234,7 +3247,12 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): ) anonymize["keep_source"] = False bids_path = write_raw_bids( - raw, orig_bids_path, overwrite=True, anonymize=anonymize, verbose=False + raw, + orig_bids_path, + overwrite=True, + anonymize=anonymize, + verbose=False, + **write_kw, ) scans_tsv = _from_tsv(scans_fname) assert "source" not in scans_tsv.keys() @@ -3246,6 +3264,7 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): overwrite=True, anonymize=dict(daysback=daysback_min, keep_source=True), verbose=False, + **write_kw, ) scans_fname = BIDSPath( subject=bids_path.subject, @@ -3256,7 +3275,8 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): ) scans_tsv = _from_tsv(scans_fname) assert scans_tsv["source"] == [Path(f).name for f in raw.filenames] - _bids_validate(bids_path.root) + if dir_name != "Brainvision": # EMG not yet supported by validator + _bids_validate(bids_path.root) # update the scans sidecar JSON with information scans_json_fpath = scans_fname.copy().update(extension=".json") @@ -3272,6 +3292,7 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): overwrite=True, anonymize=dict(daysback=daysback_min, keep_source=True), verbose=False, + **write_kw, ) with open(scans_json_fpath) as fin: scans_json = json.load(fin) @@ -3365,6 +3386,62 @@ def test_sidecar_encoding(_bids_validate, tmp_path): assert_array_equal(raw.annotations.description, raw_read.annotations.description) +@testing.requires_testing_data +def test_emg_errors_and_warnings(tmp_path): + """Test EMG-specific error/warning raising.""" + pytest.importorskip("mne", minversion="1.10.2", reason="BDF export") + bids_root = tmp_path / "EMG_errors" + raw_fname = data_path / "Brainvision" / "test_NO.vhdr" + bids_path = _bids_path.copy().update(root=bids_root, datatype="emg") + raw = _read_raw_brainvision(raw_fname) + raw.set_channel_types({ch: "emg" for ch in raw.ch_names}) # HACK eeg → emg + good_kwargs = dict(raw=raw, bids_path=bids_path, verbose=False, overwrite=True) + with pytest.raises(ValueError, match="`emg_placement` must be one of"): + write_raw_bids(**good_kwargs, emg_placement="Foo") + with ( + pytest.warns(RuntimeWarning, match="BDF format requires equal-length data"), + pytest.warns(RuntimeWarning, match="Converting data files to BDF format"), + pytest.warns(RuntimeWarning, match="add `coordsystem.json` file manually"), + ): + write_raw_bids(**good_kwargs, emg_placement="Other") + + +@pytest.mark.parametrize("dir_name, fmt, fname, reader", test_convertemg_data) +@pytest.mark.filterwarnings( + warning_str["edfblocks"], + warning_str["emg_coords_missing"], + warning_str["converting_to_edf"], +) +@testing.requires_testing_data +def test_convert_emg_formats(tmp_path, dir_name, fmt, fname, reader): + """Test EDF ←→ BDF conversion of EMG datasets.""" + pytest.importorskip("mne", minversion="1.10.2", reason="BDF export") + bids_root = tmp_path / fmt + raw_fname = data_path / dir_name / fname + bids_path = _bids_path.copy().update(root=bids_root, datatype="emg") + raw = reader(raw_fname) + raw.set_channel_types({ch: "emg" for ch in raw.ch_names}) # HACK eeg → emg + # test anonymization in one case too, for coverage + if dir_name == "Brainvision": + raw.anonymize() + # make sure it's possible to define arbitrarily-named coordsys spaces for EMG + bids_path.update(space="foo", check=True) + bids_output_path = write_raw_bids( + raw=raw, + format=fmt, + bids_path=bids_path, + overwrite=True, + verbose=False, + emg_placement="Other", + ) + # make sure the file extension is correct (different from what we started with) + outdir = tmp_path / fmt / "sub-01" / "ses-01" / "emg" + dont_want = list((outdir).glob(f"*{raw_fname.suffix}")) + want = list((outdir).glob(f"*.{fmt.lower()}")) + assert len(dont_want) == 0 + assert want == [bids_output_path.fpath] + + @pytest.mark.parametrize("dir_name, fmt, fname, reader", test_converteeg_data) @pytest.mark.filterwarnings( warning_str["channel_unit_changed"], @@ -3812,10 +3889,15 @@ def test_preload_errors(tmp_path): @pytest.mark.filterwarnings( warning_str["converting_to_edf"], warning_str["edfblocks"], + warning_str["emg_coords_missing"], +) +@pytest.mark.parametrize( + "format,ch_type", (("BrainVision", "eeg"), ("BDF", "emg"), ("EDF", "seeg")) ) -@pytest.mark.parametrize("format,ch_type", (("BrainVision", "eeg"), ("EDF", "seeg"))) def test_preload(_bids_validate, tmp_path, format, ch_type): """Test writing custom preloaded raw objects.""" + if ch_type == "emg": + pytest.importorskip("mne", minversion="1.10.2", reason="BDF export") bids_root = tmp_path / "bids" bids_path = _bids_path.copy().update(root=bids_root) sfreq = 1024.0 @@ -3825,6 +3907,7 @@ def test_preload(_bids_validate, tmp_path, format, ch_type): raw = mne.io.RawArray(data, info) raw.orig_format = "single" raw.info["line_freq"] = 60 + kw = dict(emg_placement="Measured") if ch_type == "emg" else dict() write_raw_bids( raw, bids_path, @@ -3832,8 +3915,10 @@ def test_preload(_bids_validate, tmp_path, format, ch_type): format=format, verbose=False, overwrite=True, + **kw, ) - _bids_validate(bids_root) + if ch_type != "emg": # TODO validator support for EMG not available yet + _bids_validate(bids_root) @pytest.mark.parametrize("dir_name", ("tsv_test", "json_test")) diff --git a/mne_bids/utils.py b/mne_bids/utils.py index 73d505cbc..354f268f5 100644 --- a/mne_bids/utils.py +++ b/mne_bids/utils.py @@ -135,7 +135,7 @@ def _handle_datatype(raw, datatype): raw : mne.io.Raw Raw object. datatype : str | None - Can be one of either ``'meg'``, ``'eeg'``, or ``'ieeg'``. If ``None``, + Can be one of either ``'meg'``, ``'eeg'``, ``'emg'`` or ``'ieeg'``. If ``None``, `mne.utils._handle_datatype()` will attempt to infer the datatype from the ``raw`` object. In case of multiple data types in the ``raw`` object, ``datatype`` must not be ``None``. @@ -143,7 +143,7 @@ def _handle_datatype(raw, datatype): Returns ------- datatype : str - One of either ``'meg'``, ``'eeg'``, or ``'ieeg'``. + One of either ``'meg'``, ``'eeg'``, ``'emg'``, or ``'ieeg'``. """ if datatype is not None: _check_datatype(raw, datatype) @@ -167,11 +167,13 @@ def _handle_datatype(raw, datatype): datatypes.append("meg") if "eeg" in raw: datatypes.append("eeg") + if "emg" in raw: + datatypes.append("emg") if "fnirs_cw_amplitude" in raw: datatypes.append("nirs") if len(datatypes) == 0: raise ValueError( - "No MEG, EEG or iEEG channels found in data. " + "No MEG, EEG, iEEG, EMG, or fNIRS channels found in data. " "Please use raw.set_channel_types to set the " "channel types in the data." ) @@ -488,7 +490,7 @@ def _check_datatype(raw, datatype): ------- None """ - supported_types = ("meg", "eeg", "ieeg", "nirs") + supported_types = ("eeg", "emg", "ieeg", "meg", "nirs") if datatype not in supported_types: raise ValueError( f"The specified datatype {datatype} is currently not supported. " @@ -499,6 +501,8 @@ def _check_datatype(raw, datatype): datatype_matches = False if datatype == "eeg" and datatype in raw: datatype_matches = True + elif datatype == "emg" and datatype in raw: + datatype_matches = True elif datatype == "meg" and datatype in raw: datatype_matches = True elif datatype == "nirs" and "fnirs_cw_amplitude" in raw: diff --git a/mne_bids/write.py b/mne_bids/write.py index 418aed1a1..ca2da3d35 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -959,7 +959,15 @@ def _mri_scanner_ras_to_mri_voxels(ras_landmarks, img_mgh): def _sidecar_json( - raw, task, manufacturer, fname, datatype, emptyroom_fname=None, overwrite=False + raw, + task, + manufacturer, + fname, + datatype, + *, + emg_placement=None, + emptyroom_fname=None, + overwrite=False, ): """Create a sidecar json file depending on the suffix and save it. @@ -979,6 +987,9 @@ def _sidecar_json( Filename to save the sidecar json to. datatype : str Type of the data as in ALLOWED_ELECTROPHYSIO_DATATYPE. + emg_placement : "Measured" | "ChannelSpecific" | "Other" | None + How the EMG sensor locations were determined. Must be one of the literal strings + if ``datatype="emg"`` and should be ``None`` for all other datatypes. emptyroom_fname : str | mne_bids.BIDSPath For MEG recordings, the path to an empty-room data file to be associated with ``raw``. Only supported for MEG. @@ -1127,6 +1138,19 @@ def _sidecar_json( ("Manufacturer", manufacturer), ] + ch_info_json_emg = [ + ("EMGReference", "n/a"), + ("EMGGround", "n/a"), + # n/a will fail to validate, but if emg_placement is None that's our bug: + # the user-called function write_raw_bids should guarantee a valid string here + ("EMGPlacementScheme", emg_placement or "n/a"), + ("Manufacturer", manufacturer), + ] + if emg_placement == "Other": + ch_info_json_emg.append( + ("EMGPlacementSchemeDescription", "TODO FIXME please complete this field") + ) + ch_info_json_ieeg = [ ("iEEGReference", "n/a"), ("ECOGChannelCount", n_ecogchan), @@ -1156,6 +1180,8 @@ def _sidecar_json( append_datatype_json = ch_info_json_meg elif datatype == "eeg": append_datatype_json = ch_info_json_eeg + elif datatype == "emg": + append_datatype_json = ch_info_json_emg elif datatype == "ieeg": append_datatype_json = ch_info_json_ieeg elif datatype == "nirs": @@ -1601,6 +1627,7 @@ def write_raw_bids( montage=None, acpc_aligned=False, electrodes_tsv_task=False, + emg_placement=None, overwrite=False, verbose=None, ): @@ -1719,14 +1746,14 @@ def write_raw_bids( ``source`` column of ``scans.tsv``. By default, this information is not stored. - format : 'auto' | 'BrainVision' | 'EDF' | 'FIF' | 'EEGLAB' + format : 'auto' | 'BrainVision' | 'BDF' | '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 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. + to BDF or EDF for EMG, 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 @@ -1780,6 +1807,9 @@ def write_raw_bids( electrodes_tsv_task : bool Add the ``task-`` entity to the ``electrodes.tsv`` filename. Defaults to ``False``. + emg_placement : "Measured" | "ChannelSpecific" | "Other" | None + How the EMG sensor locations were determined. Must be one of the literal strings + if datatype is "emg" and should be ``None`` for all other datatypes. overwrite : bool Whether to overwrite existing files or data in files. Defaults to ``False``. @@ -1964,6 +1994,8 @@ def write_raw_bids( else: if format == "BrainVision": ext = ".vhdr" + elif format == "BDF": + ext = ".bdf" elif format == "EDF": ext = ".edf" elif format == "EEGLAB": @@ -1973,7 +2005,7 @@ def write_raw_bids( else: msg = ( 'For preloaded data, you must set the "format" parameter ' - "to one of: BrainVision, EDF, EEGLAB, or FIF" + "to one of: BrainVision, BDF, EDF, EEGLAB, or FIF" ) if format != "auto": # the default was changed msg += f', but got: "{format}"' @@ -2017,6 +2049,16 @@ def write_raw_bids( datatype=datatype, suffix=datatype, extension=ext ) + if datatype == "emg" and emg_placement not in ( + "Measured", + "ChannelSpecific", + "Other", + ): + raise ValueError( + '`emg_placement` must be one of "Measured", "ChannelSpecific", or "Other" ' + f"(got {emg_placement})" + ) + # Check whether provided info and raw indicates valid MEG emptyroom data data_is_emptyroom = False if ( @@ -2161,6 +2203,12 @@ def write_raw_bids( warn("Converting data files to BrainVision format for anonymization") convert = True bids_path.update(extension=".vhdr") + elif bids_path.datatype == "emg": + if ext not in [".edf", ".bdf", ".EDF", ".BDF"]: + warn("Converting data files to BDF format for anonymization") + convert = True + bids_path.update(extension=".bdf") + # Read in Raw object and extract metadata from Raw object if needed orient = ORIENTATION.get(ext, "n/a") unit = EXT_TO_UNIT_MAP.get(ext, "n/a") @@ -2203,12 +2251,22 @@ def write_raw_bids( datatype=bids_path.datatype, overwrite=overwrite, ) - elif bids_path.datatype in ["eeg", "ieeg", "nirs"]: + elif bids_path.datatype in ["eeg", "emg", "ieeg", "nirs"]: have_dig = raw.info["dig"] is not None and bool(raw.info["dig"]) if montage is not None or have_dig: _write_dig_bids( bids_path, raw, montage, acpc_aligned, electrodes_tsv_task, overwrite ) + elif bids_path.datatype == "emg": + # TODO EMG: Handle EMG coordsystem if it's not in `raw.info["dig"]`. + # In theory we could make a helper func for creating a `DigMontage` from + # EMG electrode location info, and users could pass that into + # `write_raw_bids`... + warn( + "No electrode location info found in raw file, so not writing " + "coordinate system info for EMG data. Please add `coordsystem.json` " + "file manually." + ) elif bids_path.datatype == "ieeg": _write_empty_ieeg_positions( bids_path=bids_path, @@ -2268,6 +2326,7 @@ def write_raw_bids( fname=sidecar_path.fpath, datatype=bids_path.datatype, emptyroom_fname=associated_er_path, + emg_placement=emg_placement, overwrite=overwrite, ) @@ -2304,9 +2363,12 @@ def write_raw_bids( if format == "BrainVision" and bids_path.datatype in ["ieeg", "eeg"]: convert = True bids_path.update(extension=".vhdr") - elif format == "EDF" and bids_path.datatype in ["ieeg", "eeg"]: + elif format == "EDF" and bids_path.datatype in ["ieeg", "eeg", "emg"]: convert = True bids_path.update(extension=".edf") + elif format == "BDF" and bids_path.datatype in ["emg"]: + convert = True + bids_path.update(extension=".bdf") elif format == "EEGLAB" and bids_path.datatype in ["ieeg", "eeg"]: convert = True bids_path.update(extension=".set") @@ -2375,12 +2437,20 @@ def write_raw_bids( else bids_path.fpath ), ) - elif bids_path.datatype in ["eeg", "ieeg"] and format == "EDF": + elif bids_path.datatype in ["emg"] and format == "BDF": + bids_path.update(extension=".bdf") + _write_raw_edf_bdf(raw, bids_path.fpath, overwrite=overwrite) + elif bids_path.datatype in ["eeg", "emg", "ieeg"] and format == "EDF": warn("Converting data files to EDF format") + bids_path.update(extension=".edf") _write_raw_edf_bdf(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) + elif bids_path.datatype in ["emg"]: + bids_path.update(extension=".bdf") + warn("Converting data files to BDF format") + _write_raw_edf_bdf(raw, bids_path.fpath, overwrite=overwrite) else: warn("Converting data files to BrainVision format") bids_path.update(suffix=bids_path.datatype, extension=".vhdr")