Repository: mne-tools/mne-python

Issue:
BUG: EGI coords read but not transformed
Doing:
```
mne.viz.plot_alignment(mne.io.read_raw_egi('test_egi.mff').info, show_axes=True, dig=True, eeg=True, coord_frame='head')
```
gets sensor positions plus LPA/Nasion/RPA (good), but not transformed to the head coordinate frame (bad):

![Screenshot from 2021-04-05 14-48-36](https://user-images.githubusercontent.com/2365790/113612958-833e1000-961e-11eb-9517-a58e348aa698.png)





Discussion:

Resolving patch:
diff --git a/mne/io/_digitization.py b/mne/io/_digitization.py
--- a/mne/io/_digitization.py
+++ b/mne/io/_digitization.py
@@ -406,7 +406,7 @@ def _make_dig_points(nasion=None, lpa=None, rpa=None, hpi=None,
                         'coord_frame': coord_frame})
     if extra_points is not None:
         extra_points = np.asarray(extra_points)
-        if extra_points.shape[1] != 3:
+        if len(extra_points) and extra_points.shape[1] != 3:
             raise ValueError('Points should have the shape (n_points, 3) '
                              'instead of %s' % (extra_points.shape,))
         for idx, point in enumerate(extra_points):
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
@@ -1,5 +1,6 @@
 """EGI NetStation Load Function."""
 
+from collections import OrderedDict
 import datetime
 import math
 import os.path as op
@@ -12,10 +13,9 @@
 from .events import _read_events, _combine_triggers
 from .general import (_get_signalfname, _get_ep_info, _extract, _get_blocks,
                       _get_gains, _block_r)
-from .._digitization import DigPoint
 from ..base import BaseRaw
 from ..constants import FIFF
-from ..meas_info import _empty_info, create_info
+from ..meas_info import _empty_info, create_info, _ensure_meas_date_none_or_dt
 from ..proj import setup_proj
 from ..utils import _create_chs, _mult_cal_one
 from ...annotations import Annotations
@@ -252,28 +252,22 @@ def _get_eeg_calibration_info(filepath, egi_info):
 
 def _read_locs(filepath, chs, egi_info):
     """Read channel locations."""
+    from ...channels.montage import make_dig_montage
     fname = op.join(filepath, 'coordinates.xml')
     if not op.exists(fname):
-        return chs, []
+        return chs, None
     reference_names = ('VREF', 'Vertex Reference')
-    dig_kind_map = {
-        '': FIFF.FIFFV_POINT_EEG,
-        'VREF': FIFF.FIFFV_POINT_EEG,
-        'Vertex Reference': FIFF.FIFFV_POINT_EEG,
-        'Left periauricular point': FIFF.FIFFV_POINT_CARDINAL,
-        'Right periauricular point': FIFF.FIFFV_POINT_CARDINAL,
-        'Nasion': FIFF.FIFFV_POINT_CARDINAL,
-    }
     dig_ident_map = {
-        'Left periauricular point': FIFF.FIFFV_POINT_LPA,
-        'Right periauricular point': FIFF.FIFFV_POINT_RPA,
-        'Nasion': FIFF.FIFFV_POINT_NASION,
+        'Left periauricular point': 'lpa',
+        'Right periauricular point': 'rpa',
+        'Nasion': 'nasion',
     }
     numbers = np.array(egi_info['numbers'])
     coordinates = parse(fname)
     sensors = coordinates.getElementsByTagName('sensor')
-    dig_points = []
-    dig_reference = None
+    ch_pos = OrderedDict()
+    hsp = list()
+    nlr = dict()
     for sensor in sensors:
         name_element = sensor.getElementsByTagName('name')[0].firstChild
         name = '' if name_element is None else name_element.data
@@ -282,27 +276,19 @@ def _read_locs(filepath, chs, egi_info):
                   for coord in 'xyz']
         loc = np.array(coords) / 100  # cm -> m
         # create dig entry
-        kind = dig_kind_map[name]
-        if kind == FIFF.FIFFV_POINT_CARDINAL:
-            ident = dig_ident_map[name]
+        if name in dig_ident_map:
+            nlr[dig_ident_map[name]] = loc
         else:
