Repository: mne-tools/mne-python

Issue:
The order of raw1.info["bads"] should not matter when concatenating with raw0.info["bads"]
### Description of the problem

I have a list of `raws` and combine them using `raw_concat = concatenate_raws(raws)`. However, I got the following error:

```Python
Exception has occurred: ValueError       (note: full exception trace is shown but execution is paused at: _run_module_as_main)
raw[1].info[bads] must match:
['LFP_R_4_STN_BS_RSTN', 'LFP_R_4_STN_BS'] != ['LFP_R_4_STN_BS', 'LFP_R_4_STN_BS_RSTN']
  File "/Users/moritzgerster/Library/CloudStorage/Dropbox/Code/BIDS_Neumann_ECoG_LFP/scripts/utils.py", line 482, in _combine_raws
    raw_concat = concatenate_raws(raws)
                 ^^^^^^^^^^^^^^^^^^^^^^
  File "<decorator-gen-238>", line 12, in concatenate_raws
  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/site-packages/mne/io/base.py", line 2542, in concatenate_raws
    _ensure_infos_match(info1=raws[0].info, info2=raw.info,
  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/site-packages/mne/io/meas_info.py", line 2884, in _ensure_infos_match
    raise ValueError(f'{name}.info[\'nchan\'] must match')
ValueError: raws[1].info['nchan'] must match

During handling of the above exception, another exception occurred:

  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/site-packages/mne/io/base.py", line 2495, in _check_raw_compatibility
    raise ValueError(
  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/site-packages/mne/io/base.py", line 1641, in append
    _check_raw_compatibility(all_raws)
  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/site-packages/mne/io/base.py", line 2551, in concatenate_raws
    raws[0].append(raws[1:], preload)
  File "<decorator-gen-238>", line 12, in concatenate_raws
  File "/Users/moritzgerster/Library/CloudStorage/Dropbox/Code/BIDS_Neumann_ECoG_LFP/scripts/utils.py", line 492, in _combine_raws
    raw_concat = concatenate_raws(raws)
                 ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/moritzgerster/Library/CloudStorage/Dropbox/Code/BIDS_Neumann_ECoG_LFP/scripts/_02_fooof_slopes.py", line 59, in get_fitted_spectra
    raw_concat = _combine_raws(bids_paths, save_path=bids_path)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/moritzgerster/Library/CloudStorage/Dropbox/Code/BIDS_Neumann_ECoG_LFP/scripts/_02_fooof_slopes.py", line 36, in psd_fooof_to_dataframe
    dics = get_fitted_spectra(paths_to_sub_in_cond)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/moritzgerster/Library/CloudStorage/Dropbox/Code/BIDS_Neumann_ECoG_LFP/scripts/main.py", line 28, in <module>
    psd_fooof_to_dataframe()
  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/runpy.py", line 88, in _run_code
    exec(code, run_globals)
  File "/Users/moritzgerster/anaconda3/envs/bids_neu/lib/python3.11/runpy.py", line 198, in _run_module_as_main (Current frame)
    return _run_code(code, main_globals, None,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: raw[1].info[bads] must match:
['LFP_R_4_STN_BS_RSTN', 'LFP_R_4_STN_BS'] != ['LFP_R_4_STN_BS', 'LFP_R_4_STN_BS_RSTN']
```

As you can see the bad channels 
`['LFP_R_4_STN_BS_RSTN', 'LFP_R_4_STN_BS'] != ['LFP_R_4_STN_BS', 'LFP_R_4_STN_BS_RSTN']`
are identical in both cases, only their order is wrong.


https://github.com/mne-tools/mne-python/blob/603a96da58ddba3c2c05d3a01639abd669cc9543/mne/io/base.py#L2506-L2513

The above lines of code should be replaced with:

```Python
        for key in ('nchan', 'sfreq'):
            a, b = raw[ri].info[key], raw[0].info[key]
            if a != b:
                raise ValueError(
                    f'raw[{ri}].info[{key}] must match:\n'
                    f'{repr(a)} != {repr(b)}')
        if not set(raw[ri].info['bads']) == set(raw[0].info['bads']):
            raise ValueError('raw[%d][\'info\'][\'bads\'] must match' % ri)
        if not set(raw[ri].info['ch_names']) == set(raw[0].info['ch_names']):
            raise ValueError('raw[%d][\'info\'][\'ch_names\'] must match' % ri)
```


### Steps to reproduce

```Python
import mne
from mne import create_info
from mne.io import RawArray
from numpy.random import default_rng

def create_toy_data(n_channels=3, sfreq=250, seed=None):
    rng = default_rng(seed)
    data = rng.standard_normal(size=(n_channels, 50 * sfreq)) * 5e-6
    info = create_info(n_channels, sfreq, "eeg")
    return RawArray(data, info)

raw0 = create_toy_data()
raw1 = create_toy_data()

raw0.info["bads"] = ["0", "1"]
raw1.info["bads"] = ["1", "0"]

raw_concat = mne.concatenate_raws([raw0, raw1])
```


### Link to data

_No response_

### Expected results

Concatenation should work.

### Actual results

ValueError.

### Additional information

