Skip to content

Commit e8baea6

Browse files
rob-lukepre-commit-ci[bot]larsonerdrammock
authored
ENH: Add support for Artinis SNIRF data (#11926)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Larson <larson.eric.d@gmail.com> Co-authored-by: Daniel McCloy <dan@mccloy.info>
1 parent 59b3160 commit e8baea6

File tree

8 files changed

+76
-14
lines changed

8 files changed

+76
-14
lines changed

azure-pipelines.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,9 @@ stages:
257257
3.9 pip:
258258
TEST_MODE: 'pip'
259259
PYTHON_VERSION: '3.9'
260-
3.10 pip pre:
260+
3.11 pip pre:
261261
TEST_MODE: 'pip-pre'
262-
PYTHON_VERSION: '3.10'
262+
PYTHON_VERSION: '3.11'
263263
steps:
264264
- task: UsePythonVersion@0
265265
inputs:

doc/changes/devel.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Enhancements
2929
- Added public :func:`mne.io.write_info` to complement :func:`mne.io.read_info` (:gh:`11918` by `Eric Larson`_)
3030
- Added option ``remove_dc`` to to :meth:`Raw.compute_psd() <mne.io.Raw.compute_psd>`, :meth:`Epochs.compute_psd() <mne.Epochs.compute_psd>`, and :meth:`Evoked.compute_psd() <mne.Evoked.compute_psd>`, to allow skipping DC removal when computing Welch or multitaper spectra (:gh:`11769` by `Nikolai Chapochnikov`_)
3131
- Add the possibility to provide a float between 0 and 1 as ``n_grad``, ``n_mag`` and ``n_eeg`` in `~mne.compute_proj_raw`, `~mne.compute_proj_epochs` and `~mne.compute_proj_evoked` to select the number of vectors based on the cumulative explained variance (:gh:`11919` by `Mathieu Scheltienne`_)
32+
- Added support for Artinis fNIRS data files to :func:`mne.io.read_raw_snirf` (:gh:`11926` by `Robert Luke`_)
3233
- Add helpful error messages when using methods on empty :class:`mne.Epochs`-objects (:gh:`11306` by `Martin Schulz`_)
3334
- Add inferring EEGLAB files' montage unit automatically based on estimated head radius using :func:`read_raw_eeglab(..., montage_units="auto") <mne.io.read_raw_eeglab>` (:gh:`11925` by `Jack Zhang`_, :gh:`11951` by `Eric Larson`_)
3435
- Add :class:`~mne.time_frequency.EpochsSpectrumArray` and :class:`~mne.time_frequency.SpectrumArray` to support creating power spectra from :class:`NumPy array <numpy.ndarray>` data (:gh:`11803` by `Alex Rockhill`_)

mne/io/snirf/_snirf.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -526,23 +526,42 @@ def _get_lengthunit_scaling(length_unit):
526526

527527
def _extract_sampling_rate(dat):
528528
"""Extract the sample rate from the time field."""
529+
# This is a workaround to provide support for Artinis data.
530+
# It allows for a 1% variation in the sampling times relative
531+
# to the average sampling rate of the file.
532+
MAXIMUM_ALLOWED_SAMPLING_JITTER_PERCENTAGE = 1.0
533+
529534
time_data = np.array(dat.get("nirs/data1/time"))
530535
sampling_rate = 0
531536
if len(time_data) == 2:
532537
# specified as onset, samplerate
533538
sampling_rate = 1.0 / (time_data[1] - time_data[0])
534539
else:
535540
# specified as time points
536-
fs_diff = np.around(np.diff(time_data), decimals=4)
537-
if len(np.unique(fs_diff)) == 1:
541+
periods = np.diff(time_data)
542+
uniq_periods = np.unique(periods.round(decimals=4))
543+
if uniq_periods.size == 1:
538544
# Uniformly sampled data
539-
sampling_rate = 1.0 / np.unique(fs_diff).item()
545+
sampling_rate = 1.0 / uniq_periods.item()
540546
else:
541-
warn(
542-
"MNE does not currently support reading "
543-
"SNIRF files with non-uniform sampled data."
547+
# Hopefully uniformly sampled data with some precision issues.
548+
# This is a workaround to provide support for Artinis data.
549+
mean_period = np.mean(periods)
550+
sampling_rate = 1.0 / mean_period
551+
ideal_times = np.linspace(time_data[0], time_data[-1], time_data.size)
552+
max_jitter = np.max(np.abs(time_data - ideal_times))
553+
percent_jitter = 100.0 * max_jitter / mean_period
554+
msg = (
555+
f"Found jitter of {percent_jitter:3f}% in sample times. Sampling "
556+
f"rate has been set to {sampling_rate:1f}."
544557
)
545-
558+
if percent_jitter > MAXIMUM_ALLOWED_SAMPLING_JITTER_PERCENTAGE:
559+
warn(
560+
f"{msg} Note that MNE-Python does not currently support SNIRF "
561+
"files with non-uniformly-sampled data."
562+
)
563+
else:
564+
logger.info(msg)
546565
time_unit = _get_metadata_str(dat, "TimeUnit")
547566
time_unit_scaling = _get_timeunit_scaling(time_unit)
548567
sampling_rate *= time_unit_scaling

mne/io/snirf/tests/test_snirf.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from mne.transforms import apply_trans, _get_trans
2020
from mne._fiff.constants import FIFF
21+
from mne.utils import catch_logging
2122

2223

2324
testing_path = data_path(download=False)
@@ -471,7 +472,11 @@ def test_annotation_duration_from_stim_groups():
471472

472473
def test_birthday(tmp_path, monkeypatch):
473474
"""Test birthday parsing."""
474-
snirf = pytest.importorskip("snirf")
475+
try:
476+
snirf = pytest.importorskip("snirf")
477+
except AttributeError as exc:
478+
# Until https://github.com/BUNPC/pysnirf2/pull/43 is released
479+
pytest.skip(f"snirf import error: {exc}")
475480
fname = tmp_path / "test.snirf"
476481
with snirf.Snirf(str(fname), "w") as a:
477482
a.nirs.appendGroup()
@@ -503,3 +508,40 @@ def test_birthday(tmp_path, monkeypatch):
503508

504509
raw = read_raw_snirf(fname)
505510
assert raw.info["subject_info"]["birthday"] == (1950, 1, 1)
511+
512+
513+
@requires_testing_data
514+
def test_sample_rate_jitter(tmp_path):
515+
"""Test handling of jittered sample times."""
516+
from shutil import copy2
517+
518+
# Create a clean copy and ensure it loads without error
519+
new_file = tmp_path / "snirf_nirsport2_2019.snirf"
520+
copy2(snirf_nirsport2_20219, new_file)
521+
read_raw_snirf(new_file)
522+
523+
# Edit the file and add jitter within tolerance (0.99%)
524+
with h5py.File(new_file, "r+") as f:
525+
orig_time = np.array(f.get("nirs/data1/time"))
526+
acceptable_time_jitter = orig_time.copy()
527+
average_time_diff = np.mean(np.diff(orig_time))
528+
acceptable_time_jitter[-1] += 0.0099 * average_time_diff
529+
del f["nirs/data1/time"]
530+
f.flush()
531+
f.create_dataset("nirs/data1/time", data=acceptable_time_jitter)
532+
with catch_logging("info") as log:
533+
read_raw_snirf(new_file)
534+
lines = "\n".join(line for line in log.getvalue().splitlines() if "jitter" in line)
535+
assert "Found jitter of 0.9" in lines
536+
537+
# Add jitter of 1.01%, which is greater than allowed tolerance
538+
with h5py.File(new_file, "r+") as f:
539+
unacceptable_time_jitter = orig_time
540+
unacceptable_time_jitter[-1] = unacceptable_time_jitter[-1] + (
541+
0.0101 * average_time_diff
542+
)
543+
del f["nirs/data1/time"]
544+
f.flush()
545+
f.create_dataset("nirs/data1/time", data=unacceptable_time_jitter)
546+
with pytest.warns(RuntimeWarning, match="non-uniformly-sampled data"):
547+
read_raw_snirf(new_file, verbose=True)

requirements_doc.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# requirements for building docs
2-
sphinx!=4.1.0,<6
2+
sphinx>=6
33
numpydoc
44
pydata_sphinx_theme==0.13.3
55
git+https://github.com/sphinx-gallery/sphinx-gallery@master

requirements_testing_extra.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ EDFlib-Python
77
pybv
88
imageio>=2.6.1
99
imageio-ffmpeg>=0.4.1
10-
pysnirf2
10+
snirf

tools/azure_dependencies.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ elif [ "${TEST_MODE}" == "pip-pre" ]; then
99
python -m pip install $STD_ARGS pip setuptools wheel packaging setuptools_scm
1010
python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://www.riverbankcomputing.com/pypi/simple" PyQt6 PyQt6-sip PyQt6-Qt6
1111
echo "Numpy etc."
12-
python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" "numpy>=2.0.0.dev0" scipy statsmodels pandas scikit-learn matplotlib
12+
python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" "numpy>=2.0.0.dev0" "scipy>=1.12.0.dev0" statsmodels pandas scikit-learn matplotlib
1313
echo "dipy"
1414
python -m pip install $STD_ARGS --only-binary ":all:" --extra-index-url "https://pypi.anaconda.org/scipy-wheels-nightly/simple" dipy
1515
echo "h5py"

tutorials/io/30_reading_fnirs_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
is designed by the fNIRS community in an effort to facilitate
3939
sharing and analysis of fNIRS data. And is the official format of the
4040
Society for functional near-infrared spectroscopy (SfNIRS).
41-
The manufacturers Gowerlabs, NIRx, Kernel, and Cortivision
41+
The manufacturers Gowerlabs, NIRx, Kernel, Artinis, and Cortivision
4242
export data in the SNIRF format, and these files can be imported in to MNE.
4343
SNIRF is the preferred format for reading data in to MNE-Python.
4444
Data stored in the SNIRF format can be read in

0 commit comments

Comments
 (0)