Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Optional:
* ``matplotlib`` (>=3.6, for using the interactive data inspector)
* ``pandas`` (>=1.3.2, for generating event statistics)
* ``edfio`` (>=0.4.10, for writing EDF data)
* ``curryreader`` (>=0.1.2, for reading Curry data)
* ``defusedxml`` (for writing reading EGI MFF data and BrainVision montages)
* ``filelock`` (for atomic file writing, and parallel processing support)

Expand Down
6 changes: 5 additions & 1 deletion doc/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The following authors contributed for the first time. Thank you so much! 🤩
The following authors had contributed before. Thank you for sticking around! 🤘

* `Stefan Appelhoff`_
* `Daniel McCloy`_


Detailed list of changes
Expand All @@ -44,8 +45,9 @@ Detailed list of changes
🧐 API and behavior changes
^^^^^^^^^^^^^^^^^^^^^^^^^^^

- `tracksys` accepted as argument in :class:`mne_bids.BIDSPath()` by `Julius Welzel`_ (:gh:`1430`)
- ``tracksys`` accepted as argument in :class:`mne_bids.BIDSPath()` by `Julius Welzel`_ (:gh:`1430`)
- :func:`mne_bids.read_raw_bids()` has a new parameter ``on_ch_mismatch`` that controls behaviour when there is a mismatch between channel names in ``channels.tsv`` and the raw data; accepted values are ``'raise'`` (default), ``'reorder'``, and ``'rename'``, by `Kalle Mäkelä`_.
- Column names read from ``.tsv`` are now converted from :class:`numpy.str_` class to built-in :class:`str` class, by `Daniel McCloy`_ (:gh:`1475`).

🛠 Requirements
^^^^^^^^^^^^^^^
Expand All @@ -60,12 +62,14 @@ Detailed list of changes
- Updated MEG/iEEG writers to satisfy the stricter checks in the latest BIDS validator releases: BTi/4D run folders now retain their ``.pdf`` suffix (falling back to the legacy naming when an older validator is detected), KIT marker files encode the run via the ``acq`` entity instead of ``run``, datasets lacking iEEG montages receive placeholder ``electrodes.tsv``/``coordsystem.json`` files, and the ``AssociatedEmptyRoom`` entry stores dataset-relative paths by `Bruno Aristimunha`_ (:gh:`1449`)
- Made the lock helpers skip reference counting when the optional ``filelock`` dependency is missing, preventing spurious ``AttributeError`` crashes during reads, by `Bruno Aristimunha`_ (:gh:`1469`)
- Fixed a bug in :func:`mne_bids.read_raw_bids` that caused it to fail when reading BIDS datasets where the acquisition time was specified in local time rather than UTC only in Windows, by `Bruno Aristimunha`_ (:gh:`1452`)
- Fixed bug in :func:`~mne_bids.write_raw_bids` where incorrect unit was sometimes written into ``channels.tsv`` file when converting data to BrainVision, EDF, or EEGLAB formats, by `Daniel McCloy`_ (:gh:`1475`)

⚕️ Code health
^^^^^^^^^^^^^^

- Made :func:`mne_bids.copyfiles.copyfile_brainvision` output more meaningful error messages when encountering problematic files, by `Stefan Appelhoff`_ (:gh:`1444`)
- Raised the minimum ``edfio`` requirement to ``0.4.10``, eeglabio to ``0.1.0`` by `Bruno Aristimunha`_ (:gh:`1449`)
- Relaxed EDF padding warnings in the test suite to accommodate upstream changes by `Bruno Aristimunha`_ (:gh:`1449`)
- Adapt to upstream switch to new reader package for Neuroscan-Curry-format files, by `Daniel McCloy`_ (:gh:`1475`)

:doc:`Find out what was new in previous releases <whats_new_previous_releases>`
1 change: 1 addition & 0 deletions mne_bids/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

PYBV_VERSION = "0.7.3"
EEGLABIO_VERSION = "0.0.2"
CURRYREADER_VERSION = "0.1.2"

DOI = """https://doi.org/10.21105/joss.01896"""

Expand Down
3 changes: 2 additions & 1 deletion mne_bids/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -1766,7 +1766,8 @@ def get_entities_from_fname(fname, on_error="raise", verbose=None):
'space': None, \
'recording': None, \
'split': None, \
'description': None}
'description': None, \
'tracking_system': None}
"""
if on_error not in ("warn", "raise", "ignore"):
raise ValueError(
Expand Down
22 changes: 11 additions & 11 deletions mne_bids/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,14 +541,16 @@ def _handle_info_reading(sidecar_fname, raw):
return raw


def events_file_to_annotation_kwargs(events_fname: str | Path) -> dict:
@verbose
def events_file_to_annotation_kwargs(events_fname: str | Path, *, verbose=None) -> dict:
r"""
Read the ``events.tsv`` file and extract onset, duration, and description.

Parameters
----------
events_fname : str
The file path to the ``events.tsv`` file.
%(verbose)s

