Repository: mne-tools/mne-python

Issue:
When reading EGI data (.mff), import channel names from sensorLayout.xml
When working on https://mne.discourse.group/t/what-eeg-montage-to-use-for-257-channels/5093/11, I found that we currently don't use the channel names provided in (all? some?) MFF datasets. Specifically, `read_raw_egi()` always applies a custom naming scheme, ignoring all available metadata related to channel names. This led, for example, to the following channel, `REF`, being named `E257` on import.

Snippet from `sensorLayout.xml`:
```
        <sensor>
            <name>VREF</name>
            <number>257</number>
            <type>1</type>
            <x>0.000</x>
            <y>-0.000</y>
            <z>0.000</z>
            <identifier>1001</identifier>
        </sensor>
```


Discussion:
Hi @hoechenberger, thanks for bringing this up, I saw that discourse thread, it's definitely an issue that has come up for me, particularly when setting montages. This might overlap with the issue I raised [10823](https://github.com/mne-tools/mne-python/issues/10823).

It's not just MNE-Python, EEGLAB also names these channels according to their number (at least in my experience). The only issue I foresee, is that even if we change the code in `read_raw_egi()` to import  the channel names from `sensorLayout.xml`, users will still hit a snag when setting a standard montage, because in the MNE-Python standard Montages for EGI nets, the `REF` channel is named `Cz`. 

Not sure what the best approach is here. Maybe we do amend the code to import the channel names from `sensorLayout.xml` as you suggest, and then add `VREF` to `mne.io.constants.CHANNEL_LOC_ALIASES`, so that users have the option skip the step of manually renaming the `VREF` channel before setting a standard montage with `raw.set_montage`. But at the same time, I'm not sure how many users will actually be aware of this nifty `match_alias` parameter in `raw.set_montage`, which defaults to `False` : ) 

Or maybe we just make sure that the channel names in the standard montage for EGI nets match the channel names that are imported during `read_raw_egi`(regardless of whether we decide to keep it as `E257`, or change it to `VREF` or even `Cz`). Not sure! But i'm happy to help with whatever you all think is the best approach during the code sprint next week.
I'll drop this code snippet that could be useful:

```
import xml.etree.ElementTree as ET
from pathlib import Path

import numpy as np
from mne.channels import make_dig_montage


# load montage
# fname is the path to a .mff folder
montage_fname = Path(fname) / "coordinates.xml"
# the montage contains 257 channels with the field <number> from 1 to 257
# and 3 fiducials: Nasion, LPA, RPA with the number and names:
# - Nasion - 258
# - Left periauricular point - 259
# - Right periauricular point - 260
namespaces = {"": "http://www.egi.com/coordinates_mff"}
tree = ET.parse(montage_fname)
root = tree.getroot()
sensors = root.findall(".//sensorLayout/sensors/sensor", namespaces)
ch_pos = dict()
for sensor in sensors:
    name = sensor.find("name", namespaces).text
    number = sensor.find("number", namespaces).text
    name = f"E{number}" if name is None else name
    name = "E257" if name == "VREF" else name
    x = sensor.find("x", namespaces).text
    y = sensor.find("y", namespaces).text
    z = sensor.find("z", namespaces).text
    ch_pos[name] = np.array([float(x), float(y), float(z)])
nasion = ch_pos["Nasion"]
lpa = ch_pos["Left periauricular point"]
rpa = ch_pos["Right periauricular point"]
del ch_pos["Nasion"]
del ch_pos["Left periauricular point"]
del ch_pos["Right periauricular point"]
montage = make_dig_montage(ch_pos, nasion, lpa, rpa, coord_frame="unknown")
```

Note that the loaded montage is not correctly scaled.

If EGI saves the channels as `f'E{k}'` + `'VREF'`, I think MNE should load those names instead of renaming the reference channel to e.g. `E257`.
Thanks for sharing the code! I've only done some preliminary testing on my local machine, but I think I have amended `mne.io.read_raw_egi()` to load the chan names where the ref channel is now called `VREF`

As far as the channel locations go, it's seems like there is already code in `egimff.py` that [reads the sensor locations](https://github.com/mne-tools/mne-python/blob/1310269f27bdf4347dfc465a834c1e04d25563d8/mne/io/egi/egimff.py#L257-L295).

I'm first trying to wrap my head around how `read_raw_egi()` currently uses these sensor locations. It seems like at some point in post processing, you usually need to set a standard montage, using the one provided by mne-python for EGI nets. should the channel locations in `coordinates.xml` be used for the montage? And if that's the case, do we need to explicitly set the montage inside `read_raw_egi`?

It might be helpful to chat with one of you some time this week!


> It seems like at some point in post processing, you usually need to set a standard montage, using the one provided by mne-python for EGI nets.

