Skip to content

Commit 8c70f31

Browse files
Merge branch 'main' into feat/brainvision-units-fallback
2 parents 48221ce + 6b495eb commit 8c70f31

7 files changed

Lines changed: 161 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix :func:`mne.preprocessing.nirs.beer_lambert_law` to support explicitly provided ``sd_distances`` during Beer-Lambert conversion. When valid source-detector distances are already present in ``raw.info``, overriding them now emits a warning, and when all inferred distances are invalid and no explicit distances are provided, a ``ValueError`` is raised instead of silently producing zero concentrations, by :newcontrib:`Kalle Makela`.

mne/io/hitachi/tests/test_hitachi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def test_hitachi_basic(
226226
want = [np.nan] * (n_ch - 4)
227227
assert_allclose(distances, want, atol=0.0)
228228
raw_od_bad = optical_density(raw)
229-
with pytest.warns(RuntimeWarning, match="will be zero"):
229+
with pytest.raises(ValueError, match="all zero or NaN"):
230230
beer_lambert_law(raw_od_bad, ppf=6)
231231
# bad distances (too big)
232232
if versions[0] == "1.18" and len(fnames) == 1:

mne/preprocessing/nirs/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Copyright the MNE-Python contributors.
66

77
from .nirs import (
8+
_has_source_detector_distances,
89
short_channels,
910
source_detector_distances,
1011
_check_channels_ordered,

mne/preprocessing/nirs/_beer_lambert_law.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
from ..._fiff.constants import FIFF
1212
from ...io import BaseRaw
1313
from ...utils import _validate_type, pinv, warn
14-
from ..nirs import _channel_frequencies, _validate_nirs_info, source_detector_distances
14+
from ..nirs import (
15+
_channel_frequencies,
16+
_has_source_detector_distances,
17+
_validate_nirs_info,
18+
source_detector_distances,
19+
)
1520

1621

17-
def beer_lambert_law(raw, ppf=6.0):
22+
def beer_lambert_law(raw, ppf=6.0, *, sd_distances=None):
1823
r"""Convert NIRS optical density data to haemoglobin concentration.
1924
2025
Parameters
@@ -26,6 +31,12 @@ def beer_lambert_law(raw, ppf=6.0):
2631
2732
.. versionchanged:: 1.7
2833
Support for different factors for the two wavelengths.
34+
sd_distances : array-like | float | None
35+
Source-detector distances in meters. If ``None``, distances are read
36+
from ``raw.info['chs']``. If array-like, the values must have a distance
37+
for each channel, matching the order in ``info['chs']``.
38+
39+
.. versionadded:: 1.13
2940
3041
Returns
3142
-------
@@ -70,9 +81,14 @@ def beer_lambert_law(raw, ppf=6.0):
7081
)
7182

7283
abs_coef = _load_absorption(unique_freqs) # shape (n_wavelengths, 2)
73-
distances = source_detector_distances(raw.info, picks="all")
84+
distances = _get_sd_distances(raw, sd_distances)
7485
bad = ~np.isfinite(distances[picks])
7586
bad |= distances[picks] <= 0
87+
if bad.all():
88+
raise ValueError(
89+
"Source-detector distances are all zero or NaN. Consider setting a "
90+
"montage with raw.set_montage or providing sd_distances."
91+
)
7692
if bad.any():
7793
warn(
7894
"Source-detector distances are zero or NaN, some resulting "
@@ -129,6 +145,39 @@ def beer_lambert_law(raw, ppf=6.0):
129145
return raw
130146

131147

148+
def _get_sd_distances(raw, sd_distances):
149+
"""Get source-detector distances for each channel.
150+
151+
Returns
152+
-------
153+
dists : array of float
154+
Array containing distances in meters.
155+
Of shape equal to number of channels.
156+
"""
157+
if sd_distances is None:
158+
# picks="all" used here instead of picks s.t. distance indices match raw
159+
return source_detector_distances(raw.info, picks="all")
160+
elif _has_source_detector_distances(raw.info, picks="all"):
161+
warn("Source-detector distances in raw.info[] will be overridden")
162+
_validate_type(sd_distances, ("numeric", "array-like"), "sd_distances")
163+
sd_distances = np.array(sd_distances, float)
164+
n_channels = len(raw.info["chs"])
165+
if sd_distances.ndim == 0:
166+
return np.full(n_channels, sd_distances)
167+
if sd_distances.ndim != 1:
168+
raise ValueError(
169+
"sd_distances must be a float or a 1D array-like, got "
170+
f"shape {sd_distances.shape}"
171+
)
172+
if len(sd_distances) != n_channels:
173+
raise ValueError(
174+
"sd_distances must be a float or an array-like with length matching "
175+
f"the len(raw.info['chs']) ({n_channels}), "
176+
f"got length {len(sd_distances)}"
177+
)
178+
return sd_distances
179+
180+
132181
def _load_absorption(freqs):
133182
"""Load molar extinction coefficients."""
134183
# Data from https://omlc.org/spectra/hemoglobin/summary.html

mne/preprocessing/nirs/nirs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ def short_channels(info, threshold=0.01):
6262
return source_detector_distances(info) < threshold
6363

6464

65+
def _has_source_detector_distances(info, picks=None):
66+
"""Return True if source-detector distances can be computed."""
67+
distances = source_detector_distances(info, picks=picks)
68+
return len(distances) > 0 and np.all(distances > 0)
69+
70+
6571
def _channel_frequencies(info):
6672
"""Return the light frequency for each channel."""
6773
# Only valid for fNIRS data before conversion to haemoglobin

mne/preprocessing/nirs/tests/test_beer_lambert_law.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22
# License: BSD-3-Clause
33
# Copyright the MNE-Python contributors.
44

5+
56
import numpy as np
67
import pytest
8+
from numpy.testing import assert_allclose
79

10+
from mne import create_info
811
from mne.datasets import testing
912
from mne.datasets.testing import data_path
10-
from mne.io import BaseRaw, read_raw_fif, read_raw_nirx, read_raw_snirf
13+
from mne.io import BaseRaw, RawArray, read_raw_fif, read_raw_nirx, read_raw_snirf
1114
from mne.preprocessing.nirs import (
1215
_channel_frequencies,
1316
beer_lambert_law,
1417
optical_density,
18+
source_detector_distances,
1519
)
20+
from mne.preprocessing.nirs._beer_lambert_law import _get_sd_distances
1621
from mne.utils import _validate_type
1722

1823
testing_path = data_path(download=False)
@@ -112,3 +117,80 @@ def test_beer_lambert_v_matlab():
112117
+ matlab_data["type"][idx]
113118
)
114119
assert raw.info["ch_names"][idx] == matlab_name
120+
121+
122+
def test_beer_lambert_sd_distances():
123+
"""Test Beer-Lambert conversion with explicit source-detector distances."""
124+
data = np.array(
125+
[[0.1, 0.2, 0.3], [0.15, 0.25, 0.35], [0.4, 0.5, 0.6], [0.45, 0.55, 0.65]]
126+
)
127+
# Ch names chosen to test reordered indices
128+
ch_names = ["S1_D1 760", "S1_D1 850", "S10_D10 760", "S10_D10 850"]
129+
130+
# Case 1: valid locations, sd_distances=None
131+
raw = RawArray(data, create_info(ch_names, sfreq=1.0, ch_types="fnirs_od"))
132+
sd_distances = [0.03, 0.03, 0.03, 0.03]
133+
for idx, (freq, distance) in enumerate(zip([760, 850, 760, 850], sd_distances)):
134+
raw.info["chs"][idx]["loc"][3:6] = [0.0, 0.0, 0.0]
135+
raw.info["chs"][idx]["loc"][6:9] = [distance, 0.0, 0.0]
136+
raw.info["chs"][idx]["loc"][9] = freq
137+
expected = beer_lambert_law(raw)
138+
139+
# Case 2: valid locations, sd_distances=<arr>
140+
with pytest.warns(RuntimeWarning, match=r"(?i)will be overridden"):
141+
actual = beer_lambert_law(raw, sd_distances=sd_distances)
142+
assert actual.ch_names == expected.ch_names
143+
assert_allclose(actual.get_data(), expected.get_data(), rtol=1e-12, atol=0)
144+
145+
# Case 3: no locations, sd_distances=None
146+
for idx in range(len(raw.info["chs"])):
147+
raw.info["chs"][idx]["loc"][3:9] = np.nan
148+
assert np.isnan(source_detector_distances(raw.info)).all()
149+
with pytest.raises(
150+
ValueError, match=r"(?i)source-detector distances are all zero or NaN"
151+
):
152+
beer_lambert_law(raw)
153+
154+
# Case 4: no locations, sd_distances=<arr>
155+
actual = beer_lambert_law(raw, sd_distances=sd_distances)
156+
assert actual.ch_names == expected.ch_names
157+
assert_allclose(actual.get_data(), expected.get_data(), rtol=1e-12, atol=0)
158+
159+
# Case 5: no locations, sd_distances=<scalar>
160+
actual = beer_lambert_law(raw, sd_distances=sd_distances[0])
161+
assert actual.ch_names == expected.ch_names
162+
assert_allclose(actual.get_data(), expected.get_data(), rtol=1e-12, atol=0)
163+
164+
165+
def test_get_sd_distances():
166+
"""Test source-detector distance selection and validation."""
167+
raw = RawArray(
168+
np.zeros((4, 3)),
169+
create_info(
170+
["S1_D1 760", "S1_D1 850", "S2_D2 760", "S2_D2 850"], 1.0, "fnirs_od"
171+
),
172+
)
173+
expected = np.array([0.03, 0.03, 0.04, 0.04])
174+
for idx, (freq, distance) in enumerate(zip([760, 850, 760, 850], expected)):
175+
raw.info["chs"][idx]["loc"][3:6] = [0.0, 0.0, 0.0]
176+
raw.info["chs"][idx]["loc"][6:9] = [distance, 0.0, 0.0]
177+
raw.info["chs"][idx]["loc"][9] = freq
178+
179+
assert_allclose(_get_sd_distances(raw, None), expected, rtol=1e-12, atol=0)
180+
with pytest.warns(RuntimeWarning, match=r"(?i)will be overridden"):
181+
assert_allclose(_get_sd_distances(raw, expected), expected, rtol=1e-12, atol=0)
182+
with pytest.warns(RuntimeWarning, match=r"(?i)will be overridden"):
183+
assert_allclose(
184+
_get_sd_distances(raw, 0.05), np.full(4, 0.05), rtol=1e-12, atol=0
185+
)
186+
187+
for idx in range(len(raw.info["chs"])):
188+
raw.info["chs"][idx]["loc"][3:9] = np.nan
189+
assert_allclose(_get_sd_distances(raw, expected), expected, rtol=1e-12, atol=0)
190+
191+
with pytest.raises(ValueError, match=r"1D array-like"):
192+
_get_sd_distances(raw, np.ones((2, 2)))
193+
with pytest.raises(ValueError, match=r"length matching"):
194+
_get_sd_distances(raw, [0.03, 0.03])
195+
with pytest.raises(TypeError, match=r"sd_distances"):
196+
_get_sd_distances(raw, "foo")

mne/preprocessing/nirs/tests/test_nirs.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_check_channels_ordered,
1919
_fnirs_optode_names,
2020
_fnirs_spread_bads,
21+
_has_source_detector_distances,
2122
_optode_position,
2223
_validate_nirs_info,
2324
beer_lambert_law,
@@ -538,6 +539,22 @@ def test_optode_names():
538539
assert_array_equal(det_names, [f"D{n}" for n in ["1", "11", "17"]])
539540

540541

542+
def test_has_source_detector_distances():
543+
"""Ensure source-detector distance availability is detected."""
544+
ch_names = ["S1_D1 760", "S1_D1 850", "S2_D1 760", "S2_D1 850"]
545+
info = create_info(ch_names=ch_names, ch_types=np.repeat("fnirs_od", 4), sfreq=1.0)
546+
assert not _has_source_detector_distances(info)
547+
for idx in range(2):
548+
info["chs"][idx]["loc"][3:6] = [0.0, 0.0, 0.0]
549+
info["chs"][idx]["loc"][6:9] = [0.03, 0.0, 0.0]
550+
assert _has_source_detector_distances(info, picks=[0, 1])
551+
assert not _has_source_detector_distances(info) # some chs missing
552+
for idx in range(2, 4):
553+
info["chs"][idx]["loc"][3:6] = [0.01, 0.0, 0.0]
554+
info["chs"][idx]["loc"][6:9] = [0.04, 0.0, 0.0]
555+
assert _has_source_detector_distances(info)
556+
557+
541558
@testing.requires_testing_data
542559
def test_optode_loc():
543560
"""Ensure optode location extraction is correct."""

0 commit comments

Comments
 (0)