From 9b57c51686ca5536edc2a5e74444428a9a138ef6 Mon Sep 17 00:00:00 2001 From: Jacob Woessner Date: Tue, 14 Nov 2023 15:05:36 -0600 Subject: [PATCH] [WIP] [BUG] Fix reading bad channels in cnt files (#12189) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Larson --- doc/changes/devel.rst | 2 ++ mne/io/cnt/cnt.py | 39 ++++++++++++++++++++++++++++++------ mne/io/cnt/tests/test_cnt.py | 35 +++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst index c586d1e44df..f0179a0a705 100644 --- a/doc/changes/devel.rst +++ b/doc/changes/devel.rst @@ -95,6 +95,8 @@ Bugs - Fix bug with :func:`mne.io.read_raw_eeglab` and :func:`mne.read_epochs_eeglab` where automatic fiducial detection would fail for certain files (:gh:`12165` by `Clemens Brunner`_) - Fix concatenation of ``raws`` with ``np.nan`` in the device to head transformation (:gh:`12198` by `Mathieu Scheltienne`_) - Fix bug with :func:`mne.viz.plot_compare_evokeds` where the title was not displayed when ``axes='topo'`` (:gh:`12192` by `Jacob Woessner`_) +- Fix bug with :func:`mne.io.read_raw_cnt` where the bad channels were not properly read (:gh:`12189` by `Jacob Woessner`_) + API changes ~~~~~~~~~~~ diff --git a/mne/io/cnt/cnt.py b/mne/io/cnt/cnt.py index 249497fdf73..32f2611173e 100644 --- a/mne/io/cnt/cnt.py +++ b/mne/io/cnt/cnt.py @@ -14,7 +14,7 @@ from ..._fiff.utils import _create_chs, _find_channels, _mult_cal_one, read_str from ...annotations import Annotations from ...channels.layout import _topo_to_sphere -from ...utils import _check_option, fill_doc, warn +from ...utils import _check_option, _validate_type, fill_doc, warn from ..base import BaseRaw from ._utils import ( CNTEventType3, @@ -169,6 +169,8 @@ def read_raw_cnt( emg=(), data_format="auto", date_format="mm/dd/yy", + *, + header="auto", preload=False, verbose=None, ): @@ -219,6 +221,13 @@ def read_raw_cnt( Defaults to ``'auto'``. date_format : ``'mm/dd/yy'`` | ``'dd/mm/yy'`` Format of date in the header. Defaults to ``'mm/dd/yy'``. + header : ``'auto'`` | ``'new'`` | ``'old'`` + Defines the header format. Used to describe how bad channels + are formatted. If auto, reads using old and new header and + if either contain a bad channel make channel bad. + Defaults to ``'auto'``. + + .. versionadded:: 1.6 %(preload)s %(verbose)s @@ -244,12 +253,13 @@ def read_raw_cnt( emg=emg, data_format=data_format, date_format=date_format, + header=header, preload=preload, verbose=verbose, ) -def _get_cnt_info(input_fname, eog, ecg, emg, misc, data_format, date_format): +def _get_cnt_info(input_fname, eog, ecg, emg, misc, data_format, date_format, header): """Read the cnt header.""" data_offset = 900 # Size of the 'SETUP' header. cnt_info = dict() @@ -340,13 +350,23 @@ def _get_cnt_info(input_fname, eog, ecg, emg, misc, data_format, date_format): ch_names, cals, baselines, chs, pos = (list(), list(), list(), list(), list()) bads = list() + _validate_type(header, str, "header") + _check_option("header", header, ("auto", "new", "old")) for ch_idx in range(n_channels): # ELECTLOC fields fid.seek(data_offset + 75 * ch_idx) ch_name = read_str(fid, 10) ch_names.append(ch_name) - fid.seek(data_offset + 75 * ch_idx + 4) - if np.fromfile(fid, dtype="u1", count=1).item(): - bads.append(ch_name) + + # Some files have bad channels marked differently in the header. + if header in ("new", "auto"): + fid.seek(data_offset + 75 * ch_idx + 14) + if np.fromfile(fid, dtype="u1", count=1).item(): + bads.append(ch_name) + if header in ("old", "auto"): + fid.seek(data_offset + 75 * ch_idx + 4) + if np.fromfile(fid, dtype="u1", count=1).item(): + bads.append(ch_name) + fid.seek(data_offset + 75 * ch_idx + 19) xy = np.fromfile(fid, dtype="f4", count=2) xy[1] *= -1 # invert y-axis @@ -451,6 +471,11 @@ class RawCNT(BaseRaw): Defaults to ``'auto'``. date_format : ``'mm/dd/yy'`` | ``'dd/mm/yy'`` Format of date in the header. Defaults to ``'mm/dd/yy'``. + header : ``'auto'`` | ``'new'`` | ``'old'`` + Defines the header format. Used to describe how bad channels + are formatted. If auto, reads using old and new header and + if either contain a bad channel make channel bad. + Defaults to ``'auto'``. %(preload)s stim_channel : bool | None Add a stim channel from the events. Defaults to None to trigger a @@ -478,6 +503,8 @@ def __init__( emg=(), data_format="auto", date_format="mm/dd/yy", + *, + header="auto", preload=False, verbose=None, ): # noqa: D102 @@ -489,7 +516,7 @@ def __init__( input_fname = path.abspath(input_fname) info, cnt_info = _get_cnt_info( - input_fname, eog, ecg, emg, misc, data_format, _date_format + input_fname, eog, ecg, emg, misc, data_format, _date_format, header ) last_samps = [cnt_info["n_samples"] - 1] super(RawCNT, self).__init__( diff --git a/mne/io/cnt/tests/test_cnt.py b/mne/io/cnt/tests/test_cnt.py index a5e5788eff3..c7dd956e9f9 100644 --- a/mne/io/cnt/tests/test_cnt.py +++ b/mne/io/cnt/tests/test_cnt.py @@ -19,7 +19,7 @@ @testing.requires_testing_data -def test_data(): +def test_old_data(): """Test reading raw cnt files.""" with pytest.warns(RuntimeWarning, match="number of bytes"): raw = _test_raw_reader( @@ -37,6 +37,39 @@ def test_data(): assert raw.info["meas_date"] is None +@testing.requires_testing_data +def test_new_data(): + """Test reading raw cnt files with different header.""" + with pytest.warns(RuntimeWarning): + raw = read_raw_cnt(input_fname=fname_bad_spans, header="new") + + assert raw.info["bads"] == ["F8"] # test bads + + +@testing.requires_testing_data +def test_auto_data(): + """Test reading raw cnt files with automatic header.""" + with pytest.warns(RuntimeWarning): + raw = read_raw_cnt(input_fname=fname_bad_spans) + + assert raw.info["bads"] == ["F8"] + + with pytest.warns(RuntimeWarning, match="number of bytes"): + raw = _test_raw_reader( + read_raw_cnt, input_fname=fname, eog="auto", misc=["NA1", "LEFT_EAR"] + ) + + # make sure we use annotations event if we synthesized stim + assert len(raw.annotations) == 6 + + eog_chs = pick_types(raw.info, eog=True, exclude=[]) + assert len(eog_chs) == 2 # test eog='auto' + assert raw.info["bads"] == ["LEFT_EAR", "VEOGR"] # test bads + + # the data has "05/10/200 17:35:31" so it is set to None + assert raw.info["meas_date"] is None + + @testing.requires_testing_data def test_compare_events_and_annotations(): """Test comparing annotations and events."""