```Python
Platform:         macOS-13.2.1-arm64-arm-64bit
Python:           3.11.0 | packaged by conda-forge | (main, Jan 14 2023, 12:26:40) [Clang 14.0.6 ]
Executable:       /Users/moritzgerster/anaconda3/envs/bids_neu/bin/python
CPU:              arm: 10 cores
Memory:           16.0 GB

mne:              1.3.0
numpy:            1.23.4 {OpenBLAS 0.3.21 with 10 threads}
scipy:            1.9.3
matplotlib:       3.6.2 {backend=MacOSX}

sklearn:          1.1.3
numba:            Not found
nibabel:          5.0.1
nilearn:          Not found
dipy:             Not found
openmeeg:         Not found
cupy:             Not found
pandas:           1.5.1
pyvista:          Not found
pyvistaqt:        Not found
ipyvtklink:       Not found
vtk:              Not found
qtpy:             Not found
ipympl:           Not found
pyqtgraph:        Not found
pooch:            v1.6.0

mne_bids:         0.12
mne_nirs:         Not found
mne_features:     Not found
mne_qt_browser:   Not found
mne_connectivity: Not found
mne_icalabel:     Not found
```


Discussion:

Resolving patch:
diff --git a/mne/io/base.py b/mne/io/base.py
--- a/mne/io/base.py
+++ b/mne/io/base.py
@@ -2503,22 +2503,27 @@ def _check_raw_compatibility(raw):
     for ri in range(1, len(raw)):
         if not isinstance(raw[ri], type(raw[0])):
             raise ValueError(f'raw[{ri}] type must match')
-        for key in ('nchan', 'bads', 'sfreq'):
+        for key in ('nchan', 'sfreq'):
             a, b = raw[ri].info[key], raw[0].info[key]
             if a != b:
                 raise ValueError(
                     f'raw[{ri}].info[{key}] must match:\n'
                     f'{repr(a)} != {repr(b)}')
-        if not set(raw[ri].info['ch_names']) == set(raw[0].info['ch_names']):
-            raise ValueError('raw[%d][\'info\'][\'ch_names\'] must match' % ri)
-        if not all(raw[ri]._cals == raw[0]._cals):
+        for kind in ('bads', 'ch_names'):
+            set1 = set(raw[0].info[kind])
+            set2 = set(raw[ri].info[kind])
+            mismatch = set1.symmetric_difference(set2)
+            if mismatch:
+                raise ValueError(f'raw[{ri}][\'info\'][{kind}] do not match: '
+                                 f'{sorted(mismatch)}')
+        if any(raw[ri]._cals != raw[0]._cals):
             raise ValueError('raw[%d]._cals must match' % ri)
         if len(raw[0].info['projs']) != len(raw[ri].info['projs']):
             raise ValueError('SSP projectors in raw files must be the same')
         if not all(_proj_equal(p1, p2) for p1, p2 in
                    zip(raw[0].info['projs'], raw[ri].info['projs'])):
             raise ValueError('SSP projectors in raw files must be the same')
-    if not all(r.orig_format == raw[0].orig_format for r in raw):
+    if any(r.orig_format != raw[0].orig_format for r in raw):
         warn('raw files do not all have the same data format, could result in '
              'precision mismatch. Setting raw.orig_format="unknown"')
         raw[0].orig_format = 'unknown'


Test patch:
diff --git a/mne/io/fiff/tests/test_raw_fiff.py b/mne/io/fiff/tests/test_raw_fiff.py
--- a/mne/io/fiff/tests/test_raw_fiff.py
+++ b/mne/io/fiff/tests/test_raw_fiff.py
@@ -28,7 +28,7 @@
 from mne.io.tests.test_raw import _test_concat, _test_raw_reader
 from mne import (concatenate_events, find_events, equalize_channels,
                  compute_proj_raw, pick_types, pick_channels, create_info,
-                 pick_info)
+                 pick_info, make_fixed_length_epochs)
 from mne.utils import (requires_pandas, assert_object_equal, _dt_to_stamp,
                        requires_mne, run_subprocess, _record_warnings,
                        assert_and_remove_boundary_annot)
@@ -382,6 +382,54 @@ def test_concatenate_raws(on_mismatch):
             concatenate_raws(**kws)
 
 
+def _create_toy_data(n_channels=3, sfreq=250, seed=None):
+    rng = np.random.default_rng(seed)
+    data = rng.standard_normal(size=(n_channels, 50 * sfreq)) * 5e-6
+    info = create_info(n_channels, sfreq, "eeg")
+    return RawArray(data, info)
+
+
+def test_concatenate_raws_bads_order():
+    """Test concatenation of raw instances."""
+    raw0 = _create_toy_data()
+    raw1 = _create_toy_data()
+
+    # Test bad channel order
+    raw0.info["bads"] = ["0", "1"]
+    raw1.info["bads"] = ["1", "0"]
+
+    # raw0 is modified in-place and therefore copied
+    raw_concat = concatenate_raws([raw0.copy(), raw1])
+
+    # Check data are equal
+    data_concat = np.concatenate([raw0.get_data(), raw1.get_data()], 1)
+    assert np.all(raw_concat.get_data() == data_concat)
+
+    # Check bad channels
+    assert set(raw_concat.info["bads"]) == {"0", "1"}
+
+    # Bad channel mismatch raises
+    raw2 = raw1.copy()
+    raw2.info["bads"] = ["0", "2"]
+    with pytest.raises(ValueError):
+        concatenate_raws([raw0, raw2])
+
+    # Type mismatch raises
+    epochs1 = make_fixed_length_epochs(raw1)
+    with pytest.raises(ValueError):
+        concatenate_raws([raw0, epochs1])
+
+    # Sample rate mismatch
+    raw3 = _create_toy_data(sfreq=500)
+    with pytest.raises(ValueError):
+        concatenate_raws([raw0, raw3])
+
+    # Number of channels mismatch
+    raw4 = _create_toy_data(n_channels=4)
+    with pytest.raises(ValueError):
+        concatenate_raws([raw0, raw4])
+
+
 @testing.requires_testing_data
 @pytest.mark.parametrize('mod', (
     'meg',