-            ident = int(nr)
-        dig_point = DigPoint(kind=kind, ident=ident, r=loc,
-                             coord_frame=FIFF.FIFFV_COORD_HEAD)
-        dig_points.append(dig_point)
-        if name in reference_names:
-            dig_reference = dig_point
-        # add location to channel entry
-        id = np.flatnonzero(numbers == nr)
-        if len(id) == 0:
-            continue
-        chs[id[0]]['loc'][:3] = loc
-    # Insert reference location into channel location
-    if dig_reference is not None:
-        for ch in chs:
-            if ch['kind'] == FIFF.FIFFV_EEG_CH:
-                ch['loc'][3:6] = dig_reference['r']
-    return chs, dig_points
+            if name in reference_names:
+                ch_pos['EEG000'] = loc
+            # add location to channel entry
+            id_ = np.flatnonzero(numbers == nr)
+            if len(id_) == 0:
+                hsp.append(loc)
+            else:
+                ch_pos[chs[id_[0]]['ch_name']] = loc
+    mon = make_dig_montage(ch_pos=ch_pos, hsp=hsp, **nlr)
+    return chs, mon
 
 
 def _add_pns_channel_info(chs, egi_info, ch_names):
@@ -443,7 +429,8 @@ def __init__(self, input_fname, eog=None, misc=None,
                 if isinstance(v, list):
                     for k in v:
                         if k not in event_codes:
-                            raise ValueError('Could find event named "%s"' % k)
+                            raise ValueError(
+                                f'Could not find event named {repr(k)}')
                 elif v is not None:
                     raise ValueError('`%s` must be None or of type list' % kk)
             logger.info('    Synthesizing trigger channel "STI 014" ...')
@@ -468,7 +455,7 @@ def __init__(self, input_fname, eog=None, misc=None,
             egi_info['year'], egi_info['month'], egi_info['day'],
             egi_info['hour'], egi_info['minute'], egi_info['second'])
         my_timestamp = time.mktime(my_time.timetuple())
-        info['meas_date'] = (my_timestamp, 0)
+        info['meas_date'] = _ensure_meas_date_none_or_dt((my_timestamp, 0))
 
         # First: EEG
         ch_names = [channel_naming % (i + 1) for i in
@@ -490,7 +477,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, dig = _read_locs(input_fname, chs, egi_info)
+        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:
@@ -500,10 +487,10 @@ def __init__(self, input_fname, eog=None, misc=None,
                              'coil_type': FIFF.FIFFV_COIL_NONE,
                              'unit': FIFF.FIFF_UNIT_NONE})
         chs = _add_pns_channel_info(chs, egi_info, ch_names)
-
         info['chs'] = chs
-        info['dig'] = dig
         info._update_redundant()
+        if mon is not None:
+            info.set_montage(mon, on_missing='ignore')
         file_bin = op.join(input_fname, egi_info['eeg_fname'])
         egi_info['egi_events'] = egi_events
 
@@ -864,11 +851,12 @@ 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, dig = _read_locs(fname, chs, egi_info)
+    chs, mon = _read_locs(fname, chs, egi_info)
     # Update PNS channel info
     chs = _add_pns_channel_info(chs, egi_info, ch_names)
     info['chs'] = chs
-    info['dig'] = dig
+    if mon is not None:
+        info.set_montage(mon, on_missing='ignore')
 
     # Add bad channels to info
     info['description'] = category
diff --git a/mne/io/meas_info.py b/mne/io/meas_info.py
--- a/mne/io/meas_info.py
+++ b/mne/io/meas_info.py
@@ -729,7 +729,7 @@ def _check_consistency(self, prepend_error=''):
                     self['meas_date'].tzinfo is None or
                     self['meas_date'].tzinfo is not datetime.timezone.utc):
                 raise RuntimeError('%sinfo["meas_date"] must be a datetime '
-                                   'object in UTC or None, got "%r"'
+                                   'object in UTC or None, got %r'
                                    % (prepend_error, repr(self['meas_date']),))
 
         chs = [ch['ch_name'] for ch in self['chs']]


Test patch:
diff --git a/mne/gui/tests/test_file_traits.py b/mne/gui/tests/test_file_traits.py
--- a/mne/gui/tests/test_file_traits.py
+++ b/mne/gui/tests/test_file_traits.py
@@ -90,10 +90,10 @@ def test_digitization_source(tmpdir):
     # EGI MFF
     inst.file = op.join(data_path, 'EGI', 'test_egi.mff')
     assert len(inst.points) == 0
