Repository: mne-tools/mne-python

Issue:
ENH: Add montage for Artinis OctaMon and Brite23
#### Describe the new feature or enhancement
We would like to add a default montage for the Artinis OctaMon fNIRS device. This would enable to have spacial locations useful for some pre-processing functions (such as the modified Beer Lamber law) and having visualization on a brain map.

![octamon](https://user-images.githubusercontent.com/32393169/111283357-f0214400-8636-11eb-9c85-ace5acbdb2c6.jpg)


#### Describe your proposed implementation
Adding a file in [mne/channels/data/montages/](https://github.com/mne-tools/mne-python/tree/main/mne/channels/data/montages), modifying [`set_montage()`](https://mne.tools/stable/generated/mne.io.Raw.html#mne.io.Raw.set_montage) (in Raw but also other data types) as and probably the loading functions.


#### Additional comments
Not sure what file format is best to use (csd/sfp/txt/elc), would you have recommendations?



Discussion:
We discussed it with @rob-luke and I would be happy to work on a PR for this if you are okay with it.
no objection

is there such a standard montage file available somewhere?

our montage files are shared between packages.

>

I don't think there is a standard montage file with 3D coordinates, will try to see if I can adapt the montage template the Artinis software has.
sounds good

>

@HanBnrd has received the locations in MNI ICBM 152 space. The file format provided by the vendor looks custom. Rather than writing another custom file format reader, I suggest we manually convert these to one of the existing formats perhaps sfp. Would this be acceptable @agramfort @drammock @larsoner 
We will need to edit: https://github.com/mne-tools/mne-python/blob/c638f74ac727aeaca3b9616e56115b990a84c7ba/mne/channels/_standard_montage_utils.py#L108

https://github.com/mne-tools/mne-python/blob/c638f74ac727aeaca3b9616e56115b990a84c7ba/mne/channels/montage.py#L1226-L1306


Resolving patch:
diff --git a/mne/channels/_standard_montage_utils.py b/mne/channels/_standard_montage_utils.py
--- a/mne/channels/_standard_montage_utils.py
+++ b/mne/channels/_standard_montage_utils.py
@@ -73,7 +73,7 @@ def _biosemi(basename, head_size):
     return _read_theta_phi_in_degrees(fname, head_size, fid_names)
 
 
-def _mgh_or_standard(basename, head_size):
+def _mgh_or_standard(basename, head_size, coord_frame='unknown'):
     fid_names = ('Nz', 'LPA', 'RPA')
     fname = op.join(MONTAGE_PATH, basename)
 
@@ -103,7 +103,7 @@ def _mgh_or_standard(basename, head_size):
     lpa *= scale
     rpa *= scale
 
-    return make_dig_montage(ch_pos=ch_pos, coord_frame='unknown',
+    return make_dig_montage(ch_pos=ch_pos, coord_frame=coord_frame,
                             nasion=nasion, lpa=lpa, rpa=rpa)
 
 
@@ -144,6 +144,10 @@ def _mgh_or_standard(basename, head_size):
                                  basename='standard_prefixed.elc'),
     'standard_primed': partial(_mgh_or_standard,
                                basename='standard_primed.elc'),
+    'artinis-octamon': partial(_mgh_or_standard, coord_frame='mri',
+                               basename='artinis-octamon.elc'),
+    'artinis-brite23': partial(_mgh_or_standard, coord_frame='mri',
+                               basename='artinis-brite23.elc'),
 }
 
 
diff --git a/mne/channels/montage.py b/mne/channels/montage.py
--- a/mne/channels/montage.py
+++ b/mne/channels/montage.py
@@ -33,7 +33,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
+from ..io.pick import pick_types, _picks_to_idx
 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,
@@ -52,7 +52,8 @@
     'easycap-M1', 'easycap-M10',
     'mgh60', 'mgh70',
     'standard_1005', 'standard_1020', 'standard_alphabetic',
-    'standard_postfixed', 'standard_prefixed', 'standard_primed'
+    'standard_postfixed', 'standard_prefixed', 'standard_primed',
+    'artinis-octamon', 'artinis-brite23'
 ]
 
 
@@ -725,6 +726,47 @@ def _get_montage_in_head(montage):
         return transform_to_head(montage.copy())
 
 