Returns
-------
Expand Down Expand Up @@ -592,8 +594,8 @@ def events_file_to_annotation_kwargs(events_fname: str | Path) -> dict:
... 'duration': [0.1, 0.1, 0.1],
... 'trial_type': ['event1', 'event2', 'event1'],
... 'value': [1, 2, 1],
... 'sample': [10, 20, 30]
'foo': ['a', 'b', 'c'],
... 'sample': [10, 20, 30],
... 'foo': ['a', 'b', 'c'],
... }
>>> df = pd.DataFrame(data)
>>>
Expand All @@ -603,15 +605,11 @@ def events_file_to_annotation_kwargs(events_fname: str | Path) -> dict:
>>> df.to_csv(events_file, sep='\t', index=False)
>>>
>>> # Read the events file using the function
>>> events_dict = events_file_to_annotation_kwargs(events_file)
>>> events_dict = events_file_to_annotation_kwargs(events_file, verbose=False)
>>> events_dict
{'onset': array([0.1, 0.2, 0.3]),
'duration': array([0.1, 0.1, 0.1]),
'description': array(['event1', 'event2', 'event1'], dtype='<U6'),
'event_id': {'event1': 1, 'event2': 2},
'extras': [{'foo': 'a'}, {'foo': 'b'}, {'foo': 'c'}]}
{'onset': array([0.1, 0.2, 0.3]), 'duration': array([0.1, 0.1, 0.1]), 'description': array(['event1', 'event2', 'event1'], dtype='<U6'), 'event_id': {'event1': 1, 'event2': 2}, 'extras': [{'foo': 'a'}, {'foo': 'b'}, {'foo': 'c'}]}

"""
""" # noqa E501
logger.info(f"Reading events from {events_fname}.")
events_dict = _from_tsv(events_fname)

Expand Down Expand Up @@ -677,9 +675,11 @@ def events_file_to_annotation_kwargs(events_fname: str | Path) -> dict:
culled_vals = culled_vals.astype(int)
except ValueError: # numeric, but has some non-integer values
pass
# purge any np.str_, np.int_, or np.float_ types
culled_vals = np.asarray(culled_vals).tolist()
event_id = dict(zip(culled[trial_type_col_name], culled_vals))
else:
event_id = dict(zip(trial_types, np.arange(len(trial_types))))
event_id = dict(zip(trial_types, list(range(len(trial_types)))))
descrs = np.asarray(trial_types, dtype=str)

