Skip to content

Commit 60830be

Browse files
Kallemakelapre-commit-ci[bot]scott-huberty
authored
Handle channel name mismatches (#1466)
* add credentials * Handle channel name mismatch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Apply formatters to previous commit * apply docstring improvement suggestion * refactor _handle_channel_mismatch to a separate function * rename ch_name_mismatch to on_ch_mismatch * More logical arg order in _handle_channel_mismatch * add hint when raising channel mismatch error * more consistent formatting in docstrings and messages * clarify reorder docstring * remove warn with specified on_ch_mismatch * add test for invalid on_ch_mismatch arg * fix authors.rst order * add on_ch_mismatch to whats_new.rst --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Scott Huberty <[email protected]>
1 parent 465dd55 commit 60830be

File tree

6 files changed

+162
-6
lines changed

6 files changed

+162
-6
lines changed

CITATION.cff

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ authors:
225225
family-names: Aristimunha
226226
affiliation: 'Université Paris-Saclay, LISN, Inria TAU/INRIA Nerv, France'
227227
orcid: 'https://orcid.org/0000-0001-5258-2995'
228+
- given-names: Kalle
229+
family-names: Mäkelä
230+
affiliation: 'University of Helsinki, Helsinki, Finland'
231+
orcid: 'https://orcid.org/0009-0005-5706-0842'
228232
- given-names: Alexandre
229233
family-names: Gramfort
230234
affiliation: 'Université Paris-Saclay, Inria, CEA, Palaiseau, France'

doc/authors.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
.. _Julia Guiomar Niso Galán: https://github.com/guiomar
3333
.. _Julius Welzel: https://github.com/JuliusWelzel
3434
.. _Kaare Mikkelsen: https://github.com/kaare-mikkelsen
35+
.. _Kalle Mäkelä: https://github.com/Kallemakela
3536
.. _Kambiz Tavabi: https://github.com/ktavabi
3637
.. _Laetitia Fesselier: https://github.com/laemtl
3738
.. _Mainak Jas: https://jasmainak.github.io/

doc/whats_new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The following authors contributed for the first time. Thank you so much! 🤩
2222
* `Julius Welzel`_
2323
* `Alex Lopez Marquez`_
2424
* `Bruno Aristimunha`_
25+
* `Kalle Mäkelä`_
2526

2627
The following authors had contributed before. Thank you for sticking around! 🤘
2728

@@ -44,6 +45,7 @@ Detailed list of changes
4445
^^^^^^^^^^^^^^^^^^^^^^^^^^^
4546

4647
- `tracksys` accepted as argument in :class:`mne_bids.BIDSPath()` by `Julius Welzel`_ (:gh:`1430`)
48+
- :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ä`_.
4749

4850
🛠 Requirements
4951
^^^^^^^^^^^^^^^

mne_bids/read.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,28 @@ def _get_bads_from_tsv_data(tsv_data):
771771
return bads
772772

773773

774-
def _handle_channels_reading(channels_fname, raw):
774+
def _handle_channel_mismatch(raw, on_ch_mismatch, ch_names_tsv, channels_fname):
775+
"""Handle mismatch between channels.tsv and raw channel names."""
776+
if on_ch_mismatch == "raise":
777+
raise RuntimeError(
778+
f"Channel mismatch between {channels_fname} and the raw data file detected."
779+
f"Either align channel names in channels.tsv with the raw file, or call "
780+
f"read_raw_bids(on_ch_mismatch='reorder'|'rename') to proceed."
781+
)
782+
logger.info(
783+
"Channel mismatch between "
784+
f"{channels_fname} and the raw data file detected. "
785+
f"Using mismatch strategy: {on_ch_mismatch}."
786+
)
787+
if on_ch_mismatch == "reorder":
788+
raw.reorder_channels(ch_names_tsv)
789+
elif on_ch_mismatch == "rename":
790+
raw.rename_channels(dict(zip(raw.ch_names, ch_names_tsv)))
791+
else:
792+
raise ValueError("on_ch_mismatch must be one of {'reorder','raise','rename'}")
793+
794+
795+
def _handle_channels_reading(channels_fname, raw, on_ch_mismatch="raise"):
775796
"""Read associated channels.tsv and populate raw.
776797
777798
Updates status (bad) and types of channels.
@@ -852,7 +873,9 @@ def _handle_channels_reading(channels_fname, raw):
852873
f"set channel names."
853874
)
854875
else:
855-
raw.rename_channels(dict(zip(raw.ch_names, ch_names_tsv)))
876+
orig_names = list(raw.ch_names)
877+
if orig_names != ch_names_tsv:
878+
_handle_channel_mismatch(raw, on_ch_mismatch, ch_names_tsv, channels_fname)
856879

857880
# Set the channel types in the raw data according to channels.tsv
858881
channel_type_bids_mne_map_available_channels = {
@@ -892,7 +915,12 @@ def _handle_channels_reading(channels_fname, raw):
892915

893916
@verbose
894917
def read_raw_bids(
895-
bids_path, extra_params=None, *, return_event_dict=False, verbose=None
918+
bids_path,
919+
extra_params=None,
920+
*,
921+
return_event_dict=False,
922+
on_ch_mismatch="raise",
923+
verbose=None,
896924
):
897925
"""Read BIDS compatible data.
898926
@@ -923,6 +951,17 @@ def read_raw_bids(
923951
event IDs, in addition to the :class:`~mne.io.Raw` object. If a ``value`` column
924952
is present in the ``*_events.tsv`` file, it will be used as the source of the
925953
integer event ID values (events with ``value="n/a"`` will be omitted).
954+
on_ch_mismatch : str
955+
How to handle a mismatch between channel names in channels.tsv file
956+
and channel names in the raw data file.
957+
Must be one of ``'raise'``, ``'reorder'``, ``'rename'`` (default ``'raise'``).
958+
959+
* ``'raise'`` will raise a RuntimeError if there is a channel mismatch.
960+
* ``'reorder'`` will reorder the channels in the raw data file to match the
961+
channel order in the channels.tsv file.
962+
* ``'rename'`` will rename the channels in the raw data file to match the
963+
channel names in the channels.tsv file.
964+
926965
%(verbose)s
927966
928967
Returns
@@ -949,6 +988,9 @@ def read_raw_bids(
949988
ValueError
950989
If the specified ``datatype`` cannot be found in the dataset.
951990
991+
RuntimeError
992+
If channels.tsv and the raw file have a channel-name mismatch
993+
and ``on_ch_mismatch`` is 'raise'.
952994
"""
953995
if not isinstance(bids_path, BIDSPath):
954996
raise RuntimeError(
@@ -1087,7 +1129,9 @@ def read_raw_bids(
10871129
bids_path, suffix="channels", extension=".tsv", on_error="warn"
10881130
)
10891131
if channels_fname is not None:
1090-
raw = _handle_channels_reading(channels_fname, raw)
1132+
raw = _handle_channels_reading(
1133+
channels_fname, raw, on_ch_mismatch=on_ch_mismatch
1134+
)
10911135

10921136
# Try to find an associated electrodes.tsv and coordsystem.json
10931137
# to get information about the status and type of present channels

mne_bids/tests/test_read.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from mne_bids.path import _find_matching_sidecar
3636
from mne_bids.read import (
37+
_handle_channels_reading,
3738
_handle_events_reading,
3839
_handle_scans_reading,
3940
_read_raw,
@@ -1587,7 +1588,7 @@ def test_channels_tsv_raw_mismatch(tmp_path):
15871588
raw.reorder_channels(ch_names_new)
15881589
raw.save(raw_path, overwrite=True)
15891590

1590-
raw = read_raw_bids(bids_path)
1591+
raw = read_raw_bids(bids_path, on_ch_mismatch="reorder")
15911592
assert raw.ch_names == ch_names_orig
15921593

15931594

@@ -1633,6 +1634,105 @@ def test_gsr_and_temp_reading():
16331634
assert raw.get_channel_types(["Temperature"]) == ["temperature"]
16341635

16351636

1637+
def _setup_nirs_channel_mismatch(tmp_path):
1638+
ch_order_snirf = ["S1_D1 760", "S1_D2 760", "S1_D1 850", "S1_D2 850"]
1639+
ch_types = ["fnirs_cw_amplitude"] * len(ch_order_snirf)
1640+
info = mne.create_info(ch_order_snirf, sfreq=10, ch_types=ch_types)
1641+
data = np.arange(len(ch_order_snirf) * 10.0).reshape(len(ch_order_snirf), 10)
1642+
raw = mne.io.RawArray(data, info)
1643+
1644+
for i, ch_name in enumerate(raw.ch_names):
1645+
loc = np.zeros(12)
1646+
if "S1" in ch_name:
1647+
loc[3:6] = np.array([0, 0, 0])
1648+
if "D1" in ch_name:
1649+
loc[6:9] = np.array([1, 0, 0])
1650+
elif "D2" in ch_name:
1651+
loc[6:9] = np.array([0, 1, 0])
1652+
loc[9] = int(ch_name.split(" ")[1])
1653+
loc[0:3] = (loc[3:6] + loc[6:9]) / 2
1654+
raw.info["chs"][i]["loc"] = loc
1655+
1656+
orig_name_to_loc = {
1657+
name: raw.info["chs"][i]["loc"].copy() for i, name in enumerate(raw.ch_names)
1658+
}
1659+
orig_name_to_data = {
1660+
name: raw.get_data(picks=i).copy() for i, name in enumerate(raw.ch_names)
1661+
}
1662+
1663+
ch_order_bids = ["S1_D1 760", "S1_D1 850", "S1_D2 760", "S1_D2 850"]
1664+
ch_types_bids = ["NIRSCWAMPLITUDE"] * len(ch_order_bids)
1665+
channels_dict = OrderedDict([("name", ch_order_bids), ("type", ch_types_bids)])
1666+
channels_fname = tmp_path / "channels.tsv"
1667+
_to_tsv(channels_dict, channels_fname)
1668+
1669+
return (
1670+
raw,
1671+
ch_order_snirf,
1672+
ch_order_bids,
1673+
channels_fname,
1674+
orig_name_to_loc,
1675+
orig_name_to_data,
1676+
)
1677+
1678+
1679+
def test_channel_mismatch_raise(tmp_path):
1680+
"""Raise error when ``on_ch_mismatch='raise'`` and names differ."""
1681+
raw, _, _, channels_fname, _, _ = _setup_nirs_channel_mismatch(tmp_path)
1682+
with pytest.raises(
1683+
RuntimeError,
1684+
match=("Channel mismatch between .*channels"),
1685+
):
1686+
_handle_channels_reading(channels_fname, raw.copy(), on_ch_mismatch="raise")
1687+
1688+
1689+
def test_channel_mismatch_reorder(tmp_path):
1690+
"""Reorder channels to match ``channels.tsv`` ordering."""
1691+
raw, _, ch_order_bids, channels_fname, orig_name_to_loc, orig_name_to_data = (
1692+
_setup_nirs_channel_mismatch(tmp_path)
1693+
)
1694+
raw_out = _handle_channels_reading(channels_fname, raw, on_ch_mismatch="reorder")
1695+
assert raw_out.ch_names == ch_order_bids
1696+
for i, new_name in enumerate(raw_out.ch_names):
1697+
np.testing.assert_allclose(
1698+
raw_out.info["chs"][i]["loc"], orig_name_to_loc[new_name]
1699+
)
1700+
np.testing.assert_allclose(
1701+
raw_out.get_data(picks=i), orig_name_to_data[new_name]
1702+
)
1703+
1704+
1705+
def test_channel_mismatch_rename(tmp_path):
1706+
"""Rename channels to match ``channels.tsv`` names."""
1707+
(
1708+
raw,
1709+
ch_order_snirf,
1710+
ch_order_bids,
1711+
channels_fname,
1712+
orig_name_to_loc,
1713+
orig_name_to_data,
1714+
) = _setup_nirs_channel_mismatch(tmp_path)
1715+
raw_out_rename = _handle_channels_reading(
1716+
channels_fname, raw.copy(), on_ch_mismatch="rename"
1717+
)
1718+
assert raw_out_rename.ch_names == ch_order_bids
1719+
for i in range(len(ch_order_bids)):
1720+
orig_name_at_i = ch_order_snirf[i]
1721+
np.testing.assert_allclose(
1722+
raw_out_rename.info["chs"][i]["loc"], orig_name_to_loc[orig_name_at_i]
1723+
)
1724+
np.testing.assert_allclose(
1725+
raw_out_rename.get_data(picks=i), orig_name_to_data[orig_name_at_i]
1726+
)
1727+
1728+
1729+
def test_channel_mismatch_invalid_option(tmp_path):
1730+
"""Invalid ``on_ch_mismatch`` value should raise ``ValueError``."""
1731+
raw, _, _, channels_fname, _, _ = _setup_nirs_channel_mismatch(tmp_path)
1732+
with pytest.raises(ValueError, match="on_ch_mismatch must be one of"):
1733+
_handle_channels_reading(channels_fname, raw.copy(), on_ch_mismatch="invalid")
1734+
1735+
16361736
def test_events_file_to_annotation_kwargs(tmp_path):
16371737
"""Test that events file is read correctly."""
16381738
bids_path = BIDSPath(

mne_bids/tests/test_write.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@
9797
no_hand=r"ignore:Unable to map.*\n.*subject handedness.*:RuntimeWarning:mne",
9898
no_montage=r"ignore:Not setting position of.*channel found in "
9999
r"montage.*:RuntimeWarning:mne",
100+
channel_mismatch=(
101+
"ignore:Channel mismatch between .*channels\\.tsv and the raw data file "
102+
"detected\\.:RuntimeWarning:mne"
103+
),
100104
)
101105

102106

@@ -1435,6 +1439,7 @@ def test_vhdr(_bids_validate, tmp_path):
14351439
warning_str["cnt_warning2"],
14361440
warning_str["no_hand"],
14371441
warning_str["no_montage"],
1442+
warning_str["channel_mismatch"],
14381443
)
14391444
@testing.requires_testing_data
14401445
def test_eegieeg(dir_name, fname, reader, _bids_validate, tmp_path):
@@ -1525,7 +1530,7 @@ def test_eegieeg(dir_name, fname, reader, _bids_validate, tmp_path):
15251530
# Reading the file back should still work, even though we've renamed
15261531
# some channels (there's now a mismatch between BIDS and Raw channel
15271532
# names, and BIDS should take precedence)
1528-
raw_read = read_raw_bids(bids_path=bids_path)
1533+
raw_read = read_raw_bids(bids_path=bids_path, on_ch_mismatch="rename")
15291534
assert raw_read.ch_names[0] == "EOGtest"
15301535
assert raw_read.ch_names[1] == "EMG"
15311536

0 commit comments

Comments
 (0)