From e74649cebeacd949ca3be480050e84fdc03fed0a Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Fri, 29 May 2026 13:38:13 -0700 Subject: [PATCH 01/13] ENH: Support TD-NIRS data types in SNIRF reader Add support for time-domain NIRS data types including: - TD gated histograms (fnirs_td_gated_amplitude) - TD moments (intensity, mean, variance) - Processed hemoglobin data (HbO/HbR) Updates constants, channel picking, covariance, defaults, and the SNIRF reader/tests to handle these new data types. --- .mailmap | 1 + doc/changes/devel/11064.newfeature.rst | 1 + doc/changes/names.inc | 3 + environment.yml | 1 + mne/_fiff/constants.py | 10 +- mne/_fiff/meas_info.py | 1 + mne/_fiff/pick.py | 54 ++++- mne/_fiff/tests/test_constants.py | 8 +- mne/cov.py | 35 +++ mne/defaults.py | 26 ++- mne/io/snirf/_snirf.py | 265 ++++++++++++++++------ mne/io/snirf/tests/test_snirf.py | 134 +++++++++-- mne/preprocessing/nirs/nirs.py | 34 ++- mne/preprocessing/nirs/tests/test_nirs.py | 2 +- mne/tests/test_defaults.py | 2 +- mne/utils/__init__.pyi | 3 +- 16 files changed, 475 insertions(+), 105 deletions(-) create mode 100644 doc/changes/devel/11064.newfeature.rst diff --git a/.mailmap b/.mailmap index 0dbd52c8f84..e8c3620e4ee 100644 --- a/.mailmap +++ b/.mailmap @@ -379,6 +379,7 @@ Yousra Bekhti Yoursa BEKHTI Yoursa BEKHTI Yousra Bekhti Yousra BEKHTI Yousra Bekhti yousrabk +Zahra M. Aghajan Zahra M. Aghajan Zhi Zhang <850734033@qq.com> ZHANG Zhi <850734033@qq.com> Zhi Zhang <850734033@qq.com> ZHANG Zhi Ziyi ZENG ZIYI ZENG diff --git a/doc/changes/devel/11064.newfeature.rst b/doc/changes/devel/11064.newfeature.rst new file mode 100644 index 00000000000..24f3819a85d --- /dev/null +++ b/doc/changes/devel/11064.newfeature.rst @@ -0,0 +1 @@ +Added basic support for TD fNIRS data, by :newcontrib:`Zahra Aghajan`, :newcontrib:`Julien Dubois`, :newcontrib:`John Griffiths`, `Robert Luke`_, and `Eric Larson`_. \ No newline at end of file diff --git a/doc/changes/names.inc b/doc/changes/names.inc index ba0c60e79e5..7b83fbe541f 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -161,6 +161,7 @@ .. _Johann Benerradi: https://github.com/HanBnrd .. _Johannes Herforth: https://herforth.net .. _Johannes Niediek: https://github.com/jniediek +.. _John Griffiths: https://www.grifflab.com .. _John Samuelsson: https://github.com/johnsam7 .. _John Veillette: https://psychology.uchicago.edu/directory/john-veillette .. _Jon Houck: https://www.mrn.org/people/jon-m.-houck/principal-investigators @@ -174,6 +175,7 @@ .. _Judy D Zhu: https://github.com/JD-Zhu .. _Juergen Dammers: https://github.com/jdammers .. _Jukka Nenonen: https://www.linkedin.com/pub/jukka-nenonen/28/b5a/684 +.. _Julien Dubois: https://github.com/julien-dubois-k .. _Jussi Nurminen: https://github.com/jjnurminen .. _Kaisu Lankinen: http://bishoplab.berkeley.edu/Kaisu.html .. _Kalle Makela: https://github.com/Kallemakela @@ -374,6 +376,7 @@ .. _Yixiao Shen: https://github.com/SYXiao2002 .. _Yousra Bekhti: https://www.linkedin.com/pub/yousra-bekhti/56/886/421 .. _Yu-Han Luo: https://github.com/yh-luo +.. _Zahra Aghajan: https://github.com/Zahra-M-Aghajan .. _Zhi Zhang: https://github.com/tczhangzhi/ .. _Ziyi ZENG: https://github.com/ZiyiTsang .. _Zvi Baratz: https://github.com/ZviBaratz diff --git a/environment.yml b/environment.yml index 25e05561277..11257844fa7 100644 --- a/environment.yml +++ b/environment.yml @@ -26,6 +26,7 @@ dependencies: - joblib >=0.8 - jupyter - lazy_loader >=0.3 + - libxml2 !=2.14.0 - mamba - matplotlib >=3.9 - mffpy >=0.5.7 diff --git a/mne/_fiff/constants.py b/mne/_fiff/constants.py index 08331cfe1f9..aced3454d57 100644 --- a/mne/_fiff/constants.py +++ b/mne/_fiff/constants.py @@ -931,6 +931,7 @@ FIFF.FIFF_UNIT_LM = 115 # lumen FIFF.FIFF_UNIT_LX = 116 # lux FIFF.FIFF_UNIT_V_M2 = 117 # V/m^2 +FIFF.FIFF_UNIT_SEC2 = 118 # second^2 # # Others we need # @@ -972,6 +973,7 @@ FIFF.FIFF_UNIT_LM, FIFF.FIFF_UNIT_LX, FIFF.FIFF_UNIT_V_M2, + FIFF.FIFF_UNIT_SEC2, FIFF.FIFF_UNIT_T_M, FIFF.FIFF_UNIT_AM, FIFF.FIFF_UNIT_AM_M2, @@ -1044,7 +1046,9 @@ FIFF.FIFFV_COIL_FNIRS_FD_PHASE = 305 # fNIRS frequency domain phase FIFF.FIFFV_COIL_FNIRS_RAW = FIFF.FIFFV_COIL_FNIRS_CW_AMPLITUDE # old alias FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE = 306 # fNIRS time-domain gated amplitude -FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_AMPLITUDE = 307 # fNIRS time-domain moments amplitude +FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_INTENSITY = 307 # fNIRS time-domain moments intensity +FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_MEAN = 308 # fNIRS time-domain moments mean +FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_VARIANCE = 309 # fNIRS time-domain moments variance FIFF.FIFFV_COIL_EYETRACK_POS = 400 # Eye-tracking gaze position FIFF.FIFFV_COIL_EYETRACK_PUPIL = 401 # Eye-tracking pupil size @@ -1145,7 +1149,9 @@ FIFF.FIFFV_COIL_FNIRS_FD_AC_AMPLITUDE, FIFF.FIFFV_COIL_FNIRS_FD_PHASE, FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE, - FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_AMPLITUDE, + FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_INTENSITY, + FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_MEAN, + FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_VARIANCE, FIFF.FIFFV_COIL_MCG_42, FIFF.FIFFV_COIL_EYETRACK_POS, FIFF.FIFFV_COIL_EYETRACK_PUPIL, diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index 01a36292cd7..0e5874e0d22 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -445,6 +445,7 @@ def set_montage( FIFF.FIFF_UNIT_NONE: "NA", FIFF.FIFF_UNIT_CEL: "C", FIFF.FIFF_UNIT_S: "S", + FIFF.FIFF_UNIT_SEC: "s", FIFF.FIFF_UNIT_PX: "px", } diff --git a/mne/_fiff/pick.py b/mne/_fiff/pick.py index 1b24fd27509..5a0c3c6c992 100644 --- a/mne/_fiff/pick.py +++ b/mne/_fiff/pick.py @@ -104,6 +104,26 @@ def get_channel_type_constants(include_defaults=False): unit=FIFF.FIFF_UNIT_RAD, coil_type=FIFF.FIFFV_COIL_FNIRS_FD_PHASE, ), + fnirs_td_gated_amplitude=dict( + kind=FIFF.FIFFV_FNIRS_CH, + unit=FIFF.FIFF_UNIT_V, + coil_type=FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE, + ), + fnirs_td_moments_intensity=dict( + kind=FIFF.FIFFV_FNIRS_CH, + unit=FIFF.FIFF_UNIT_UNITLESS, + coil_type=FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_INTENSITY, + ), + fnirs_td_moments_mean=dict( + kind=FIFF.FIFFV_FNIRS_CH, + unit=FIFF.FIFF_UNIT_SEC, + coil_type=FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_MEAN, + ), + fnirs_td_moments_variance=dict( + kind=FIFF.FIFFV_FNIRS_CH, + unit=FIFF.FIFF_UNIT_SEC2, + coil_type=FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_VARIANCE, + ), fnirs_od=dict(kind=FIFF.FIFFV_FNIRS_CH, coil_type=FIFF.FIFFV_COIL_FNIRS_OD), hbo=dict( kind=FIFF.FIFFV_FNIRS_CH, @@ -197,6 +217,10 @@ def get_channel_type_constants(include_defaults=False): FIFF.FIFFV_COIL_FNIRS_FD_AC_AMPLITUDE: "fnirs_fd_ac_amplitude", FIFF.FIFFV_COIL_FNIRS_FD_PHASE: "fnirs_fd_phase", FIFF.FIFFV_COIL_FNIRS_OD: "fnirs_od", + FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE: "fnirs_td_gated_amplitude", + FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_INTENSITY: "fnirs_td_moments_intensity", + FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_MEAN: "fnirs_td_moments_mean", + FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_VARIANCE: "fnirs_td_moments_variance", }, ), "eeg": ( @@ -385,6 +409,26 @@ def _triage_fnirs_pick(ch, fnirs, warned): return True elif ch["coil_type"] == FIFF.FIFFV_COIL_FNIRS_OD and "fnirs_od" in fnirs: return True + elif ( + ch["coil_type"] == FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE + and "fnirs_td_gated_amplitude" in fnirs + ): + return True + elif ( + ch["coil_type"] == FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_INTENSITY + and "fnirs_td_moments_intensity" in fnirs + ): + return True + elif ( + ch["coil_type"] == FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_MEAN + and "fnirs_td_moments_mean" in fnirs + ): + return True + elif ( + ch["coil_type"] == FIFF.FIFFV_COIL_FNIRS_TD_MOMENTS_VARIANCE + and "fnirs_td_moments_variance" in fnirs + ): + return True return False @@ -569,7 +613,7 @@ def pick_types( pick[k] = _triage_meg_pick(info["chs"][k], ref_meg) elif ch_type in ("eyegaze", "pupil"): pick[k] = _triage_eyetrack_pick(info["chs"][k], eyetrack) - else: # ch_type in ('hbo', 'hbr') + else: # ch_type in ('hbo', 'hbr', ...) pick[k] = _triage_fnirs_pick(info["chs"][k], fnirs, warned) # restrict channels to selection if provided @@ -867,6 +911,10 @@ def channel_indices_by_type(info, picks=None, *, exclude=()): fnirs_fd_ac_amplitude=list(), fnirs_fd_phase=list(), fnirs_od=list(), + fnirs_td_gated_amplitude=list(), + fnirs_td_moments_intensity=list(), + fnirs_td_moments_mean=list(), + fnirs_td_moments_variance=list(), eyegaze=list(), pupil=list(), ) @@ -1104,6 +1152,10 @@ def _check_excludes_includes(chs, info=None, allow_bads=False): "fnirs_fd_ac_amplitude", "fnirs_fd_phase", "fnirs_od", + "fnirs_td_gated_amplitude", + "fnirs_td_moments_intensity", + "fnirs_td_moments_mean", + "fnirs_td_moments_variance", ) _EYETRACK_CH_TYPES_SPLIT = ("eyegaze", "pupil") _DATA_CH_TYPES_ORDER_DEFAULT = ( diff --git a/mne/_fiff/tests/test_constants.py b/mne/_fiff/tests/test_constants.py index 43ea2290346..a808c71798d 100644 --- a/mne/_fiff/tests/test_constants.py +++ b/mne/_fiff/tests/test_constants.py @@ -27,8 +27,8 @@ from mne.utils import requires_good_network # https://github.com/mne-tools/fiff-constants/commits/master -REPO = "mne-tools" -COMMIT = "e27f68cbf74dbfc5193ad429cc77900a59475181" +REPO = "larsoner" # TODO: Replace with upstream once merged +COMMIT = "63cdf5d64a7006d2b8d21931bd7c4d898e310df8" # These are oddities that we won't address: iod_dups = (355, 359) # these are in both MEGIN and MNE files @@ -91,7 +91,9 @@ 304, # fNIRS frequency domain AC amplitude 305, # fNIRS frequency domain phase 306, # fNIRS time domain gated amplitude - 307, # fNIRS time domain moments amplitude + 307, # fNIRS time domain moments intensity + 308, # fNIRS time domain moments mean + 309, # fNIRS time domain moments variance 400, # Eye-tracking gaze position 401, # Eye-tracking pupil size 1000, # For testing the MCG software diff --git a/mne/cov.py b/mne/cov.py index 07af31476d8..f1b4f1e1641 100644 --- a/mne/cov.py +++ b/mne/cov.py @@ -1538,6 +1538,7 @@ def __init__( grad=0.1, mag=0.1, eeg=0.1, + *, seeg=0.1, ecog=0.1, hbo=0.1, @@ -1546,6 +1547,10 @@ def __init__( fnirs_fd_ac_amplitude=0.1, fnirs_fd_phase=0.1, fnirs_od=0.1, + fnirs_td_gated_amplitude=0.1, + fnirs_td_moments_intensity=0.1, + fnirs_td_moments_mean=0.1, + fnirs_td_moments_variance=0.1, csd=0.1, dbs=0.1, store_precision=False, @@ -1566,6 +1571,10 @@ def __init__( self.fnirs_fd_ac_amplitude = fnirs_fd_ac_amplitude self.fnirs_fd_phase = fnirs_fd_phase self.fnirs_od = fnirs_od + self.fnirs_td_gated_amplitude = fnirs_td_gated_amplitude + self.fnirs_td_moments_intensity = fnirs_td_moments_intensity + self.fnirs_td_moments_mean = fnirs_td_moments_mean + self.fnirs_td_moments_variance = fnirs_td_moments_variance self.csd = csd self.store_precision = store_precision self.assume_centered = assume_centered @@ -1598,6 +1607,15 @@ def fit(self, X): dbs=self.dbs, hbo=self.hbo, hbr=self.hbr, + fnirs_cw_amplitude=self.fnirs_cw_amplitude, + fnirs_fd_ac_amplitude=self.fnirs_fd_ac_amplitude, + fnirs_fd_phase=self.fnirs_fd_phase, + fnirs_od=self.fnirs_od, + fnirs_td_gated_amplitude=self.fnirs_td_gated_amplitude, + fnirs_td_moments_intensity=self.fnirs_td_moments_intensity, + fnirs_td_moments_mean=self.fnirs_td_moments_mean, + fnirs_td_moments_variance=self.fnirs_td_moments_variance, + csd=self.csd, rank="full", ) self.estimator_.covariance_ = self.covariance_ = cov_.data @@ -1926,6 +1944,7 @@ def regularize( eeg=0.1, exclude="bads", proj=True, + *, seeg=0.1, ecog=0.1, hbo=0.1, @@ -1934,6 +1953,10 @@ def regularize( fnirs_fd_ac_amplitude=0.1, fnirs_fd_phase=0.1, fnirs_od=0.1, + fnirs_td_gated_amplitude=0.1, + fnirs_td_moments_intensity=0.1, + fnirs_td_moments_mean=0.1, + fnirs_td_moments_variance=0.1, csd=0.1, dbs=0.1, rank=None, @@ -1985,6 +2008,14 @@ def regularize( Regularization factor for fNIRS raw phase signals. fnirs_od : float (default 0.1) Regularization factor for fNIRS optical density signals. + fnirs_td_gated_amplitude : float (default 0.1) + Regularization factor for fNIRS time domain gated amplitude signals. + fnirs_td_moments_intensity : float (default 0.1) + Regularization factor for fNIRS time domain moments amplitude signals. + fnirs_td_moments_mean : float (default 0.1) + Regularization factor for fNIRS time domain moments mean signals. + fnirs_td_moments_variance : float (default 0.1) + Regularization factor for fNIRS time domain moments variance signals. csd : float (default 0.1) Regularization factor for EEG-CSD signals. dbs : float (default 0.1) @@ -2025,6 +2056,10 @@ def regularize( fnirs_fd_ac_amplitude=fnirs_fd_ac_amplitude, fnirs_fd_phase=fnirs_fd_phase, fnirs_od=fnirs_od, + fnirs_td_gated_amplitude=fnirs_td_gated_amplitude, + fnirs_td_moments_intensity=fnirs_td_moments_intensity, + fnirs_td_moments_mean=fnirs_td_moments_mean, + fnirs_td_moments_variance=fnirs_td_moments_variance, csd=csd, ) diff --git a/mne/defaults.py b/mne/defaults.py index ac040f8fceb..00236c92f1a 100644 --- a/mne/defaults.py +++ b/mne/defaults.py @@ -32,6 +32,10 @@ fnirs_fd_ac_amplitude="k", fnirs_fd_phase="k", fnirs_od="k", + fnirs_td_gated_amplitude="k", + fnirs_td_moments_intensity="k", + fnirs_td_moments_mean="k", + fnirs_td_moments_variance="k", csd="k", whitened="k", gsr="#666633", @@ -60,6 +64,10 @@ fnirs_fd_ac_amplitude="V", fnirs_fd_phase="rad", fnirs_od="V", + fnirs_td_gated_amplitude="AU", # counts + fnirs_td_moments_intensity="AU", # counts + fnirs_td_moments_mean="s", + fnirs_td_moments_variance="s²", csd="V/m²", whitened="Z", gsr="S", @@ -88,6 +96,10 @@ fnirs_fd_ac_amplitude="V", fnirs_fd_phase="rad", fnirs_od="V", + fnirs_td_gated_amplitude="AU", + fnirs_td_moments_intensity="AU", + fnirs_td_moments_mean="ps", + fnirs_td_moments_variance="ps²", csd="mV/m²", whitened="Z", gsr="S", @@ -95,7 +107,7 @@ eyegaze="rad", pupil="mm", ), - # scalings for the units + # scalings for the "units" above (must match!) scalings=dict( mag=1e15, grad=1e13, @@ -117,6 +129,10 @@ fnirs_fd_ac_amplitude=1.0, fnirs_fd_phase=1.0, fnirs_od=1.0, + fnirs_td_gated_amplitude=1.0, + fnirs_td_moments_intensity=1.0, + fnirs_td_moments_mean=1e12, + fnirs_td_moments_variance=1e24, csd=1e3, whitened=1.0, gsr=1.0, @@ -151,6 +167,10 @@ fnirs_fd_ac_amplitude=2e-2, fnirs_fd_phase=2e-1, fnirs_od=2e-2, + fnirs_td_gated_amplitude=1.0, + fnirs_td_moments_intensity=1.0, + fnirs_td_moments_mean=1e-10, + fnirs_td_moments_variance=1e-20, csd=200e-4, dipole=1e-7, gof=1e2, @@ -206,6 +226,10 @@ fnirs_fd_phase="fNIRS (FD phase)", fnirs_od="fNIRS (OD)", hbr="Deoxyhemoglobin", + fnirs_td_gated_amplitude="fNIRS (TD amplitude)", + fnirs_td_moments_intensity="fNIRS (TD moment intensity)", + fnirs_td_moments_mean="fNIRS (TD moment mean)", + fnirs_td_moments_variance="fNIRS (TD moment variance)", gof="Goodness of fit", csd="Current source density", stim="Stimulus", diff --git a/mne/io/snirf/_snirf.py b/mne/io/snirf/_snirf.py index 1b3f940a58d..8ecb59fe293 100644 --- a/mne/io/snirf/_snirf.py +++ b/mne/io/snirf/_snirf.py @@ -15,7 +15,9 @@ from ...annotations import Annotations from ...transforms import _frame_to_str, apply_trans from ...utils import ( + NamedInt, _check_fname, + _check_option, _import_h5py, _validate_type, fill_doc, @@ -26,6 +28,45 @@ from ..base import BaseRaw from ..nirx.nirx import _convert_fnirs_to_head +SNIRF_CW_AMPLITUDE = NamedInt("SNIRF_CW_AMPLITUDE", 1) +SNIRF_TD_GATED_AMPLITUDE = NamedInt("SNIRF_TD_GATED_AMPLITUDE", 201) +SNIRF_TD_MOMENTS_AMPLITUDE = NamedInt("SNIRF_TD_MOMENTS_AMPLITUDE", 301) +SNIRF_PROCESSED = NamedInt("SNIRF_PROCESSED", 99999) +_AVAILABLE_SNIRF_DATA_TYPES = ( + SNIRF_CW_AMPLITUDE, + SNIRF_TD_GATED_AMPLITUDE, + SNIRF_TD_MOMENTS_AMPLITUDE, + SNIRF_PROCESSED, +) + + +# SNIRF: Supported measurementList(k).dataTypeLabel values in dataTimeSeries +FNIRS_SNIRF_DATATYPELABELS = { + # These types are specified here: + # https://github.com/fNIRS/snirf/blob/master/snirf_specification.md#supported-measurementlistkdatatypelabel-values-in-datatimeseries # noqa: E501 + "HbO": 1, # Oxygenated hemoglobin (oxyhemoglobin) concentration + "HbR": 2, # Deoxygenated hemoglobin (deoxyhemoglobin) concentration + "HbT": 3, # Total hemoglobin concentration + "dOD": 4, # Change in optical density + "mua": 5, # Absorption coefficient + "musp": 6, # Scattering coefficient + "H2O": 7, # Water content + "Lipid": 8, # Lipid concentration + "BFi": 9, # Blood flow index + "HRF dOD": 10, # HRF for change in optical density + "HRF HbO": 11, # HRF for oxyhemoglobin concentration + "HRF HbR": 12, # HRF for deoxyhemoglobin concentration + "HRF HbT": 13, # HRF for total hemoglobin concentration + "HRF BFi": 14, # HRF for blood flow index +} + +# In each file, the TD moment order maps to these values +_TD_MOMENT_ORDER_MAP = { + 0: "intensity", + 1: "mean", + 2: "variance", +} + @fill_doc def read_raw_snirf( @@ -124,19 +165,14 @@ def __init__( if (optode_frame == "unknown") & (manufacturer == "Gowerlabs"): optode_frame = "head" - snirf_data_type = np.array( - dat.get("nirs/data1/measurementList1/dataType") - ).item() - if snirf_data_type not in [1, 99999]: - # 1 = Continuous Wave - # 99999 = Processed - raise RuntimeError( - "MNE only supports reading continuous" - " wave amplitude and processed haemoglobin" - " SNIRF files. Expected type" - " code 1 or 99999 but received type " - f"code {snirf_data_type}" - ) + snirf_data_type = _correct_shape( + np.array(dat.get("nirs/data1/measurementList1/dataType")) + )[0] + _check_option( + "SNIRF data type", + snirf_data_type, + list(_AVAILABLE_SNIRF_DATA_TYPES), + ) last_samps = dat.get("/nirs/data1/dataTimeSeries").shape[0] - 1 @@ -157,6 +193,15 @@ def __init__( "continuous wave amplitude SNIRF files." ) + # Get data type specific probe information + if snirf_data_type == SNIRF_TD_GATED_AMPLITUDE: + fnirs_time_delays = np.array(dat.get("nirs/probe/timeDelays"), float) + fnirs_time_delay_widths = np.array( + dat.get("nirs/probe/timeDelayWidths"), float + ) + elif snirf_data_type == SNIRF_TD_MOMENTS_AMPLITUDE: + fnirs_moment_orders = np.array(dat.get("nirs/probe/momentOrders"), int) + # Extract channels def atoi(text): return int(text) if text.isdigit() else text @@ -181,7 +226,7 @@ def natural_keys(text): sources = np.unique( [ _correct_shape( - np.array(dat.get("nirs/data1/" + c + "/sourceIndex")) + np.array(dat.get(f"nirs/data1/{c}/sourceIndex")) )[0] for c in channels ] @@ -198,7 +243,7 @@ def natural_keys(text): detectors = np.unique( [ _correct_shape( - np.array(dat.get("nirs/data1/" + c + "/detectorIndex")) + np.array(dat.get(f"nirs/data1/{c}/detectorIndex")) )[0] for c in channels ] @@ -242,64 +287,107 @@ def natural_keys(text): "location information" ) + # Uniform scale factor assumed here! + snirf_data_unit = np.array( + dat.get("nirs/data1/measurementList1/dataUnit", b"M") + ) + snirf_data_unit = snirf_data_unit.item().decode("utf-8") + scale = _get_dataunit_scaling(snirf_data_unit) + chnames = [] ch_types = [] + ch_cals = [] for chan in channels: + ch_root = f"nirs/data1/{chan}" src_idx = int( - _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/sourceIndex")) - )[0] + _correct_shape(np.array(dat.get(f"{ch_root}/sourceIndex")))[0] ) det_idx = int( - _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/detectorIndex")) - )[0] + _correct_shape(np.array(dat.get(f"{ch_root}/detectorIndex")))[0] ) + ch_name = f"{sources[src_idx]}_{detectors[det_idx]}" + ch_cal = scale - if snirf_data_type == 1: + if snirf_data_type in ( + SNIRF_CW_AMPLITUDE, + SNIRF_TD_GATED_AMPLITUDE, + SNIRF_TD_MOMENTS_AMPLITUDE, + ): wve_idx = int( - _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/wavelengthIndex")) - )[0] - ) - ch_name = ( - sources[src_idx] - + "_" - + detectors[det_idx] - + " " - + str(fnirs_wavelengths[wve_idx - 1]) + _correct_shape(np.array(dat.get(f"{ch_root}/wavelengthIndex")))[ + 0 + ] ) - chnames.append(ch_name) - ch_types.append("fnirs_cw_amplitude") - - elif snirf_data_type == 99999: + # append wavelength + ch_name = f"{ch_name} {fnirs_wavelengths[wve_idx - 1]}" + if snirf_data_type == SNIRF_CW_AMPLITUDE: + ch_type = "fnirs_cw_amplitude" + elif snirf_data_type == SNIRF_TD_GATED_AMPLITUDE: + bin_idx = int( + _correct_shape( + np.array(dat.get(f"{ch_root}/dataTypeIndex")) + )[0] + ) + # append time delay + ch_name = f"{ch_name} bin{fnirs_time_delays[bin_idx - 1]}" + ch_type = "fnirs_td_gated_amplitude" + else: + assert snirf_data_type == SNIRF_TD_MOMENTS_AMPLITUDE + moment_idx = int( + _correct_shape( + np.array(dat.get(f"{ch_root}/dataTypeIndex")) + )[0] + ) + # append moment order + order = fnirs_moment_orders[moment_idx - 1] + _check_option( + f"SNIRF channel {chan} moment order", + order, + _TD_MOMENT_ORDER_MAP, + ) + ch_name = f"{ch_name} moment{order}" + kind = _TD_MOMENT_ORDER_MAP[order] + ch_type = f"fnirs_td_moments_{kind}" + if kind == "mean": + # Stored in picoseconds + ch_cal = 1e-12 + elif kind == "variance": + ch_cal = 1e-24 + elif snirf_data_type == SNIRF_PROCESSED: dt_id = _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/dataTypeLabel")) + np.array(dat.get(f"{ch_root}/dataTypeLabel")) )[0].decode("UTF-8") # Convert between SNIRF processed names and MNE type names dt_id = dt_id.lower().replace("dod", "fnirs_od") - ch_name = sources[src_idx] + "_" + detectors[det_idx] - if dt_id == "fnirs_od": wve_idx = int( _correct_shape( - np.array( - dat.get("nirs/data1/" + chan + "/wavelengthIndex") - ) + np.array(dat.get(f"{ch_root}/wavelengthIndex")) )[0] ) - suffix = " " + str(fnirs_wavelengths[wve_idx - 1]) + suffix = str(fnirs_wavelengths[wve_idx - 1]) else: - suffix = " " + dt_id.lower() - ch_name = ch_name + suffix - - chnames.append(ch_name) - ch_types.append(dt_id) + if dt_id not in ("hbo", "hbr"): + raise RuntimeError( + "read_raw_snirf can only handle processed " + "data in the form of optical density or " + f"HbO/HbR, but got type f{dt_id}" + ) + suffix = dt_id.lower() + ch_name = f"{ch_name} {suffix}" + ch_type = dt_id + chnames.append(ch_name) + ch_types.append(ch_type) + ch_cals.append(ch_cal) + del ch_root, ch_name, ch_type, ch_cal + del scale # Create mne structure info = create_info(chnames, sampling_rate, ch_types=ch_types) + for ch, ch_cal in zip(info["chs"], ch_cals): + ch["cal"] = ch_cal subject_info = {} names = np.array(dat.get("nirs/metaDataTags/SubjectID")) @@ -333,8 +421,8 @@ def natural_keys(text): length_unit = _get_metadata_str(dat, "LengthUnit") length_scaling = _get_lengthunit_scaling(length_unit) - srcPos3D /= length_scaling - detPos3D /= length_scaling + srcPos3D *= length_scaling + detPos3D *= length_scaling if optode_frame in ["mri", "meg"]: # These are all in MNI or MEG coordinates, so let's transform @@ -354,15 +442,12 @@ def natural_keys(text): coord_frame = FIFF.FIFFV_COORD_UNKNOWN for idx, chan in enumerate(channels): + ch_root = f"nirs/data1/{chan}" src_idx = int( - _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/sourceIndex")) - )[0] + _correct_shape(np.array(dat.get(f"{ch_root}/sourceIndex")))[0] ) det_idx = int( - _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/detectorIndex")) - )[0] + _correct_shape(np.array(dat.get(f"{ch_root}/detectorIndex")))[0] ) info["chs"][idx]["loc"][3:6] = srcPos3D[src_idx - 1, :] @@ -374,15 +459,48 @@ def natural_keys(text): info["chs"][idx]["loc"][0:3] = midpoint info["chs"][idx]["coord_frame"] = coord_frame - if (snirf_data_type in [1]) or ( - (snirf_data_type == 99999) and (ch_types[idx] == "fnirs_od") + # get data type specific info: + wve_idx = int( + _correct_shape( + np.array(dat.get(f"{ch_root}/wavelengthIndex", [1])) + )[0] + ) + if snirf_data_type == SNIRF_CW_AMPLITUDE or ( + snirf_data_type == SNIRF_PROCESSED and ch_types[idx] == "fnirs_od" ): - wve_idx = int( - _correct_shape( - np.array(dat.get("nirs/data1/" + chan + "/wavelengthIndex")) - )[0] - ) info["chs"][idx]["loc"][9] = fnirs_wavelengths[wve_idx - 1] + elif snirf_data_type in ( + SNIRF_TD_GATED_AMPLITUDE, + SNIRF_TD_MOMENTS_AMPLITUDE, + ): + info["chs"][idx]["loc"][9] = fnirs_wavelengths[wve_idx - 1] + if snirf_data_type == SNIRF_TD_GATED_AMPLITUDE: + bin_idx = int( + _correct_shape( + np.array(dat.get(f"{ch_root}/dataTypeIndex")) + )[0] + ) + info["chs"][idx]["loc"][10] = ( + fnirs_time_delays[bin_idx - 1] + * fnirs_time_delay_widths[bin_idx - 1] + ) + else: + assert snirf_data_type == SNIRF_TD_MOMENTS_AMPLITUDE + moment_idx = int( + _correct_shape( + np.array(dat.get(f"{ch_root}/dataTypeIndex")) + )[0] + ) + info["chs"][idx]["loc"][10] = fnirs_moment_orders[ + moment_idx - 1 + ] + elif snirf_data_type == SNIRF_PROCESSED: + hb_id = ( + np.array(dat.get(f"{ch_root}/dataTypeLabel")) + .item() + .decode("UTF-8") + ) + info["chs"][idx]["loc"][9] = FNIRS_SNIRF_DATATYPELABELS[hb_id] if "landmarkPos3D" in dat.get("nirs/probe/"): diglocs = np.array(dat.get("/nirs/probe/landmarkPos3D")) @@ -501,11 +619,9 @@ def natural_keys(text): annot = Annotations([], [], []) for key in dat["nirs"]: if "stim" in key: - data = np.atleast_2d(np.array(dat.get("/nirs/" + key + "/data"))) + data = np.atleast_2d(np.array(dat.get(f"/nirs/{key}/data"))) if data.shape[1] >= 3: - desc = _correct_shape( - np.array(dat.get("/nirs/" + key + "/name")) - )[0] + desc = _correct_shape(np.array(dat.get(f"/nirs/{key}/name")))[0] annot.append(data[:, 0], data[:, 1], desc.decode("UTF-8")) self.set_annotations(annot, emit_warning=False) @@ -544,7 +660,7 @@ def _get_timeunit_scaling(time_unit): def _get_lengthunit_scaling(length_unit): """MNE expects distance in m, return required scaling.""" - scalings = {"m": 1, "cm": 100, "mm": 1000} + scalings = {"m": 1.0, "cm": 1e-2, "mm": 1e-3} if length_unit in scalings: return scalings[length_unit] else: @@ -555,6 +671,19 @@ def _get_lengthunit_scaling(length_unit): ) +def _get_dataunit_scaling(hbx_unit): + """MNE expects hbo/hbr in M, return required scaling.""" + scalings = {"M": 1.0, "uM": 1e-6, "": 1.0} + try: + return scalings[hbx_unit] + except KeyError: + raise RuntimeError( + f"The Hb unit {repr(hbx_unit)} is not supported " + "by MNE. Please report this error as a GitHub " + "issue to inform the developers." + ) from None + + def _extract_sampling_rate(dat, user_sfreq): """Extract the sample rate from the time field.""" # This is a workaround to provide support for Artinis data. diff --git a/mne/io/snirf/tests/test_snirf.py b/mne/io/snirf/tests/test_snirf.py index 40f72cf6778..bd447c22a75 100644 --- a/mne/io/snirf/tests/test_snirf.py +++ b/mne/io/snirf/tests/test_snirf.py @@ -64,7 +64,14 @@ ) # Kernel -kernel_hb = testing_path / "SNIRF" / "Kernel" / "Flow50" / "Portal_2021_11" / "hb.snirf" +kernel_flow1_path = testing_path / "SNIRF" / "Kernel" / "Flow50" / "Portal_2021_11" +kernel_hb_old = kernel_flow1_path / "hb.snirf" +kernel_td_moments_old = kernel_flow1_path / "td_moments.snirf" +kernel_flow2_path = testing_path / "SNIRF" / "Kernel" / "Flow2" / "Portal_2024_10_23" +kernel_td_gated = kernel_flow2_path / "c345d04_2.snirf" # Type 201 (TD Gated, 201) +kernel_td_moments = kernel_flow2_path / "c345d04_3.snirf" # Type 202 (TD Moments, 301) +kernel_hb = kernel_flow2_path / "c345d04_5.snirf" # Type 203 (Hb, 99999) + h5py = pytest.importorskip("h5py") # module-level @@ -97,7 +104,13 @@ def _get_loc(raw, ch_name): nirx_nirsport2_103, nirx_nirsport2_103_2, nirx_nirsport2_103_2, - kernel_hb, + pytest.param(kernel_hb_old, id=f"kernel: {kernel_hb_old.stem}"), + pytest.param( + kernel_td_moments_old, id=f"kernel: {kernel_td_moments_old.stem}" + ), + pytest.param(kernel_td_gated, id=f"kernel: {kernel_td_gated.stem}"), + pytest.param(kernel_td_moments, id=f"kernel: {kernel_td_moments.stem}"), + pytest.param(kernel_hb, id=f"kernel: {kernel_hb.stem}"), lumo110, labnirs_multi_wavelength, ] @@ -107,12 +120,29 @@ def test_basic_reading_and_min_process(fname): """Test reading SNIRF files and minimum typical processing.""" raw = read_raw_snirf(fname, preload=True) # SNIRF data can contain several types, so only apply appropriate functions + kinds = [ + "fnirs_cw_amplitude", + "fnirs_od", + "fnirs_td_gated_amplitude", + "fnirs_td_moments_intensity", + "hbo", + # TODO: add fd_* + ] + ch_types = raw.get_channel_types(unique=True) + got_kinds = [kind for kind in kinds if kind in raw] + assert len(got_kinds) == 1, f"Need one data type, {got_kinds=} and {ch_types=}" if "fnirs_cw_amplitude" in raw: raw = optical_density(raw) - if "fnirs_od" in raw: + elif "fnirs_od" in raw: raw = beer_lambert_law(raw, ppf=6) - assert "hbo" in raw - assert "hbr" in raw + elif "fnirs_td_gated_amplitude" in raw: + pass + elif "fnirs_td_moments_intensity" in raw: + assert "fnirs_td_moments_mean" in raw + assert "fnirs_td_moments_variance" in raw + else: + assert "hbo" in raw + assert "hbr" in raw @requires_testing_data @@ -455,25 +485,83 @@ def test_snirf_fieldtrip_od(): @requires_testing_data -def test_snirf_kernel_hb(): - """Test reading Kernel SNIRF files with haemoglobin data.""" - raw = read_raw_snirf(kernel_hb, preload=True) - - # Test data import - assert raw._data.shape == (180 * 2, 14) - assert raw.copy().pick("hbo")._data.shape == (180, 14) - assert raw.copy().pick("hbr")._data.shape == (180, 14) - - assert_allclose(raw.info["sfreq"], 8.256495) +@pytest.mark.parametrize( + "kind, ver, shape, n_nan, fname", + [ + pytest.param("hb", "new", (4, 38), 0, kernel_hb, id="hb"), + pytest.param("hb", "old", (180 * 2, 14), 20, kernel_hb_old, id="hb old"), + pytest.param( + "td moments", "new", (12, 38), 0, kernel_td_moments, id="td moments" + ), + pytest.param("td gated", "new", (100, 38), 0, kernel_td_gated, id="td gated"), + pytest.param( + "td moments", + "old", + (1080, 14), + 60, + kernel_td_moments_old, + id="td moments old", + ), + ], +) +def test_snirf_kernel_basic(kind, ver, shape, n_nan, fname): + """Test reading Kernel SNIRF files with haemoglobin or TD data.""" + raw = read_raw_snirf(fname, preload=True) + if kind == "hb": + # Test data import + assert raw._data.shape == shape + hbo_data = raw.get_data("hbo") + hbr_data = raw.get_data("hbr") + assert hbo_data.shape == hbr_data.shape == (shape[0] // 2, shape[1]) + hbo_norm = np.nanmedian(np.linalg.norm(hbo_data, axis=-1)) + hbr_norm = np.nanmedian(np.linalg.norm(hbr_data, axis=-1)) + # TODO: Old file vs new file scaling, old one is wrong most likely! + if ver == "new": + assert 1e-5 < hbr_norm < hbo_norm < 1e-4 + else: + assert 1 < hbr_norm < 3 + elif kind == "td moments": + assert raw._data.shape == shape + n_ch = 0 + lims = dict(intensity=(1e4, 1e7), mean=(1e-9, 1e-8), variance=(1e-19, 1e-16)) + for key, val in lims.items(): + data = raw.get_data(f"fnirs_td_moments_{key}") + assert data.shape[1] == len(raw.times) + norm = np.nanmedian(np.linalg.norm(data, axis=-1)) + min_, max_ = val + assert min_ < norm < max_, key + n_ch += data.shape[0] + assert raw._data.shape[0] == len(raw.ch_names) == n_ch + mean_ch = raw.copy().pick("fnirs_td_moments_mean").info["chs"][0] + assert mean_ch["unit"] == FIFF.FIFF_UNIT_SEC + var_ch = raw.copy().pick("fnirs_td_moments_variance").info["chs"][0] + assert var_ch["unit"] == FIFF.FIFF_UNIT_SEC2 + else: + pass # TODO: add some gated tests + if ver == "old": + sfreq = 8.256495 + n_annot = 2 + else: + sfreq = 3.759351 + n_annot = 8 + + assert_allclose(raw.info["sfreq"], sfreq, atol=1e-5) bad_nans = np.isnan(raw.get_data()).any(axis=1) - assert np.sum(bad_nans) == 20 - - assert len(raw.annotations.description) == 2 - assert raw.annotations.onset[0] == 0.036939 - assert raw.annotations.onset[1] == 0.874633 - assert raw.annotations.description[0] == "StartTrial" - assert raw.annotations.description[1] == "StartIti" + assert np.sum(bad_nans) == n_nan + + if n_annot == 2: + assert len(raw.annotations.description) == n_annot + assert raw.annotations.onset[0] == 0.036939 + assert raw.annotations.onset[1] == 0.874633 + assert raw.annotations.description[0] == "StartTrial" + assert raw.annotations.description[1] == "StartIti" + else: + assert len(raw.annotations.description) == n_annot + assert raw.annotations.onset[0] == 4.988107 + assert raw.annotations.onset[1] == 5.988107 + assert raw.annotations.description[0] == "StartBlock" + assert raw.annotations.description[1] == "StartTrial" @requires_testing_data @@ -489,7 +577,7 @@ def test_user_set_sfreq(sfreq, context): with context: # both sfreqs are far enough from true rate to yield >1% jitter with pytest.warns(RuntimeWarning, match=r"jitter of \d+\.\d*% in sample times"): - raw = read_raw_snirf(kernel_hb, preload=False, sfreq=sfreq) + raw = read_raw_snirf(kernel_hb_old, preload=False, sfreq=sfreq) assert raw.info["sfreq"] == sfreq diff --git a/mne/preprocessing/nirs/nirs.py b/mne/preprocessing/nirs/nirs.py index d33e3545146..ddc7ad0c9f0 100644 --- a/mne/preprocessing/nirs/nirs.py +++ b/mne/preprocessing/nirs/nirs.py @@ -110,10 +110,33 @@ def _check_channels_ordered(info, pair_vals, *, throw_errors=True, check_bads=Tr # All chromophore fNIRS data picks_chroma = _picks_to_idx(info, ["hbo", "hbr"], exclude=[], allow_empty=True) - if (len(picks_wave) > 0) and (len(picks_chroma) > 0): + # All TD moments + td_moments = [ + "fnirs_td_moments_intensity", + "fnirs_td_moments_mean", + "fnirs_td_moments_variance", + ] + picks_moments = _picks_to_idx(info, td_moments, exclude=[], allow_empty=True) + + # All TD gated + picks_gated = _picks_to_idx( + info, ["fnirs_td_gated_amplitude"], exclude=[], allow_empty=True + ) + + n_found = sum( + len(x) > 0 + for x in ( + picks_wave, + picks_chroma, + picks_moments, + picks_gated, + ) + ) + if n_found != 1: picks = _throw_or_return_empty( - "MNE does not support a combination of amplitude, optical " - "density, and haemoglobin data in the same raw structure.", + "MNE supports exactly one of amplitude, optical density, " + "TD moments, TD gated, and haemoglobin data in a given raw " + f"structure, found {n_found}", throw_errors, ) @@ -122,10 +145,13 @@ def _check_channels_ordered(info, pair_vals, *, throw_errors=True, check_bads=Tr error_word = "frequencies" use_RE = _S_D_F_RE picks = picks_wave - else: + elif len(picks_chroma): error_word = "chromophore" use_RE = _S_D_H_RE picks = picks_chroma + else: + assert len(picks_moments) or len(picks_gated) + return # nothing to check pair_vals = np.array(pair_vals) if pair_vals.shape[0] < 2: diff --git a/mne/preprocessing/nirs/tests/test_nirs.py b/mne/preprocessing/nirs/tests/test_nirs.py index 3ddbebd42d5..b4da3840009 100644 --- a/mne/preprocessing/nirs/tests/test_nirs.py +++ b/mne/preprocessing/nirs/tests/test_nirs.py @@ -429,7 +429,7 @@ def test_fnirs_channel_naming_and_order_custom_optical_density(): info = create_info(ch_names=ch_names, ch_types=ch_types, sfreq=1.0) raw2 = RawArray(data, info, verbose=True) raw.add_channels([raw2]) - with pytest.raises(ValueError, match="does not support a combination"): + with pytest.raises(ValueError, match="exactly one of"): _check_channels_ordered(raw.info, [760, 850]) diff --git a/mne/tests/test_defaults.py b/mne/tests/test_defaults.py index ba3a8395fa8..aae295c3cdf 100644 --- a/mne/tests/test_defaults.py +++ b/mne/tests/test_defaults.py @@ -44,7 +44,7 @@ def test_si_units(): want_scale = _get_scaling(key, units[key]) else: want_scale = _get_scaling(key, units[key]) - assert_allclose(scale, want_scale, rtol=1e-12) + assert_allclose(scale, want_scale, rtol=1e-12, err_msg=key) @pytest.mark.parametrize("key", ("si_units", "color", "scalings", "scalings_plot_raw")) diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index aeff12f21f7..73a0b6e2f6f 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -6,6 +6,7 @@ __all__ = [ "ClosingStringIO", "ExtendedTimeMixin", "GetEpochsMixin", + "NamedInt", "ProgressBar", "SizeMixin", "TimeMixin", @@ -185,7 +186,7 @@ __all__ = [ "warn", "wrapped_stdout", ] -from ._bunch import Bunch, BunchConst, BunchConstNamed +from ._bunch import Bunch, BunchConst, BunchConstNamed, NamedInt from ._logging import ( ClosingStringIO, _get_call_line, From 80afbd1b81afd77fe5f5d58ec99a8f5bbdc2d7e6 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Fri, 29 May 2026 14:06:02 -0700 Subject: [PATCH 02/13] MAINT: Remove old Kernel Flow50 test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old files (Portal_2021_11) don't conform to the SNIRF spec — they store HbO/HbR in micromolar without declaring dataUnit. Rather than adding heuristics, remove these tests. The files can be fixed upstream by adding a proper dataUnit field to the HDF5. --- mne/io/snirf/tests/test_snirf.py | 67 ++++++++------------------------ 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/mne/io/snirf/tests/test_snirf.py b/mne/io/snirf/tests/test_snirf.py index bd447c22a75..1f42e76c459 100644 --- a/mne/io/snirf/tests/test_snirf.py +++ b/mne/io/snirf/tests/test_snirf.py @@ -64,9 +64,6 @@ ) # Kernel -kernel_flow1_path = testing_path / "SNIRF" / "Kernel" / "Flow50" / "Portal_2021_11" -kernel_hb_old = kernel_flow1_path / "hb.snirf" -kernel_td_moments_old = kernel_flow1_path / "td_moments.snirf" kernel_flow2_path = testing_path / "SNIRF" / "Kernel" / "Flow2" / "Portal_2024_10_23" kernel_td_gated = kernel_flow2_path / "c345d04_2.snirf" # Type 201 (TD Gated, 201) kernel_td_moments = kernel_flow2_path / "c345d04_3.snirf" # Type 202 (TD Moments, 301) @@ -104,10 +101,6 @@ def _get_loc(raw, ch_name): nirx_nirsport2_103, nirx_nirsport2_103_2, nirx_nirsport2_103_2, - pytest.param(kernel_hb_old, id=f"kernel: {kernel_hb_old.stem}"), - pytest.param( - kernel_td_moments_old, id=f"kernel: {kernel_td_moments_old.stem}" - ), pytest.param(kernel_td_gated, id=f"kernel: {kernel_td_gated.stem}"), pytest.param(kernel_td_moments, id=f"kernel: {kernel_td_moments.stem}"), pytest.param(kernel_hb, id=f"kernel: {kernel_hb.stem}"), @@ -486,25 +479,14 @@ def test_snirf_fieldtrip_od(): @requires_testing_data @pytest.mark.parametrize( - "kind, ver, shape, n_nan, fname", + "kind, shape, fname", [ - pytest.param("hb", "new", (4, 38), 0, kernel_hb, id="hb"), - pytest.param("hb", "old", (180 * 2, 14), 20, kernel_hb_old, id="hb old"), - pytest.param( - "td moments", "new", (12, 38), 0, kernel_td_moments, id="td moments" - ), - pytest.param("td gated", "new", (100, 38), 0, kernel_td_gated, id="td gated"), - pytest.param( - "td moments", - "old", - (1080, 14), - 60, - kernel_td_moments_old, - id="td moments old", - ), + pytest.param("hb", (4, 38), kernel_hb, id="hb"), + pytest.param("td moments", (12, 38), kernel_td_moments, id="td moments"), + pytest.param("td gated", (100, 38), kernel_td_gated, id="td gated"), ], ) -def test_snirf_kernel_basic(kind, ver, shape, n_nan, fname): +def test_snirf_kernel_basic(kind, shape, fname): """Test reading Kernel SNIRF files with haemoglobin or TD data.""" raw = read_raw_snirf(fname, preload=True) if kind == "hb": @@ -515,11 +497,7 @@ def test_snirf_kernel_basic(kind, ver, shape, n_nan, fname): assert hbo_data.shape == hbr_data.shape == (shape[0] // 2, shape[1]) hbo_norm = np.nanmedian(np.linalg.norm(hbo_data, axis=-1)) hbr_norm = np.nanmedian(np.linalg.norm(hbr_data, axis=-1)) - # TODO: Old file vs new file scaling, old one is wrong most likely! - if ver == "new": - assert 1e-5 < hbr_norm < hbo_norm < 1e-4 - else: - assert 1 < hbr_norm < 3 + assert 1e-5 < hbr_norm < hbo_norm < 1e-4 elif kind == "td moments": assert raw._data.shape == shape n_ch = 0 @@ -538,37 +516,24 @@ def test_snirf_kernel_basic(kind, ver, shape, n_nan, fname): assert var_ch["unit"] == FIFF.FIFF_UNIT_SEC2 else: pass # TODO: add some gated tests - if ver == "old": - sfreq = 8.256495 - n_annot = 2 - else: - sfreq = 3.759351 - n_annot = 8 - assert_allclose(raw.info["sfreq"], sfreq, atol=1e-5) + assert_allclose(raw.info["sfreq"], 3.759351, atol=1e-5) bad_nans = np.isnan(raw.get_data()).any(axis=1) - assert np.sum(bad_nans) == n_nan - - if n_annot == 2: - assert len(raw.annotations.description) == n_annot - assert raw.annotations.onset[0] == 0.036939 - assert raw.annotations.onset[1] == 0.874633 - assert raw.annotations.description[0] == "StartTrial" - assert raw.annotations.description[1] == "StartIti" - else: - assert len(raw.annotations.description) == n_annot - assert raw.annotations.onset[0] == 4.988107 - assert raw.annotations.onset[1] == 5.988107 - assert raw.annotations.description[0] == "StartBlock" - assert raw.annotations.description[1] == "StartTrial" + assert np.sum(bad_nans) == 0 + + assert len(raw.annotations.description) == 8 + assert raw.annotations.onset[0] == 4.988107 + assert raw.annotations.onset[1] == 5.988107 + assert raw.annotations.description[0] == "StartBlock" + assert raw.annotations.description[1] == "StartTrial" @requires_testing_data @pytest.mark.parametrize( "sfreq,context", ( - [8.2, nullcontext()], # sfreq estimated from file is 8.256495 + [3.7, nullcontext()], # sfreq estimated from file is 3.759351 [22, pytest.warns(RuntimeWarning, match="User-supplied sampling frequency")], ), ) @@ -577,7 +542,7 @@ def test_user_set_sfreq(sfreq, context): with context: # both sfreqs are far enough from true rate to yield >1% jitter with pytest.warns(RuntimeWarning, match=r"jitter of \d+\.\d*% in sample times"): - raw = read_raw_snirf(kernel_hb_old, preload=False, sfreq=sfreq) + raw = read_raw_snirf(kernel_hb, preload=False, sfreq=sfreq) assert raw.info["sfreq"] == sfreq From 66814c2ba3fe906b6ee2d3d47f67697ae1bdd6fb Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Fri, 29 May 2026 14:12:39 -0700 Subject: [PATCH 03/13] TST: Add assertions for TD gated data Check shape, channel type, photon count range, and that channel metadata (wavelength, time_delay * width) are populated. --- mne/io/snirf/tests/test_snirf.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mne/io/snirf/tests/test_snirf.py b/mne/io/snirf/tests/test_snirf.py index 1f42e76c459..7820c93a525 100644 --- a/mne/io/snirf/tests/test_snirf.py +++ b/mne/io/snirf/tests/test_snirf.py @@ -515,7 +515,20 @@ def test_snirf_kernel_basic(kind, shape, fname): var_ch = raw.copy().pick("fnirs_td_moments_variance").info["chs"][0] assert var_ch["unit"] == FIFF.FIFF_UNIT_SEC2 else: - pass # TODO: add some gated tests + assert kind == "td gated" + assert raw._data.shape == shape + data = raw.get_data("fnirs_td_gated_amplitude") + assert data.shape == shape + # Photon counts should be positive and in a reasonable range + norm = np.nanmedian(np.linalg.norm(data, axis=-1)) + assert 1e3 < norm < 1e8 + # Channel names should include wavelength and bin info + assert all("bin" in ch for ch in raw.ch_names) + # Check channel metadata + ch = raw.info["chs"][0] + assert ch["coil_type"] == FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE + assert ch["loc"][9] > 0 # wavelength + assert ch["loc"][10] > 0 # time_delay * time_delay_width assert_allclose(raw.info["sfreq"], 3.759351, atol=1e-5) From 5cd45167a1e52653c65344ed3a4a1006b7499bf5 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Fri, 29 May 2026 14:13:59 -0700 Subject: [PATCH 04/13] FIX: Use UNITLESS for TD gated amplitude (photon counts) TD gated channels measure photon counts from single-photon counting detectors, not voltages. Use FIFF_UNIT_UNITLESS (matching fnirs_td_moments_intensity) instead of FIFF_UNIT_V. --- mne/_fiff/pick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/_fiff/pick.py b/mne/_fiff/pick.py index 5a0c3c6c992..117bda658b3 100644 --- a/mne/_fiff/pick.py +++ b/mne/_fiff/pick.py @@ -106,7 +106,7 @@ def get_channel_type_constants(include_defaults=False): ), fnirs_td_gated_amplitude=dict( kind=FIFF.FIFFV_FNIRS_CH, - unit=FIFF.FIFF_UNIT_V, + unit=FIFF.FIFF_UNIT_UNITLESS, coil_type=FIFF.FIFFV_COIL_FNIRS_TD_GATED_AMPLITUDE, ), fnirs_td_moments_intensity=dict( From b6efe9626072fabb91a7d695b7f9f555ef32d625 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Fri, 29 May 2026 14:29:29 -0700 Subject: [PATCH 05/13] MAINT: Move changelog to doc/changes/dev/ --- doc/changes/{devel => dev}/11064.newfeature.rst | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/changes/{devel => dev}/11064.newfeature.rst (100%) diff --git a/doc/changes/devel/11064.newfeature.rst b/doc/changes/dev/11064.newfeature.rst similarity index 100% rename from doc/changes/devel/11064.newfeature.rst rename to doc/changes/dev/11064.newfeature.rst From ba5810a6f3433189888fa5e36809ce1bb7d6a872 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Fri, 29 May 2026 14:31:37 -0700 Subject: [PATCH 06/13] MAINT: Remove stale libxml2 pin from environment.yml --- environment.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/environment.yml b/environment.yml index 11257844fa7..25e05561277 100644 --- a/environment.yml +++ b/environment.yml @@ -26,7 +26,6 @@ dependencies: - joblib >=0.8 - jupyter - lazy_loader >=0.3 - - libxml2 !=2.14.0 - mamba - matplotlib >=3.9 - mffpy >=0.5.7 From d80af36131027b2da9d1ccbc4dedbbbfe150acea Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Mon, 1 Jun 2026 09:17:00 -0700 Subject: [PATCH 07/13] handle more units --- mne/io/snirf/_snirf.py | 33 ++++++++++++++++++++++---------- mne/io/snirf/tests/test_snirf.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/mne/io/snirf/_snirf.py b/mne/io/snirf/_snirf.py index 8ecb59fe293..9831a2d890c 100644 --- a/mne/io/snirf/_snirf.py +++ b/mne/io/snirf/_snirf.py @@ -671,17 +671,30 @@ def _get_lengthunit_scaling(length_unit): ) +_SI_PREFIXES = {"": 1, "m": 1e-3, "u": 1e-6, "n": 1e-9, "p": 1e-12, "f": 1e-15} +_VOLUME_IN_LITERS = {"L": 1, "l": 1, "dm^3": 1, "m^3": 1e3} + + def _get_dataunit_scaling(hbx_unit): - """MNE expects hbo/hbr in M, return required scaling.""" - scalings = {"M": 1.0, "uM": 1e-6, "": 1.0} - try: - return scalings[hbx_unit] - except KeyError: - raise RuntimeError( - f"The Hb unit {repr(hbx_unit)} is not supported " - "by MNE. Please report this error as a GitHub " - "issue to inform the developers." - ) from None + """MNE expects hbo/hbr in mol/L, return required scaling.""" + if hbx_unit == "": + return 1.0 + + # Legacy shorthand: [prefix]M where M = mol/L + if hbx_unit.endswith("M") and hbx_unit[:-1] in _SI_PREFIXES: + return _SI_PREFIXES[hbx_unit[:-1]] + + # CMIXF-12 compound form: [prefix]mol/ + if "mol/" in hbx_unit: + prefix, denom = hbx_unit.split("mol/", 1) + if prefix in _SI_PREFIXES and denom in _VOLUME_IN_LITERS: + return _SI_PREFIXES[prefix] / _VOLUME_IN_LITERS[denom] + + raise RuntimeError( + f"The Hb unit {repr(hbx_unit)} is not supported " + "by MNE. Please report this error as a GitHub " + "issue to inform the developers." + ) def _extract_sampling_rate(dat, user_sfreq): diff --git a/mne/io/snirf/tests/test_snirf.py b/mne/io/snirf/tests/test_snirf.py index 7820c93a525..75c28ea5f44 100644 --- a/mne/io/snirf/tests/test_snirf.py +++ b/mne/io/snirf/tests/test_snirf.py @@ -685,6 +685,38 @@ def test_sample_rate_jitter(tmp_path): @requires_testing_data +def test_get_dataunit_scaling(): + """Test CMIXF-12 and legacy Hb unit scaling.""" + from mne.io.snirf._snirf import _get_dataunit_scaling + + # Legacy shorthand: [prefix]M + assert _get_dataunit_scaling("M") == 1.0 + assert _get_dataunit_scaling("mM") == 1e-3 + assert _get_dataunit_scaling("uM") == 1e-6 + assert _get_dataunit_scaling("nM") == 1e-9 + # CMIXF-12: [prefix]mol / L + assert _get_dataunit_scaling("mol/L") == 1.0 + assert _get_dataunit_scaling("mmol/L") == 1e-3 + assert _get_dataunit_scaling("umol/L") == 1e-6 + assert _get_dataunit_scaling("nmol/L") == 1e-9 + assert _get_dataunit_scaling("pmol/L") == 1e-12 + # CMIXF-12: [prefix]mol / dm^3 (dm^3 = L) + assert _get_dataunit_scaling("mol/dm^3") == 1.0 + assert _get_dataunit_scaling("mmol/dm^3") == 1e-3 + assert _get_dataunit_scaling("umol/dm^3") == 1e-6 + # CMIXF-12: [prefix]mol / m^3 (1 m^3 = 1000 L) + assert _get_dataunit_scaling("mol/m^3") == 1e-3 + assert _get_dataunit_scaling("mmol/m^3") == 1e-6 + # Non-standard lowercase liter + assert _get_dataunit_scaling("mol/l") == 1.0 + assert _get_dataunit_scaling("mmol/l") == 1e-3 + # Empty string default + assert _get_dataunit_scaling("") == 1.0 + # Unsupported unit + with pytest.raises(RuntimeError, match="not supported"): + _get_dataunit_scaling("bad_unit") + + def test_snirf_multiple_wavelengths(): """Test importing synthetic SNIRF files with >=3 wavelengths.""" raw = read_raw_snirf(labnirs_multi_wavelength, preload=True) From c9b5623d2d79021dac88f982b3d43d0f5e8ac3a6 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Mon, 1 Jun 2026 09:26:09 -0700 Subject: [PATCH 08/13] change github handle --- doc/changes/names.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 7b83fbe541f..49935aaa8ea 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -175,7 +175,7 @@ .. _Judy D Zhu: https://github.com/JD-Zhu .. _Juergen Dammers: https://github.com/jdammers .. _Jukka Nenonen: https://www.linkedin.com/pub/jukka-nenonen/28/b5a/684 -.. _Julien Dubois: https://github.com/julien-dubois-k +.. _Julien Dubois: https://github.com/jcrdubois .. _Jussi Nurminen: https://github.com/jjnurminen .. _Kaisu Lankinen: http://bishoplab.berkeley.edu/Kaisu.html .. _Kalle Makela: https://github.com/Kallemakela From 064bff2da6d2fbb39bc13c322e698ef07f19d178 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Mon, 1 Jun 2026 15:01:21 -0700 Subject: [PATCH 09/13] add original source and detector labels to info --- mne/io/snirf/_snirf.py | 169 ++++++++++++++++++++++++++--------------- 1 file changed, 106 insertions(+), 63 deletions(-) diff --git a/mne/io/snirf/_snirf.py b/mne/io/snirf/_snirf.py index 9831a2d890c..b4ff4aa2b76 100644 --- a/mne/io/snirf/_snirf.py +++ b/mne/io/snirf/_snirf.py @@ -216,39 +216,41 @@ def natural_keys(text): # Source and detector labels are optional fields. # Use S1, S2, S3, etc if not specified. - if "sourceLabels_disabled" in dat["nirs/probe"]: - # This is disabled as - # MNE-Python does not currently support custom source names. - # Instead, sources must be integer values. - sources = np.array(dat.get("nirs/probe/sourceLabels")) - sources = [s.decode("UTF-8") for s in sources] - else: - sources = np.unique( - [ - _correct_shape( - np.array(dat.get(f"nirs/data1/{c}/sourceIndex")) - )[0] - for c in channels - ] - ) - sources = {int(s): f"S{int(s)}" for s in sources} - - if "detectorLabels_disabled" in dat["nirs/probe"]: - # This is disabled as - # MNE-Python does not currently support custom detector names. - # Instead, detector must be integer values. - detectors = np.array(dat.get("nirs/probe/detectorLabels")) - detectors = [d.decode("UTF-8") for d in detectors] - else: - detectors = np.unique( - [ - _correct_shape( - np.array(dat.get(f"nirs/data1/{c}/detectorIndex")) - )[0] - for c in channels - ] - ) - detectors = {int(d): f"D{int(d)}" for d in detectors} + # MNE channel names must follow S{int}_D{int} format, + # so we always generate integer-based names. The original + # SNIRF labels are stored separately for user access. + all_src_idx = np.unique( + [ + _correct_shape( + np.array(dat.get(f"nirs/data1/{c}/sourceIndex")) + )[0] + for c in channels + ] + ) + sources = {int(s): f"S{int(s)}" for s in all_src_idx} + + all_det_idx = np.unique( + [ + _correct_shape( + np.array(dat.get(f"nirs/data1/{c}/detectorIndex")) + )[0] + for c in channels + ] + ) + detectors = {int(d): f"D{int(d)}" for d in all_det_idx} + + snirf_source_labels = None + snirf_detector_labels = None + if "sourceLabels" in dat["nirs/probe"]: + snirf_source_labels = [ + s.decode("UTF-8") if isinstance(s, bytes) else str(s) + for s in np.array(dat.get("nirs/probe/sourceLabels")) + ] + if "detectorLabels" in dat["nirs/probe"]: + snirf_detector_labels = [ + d.decode("UTF-8") if isinstance(d, bytes) else str(d) + for d in np.array(dat.get("nirs/probe/detectorLabels")) + ] # Extract source and detector locations # 3D positions are optional in SNIRF, @@ -559,33 +561,35 @@ def natural_keys(text): with info._unlock(): info["dig"] = dig - str_date = _correct_shape( - np.array(dat.get("/nirs/metaDataTags/MeasurementDate")) - )[0].decode("UTF-8") - str_time = _correct_shape( - np.array(dat.get("/nirs/metaDataTags/MeasurementTime")) - )[0].decode("UTF-8") - str_datetime = str_date + str_time - - # Several formats have been observed so we try each in turn - for dt_code in [ - "%Y-%m-%d%H:%M:%SZ", - "%Y-%m-%d%H:%M:%S", - "%Y-%m-%d%H:%M:%S.%f", - "%Y-%m-%d%H:%M:%S.%f%z", - ]: - try: - meas_date = datetime.datetime.strptime(str_datetime, dt_code) - except ValueError: - pass + raw_date = dat.get("/nirs/metaDataTags/MeasurementDate") + raw_time = dat.get("/nirs/metaDataTags/MeasurementTime") + if raw_date is not None and raw_time is not None: + str_date = _correct_shape(np.array(raw_date))[0].decode("UTF-8") + str_time = _correct_shape(np.array(raw_time))[0].decode("UTF-8") + str_datetime = str_date + str_time + + for dt_code in [ + "%Y-%m-%d%H:%M:%SZ", + "%Y-%m-%d%H:%M:%S", + "%Y-%m-%d%H:%M:%S.%f", + "%Y-%m-%d%H:%M:%S.%f%z", + ]: + try: + meas_date = datetime.datetime.strptime( + str_datetime, dt_code + ) + except ValueError: + pass + else: + break else: - break + warn( + "Extraction of measurement date from SNIRF file " + "failed. The date is being set to January 1st, " + f"2000, instead of {str_datetime}" + ) + meas_date = datetime.datetime(2000, 1, 1, 0, 0, 0) else: - warn( - "Extraction of measurement date from SNIRF file failed. " - "The date is being set to January 1st, 2000, " - f"instead of {str_datetime}" - ) meas_date = datetime.datetime(2000, 1, 1, 0, 0, 0) meas_date = meas_date.replace(tzinfo=datetime.timezone.utc) with info._unlock(): @@ -618,13 +622,52 @@ def natural_keys(text): # blob/master/snirf_specification.md#nirsistimjdata annot = Annotations([], [], []) for key in dat["nirs"]: - if "stim" in key: - data = np.atleast_2d(np.array(dat.get(f"/nirs/{key}/data"))) - if data.shape[1] >= 3: - desc = _correct_shape(np.array(dat.get(f"/nirs/{key}/name")))[0] - annot.append(data[:, 0], data[:, 1], desc.decode("UTF-8")) + if "stim" not in key: + continue + data = np.atleast_2d(np.array(dat.get(f"/nirs/{key}/data"))) + if data.shape[1] < 2: + continue + onsets = data[:, 0] + durations = data[:, 1] + group_name = _correct_shape( + np.array(dat.get(f"/nirs/{key}/name")) + )[0].decode("UTF-8") + raw_labels = dat.get(f"/nirs/{key}/dataLabels") + if raw_labels is not None: + labels = [ + lbl.decode("UTF-8") if isinstance(lbl, bytes) + else str(lbl) + for lbl in np.array(raw_labels) + ] + else: + labels = [] + # Look for one-hot "Type.X" or "BlockType.X" columns + type_cols = { + i: lbl.split(".")[-1] + for i, lbl in enumerate(labels) + if "." in lbl + and any( + prefix in lbl + for prefix in ("BlockType.", "Type.", "TrialType.") + ) + } + if type_cols and data.shape[1] > max(type_cols): + descs = [] + for row in data: + matched = [ + name + for col, name in type_cols.items() + if row[col] == 1.0 + ] + descs.append(matched[0] if matched else group_name) + annot.append(onsets, durations, descs) + else: + annot.append(onsets, durations, group_name) self.set_annotations(annot, emit_warning=False) + self._snirf_source_labels = snirf_source_labels + self._snirf_detector_labels = snirf_detector_labels + # Validate that the fNIRS info is correctly formatted _validate_nirs_info(self.info) From a5c9eb173d80154f80108b51512c8c79237ee750 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Mon, 1 Jun 2026 15:01:45 -0700 Subject: [PATCH 10/13] widen allowable fnirs datatypes for topomap --- mne/viz/topo.py | 7 ++++++- mne/viz/topomap.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/mne/viz/topo.py b/mne/viz/topo.py index 5c43d4de48e..f2a08c924e9 100644 --- a/mne/viz/topo.py +++ b/mne/viz/topo.py @@ -1047,7 +1047,12 @@ def _plot_evoked_topo( [ x for x in types_used - if x in ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od") + if x in ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od", + "fnirs_fd_ac_amplitude", "fnirs_fd_phase", + "fnirs_td_gated_amplitude", + "fnirs_td_moments_intensity", + "fnirs_td_moments_mean", + "fnirs_td_moments_variance") ] ) > 0 diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index fcbd213ce78..15962e5b456 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -76,7 +76,18 @@ plt_show, ) -_fnirs_types = ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od") +_fnirs_types = ( + "hbo", + "hbr", + "fnirs_cw_amplitude", + "fnirs_od", + "fnirs_fd_ac_amplitude", + "fnirs_fd_phase", + "fnirs_td_gated_amplitude", + "fnirs_td_moments_intensity", + "fnirs_td_moments_mean", + "fnirs_td_moments_variance", +) _opm_coils = ( FIFF.FIFFV_COIL_QUSPIN_ZFOPM_MAG, FIFF.FIFFV_COIL_QUSPIN_ZFOPM_MAG2, From 446aa72745f1438250d71c2139bddb01b18ed326 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:02:09 +0000 Subject: [PATCH 11/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/io/snirf/_snirf.py | 27 ++++++++++----------------- mne/viz/topo.py | 19 +++++++++++++------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/mne/io/snirf/_snirf.py b/mne/io/snirf/_snirf.py index b4ff4aa2b76..ad70ac4ab87 100644 --- a/mne/io/snirf/_snirf.py +++ b/mne/io/snirf/_snirf.py @@ -221,9 +221,7 @@ def natural_keys(text): # SNIRF labels are stored separately for user access. all_src_idx = np.unique( [ - _correct_shape( - np.array(dat.get(f"nirs/data1/{c}/sourceIndex")) - )[0] + _correct_shape(np.array(dat.get(f"nirs/data1/{c}/sourceIndex")))[0] for c in channels ] ) @@ -231,9 +229,9 @@ def natural_keys(text): all_det_idx = np.unique( [ - _correct_shape( - np.array(dat.get(f"nirs/data1/{c}/detectorIndex")) - )[0] + _correct_shape(np.array(dat.get(f"nirs/data1/{c}/detectorIndex")))[ + 0 + ] for c in channels ] ) @@ -575,9 +573,7 @@ def natural_keys(text): "%Y-%m-%d%H:%M:%S.%f%z", ]: try: - meas_date = datetime.datetime.strptime( - str_datetime, dt_code - ) + meas_date = datetime.datetime.strptime(str_datetime, dt_code) except ValueError: pass else: @@ -629,14 +625,13 @@ def natural_keys(text): continue onsets = data[:, 0] durations = data[:, 1] - group_name = _correct_shape( - np.array(dat.get(f"/nirs/{key}/name")) - )[0].decode("UTF-8") + group_name = _correct_shape(np.array(dat.get(f"/nirs/{key}/name")))[ + 0 + ].decode("UTF-8") raw_labels = dat.get(f"/nirs/{key}/dataLabels") if raw_labels is not None: labels = [ - lbl.decode("UTF-8") if isinstance(lbl, bytes) - else str(lbl) + lbl.decode("UTF-8") if isinstance(lbl, bytes) else str(lbl) for lbl in np.array(raw_labels) ] else: @@ -655,9 +650,7 @@ def natural_keys(text): descs = [] for row in data: matched = [ - name - for col, name in type_cols.items() - if row[col] == 1.0 + name for col, name in type_cols.items() if row[col] == 1.0 ] descs.append(matched[0] if matched else group_name) annot.append(onsets, durations, descs) diff --git a/mne/viz/topo.py b/mne/viz/topo.py index f2a08c924e9..37781cde08d 100644 --- a/mne/viz/topo.py +++ b/mne/viz/topo.py @@ -1047,12 +1047,19 @@ def _plot_evoked_topo( [ x for x in types_used - if x in ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od", - "fnirs_fd_ac_amplitude", "fnirs_fd_phase", - "fnirs_td_gated_amplitude", - "fnirs_td_moments_intensity", - "fnirs_td_moments_mean", - "fnirs_td_moments_variance") + if x + in ( + "hbo", + "hbr", + "fnirs_cw_amplitude", + "fnirs_od", + "fnirs_fd_ac_amplitude", + "fnirs_fd_phase", + "fnirs_td_gated_amplitude", + "fnirs_td_moments_intensity", + "fnirs_td_moments_mean", + "fnirs_td_moments_variance", + ) ] ) > 0 From 7d4815c61ad8d3065b72752204fe7931e83d9b25 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Tue, 2 Jun 2026 10:56:03 -0700 Subject: [PATCH 12/13] cleanup --- .mailmap | 2 +- mne/_fiff/tests/test_constants.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.mailmap b/.mailmap index e8c3620e4ee..98984689171 100644 --- a/.mailmap +++ b/.mailmap @@ -379,7 +379,7 @@ Yousra Bekhti Yoursa BEKHTI Yoursa BEKHTI Yousra Bekhti Yousra BEKHTI Yousra Bekhti yousrabk -Zahra M. Aghajan Zahra M. Aghajan +Zahra M. Aghajan Zahra M. Aghajan Zhi Zhang <850734033@qq.com> ZHANG Zhi <850734033@qq.com> Zhi Zhang <850734033@qq.com> ZHANG Zhi Ziyi ZENG ZIYI ZENG diff --git a/mne/_fiff/tests/test_constants.py b/mne/_fiff/tests/test_constants.py index a808c71798d..ebb1d729bd9 100644 --- a/mne/_fiff/tests/test_constants.py +++ b/mne/_fiff/tests/test_constants.py @@ -27,8 +27,8 @@ from mne.utils import requires_good_network # https://github.com/mne-tools/fiff-constants/commits/master -REPO = "larsoner" # TODO: Replace with upstream once merged -COMMIT = "63cdf5d64a7006d2b8d21931bd7c4d898e310df8" +REPO = "mne-tools" +COMMIT = "9ccb09d69daa8332f2e7252638ba397b60ba2502" # These are oddities that we won't address: iod_dups = (355, 359) # these are in both MEGIN and MNE files From 5fd7c08d561ccd77e627487f6b8e9a3f53de3e4f Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Tue, 2 Jun 2026 10:58:34 -0700 Subject: [PATCH 13/13] PR # change --- doc/changes/dev/{11064.newfeature.rst => 13938.newfeature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/changes/dev/{11064.newfeature.rst => 13938.newfeature.rst} (100%) diff --git a/doc/changes/dev/11064.newfeature.rst b/doc/changes/dev/13938.newfeature.rst similarity index 100% rename from doc/changes/dev/11064.newfeature.rst rename to doc/changes/dev/13938.newfeature.rst