Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d81ead8
WIP : add support for EMG data
agramfort Apr 4, 2023
e214d25
Merge remote-tracking branch 'upstream/main' into add_emg_support
drammock Oct 8, 2024
1e9fde4
fix formatter-mangled comments
drammock Oct 8, 2024
b3f8b98
add a couple TODOs [ci skip]
drammock Oct 9, 2024
2019f40
Merge branch 'main' into add_emg_support
drammock Jan 22, 2025
eb4a3f3
prevent duplicates
drammock Jan 22, 2025
a0532a8
tweak comment
drammock Jan 22, 2025
c84882c
fix description (unrelated to EMG)
drammock Jan 22, 2025
f345306
uncomment JSON fields
drammock Jan 22, 2025
9026973
Merge branch 'main' into add_emg_support
drammock Mar 18, 2025
347dab7
more WIP changes [ci skip]
drammock Mar 19, 2025
acdcc18
make EMG a valid datatype
drammock Mar 26, 2025
97397a7
update EMG TODO comments
drammock Mar 26, 2025
c1d70c5
Merge remote-tracking branch 'upstream/main' into add_emg_support
drammock Oct 24, 2025
28a4c78
add BDF export; handle coordsys spaces
drammock Oct 24, 2025
39c745b
fix test err match
drammock Oct 27, 2025
a27a7a2
add EMG to valid read lists (and sort those lists)
drammock Oct 27, 2025
3977590
sort another one
drammock Oct 27, 2025
f4e0a03
actually enable BDF writing
drammock Oct 27, 2025
8cf38c3
coverage
drammock Oct 27, 2025
bfbdf1d
Merge branch 'main' into add_emg_support
drammock Oct 27, 2025
7432aba
fix test (MNE minversion)
drammock Nov 4, 2025
571f7c2
add missing metadata key
drammock Nov 4, 2025
50388b4
Merge remote-tracking branch 'upstream/main' into add_emg_support
drammock Nov 4, 2025
44ce4cc
more coverage
drammock Nov 5, 2025
08e6939
more more coverage
drammock Nov 5, 2025
b89c159
do not require coordsystem.json for EMG
agramfort Nov 8, 2025
31ee317
coverage + test refactor
drammock Nov 6, 2025
f53a4e2
Merge branch 'main' into add_emg_support
drammock Nov 14, 2025
7d6b576
fix warning match
drammock Nov 14, 2025
ad30c6a
add version guard on more tests
drammock Nov 14, 2025
14f4a71
fix dumb
drammock Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 58 additions & 18 deletions mne_bids/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,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,
Expand Down Expand Up @@ -86,6 +88,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
Expand Down Expand Up @@ -117,6 +126,7 @@
MANUFACTURERS = dict()
MANUFACTURERS.update(meg_manufacturers)
MANUFACTURERS.update(eeg_manufacturers)
MANUFACTURERS.update(emg_manufacturers)
MANUFACTURERS.update(ieeg_manufacturers)
MANUFACTURERS.update(nirs_manufacturers)

Expand All @@ -137,6 +147,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
Expand All @@ -157,6 +172,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,
Expand All @@ -165,39 +181,48 @@
# 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",
Copy link
Member

Choose a reason for hiding this comment

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

this is the only new line here; the rest of the changes to this list are just un-doing some bad comment placement arising from a past autoformatter snafu

"nirs",
"T1w",
"T2w",
"FLASH", # datatype
"FLASH",
# sidecars:
"participants",
"scans",
"sessions",
"electrodes",
"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
Expand All @@ -208,6 +233,7 @@
"markers": "meg",
"eeg": "eeg",
"ieeg": "ieeg",
"emg": "emg",
"T1w": "anat",
"FLASH": "anat",
}
Expand All @@ -216,9 +242,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
Comment on lines +246 to +248
Copy link
Member

Choose a reason for hiding this comment

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

another unrelated-to-EMG fix of bad autoformatter comment placement

)

# allowed BIDSPath entities
Expand Down Expand Up @@ -317,13 +343,26 @@
+ 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()
Comment on lines +348 to +357
Copy link
Member

Choose a reason for hiding this comment

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

this may be a bit too clever; EMG-BIDS allows coordsys spaces to have arbitrary (user-defined) names, and this is a way to make that possible (avoid "space entity is not valid for datatype EMG" errors) without disabling the existing checking mechanisms. Open to other approaches if this doesn't sit well with somebody.


ALLOWED_SPACES = dict()
ALLOWED_SPACES["meg"] = ALLOWED_SPACES["eeg"] = (
BIDS_SHARED_COORDINATE_FRAMES
+ BIDS_MEG_COORDINATE_FRAMES
+ 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
Expand Down Expand Up @@ -463,9 +502,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."
Comment on lines +506 to +507
Copy link
Member

Choose a reason for hiding this comment

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

another unrelated autoformatter snafu fix

)
" 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., "
Expand Down Expand Up @@ -499,6 +538,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",
}


Expand Down
15 changes: 13 additions & 2 deletions mne_bids/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +1945 to +1954
Copy link
Member

Choose a reason for hiding this comment

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

added EMG and also sorted the entries

)
datatypes = list()
for root, dirs, files in os.walk(root):
for _dir in dirs:
Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion mne_bids/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions mne_bids/tests/test_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"],
Expand Down
10 changes: 8 additions & 2 deletions mne_bids/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,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, or EMG channels found in data. "
"Please use raw.set_channel_types to set the "
"channel types in the data."
)
Expand All @@ -179,6 +181,8 @@ 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 "
Expand Down Expand Up @@ -487,7 +491,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. "
Expand All @@ -498,6 +502,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:
Expand Down
Loading
Loading