Repository: mne-tools/mne-python

Issue:
Snirf Fill Parsing Incorrect Assertions
### Description of the problem

I am working with snirf files produced from a NirX Sport 2 system. I have validated the files against the official specification using (pysnirf2)[https://github.com/BUNPC/pysnirf2]. It shows that these are valid snirf files according to the official protocol. When attempting to read these files into mne using `read_raw_snirf`, an assertion is failed that causes the parsers to fail early. I haven't found any additional documentation to explain why this file isn't capable of being read. Regardless, the parser shouldn't fail to read a file that meets the official specification without explanation as to why (ie missing features). I include below the validator result. Additionally, the snirf file is following version 1.1 of the spec.

```python
from snirf import validateSnirf

target = '2024-01-19_007.snirf'
result = validateSnirf(target)

>>> Found 564 OK      (hidden)
>>> Found 704 INFO    (hidden)
>>> Found 0 WARNING
>>> Found 0 FATAL  

>>> File is VALID
```

Looking into this problem myself, it seems that the assertion of the number of detector positions and number of detectors in the channel data does not always hold true. Looking at my data, the number of detector positions is 15, while the number of detectors found in the channel data is 14, where D8 is missing. An explanation of why this is the case follows.

In our optode layout, we have bundles of sources and detectors, 8 each. In this layout, we are using 16 sources and 15 detectors. However, because the sources and detectors are in these bundles, there is one remaining detector that would otherwise hang off the cap. To make this less of an obstruction, that detector is placed on the side of the cap, but is not used to capture any signal information. For that reason, it exists in the detector position array, but not in the channels. 

### Steps to reproduce

```Python
from mne.io import read_raw_snirf

target = '2024-01-19_007.snirf'
raw_intensity = read_raw_snirf(target, optode_frame='mri')
```


### Link to data

[2024-01-19_007.snirf.zip](https://github.com/mne-tools/mne-python/files/14202616/2024-01-19_007.snirf.zip)

### Expected results

A parsed snirf file as a RawSNIRF object.

### Actual results

```python
Traceback (most recent call last):
  File "/home/alexk101/Documents/Research/Bertenthal/fnirs/fnirs-nirx/src/process.py", line 83, in <module>
    process_all()
  File "/home/alexk101/Documents/Research/Bertenthal/fnirs/fnirs-nirx/src/process.py", line 78, in process_all
    sub_data[int(sub.stem)] = process_sub(snirf_file)
                              ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/alexk101/Documents/Research/Bertenthal/fnirs/fnirs-nirx/src/process.py", line 35, in process_sub
    raw_intensity = read_raw_snirf(target, optode_frame='mri')
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/alexk101/.mambaforge/envs/fnirs/lib/python3.11/site-packages/mne/io/snirf/_snirf.py", line 56, in read_raw_snirf
    return RawSNIRF(fname, optode_frame, preload, verbose)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<decorator-gen-340>", line 12, in __init__
  File "/home/alexk101/.mambaforge/envs/fnirs/lib/python3.11/site-packages/mne/io/snirf/_snirf.py", line 226, in __init__
    assert len(detectors) == detPos3D.shape[0]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
```

### Additional information

This issue was originally filed under the mne-fnirs repo, but it seems that io is the responsibility of the main mne package. The old issue can be found [here](https://github.com/mne-tools/mne-nirs/issues/529). I have discussed a correction for this with @larsoner and will attach the pull request shortly.

```
Platform             Linux-6.7.2-arch1-1-x86_64-with-glibc2.38
Python               3.11.6 | packaged by conda-forge | (main, Oct  3 2023, 10:40:35) [GCC 12.3.0]
Executable           /home/alexk101/.mambaforge/envs/fnirs/bin/python
CPU                   (16 cores)
Memory               30.5 GB

Core
├☒ mne               1.6.0 (outdated, release 1.6.1 is available!)
├☑ numpy             1.26.2 (OpenBLAS 0.3.25 with 16 threads)
├☑ scipy             1.11.4
├☑ matplotlib        3.8.2 (backend=QtAgg)
├☑ pooch             1.8.0
└☑ jinja2            3.1.2

Numerical (optional)
├☑ sklearn           1.3.2
├☑ numba             0.58.1
├☑ nibabel           5.1.0
├☑ nilearn           0.10.2
├☑ dipy              1.7.0
├☑ openmeeg          2.5.7
├☑ cupy              12.2.0
└☑ pandas            2.1.3

Visualization (optional)
├☑ pyvista           0.42.3 (OpenGL 4.5.0 NVIDIA 545.29.06 via NVIDIA GeForce RTX 3070 Ti Laptop GPU/PCIe/SSE2)
├☑ pyvistaqt         0.11.0
├☑ vtk               9.2.6
qt.qpa.plugin: Could not find the Qt platform plugin "wayland" in ""
├☑ qtpy              2.4.1 (PyQt5=5.15.8)
├☑ pyqtgraph         0.13.3
├☑ mne-qt-browser    0.6.1
├☑ ipywidgets        8.1.1
├☑ trame_client      2.13.0
├☑ trame_server      2.12.1
├☑ trame_vtk         2.6.2
├☑ trame_vuetify     2.3.1
└☐ unavailable       ipympl

Ecosystem (optional)
├☑ mne-nirs          0.6.0
└☐ unavailable       mne-bids, mne-features, mne-connectivity, mne-icalabel, mne-bids-pipeline
```



Discussion:
Hello! 👋 Thanks for opening your first issue here! ❤️ We will try to get back to you soon. 🚴


Resolving patch:
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
@@ -168,7 +168,7 @@ def natural_keys(text):
                         for c in channels
                     ]
                 )
-                sources = [f"S{int(s)}" for s in sources]
+                sources = {int(s): f"S{int(s)}" for s in sources}
 
             if "detectorLabels_disabled" in dat["nirs/probe"]:
                 # This is disabled as
@@ -185,7 +185,7 @@ def natural_keys(text):
                         for c in channels
                     ]
                 )
-                detectors = [f"D{int(d)}" for d in detectors]
+                detectors = {int(d): f"D{int(d)}" for d in detectors}
 
             # Extract source and detector locations
             # 3D positions are optional in SNIRF,
@@ -224,9 +224,6 @@ def natural_keys(text):
                     "location information"
                 )
 
-            assert len(sources) == srcPos3D.shape[0]
-            assert len(detectors) == detPos3D.shape[0]
-
             chnames = []
             ch_types = []
             for chan in channels:
@@ -248,9 +245,9 @@ def natural_keys(text):
                         )[0]
                     )
                     ch_name = (
-                        sources[src_idx - 1]
+                        sources[src_idx]
                         + "_"
-                        + detectors[det_idx - 1]
+                        + detectors[det_idx]
                         + " "
                         + str(fnirs_wavelengths[wve_idx - 1])
                     )
@@ -265,7 +262,7 @@ def natural_keys(text):
                     # Convert between SNIRF processed names and MNE type names
                     dt_id = dt_id.lower().replace("dod", "fnirs_od")
 
-                    ch_name = sources[src_idx - 1] + "_" + detectors[det_idx - 1]
+                    ch_name = sources[src_idx] + "_" + detectors[det_idx]
 
                     if dt_id == "fnirs_od":
                         wve_idx = int(


Test patch: