Repository: mne-tools/mne-bids

Issue:
read_raw_bids() respects info['bads'], but it really shouldn't
#### Describe the bug
While working in #491 I think I've discovered a bug in `_handle_channels_reading()`, which is used by `read_raw_bids()`. Specifically, when reading data and populating the list of "bads", MNE-BIDS currently **merges** the information from `_channels.tsv` and `info['bads']` in the data file. I think this is not correct, and **only** `_channels.tsv` should be honored.

https://github.com/mne-tools/mne-bids/blob/8eec738aa521344e5c772d8a744f4838059f28d8/mne_bids/read.py#L259-L263

#### Steps to reproduce
Checkout `hoechenberger:mark-bads` (the branch from  #491). Run:

```python
import os.path as op
import mne
from mne_bids import (make_bids_basename, write_raw_bids, read_raw_bids,
                      mark_bad_channels)

data_path = mne.datasets.sample.data_path()
raw_fname = op.join(data_path, 'MEG', 'sample', 'sample_audvis_raw.fif')
bids_root = op.join(data_path, '..', 'MNE-sample-data-bids')
bids_basename = make_bids_basename(subject='01', session='01',
                                   task='audiovisual', run='01')

raw = mne.io.read_raw_fif(raw_fname, verbose=False)
print(f'The following channels are marked as bad: {raw.info["bads"]}')

write_raw_bids(raw, bids_basename=bids_basename, bids_root=bids_root,
               overwrite=True, verbose=False)

# Mark two channels as bad, RETAINING existing bads.
bads = ['MEG 0112', 'MEG 0131']
mark_bad_channels(ch_names=bads, bids_basename=bids_basename,
                  bids_root=bids_root)

raw = read_raw_bids(bids_basename=bids_basename, bids_root=bids_root,
                    verbose=False)
print(f'After marking MEG 0112 and MEG 0131 as bad, the following channels '
      f'are now marked as bad: {raw.info["bads"]}')

# Mark the same channels as bad, NOT RETAINING any existing bads.
bads = ['MEG 0112', 'MEG 0131']
mark_bad_channels(ch_names=bads, bids_basename=bids_basename,
                  bids_root=bids_root, overwrite=True)

raw = read_raw_bids(bids_basename=bids_basename, bids_root=bids_root,
                    verbose=False)
print(f'After marking MEG 0112 and MEG 0131 as bad and passing '
      f'`overwrite=True`, the following channels '
      f'are now marked as bad: {raw.info["bads"]}')


```

#### Expected results
```
The following channels are marked as bad: ['MEG 2443', 'EEG 053']

After marking MEG 0112 and MEG 0131 as bad, the following channels are now marked as bad: ['MEG 0112', 'EEG 053', 'MEG 0131', 'MEG 2443']

After marking MEG 0112 and MEG 0131 as bad and passing `overwrite=True`, the following channels are now marked as bad: ['MEG 0131', 'MEG 0112']
```

#### Actual results
```
The following channels are marked as bad: ['MEG 2443', 'EEG 053']

After marking MEG 0112 and MEG 0131 as bad, the following channels are now marked as bad: ['MEG 0112', 'EEG 053', 'MEG 0131', 'MEG 2443']

After marking MEG 0112 and MEG 0131 as bad and passing `overwrite=True`, the following channels are now marked as bad: ['EEG 053', 'MEG 0131', 'MEG 0112', 'MEG 2443']
```

#### Additional information
Looking into `sub-01_ses-01_task-audiovisual_run-01_channels.tsv`, it's clear that it's being updated correctly:
```
$ grep bad sub-01_ses-01_task-audiovisual_run-01_channels.tsv
MEG 0112        MEGGRADPLANAR   T/m     0.10000000149011612     172.17630004882812      Planar Gradiometer      600.614990234375        bad     n/a
MEG 0131        MEGMAG  T       0.10000000149011612     172.17630004882812      Magnetometer    600.614990234375        bad     n/a
$
```


Discussion:
Agreed


Resolving patch:
diff --git a/mne_bids/read.py b/mne_bids/read.py
--- a/mne_bids/read.py
+++ b/mne_bids/read.py
@@ -256,11 +256,24 @@ def _handle_channels_reading(channels_fname, bids_fname, raw):
         # find bads from channels.tsv
         bad_bool = [True if chn.lower() == 'bad' else False
                     for chn in channels_dict['status']]
-        bads = np.asarray(channels_dict['name'])[bad_bool]
-
-        # merge with bads already present in raw data file (if there are any)
-        unique_bads = set(raw.info['bads']).union(set(bads))
-        raw.info['bads'] = list(unique_bads)
+        bads_from_tsv = np.asarray(channels_dict['name'])[bad_bool]
+
+        if raw.info['bads'] and set(bads_from_tsv) != set(raw.info['bads']):
+            warn(f'Encountered conflicting information on channel status '
+                 f'between {op.basename(channels_fname)} and the associated '
+                 f'raw data file.\n'
+                 f'Channels marked as bad in '
+                 f'{op.basename(channels_fname)}: {bads_from_tsv}\n'
+                 f'Channels marked as bad in '
+                 f'raw.info["bads"]: {raw.info["bads"]}\n'
+                 f'Setting list of bad channels to: {bads_from_tsv}')
+
+        raw.info['bads'] = bads_from_tsv
+    elif raw.info['bads']:
+        # We do have info['bads'], but no `status` in channels.tsv
+        logger.info(f'No "status" column found in '
+                    f'{op.basename(channels_fname)}; using list of bad '
+                    f'channels found in raw.info["bads"]: {raw.info["bads"]}')
 
     return raw
 


Test patch:
diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -782,3 +782,68 @@ def test_handle_channel_type_casing():
 
     with pytest.warns(RuntimeWarning, match='lowercase spelling'):
         read_raw_bids(bids_basename, bids_root=bids_root)
+
+
+@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
+def test_bads_reading():
+    bids_root = _TempDir()
+    channels_fname = (bids_basename.copy()
+                      .update(prefix=op.join(bids_root, 'sub-01', 'ses-01',
+                                             'meg'),
+                              suffix='channels.tsv'))
+    raw_bids_fname = (bids_basename.copy()
+                      .update(prefix=op.join(bids_root, 'sub-01', 'ses-01',
+                                             'meg'),
+                              suffix='meg.fif'))
+    raw = mne.io.read_raw_fif(raw_fname, verbose=False)
+
+    ###########################################################################
+    # bads in FIF only, no `status` column in channels.tsv
+    bads = ['EEG 053', 'MEG 2443']
+    raw.info['bads'] = bads
+    write_raw_bids(raw, bids_basename, bids_root, overwrite=True,
+                   verbose=False)
+
+    # Delete `status` column
+    tsv_data = _from_tsv(channels_fname)
+    del tsv_data['status'], tsv_data['status_description']
+    _to_tsv(tsv_data, fname=channels_fname)
+
+    raw = read_raw_bids(bids_basename=bids_basename, bids_root=bids_root,
+                        verbose=False)
+    assert raw.info['bads'] == bads
+
+    ###########################################################################
+    # bads in `status` column in channels.tsv, no bads in raw.info['bads']
+    bads = ['EEG 053', 'MEG 2443']
+    raw.info['bads'] = bads
+    write_raw_bids(raw, bids_basename, bids_root, overwrite=True,
+                   verbose=False)
+
+    # Remove info['bads'] from the raw file.
+    raw = mne.io.read_raw_fif(raw_bids_fname, preload=True, verbose=False)
+    raw.info['bads'] = []
+    raw.save(raw_bids_fname, overwrite=True, verbose=False)
+
+    raw = read_raw_bids(bids_basename=bids_basename, bids_root=bids_root,
+                        verbose=False)
+    assert set(raw.info['bads']) == set(bads)
+
+    ###########################################################################
+    # Different bads in `status` column and raw.info['bads']
+    bads_bids = ['EEG 053', 'MEG 2443']
+    bads_raw = ['MEG 0112', 'MEG 0131']
+
+    raw.info['bads'] = bads_bids
+    write_raw_bids(raw, bids_basename, bids_root, overwrite=True,
+                   verbose=False)
+
+    # Replace info['bads'] in the raw file.
+    raw = mne.io.read_raw_fif(raw_bids_fname, preload=True, verbose=False)
+    raw.info['bads'] = bads_raw
+    raw.save(raw_bids_fname, overwrite=True, verbose=False)
+
+    with pytest.warns(RuntimeWarning, match='conflicting information'):
+        raw = read_raw_bids(bids_basename=bids_basename, bids_root=bids_root,
+                            verbose=False)
+    assert set(raw.info['bads']) == set(bads_bids)