Skip to content

Commit 41544d9

Browse files
committed
Merge remote-tracking branch 'upstream/main' into numpydoc
2 parents c2271be + 66c8e0b commit 41544d9

7 files changed

Lines changed: 78 additions & 5 deletions

File tree

doc/changes/dev/13847.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a helper to split colocated OPM overlap sets into radial and tangential channel groups, and updated topomap regression coverage to use shared triaxial OPM fixtures, by `Pragnya Khandelwal`_.

doc/changes/dev/13856.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed an indexing bug in fNIRS support in :meth:`mne.io.BaseRaw.interpolate_bads` (and related methods) that could errantly use incorrect donor channels, by :newcontrib:`Kalle Makela`.

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
.. _Jukka Nenonen: https://www.linkedin.com/pub/jukka-nenonen/28/b5a/684
173173
.. _Jussi Nurminen: https://github.com/jjnurminen
174174
.. _Kaisu Lankinen: http://bishoplab.berkeley.edu/Kaisu.html
175+
.. _Kalle Makela: https://github.com/Kallemakela
175176
.. _Katarina Slama: https://github.com/katarinaslama
176177
.. _Katia Al-Amir: https://github.com/katia-sentry
177178
.. _Kay Robbins: https://github.com/VisLab

mne/channels/interpolation.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,15 +278,19 @@ def _interpolate_bads_nirs(inst, exclude=(), verbose=None):
278278
dist = pdist(locs3d)
279279
dist = squareform(dist)
280280

281-
for bad in picks_bad:
282-
dists_to_bad = dist[bad]
281+
for bad_raw_idx in picks_bad:
282+
# `bad_raw_idx` is the index of the bad channel in `inst`
283+
# `bad_dist_idx` is the index of the bad channel in `dist`
284+
bad_dist_idx = np.where(picks_nirs == bad_raw_idx)[0][0]
285+
dists_to_bad = dist[bad_dist_idx].copy()
283286
# Ignore distances to self
284287
dists_to_bad[dists_to_bad == 0] = np.inf
285288
# Ignore distances to other bad channels
286289
dists_to_bad[bads_mask] = np.inf
287290
# Find closest remaining channels for same frequency
288-
closest_idx = np.argmin(dists_to_bad) + (bad % 2)
289-
inst._data[bad] = inst._data[closest_idx]
291+
closest_dist_idx = np.argmin(dists_to_bad) + (bad_dist_idx % 2)
292+
closest_raw_idx = picks_nirs[closest_dist_idx]
293+
inst._data[bad_raw_idx] = inst._data[closest_raw_idx]
290294

291295
# TODO: this seems like a bug because it does not respect reset_bads
292296
inst.info["bads"] = [ch for ch in inst.info["bads"] if ch in exclude]

mne/channels/tests/test_interpolation.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from numpy.testing import assert_allclose, assert_array_equal
1111

1212
import mne.channels.channels
13-
from mne import Epochs, pick_channels, pick_types, read_events
13+
from mne import Epochs, create_info, pick_channels, pick_types, read_events
1414
from mne._fiff.constants import FIFF
1515
from mne._fiff.proj import _has_eeg_average_ref_proj
1616
from mne.channels import make_dig_montage, make_standard_montage
@@ -333,6 +333,44 @@ def test_interpolation_nirs():
333333
assert raw_haemo.info["bads"] == []
334334

335335

336+
def test_interpolation_nirs_reordered_picks():
337+
"""Test NIRS interpolation uses the closest donor in raw channel space."""
338+
ch_names = [
339+
"S1_D1 760",
340+
"S1_D1 850",
341+
"S2_D2 760",
342+
"S2_D2 850",
343+
"S3_D3 760",
344+
"S3_D3 850",
345+
"S10_D10 760",
346+
"S10_D10 850",
347+
]
348+
info = create_info(ch_names, sfreq=1.0, ch_types=["fnirs_cw_amplitude"] * 8)
349+
pair_positions = {
350+
"S1_D1": (0.009, 0.0, 0.0),
351+
"S2_D2": (0.010, 0.0, 0.0),
352+
"S3_D3": (0.030, 0.0, 0.0),
353+
"S10_D10": (0.040, 0.0, 0.0),
354+
}
355+
for idx, ch in enumerate(info["chs"]):
356+
pair = ch["ch_name"].rsplit(" ", 1)[0]
357+
ch["loc"][:3] = pair_positions[pair]
358+
ch["loc"][9] = 760.0 if idx % 2 == 0 else 850.0
359+
data = np.arange(len(ch_names), dtype=float).reshape(-1, 1)
360+
data = np.repeat(data, 5, axis=1)
361+
raw = RawArray(data, info, verbose=False)
362+
raw.info["bads"] = ["S2_D2 760", "S2_D2 850"]
363+
364+
raw.interpolate_bads(
365+
method=dict(fnirs="nearest"), origin=(0.0, 0.0, 0.0), verbose=False
366+
)
367+
368+
# Bad S2_D2 should copy from the nearest good pair, S1_D1.
369+
picks_bad = pick_channels(raw.ch_names, ["S2_D2 760", "S2_D2 850"], exclude=[])
370+
picks_want = pick_channels(raw.ch_names, ["S1_D1 760", "S1_D1 850"], exclude=[])
371+
assert_allclose(raw.get_data(picks=picks_bad), raw.get_data(picks=picks_want))
372+
373+
336374
@testing.requires_testing_data
337375
def test_interpolation_ecog():
338376
"""Test interpolation for ECoG."""

mne/viz/tests/test_topomap.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,17 @@ def test_prepare_topomap_plot_opm_non_quspin_coils():
840840
assert sum(name.endswith("MERGE-REMOVE") for name in merged_names) == 4
841841

842842

843+
def test_split_opm_overlaps(triaxial_evoked):
844+
"""Test splitting colocated OPM overlap sets into orientation groups."""
845+
_picks, _pos, merge_channels, _merged_names, *_ = topomap._prepare_topomap_plot(
846+
triaxial_evoked, "mag"
847+
)
848+
849+
radial, tangential = topomap._split_opm_overlaps(merge_channels)
850+
assert radial == ["OPM001", "OPM004"]
851+
assert tangential == ["OPM002", "OPM003", "OPM005", "OPM006"]
852+
853+
843854
def test_plot_topomap_nirs_overlap(fnirs_epochs):
844855
"""Test plotting nirs topomap with overlapping channels (gh-7414)."""
845856
fig = fnirs_epochs["A"].average(picks="hbo").plot_topomap()

mne/viz/topomap.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,23 @@ def _find_radial_channel(info, overlapping_set):
312312
return radial_sensor
313313

314314

315+
def _split_opm_overlaps(overlapping_channels):
316+
"""Split OPM overlap sets into radial and tangential channel groups.
317+
318+
This keeps the first channel from each overlap set, which is the radial
319+
channel as determined by :func:`_find_overlaps`, separate from the
320+
remaining tangential channels. The result can be used by later plotting
321+
code to render separate topomaps per orientation family.
322+
"""
323+
radial = [overlap_set[0] for overlap_set in overlapping_channels]
324+
tangential = list(
325+
itertools.chain.from_iterable(
326+
overlap_set[1:] for overlap_set in overlapping_channels
327+
)
328+
)
329+
return radial, tangential
330+
331+
315332
def _plot_update_evoked_topomap(params, bools):
316333
"""Update topomaps."""
317334
from ..channels.layout import _merge_ch_data

0 commit comments

Comments
 (0)