-    assert len(inst.eeg_points) == 129
-    assert_allclose(inst.lpa * 1000, [[-67.1, 0.5, -37.1]], atol=0.1)
-    assert_allclose(inst.nasion * 1000, [[0.0, 103.6, -26.9]], atol=0.1)
-    assert_allclose(inst.rpa * 1000, [[67.1, 0.5, -37.1]], atol=0.1)
+    assert len(inst.eeg_points) == 130
+    assert_allclose(inst.lpa * 1000, [[-67.1, 0, 0]], atol=0.1)
+    assert_allclose(inst.nasion * 1000, [[0.0, 103.6, 0]], atol=0.1)
+    assert_allclose(inst.rpa * 1000, [[67.1, 0, 0]], atol=0.1)
 
     # CTF
     inst.file = op.join(data_path, 'CTF', 'testdata_ctf.ds')
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
@@ -9,16 +9,16 @@
 import shutil
 
 import numpy as np
-from numpy.testing import assert_array_equal, assert_allclose, assert_equal
+from numpy.testing import assert_array_equal, assert_allclose
 import pytest
 from scipy import io as sio
 
-
 from mne import find_events, pick_types
 from mne.io import read_raw_egi, read_evokeds_mff
-from mne.io.tests.test_raw import _test_raw_reader
+from mne.io.constants import FIFF
 from mne.io.egi.egi import _combine_triggers
-from mne.utils import run_tests_if_main, requires_version, object_diff
+from mne.io.tests.test_raw import _test_raw_reader
+from mne.utils import requires_version, object_diff
 from mne.datasets.testing import data_path, requires_testing_data
 
 base_dir = op.join(op.dirname(op.abspath(__file__)), 'data')
@@ -115,33 +115,38 @@ def test_io_egi_mff():
                            test_scaling=False,  # XXX probably some bug
                            )
     assert raw.info['sfreq'] == 1000.
-    assert len(raw.info['dig']) == 132  # 128 eeg + 1 ref + 3 cardinal points
+    # 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'][128]['ident'] == 129  # Reference channel
-    ref_loc = raw.info['dig'][128]['r']
-    for i in pick_types(raw.info, eeg=True):
-        assert_equal(raw.info['chs'][i]['loc'][3:6], ref_loc)
-
-    assert_equal('eeg' in raw, True)
+    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']
+    eeg_picks = pick_types(raw.info, eeg=True)
+    assert len(eeg_picks) == 129
+    for i in eeg_picks:
+        loc = raw.info['chs'][i]['loc']
+        assert loc[:3].any(), loc[:3]
+        assert_array_equal(loc[3:6], ref_loc, err_msg=f'{i}')
+
+    assert 'eeg' in raw
     eeg_chan = [c for c in raw.ch_names if 'EEG' in c]
-    assert_equal(len(eeg_chan), 129)
-    picks = pick_types(raw.info, eeg=True)
-    assert_equal(len(picks), 129)
-    assert_equal('STI 014' in raw.ch_names, True)
+    assert len(eeg_chan) == 129
+    assert 'STI 014' in raw.ch_names
 
     events = find_events(raw, stim_channel='STI 014')
-    assert_equal(len(events), 8)
-    assert_equal(np.unique(events[:, 1])[0], 0)
-    assert (np.unique(events[:, 0])[0] != 0)
-    assert (np.unique(events[:, 2])[0] != 0)
-
-    pytest.raises(ValueError, read_raw_egi, egi_mff_fname, include=['Foo'],
-                  preload=False)
-    pytest.raises(ValueError, read_raw_egi, egi_mff_fname, exclude=['Bar'],
-                  preload=False)
+    assert len(events) == 8
+    assert np.unique(events[:, 1])[0] == 0
+    assert np.unique(events[:, 0])[0] != 0
+    assert np.unique(events[:, 2])[0] != 0
+
+    with pytest.raises(ValueError, match='Could not find event'):
+        read_raw_egi(egi_mff_fname, include=['Foo'])
+    with pytest.raises(ValueError, match='Could not find event'):
+        read_raw_egi(egi_mff_fname, exclude=['Bar'])
     for ii, k in enumerate(include, 1):
