diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index b969ac037ec..14fcd700df6 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -46,8 +46,8 @@ Bugs - Fix bug in :class:`mne.viz.Brain` constructor where the first argument was named ``subject_id`` instead of ``subject`` (:gh:`11049` by `Eric Larson`_) - Fix bug in :ref:`mne coreg` where the MEG helmet position was not updated during ICP fitting (:gh:`11084` by `Eric Larson`_) - Document ``height`` and ``weight`` keys of ``subject_info`` entry in :class:`mne.Info` (:gh:`11019` by :newcontrib:`Sena Er`) -- Fixed bug in :func:`mne.viz.plot_filter` when plotting filters created using ``output='ba'`` mode with ``compensation`` turned on. (by `Marian Dovgialo`_) - Fix bug in :func:`mne.viz.plot_filter` when plotting filters created using ``output='ba'`` mode with ``compensation`` turned on. (:gh:`11040` by `Marian Dovgialo`_) +- Fix bug in :func:`mne.io.read_raw_bti` where EEG, EMG, and H/VEOG channels were not detected properly, and many non-ECG channels were called ECG. The logic has been improved, and any channels of unknown type are now labeled as ``misc`` (:gh:`11102` by `Eric Larson`_) - Fix bug in :func:`mne.viz.plot_topomap` when providing ``sphere="eeglab"`` (:gh:`11081` by `Mathieu Scheltienne`_) - Applying a montage where EEG locations are not in head space (or unknown space) without fiducials will now raise an error message. (:gh:`11080` by `Marijn van Vliet`_) diff --git a/mne/io/bti/bti.py b/mne/io/bti/bti.py index 17a70ac5d6b..2204d1b59dd 100644 --- a/mne/io/bti/bti.py +++ b/mne/io/bti/bti.py @@ -8,6 +8,7 @@ # # simplified BSD-3 license +import functools import os.path as op from io import BytesIO from itertools import count @@ -48,7 +49,7 @@ def _instantiate_default_info_chs(): unit=FIFF.FIFF_UNIT_V, cal=1.0, scanno=None, - kind=FIFF.FIFFV_ECG_CH, + kind=FIFF.FIFFV_MISC_CH, logno=None) @@ -997,6 +998,23 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult): _mult_cal_one(data_view, one, idx, cals, mult) +@functools.lru_cache(1) +def _1020_names(): + from mne.channels import make_standard_montage + return set(ch_name.lower() + for ch_name in make_standard_montage('standard_1005').ch_names) + + +def _eeg_like(ch_name): + # Some bti recordigs look like "F4-POz", so let's at least mark them + # as EEG + if ch_name.count('-') != 1: + return + ch, ref = ch_name.split('-') + eeg_names = _1020_names() + return ch.lower() in eeg_names and ref.lower() in eeg_names + + def _make_bti_digitization( info, head_shape_fname, convert, use_hpi, bti_dev_t, dev_ctf_t): with info._unlock(): @@ -1184,7 +1202,7 @@ def _get_bti_info(pdf_fname, config_fname, head_shape_fname, rotation_x, chan_info['coil_type'] = \ FIFF.FIFFV_COIL_MAGNES_OFFDIAG_REF_GRAD - elif chan_4d.startswith('EEG'): + elif chan_4d.startswith('EEG') or _eeg_like(chan_4d): chan_info['kind'] = FIFF.FIFFV_EEG_CH chan_info['coil_type'] = FIFF.FIFFV_COIL_EEG chan_info['coord_frame'] = eeg_frame @@ -1194,14 +1212,17 @@ def _get_bti_info(pdf_fname, config_fname, head_shape_fname, rotation_x, chan_info['kind'] = FIFF.FIFFV_STIM_CH elif chan_4d == 'TRIGGER': chan_info['kind'] = FIFF.FIFFV_STIM_CH - elif chan_4d.startswith('EOG') or chan_4d in eog_ch: + elif chan_4d.startswith('EOG') or \ + chan_4d[:4] in ('HEOG', 'VEOG') or chan_4d in eog_ch: chan_info['kind'] = FIFF.FIFFV_EOG_CH - elif chan_4d == ecg_ch: + elif chan_4d.startswith('EMG'): + chan_info['kind'] = FIFF.FIFFV_EMG_CH + elif chan_4d == ecg_ch or chan_4d.startswith('ECG'): chan_info['kind'] = FIFF.FIFFV_ECG_CH - elif chan_4d.startswith('X'): - chan_info['kind'] = FIFF.FIFFV_MISC_CH - elif chan_4d == 'UACurrent': - chan_info['kind'] = FIFF.FIFFV_MISC_CH + # Our default is now misc, but if we ever change that, + # we'll need this: + # elif chan_4d.startswith('X') or chan_4d == 'UACurrent': + # chan_info['kind'] = FIFF.FIFFV_MISC_CH chs.append(chan_info) diff --git a/mne/io/bti/tests/test_bti.py b/mne/io/bti/tests/test_bti.py index 56d25ba0542..324936e400d 100644 --- a/mne/io/bti/tests/test_bti.py +++ b/mne/io/bti/tests/test_bti.py @@ -2,6 +2,7 @@ # # License: BSD-3-Clause +from collections import Counter from io import BytesIO import os import os.path as op @@ -38,12 +39,10 @@ for a in archs] tmp_raw_fname = op.join(base_dir, 'tmp_raw.fif') -fname_2500 = op.join(testing.data_path(download=False), 'BTi', 'erm_HFH', - 'c,rfDC') -fname_sim = op.join(testing.data_path(download=False), 'BTi', '4Dsim', - 'c,rfDC') -fname_sim_filt = op.join(testing.data_path(download=False), 'BTi', '4Dsim', - 'c,rfDC,fn50,o') +testing_path_bti = testing.data_path(download=False) / 'BTi' +fname_2500 = testing_path_bti / 'erm_HFH' / 'c,rfDC' +fname_sim = testing_path_bti / '4Dsim' / 'c,rfDC' +fname_sim_filt = testing_path_bti / '4Dsim' / 'c,rfDC,fn50,o' # the 4D exporter doesn't export all channels, so we confine our comparison NCH = 248 @@ -383,3 +382,61 @@ def test_bti_set_eog(): preload=False, eog_ch=('X65', 'X67', 'X69', 'X66', 'X68')) assert_equal(len(pick_types(raw.info, eog=True)), 5) + + +@testing.requires_testing_data +def test_bti_ecg_eog_emg(monkeypatch): + """Test that EOG/ECG/EMG are set properly in BTi.""" + kwargs = dict(rename_channels=False, head_shape_fname=None) + raw = read_raw_bti(fname_2500, **kwargs) + ch_types = raw.get_channel_types() + got = Counter(ch_types) + # Before improving the triaging in gh-, these values were: + # want = dict(mag=148, ref_meg=11, ecg=32, stim=2, misc=1) + want = dict(mag=148, ref_meg=11, ecg=1, stim=2, misc=1, eeg=31) + assert set(want) == set(got) + for key in want: + assert want[key] == got[key], key + + # replace channel names with some from HCP (starting from the end) + # not including UACurrent (misc) or TRIGGER/RESPONSE (stim) b/c they + # already exist + got_map = dict(zip(raw.ch_names, ch_types)) + kind_map = dict( + stim=['TRIGGER', 'RESPONSE'], + misc=['UACurrent'], + ) + for kind, ch_names in kind_map.items(): + for ch_name in ch_names: + assert got_map[ch_name] == kind + kind_map = dict( + misc=['SA1', 'SA2', 'SA3'], + ecg=['ECG+', 'ECG-'], + eog=['VEOG+', 'HEOG+', 'VEOG-', 'HEOG-'], + emg=['EMG_LF', 'EMG_LH', 'EMG_RF', 'EMG_RH'], + ) + new_names = sum(kind_map.values(), list()) + assert len(new_names) == 13 + assert set(new_names).intersection(set(raw.ch_names)) == set() + + def _read_bti_header_2(*args, **kwargs): + bti_info = _read_bti_header(*args, **kwargs) + for ch_name, ch in zip(new_names, bti_info['chs'][::-1]): + ch['chan_label'] = ch_name + return bti_info + + monkeypatch.setattr(mne.io.bti.bti, '_read_bti_header', _read_bti_header_2) + raw = read_raw_bti(fname_2500, **kwargs) + got_map = dict(zip(raw.ch_names, raw.get_channel_types())) + got = Counter(got_map.values()) + want = dict(mag=148, ref_meg=11, misc=1, stim=2, eeg=19) + for kind, ch_names in kind_map.items(): + want[kind] = want.get(kind, 0) + len(ch_names) + assert set(want) == set(got) + for key in want: + assert want[key] == got[key], key + for kind, ch_names in kind_map.items(): + for ch_name in ch_names: + assert ch_name in raw.ch_names + err_msg = f'{ch_name} type {got_map[ch_name]} !+ {kind}' + assert got_map[ch_name] == kind, err_msg