+def _set_montage_fnirs(info, montage):
+    """Set the montage for fNIRS data.
+
+    This needs to be different to electrodes as each channel has three
+    coordinates that need to be set. For each channel there is a source optode
+    location, a detector optode location, and a channel midpoint that must be
+    stored. This function modifies info['chs'][#]['loc'] and info['dig'] in
+    place.
+    """
+    from ..preprocessing.nirs import (_channel_frequencies,
+                                      _channel_chromophore,
+                                      _check_channels_ordered)
+    # Validate that the fNIRS info is correctly formatted
+    freqs = np.unique(_channel_frequencies(info))
+    if freqs.size > 0:
+        picks = _check_channels_ordered(info, freqs)
+    else:
+        picks = _check_channels_ordered(info,
+                                        np.unique(_channel_chromophore(info)))
+
+    # Modify info['chs'][#]['loc'] in place
+    num_ficiduals = len(montage.dig) - len(montage.ch_names)
+    for ch_idx in picks:
+        ch = info['chs'][ch_idx]['ch_name']
+        source, detector = ch.split(' ')[0].split('_')
+        source_pos = montage.dig[montage.ch_names.index(source)
+                                 + num_ficiduals]['r']
+        detector_pos = montage.dig[montage.ch_names.index(detector)
+                                   + num_ficiduals]['r']
+
+        info['chs'][ch_idx]['loc'][3:6] = source_pos
+        info['chs'][ch_idx]['loc'][6:9] = detector_pos
+        midpoint = (source_pos + detector_pos) / 2
+        info['chs'][ch_idx]['loc'][:3] = midpoint
+
+    # Modify info['dig'] in place
+    info['dig'] = montage.dig
+
+    return info
+
+
 @fill_doc
 def _set_montage(info, montage, match_case=True, match_alias=False,
                  on_missing='raise'):