-        assert (k in raw.event_id)
-        assert (raw.event_id[k] == ii)
+        assert k in raw.event_id
+        assert raw.event_id[k] == ii
 
 
 def test_io_egi():
@@ -171,19 +176,19 @@ def test_io_egi():
                            test_scaling=False,  # XXX probably some bug
                            )
 
-    assert_equal('eeg' in raw, True)
+    assert 'eeg' in raw
 
     eeg_chan = [c for c in raw.ch_names if c.startswith('E')]
-    assert_equal(len(eeg_chan), 256)
+    assert len(eeg_chan) == 256
     picks = pick_types(raw.info, eeg=True)
-    assert_equal(len(picks), 256)
-    assert_equal('STI 014' in raw.ch_names, True)
+    assert len(picks) == 256
+    assert 'STI 014' in raw.ch_names
 
     events = find_events(raw, stim_channel='STI 014')
-    assert_equal(len(events), 2)  # ground truth
-    assert_equal(np.unique(events[:, 1])[0], 0)
-    assert (np.unique(events[:, 0])[0] != 0)
-    assert (np.unique(events[:, 2])[0] != 0)
+    assert len(events) == 2  # ground truth
+    assert np.unique(events[:, 1])[0] == 0
+    assert np.unique(events[:, 0])[0] != 0
+    assert np.unique(events[:, 2])[0] != 0
     triggers = np.array([[0, 1, 1, 0], [0, 0, 1, 0]])
 
     # test trigger functionality
@@ -208,7 +213,7 @@ def test_io_egi_pns_mff(tmpdir):
                        verbose='error')
     assert ('RawMff' in repr(raw))
     pns_chans = pick_types(raw.info, ecg=True, bio=True, emg=True)
-    assert_equal(len(pns_chans), 7)
+    assert len(pns_chans) == 7
     names = [raw.ch_names[x] for x in pns_chans]
     pns_names = ['Resp. Temperature',
                  'Resp. Pressure',
@@ -222,7 +227,7 @@ def test_io_egi_pns_mff(tmpdir):
                      test_rank='less',
                      test_scaling=False,  # XXX probably some bug
                      )
-    assert_equal(names, pns_names)
+    assert names == pns_names
     mat_names = [
         'Resp_Temperature',
         'Resp_Pressure',
@@ -364,7 +369,7 @@ def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads):
     assert evoked_cond.info['nchan'] == 259
     assert evoked_cond.info['sfreq'] == 250.0
     assert not evoked_cond.info['custom_ref_applied']
-    assert len(evoked_cond.info['dig']) == 0  # coordinates.xml missing
+    assert evoked_cond.info['dig'] is None
 
 
 @requires_version('mffpy', '0.5.7')
@@ -384,4 +389,29 @@ def test_read_evokeds_mff_bad_input():
     assert str(exc_info.value) == message
 
 
-run_tests_if_main()
+@requires_testing_data
+def test_egi_coord_frame():
+    """Test that EGI coordinate frame is changed to head."""
+    info = read_raw_egi(egi_mff_fname).info
+    want_idents = (
+        FIFF.FIFFV_POINT_LPA,
+        FIFF.FIFFV_POINT_NASION,
+        FIFF.FIFFV_POINT_RPA,
+    )
+    for ii, want in enumerate(want_idents):
+        d = info['dig'][ii]
+        assert d['kind'] == FIFF.FIFFV_POINT_CARDINAL
+        assert d['ident'] == want
+        loc = d['r']
+        if ii == 0:
+            assert 0.05 < -loc[0] < 0.1, 'LPA'
+            assert_allclose(loc[1:], 0, atol=1e-7, err_msg='LPA')
+        elif ii == 1:
+            assert 0.05 < loc[1] < 0.11, 'Nasion'
+            assert_allclose(loc[::2], 0, atol=1e-7, err_msg='Nasion')
+        else:
+            assert ii == 2
+            assert 0.05 < loc[0] < 0.1, 'RPA'
+            assert_allclose(loc[1:], 0, atol=1e-7, err_msg='RPA')
+    for d in info['dig'][3:]:
+        assert d['kind'] == FIFF.FIFFV_POINT_EEG