From 17cc9830f7492bad37d86c2baf1921590ad8497b Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 10 Nov 2025 17:07:23 -0600 Subject: [PATCH 1/5] fixups from #1430 --- doc/whats_new.rst | 2 +- mne_bids/path.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/whats_new.rst b/doc/whats_new.rst index bbb422ed9..d8523e8d2 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -44,7 +44,7 @@ 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ä`_. 🛠 Requirements diff --git a/mne_bids/path.py b/mne_bids/path.py index c8f1c63ee..4e09bd06c 100644 --- a/mne_bids/path.py +++ b/mne_bids/path.py @@ -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( From 37e576a6dfef14605c32e7d884cc31d233618f31 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 10 Nov 2025 15:44:25 -0600 Subject: [PATCH 2/5] fix wrong orig_unit sometimes written to channels.tsv --- doc/whats_new.rst | 2 ++ mne_bids/tests/test_write.py | 2 +- mne_bids/write.py | 40 ++++++++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/doc/whats_new.rst b/doc/whats_new.rst index d8523e8d2..29ba6a512 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -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 @@ -60,6 +61,7 @@ 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 ^^^^^^^^^^^^^^ diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index 138d224d7..00f126444 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -3431,7 +3431,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" diff --git a/mne_bids/write.py b/mne_bids/write.py index 041056ade..835a42b92 100644 --- a/mne_bids/write.py +++ b/mne_bids/write.py @@ -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 @@ -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. @@ -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): @@ -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)) @@ -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 ( From 4e7474c06d155f223c4744420f5e4df222559010 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Mon, 10 Nov 2025 17:18:21 -0600 Subject: [PATCH 3/5] fix doctest (bad syntax; add verbose; avoid np types) --- doc/whats_new.rst | 1 + mne_bids/read.py | 22 +++++++++++----------- mne_bids/tests/test_read.py | 2 +- mne_bids/tsv_handler.py | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/doc/whats_new.rst b/doc/whats_new.rst index 29ba6a512..5559fd1d4 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -47,6 +47,7 @@ Detailed list of changes - ``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 ^^^^^^^^^^^^^^^ diff --git a/mne_bids/read.py b/mne_bids/read.py index 1c690d872..d2d4d736c 100644 --- a/mne_bids/read.py +++ b/mne_bids/read.py @@ -541,7 +541,8 @@ 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. @@ -549,6 +550,7 @@ def events_file_to_annotation_kwargs(events_fname: str | Path) -> dict: ---------- events_fname : str The file path to the ``events.tsv`` file. + %(verbose)s Returns ------- @@ -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) >>> @@ -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=' 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) diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py index 5fb556d5d..7d273e34a 100644 --- a/mne_bids/tests/test_read.py +++ b/mne_bids/tests/test_read.py @@ -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") diff --git a/mne_bids/tsv_handler.py b/mne_bids/tsv_handler.py index 6fe86ff7f..fd8ce0a06 100644 --- a/mne_bids/tsv_handler.py +++ b/mne_bids/tsv_handler.py @@ -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: From 7c94fbebd8b9794d3dbc87921be9061a9d8958d3 Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Thu, 6 Nov 2025 16:00:12 -0600 Subject: [PATCH 4/5] add curryreader to full and test deps --- doc/install.rst | 1 + doc/whats_new.rst | 1 + pyproject.toml | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/install.rst b/doc/install.rst index 6215f6894..f79f7af10 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -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) diff --git a/doc/whats_new.rst b/doc/whats_new.rst index 5559fd1d4..ae32abe1b 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -70,5 +70,6 @@ Detailed list of changes - 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 ` diff --git a/pyproject.toml b/pyproject.toml index a4608a3e8..147bd170d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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/" From c8009184ed1f001276680f8a1df1c072d93a052e Mon Sep 17 00:00:00 2001 From: Daniel McCloy Date: Fri, 7 Nov 2025 17:33:48 -0600 Subject: [PATCH 5/5] importorskip --- mne_bids/config.py | 1 + mne_bids/tests/test_write.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/mne_bids/config.py b/mne_bids/config.py index 5e356bf21..47826bac9 100644 --- a/mne_bids/config.py +++ b/mne_bids/config.py @@ -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""" diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py index 00f126444..d253c93ee 100644 --- a/mne_bids/tests/test_write.py +++ b/mne_bids/tests/test_write.py @@ -47,6 +47,7 @@ ) from mne_bids.config import ( BIDS_COORD_FRAME_DESCRIPTIONS, + CURRYREADER_VERSION, EEGLABIO_VERSION, PYBV_VERSION, REFERENCES, @@ -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