Skip to content

Commit 61ebcf9

Browse files
Colocated topomaps for OPM (#13144)
Co-authored-by: Eric Larson <[email protected]>
1 parent 0febd73 commit 61ebcf9

File tree

9 files changed

+181
-36
lines changed

9 files changed

+181
-36
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow for ``topomap`` plotting of optically pumped MEG (OPM) sensors with overlapping channel locations. When channel locations overlap, plot the most radially oriented channel. By :newcontrib:`Harrison Ritz`.

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
.. _Hamid Maymandi: https://github.com/HamidMandi
108108
.. _Hamza Abdelhedi: https://github.com/BabaSanfour
109109
.. _Hari Bharadwaj: https://github.com/haribharadwaj
110+
.. _Harrison Ritz: https://github.com/harrisonritz
110111
.. _Hasrat Ali Arzoo: https://github.com/hasrat17
111112
.. _Henrich Kolkhorst: https://github.com/hekolk
112113
.. _Hongjiang Ye: https://github.com/hongjiang-ye

mne/channels/layout.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ def _auto_topomap_coords(info, picks, ignore_overlap, to_sphere, sphere):
902902
# Use channel locations if available
903903
locs3d = np.array([ch["loc"][:3] for ch in chs])
904904

905-
# If electrode locations are not available, use digization points
905+
# If electrode locations are not available, use digitization points
906906
if not _check_ch_locs(info=info, picks=picks):
907907
logging.warning(
908908
"Did not find any electrode locations (in the info "
@@ -1089,7 +1089,7 @@ def _pair_grad_sensors(
10891089
return picks
10901090

10911091

1092-
def _merge_ch_data(data, ch_type, names, method="rms"):
1092+
def _merge_ch_data(data, ch_type, names, method="rms", *, modality="opm"):
10931093
"""Merge data from channel pairs.
10941094
10951095
Parameters
@@ -1102,6 +1102,8 @@ def _merge_ch_data(data, ch_type, names, method="rms"):
11021102
List of channel names.
11031103
method : str
11041104
Can be 'rms' or 'mean'.
1105+
modality : str
1106+
The modality of the data, either 'grad', 'fnirs', or 'opm'
11051107
11061108
Returns
11071109
-------
@@ -1112,9 +1114,13 @@ def _merge_ch_data(data, ch_type, names, method="rms"):
11121114
"""
11131115
if ch_type == "grad":
11141116
data = _merge_grad_data(data, method)
1115-
else:
1116-
assert ch_type in _FNIRS_CH_TYPES_SPLIT
1117+
elif modality == "fnirs" or ch_type in _FNIRS_CH_TYPES_SPLIT:
11171118
data, names = _merge_nirs_data(data, names)
1119+
elif modality == "opm" and ch_type == "mag":
1120+
data, names = _merge_opm_data(data, names)
1121+
else:
1122+
raise ValueError(f"Unknown modality {modality} for channel type {ch_type}")
1123+
11181124
return data, names
11191125

11201126

@@ -1180,6 +1186,37 @@ def _merge_nirs_data(data, merged_names):
11801186
return data, merged_names
11811187

11821188

1189+
def _merge_opm_data(data, merged_names):
1190+
"""Merge data from multiple opm channel by just using the radial component.
1191+
1192+
Channel names that end in "MERGE_REMOVE" (ie non-radial channels) will be
1193+
removed. Only the the radial channel is kept.
1194+
1195+
Parameters
1196+
----------
1197+
data : array, shape = (n_channels, ..., n_times)
1198+
Data for channels.
1199+
merged_names : list
1200+
List of strings containing the channel names. Channels that are to be
1201+
removed end in "MERGE_REMOVE".
1202+
1203+
Returns
1204+
-------
1205+
data : array
1206+
Data for channels with requested channels merged. Channels used in the
1207+
merge are removed from the array.
1208+
"""
1209+
to_remove = np.empty(0, dtype=np.int32)
1210+
for idx, ch in enumerate(merged_names):
1211+
if ch.endswith("MERGE-REMOVE"):
1212+
to_remove = np.append(to_remove, idx)
1213+
to_remove = np.unique(to_remove)
1214+
for rem in sorted(to_remove, reverse=True):
1215+
del merged_names[rem]
1216+
data = np.delete(data, to_remove, axis=0)
1217+
return data, merged_names
1218+
1219+
11831220
def generate_2d_layout(
11841221
xy,
11851222
w=0.07,

mne/datasets/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
# update the checksum in the MNE_DATASETS dict below, and change version
8888
# here: ↓↓↓↓↓↓↓↓
8989
RELEASES = dict(
90-
testing="0.156",
90+
testing="0.161",
9191
misc="0.27",
9292
phantom_kit="0.2",
9393
ucl_opm_auditory="0.2",
@@ -115,7 +115,7 @@
115115
# Testing and misc are at the top as they're updated most often
116116
MNE_DATASETS["testing"] = dict(
117117
archive_name=f"{TESTING_VERSIONED}.tar.gz",
118-
hash="md5:d94fe9f3abe949a507eaeb865fb84a3f",
118+
hash="md5:a32cfb9e098dec39a5f3ed6c0833580d",
119119
url=(
120120
"https://codeload.github.com/mne-tools/mne-testing-data/"
121121
f"tar.gz/{RELEASES['testing']}"

mne/preprocessing/tests/test_ica.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1027,7 +1027,6 @@ def f(x, y):
10271027

10281028
def test_get_explained_variance_ratio(tmp_path, short_raw_epochs):
10291029
"""Test ICA.get_explained_variance_ratio()."""
1030-
pytest.importorskip("sklearn")
10311030
raw, epochs, _ = short_raw_epochs
10321031
ica = ICA(max_iter=1)
10331032

mne/viz/tests/test_ica.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
pick_types,
1818
read_cov,
1919
read_events,
20+
read_evokeds,
2021
)
21-
from mne.io import read_raw_fif
22+
from mne.datasets import testing
23+
from mne.io import RawArray, read_raw_fif
2224
from mne.preprocessing import ICA, create_ecg_epochs, create_eog_epochs
2325
from mne.utils import _record_warnings, catch_logging
2426
from mne.viz.ica import _create_properties_layout, plot_ica_properties
@@ -32,6 +34,9 @@
3234
event_id, tmin, tmax = 1, -0.1, 0.2
3335
raw_ctf_fname = base_dir / "test_ctf_raw.fif"
3436

37+
testing_path = testing.data_path(download=False)
38+
opm_fname = testing_path / "OPM" / "opm-evoked-ave.fif"
39+
3540
pytest.importorskip("sklearn")
3641

3742

@@ -526,3 +531,15 @@ def test_plot_instance_components(browser_backend):
526531
fig._fake_click((x, y), xform="data")
527532
fig._click_ch_name(ch_index=0, button=1)
528533
fig._fake_keypress("escape")
534+
535+
536+
@pytest.mark.slowtest
537+
@pytest.mark.filterwarnings("ignore:.*did not converge.*:")
538+
@testing.requires_testing_data
539+
def test_plot_components_opm():
540+
"""Test for gh-12934."""
541+
evoked = read_evokeds(opm_fname, kind="average")[0]
542+
ica = ICA(max_iter=1, random_state=0, n_components=10)
543+
ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error")
544+
fig = ica.plot_components()
545+
assert len(fig.axes) == 10

mne/viz/tests/test_topomap.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
subjects_dir = data_dir / "subjects"
6363
ecg_fname = data_dir / "MEG" / "sample" / "sample_audvis_ecg-proj.fif"
6464
triux_fname = data_dir / "SSS" / "TRIUX" / "triux_bmlhus_erm_raw.fif"
65+
opm_fname = data_dir / "OPM" / "opm-evoked-ave.fif"
66+
6567

6668
base_dir = Path(__file__).parents[2] / "io" / "tests" / "data"
6769
evoked_fname = base_dir / "test-ave.fif"
@@ -776,6 +778,19 @@ def test_plot_topomap_bads_grad():
776778
plot_topomap(data, info, res=8)
777779

778780

781+
@testing.requires_testing_data
782+
def test_plot_topomap_opm():
783+
"""Test plotting topomap with OPM data."""
784+
# load data
785+
evoked = read_evokeds(opm_fname, kind="average")[0]
786+
787+
# plot evoked topomap
788+
fig_evoked = evoked.plot_topomap(
789+
times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False
790+
)
791+
assert len(fig_evoked.axes) == 5
792+
793+
779794
def test_plot_topomap_nirs_overlap(fnirs_epochs):
780795
"""Test plotting nirs topomap with overlapping channels (gh-7414)."""
781796
fig = fnirs_epochs["A"].average(picks="hbo").plot_topomap()

0 commit comments

Comments
 (0)