Can you check to see if this is actually necessary? It's possible that those montages are provided for cases where the sensor locations cannot be read from the `.mff` directly. Some experimentation / testing is probably needed here with different `.mff` files, with and without `.set_montage` called. Direct testing here is probably better than relying on fuzzy memory anyway :)
Apologies, I was mistaken - you don't need to set a standard montage.

 See below, plotting the sensors, with the amended code to read in the channel names, with the ref chan named `VREF`:
![Screenshot from 2022-06-29 11-31-15](https://user-images.githubusercontent.com/52462026/176476773-7c954e18-30db-4e4f-a421-4c92268f3c74.png)

Compared to the prior code, with the ref chan named `E129`:
![Screenshot from 2022-06-29 11-28-44](https://user-images.githubusercontent.com/52462026/176476776-c5361ad0-6e2d-4d9f-bb43-38271fbba870.png)

and finally, plotting the sensors after setting a standard montage `GSN-HydroCel-129`:
![Screenshot from 2022-06-29 11-28-04](https://user-images.githubusercontent.com/52462026/176476774-d5f9874f-494b-43d7-b5e3-652e695240e9.png)



Resolving patch:
diff --git a/mne/io/egi/egi.py b/mne/io/egi/egi.py
--- a/mne/io/egi/egi.py
+++ b/mne/io/egi/egi.py
@@ -152,7 +152,7 @@ def read_raw_egi(input_fname, eog=None, misc=None,
     """
     _validate_type(input_fname, 'path-like', 'input_fname')
     input_fname = str(input_fname)
-    if input_fname.endswith('.mff'):
+    if input_fname.rstrip('/\\').endswith('.mff'):  # allows .mff or .mff/
         return _read_raw_egi_mff(input_fname, eog, misc, include,
                                  exclude, preload, channel_naming, verbose)
     return RawEGI(input_fname, eog, misc, include, exclude, preload,
diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py
--- a/mne/io/egi/egimff.py
+++ b/mne/io/egi/egimff.py
@@ -21,6 +21,8 @@
 from ...utils import verbose, logger, warn, _check_option, _check_fname
 from ...evoked import EvokedArray
 
+REFERENCE_NAMES = ('VREF', 'Vertex Reference')
+
 
 def _read_mff_header(filepath):
     """Read mff header."""
@@ -254,13 +256,16 @@ def _get_eeg_calibration_info(filepath, egi_info):
     return cals
 
 
-def _read_locs(filepath, chs, egi_info):
+def _read_locs(filepath, egi_info, channel_naming):
     """Read channel locations."""
     from ...channels.montage import make_dig_montage
     fname = op.join(filepath, 'coordinates.xml')
     if not op.exists(fname):
-        return chs, None
-    reference_names = ('VREF', 'Vertex Reference')
+        logger.warn(
+            'File coordinates.xml not found, not setting channel locations')
+        ch_names = [channel_naming % (i + 1) for i in
+                    range(egi_info['n_channels'])]
+        return ch_names, None
     dig_ident_map = {
         'Left periauricular point': 'lpa',
         'Right periauricular point': 'rpa',
@@ -272,10 +277,14 @@ def _read_locs(filepath, chs, egi_info):
     ch_pos = OrderedDict()
     hsp = list()
     nlr = dict()
+    ch_names = list()
+
     for sensor in sensors:
         name_element = sensor.getElementsByTagName('name')[0].firstChild
-        name = '' if name_element is None else name_element.data
-        nr = sensor.getElementsByTagName('number')[0].firstChild.data.encode()
+        num_element = sensor.getElementsByTagName('number')[0].firstChild
+        name = (channel_naming % int(num_element.data) if name_element is None
+                else name_element.data)
+        nr = num_element.data.encode()
         coords = [float(sensor.getElementsByTagName(coord)[0].firstChild.data)
                   for coord in 'xyz']
         loc = np.array(coords) / 100  # cm -> m
@@ -283,16 +292,17 @@ def _read_locs(filepath, chs, egi_info):
         if name in dig_ident_map:
             nlr[dig_ident_map[name]] = loc
         else:
-            if name in reference_names:
-                ch_pos['EEG000'] = loc
-            # add location to channel entry
+            # id_ is the index of the channel in egi_info['numbers']
             id_ = np.flatnonzero(numbers == nr)
+            # if it's not in egi_info['numbers'], it's a headshape point
             if len(id_) == 0:
                 hsp.append(loc)
+            # not HSP, must be a data or reference channel
             else:
-                ch_pos[chs[id_[0]]['ch_name']] = loc
+                ch_names.append(name)
+                ch_pos[name] = loc
     mon = make_dig_montage(ch_pos=ch_pos, hsp=hsp, **nlr)
-    return chs, mon
+    return ch_names, mon
 
 
 def _add_pns_channel_info(chs, egi_info, ch_names):
@@ -462,10 +472,8 @@ def __init__(self, input_fname, eog=None, misc=None,
         info['utc_offset'] = egi_info['utc_offset']
         info['device_info'] = dict(type=egi_info['device'])
 
-        # First: EEG
-        ch_names = [channel_naming % (i + 1) for i in
-                    range(egi_info['n_channels'])]
-
+        # read in the montage, if it exists
+        ch_names, mon = _read_locs(input_fname, egi_info, channel_naming)
         # Second: Stim
         ch_names.extend(list(egi_info['event_codes']))
         if egi_info['new_trigger'] is not None:
@@ -482,7 +490,7 @@ def __init__(self, input_fname, eog=None, misc=None,
         ch_coil = FIFF.FIFFV_COIL_EEG
         ch_kind = FIFF.FIFFV_EEG_CH
         chs = _create_chs(ch_names, cals, ch_coil, ch_kind, eog, (), (), misc)
-        chs, mon = _read_locs(input_fname, chs, egi_info)
+
         sti_ch_idx = [i for i, name in enumerate(ch_names) if
                       name.startswith('STI') or name in event_codes]
         for idx in sti_ch_idx:
@@ -495,8 +503,22 @@ def __init__(self, input_fname, eog=None, misc=None,
         info['chs'] = chs
         info._unlocked = False
         info._update_redundant()
+
         if mon is not None:
             info.set_montage(mon, on_missing='ignore')
+
+        ref_idx = np.flatnonzero(np.in1d(mon.ch_names, REFERENCE_NAMES))
+        if len(ref_idx):
+            ref_coords = info['chs'][int(ref_idx)]['loc'][:3]
+            for chan in info['chs']:
+                is_eeg = chan['kind'] == FIFF.FIFFV_EEG_CH
+                is_not_ref = chan['ch_name'] not in REFERENCE_NAMES
+                if is_eeg and is_not_ref:
+                    chan['loc'][3:6] = ref_coords
+
+            # Cz ref was applied during acquisition, so mark as already set.
+            with info._unlock():
+                info['custom_ref_applied'] = FIFF.FIFFV_MNE_CUSTOM_REF_ON
         file_bin = op.join(input_fname, egi_info['eeg_fname'])
         egi_info['egi_events'] = egi_events
 
@@ -859,8 +881,7 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None):
     # Load metadata into info object
     # Exclude info['meas_date'] because record time info in
     # averaged MFF is the time of the averaging, not true record time.
-    ch_names = [channel_naming % (i + 1) for i in
-                range(mff.num_channels['EEG'])]
+    ch_names, mon = _read_locs(fname, egi_info, channel_naming)
     ch_names.extend(egi_info['pns_names'])
     info = create_info(ch_names, mff.sampling_rates['EEG'], ch_types)
     with info._unlock():
@@ -875,7 +896,6 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None):
     ch_coil = FIFF.FIFFV_COIL_EEG
     ch_kind = FIFF.FIFFV_EEG_CH
     chs = _create_chs(ch_names, cals, ch_coil, ch_kind, (), (), (), ())
-    chs, mon = _read_locs(fname, chs, egi_info)
     # Update PNS channel info
     chs = _add_pns_channel_info(chs, egi_info, ch_names)
     with info._unlock():
@@ -898,7 +918,7 @@ def _read_evoked_mff(fname, condition, channel_naming='E%d', verbose=None):
                 if entry['signalBin'] == 1:
                     # Add bad EEG channels
                     for ch in entry['channels']:
-                        bads.append(channel_naming % ch)
+                        bads.append(ch_names[ch - 1])
                 elif entry['signalBin'] == 2:
                     # Add bad PNS channels
                     for ch in entry['channels']:


Test patch:
diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py
--- a/mne/io/egi/tests/test_egi.py
+++ b/mne/io/egi/tests/test_egi.py
@@ -14,7 +14,7 @@
 import pytest
 from scipy import io as sio
 
-from mne import find_events, pick_types
+from mne import find_events, pick_types, pick_channels
 from mne.io import read_raw_egi, read_evokeds_mff, read_raw_fif
 from mne.io.constants import FIFF
 from mne.io.egi.egi import _combine_triggers
@@ -127,6 +127,11 @@ def test_egi_mff_pause_chunks(fname, tmp_path):
 @requires_testing_data
 def test_io_egi_mff():
     """Test importing EGI MFF simple binary files."""
+    # want vars for n chans
+    n_ref = 1
+    n_eeg = 128
+    n_card = 3
+
     raw = read_raw_egi(egi_mff_fname, include=None)
     assert ('RawMff' in repr(raw))
     assert raw.orig_format == "single"
@@ -136,15 +141,19 @@ def test_io_egi_mff():
                            test_scaling=False,  # XXX probably some bug
                            )
     assert raw.info['sfreq'] == 1000.
-    # The ref here is redundant, but we don't currently have a way in
-    # DigMontage to mark that a given channel is actually the ref so...
-    assert len(raw.info['dig']) == 133  # 129 eeg + 1 ref + 3 cardinal points
-    assert raw.info['dig'][0]['ident'] == 1  # EEG channel E1
-    assert raw.info['dig'][3]['ident'] == 0  # Reference channel
-    assert raw.info['dig'][-1]['ident'] == 129  # Reference channel
-    ref_loc = raw.info['dig'][3]['r']
+    assert len(raw.info['dig']) == n_card + n_eeg + n_ref
+    assert raw.info['dig'][0]['ident'] == FIFF.FIFFV_POINT_LPA
+    assert raw.info['dig'][0]['kind'] == FIFF.FIFFV_POINT_CARDINAL
+    assert raw.info['dig'][3]['kind'] == FIFF.FIFFV_POINT_EEG
+    assert raw.info['dig'][-1]['ident'] == 129
+    assert raw.info['custom_ref_applied'] == FIFF.FIFFV_MNE_CUSTOM_REF_ON
+    ref_loc = raw.info['dig'][-1]['r']
     eeg_picks = pick_types(raw.info, eeg=True)
-    assert len(eeg_picks) == 129
+    assert len(eeg_picks) == n_eeg + n_ref  # 129
+    # ref channel doesn't store its own loc as ref location
+    # so don't test it
+    ref_pick = pick_channels(raw.info['ch_names'], ['VREF'])
+    eeg_picks = np.setdiff1d(eeg_picks, ref_pick)
     for i in eeg_picks:
         loc = raw.info['chs'][i]['loc']
         assert loc[:3].any(), loc[:3]
@@ -152,8 +161,9 @@ def test_io_egi_mff():
     assert raw.info['device_info']['type'] == 'HydroCel GSN 128 1.0'
 
     assert 'eeg' in raw
+    # test our custom channel naming logic functionality
     eeg_chan = [c for c in raw.ch_names if 'EEG' in c]
-    assert len(eeg_chan) == 129
+    assert len(eeg_chan) == n_eeg  # 128: VREF will not match in comprehension
     assert 'STI 014' in raw.ch_names
 
     events = find_events(raw, stim_channel='STI 014')
@@ -339,10 +349,16 @@ def test_io_egi_crop_no_preload():
     (0, 'Category 1', 0.016, egi_txt_evoked_cat1_fname,
      ['E8', 'E11', 'E17', 'E28', 'ECG']),
     (1, 'Category 2', 0.0, egi_txt_evoked_cat2_fname,
-     ['E257', 'EMG'])
+     ['VREF', 'EMG'])
 ])
 def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads):
     """Test reading evoked MFF file."""
+    # expected n channels
+    n_eeg = 256
+    n_ref = 1
+    n_card = 3
+    n_pns = 2  # 1 ECG + 1 EMG
+
     # Test reading all conditions from evokeds
     evokeds = read_evokeds_mff(egi_mff_evoked_fname)
     assert len(evokeds) == 2
@@ -381,17 +397,17 @@ def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads):
     assert object_diff(evoked_cond.info, evoked_idx.info) == ''
     assert evoked_cond.info['description'] == cond
     assert evoked_cond.info['bads'] == bads
-    assert len(evoked_cond.info['ch_names']) == 259
+    assert len(evoked_cond.info['ch_names']) == n_eeg + n_ref + n_pns  # 259
     assert 'ECG' in evoked_cond.info['ch_names']
     assert 'EMG' in evoked_cond.info['ch_names']
     assert 'ecg' in evoked_cond
     assert 'emg' in evoked_cond
     pick_eeg = pick_types(evoked_cond.info, eeg=True, exclude=[])
-    assert len(pick_eeg) == 257
-    assert evoked_cond.info['nchan'] == 259
+    assert len(pick_eeg) == n_eeg + n_ref  # 257
+    assert evoked_cond.info['nchan'] == n_eeg + n_ref + n_pns  # 259
     assert evoked_cond.info['sfreq'] == 250.0
     assert not evoked_cond.info['custom_ref_applied']
-    assert len(evoked_cond.info['dig']) == 261
+    assert len(evoked_cond.info['dig']) == n_card + n_eeg + n_ref
     assert evoked_cond.info['device_info']['type'] == 'HydroCel GSN 256 1.0'