Repository: mne-tools/mne-python

Issue:
raw.set_montage does not convert to the HEAD coordinate frame for montage with LPA/Nasion/RPA set
#### bug
`raw.set_montage` does not convert to the HEAD coordinate frame when using a montage with LPA/Nasion/RPA set.

#### Steps to reproduce
```python
import numpy as np
import mne
from mne.datasets import fetch_fsaverage

# creating fake raws does not set the head coordinate frame
data = np.random.randn(2, 100)
info = mne.create_info(ch_names=['a', 'b'], sfreq=512)
raw = mne.io.RawArray(data, info)
print(raw.info['chs'][0]['coord_frame'])

# adding a montage does not help
sample_path = mne.datasets.sample.data_path()
subject = 'fsaverage'
subjects_dir = sample_path + '/subjects'
fetch_fsaverage(subjects_dir=subjects_dir) 
ch_coords = np.array([[-0.00250136,  0.04913788,  0.05047056],
                       [-0.00528394,  0.05066484,  0.05061559]])
ch_pos = dict(zip(['a', 'b'], ch_coords))
lpa, nasion, rpa = mne.coreg.get_mni_fiducials(
    subject, subjects_dir=subjects_dir)
lpa, nasion, rpa = lpa['r'], nasion['r'], rpa['r']
montage = mne.channels.make_dig_montage(
    ch_pos, coord_frame='mri', nasion=nasion, lpa=lpa, rpa=rpa)
raw.set_montage(montage)
print(raw.info['chs'][0]['coord_frame'])
```

#### Expected results
After setting montage, ```raw``` should be in the HEAD coordinate frame.

#### Actual output

`print(raw.info['chs'][0]['coord_frame'])` gives `0 (FIFFV_COORD_UNKNOWN)`

#### MNE version
 0.24.0 devel


Discussion:

Resolving patch:
diff --git a/mne/channels/montage.py b/mne/channels/montage.py
--- a/mne/channels/montage.py
+++ b/mne/channels/montage.py
@@ -13,7 +13,6 @@
 
 from collections import OrderedDict
 from copy import deepcopy
-from functools import partial
 import os.path as op
 import re
 
@@ -33,7 +32,7 @@
                                 _get_data_as_dict_from_dig)
 from ..io.meas_info import create_info
 from ..io.open import fiff_open