@@ -758,6 +800,7 @@ def _set_montage(info, montage, match_case=True, match_alias=False,
 
     if isinstance(montage, DigMontage):
         mnt_head = _get_montage_in_head(montage)
+        del montage
 
         def _backcompat_value(pos, ref_pos):
             if any(np.isnan(pos)):
@@ -862,6 +905,7 @@ def _backcompat_value(pos, ref_pos):
 
         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
@@ -890,14 +934,23 @@ def _backcompat_value(pos, ref_pos):
             # 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)
 
         if mnt_head.dev_head_t is not None:
+            # Next line modifies info['dev_head_t'] in place
             info['dev_head_t'] = Transform('meg', 'head', mnt_head.dev_head_t)
 
+        # 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)
 
 
@@ -1361,6 +1414,10 @@ def make_standard_montage(kind, head_size=HEAD_SIZE_DEFAULT):
                           MGH (60+3 locations)
     mgh70                 The (newer) 70-channel BrainVision cap used at
                           MGH (70+3 locations)
+
+    artinis-octamon       Artinis OctaMon fNIRS (8 sources, 2 detectors)
+
+    artinis-brite23       Artinis Brite23 fNIRS (11 sources, 7 detectors)
     ===================   =====================================================
 
     .. versionadded:: 0.19.0
diff --git a/tutorials/io/30_reading_fnirs_data.py b/tutorials/io/30_reading_fnirs_data.py
--- a/tutorials/io/30_reading_fnirs_data.py
+++ b/tutorials/io/30_reading_fnirs_data.py
@@ -9,6 +9,13 @@
 MNE includes various functions and utilities for reading NIRS
 data and optode locations.
 
+fNIRS devices consist of light sources and light detectors. A channel is formed
+by source-detector pairs. MNE stores the location of the channels, sources, and
+detectors.
+
+.. warning:: Information about device light wavelength is stored in channel
+             names. Manual modification of channel names is not recommended.
+
 .. _import-nirx:
 
 NIRx (directory)
@@ -17,6 +24,7 @@
 NIRx recordings can be read in using :func:`mne.io.read_raw_nirx`.
 The NIRx device stores data directly to a directory with multiple file types,
 MNE extracts the appropriate information from each file.
+MNE only supports NIRx files recorded with NIRStar version 15.0 and above.
 
 
 .. _import-snirf:
@@ -61,16 +69,136 @@
 will also read the ``digaux`` data and create annotations for any triggers.
 
 
-Storing of optode locations
-===========================
-
-NIRs devices consist of light sources and light detectors.
-A channel is formed by source-detector pairs.
-MNE stores the location of the channels, sources, and detectors.
+Loading legacy data in CSV or TSV format
+========================================
 
+.. warning:: This method is not supported and users are discoraged to use it.
+             You should convert your data to the
+             `SNIRF <https://github.com/fNIRS/snirf>`_ format using the tools
+             provided by the Society for functional Near-Infrared Spectroscopy,
+             and then load it using :func:`mne.io.read_raw_snirf`.
 
-.. warning:: Information about device light wavelength is stored in
-             channel names. Manual modification of channel names is not
-             recommended.
-
+fNIRS measurements can have a non-standardised format that is not supported by
+MNE and cannot be converted easily into SNIRF. This legacy data is often in CSV
+or TSV format, we show here a way to load it even though it is not officially
+supported by MNE due to the lack of standardisation of the file format (the
+naming and ordering of channels, the type and scaling of data, and
+specification of sensor positions varies between each vendor). You will likely
+have to adapt this depending on the system from which your CSV originated.
 """  # noqa:E501
+
+import numpy as np
+import pandas as pd
+import mne
+
+# sphinx_gallery_thumbnail_number = 2
+
+###############################################################################
+# First, we generate an example CSV file which will then be loaded in to MNE.
+# This step would be skipped if you have actual data you wish to load.
+# We simulate 16 channels with 100 samples of data and save this to a file
+# called fnirs.csv.
+
+pd.DataFrame(np.random.normal(size=(16, 100))).to_csv("fnirs.csv")
+
+
+###############################################################################
+#
+# .. warning:: The channels must be ordered in haemoglobin pairs, such that for
+#              a single channel all the types are in subsequent indices. The
+#              type order must be 'hbo' then 'hbr'.
+#              The data below is already in the correct order and may be
+#              used as a template for how data must be stored.
+#              If the order that your data is stored is different to the
+#              mandatory formatting, then you must first read the data with
+#              channel naming according to the data structure, then reorder
+#              the channels to match the required format.
+#
+# Next, we will load the example CSV file.
+
+data = pd.read_csv('fnirs.csv')
+
+
+###############################################################################
+# Then, the metadata must be specified manually as the CSV file does not
+# contain information about channel names, types, sample rate etc.
+#
+# .. warning:: In MNE the naming of channels MUST follow the structure of
+#              ``S#_D# type`` where # is replaced by the appropriate source and
+#              detector numbers and type is either ``hbo``, ``hbr`` or the
+#              wavelength.
+
+ch_names = ['S1_D1 hbo', 'S1_D1 hbr', 'S2_D1 hbo', 'S2_D1 hbr',
+            'S3_D1 hbo', 'S3_D1 hbr', 'S4_D1 hbo', 'S4_D1 hbr',
+            'S5_D2 hbo', 'S5_D2 hbr', 'S6_D2 hbo', 'S6_D2 hbr',
+            'S7_D2 hbo', 'S7_D2 hbr', 'S8_D2 hbo', 'S8_D2 hbr']
+ch_types = ['hbo', 'hbr', 'hbo', 'hbr',
+            'hbo', 'hbr', 'hbo', 'hbr',
+            'hbo', 'hbr', 'hbo', 'hbr',
+            'hbo', 'hbr', 'hbo', 'hbr']
+sfreq = 10.  # in Hz
+
+
+###############################################################################
+# Finally, the data can be converted in to an MNE data structure.
+# The metadata above is used to create an :class:`mne.Info` data structure,
+# and this is combined with the data to create an MNE :class:`~mne.io.Raw`
+# object. For more details on the info structure see :ref:`tut-info-class`, and
+# for additional details on how continuous data is stored in MNE see
+# :ref:`tut-raw-class`.
+# For a more extensive description of how to create MNE data structures from
+# raw array data see :ref:`tut_creating_data_structures`.
+
+info = mne.create_info(ch_names=ch_names, ch_types=ch_types, sfreq=sfreq)
+raw = mne.io.RawArray(data, info, verbose=True)
+
+
+###############################################################################
+# Applying standard sensor locations to imported data
+# ---------------------------------------------------
+#
+# Having information about optode locations may assist in your analysis.
+# Beyond the general benefits this provides (e.g. creating regions of interest,
+# etc), this is may be particularly important for fNIRS as information about
+# the optode locations is required to convert the optical density data in to an
+# estimate of the haemoglobin concentrations.
+# MNE provides methods to load standard sensor configurations (montages) from
+# some vendors, and this is demonstrated below.
+# Some handy tutorials for understanding sensor locations, coordinate systems,
+# and how to store and view this information in MNE are:
+# :ref:`tut-sensor-locations`, :ref:`plot_source_alignment`, and
+# :ref:`ex-eeg-on-scalp`.
+#
+# Below is an example of how to load the optode positions for an Artinis
+# OctaMon device.
+#
+# .. note:: It is also possible to create a custom montage from a file for
+#           fNIRS with :func:`mne.channels.read_custom_montage` by setting
+#           ``coord_frame`` to ``'mri'``.
+
+montage = mne.channels.make_standard_montage('artinis-octamon')
+raw.set_montage(montage)
+
+# View the position of optodes in 2D to confirm the positions are correct.
+raw.plot_sensors()
+
+
+###############################################################################
+# To validate the positions were loaded correctly it is also possible to view
+# the location of the sources (red), detectors (black), and channels (white
+# lines and orange dots) in a 3D representation.
+# The ficiduals are marked in blue, green and red.
+# See :ref:`plot_source_alignment` for more details.
+
+subjects_dir = mne.datasets.sample.data_path() + '/subjects'
+mne.datasets.fetch_fsaverage(subjects_dir=subjects_dir)
+
+trans = mne.channels.compute_native_head_t(montage)
+
+fig = mne.viz.create_3d_figure(size=(800, 600), bgcolor='white')
+fig = mne.viz.plot_alignment(
+    raw.info, trans=trans, subject='fsaverage', subjects_dir=subjects_dir,
+    surfaces=['brain', 'head'], coord_frame='mri', dig=True, show_axes=True,
+    fnirs=['channels', 'pairs', 'sources', 'detectors'], fig=fig)
+mne.viz.set_3d_view(figure=fig, azimuth=90, elevation=90, distance=0.5,
+                    focalpoint=(0., -0.01, 0.02))


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
@@ -1498,7 +1498,7 @@ def test_read_dig_hpts():
 
 def test_get_builtin_montages():
     """Test help function to obtain builtin montages."""
-    EXPECTED_NUM = 24
+    EXPECTED_NUM = 26
     assert len(get_builtin_montages()) == EXPECTED_NUM
 
 
diff --git a/mne/channels/tests/test_standard_montage.py b/mne/channels/tests/test_standard_montage.py
--- a/mne/channels/tests/test_standard_montage.py
+++ b/mne/channels/tests/test_standard_montage.py
@@ -8,12 +8,17 @@
 
 import numpy as np
 
-from numpy.testing import assert_allclose
+from numpy.testing import (assert_allclose, assert_array_almost_equal,
+                           assert_raises)
 
-from mne.channels import make_standard_montage
-from mne.io._digitization import _get_dig_eeg, _get_fid_coords
+from mne import create_info
+from mne.channels import make_standard_montage, compute_native_head_t
 from mne.channels.montage import get_builtin_montages, HEAD_SIZE_DEFAULT
+from mne.io import RawArray
+from mne.io._digitization import _get_dig_eeg, _get_fid_coords
 from mne.io.constants import FIFF
+from mne.preprocessing.nirs import optical_density, beer_lambert_law
+from mne.transforms import _get_trans, _angle_between_quats, rot_to_quat
 
 
 @pytest.mark.parametrize('kind', get_builtin_montages())
@@ -24,7 +29,10 @@ def test_standard_montages_have_fids(kind):
     for k, v in fids.items():
         assert v is not None, k
     for d in montage.dig:
-        assert d['coord_frame'] == FIFF.FIFFV_COORD_UNKNOWN
+        if kind == 'artinis-octamon' or kind == 'artinis-brite23':
+            assert d['coord_frame'] == FIFF.FIFFV_COORD_MRI
+        else:
+            assert d['coord_frame'] == FIFF.FIFFV_COORD_UNKNOWN
 
 
 def test_standard_montage_errors():
@@ -73,3 +81,164 @@ def test_standard_superset():
     for key, value in m_1020._get_ch_pos().items():
         if key not in ('O10', 'O9'):
             assert_allclose(c_1005[key], value, atol=1e-4, err_msg=key)
+
+
+def _simulate_artinis_octamon():
+    """Simulate artinis OctaMon channel data from numpy data.
+
+    This is to test data that is imported with missing or incorrect montage
+    info. This data can then be used to test the set_montage function.
+    """
+    np.random.seed(42)
+    data = np.absolute(np.random.normal(size=(16, 100)))
+    ch_names = ['S1_D1 760', 'S1_D1 850', 'S2_D1 760', 'S2_D1 850',
+                'S3_D1 760', 'S3_D1 850', 'S4_D1 760', 'S4_D1 850',
+                'S5_D2 760', 'S5_D2 850', 'S6_D2 760', 'S6_D2 850',
+                'S7_D2 760', 'S7_D2 850', 'S8_D2 760', 'S8_D2 850']
+    ch_types = ['fnirs_cw_amplitude' for _ in ch_names]
+    sfreq = 10.  # Hz
+    info = create_info(ch_names=ch_names, ch_types=ch_types, sfreq=sfreq)
+    for i, ch_name in enumerate(ch_names):
+        info['chs'][i]['loc'][9] = int(ch_name.split(' ')[1])
+    raw = RawArray(data, info)
+
+    return raw
+
+
+def _simulate_artinis_brite23():
+    """Simulate artinis Brite23 channel data from numpy data.
+
+    This is to test data that is imported with missing or incorrect montage
+    info. This data can then be used to test the set_montage function.
+    """
+    np.random.seed(0)
+    data = np.random.normal(size=(46, 100))
+    sd_names = ['S1_D1', 'S2_D1', 'S3_D1', 'S4_D1', 'S3_D2', 'S4_D2', 'S5_D2',
+                'S4_D3', 'S5_D3', 'S6_D3', 'S5_D4', 'S6_D4', 'S7_D4', 'S6_D5',
+                'S7_D5', 'S8_D5', 'S7_D6', 'S8_D6', 'S9_D6', 'S8_D7', 'S9_D7',
+                'S10_D7', 'S11_D7']
+    ch_names = []
+    ch_types = []
+    for name in sd_names:
+        ch_names.append(name + ' hbo')
+        ch_types.append('hbo')
+        ch_names.append(name + ' hbr')
+        ch_types.append('hbr')
+    sfreq = 10.  # Hz
+    info = create_info(ch_names=ch_names, ch_types=ch_types, sfreq=sfreq)
+    raw = RawArray(data, info)
+
+    return raw
+
+
+@pytest.mark.parametrize('kind', ('octamon', 'brite23'))
+def test_set_montage_artinis_fsaverage(kind):
+    """Test that artinis montages match fsaverage's head<->MRI transform."""
+    # Compare OctaMon and Brite23 to fsaverage
+    trans_fs, _ = _get_trans('fsaverage')
+    montage = make_standard_montage(f'artinis-{kind}')
+    trans = compute_native_head_t(montage)
+    assert trans['to'] == trans_fs['to']
+    assert trans['from'] == trans_fs['from']
+    translation = 1000 * np.linalg.norm(trans['trans'][:3, 3] -
+                                        trans_fs['trans'][:3, 3])
+    # TODO: This is actually quite big...
+    assert 15 < translation < 18  # mm
+    rotation = np.rad2deg(
+        _angle_between_quats(rot_to_quat(trans['trans'][:3, :3]),
+                             rot_to_quat(trans_fs['trans'][:3, :3])))
+    assert 3 < rotation < 7  # degrees
+
+
+def test_set_montage_artinis_basic():
+    """Test that OctaMon and Brite23 montages are set properly."""
+    # Test OctaMon montage
+    montage_octamon = make_standard_montage('artinis-octamon')
+    montage_brite23 = make_standard_montage('artinis-brite23')
+    raw = _simulate_artinis_octamon()
+    raw_od = optical_density(raw)
+    old_info = raw.info.copy()
+    old_info_od = raw_od.info.copy()
+    raw.set_montage(montage_octamon)
+    raw_od.set_montage(montage_octamon)
+    raw_hb = beer_lambert_law(raw_od)  # montage needed for Beer Lambert law
+    # Check that the montage was actually modified
+    assert_raises(AssertionError, assert_array_almost_equal,
+                  old_info['chs'][0]['loc'][:9],
+                  raw.info['chs'][0]['loc'][:9])
+    assert_raises(AssertionError, assert_array_almost_equal,
+                  old_info_od['chs'][0]['loc'][:9],
+                  raw_od.info['chs'][0]['loc'][:9])
+
+    # Check a known location
+    assert_array_almost_equal(raw.info['chs'][0]['loc'][:3],
+                              [0.0616, 0.075398, 0.07347])
+    assert_array_almost_equal(raw.info['chs'][8]['loc'][:3],
+                              [-0.033875,  0.101276,  0.077291])
+    assert_array_almost_equal(raw.info['chs'][12]['loc'][:3],
+                              [-0.062749,  0.080417,  0.074884])
+    assert_array_almost_equal(raw_od.info['chs'][12]['loc'][:3],
+                              [-0.062749,  0.080417,  0.074884])
+    assert_array_almost_equal(raw_hb.info['chs'][12]['loc'][:3],
+                              [-0.062749,  0.080417,  0.074884])
+    # Check that locations are identical for a pair of channels (all elements
+    # except the 10th which is the wavelength if not hbo and hbr type)
+    assert_array_almost_equal(raw.info['chs'][0]['loc'][:9],
+                              raw.info['chs'][1]['loc'][:9])
+    assert_array_almost_equal(raw_od.info['chs'][0]['loc'][:9],
+                              raw_od.info['chs'][1]['loc'][:9])
+    assert_array_almost_equal(raw_hb.info['chs'][0]['loc'][:9],
+                              raw_hb.info['chs'][1]['loc'][:9])
+
+    # Test Brite23 montage
+    raw = _simulate_artinis_brite23()
+    old_info = raw.info.copy()
+    raw.set_montage(montage_brite23)
+    # Check that the montage was actually modified
+    assert_raises(AssertionError, assert_array_almost_equal,
+                  old_info['chs'][0]['loc'][:9],
+                  raw.info['chs'][0]['loc'][:9])
+    # Check a known location
+    assert_array_almost_equal(raw.info['chs'][0]['loc'][:3],
+                              [0.085583, 0.036275, 0.089426])
+    assert_array_almost_equal(raw.info['chs'][8]['loc'][:3],
+                              [0.069555, 0.078579, 0.069305])
+    assert_array_almost_equal(raw.info['chs'][12]['loc'][:3],
+                              [0.044861, 0.100952, 0.065175])
+    # Check that locations are identical for a pair of channels (all elements
+    # except the 10th which is the wavelength if not hbo and hbr type)
+    assert_array_almost_equal(raw.info['chs'][0]['loc'][:9],
+                              raw.info['chs'][1]['loc'][:9])
+
+    # Test channel variations
+    raw_old = _simulate_artinis_brite23()
+    # Raw missing some channels that are in the montage: pass
+    raw = raw_old.copy()
+    raw.pick(['S1_D1 hbo', 'S1_D1 hbr'])
+    raw.set_montage('artinis-brite23')
+
+    # Unconventional channel pair: pass
+    raw = raw_old.copy()
+    info_new = create_info(['S11_D1 hbo', 'S11_D1 hbr'], raw.info['sfreq'],
+                           ['hbo', 'hbr'])
+    new = RawArray(np.random.normal(size=(2, len(raw))), info_new)
+    raw.add_channels([new], force_update_info=True)
+    raw.set_montage('artinis-brite23')
+
+    # Source not in montage: fail
+    raw = raw_old.copy()
+    info_new = create_info(['S12_D7 hbo', 'S12_D7 hbr'], raw.info['sfreq'],
+                           ['hbo', 'hbr'])
+    new = RawArray(np.random.normal(size=(2, len(raw))), info_new)
+    raw.add_channels([new], force_update_info=True)
+    with pytest.raises(ValueError, match='is not in list'):
+        raw.set_montage('artinis-brite23')
+
+    # Detector not in montage: fail
+    raw = raw_old.copy()
+    info_new = create_info(['S11_D8 hbo', 'S11_D8 hbr'], raw.info['sfreq'],
+                           ['hbo', 'hbr'])
+    new = RawArray(np.random.normal(size=(2, len(raw))), info_new)
+    raw.add_channels([new], force_update_info=True)
+    with pytest.raises(ValueError, match='is not in list'):
+        raw.set_montage('artinis-brite23')