# convert onsets & durations to floats ("n/a" onsets were already dropped)
Expand Down
2 changes: 1 addition & 1 deletion mne_bids/tests/test_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ def test_handle_events_reading(tmp_path, with_extras):
match=re.escape(
"The version of MNE-Python you are using (<1.10) "
"does not support the extras argument in mne.Annotations. "
"The extra column(s) [np.str_('foo')] will be ignored."
"The extra column(s) ['foo'] will be ignored."
),
)
if with_extras and not check_version("mne", "1.10")
Expand Down
11 changes: 8 additions & 3 deletions mne_bids/tests/test_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
)
from mne_bids.config import (
BIDS_COORD_FRAME_DESCRIPTIONS,
CURRYREADER_VERSION,
EEGLABIO_VERSION,
PYBV_VERSION,
REFERENCES,
Expand Down Expand Up @@ -3373,8 +3374,12 @@ def test_sidecar_encoding(_bids_validate, tmp_path):
@testing.requires_testing_data
def test_convert_eeg_formats(dir_name, fmt, fname, reader, tmp_path):
"""Test conversion of EEG/iEEG manufacturer fmt to BrainVision/EDF."""
pytest.importorskip("pybv", PYBV_VERSION)
pytest.importorskip("eeglabio", EEGLABIO_VERSION)
if dir_name == "BrainVision" or fmt == "BrainVision":
pytest.importorskip("pybv", PYBV_VERSION)
elif dir_name == "EEGLAB" or fmt == "EEGLAB":
pytest.importorskip("eeglabio", EEGLABIO_VERSION)
elif dir_name == "curry" or fmt == "curry":
pytest.importorskip("curryreader", CURRYREADER_VERSION)
bids_root = tmp_path / fmt
raw_fname = data_path / dir_name / fname

Expand Down Expand Up @@ -3431,7 +3436,7 @@ def test_convert_eeg_formats(dir_name, fmt, fname, reader, tmp_path):
# load channels.tsv; the unit should be Volts
channels_fname = bids_output_path.copy().update(suffix="channels", extension=".tsv")
channels_tsv = _from_tsv(channels_fname)
assert channels_tsv["units"][0] == "V"
assert channels_tsv["units"][0] == "µV"

if fmt == "BrainVision":
assert Path(raw2.filenames[0]).suffix == ".eeg"
Expand Down
2 changes: 1 addition & 1 deletion mne_bids/tsv_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def _from_tsv(fname, dtypes=None):
# If data is 1-dimensional (only header), make it 2D
data = np.atleast_2d(data)

column_names = data[0, :]
column_names = data[0, :].tolist() # cast to list to avoid `np.str_()` keys in dict
info = data[1:, :]
data_dict = OrderedDict()
if dtypes is None:
Expand Down
40 changes: 36 additions & 4 deletions mne_bids/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def _should_use_bti_pdf_suffix() -> bool:
return use_pdf_suffix


def _channels_tsv(raw, fname, overwrite=False):
def _channels_tsv(raw, fname, *, convert_fmt, overwrite=False):
"""Create a channels.tsv file and save it.

Parameters
Expand All @@ -145,6 +145,10 @@ def _channels_tsv(raw, fname, overwrite=False):
The data as MNE-Python Raw object.
fname : str | mne_bids.BIDSPath
Filename to save the channels.tsv to.
convert_fmt : str | None
Which format the data are being converted to (determines what gets written in
the "unit" column). If ``None`` then we assume no conversion is happening (the
raw data files are being copied from the source tree into the BIDS folder tree).
overwrite : bool
Whether to overwrite the existing file.
Defaults to False.
Expand Down Expand Up @@ -193,11 +197,32 @@ def _channels_tsv(raw, fname, overwrite=False):
ch_type.append(map_chs[_channel_type])
description.append(map_desc[_channel_type])
low_cutoff, high_cutoff = (raw.info["highpass"], raw.info["lowpass"])
if raw._orig_units:
# If data are being *converted*, unit is determined by destination format:
# - `eeglabio.raw.export_set` always converts V to µV, cf:
# https://github.com/jackz314/eeglabio/blob/3961bb29daf082767ea44e7c7d9da2df10971c37/eeglabio/raw.py#L57
#
# - `mne.export._edf_bdf._export_raw_edf_bdf` always converts V to µV, cf:
# https://github.com/mne-tools/mne-python/blob/1b921f4af5154bad40202d87428a2583ef896a00/mne/export/_edf_bdf.py#L61-L63
#
# - `pybv.write_brainvision` converts V to µV by default (and we don't alter that)
# https://github.com/bids-standard/pybv/blob/2832c80ee00d12990a8c79f12c843c0d4ddc825b/pybv/io.py#L40
# https://github.com/mne-tools/mne-bids/blob/1e0a96e132fc904ba856d42beaa9ddddb985f1ed/mne_bids/write.py#L1279-L1280
if convert_fmt:
volt_like = "µV" if convert_fmt in ("BrainVision", "EDF", "EEGLAB") else "V"
units = [
volt_like
if ch_i["unit"] == FIFF.FIFF_UNIT_V
else _unit2human.get(ch_i["unit"], "n/a")
for ch_i in raw.info["chs"]
]
# if raw data is merely copied, check `raw._orig_units`
elif raw._orig_units:
units = [raw._orig_units.get(ch, "n/a") for ch in raw.ch_names]
# If `raw._orig_units` is missing, assume SI units
else:
units = [_unit2human.get(ch_i["unit"], "n/a") for ch_i in raw.info["chs"]]
units = [u if u not in ["NA"] else "n/a" for u in units]
# fixup "NA" (from `_unit2human`) → "n/a"
units = [u if u not in ["NA"] else "n/a" for u in units]

# Translate from MNE to BIDS unit naming
for idx, mne_unit in enumerate(units):
Expand Down Expand Up @@ -2229,7 +2254,6 @@ def write_raw_bids(
emptyroom_fname=associated_er_path,
overwrite=overwrite,
)
_channels_tsv(raw, channels_path.fpath, overwrite)

# create parent directories if needed
_mkdir_p(os.path.dirname(data_path))
Expand Down Expand Up @@ -2288,6 +2312,14 @@ def write_raw_bids(
f"for {datatype} datatype."
)

# this can't happen until after value of `convert` has been determined
_channels_tsv(
raw,
channels_path.fpath,
convert_fmt=format if convert else None,
overwrite=overwrite,
)

# raise error when trying to copy files (copyfile_*) into same location
# (src == dest, see https://github.com/mne-tools/mne-bids/issues/867)
if (
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ doc = [
]
# Dependencies for using all mne_bids features
full = [
"curryreader >= 0.1.2",
"defusedxml", # For reading EGI MFF data and BrainVision montages
"edfio >= 0.4.10",
"eeglabio >= 0.1.0",
Expand All @@ -74,7 +75,7 @@ full = [
"pymatreader",
]
# Dependencies for running the test infrastructure
test = ["mne_bids[full]", "pytest", "pytest-cov", "pytest-rerunfailures", "pytest-sugar", "ruff"]
test = ["curryreader", "mne_bids[full]", "pytest", "pytest-cov", "pytest-rerunfailures", "pytest-sugar", "ruff"]

[project.urls]
"Bug Tracker" = "https://github.com/mne-tools/mne-bids/issues/"
Expand Down