Repository: mne-tools/mne-python

Issue:
mne.concatenate_raws(raws) wrongly concatenates raws if the order of the channel names varies across raws
### Description of the problem

[For a description of the problem check this pdf.](https://github.com/mne-tools/mne-python/files/11248385/mne_concatenate_channels_order.pdf)

`mne.concatenate_raws(raws)` wrongly concatenates raws if the order of the channel names varies across raws.

In my opinion, this is a huge bug that I noticed only accidentally. In my data, I add reference channels when they are missing and the index of this channel varies across my raws. In other words, the order of `raw.ch_names` is not consistent across my raws. This means, I cannot use` mne.concatenate_raws `and I don't know how to fix this as `raw.ch_names = sorted(raw.ch_names)` does not work.

I would have never thought that this function does not check the names of the channels it concatenates.

### Steps to reproduce

```Python
See jupyter-notebook pdf above.
```


### Link to data

_No response_

### Expected results

`mne.concatenate_raws` reads in the channel names and only concatenates `"ch1"` of `raw1` with `"ch1"` of `raw2`.

### Actual results

`mne.concatenate_raws` concatenates `raw1.ch_names[0]` with `raw2.ch_names[0]` without checking whether `raw1.ch_names[0] == raw2.ch_names[0]`.

### Additional information

Platform:         macOS-11.7.3-x86_64-i386-64bit
Python:           3.11.0 | packaged by conda-forge | (main, Jan 15 2023, 05:44:48) [Clang 14.0.6 ]
Executable:       /opt/anaconda3/envs/bids_neu/bin/python
CPU:              i386: 8 cores
Memory:           16.0 GB

mne:              1.3.0
numpy:            1.23.4 {OpenBLAS 0.3.20 with 4 threads}
scipy:            1.10.0
matplotlib:       3.7.0 {backend=module://matplotlib_inline.backend_inline}

sklearn:          1.2.1
numba:            Not found
nibabel:          5.0.1
nilearn:          Not found
dipy:             Not found
openmeeg:         Not found
cupy:             Not found
pandas:           1.5.3
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:
agramfort: @moritz-gerster can you add a tiny test using simulated data?

thanks
moritz-gerster: > @moritz-gerster can you add a tiny test using simulated data?

@agramfort done. 


mscheltienne: Hello, no we don't see "pending" comments. You probably commented from the tab "Files Changed" and it added your changes as "pending" until you release the review. On the same tab, you need to press on "Review Changes" and "Submit review".

![image](https://user-images.githubusercontent.com/73893616/234087948-2d9d2fcd-b950-44c2-84a8-821cc12c5ad3.png)

drammock: > Can you actually see my comments @larsoner? I am confused because my comments have the label "pending".

Usually that means that on the files changed tab you clicked "start a review" and made several comments but then never clicked "finish your review"
larsoner: +1 for checking if they match and raising an error if they don't, and providing a `match_channel_orders` or so that does the matching. Then we can add options like `copy=True (default) | False` that either returns copies or not without having to add new kwargs to `concatenate_raws`
moritz-gerster: > +1 for checking if they match and raising an error if they don't, and providing a match_channel_orders or so that does the matching. Then we can add options like copy=True (default) | False that either returns copies or not without having to add new kwargs to concatenate_raws

@larsoner done.

moritz-gerster: > Other than my detailed comments (below) one general comment is that the new test is very thorough! It might even be overkill; I'll let @larsoner decide if it needs some pruning (our current tests take a really long time to run so we are very cautious about how fast new tests run)

thanks @drammock! I addressed all your comments
moritz-gerster: again: thanks @drammock! I addressed all your comments
drammock: ci failure is this:
```
 _______________________ test_brain_save_movie[pyvistaqt] _______________________
mne/viz/_brain/tests/test_brain.py:530: in test_brain_save_movie
    brain.save_movie(filename, time_dilation=1, tmin=1, tmax=1.1,
mne/viz/_brain/_brain.py:3524: in save_movie
    func(filename, time_dilation, tmin, tmax,
mne/viz/_brain/_brain.py:3419: in _save_movie
    import imageio
E   ModuleNotFoundError: No module named 'imageio'
```
??? I looked at their recent commit history, nothing jumps out as an obvious culprit.

Patch:
diff --git a/doc/sensor_space.rst b/doc/sensor_space.rst
index a1c72b3aa..b4bbda600 100644
--- a/doc/sensor_space.rst
+++ b/doc/sensor_space.rst
@@ -11,6 +11,7 @@ Sensor Space Data
    concatenate_raws
    equalize_channels
    grand_average
+   match_channel_orders
    pick_channels
    pick_channels_cov
    pick_channels_forward
diff --git a/mne/__init__.py b/mne/__init__.py
index fc799f9d5..27a284688 100644
--- a/mne/__init__.py
+++ b/mne/__init__.py
@@ -35,7 +35,7 @@ from .io.pick import (pick_types, pick_channels,
                       pick_types_forward, pick_channels_cov,
                       pick_channels_evoked, pick_info,
                       channel_type, channel_indices_by_type)
-from .io.base import concatenate_raws
+from .io.base import concatenate_raws, match_channel_orders
 from .io.meas_info import create_info, Info
 from .io.proj import Projection
 from .io.kit import read_epochs_kit
diff --git a/mne/io/__init__.py b/mne/io/__init__.py
index 0abb70487..8af949549 100644
--- a/mne/io/__init__.py
+++ b/mne/io/__init__.py
@@ -66,6 +66,6 @@ from .eyelink import read_raw_eyelink
 # for backward compatibility
 from .fiff import Raw
 from .fiff import Raw as RawFIF
-from .base import concatenate_raws
+from .base import concatenate_raws, match_channel_orders
 from .reference import (set_eeg_reference, set_bipolar_reference,
                         add_reference_channels)
diff --git a/mne/io/base.py b/mne/io/base.py
index df198af40..867c58656 100644
--- a/mne/io/base.py
+++ b/mne/io/base.py
@@ -2570,6 +2570,28 @@ def concatenate_raws(raws, preload=None, events_list=None, *,
         return raws[0], events
 
 
+@fill_doc
+def match_channel_orders(raws, copy=True):
+    """Ensure consistent channel order across raws.
+
+    Parameters
+    ----------
+    raws : list
+        List of :class:`~mne.io.Raw` instances to order.
+    %(copy_df)s
+
+    Returns
+    -------
+    list of Raw
+        List of Raws with matched channel orders.
+    """
+    raws = deepcopy(raws) if copy else raws
+    ch_order = raws[0].ch_names
+    for raw in raws[1:]:
+        raw.reorder_channels(ch_order)
+    return raws
+
+
 def _check_maxshield(allow_maxshield):
     """Warn or error about MaxShield."""
     msg = ('This file contains raw Internal Active '
diff --git a/mne/io/meas_info.py b/mne/io/meas_info.py
index 9bc63b380..3e6eb62c4 100644
--- a/mne/io/meas_info.py
+++ b/mne/io/meas_info.py
@@ -2906,6 +2906,10 @@ def _ensure_infos_match(info1, info2, name, *, on_mismatch='raise'):
         raise ValueError(f'{name}.info[\'sfreq\'] must match')
     if set(info1['ch_names']) != set(info2['ch_names']):
         raise ValueError(f'{name}.info[\'ch_names\'] must match')
+    if info1['ch_names'] != info2['ch_names']:
+        msg = (f'{name}.info[\'ch_names\']: Channel order must match. Use '
+                '"mne.match_channel_orders()" to sort channels.')
+        raise ValueError(msg)
     if len(info2['projs']) != len(info1['projs']):
         raise ValueError(f'SSP projectors in {name} must be the same')
     if any(not _proj_equal(p1, p2) for p1, p2 in


Test patch:
diff --git a/mne/io/fiff/tests/test_raw_fiff.py b/mne/io/fiff/tests/test_raw_fiff.py
index 25e2ad179..febd699d2 100644
--- a/mne/io/fiff/tests/test_raw_fiff.py
+++ b/mne/io/fiff/tests/test_raw_fiff.py
@@ -21,7 +21,8 @@ import pytest
 from mne.datasets import testing
 from mne.filter import filter_data
 from mne.io.constants import FIFF
-from mne.io import RawArray, concatenate_raws, read_raw_fif, base
+from mne.io import (RawArray, concatenate_raws, read_raw_fif,
+                    match_channel_orders, base)
 from mne.io.open import read_tag, read_tag_info
 from mne.io.tag import _read_tag_header
 from mne.io.tests.test_raw import _test_concat, _test_raw_reader
@@ -389,7 +390,7 @@ def _create_toy_data(n_channels=3, sfreq=250, seed=None):
 
 
 def test_concatenate_raws_bads_order():
-    """Test concatenation of raw instances."""
+    """Test concatenation of raws when the order of *bad* channels varies."""
     raw0 = _create_toy_data()
     raw1 = _create_toy_data()
 
@@ -410,25 +411,58 @@ def test_concatenate_raws_bads_order():
     # Bad channel mismatch raises
     raw2 = raw1.copy()
     raw2.info["bads"] = ["0", "2"]
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="bads.*must match"):
         concatenate_raws([raw0, raw2])
 
     # Type mismatch raises
     epochs1 = make_fixed_length_epochs(raw1)
-    with pytest.raises(ValueError):
-        concatenate_raws([raw0, epochs1])
+    with pytest.raises(ValueError, match="type.*must match"):
+        concatenate_raws([raw0, epochs1.load_data()])
 
     # Sample rate mismatch
     raw3 = _create_toy_data(sfreq=500)
-    with pytest.raises(ValueError):
+    raw3.info["bads"] = ["0", "1"]
+    with pytest.raises(ValueError, match="info.*must match"):
         concatenate_raws([raw0, raw3])
 
     # Number of channels mismatch
     raw4 = _create_toy_data(n_channels=4)
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="nchan.*must match"):
         concatenate_raws([raw0, raw4])
 
 
+def test_concatenate_raws_order():
+    """Test concatenation of raws when the order of *good* channels varies."""
+    raw0 = _create_toy_data(n_channels=2)
+    raw0._data[0] = np.zeros_like(raw0._data[0])  # set one channel zero
+
+    # Create copy and concatenate raws
+    raw1 = raw0.copy()
+    raw_concat = concatenate_raws([raw0.copy(), raw1])
+    assert raw0.ch_names == raw1.ch_names == raw_concat.ch_names == ["0", "1"]
+    ch0 = raw_concat.get_data(picks=["0"])
+    assert np.all(ch0 == 0)
+
+    # Change the order of the channels and concatenate again
+    raw1.reorder_channels(["1", "0"])
+    assert raw1.ch_names == ["1", "0"]
+    raws = [raw0.copy(), raw1]
+    with pytest.raises(ValueError, match="Channel order must match."):
+        # Fails now due to wrong order of channels
+        raw_concat = concatenate_raws(raws)
+
+    with pytest.raises(ValueError, match="Channel order must match."):
+        # still fails, because raws is copied and not changed in place
+        match_channel_orders(raws, copy=True)
+        raw_concat = concatenate_raws(raws)
+
+    # Now passes because all raws have the same order
+    match_channel_orders(raws, copy=False)
+    raw_concat = concatenate_raws(raws)
+    ch0 = raw_concat.get_data(picks=["0"])
+    assert np.all(ch0 == 0)
+
+
 @testing.requires_testing_data
 @pytest.mark.parametrize('mod', (
     'meg',