Repository: mne-tools/mne-python

Issue:
ENH: Add montage.rename_channels
It should just be a simple dict remapping, I think. It seems like a nicer thing to do than ask people to rename their raw data channels (and possibly rename them back).


Discussion:
I don't get it

>

In things like:
```
decoding/plot_decoding_csp_eeg.py:raw.rename_channels(lambda x: x.strip('.'))
time_frequency/plot_time_frequency_erds.py:raw.rename_channels(lambda x: x.strip('.'))  # remove dots from channel names
visualization/plot_roi_erpimage_by_rt.py:raw.rename_channels(mapping)
```
we rename the channels in the `raw` instance just so that we can successfully call `apply_montage`. It seems cleaner (and reasonable) to want to keep the original channel names in `raw` as they are, and be able to do `montage.rename_channels(...)` instead of `raw.rename_channels(...)` before calling `raw.set_montage`. If you do want to keep your original channel names, this allows you to do it, otherwise (in `master`) you need another call to `raw.rename_channels({val: key for key, val in mapping})` or so.

There have been several times I have hacked this sort of thing together personally. For example, if I want to replace digitized EEG locations with montage ones (for example for data simulation or testing) and I have the mapping from my EEGXXX to 10-20 channels, it would be nicer to do `read_dig_montage('standard_1020').rename_channels(...)` knowing my mapping than it is to rename my `raw` data to use the `standard_1020` names.

Since raw data is more "permanent" and montages transient / added to the `raw` data, it seems to make sense to make the `montage` the more flexible/changeable entity.
got it.

Yes it seems useful indeed.

>


Resolving patch:
diff --git a/mne/channels/channels.py b/mne/channels/channels.py
--- a/mne/channels/channels.py
+++ b/mne/channels/channels.py
@@ -462,15 +462,13 @@ def set_channel_types(self, mapping, verbose=None):
             warn(msg.format(", ".join(sorted(names)), *this_change))
         return self
 
+    @fill_doc
     def rename_channels(self, mapping):
         """Rename channels.
 
         Parameters
         ----------
-        mapping : dict | callable
-            A dictionary mapping the old channel to a new channel name
-            e.g. {'EEG061' : 'EEG161'}. Can also be a callable function
-            that takes and returns a string (new in version 0.10.0).
+        %(rename_channels_mapping)s
 
         Returns
         -------
@@ -1027,6 +1025,7 @@ def interpolate_bads(self, reset_bads=True, mode='accurate',
         return self
 
 
+@fill_doc
 def rename_channels(info, mapping):
     """Rename channels.
 
@@ -1035,12 +1034,10 @@ def rename_channels(info, mapping):
     Parameters
     ----------
     info : dict
-        Measurement info.
-    mapping : dict | callable
-        A dictionary mapping the old channel to a new channel name
-        e.g. {'EEG061' : 'EEG161'}. Can also be a callable function
-        that takes and returns a string (new in version 0.10.0).
+        Measurement info to modify.
+    %(rename_channels_mapping)s
     """
+    _validate_type(info, Info, 'info')
     info._check_consistency()
     bads = list(info['bads'])  # make our own local copies
     ch_names = list(info['ch_names'])
diff --git a/mne/channels/montage.py b/mne/channels/montage.py
--- a/mne/channels/montage.py
+++ b/mne/channels/montage.py
@@ -28,6 +28,7 @@
                                 _get_dig_eeg, _make_dig_points, write_dig,
                                 _read_dig_fif, _format_dig_points,
                                 _get_fid_coords, _coord_frame_const)
+from ..io.meas_info import create_info
 from ..io.open import fiff_open
 from ..io.pick import pick_types
 from ..io.constants import FIFF
@@ -188,6 +189,23 @@ def plot(self, scale_factor=20, show_names=True, kind='topomap', show=True,
                             show_names=show_names, kind=kind, show=show,
                             sphere=sphere)
 
+    def rename_channels(self, mapping):
+        """Rename the channels.
+
+        Parameters
+        ----------
+        %(rename_channels_mapping)s
+
+        Returns
+        -------
+        inst : instance of DigMontage
+            The instance. Operates in-place.
+        """
+        from .channels import rename_channels
+        temp_info = create_info(list(self._get_ch_pos()), 1000., 'eeg')
+        rename_channels(temp_info, mapping)
+        self.ch_names = temp_info['ch_names']
+
     def save(self, fname):
         """Save digitization points to FIF.
 
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -802,6 +802,15 @@
 
     .. versionadded:: 0.20.1
 """
+docdict['rename_channels_mapping'] = """
+mapping : dict | callable
+    A dictionary mapping the old channel to a new channel name
+    e.g. {'EEG061' : 'EEG161'}. Can also be a callable function
+    that takes and returns a string.
+
+    .. versionchanged:: 0.10.0
+       Support for a callable function.
+"""
 
 # Brain plotting
 docdict["clim"] = """


