Repository: mne-tools/mne-python

Issue:
BUG:mne.create_info function mismatch channel names with DigMontage
#### Describe the bug
In 0.19.2, **mne.channels.read_montage** has been replaced by **mne.channels.make_standard_montage**, which returns a **DigMontage** object. 

However, **mne.create_info** with **DigMontage** can not match channel names in a case insensitive manner. 


#### Steps to reproduce
```
import mne

montage = mne.channels.make_standard_montage('standard_1005')
info = mne.create_info(
    ['FPz'], 100, 
    ch_types=['eeg'], montage=montage)
```

#### Actual results
```console
~/miniconda3/envs/ml/lib/python3.7/site-packages/mne/utils/_logging.py in wrapper(*args, **kwargs)
     88             with use_log_level(verbose_level):
     89                 return function(*args, **kwargs)
---> 90         return function(*args, **kwargs)
     91     return FunctionMaker.create(
     92         function, 'return decfunc(%(signature)s)',

~/miniconda3/envs/ml/lib/python3.7/site-packages/mne/io/meas_info.py in create_info(ch_names, sfreq, ch_types, montage, verbose)
   1798         for montage_ in montage:
   1799             if isinstance(montage_, (str, Montage, DigMontage)):
-> 1800                 _set_montage(info, montage_)
   1801             else:
   1802                 raise TypeError('Montage must be an instance of Montage, '

~/miniconda3/envs/ml/lib/python3.7/site-packages/mne/channels/montage.py in _set_montage(info, montage, update_ch_names, set_dig, raise_if_subset)
   1564             info_names=set([info['ch_names'][ii] for ii in _pick_chs(info)]),
   1565             montage_names=set(ch_pos),
-> 1566             raise_if_subset=_raise,  # XXX: deprecated param to remove in 0.20
   1567         )
   1568 

~/miniconda3/envs/ml/lib/python3.7/site-packages/mne/channels/montage.py in _check_ch_names_are_compatible(info_names, montage_names, raise_if_subset)
     91                 '\nYou can use `raise_if_subset=False` in `set_montage` to'
     92                 ' avoid this ValueError and get a DeprecationWarning instead.'
---> 93             ).format(n_ch=len(not_in_montage), ch_names=not_in_montage))
     94         else:
     95             # XXX: deprecated. to remove in 0.20 (raise_if_subset, too)

ValueError: DigMontage is a only a subset of info. There are 1 channel positions not present it the DigMontage. The required channels are: {'FPz'}.
You can use `raise_if_subset=False` in `set_montage` to avoid this ValueError and get a DeprecationWarning instead.
```



Discussion:
It seems reasonable to me to make the name comparisons case insensitive. Okay with you @agramfort ?
We doing the API change as mne requires unique channel names in case sensitive manner it was decided that case match should be enforced to avoid bugs. Maybe a param to set_montage could be added though. Something like match_case or something 

I meet the same bug.
Channel naming rules are not uniform.
Such as standard_1005:   Fp1 & CP1.
you mean our default standard montage are not uniformly named?

maybe a parameter in set_montage is the way.


Resolving patch:
diff --git a/mne/channels/channels.py b/mne/channels/channels.py
--- a/mne/channels/channels.py
+++ b/mne/channels/channels.py
@@ -511,9 +511,8 @@ def rename_channels(self, mapping):
         rename_channels(self.info, mapping)
 
     @verbose
