Repository: mne-tools/mne-python

Issue:
ENH: Add data reader for NIRx NIRSport2 NIRS files
I now have access to a NIRSport2 NIRx device. So I will take some small test measurements to ensure our `read_raw_nirx` function can support it. Following #9348 I don't think this will take much code change.

I will also tidy up the https://mne.tools/stable/auto_tutorials/io/30_reading_fnirs_data.html tutorial as it is growing (#9355) and could now do with a general intro and more details on how fNIRS is handled in MNE. Just an FYI so work isn't duplicated.


Discussion:

Resolving patch:
diff --git a/mne/datasets/utils.py b/mne/datasets/utils.py
--- a/mne/datasets/utils.py
+++ b/mne/datasets/utils.py
@@ -254,7 +254,7 @@ def _data_path(path=None, force_update=False, update_path=True, download=True,
     path = _get_path(path, key, name)
     # To update the testing or misc dataset, push commits, then make a new
     # release on GitHub. Then update the "releases" variable:
-    releases = dict(testing='0.119', misc='0.9')
+    releases = dict(testing='0.121', misc='0.9')
     # And also update the "md5_hashes['testing']" variable below.
     # To update any other dataset, update the data archive itself (upload
     # an updated version) and update the md5 hash.
@@ -349,7 +349,7 @@ def _data_path(path=None, force_update=False, update_path=True, download=True,
         sample='12b75d1cb7df9dfb4ad73ed82f61094f',
         somato='32fd2f6c8c7eb0784a1de6435273c48b',
         spm='9f43f67150e3b694b523a21eb929ea75',
-        testing='2e7c60a055228928bd39f68892b3d488',
+        testing='570186827dfdc1d454675dab552441c6',
         multimodal='26ec847ae9ab80f58f204d09e2c08367',
         fnirs_motor='c4935d19ddab35422a69f3326a01fef8',
         opm='370ad1dcfd5c47e029e692c85358a374',
diff --git a/mne/io/nirx/nirx.py b/mne/io/nirx/nirx.py
--- a/mne/io/nirx/nirx.py
+++ b/mne/io/nirx/nirx.py
@@ -7,6 +7,7 @@
 import re as re
 import os.path as op
 import datetime as dt
+import json
 
 import numpy as np
 
@@ -87,10 +88,29 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
 
         fname = _check_fname(fname, 'read', True, 'fname', need_dir=True)
 
+        json_config = glob.glob('%s/*%s' % (fname, "config.json"))
+        if len(json_config):
+            is_aurora = True
+        else:
+            is_aurora = False
+
+        if is_aurora:
+            # NIRSport2 devices using Aurora software
+            keys = ('hdr', 'config.json', 'description.json',
+                    'wl1', 'wl2', 'probeInfo.mat', 'tri')
+        else:
+            # NIRScout devices and NIRSport1 devices
+            keys = ('hdr', 'inf', 'set', 'tpl', 'wl1', 'wl2',
+                    'config.txt', 'probeInfo.mat')
+            n_dat = len(glob.glob('%s/*%s' % (fname, 'dat')))
+            if n_dat != 1:
+                warn("A single dat file was expected in the specified path, "
+                     f"but got {n_dat}. This may indicate that the file "
+                     "structure has been modified since the measurement "
+                     "was saved.")
+
         # Check if required files exist and store names for later use
         files = dict()
-        keys = ('hdr', 'inf', 'set', 'tpl', 'wl1', 'wl2',
-                'config.txt', 'probeInfo.mat')
         nan_mask = dict()
         for key in keys:
             files[key] = glob.glob('%s/*%s' % (fname, key))
@@ -119,11 +139,6 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
                     fidx = noidx
                     nan_mask[key] = files[key][0 if noidx == 1 else 1]
             files[key] = files[key][fidx]
-        if len(glob.glob('%s/*%s' % (fname, 'dat'))) != 1:
-            warn("A single dat file was expected in the specified path, but "
-                 "got %d. This may indicate that the file structure has been "
-                 "modified since the measurement was saved." %
-                 (len(glob.glob('%s/*%s' % (fname, 'dat')))))
 
         # Read number of rows/samples of wavelength data
         with _open(files['wl1']) as fid:
@@ -133,25 +148,40 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
         # The header file isn't compliant with the configparser. So all the
         # text between comments must be removed before passing to parser
         with _open(files['hdr']) as f:
-            hdr_str = f.read()
-        hdr_str = re.sub('#.*?#', '', hdr_str, flags=re.DOTALL)
+            hdr_str_all = f.read()
+        hdr_str = re.sub('#.*?#', '', hdr_str_all, flags=re.DOTALL)
+        if is_aurora:
+            hdr_str = re.sub('(\\[DataStructure].*)', '',
+                             hdr_str, flags=re.DOTALL)
         hdr = RawConfigParser()
         hdr.read_string(hdr_str)
 
         # Check that the file format version is supported
-        if hdr['GeneralInfo']['NIRStar'] not in ['"15.0"', '"15.2"', '"15.3"']:
-            raise RuntimeError('MNE does not support this NIRStar version'
-                               ' (%s)' % (hdr['GeneralInfo']['NIRStar'],))
-        if "NIRScout" not in hdr['GeneralInfo']['Device'] \
-                and "NIRSport" not in hdr['GeneralInfo']['Device']:
-            warn("Only import of data from NIRScout devices have been "
-                 "thoroughly tested. You are using a %s device. " %
-                 hdr['GeneralInfo']['Device'])
+        if is_aurora:
+            # We may need to ease this requirement back
+            if hdr['GeneralInfo']['Version'] not in ['2021.4.0-34-ge9fdbbc8']:
+                warn("MNE has not been tested with Aurora version "
+                     f"{hdr['GeneralInfo']['Version']}")
+        else:
+            if hdr['GeneralInfo']['NIRStar'] not in ['"15.0"', '"15.2"',
+                                                     '"15.3"']:
+                raise RuntimeError('MNE does not support this NIRStar version'
+                                   ' (%s)' % (hdr['GeneralInfo']['NIRStar'],))
+            if "NIRScout" not in hdr['GeneralInfo']['Device'] \
+                    and "NIRSport" not in hdr['GeneralInfo']['Device']:
+                warn("Only import of data from NIRScout devices have been "
+                     "thoroughly tested. You are using a %s device. " %
+                     hdr['GeneralInfo']['Device'])
 
         # Parse required header fields
 
         # Extract measurement date and time
-        datetime_str = hdr['GeneralInfo']['Date'] + hdr['GeneralInfo']['Time']
+        if is_aurora:
+            datetime_str = hdr['GeneralInfo']['Date']
+        else:
+            datetime_str = hdr['GeneralInfo']['Date'] + \
+                hdr['GeneralInfo']['Time']
+
         meas_date = None
         # Several formats have been observed so we try each in turn
         for dt_code in ['"%a, %b %d, %Y""%H:%M:%S.%f"',
@@ -172,27 +202,45 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
                                     tzinfo=dt.timezone.utc)
 
         # Extract frequencies of light used by machine
-        fnirs_wavelengths = [int(s) for s in
-                             re.findall(r'(\d+)',
-                                        hdr['ImagingParameters'][
-                                            'Wavelengths'])]
+        if is_aurora:
+            fnirs_wavelengths = [760, 850]
+        else:
+            fnirs_wavelengths = [int(s) for s in
+                                 re.findall(r'(\d+)',
+                                            hdr['ImagingParameters'][
+                                                'Wavelengths'])]
 
         # Extract source-detectors
-        sources = np.asarray([int(s) for s in re.findall(r'(\d+)-\d+:\d+',
-                                                         hdr['DataStructure'][
-                                                             'S-D-Key'])], int)
-        detectors = np.asarray([int(s) for s in re.findall(r'\d+-(\d+):\d+',
-                                                           hdr['DataStructure']
-                                                           ['S-D-Key'])],
-                               int)
+        if is_aurora:
+            sources = re.findall(r'(\d+)-\d+', hdr_str_all.split("\n")[-2])
+            detectors = re.findall(r'\d+-(\d+)', hdr_str_all.split("\n")[-2])
+            sources = [int(s) + 1 for s in sources]
+            detectors = [int(d) + 1 for d in detectors]
+
+        else:
+            sources = np.asarray([int(s) for s in
+                                  re.findall(r'(\d+)-\d+:\d+',
+                                             hdr['DataStructure']
+                                             ['S-D-Key'])], int)
+            detectors = np.asarray([int(s) for s in
+                                    re.findall(r'\d+-(\d+):\d+',
+                                               hdr['DataStructure']
+                                               ['S-D-Key'])], int)
 
         # Extract sampling rate
-        samplingrate = float(hdr['ImagingParameters']['SamplingRate'])
+        if is_aurora:
+            samplingrate = float(hdr['GeneralInfo']['Sampling rate'])
+        else:
+            samplingrate = float(hdr['ImagingParameters']['SamplingRate'])
 
         # Read participant information file
-        inf = ConfigParser(allow_no_value=True)
-        inf.read(files['inf'])
-        inf = inf._sections['Subject Demographics']
+        if is_aurora:
+            with open(files['description.json']) as f:
+                inf = json.load(f)
+        else:
+            inf = ConfigParser(allow_no_value=True)
+            inf.read(files['inf'])
+            inf = inf._sections['Subject Demographics']
 
         # Store subject information from inf file in mne format
         # Note: NIRX also records "Study Type", "Experiment History",
@@ -200,16 +248,19 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
         #       is currently discarded
         # NIRStar does not record an id, or handedness by default
         subject_info = {}
-        names = inf['name'].split()
+        if is_aurora:
+            names = inf["subject"].split()
+        else:
+            names = inf['name'].split()
         if len(names) > 0:
             subject_info['first_name'] = \
-                inf['name'].split()[0].replace("\"", "")
+                names[0].replace("\"", "")
         if len(names) > 1:
             subject_info['last_name'] = \
-                inf['name'].split()[-1].replace("\"", "")
+                names[-1].replace("\"", "")
         if len(names) > 2:
             subject_info['middle_name'] = \
-                inf['name'].split()[-2].replace("\"", "")
+                names[-2].replace("\"", "")
         subject_info['sex'] = inf['gender'].replace("\"", "")
         # Recode values
         if subject_info['sex'] in {'M', 'Male', '1'}:
@@ -237,10 +288,8 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
 
         # These are all in MNI coordinates, so let's transform them to
         # the Neuromag head coordinate frame
-        mri_head_t, _ = _get_trans('fsaverage', 'mri', 'head')
-        src_locs = apply_trans(mri_head_t, src_locs)
-        det_locs = apply_trans(mri_head_t, det_locs)
-        ch_locs = apply_trans(mri_head_t, ch_locs)
+        src_locs, det_locs, ch_locs, mri_head_t = _convert_fnirs_to_head(
+            'fsaverage', 'mri', 'head', src_locs, det_locs, ch_locs)
 
         # Set up digitization
         dig = get_mni_fiducials('fsaverage', verbose=False)
@@ -268,14 +317,8 @@ def __init__(self, fname, saturated, preload=False, verbose=None):
             req_ind = np.concatenate((req_ind, sd_idx[0]))
         req_ind = req_ind.astype(int)
 
-        # Generate meaningful channel names
-        def prepend(li, str):
-            str += '{0}'
-            li = [str.format(i) for i in li]
-            return li
-
-        snames = prepend(sources[req_ind], 'S')
-        dnames = prepend(detectors[req_ind], '_D')
+        snames = [f"S{sources[idx]}" for idx in req_ind]
+        dnames = [f"_D{detectors[idx]}" for idx in req_ind]
         sdnames = [m + str(n) for m, n in zip(snames, dnames)]
         sd1 = [s + ' ' + str(fnirs_wavelengths[0]) for s in sdnames]
         sd2 = [s + ' ' + str(fnirs_wavelengths[1]) for s in sdnames]
@@ -366,15 +409,22 @@ def prepend(li, str):
                 ch_names.extend([self.ch_names[2 * ci:2 * ci + 2]] * len(on))
 
         # Read triggers from event file
-        if op.isfile(files['hdr'][:-3] + 'evt'):
-            with _open(files['hdr'][:-3] + 'evt') as fid:
+        if not is_aurora:
+            files['tri'] = files['hdr'][:-3] + 'evt'
+        if op.isfile(files['tri']):
+            with _open(files['tri']) as fid:
                 t = [re.findall(r'(\d+)', line) for line in fid]
             for t_ in t:
-                binary_value = ''.join(t_[1:])[::-1]
-                trigger_frame = float(t_[0])
+                if is_aurora:
+                    trigger_frame = float(t_[7])
+                    desc = float(t_[8])
+                else:
+                    binary_value = ''.join(t_[1:])[::-1]
+                    desc = float(int(binary_value, 2))
+                    trigger_frame = float(t_[0])
                 onset.append(trigger_frame / samplingrate)
                 duration.append(1.)  # No duration info stored in files
-                description.append(float(int(binary_value, 2)))
+                description.append(desc)
                 ch_names.append(list())
         annot = Annotations(onset, duration, description, ch_names=ch_names)
         self.set_annotations(annot)
@@ -419,3 +469,11 @@ def _read_csv_rows_cols(fname, start, stop, cols, bounds):
     x.shape = (stop - start, -1)
     x = x[:, cols]
     return x
+
+
+def _convert_fnirs_to_head(trans, fro, to, src_locs, det_locs, ch_locs):
+    mri_head_t, _ = _get_trans(trans, fro, to)
+    src_locs = apply_trans(mri_head_t, src_locs)
+    det_locs = apply_trans(mri_head_t, det_locs)
+    ch_locs = apply_trans(mri_head_t, ch_locs)
+    return src_locs, det_locs, ch_locs, mri_head_t
diff --git a/mne/io/snirf/_snirf.py b/mne/io/snirf/_snirf.py
--- a/mne/io/snirf/_snirf.py
+++ b/mne/io/snirf/_snirf.py
@@ -15,10 +15,11 @@
 from ..constants import FIFF
 from .._digitization import _make_dig_points
 from ...transforms import _frame_to_str
+from ..nirx.nirx import _convert_fnirs_to_head
 
 
 @fill_doc
-def read_raw_snirf(fname, preload=False, verbose=None):
+def read_raw_snirf(fname, optode_frame="unknown", preload=False, verbose=None):
     """Reader for a continuous wave SNIRF data.
 
     .. note:: This reader supports the .snirf file type only,
@@ -32,6 +33,11 @@ def read_raw_snirf(fname, preload=False, verbose=None):
     ----------
     fname : str
         Path to the SNIRF data file.
+    optode_frame : str
+        Coordinate frame used for the optode positions. The default is unknown,
+        in which case the positions are not modified. If a known coordinate
+        frame is provided (head, meg, mri), then the positions are transformed
+        in to the Neuromag head coordinate frame (head).
     %(preload)s
     %(verbose)s
 
@@ -44,7 +50,7 @@ def read_raw_snirf(fname, preload=False, verbose=None):
     --------
     mne.io.Raw : Documentation of attribute and methods.
     """
-    return RawSNIRF(fname, preload, verbose)
+    return RawSNIRF(fname, optode_frame, preload, verbose)
 
 
 def _open(fname):
@@ -59,6 +65,11 @@ class RawSNIRF(BaseRaw):
     ----------
     fname : str
         Path to the SNIRF data file.
+    optode_frame : str
+        Coordinate frame used for the optode positions. The default is unknown,
+        in which case the positions are not modified. If a known coordinate
+        frame is provided (head, meg, mri), then the positions are transformed
+        in to the Neuromag head coordinate frame (head).
     %(preload)s
     %(verbose)s
 
@@ -68,7 +79,8 @@ class RawSNIRF(BaseRaw):
     """
 
     @verbose
-    def __init__(self, fname, preload=False, verbose=None):
+    def __init__(self, fname, optode_frame="unknown",
+                 preload=False, verbose=None):
         _require_version('h5py', 'read raw SNIRF data')
         from ...externals.pymatreader.utils import _import_h5py
         # Must be here due to circular import error
@@ -234,6 +246,15 @@ def natural_keys(text):
             elif "mm" in LengthUnit:
                 scal = 1000
 
+            srcPos3D /= scal
+            detPos3D /= scal
+
+            if optode_frame in ["head", "mri", "meg"]:
+                # These are all in MNI or MEG coordinates, so let's transform
+                # them to the Neuromag head coordinate frame
+                srcPos3D, detPos3D, _, _ = _convert_fnirs_to_head(
+                    'fsaverage', optode_frame, 'head', srcPos3D, detPos3D, [])
+
             for idx, chan in enumerate(channels):
                 src_idx = int(np.array(dat.get('nirs/data1/' +
                                                chan + '/sourceIndex'))[0])
@@ -241,15 +262,18 @@ def natural_keys(text):
                                                chan + '/detectorIndex'))[0])
                 wve_idx = int(np.array(dat.get('nirs/data1/' +
                                                chan + '/wavelengthIndex'))[0])
-                info['chs'][idx]['loc'][3:6] = srcPos3D[src_idx - 1, :] / scal
-                info['chs'][idx]['loc'][6:9] = detPos3D[det_idx - 1, :] / scal
+                info['chs'][idx]['loc'][3:6] = srcPos3D[src_idx - 1, :]
+                info['chs'][idx]['loc'][6:9] = detPos3D[det_idx - 1, :]
                 # Store channel as mid point
                 midpoint = (info['chs'][idx]['loc'][3:6] +
                             info['chs'][idx]['loc'][6:9]) / 2
                 info['chs'][idx]['loc'][0:3] = midpoint
                 info['chs'][idx]['loc'][9] = fnirs_wavelengths[wve_idx - 1]
 
-            if 'MNE_coordFrame' in dat.get('nirs/metaDataTags/'):
+            if optode_frame in ["head", "mri", "meg"]:
+                # Then the transformation to head was performed above
+                coord_frame = FIFF.FIFFV_COORD_HEAD
+            elif 'MNE_coordFrame' in dat.get('nirs/metaDataTags/'):
                 coord_frame = int(dat.get('/nirs/metaDataTags/MNE_coordFrame')
                                   [0])
             else:


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
@@ -369,7 +369,6 @@ 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 evoked_cond.info['dig'] is None
 
 
 @requires_version('mffpy', '0.5.7')
diff --git a/mne/io/nirx/tests/test_nirx.py b/mne/io/nirx/tests/test_nirx.py
--- a/mne/io/nirx/tests/test_nirx.py
+++ b/mne/io/nirx/tests/test_nirx.py
@@ -14,7 +14,8 @@
 
 from mne import pick_types
 from mne.datasets.testing import data_path, requires_testing_data
-from mne.io import read_raw_nirx
+from mne.io import read_raw_nirx, read_raw_snirf
+from mne.utils import requires_h5py
 from mne.io.tests.test_raw import _test_raw_reader
 from mne.preprocessing import annotate_nan
 from mne.transforms import apply_trans, _get_trans
@@ -45,6 +46,98 @@
                               'nirx_15_3_recording_w_'
                               'saturation_on_montage_channels')
 
+# NIRSport2 device using Aurora software and matching snirf file
+nirsport2 = op.join(data_path(download=False), 'NIRx', 'nirsport_v2',
+                    'aurora_recording _w_short_and_acc')
+nirsport2_snirf = op.join(data_path(download=False), 'SNIRF', 'NIRx',
+                          'NIRSport2', '1.0.3', '2021-05-05_001.snirf')
+
+
+@requires_h5py
+@requires_testing_data
+@pytest.mark.filterwarnings('ignore:.*Extraction of measurement.*:')
+def test_nirsport_v2_matches_snirf():
+    """Test NIRSport2 raw files return same data as snirf."""
+    raw = read_raw_nirx(nirsport2, preload=True)
+    raw_snirf = read_raw_snirf(nirsport2_snirf, preload=True)
+
+    assert_allclose(raw._data, raw_snirf._data)
+
+    # Check the timing of annotations match (naming is different)
+    assert_allclose(raw.annotations.onset, raw_snirf.annotations.onset)
+
+    assert_array_equal(raw.ch_names, raw_snirf.ch_names)
+
+    # This test fails as snirf encodes name incorrectly.
+    # assert raw.info["subject_info"]["first_name"] ==
+    # raw_snirf.info["subject_info"]["first_name"]
+
+
+@requires_testing_data
+@pytest.mark.filterwarnings('ignore:.*Extraction of measurement.*:')
+def test_nirsport_v2():
+    """Test NIRSport2 file."""
+    raw = read_raw_nirx(nirsport2, preload=True)
+    assert raw._data.shape == (40, 128)
+
+    # Test distance between optodes matches values from
+    # nirsite https://github.com/mne-tools/mne-testing-data/pull/86
+    # figure 3
+    allowed_distance_error = 0.005
+    distances = source_detector_distances(raw.info)
+    assert_allclose(distances[::2][:14],
+                    [0.0304, 0.0411, 0.008, 0.0400, 0.008, 0.0310, 0.0411,
+                     0.008, 0.0299, 0.008, 0.0370, 0.008, 0.0404, 0.008],
+                    atol=allowed_distance_error)
+
+    # Test location of detectors
+    # The locations of detectors can be seen in the first
+    # figure on this page...
+    # https://github.com/mne-tools/mne-testing-data/pull/86
+    allowed_dist_error = 0.0002
+    locs = [ch['loc'][6:9] for ch in raw.info['chs']]
+    head_mri_t, _ = _get_trans('fsaverage', 'head', 'mri')
+    mni_locs = apply_trans(head_mri_t, locs)
+
+    assert raw.info['ch_names'][0][3:5] == 'D1'
+    assert_allclose(
+        mni_locs[0], [-0.0841, -0.0464, -0.0129], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][2][3:5] == 'D6'
+    assert_allclose(
+        mni_locs[2], [-0.0841, -0.0138, 0.0248], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][34][3:5] == 'D5'
+    assert_allclose(
+        mni_locs[34], [0.0845, -0.0451, -0.0123], atol=allowed_dist_error)
+
+    # Test location of sensors
+    # The locations of sensors can be seen in the second
+    # figure on this page...
+    # https://github.com/mne-tools/mne-testing-data/pull/86
+    locs = [ch['loc'][3:6] for ch in raw.info['chs']]
+    head_mri_t, _ = _get_trans('fsaverage', 'head', 'mri')
+    mni_locs = apply_trans(head_mri_t, locs)
+
+    assert raw.info['ch_names'][0][:2] == 'S1'
+    assert_allclose(
+        mni_locs[0], [-0.0848, -0.0162, -0.0163], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][9][:2] == 'S2'
+    assert_allclose(
+        mni_locs[9], [-0.0, -0.1195, 0.0142], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][34][:2] == 'S8'
+    assert_allclose(
+        mni_locs[34], [0.0828, -0.046, 0.0285], atol=allowed_dist_error)
+
+    assert len(raw.annotations) == 3
+    assert raw.annotations.description[0] == '1.0'
+    assert raw.annotations.description[2] == '6.0'
+    # Lose tolerance as I am eyeballing the time differences on screen
+    assert_allclose(
+        np.diff(raw.annotations.onset), [2.3, 3.1], atol=0.1)
+
 
 @requires_testing_data
 @pytest.mark.filterwarnings('ignore:.*Extraction of measurement.*:')
diff --git a/mne/io/snirf/tests/test_snirf.py b/mne/io/snirf/tests/test_snirf.py
--- a/mne/io/snirf/tests/test_snirf.py
+++ b/mne/io/snirf/tests/test_snirf.py
@@ -12,7 +12,8 @@
 from mne.io import read_raw_snirf, read_raw_nirx
 from mne.io.tests.test_raw import _test_raw_reader
 from mne.preprocessing.nirs import (optical_density, beer_lambert_law,
-                                    short_channels)
+                                    short_channels, source_detector_distances)
+from mne.transforms import apply_trans, _get_trans
 
 # SfNIRS files
 sfnirs_homer_103_wShort = op.join(data_path(download=False),
@@ -27,10 +28,13 @@
                                'SNIRF', 'SfNIRS', 'snirf_homer3', '1.0.3',
                                'nirx_15_3_recording.snirf')
 
-# NIRx files
+# NIRSport2 files
 nirx_nirsport2_103 = op.join(data_path(download=False),
                              'SNIRF', 'NIRx', 'NIRSport2', '1.0.3',
                              '2021-04-23_005.snirf')
+nirx_nirsport2_103_2 = op.join(data_path(download=False),
+                               'SNIRF', 'NIRx', 'NIRSport2', '1.0.3',
+                               '2021-05-05_001.snirf')
 
 
 @requires_h5py
@@ -38,7 +42,9 @@
 @pytest.mark.filterwarnings('ignore:.*contains 2D location.*:')
 @pytest.mark.parametrize('fname', ([sfnirs_homer_103_wShort,
                                     nirx_nirsport2_103,
-                                    sfnirs_homer_103_153]))
+                                    sfnirs_homer_103_153,
+                                    nirx_nirsport2_103,
+                                    nirx_nirsport2_103_2]))
 def test_basic_reading_and_min_process(fname):
     """Test reading SNIRF files and minimum typical processing."""
     raw = read_raw_snirf(fname, preload=True)
@@ -180,6 +186,81 @@ def test_snirf_nirsport2():
     assert sum(short_channels(raw.info)) == 16
 
 
+@requires_testing_data
+@requires_h5py
+def test_snirf_nirsport2_w_positions():
+    """Test reading SNIRF files with known positions."""
+    raw = read_raw_snirf(nirx_nirsport2_103_2, preload=True,
+                         optode_frame="mri")
+
+    # Test data import
+    assert raw._data.shape == (40, 128)
+    assert_almost_equal(raw.info['sfreq'], 10.2, decimal=1)
+
+    # Test channel naming
+    assert raw.info['ch_names'][:4] == ['S1_D1 760', 'S1_D1 850',
+                                        'S1_D6 760', 'S1_D6 850']
+    assert raw.info['ch_names'][24:26] == ['S6_D4 760', 'S6_D4 850']
+
+    # Test frequency encoding
+    assert raw.info['chs'][0]['loc'][9] == 760
+    assert raw.info['chs'][1]['loc'][9] == 850
+
+    assert sum(short_channels(raw.info)) == 16
+
+    # Test distance between optodes matches values from
+    # nirsite https://github.com/mne-tools/mne-testing-data/pull/86
+    # figure 3
+    allowed_distance_error = 0.005
+    distances = source_detector_distances(raw.info)
+    assert_allclose(distances[::2][:14],
+                    [0.0304, 0.0411, 0.008, 0.0400, 0.008, 0.0310, 0.0411,
+                     0.008, 0.0299, 0.008, 0.0370, 0.008, 0.0404, 0.008],
+                    atol=allowed_distance_error)
+
+    # Test location of detectors
+    # The locations of detectors can be seen in the first
+    # figure on this page...
+    # https://github.com/mne-tools/mne-testing-data/pull/86
+    allowed_dist_error = 0.0002
+    locs = [ch['loc'][6:9] for ch in raw.info['chs']]
+    head_mri_t, _ = _get_trans('fsaverage', 'head', 'mri')
+    mni_locs = apply_trans(head_mri_t, locs)
+
+    assert raw.info['ch_names'][0][3:5] == 'D1'
+    assert_allclose(
+        mni_locs[0], [-0.0841, -0.0464, -0.0129], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][2][3:5] == 'D6'
+    assert_allclose(
+        mni_locs[2], [-0.0841, -0.0138, 0.0248], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][34][3:5] == 'D5'
+    assert_allclose(
+        mni_locs[34], [0.0845, -0.0451, -0.0123], atol=allowed_dist_error)
+
+    # Test location of sensors
+    # The locations of sensors can be seen in the second
+    # figure on this page...
+    # https://github.com/mne-tools/mne-testing-data/pull/86
+    allowed_dist_error = 0.0002
+    locs = [ch['loc'][3:6] for ch in raw.info['chs']]
+    head_mri_t, _ = _get_trans('fsaverage', 'head', 'mri')
+    mni_locs = apply_trans(head_mri_t, locs)
+
+    assert raw.info['ch_names'][0][:2] == 'S1'
+    assert_allclose(
+        mni_locs[0], [-0.0848, -0.0162, -0.0163], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][9][:2] == 'S2'
+    assert_allclose(
+        mni_locs[9], [-0.0, -0.1195, 0.0142], atol=allowed_dist_error)
+
+    assert raw.info['ch_names'][34][:2] == 'S8'
+    assert_allclose(
+        mni_locs[34], [0.0828, -0.046, 0.0285], atol=allowed_dist_error)
+
+
 @requires_testing_data
 @requires_h5py
 def test_snirf_standard():