Test patch:
diff --git a/mne/channels/tests/test_montage.py b/mne/channels/tests/test_montage.py
--- a/mne/channels/tests/test_montage.py
+++ b/mne/channels/tests/test_montage.py
@@ -16,7 +16,7 @@
 from numpy.testing import (assert_array_equal,
                            assert_allclose, assert_equal)
 
-from mne import __file__ as _mne_file, create_info, read_evokeds
+from mne import __file__ as _mne_file, create_info, read_evokeds, pick_types
 from mne.utils._testing import _dig_sort_key
 from mne.channels import (get_builtin_montages, DigMontage, read_dig_dat,
                           read_dig_egi, read_dig_captrak, read_dig_fif,
@@ -901,21 +901,58 @@ def test_read_dig_captrak(tmpdir):
                             [-0.005103, 0.05395, 0.144622], rtol=1e-04)
 
 
-def test_set_montage_mgh():
+# https://gist.github.com/larsoner/2264fb5895070d29a8c9aa7c0dc0e8a6
+_MGH60 = [
+    'Fz', 'F2', 'AF4', 'Fpz', 'Fp1', 'AF8', 'FT9', 'F7', 'FC5', 'FC6', 'FT7',
+    'F1', 'AF7', 'FT8', 'F6', 'F5', 'FC1', 'FC2', 'FT10', 'T9', 'Cz', 'F4',
+    'T7', 'C2', 'C4', 'C1', 'C3', 'F8', 'F3', 'C5', 'Fp2', 'AF3',
+    'CP2', 'P2', 'O2', 'Iz', 'Oz', 'PO4', 'O1', 'P8', 'PO8', 'P6', 'PO7', 'PO3', 'C6', 'TP9', 'TP8', 'CP4', 'P4',  # noqa
+    'CP3', 'CP1', 'TP7', 'P3', 'Pz', 'P1', 'P7', 'P5', 'TP10', 'T8', 'T10',
+]
+
+
+@pytest.mark.parametrize('rename', ('raw', 'montage', 'custom'))
+def test_set_montage_mgh(rename):
     """Test setting 'mgh60' montage to old fif."""
     raw = read_raw_fif(fif_fname)
-    raw.rename_channels(lambda x: x.replace('EEG ', 'EEG'))
-
-    orig_pos = np.array([ch['loc'][:3] for ch in raw.info['chs']
-                         if ch['ch_name'].startswith('EEG')])
+    eeg_picks = pick_types(raw.info, meg=False, eeg=True, exclude=())
+    assert list(eeg_picks) == [ii for ii, name in enumerate(raw.ch_names)
+                               if name.startswith('EEG')]
+    orig_pos = np.array([raw.info['chs'][pick]['loc'][:3]
+                         for pick in eeg_picks])
+    atol = 1e-6
+    if rename == 'raw':
+        raw.rename_channels(lambda x: x.replace('EEG ', 'EEG'))
+        raw.set_montage('mgh60')  # test loading with string argument
+    elif rename == 'montage':
+        mon = make_standard_montage('mgh60')
+        mon.rename_channels(lambda x: x.replace('EEG', 'EEG '))
+        assert [raw.ch_names[pick] for pick in eeg_picks] == mon.ch_names
+        raw.set_montage(mon)
+    else:
+        atol = 3e-3  # XXX old defs here apparently (maybe not realistic)?
+        assert rename == 'custom'
+        assert len(_MGH60) == 60
+        mon = make_standard_montage('standard_1020')
+
+        def renamer(x):
+            try:
+                return 'EEG %03d' % (_MGH60.index(x) + 1,)
+            except ValueError:
+                return x
+
+        mon.rename_channels(renamer)
+        raw.set_montage(mon)
 
-    raw.set_montage('mgh60')  # test loading with string argument
     new_pos = np.array([ch['loc'][:3] for ch in raw.info['chs']
                         if ch['ch_name'].startswith('EEG')])
     assert ((orig_pos != new_pos).all())
 
     r0 = _fit_sphere(new_pos)[1]
     assert_allclose(r0, [0.000775, 0.006881, 0.047398], atol=1e-3)
+    # spot check
+    assert_allclose(new_pos[:2], [[0.000273, 0.084920, 0.105838],
+                                  [0.028822, 0.083529, 0.099164]], atol=atol)
 
 
 # XXX: this does not check ch_names + it cannot work because of write_dig