-    def set_montage(
-        self, montage, raise_if_subset=DEPRECATED_PARAM, verbose=None
-    ):
+    def set_montage(self, montage, raise_if_subset=DEPRECATED_PARAM,
+                    match_case=True, verbose=None):
         """Set EEG sensor configuration and head digitization.
 
         Parameters
@@ -528,6 +527,7 @@ def set_montage(
             0.20, and will be removed in 0.21.
 
             .. versionadded:: 0.19
+        %(match_case)s
         %(verbose_meth)s
 
         Returns
@@ -545,7 +545,7 @@ def set_montage(
         # https://gist.github.com/massich/f6a9f4799f1fbeb8f5e8f8bc7b07d3df
 
         from .montage import _set_montage
-        _set_montage(self.info, montage, raise_if_subset=raise_if_subset)
+        _set_montage(self.info, montage, raise_if_subset, match_case)
         return self
 
     @verbose
diff --git a/mne/channels/montage.py b/mne/channels/montage.py
--- a/mne/channels/montage.py
+++ b/mne/channels/montage.py
@@ -32,7 +32,7 @@
 from ..io.pick import pick_types
 from ..io.open import fiff_open
 from ..io.constants import FIFF
-from ..utils import (warn, logger, copy_function_doc_to_method_doc,
+from ..utils import (warn, copy_function_doc_to_method_doc, _pl,
                      _check_option, _validate_type, _check_fname,
                      fill_doc)
 
@@ -62,34 +62,6 @@ def _check_get_coord_frame(dig):
     return _frame_to_str[dig_coord_frames.pop()] if dig_coord_frames else None
 
 
-def _check_ch_names_are_compatible(info_names, montage_names):
-    assert isinstance(info_names, set) and isinstance(montage_names, set)
-
-    match_set = info_names & montage_names
-    not_in_montage = info_names - montage_names
-    not_in_info = montage_names - info_names
-
-    if len(not_in_montage):  # DigMontage is subset of info
-        raise ValueError((
-            'DigMontage is a only a subset of info.'
-            ' There are {n_ch} channel positions not present in the'
-            ' DigMontage. The required channels are: {ch_names}.'
-        ).format(n_ch=len(not_in_montage), ch_names=not_in_montage))
-    else:
-        pass  # noqa
-
-    if len(not_in_info):  # DigMontage is superset of info
-        logger.info((
-            'DigMontage is a superset of info. {n_ch} in DigMontage will be'
-            ' ignored. The ignored channels are: {ch_names}'
-        ).format(n_ch=len(not_in_info), ch_names=not_in_info))
-
-    else:
-        pass  # noqa
-
-    return match_set
-
-
 def get_builtin_montages():
     """Get a list of all builtin montages.
 
@@ -682,7 +654,8 @@ def _set_montage_deprecation_helper(montage, update_ch_names, set_dig,
 
 
 @fill_doc
-def _set_montage(info, montage, raise_if_subset=DEPRECATED_PARAM):
+def _set_montage(info, montage, raise_if_subset=DEPRECATED_PARAM,
+                 match_case=True):
     """Apply montage to data.
 
     With a DigMontage, this function will replace the digitizer info with
@@ -705,6 +678,8 @@ def _set_montage(info, montage, raise_if_subset=DEPRECATED_PARAM):
         0.20, and will be removed in 0.21.
 
         .. versionadded: 0.19
+    %(match_case)s
+
     Notes
     -----
     This function will change the info variable in place.
@@ -718,7 +693,7 @@ def _set_montage(info, montage, raise_if_subset=DEPRECATED_PARAM):
         montage = make_standard_montage(montage)
 
     if isinstance(montage, DigMontage):
-        _mnt = _get_montage_in_head(montage)
+        mnt_head = _get_montage_in_head(montage)
 
         def _backcompat_value(pos, ref_pos):
             if any(np.isnan(pos)):
@@ -726,8 +701,8 @@ def _backcompat_value(pos, ref_pos):
             else:
                 return np.concatenate((pos, ref_pos))
 
-        ch_pos = _mnt._get_ch_pos()
-        refs = set(ch_pos.keys()) & {'EEG000', 'REF'}
+        ch_pos = mnt_head._get_ch_pos()
+        refs = set(ch_pos) & {'EEG000', 'REF'}
         assert len(refs) <= 1
         eeg_ref_pos = np.zeros(3) if not(refs) else ch_pos.pop(refs.pop())
 
@@ -735,23 +710,50 @@ def _backcompat_value(pos, ref_pos):
         _pick_chs = partial(
             pick_types, exclude=[], eeg=True, seeg=True, ecog=True, meg=False,
         )
-        matched_ch_names = _check_ch_names_are_compatible(
-            info_names=set([info['ch_names'][ii] for ii in _pick_chs(info)]),
-            montage_names=set(ch_pos),
-        )
-
-        for name in matched_ch_names:
+        info_names = [info['ch_names'][ii] for ii in _pick_chs(info)]
+        dig_names = mnt_head._get_dig_names()
+        ref_names = [None, 'EEG000', 'REF']
+
+        if match_case:
+            ch_pos_use = ch_pos
+            info_names_use = info_names
+            dig_names_use = dig_names
+        else:
+            ch_pos_use = {name.lower(): pos for name, pos in ch_pos.items()}
+            info_names_use = [name.lower() for name in info_names]
+            dig_names_use = [name.lower() if name is not None else name
+                             for name in dig_names]
+            ref_names = [name.lower() if name is not None else name
+                         for name in ref_names]
+            n_dup = len(ch_pos) - len(ch_pos_use)
+            if n_dup:
+                raise ValueError('Cannot use match_case=False as %s montage '
+                                 'name(s) require case sensitivity' % n_dup)
+            n_dup = len(info_names_use) - len(set(info_names_use))
+            if n_dup:
+                raise ValueError('Cannot use match_case=False as %s channel '
+                                 'name(s) require case sensitivity' % n_dup)
+        not_in_montage = [name for name, use in zip(info_names, info_names_use)
+                          if use not in ch_pos_use]
+        if len(not_in_montage):  # DigMontage is subset of info
+            raise ValueError('DigMontage is a only a subset of info. '
+                             'There are %s channel position%s not present in '
+                             'the DigMontage. The required channels are: %s'
+                             % (len(not_in_montage), _pl(not_in_montage),
+                                not_in_montage))
+
+        for name, use in zip(info_names, info_names_use):
             _loc_view = info['chs'][info['ch_names'].index(name)]['loc']
-            _loc_view[:6] = _backcompat_value(ch_pos[name], eeg_ref_pos)
+            _loc_view[:6] = _backcompat_value(ch_pos_use[use], eeg_ref_pos)
 
-        _names = _mnt._get_dig_names()
+        # XXX this is probably wrong as it uses the order from the montage
+        # rather than the order of our info['ch_names'] ...
         info['dig'] = _format_dig_points([
-            _mnt.dig[ii] for ii, name in enumerate(_names)
-            if name in matched_ch_names.union({None, 'EEG000', 'REF'})
-        ])
+            mnt_head.dig[ii] for ii, name in enumerate(dig_names_use)
+            if name in (info_names_use + ref_names)])
 
-        if _mnt.dev_head_t is not None:
-            info['dev_head_t'] = Transform('meg', 'head', _mnt.dev_head_t)
+        if mnt_head.dev_head_t is not None:
+            info['dev_head_t'] = Transform('meg', 'head', mnt_head.dev_head_t)
 
     else:  # None case
         info['dig'] = None
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -506,6 +506,12 @@
     positions. Default is None. See also the documentation of
     :class:`mne.channels.DigMontage` for more information.
 """
+docdict["match_case"] = """
+match_case : bool
+    If True (default), channel name matching will be case sensitive.
+
+    .. versionadded:: 0.20
+"""
 
 # 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
@@ -883,7 +883,7 @@ def test_read_dig_captrack(tmpdir):
                             [-0.005103, 0.05395, 0.144622], rtol=1e-04)
 
 
