From d81ead8e8310f240422466672594ddc759ed1488 Mon Sep 17 00:00:00 2001 From: Alexandre Gramfort Date: Tue, 4 Apr 2023 13:50:05 +0200 Subject: [PATCH 01/25] WIP : add support for EMG data --- mne_bids/config.py | 15 +++++++++++---- mne_bids/utils.py | 8 ++++++-- mne_bids/write.py | 9 +++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index dff8407c82..50707ac7eb 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -9,7 +9,7 @@ DOI = """https://doi.org/10.21105/joss.01896""" -EPHY_ALLOWED_DATATYPES = ['meg', 'eeg', 'ieeg', 'nirs'] +EPHY_ALLOWED_DATATYPES = ['meg', 'eeg', 'ieeg', 'nirs', 'emg'] ALLOWED_DATATYPES = EPHY_ALLOWED_DATATYPES + ['anat', 'beh'] @@ -110,6 +110,9 @@ '.set', # EEGLAB, potentially accompanied by .fdt ] +allowed_extensions_emg = ['.vhdr', # BrainVision, accompanied by .vmrk, .eeg + ] + allowed_extensions_ieeg = ['.vhdr', # BrainVision, accompanied by .vmrk, .eeg '.edf', # European Data Format '.set', # EEGLAB, potentially accompanied by .fdt @@ -124,6 +127,7 @@ ALLOWED_DATATYPE_EXTENSIONS = { 'meg': allowed_extensions_meg, 'eeg': allowed_extensions_eeg, + 'emg': allowed_extensions_emg, 'ieeg': allowed_extensions_ieeg, 'nirs': allowed_extensions_nirs } @@ -140,7 +144,7 @@ # the extension) ALLOWED_FILENAME_SUFFIX = [ 'meg', 'markers', 'eeg', 'ieeg', 'T1w', 'FLASH', # datatype - 'participants', 'scans', + 'emg', 'participants', 'scans', 'electrodes', 'optodes', 'channels', 'coordsystem', 'events', # sidecars 'headshape', 'digitizer', # meg-specific sidecars 'beh', 'physio', 'stim', # behavioral @@ -150,7 +154,7 @@ # converts suffix to known path modalities SUFFIX_TO_DATATYPE = { 'meg': 'meg', 'headshape': 'meg', 'digitizer': 'meg', 'markers': 'meg', - 'eeg': 'eeg', 'ieeg': 'ieeg', + 'eeg': 'eeg', 'ieeg': 'ieeg', 'emg': 'emg', 'T1w': 'anat', 'FLASH': 'anat' } @@ -415,7 +419,10 @@ 'to human intracranial electrophysiology. Scientific Data, ' '6, 102. https://doi.org/10.1038/s41597-019-0105-7', 'nirs': - 'In preperation'} + 'In preparation', + 'emg': + 'In preparation' + } # Mapping subject information between MNE-BIDS and MNE-Python. diff --git a/mne_bids/utils.py b/mne_bids/utils.py index 9a4a9b2e95..92f108ede2 100644 --- a/mne_bids/utils.py +++ b/mne_bids/utils.py @@ -132,17 +132,21 @@ 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. ' - 'Please use raw.set_channel_types to set the ' + raise ValueError('No MEG, EEG, iEEG or EMG channels found in data.' + ' Please use raw.set_channel_types to set the ' 'channel types in the data.') elif len(datatypes) > 1: if 'meg' in datatypes and 'ieeg' not in datatypes: datatype = 'meg' elif 'ieeg' in datatypes and 'meg' not in datatypes: datatype = 'ieeg' + elif 'emg' in datatypes: + datatype = 'emg' else: raise ValueError(f'Multiple data types (``{datatypes}``) were ' 'found in the data. Please specify the ' diff --git a/mne_bids/write.py b/mne_bids/write.py index 4df21cbb64..78037b1994 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -883,6 +883,13 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, ('EEGPlacementScheme', _infer_eeg_placement_scheme(raw)), ('Manufacturer', manufacturer)] + ch_info_json_emg = [ + # ('EEGReference', 'n/a'), + # ('EEGGround', 'n/a'), + # ('EEGPlacementScheme', _infer_eeg_placement_scheme(raw)), + # ('Manufacturer', manufacturer) + ] + ch_info_json_ieeg = [ ('iEEGReference', 'n/a'), ('ECOGChannelCount', n_ecogchan), @@ -912,6 +919,8 @@ def _sidecar_json(raw, task, manufacturer, fname, datatype, 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': From 1e9fde4c55b9aae1853225d3092201513a85b823 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Tue, 8 Oct 2024 13:45:52 -0500 Subject: [PATCH 02/25] fix formatter-mangled comments --- mne_bids/config.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 2e61e23113..7310345326 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -174,13 +174,16 @@ # allowed suffixes (i.e. last "_" delimiter in the BIDS filenames before # the extension) ALLOWED_FILENAME_SUFFIX = [ + # datatypes: "meg", "markers", "eeg", "ieeg", "emg", + "nirs", "T1w", - "FLASH", # datatype + "FLASH", + # sidecars: "participants", "scans", "sessions", @@ -188,13 +191,14 @@ "optodes", "channels", "coordsystem", - "events", # sidecars + "events", + # MEG-specific sidecars: "headshape", - "digitizer", # meg-specific sidecars + "digitizer", + # behavioral: "beh", "physio", - "stim", # behavioral - "nirs", + "stim", ] # converts suffix to known path modalities @@ -214,9 +218,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 From b3f8b98fc61c8cc67436370efeec841fd4820531 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 9 Oct 2024 17:17:49 -0500 Subject: [PATCH 03/25] add a couple TODOs [ci skip] --- mne_bids/config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 7310345326..e864ae265f 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -84,6 +84,8 @@ ".EEG": "Nihon Kohden", } +emg_manufacturers = {} # TODO + nirs_manufacturers = {".snirf": "SNIRF"} # file-extension map to mne-python readers @@ -115,6 +117,7 @@ MANUFACTURERS = dict() MANUFACTURERS.update(meg_manufacturers) MANUFACTURERS.update(eeg_manufacturers) +MANUFACTURERS.update(emg_manufacturers) MANUFACTURERS.update(ieeg_manufacturers) MANUFACTURERS.update(nirs_manufacturers) @@ -136,7 +139,7 @@ ] allowed_extensions_emg = [ - ".vhdr", # BrainVision, accompanied by .vmrk, .eeg + ".edf", # European Data Format ] allowed_extensions_ieeg = [ @@ -324,6 +327,7 @@ + BIDS_EEG_COORDINATE_FRAMES ) ALLOWED_SPACES["ieeg"] = BIDS_SHARED_COORDINATE_FRAMES + BIDS_IEEG_COORDINATE_FRAMES +ALLOWED_SPACES["emg"] = None # TODO revise if we support digitization of EMG sensors ALLOWED_SPACES["anat"] = None ALLOWED_SPACES["beh"] = None From eb4a3f3211abb055b33d51110471b56b010dff8c Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 22 Jan 2025 14:32:29 -0600 Subject: [PATCH 04/25] prevent duplicates --- mne_bids/config.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 804564b3a2..d984cf350d 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -166,14 +166,18 @@ # 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 = [ From a0532a86ce06a68e84ef51e23dd9ff9209e9d7cf Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 22 Jan 2025 14:32:56 -0600 Subject: [PATCH 05/25] tweak comment --- mne_bids/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index d984cf350d..3b94e16c53 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -331,7 +331,7 @@ + BIDS_EEG_COORDINATE_FRAMES ) ALLOWED_SPACES["ieeg"] = BIDS_SHARED_COORDINATE_FRAMES + BIDS_IEEG_COORDINATE_FRAMES -ALLOWED_SPACES["emg"] = None # TODO revise if we support digitization of EMG sensors +ALLOWED_SPACES["emg"] = None # TODO revise if we support digitization of EMG sensors? ALLOWED_SPACES["anat"] = None ALLOWED_SPACES["beh"] = None From c84882c0611d9fddb823a4f5b4c00e13a593ae5a Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 22 Jan 2025 14:33:25 -0600 Subject: [PATCH 06/25] fix description (unrelated to EMG) --- mne_bids/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 3b94e16c53..80d71d6caa 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -469,9 +469,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., " From f3453063fdcf6c30a9fa6ff58b62360a45126b42 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 22 Jan 2025 14:34:08 -0600 Subject: [PATCH 07/25] uncomment JSON fields --- mne_bids/write.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mne_bids/write.py b/mne_bids/write.py index cc51ecfd73..da80cb190b 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -1006,10 +1006,10 @@ def _sidecar_json( ] ch_info_json_emg = [ - # ("EEGReference", "n/a"), - # ("EEGGround", "n/a"), - # ("EEGPlacementScheme", _infer_eeg_placement_scheme(raw)), - # ("Manufacturer", manufacturer) + ("EMGReference", "n/a"), + ("EMGGround", "n/a"), + ("EMGPlacementScheme", "n/a"), + ("Manufacturer", manufacturer), ] ch_info_json_ieeg = [ From 347dab740243f42cccf1940ee48f404c366f63d0 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 19 Mar 2025 10:49:10 -0500 Subject: [PATCH 08/25] more WIP changes [ci skip] --- mne_bids/config.py | 5 +++++ mne_bids/utils.py | 5 +++++ mne_bids/write.py | 20 ++++++++++++++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 80d71d6caa..41683c7d7c 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -19,11 +19,13 @@ 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"] CONVERT_FORMATS = { "meg": MEG_CONVERT_FORMATS, "eeg": EEG_CONVERT_FORMATS, + "emg": EMG_CONVERT_FORMATS, "ieeg": IEEG_CONVERT_FORMATS, "nirs": NIRS_CONVERT_FORMATS, } @@ -140,6 +142,9 @@ allowed_extensions_emg = [ ".edf", # European Data Format + ".bdf", # Biosemi + # TODO EMG: .bdf support awaits https://github.com/the-siesta-group/edfio/issues/62 + # and corresponding downstream changes in MNE-Python and here in MNE-BIDS ] allowed_extensions_ieeg = [ diff --git a/mne_bids/utils.py b/mne_bids/utils.py index bbd9b150a8..709778a1bb 100644 --- a/mne_bids/utils.py +++ b/mne_bids/utils.py @@ -294,6 +294,11 @@ def _get_mrk_meas_date(mrk): return meas_datetime +def _infer_emg_placement_scheme(raw): + # TODO EMG: just a placeholder, may not even need to exist in the end + pass + + def _infer_eeg_placement_scheme(raw): """Based on the channel names, try to infer an EEG placement scheme. diff --git a/mne_bids/write.py b/mne_bids/write.py index da80cb190b..da9c2be96c 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -79,6 +79,7 @@ _handle_datatype, _import_nibabel, _infer_eeg_placement_scheme, + _infer_emg_placement_scheme, _stamp_to_dt, _write_json, _write_text, @@ -1008,7 +1009,8 @@ def _sidecar_json( ch_info_json_emg = [ ("EMGReference", "n/a"), ("EMGGround", "n/a"), - ("EMGPlacementScheme", "n/a"), + # TODO EMG: must be one of Measured, ChannelSpecific, Other + ("EMGPlacementScheme", _infer_emg_placement_scheme(raw)), ("Manufacturer", manufacturer), ] @@ -2050,6 +2052,10 @@ def write_raw_bids( # if we have an available DigMontage if montage is not None or (raw.info["dig"] is not None and raw.info["dig"]): _write_dig_bids(bids_path, raw, montage, acpc_aligned, overwrite) + elif bids_path.datatype == "emg": + # TODO EMG: this is where to handle EMG coordsystem. We're not going to have a + # DigMontage (probably) so need another way to intake the info + pass else: logger.info( f"Writing of electrodes.tsv is not supported " @@ -2202,12 +2208,22 @@ def write_raw_bids( else bids_path.fpath ), ) - elif bids_path.datatype in ["eeg", "ieeg"] and format == "EDF": + 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(raw, bids_path.fpath, overwrite=overwrite) + elif bids_path.datatype in ["emg"] and format == "BDF": + # TODO EMG + raise NotImplementedError("Conversion to BDF not yet supported.") 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"]: + # TODO EMG: when writing to BDF is possible, that will be the default here + # instead. cf: https://github.com/the-siesta-group/edfio/issues/62 + bids_path.update(extension=".edf") + warn("Converting data files to EDF format") + _write_raw_edf(raw, bids_path.fpath, overwrite=overwrite) else: warn("Converting data files to BrainVision format") bids_path.update(suffix=bids_path.datatype, extension=".vhdr") From acdcc185c13b3b4dec60c88e81619c5c54a0848d Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 26 Mar 2025 16:19:07 -0500 Subject: [PATCH 09/25] make EMG a valid datatype --- mne_bids/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mne_bids/utils.py b/mne_bids/utils.py index 709778a1bb..2cc0b0993e 100644 --- a/mne_bids/utils.py +++ b/mne_bids/utils.py @@ -495,7 +495,7 @@ def _check_datatype(raw, datatype): ------- None """ - supported_types = ("meg", "eeg", "ieeg", "nirs") + supported_types = ("meg", "eeg", "emg", "ieeg", "nirs") if datatype not in supported_types: raise ValueError( f"The specified datatype {datatype} is currently not supported. " @@ -506,6 +506,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: From 97397a744fc5a0a26a0f297a7b92ed0108949efd Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 26 Mar 2025 16:32:21 -0500 Subject: [PATCH 10/25] update EMG TODO comments --- mne_bids/utils.py | 5 ----- mne_bids/write.py | 28 +++++++++++++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/mne_bids/utils.py b/mne_bids/utils.py index 2cc0b0993e..14d69a705e 100644 --- a/mne_bids/utils.py +++ b/mne_bids/utils.py @@ -294,11 +294,6 @@ def _get_mrk_meas_date(mrk): return meas_datetime -def _infer_emg_placement_scheme(raw): - # TODO EMG: just a placeholder, may not even need to exist in the end - pass - - def _infer_eeg_placement_scheme(raw): """Based on the channel names, try to infer an EEG placement scheme. diff --git a/mne_bids/write.py b/mne_bids/write.py index da9c2be96c..82b3fdc37a 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -79,7 +79,6 @@ _handle_datatype, _import_nibabel, _infer_eeg_placement_scheme, - _infer_emg_placement_scheme, _stamp_to_dt, _write_json, _write_text, @@ -1009,8 +1008,9 @@ def _sidecar_json( ch_info_json_emg = [ ("EMGReference", "n/a"), ("EMGGround", "n/a"), - # TODO EMG: must be one of Measured, ChannelSpecific, Other - ("EMGPlacementScheme", _infer_emg_placement_scheme(raw)), + # TODO EMG: must be one of Measured, ChannelSpecific, or Other, so writing `n/a` + # will lead to a dataset that doesn't validate. + ("EMGPlacementScheme", "n/a"), ("Manufacturer", manufacturer), ] @@ -2047,15 +2047,21 @@ 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"]: # We only write electrodes.tsv and accompanying coordsystem.json # if we have an available DigMontage - if montage is not None or (raw.info["dig"] is not None and raw.info["dig"]): + if montage is not None or raw.info["dig"]: _write_dig_bids(bids_path, raw, montage, acpc_aligned, overwrite) - elif bids_path.datatype == "emg": - # TODO EMG: this is where to handle EMG coordsystem. We're not going to have a - # DigMontage (probably) so need another way to intake the info - pass + 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." + ) else: logger.info( f"Writing of electrodes.tsv is not supported " @@ -2213,8 +2219,8 @@ def write_raw_bids( bids_path.update(extension=".edf") _write_raw_edf(raw, bids_path.fpath, overwrite=overwrite) elif bids_path.datatype in ["emg"] and format == "BDF": - # TODO EMG - raise NotImplementedError("Conversion to BDF not yet supported.") + # TODO EMG cf: https://github.com/the-siesta-group/edfio/issues/62 + raise NotImplementedError("Conversion to BDF is not yet supported.") 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) From 28a4c78983a3d406fd3f8b651f38929c3710b8e5 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 24 Oct 2025 17:28:43 -0500 Subject: [PATCH 11/25] add BDF export; handle coordsys spaces --- mne_bids/config.py | 25 ++++++++++++++++----- mne_bids/write.py | 56 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 54bf4a632d..14ea42ddfa 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -13,7 +13,7 @@ DOI = """https://doi.org/10.21105/joss.01896""" -EPHY_ALLOWED_DATATYPES = ["meg", "eeg", "ieeg", "nirs", "emg"] +EPHY_ALLOWED_DATATYPES = ["eeg", "emg", "ieeg", "meg", "nirs"] ALLOWED_DATATYPES = EPHY_ALLOWED_DATATYPES + ["anat", "beh", "motion"] @@ -88,7 +88,12 @@ ".EEG": "Nihon Kohden", } -emg_manufacturers = {} # TODO +emg_manufacturers = { + ".edf": "n/a", + ".EDF": "n/a", + ".bdf": "Biosemi", + ".BDF": "Biosemi", +} nirs_manufacturers = {".snirf": "SNIRF"} @@ -145,8 +150,6 @@ allowed_extensions_emg = [ ".edf", # European Data Format ".bdf", # Biosemi - # TODO EMG: .bdf support awaits https://github.com/the-siesta-group/edfio/issues/62 - # and corresponding downstream changes in MNE-Python and here in MNE-BIDS ] allowed_extensions_ieeg = [ @@ -340,6 +343,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 @@ -347,7 +362,7 @@ + BIDS_EEG_COORDINATE_FRAMES ) ALLOWED_SPACES["ieeg"] = BIDS_SHARED_COORDINATE_FRAMES + BIDS_IEEG_COORDINATE_FRAMES -ALLOWED_SPACES["emg"] = None # TODO revise if we support digitization of EMG sensors? +ALLOWED_SPACES["emg"] = BIDS_EMG_COORDINATE_FRAMES ALLOWED_SPACES["anat"] = None ALLOWED_SPACES["beh"] = None ALLOWED_SPACES["motion"] = None diff --git a/mne_bids/write.py b/mne_bids/write.py index d178189b24..25351ec73b 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -879,7 +879,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. @@ -899,6 +907,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. @@ -1050,9 +1061,9 @@ def _sidecar_json( ch_info_json_emg = [ ("EMGReference", "n/a"), ("EMGGround", "n/a"), - # TODO EMG: must be one of Measured, ChannelSpecific, or Other, so writing `n/a` - # will lead to a dataset that doesn't validate. - ("EMGPlacementScheme", "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), ] @@ -1269,6 +1280,22 @@ def _write_raw_brainvision(raw, bids_fname, events, overwrite): ) +def _write_raw_bdf(raw, bids_fname, overwrite): + """Store data as BDF. + + 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(".bdf") + raw.export(bids_fname, overwrite=overwrite) + + def _write_raw_edf(raw, bids_fname, overwrite): """Store data as EDF. @@ -1511,6 +1538,7 @@ def write_raw_bids( montage=None, acpc_aligned=False, electrodes_tsv_task=False, + emg_placement=None, overwrite=False, verbose=None, ): @@ -1690,6 +1718,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``. @@ -1927,6 +1958,12 @@ def write_raw_bids( datatype=datatype, suffix=datatype, extension=ext ) + if datatype == "emg" and emg_placement is None: + 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 ( @@ -2188,6 +2225,7 @@ def write_raw_bids( fname=sidecar_path.fpath, datatype=bids_path.datatype, emptyroom_fname=associated_er_path, + emg_placement=emg_placement, overwrite=overwrite, ) _channels_tsv(raw, channels_path.fpath, overwrite) @@ -2293,16 +2331,14 @@ def write_raw_bids( bids_path.update(extension=".edf") _write_raw_edf(raw, bids_path.fpath, overwrite=overwrite) elif bids_path.datatype in ["emg"] and format == "BDF": - # TODO EMG cf: https://github.com/the-siesta-group/edfio/issues/62 - raise NotImplementedError("Conversion to BDF is not yet supported.") + bids_path.update(extension=".bdf") + _write_raw_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"]: - # TODO EMG: when writing to BDF is possible, that will be the default here - # instead. cf: https://github.com/the-siesta-group/edfio/issues/62 - bids_path.update(extension=".edf") - warn("Converting data files to EDF format") + bids_path.update(extension=".bdf") + warn("Converting data files to BDF format") _write_raw_edf(raw, bids_path.fpath, overwrite=overwrite) else: warn("Converting data files to BrainVision format") From 39c745b4dc3aec0add6e8edb1440228aa85d6c8f Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 27 Oct 2025 09:01:43 -0500 Subject: [PATCH 12/25] fix test err match --- mne_bids/tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_bids/tests/test_utils.py b/mne_bids/tests/test_utils.py index 47b4c4393a..450cd72a21 100644 --- a/mne_bids/tests/test_utils.py +++ b/mne_bids/tests/test_utils.py @@ -108,7 +108,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, or EMG channels found"): info = mne.create_info(n_channels, sampling_rate, ch_types=ch_type * 2) raw = mne.io.RawArray(data, info) _handle_datatype(raw, datatype) From a27a7a23abf737e8feb58c2cad092463ceb6b1cd Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 27 Oct 2025 09:13:28 -0500 Subject: [PATCH 13/25] add EMG to valid read lists (and sort those lists) --- mne_bids/path.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mne_bids/path.py b/mne_bids/path.py index 996e4642c5..b7de8768bb 100644 --- a/mne_bids/path.py +++ b/mne_bids/path.py @@ -1939,7 +1939,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: @@ -2301,7 +2312,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.") From 397759061115b90dcfe5ba1f316ccfefe58c6a9f Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 27 Oct 2025 09:17:56 -0500 Subject: [PATCH 14/25] sort another one --- mne_bids/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_bids/utils.py b/mne_bids/utils.py index 7651115d1b..66b7d611a0 100644 --- a/mne_bids/utils.py +++ b/mne_bids/utils.py @@ -491,7 +491,7 @@ def _check_datatype(raw, datatype): ------- None """ - supported_types = ("meg", "eeg", "emg", "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. " From f4e0a03edc30595c8722b1d25d2922cc45c482f2 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 27 Oct 2025 11:54:03 -0500 Subject: [PATCH 15/25] actually enable BDF writing --- mne_bids/write.py | 49 +++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/mne_bids/write.py b/mne_bids/write.py index 25351ec73b..b0165d985b 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -1280,23 +1280,7 @@ def _write_raw_brainvision(raw, bids_fname, events, overwrite): ) -def _write_raw_bdf(raw, bids_fname, overwrite): - """Store data as BDF. - - 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(".bdf") - raw.export(bids_fname, overwrite=overwrite) - - -def _write_raw_edf(raw, bids_fname, overwrite): +def _write_raw_edf_bdf(raw, bids_fname, overwrite): """Store data as EDF. Parameters @@ -1308,7 +1292,7 @@ def _write_raw_edf(raw, bids_fname, overwrite): overwrite : bool Whether to overwrite an existing file or not. """ - assert str(bids_fname).endswith(".edf") + assert bids_fname.suffix in (".edf", ".bdf") raw.export(bids_fname, overwrite=overwrite) @@ -1657,14 +1641,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 @@ -1905,6 +1889,8 @@ def write_raw_bids( else: if format == "BrainVision": ext = ".vhdr" + elif format == "BDF": + ext = ".bdf" elif format == "EDF": ext = ".edf" elif format == "EEGLAB": @@ -1914,7 +1900,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}"' @@ -2108,6 +2094,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") @@ -2263,9 +2255,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") @@ -2326,20 +2321,20 @@ def write_raw_bids( else bids_path.fpath ), ) + 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(raw, bids_path.fpath, overwrite=overwrite) - elif bids_path.datatype in ["emg"] and format == "BDF": - bids_path.update(extension=".bdf") - _write_raw_bdf(raw, bids_path.fpath, overwrite=overwrite) + _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(raw, bids_path.fpath, overwrite=overwrite) + _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") From 8cf38c343a3261be183c36da5345e41942bb7bb3 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 27 Oct 2025 16:46:57 -0500 Subject: [PATCH 16/25] coverage --- mne_bids/tests/test_write.py | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index ec1c1ca314..ec62d46388 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -96,6 +96,8 @@ 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 EDF format:RuntimeWarning", ) @@ -174,6 +176,10 @@ 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), +] data_path = testing.data_path(download=False) @@ -3319,6 +3325,38 @@ def test_sidecar_encoding(_bids_validate, tmp_path): assert_array_equal(raw.annotations.description, raw_read.annotations.description) +@pytest.mark.parametrize("dir_name, fmt, fname, reader", test_convertemg_data) +@pytest.mark.filterwarnings( + 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.""" + 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 + raw = raw.pick(["emg"]) # drop misc + # 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"], From 7432abae303b6cc04402d91151354ebe8fc35b68 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Tue, 4 Nov 2025 10:29:43 -0600 Subject: [PATCH 17/25] fix test (MNE minversion) --- mne_bids/tests/test_write.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index ec62d46388..3840a01fd4 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -3333,6 +3333,7 @@ def test_sidecar_encoding(_bids_validate, tmp_path): @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") From 571f7c290dbbac86d51c610083cbf1af1e418374 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Tue, 4 Nov 2025 10:29:54 -0600 Subject: [PATCH 18/25] add missing metadata key --- mne_bids/write.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mne_bids/write.py b/mne_bids/write.py index b0165d985b..189f794c2a 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -1066,6 +1066,10 @@ def _sidecar_json( ("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"), From 44ce4cceac6e83df9128183cc3253250b7e74fb3 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 5 Nov 2025 11:28:57 -0600 Subject: [PATCH 19/25] more coverage --- mne_bids/tests/test_write.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index dc8b9243ab..c7e88ac70e 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -87,7 +87,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.", @@ -220,6 +220,7 @@ def fn(fname, *args, **kwargs): 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) @@ -3369,6 +3370,7 @@ def test_sidecar_encoding(_bids_validate, tmp_path): @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"], ) @@ -3382,6 +3384,9 @@ def test_convert_emg_formats(tmp_path, dir_name, fmt, fname, reader): raw = reader(raw_fname) raw.set_channel_types({ch: "emg" for ch in raw.ch_names}) # HACK eeg → emg raw = raw.pick(["emg"]) # drop misc + # 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( From 08e6939aa2e5f9b2369083d4e8c399065c63604b Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Wed, 5 Nov 2025 12:11:32 -0600 Subject: [PATCH 20/25] more more coverage --- mne_bids/tests/test_write.py | 16 ++++++++++++++++ mne_bids/write.py | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index c7e88ac70e..5fee71a3cb 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -3368,6 +3368,22 @@ 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.""" + bids_root = tmp_path / "EDF" + raw_fname = data_path / "EDF" / "test_generator_2.edf" + bids_path = _bids_path.copy().update(root=bids_root, datatype="emg") + raw = _read_raw_edf(raw_fname) + raw.set_channel_types({ch: "emg" for ch in raw.ch_names}) # HACK eeg → emg + raw = raw.pick(["emg"]) # drop misc + good_kwargs = dict(raw=raw, bids_path=bids_path, verbose=False) + with pytest.raises(ValueError, match="`emg_placement` must be one of"): + write_raw_bids(**good_kwargs, emg_placement="Foo") + with 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"], diff --git a/mne_bids/write.py b/mne_bids/write.py index a5a35cec4c..3af0c3bf7a 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -2008,7 +2008,11 @@ def write_raw_bids( datatype=datatype, suffix=datatype, extension=ext ) - if datatype == "emg" and emg_placement is None: + 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})" From b89c159507d63c4191cb625da170a827bb1ddcc1 Mon Sep 17 00:00:00 2001 From: Alexandre Gramfort Date: Sat, 8 Nov 2025 11:16:54 +0100 Subject: [PATCH 21/25] do not require coordsystem.json for EMG --- mne_bids/read.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mne_bids/read.py b/mne_bids/read.py index 1c690d8723..90a8da7c3f 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 ) From 31ee317da2180ec57c5accf4d1d40ac12e0cddc3 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Thu, 6 Nov 2025 08:37:14 -0600 Subject: [PATCH 22/25] coverage + test refactor --- mne_bids/tests/test_utils.py | 6 ++- mne_bids/tests/test_write.py | 95 +++++++++++++++++++++++++----------- mne_bids/utils.py | 8 ++- mne_bids/write.py | 10 +++- 4 files changed, 83 insertions(+), 36 deletions(-) diff --git a/mne_bids/tests/test_utils.py b/mne_bids/tests/test_utils.py index 450cd72a21..0038b7e92f 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, iEEG, or EMG 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 5fee71a3cb..3737829eea 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -98,11 +98,12 @@ 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 EDF format: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" ), + edf_date="ignore:.*limits dates to after 1985-01-01:RuntimeWarning", ) @@ -3173,6 +3174,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), ], @@ -3182,10 +3184,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" @@ -3197,21 +3203,21 @@ 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 + 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": @@ -3225,7 +3231,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( @@ -3237,7 +3244,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() @@ -3249,6 +3261,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, @@ -3259,7 +3272,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") @@ -3275,6 +3289,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) @@ -3371,16 +3386,19 @@ def test_sidecar_encoding(_bids_validate, tmp_path): @testing.requires_testing_data def test_emg_errors_and_warnings(tmp_path): """Test EMG-specific error/warning raising.""" - bids_root = tmp_path / "EDF" - raw_fname = data_path / "EDF" / "test_generator_2.edf" + 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_edf(raw_fname) + raw = _read_raw_brainvision(raw_fname) raw.set_channel_types({ch: "emg" for ch in raw.ch_names}) # HACK eeg → emg - raw = raw.pick(["emg"]) # drop misc - good_kwargs = dict(raw=raw, bids_path=bids_path, verbose=False) + 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="add `coordsystem.json` file manually"): + 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") @@ -3399,7 +3417,6 @@ def test_convert_emg_formats(tmp_path, dir_name, fmt, fname, reader): 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 - raw = raw.pick(["emg"]) # drop misc # test anonymization in one case too, for coverage if dir_name == "Brainvision": raw.anonymize() @@ -3803,8 +3820,8 @@ def test_write_associated_emptyroom(_bids_validate, tmp_path, empty_room_dtype): assert meg_json_data["AssociatedEmptyRoom"] == expected_rel -def test_preload(_bids_validate, tmp_path): - """Test writing custom preloaded raw objects.""" +def test_preload_errors(tmp_path): + """Test allow_preload error handling.""" bids_root = tmp_path / "bids" bids_path = _bids_path.copy().update(root=bids_root) sfreq, n_points = 1024.0, int(1e6) @@ -3814,25 +3831,45 @@ def test_preload(_bids_validate, tmp_path): raw.orig_format = "single" raw.info["line_freq"] = 60 + shared_kwargs = dict(raw=raw, bids_path=bids_path, verbose=False, overwrite=True) # reject preloaded by default with pytest.raises(ValueError, match="allow_preload"): - write_raw_bids(raw, bids_path, verbose=False, overwrite=True) + write_raw_bids(**shared_kwargs) # preloaded raw must specify format with pytest.raises(ValueError, match="format"): - write_raw_bids( - raw, bids_path, allow_preload=True, verbose=False, overwrite=True - ) + write_raw_bids(**shared_kwargs, allow_preload=True) + +@pytest.mark.filterwarnings( + warning_str["edfblocks"], + warning_str["emg_coords_missing"], + warning_str["converting_to_edf"], +) +@pytest.mark.parametrize( + "format,ch_type", (("BrainVision", "eeg"), ("BDF", "emg"), ("EDF", "seeg")) +) +def test_preload(_bids_validate, tmp_path, format, ch_type): + """Test writing custom preloaded raw objects.""" + bids_root = tmp_path / "bids" + bids_path = _bids_path.copy().update(root=bids_root) + sfreq = 1024.0 + info = mne.create_info(["ch1", "ch2"], sfreq, ch_type) + raw = mne.io.RawArray(np.empty((2, 100), dtype=np.float32), 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, - allow_preload=True, - format="BrainVision", verbose=False, overwrite=True, + allow_preload=True, + format=format, + **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 f8509a083a..48ac30fdad 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) @@ -173,7 +173,7 @@ def _handle_datatype(raw, datatype): datatypes.append("nirs") if len(datatypes) == 0: raise ValueError( - "No MEG, EEG, iEEG, or EMG 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." ) @@ -182,8 +182,6 @@ def _handle_datatype(raw, datatype): datatype = "meg" elif "ieeg" in datatypes and "meg" not in datatypes: datatype = "ieeg" - elif "emg" in datatypes: - datatype = "emg" else: raise ValueError( f"Multiple data types (``{datatypes}``) were " diff --git a/mne_bids/write.py b/mne_bids/write.py index 3af0c3bf7a..8f81c30e3a 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -1351,7 +1351,15 @@ def _write_raw_edf_bdf(raw, bids_fname, overwrite): overwrite : bool Whether to overwrite an existing file or not. """ - assert bids_fname.suffix in (".edf", ".bdf") + ext = bids_fname.suffix[1:].upper() + assert ext in ("EDF", "BDF") + if raw.info["meas_date"].year < 1985: + warn( + f"Attempting to write a {ext} file with a meas_date of " + f"{raw.info['meas_date']}. This is not supported; {ext} limits dates to " + "after 1985-01-01. Setting raw.info['meas_date'] to 1985-01-01." + ) + raw.set_meas_date(raw.info["meas_date"].replace(year=1985, month=1, day=1)) raw.export(bids_fname, overwrite=overwrite) From 7d6b5760911b37ebe8a019bc839a58daa889aa0c Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 14 Nov 2025 14:44:03 -0600 Subject: [PATCH 23/25] fix warning match --- mne_bids/tests/test_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index 09458e6363..35eab54773 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -105,7 +105,7 @@ "ignore:Channel mismatch between .*channels\\.tsv and the raw data file " "detected\\.:RuntimeWarning:mne" ), - edf_date="ignore:.*limits dates to after 1985-01-01:RuntimeWarning", + edf_date="ignore:.*limits `startdate` to dates after 1985-01-01:RuntimeWarning", ) From ad30c6afb944088f71dacb9f7e31444e723455b7 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 14 Nov 2025 15:37:16 -0600 Subject: [PATCH 24/25] add version guard on more tests --- mne_bids/tests/test_write.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index 35eab54773..b6dfc5277a 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -3207,6 +3207,7 @@ def test_anonymize(subject, dir_name, fname, reader, tmp_path, _bids_validate): bids_path.update(task="noise", session=raw_date, suffix="meg", datatype="meg") 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") @@ -3388,6 +3389,7 @@ def test_sidecar_encoding(_bids_validate, tmp_path): @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") @@ -3894,6 +3896,8 @@ def test_preload_errors(tmp_path): ) def test_preload(_bids_validate, tmp_path, format, ch_type): """Test writing custom preloaded raw objects.""" + if format == "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 From 14f4a71b0cbd8174fa5663b2300f2004e271edaf Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 14 Nov 2025 15:55:31 -0600 Subject: [PATCH 25/25] fix dumb --- mne_bids/tests/test_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index b6dfc5277a..7c00ba0b82 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -3896,7 +3896,7 @@ def test_preload_errors(tmp_path): ) def test_preload(_bids_validate, tmp_path, format, ch_type): """Test writing custom preloaded raw objects.""" - if format == "emg": + 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)