-from ..io.pick import pick_types, _picks_to_idx
+from ..io.pick import pick_types, _picks_to_idx, channel_type
 from ..io.constants import FIFF, CHANNEL_LOC_ALIASES
 from ..utils import (warn, copy_function_doc_to_method_doc, _pl, verbose,
                      _check_option, _validate_type, _check_fname, _on_missing,
@@ -745,8 +744,6 @@ def _set_montage_fnirs(info, montage):
     # Modify info['dig'] in place
     info['dig'] = montage.dig
 
-    return info
-
 
 @fill_doc
 def _set_montage(info, montage, match_case=True, match_alias=False,
@@ -772,163 +769,178 @@ def _set_montage(info, montage, match_case=True, match_alias=False,
     -----
     This function will change the info variable in place.
     """
-    _validate_type(montage, types=(DigMontage, type(None), str),
-                   item_name='montage')
-
+    _validate_type(montage, (DigMontage, None, str), 'montage')
+    if montage is None:
+        # Next line modifies info['dig'] in place
+        info['dig'] = None
+        for ch in info['chs']:
+            # Next line modifies info['chs'][#]['loc'] in place
+            ch['loc'] = np.full(12, np.nan)
+        return
     if isinstance(montage, str):  # load builtin montage
         _check_option('montage', montage, _BUILT_IN_MONTAGES)
         montage = make_standard_montage(montage)
 
-    if isinstance(montage, DigMontage):
-        mnt_head = _get_montage_in_head(montage)
-        del montage
-
-        def _backcompat_value(pos, ref_pos):
-            if any(np.isnan(pos)):
-                return np.full(6, np.nan)
-            else:
-                return np.concatenate((pos, ref_pos))
-
-        # get the channels in the montage in head
-        ch_pos = mnt_head._get_ch_pos()
-
-        # only get the eeg, seeg, dbs, ecog channels
-        _pick_chs = partial(
-            pick_types, exclude=[], eeg=True, seeg=True, dbs=True, ecog=True,
-            meg=False)
-
-        # get the reference position from the loc[3:6]
-        chs = info['chs']
-        ref_pos = [chs[ii]['loc'][3:6] for ii in _pick_chs(info)]
-
-        # keep reference location from EEG/ECoG/SEEG/DBS channels if they
-        # already exist and are all the same.
-        custom_eeg_ref_dig = False
-        # Note: ref position is an empty list for fieldtrip data
-        if ref_pos:
-            if all([np.equal(ref_pos[0], pos).all() for pos in ref_pos]) \
-                    and not np.equal(ref_pos[0], [0, 0, 0]).all():
-                eeg_ref_pos = ref_pos[0]
-                # since we have an EEG reference position, we have
-                # to add it into the info['dig'] as EEG000
-                custom_eeg_ref_dig = True
-        if not custom_eeg_ref_dig:
-            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())
-
-        # This raises based on info being subset/superset of montage
-        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
+    mnt_head = _get_montage_in_head(montage)
+    del montage
+
+    def _backcompat_value(pos, ref_pos):
+        if any(np.isnan(pos)):
+            return np.full(6, np.nan)
         else:
-            ch_pos_use = OrderedDict(
-                (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)
-
-        # use lookup table to match unrecognized channel names to known aliases
-        if match_alias:
-            alias_dict = (match_alias if isinstance(match_alias, dict) else
-                          CHANNEL_LOC_ALIASES)
-            if not match_case:
-                alias_dict = {
-                    ch_name.lower(): ch_alias.lower()
-                    for ch_name, ch_alias in alias_dict.items()
-                }
+            return np.concatenate((pos, ref_pos))
+
+    # get the channels in the montage in head
+    ch_pos = mnt_head._get_ch_pos()
+
+    # only get the eeg, seeg, dbs, ecog channels
+    picks = pick_types(
+        info, meg=False, eeg=True, seeg=True, dbs=True, ecog=True,
+        exclude=())
+    non_picks = np.setdiff1d(np.arange(info['nchan']), picks)
+
+    # get the reference position from the loc[3:6]
+    chs = [info['chs'][ii] for ii in picks]
+    non_names = [info['chs'][ii]['ch_name'] for ii in non_picks]
+    del picks
+    ref_pos = [ch['loc'][3:6] for ch in chs]
+
+    # keep reference location from EEG/ECoG/SEEG/DBS channels if they
+    # already exist and are all the same.
+    custom_eeg_ref_dig = False
+    # Note: ref position is an empty list for fieldtrip data
+    if ref_pos:
+        if all([np.equal(ref_pos[0], pos).all() for pos in ref_pos]) \
+                and not np.equal(ref_pos[0], [0, 0, 0]).all():
+            eeg_ref_pos = ref_pos[0]
+            # since we have an EEG reference position, we have
+            # to add it into the info['dig'] as EEG000
+            custom_eeg_ref_dig = True
+    if not custom_eeg_ref_dig:
+        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())
+
+    # This raises based on info being subset/superset of montage
+    info_names = [ch['ch_name'] for ch in chs]
+    dig_names = mnt_head._get_dig_names()
+    ref_names = [None, 'EEG000', 'REF']
+
+    if match_case:
+        info_names_use = info_names
+        dig_names_use = dig_names
+        non_names_use = non_names
+    else:
+        ch_pos_use = OrderedDict(
+            (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]
+        non_names_use = [name.lower() for name in non_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)
+        ch_pos = ch_pos_use
+        del ch_pos_use
+    del dig_names
 
-            # excluded ch_alias not in info, to prevent unnecessary mapping and
-            # warning messages based on aliases.
+    # use lookup table to match unrecognized channel names to known aliases
+    if match_alias:
+        alias_dict = (match_alias if isinstance(match_alias, dict) else
+                      CHANNEL_LOC_ALIASES)
+        if not match_case:
             alias_dict = {
-                ch_name: ch_alias
+                ch_name.lower(): ch_alias.lower()
                 for ch_name, ch_alias in alias_dict.items()
-                if ch_alias in ch_pos_use
             }
-            info_names_use = [
-                alias_dict.get(ch_name, ch_name) for ch_name in info_names_use
-            ]
-
-        # warn user if there is not a full overlap of montage with info_chs
-        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
-            missing_coord_msg = (
-                'DigMontage is only a subset of info. There are '
-                f'{len(not_in_montage)} channel position{_pl(not_in_montage)} '
-                'not present in the DigMontage. The required channels are:\n\n'
-                f'{not_in_montage}.\n\nConsider using inst.set_channel_types '
-                'if these are not EEG channels, or use the on_missing '
-                'parameter if the channel positions are allowed to be unknown '
-                'in your analyses.'
-            )
-            _on_missing(on_missing, missing_coord_msg)
-
-            # set ch coordinates and names from digmontage or nan coords
-            ch_pos_use = dict(
-                (name, ch_pos_use.get(name, [np.nan] * 3))
-                for name in info_names)  # order does not matter here
-
-        for name, use in zip(info_names, info_names_use):
-            _loc_view = info['chs'][info['ch_names'].index(name)]['loc']
-            # Next line modifies info['chs'][#]['loc'] in place
-            _loc_view[:6] = _backcompat_value(ch_pos_use[use], eeg_ref_pos)
 
-        del ch_pos_use
-
-        # XXX this is probably wrong as it uses the order from the montage
-        # rather than the order of our info['ch_names'] ...
-        digpoints = [
-            mnt_head.dig[ii] for ii, name in enumerate(dig_names_use)
-            if name in (info_names_use + ref_names)]
-
-        # get a copy of the old dig
-        if info['dig'] is not None:
-            old_dig = info['dig'].copy()
-        else:
-            old_dig = []
-
-        # determine if needed to add an extra EEG REF DigPoint
-        if custom_eeg_ref_dig:
-            # ref_name = 'EEG000' if match_case else 'eeg000'
-            ref_dig_dict = {'kind': FIFF.FIFFV_POINT_EEG,
-                            'r': eeg_ref_pos,
-                            'ident': 0,
-                            'coord_frame': info['dig'].pop()['coord_frame']}
-            ref_dig_point = _format_dig_points([ref_dig_dict])[0]
-            # only append the reference dig point if it was already
-            # in the old dig
-            if ref_dig_point in old_dig:
-                digpoints.append(ref_dig_point)
-        # Next line modifies info['dig'] in place
-        info['dig'] = _format_dig_points(digpoints, enforce_order=True)
-
-        # Handle fNIRS with source, detector and channel
-        fnirs_picks = _picks_to_idx(info, 'fnirs', allow_empty=True)
-        if len(fnirs_picks) > 0:
-            info = _set_montage_fnirs(info, mnt_head)
-
-    else:  # None case
-        # Next line modifies info['dig'] in place
-        info['dig'] = None
-        for ch in info['chs']:
-            # Next line modifies info['chs'][#]['loc'] in place
-            ch['loc'] = np.full(12, np.nan)
+        # excluded ch_alias not in info, to prevent unnecessary mapping and
+        # warning messages based on aliases.
+        alias_dict = {
+            ch_name: ch_alias
+            for ch_name, ch_alias in alias_dict.items()
+        }
+        info_names_use = [
+            alias_dict.get(ch_name, ch_name) for ch_name in info_names_use
+        ]
+        non_names_use = [
+            alias_dict.get(ch_name, ch_name) for ch_name in non_names_use
+        ]
+
+    # warn user if there is not a full overlap of montage with info_chs
+    missing = np.where([use not in ch_pos for use in info_names_use])[0]
+    if len(missing):  # DigMontage is subset of info
+        missing_names = [info_names[ii] for ii in missing]
+        missing_coord_msg = (
+            'DigMontage is only a subset of info. There are '
+            f'{len(missing)} channel position{_pl(missing)} '
+            'not present in the DigMontage. The required channels are:\n\n'
+            f'{missing_names}.\n\nConsider using inst.set_channel_types '
+            'if these are not EEG channels, or use the on_missing '
+            'parameter if the channel positions are allowed to be unknown '
+            'in your analyses.'
+        )
+        _on_missing(on_missing, missing_coord_msg)
+
+        # set ch coordinates and names from digmontage or nan coords
+        for ii in missing:
+            ch_pos[info_names_use[ii]] = [np.nan] * 3
+    del info_names
+
+    extra = np.where([non in ch_pos for non in non_names_use])[0]
+    if len(extra):
+        types = '/'.join(sorted(set(channel_type(info, ii) for ii in extra)))
+        names = [non_names[ii] for ii in extra]
+        warn(f'Not setting position{_pl(extra)} of {len(extra)} {types} '
+             f'channel{_pl(extra)} found in montage:\n{names}\n'
+             'Consider setting the channel types to be of EEG/sEEG/ECoG/DBS '
+             'using inst.set_channel_types before calling inst.set_montage.')
+
+    for ch, use in zip(chs, info_names_use):
+        # Next line modifies info['chs'][#]['loc'] in place
+        if use in ch_pos:
+            ch['loc'][:6] = _backcompat_value(ch_pos[use], eeg_ref_pos)
+        ch['coord_frame'] = FIFF.FIFFV_COORD_HEAD
+    del ch_pos
+
+    # XXX this is probably wrong as it uses the order from the montage
+    # rather than the order of our info['ch_names'] ...
+    digpoints = [
+        mnt_head.dig[ii] for ii, name in enumerate(dig_names_use)
+        if name in (info_names_use + ref_names)]
+
+    # get a copy of the old dig
+    if info['dig'] is not None:
+        old_dig = info['dig'].copy()
+    else:
+        old_dig = []
+
+    # determine if needed to add an extra EEG REF DigPoint
+    if custom_eeg_ref_dig:
+        # ref_name = 'EEG000' if match_case else 'eeg000'
+        ref_dig_dict = {'kind': FIFF.FIFFV_POINT_EEG,
+                        'r': eeg_ref_pos,
+                        'ident': 0,
+                        'coord_frame': info['dig'].pop()['coord_frame']}
+        ref_dig_point = _format_dig_points([ref_dig_dict])[0]
+        # only append the reference dig point if it was already
+        # in the old dig
+        if ref_dig_point in old_dig:
+            digpoints.append(ref_dig_point)
+    # Next line modifies info['dig'] in place
+    info['dig'] = _format_dig_points(digpoints, enforce_order=True)
+
+    # Handle fNIRS with source, detector and channel
+    fnirs_picks = _picks_to_idx(info, 'fnirs', allow_empty=True)
+    if len(fnirs_picks) > 0:
+        _set_montage_fnirs(info, mnt_head)
 
 
 def _read_isotrak_elp_points(fname):
diff --git a/mne/io/fieldtrip/utils.py b/mne/io/fieldtrip/utils.py
--- a/mne/io/fieldtrip/utils.py
+++ b/mne/io/fieldtrip/utils.py
@@ -5,12 +5,12 @@
 # License: BSD (3-clause)
 import numpy as np
 
+from .._digitization import DigPoint
+from ..constants import FIFF
 from ..meas_info import create_info
+from ..pick import pick_info
 from ...transforms import rotation3d_align_z_axis
-from ...channels import make_dig_montage
-from ..constants import FIFF
 from ...utils import warn, _check_pandas_installed
-from ..pick import pick_info
 
 _supported_megs = ['neuromag306']
 
@@ -73,12 +73,9 @@ def _create_info(ft_struct, raw_info):
         ch_idx = [info['ch_names'].index(ch) for ch in ch_names]
         pick_info(info, ch_idx, copy=False)
     else:
-        montage = _create_montage(ft_struct)
-
         info = create_info(ch_names, sfreq)
-        info.set_montage(montage)
-        chs = _create_info_chs(ft_struct)
-        info['chs'] = chs
+        chs, dig = _create_info_chs_dig(ft_struct)
+        info.update(chs=chs, dig=dig)
         info._update_redundant()
 
     return info
@@ -102,7 +99,7 @@ def _remove_missing_channels_from_trial(trial, missing_chan_idx):
     return trial
 
 
-def _create_info_chs(ft_struct):
+def _create_info_chs_dig(ft_struct):
     """Create the chs info field from the FieldTrip structure."""
     all_channels = ft_struct['label']
     ch_defaults = dict(coord_frame=FIFF.FIFFV_COORD_UNKNOWN,
@@ -125,8 +122,16 @@ def _create_info_chs(ft_struct):
         warn('The supplied FieldTrip structure does not have an elec or grad '
              'field. No channel locations will extracted and the kind of '
              'channel might be inaccurate.')
+    if 'chanpos' not in (elec or grad or {'chanpos': None}):
+        raise RuntimeError(
+            'This file was created with an old version of FieldTrip. You can '
+            'convert the data to the new version by loading it into FieldTrip '
+            'and applying ft_selectdata with an empty cfg structure on it. '
+            'Otherwise you can supply the Info field.')
 
     chs = list()
+    dig = list()
+    counter = 0
     for idx_chan, cur_channel_label in enumerate(all_channels):
         cur_ch = ch_defaults.copy()
         cur_ch['ch_name'] = cur_channel_label
@@ -134,7 +139,12 @@ def _create_info_chs(ft_struct):
         cur_ch['scanno'] = idx_chan + 1
         if elec and cur_channel_label in elec['label']:
             cur_ch = _process_channel_eeg(cur_ch, elec)
-
+            # Ref gets ident=0 and we don't have it, so start at 1
+            counter += 1
+            d = DigPoint(
+                r=cur_ch['loc'][:3], coord_frame=FIFF.FIFFV_COORD_HEAD,
+                kind=FIFF.FIFFV_POINT_EEG, ident=counter)
+            dig.append(d)
         elif grad and cur_channel_label in grad['label']:
             cur_ch = _process_channel_meg(cur_ch, grad)
         else:
@@ -155,46 +165,7 @@ def _create_info_chs(ft_struct):
 
         chs.append(cur_ch)
 
-    return chs
-
-
-def _create_montage(ft_struct):
-    """Create a montage from the FieldTrip data."""
-    # try to create a montage
-    montage_pos, montage_ch_names = list(), list()
-
-    for cur_ch_type in ('grad', 'elec'):
-        if cur_ch_type in ft_struct:
-            cur_ch_struct = ft_struct[cur_ch_type]
-            available_channels = np.where(np.in1d(cur_ch_struct['label'],
-                                                  ft_struct['label']))[0]
-            tmp_labels = cur_ch_struct['label']
-            if not isinstance(tmp_labels, list):
-                tmp_labels = [tmp_labels]
-            cur_labels = np.asanyarray(tmp_labels)
-            montage_ch_names.extend(
-                cur_labels[available_channels])
-            try:
-                montage_pos.extend(
-                    cur_ch_struct['chanpos'][available_channels])
-            except KeyError:
-                raise RuntimeError('This file was created with an old version '
-                                   'of FieldTrip. You can convert the data to '
-                                   'the new version by loading it into '
-                                   'FieldTrip and applying ft_selectdata with '
-                                   'an empty cfg structure on it. '
-                                   'Otherwise you can supply the Info field.')
-
-    montage = None
-
-    if (len(montage_ch_names) > 0 and len(montage_pos) > 0 and
-            len(montage_ch_names) == len(montage_pos)):
-        montage = make_dig_montage(
-            ch_pos=dict(zip(montage_ch_names, montage_pos)),
-            # XXX: who grants 'head'?? this is BACKCOMPAT but seems a BUG
-            coord_frame='head',
-        )
-    return montage
+    return chs, dig
 
 
 def _set_sfreq(ft_struct):
@@ -293,14 +264,12 @@ def _process_channel_eeg(cur_ch, elec):
     all_labels = np.asanyarray(elec['label'])
     chan_idx_in_elec = np.where(all_labels == cur_ch['ch_name'])[0][0]
     position = np.squeeze(elec['chanpos'][chan_idx_in_elec, :])
-    chanunit = elec['chanunit'][chan_idx_in_elec]
+    # chanunit = elec['chanunit'][chan_idx_in_elec]  # not used/needed yet
     position_unit = elec['unit']
 
     position = position * _unit_dict[position_unit]
     cur_ch['loc'] = np.hstack((position, np.zeros((9,))))
-    cur_ch['loc'][-1] = 1
     cur_ch['unit'] = FIFF.FIFF_UNIT_V
-    cur_ch['unit_mul'] = np.log10(_unit_dict[chanunit[0]])
     cur_ch['kind'] = FIFF.FIFFV_EEG_CH
     cur_ch['coil_type'] = FIFF.FIFFV_COIL_EEG
     cur_ch['coord_frame'] = FIFF.FIFFV_COORD_HEAD
@@ -350,7 +319,7 @@ def _process_channel_meg(cur_ch, grad):
         orientation = np.eye(3)
     assert orientation.shape == (3, 3)
     orientation = orientation.flatten()
-    chanunit = grad['chanunit'][chan_idx_in_grad]
+    # chanunit = grad['chanunit'][chan_idx_in_grad]  # not used/needed yet
 
     cur_ch['loc'] = np.hstack((position, orientation))
     cur_ch['kind'] = FIFF.FIFFV_MEG_CH
@@ -373,7 +342,6 @@ def _process_channel_meg(cur_ch, grad):
         raise RuntimeError('Unexpected coil type: %s.' % (
             chantype,))
 
-    cur_ch['unit_mul'] = np.log10(_unit_dict[chanunit[0]])
     cur_ch['coord_frame'] = FIFF.FIFFV_COORD_HEAD
 
     return cur_ch
diff --git a/mne/utils/numerics.py b/mne/utils/numerics.py
--- a/mne/utils/numerics.py
+++ b/mne/utils/numerics.py
@@ -725,15 +725,19 @@ def _sort_keys(x):
     return keys
 
 
-def _array_equal_nan(a, b):
+def _array_equal_nan(a, b, allclose=False):
     try:
-        np.testing.assert_array_equal(a, b)
+        if allclose:
+            func = np.testing.assert_allclose
+        else:
+            func = np.testing.assert_array_equal
+        func(a, b)
     except AssertionError:
         return False
     return True
 
 
-def object_diff(a, b, pre=''):
+def object_diff(a, b, pre='', *, allclose=False):
     """Compute all differences between two python variables.
 
     Parameters
@@ -745,6 +749,8 @@ def object_diff(a, b, pre=''):
         Must be same type as ``a``.
     pre : str
         String to prepend to each line.
+    allclose : bool
+        If True (default False), use assert_allclose.
 
     Returns
     -------
@@ -771,15 +777,17 @@ def object_diff(a, b, pre=''):
                 out += pre + ' right missing key %s\n' % key
             else:
                 out += object_diff(a[key], b[key],
-                                   pre=(pre + '[%s]' % repr(key)))
+                                   pre=(pre + '[%s]' % repr(key)),
+                                   allclose=allclose)
     elif isinstance(a, (list, tuple)):
         if len(a) != len(b):
             out += pre + ' length mismatch (%s, %s)\n' % (len(a), len(b))
         else:
             for ii, (xx1, xx2) in enumerate(zip(a, b)):
-                out += object_diff(xx1, xx2, pre + '[%s]' % ii)
+                out += object_diff(
+                    xx1, xx2, pre + '[%s]' % ii, allclose=allclose)
     elif isinstance(a, float):
-        if not _array_equal_nan(a, b):
+        if not _array_equal_nan(a, b, allclose):
             out += pre + ' value mismatch (%s, %s)\n' % (a, b)
     elif isinstance(a, (str, int, bytes, np.generic)):
         if a != b:
@@ -788,7 +796,7 @@ def object_diff(a, b, pre=''):
         if b is not None:
             out += pre + ' left is None, right is not (%s)\n' % (b)
     elif isinstance(a, np.ndarray):
-        if not _array_equal_nan(a, b):
+        if not _array_equal_nan(a, b, allclose):
             out += pre + ' array mismatch\n'
     elif isinstance(a, (StringIO, BytesIO)):
         if a.getvalue() != b.getvalue():
@@ -808,7 +816,8 @@ def object_diff(a, b, pre=''):
                 out += pre + (' sparse matrix a and b differ on %s '
                               'elements' % c.nnz)
     elif hasattr(a, '__getstate__'):
-        out += object_diff(a.__getstate__(), b.__getstate__(), pre)
+        out += object_diff(a.__getstate__(), b.__getstate__(), pre,
+                           allclose=allclose)
     else:
         raise RuntimeError(pre + ': unsupported type %s (%s)' % (type(a), a))
     return out


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
@@ -19,6 +19,7 @@
 import matplotlib.pyplot as plt
 
 from mne import __file__ as _mne_file, create_info, read_evokeds, pick_types
+from mne.source_space import get_mni_fiducials
 from mne.utils._testing import assert_object_equal
 from mne.channels import (get_builtin_montages, DigMontage, read_dig_dat,
                           read_dig_egi, read_dig_captrak, read_dig_fif,
@@ -34,7 +35,7 @@
 from mne.io._digitization import (_format_dig_points,
                                   _get_fid_coords, _get_dig_eeg,
                                   _count_points_by_type)
-from mne.transforms import _ensure_trans
+from mne.transforms import _ensure_trans, apply_trans
 from mne.viz._3d import _fiducial_coords
 
 from mne.io.kit import read_mrk
@@ -61,6 +62,7 @@
 bdf_fname2 = op.join(data_path, 'BDF', 'test_bdf_stim_channel.bdf')
 egi_fname1 = op.join(data_path, 'EGI', 'test_egi.mff')
 cnt_fname = op.join(data_path, 'CNT', 'scan41_short.cnt')
+subjects_dir = op.join(data_path, 'subjects')
 
 io_dir = op.dirname(_MNE_IO_FILE)
 kit_dir = op.join(io_dir, 'kit', 'tests', 'data')
@@ -1346,6 +1348,39 @@ def test_set_montage_coord_frame_in_head_vs_unknown():
     )
 
 
+@testing.requires_testing_data
+@pytest.mark.parametrize('ch_type', ('eeg', 'ecog', 'seeg', 'dbs'))
+def test_montage_head_frame(ch_type):
+    """Test that head frame is set properly."""
+    # gh-9446
+    data = np.random.randn(2, 100)
+    info = create_info(['a', 'b'], 512, ch_type)
+    coord_frame = getattr(
+        FIFF, f'FIFFV_COORD_{"HEAD" if ch_type == "eeg" else "UNKNOWN"}')
+    for ch in info['chs']:
+        assert ch['coord_frame'] == coord_frame
+    raw = RawArray(data, info)
+    ch_pos = dict(a=[-0.00250136, 0.04913788, 0.05047056],
+                  b=[-0.00528394, 0.05066484, 0.05061559])
+    lpa, nasion, rpa = get_mni_fiducials(
+        'fsaverage', subjects_dir=subjects_dir)
+    lpa, nasion, rpa = lpa['r'], nasion['r'], rpa['r']
+    montage = make_dig_montage(
+        ch_pos, coord_frame='mri', nasion=nasion, lpa=lpa, rpa=rpa)
+    mri_head_t = compute_native_head_t(montage)
+    raw.set_montage(montage)
+    pos = apply_trans(mri_head_t, np.array(list(ch_pos.values())))
+    for p, ch in zip(pos, raw.info['chs']):
+        assert ch['coord_frame'] == FIFF.FIFFV_COORD_HEAD
+        assert_allclose(p, ch['loc'][:3])
+
+    # Also test that including channels in the montage that will not have their
+    # positions set will emit a warning
+    raw.set_channel_types(dict(a='misc'))
+    with pytest.warns(RuntimeWarning, match='Not setting .*of 1 misc channel'):
+        raw.set_montage(montage)
+
+
 def test_set_montage_with_missing_coordinates():
     """Test set montage with missing coordinates."""
     N_CHANNELS, NaN = 3, np.nan
diff --git a/mne/io/fieldtrip/tests/helpers.py b/mne/io/fieldtrip/tests/helpers.py
--- a/mne/io/fieldtrip/tests/helpers.py
+++ b/mne/io/fieldtrip/tests/helpers.py
@@ -5,24 +5,24 @@
 # License: BSD (3-clause)
 from functools import partial
 import os
-import types
 
 import numpy as np
 
 import mne
+from mne.io.constants import FIFF
+from mne.utils import object_diff
 
 
 info_ignored_fields = ('file_id', 'hpi_results', 'hpi_meas', 'meas_id',
                        'meas_date', 'highpass', 'lowpass', 'subject_info',
                        'hpi_subsystem', 'experimenter', 'description',
                        'proj_id', 'proj_name', 'line_freq', 'gantry_angle',
-                       'dev_head_t', 'dig', 'bads', 'projs', 'ctf_head_t',
-                       'dev_ctf_t')
+                       'dev_head_t', 'bads', 'ctf_head_t', 'dev_ctf_t')
 
 ch_ignore_fields = ('logno', 'cal', 'range', 'scanno', 'coil_type', 'kind',
                     'loc', 'coord_frame', 'unit')
 
-info_long_fields = ('hpi_meas', )
+info_long_fields = ('hpi_meas', 'projs')
 
 system_to_reader_fn_dict = {'neuromag306': mne.io.read_raw_fif,
                             'CNT': partial(mne.io.read_raw_cnt),
@@ -76,6 +76,17 @@ def _remove_ignored_info_fields(info):
             del info[cur_field]
 
     _remove_ignored_ch_fields(info)
+    _remove_bad_dig_fields(info)
+
+
+def _remove_bad_dig_fields(info):
+    # The reference location appears to be lost, so we cannot add it.
+    # Similarly, fiducial locations do not appear to be stored, so we
+    # cannot add those, either. Same with HPI coils.
+    if info['dig'] is not None:
+        info['dig'] = [d for d in info['dig']
+                       if d['kind'] == FIFF.FIFFV_POINT_EEG and
+                       d['ident'] != 0]  # ref
 
 
 def get_data_paths(system):
@@ -175,7 +186,6 @@ def get_epochs(system):
 def get_evoked(system):
     """Find, load and process the avg data."""
     epochs = get_epochs(system)
-
     return epochs.average(picks=np.arange(len(epochs.ch_names)))
 
 
@@ -192,11 +202,17 @@ def check_info_fields(expected, actual, has_raw_info, ignore_long=True):
         _remove_ignored_info_fields(expected)
         _remove_ignored_info_fields(actual)
 
-    if info_long_fields:
-        _remove_long_info_fields(expected)
-        _remove_long_info_fields(actual)
+    _remove_long_info_fields(expected)
+    _remove_long_info_fields(actual)
+
+    # we annoyingly have two ways of representing this, so just always use
+    # an empty list here
+    for obj in (expected, actual):
+        if obj['dig'] is None:
+            obj['dig'] = []
 
-    assert_deep_almost_equal(expected, actual)
+    d = object_diff(actual, expected, allclose=True)
+    assert d == '', d
 
 
 def check_data(expected, actual, system):
@@ -208,63 +224,6 @@ def check_data(expected, actual, system):
     np.testing.assert_almost_equal(expected, actual, decimal=decimal)
 
 
-def assert_deep_almost_equal(expected, actual, *args, **kwargs):
-    """
-    Assert that two complex structures have almost equal contents.
-
-    Compares lists, dicts and tuples recursively. Checks numeric values
-    using test_case's :py:meth:`unittest.TestCase.assertAlmostEqual` and
-    checks all other values with :py:meth:`unittest.TestCase.assertEqual`.
-    Accepts additional positional and keyword arguments and pass those
-    intact to assertAlmostEqual() (that's how you specify comparison
-    precision).
-
-    This code has been adapted from
-    https://github.com/larsbutler/oq-engine/blob/master/tests/utils/helpers.py
-    """
-    is_root = '__trace' not in kwargs
-    trace = kwargs.pop('__trace', 'ROOT')
-
-    if isinstance(expected, np.ndarray) and expected.size == 0:
-        expected = None
-
-    if isinstance(actual, np.ndarray) and actual.size == 0:
-        actual = None
-
-    try:
-        if isinstance(expected, (int, float, complex)):
-            np.testing.assert_almost_equal(expected, actual, *args, **kwargs)
-        elif isinstance(expected, (list, tuple, np.ndarray,
-                                   types.GeneratorType)):
-            if isinstance(expected, types.GeneratorType):
-                expected = list(expected)
-                actual = list(actual)
-
-                np.testing.assert_equal(len(expected), len(actual))
-            for index in range(len(expected)):
-                v1, v2 = expected[index], actual[index]
-                assert_deep_almost_equal(v1, v2,
-                                         __trace=repr(index), *args, **kwargs)
-        elif isinstance(expected, dict):
-            np.testing.assert_equal(set(expected), set(actual))
-            for key in expected:
-                assert_deep_almost_equal(expected[key], actual[key],
-                                         __trace=repr(key), *args, **kwargs)
-        else:
-            np.testing.assert_equal(expected, actual)
-    except AssertionError as exc:
-        exc.__dict__.setdefault('traces', []).append(trace)
-        if is_root:
-            trace = ' -> '.join(reversed(exc.traces))
-            message = ''
-            try:
-                message = exc.message
-            except AttributeError:
-                pass
-            exc = AssertionError("%s\nTRACE: %s" % (message, trace))
-        raise exc
-
-
 def assert_warning_in_record(warning_message, warn_record):
     """Assert that a warning message is in the records."""
     all_messages = [str(w.message) for w in warn_record]