-def test_set_montage():
+def test_set_montage_mgh():
     """Test setting 'mgh60' montage to old fif."""
     raw = read_raw_fif(fif_fname)
     raw.rename_channels(lambda x: x.replace('EEG ', 'EEG'))
@@ -1030,7 +1030,8 @@ def test_set_montage_with_mismatching_ch_names():
     montage = make_standard_montage('mgh60')
 
     # 'EEG 001' and 'EEG001' won't match
-    with pytest.raises(ValueError, match='60 channel positions not present'):
+    missing_err = '60 channel positions not present'
+    with pytest.raises(ValueError, match=missing_err):
         raw.set_montage(montage)
 
     montage.ch_names = [  # modify the names in place
@@ -1038,6 +1039,30 @@ def test_set_montage_with_mismatching_ch_names():
     ]
     raw.set_montage(montage)  # does not raise
 
+    # Case sensitivity
+    raw.rename_channels(lambda x: x.lower())
+    with pytest.raises(ValueError, match=missing_err):
+        raw.set_montage(montage)
+    # should work
+    raw.set_montage(montage, match_case=False)
+    raw.rename_channels(lambda x: x.upper())  # restore
+    assert 'EEG 001' in raw.ch_names and 'eeg 001' not in raw.ch_names
+    raw.rename_channels({'EEG 002': 'eeg 001'})
+    assert 'EEG 001' in raw.ch_names and 'eeg 001' in raw.ch_names
+    raw.set_channel_types({'eeg 001': 'misc'})
+    raw.set_montage(montage)
+    raw.set_channel_types({'eeg 001': 'eeg'})
+    with pytest.raises(ValueError, match='1 channel position not present'):
+        raw.set_montage(montage)
+    with pytest.raises(ValueError, match='match_case=False as 1 channel name'):
+        raw.set_montage(montage, match_case=False)
+    raw = RawArray(np.zeros((1, 1000)), create_info(['EEG 001'], 1000., 'eeg'))
+    mon = make_dig_montage({'EEG 001': np.zeros(3), 'eeg 001': np.zeros(3)},
+                           nasion=[0, 1., 0], rpa=[1., 0, 0], lpa=[-1., 0, 0])
+    raw.set_montage(mon)
+    with pytest.raises(ValueError, match='match_case=False as 1 montage name'):
+        raw.set_montage(mon, match_case=False)
+
 
 def test_set_montage_with_sub_super_set_of_ch_names():
     """Test info and montage ch_names matching criteria."""