Repository: mne-tools/mne-python
Pull request #13144: Colocated topomaps for OPM
Author: login: harrisonritz
Created: 2025-03-05T16:51:06Z | Merged: 2025-04-29T20:41:23Z

<!--

Thanks for contributing a pull request! Please make sure you have read the
[contribution guidelines](https://mne.tools/dev/development/contributing.html)
before submitting.

Please be aware that we are a loose team of volunteers so patience is
necessary. Assistance handling other issues is very welcome. We value
all user contributions, no matter how minor they are. If we are slow to
review, either the pull request needs some benchmarking, tinkering,
convincing, etc. or more likely the reviewers are simply busy. In either
case, we ask for your understanding during the review process.

Again, thanks for contributing!

-->

#### Reference issue (if any)
Adds features from https://github.com/mne-tools/mne-python/issues/11579

<!-- Example:

Fixes #1234.

-->


#### What does this implement/fix?
This PR extends topomaps to accommodate OPM data with colocated sensors.

It modifies the code used for colocated fNIRS, which now handles both fnirs and OPM. 
In contrast to fNIRS which averages the colocated sensors, for OPM sensors we just take the most radial sensor.


#### Additional information

<!-- Any additional information you think is important. -->

- I've written an opm test, but would like guidance about how to access `mne.datasets.ucl_opm_auditory`. Test won't work as is (eg can't update UCL dataset, and wanted to confirm proper method), but the test is structured on the fnirs overlap test so probably won't need much more
- I rely on some hard-coded look-ups:
   - lookup OPM sensor coil types (quspin currently included)
   - I need to use different `loc` triplets for dual-axis vs triaxial sensors, based on comparing `ucl_opm_auditory` vs Princeton's triaxial opms. At UCL, they use `*-RAD` and `*-TAN` to indicate the two axes, and at Princeton we use `* X` or `* [X]`.
- for the UCL dataset, most of the `*-RAD` channels are the most radial, but in some cases both `RAD` and `TAN` have low radial scores, and TAN is the most radial sensors


Attaching my internal testing code to validate against picking `RAD` channels. 
Happy to share Princeton dataset if that's helpful.


```python
# %%
import numpy as np
import mne


opm_file = (
mne.datasets.ucl_opm_auditory.data_path() / "sub-002" / "ses-001" / "meg" / "sub-002_ses-001_task-aef_run-001_meg.bin"
)
raw = mne.io.read_raw_fil(opm_file, verbose="error")
raw.crop(100, 300).load_data()  # crop for speed
rad_string = "^.*-RAD$"
picks = mne.pick_types(raw.info, meg=True)


# %% preprocess --------

raw.notch_filter(50, picks=picks)
raw.filter(1, 40, picks=picks)

projs = mne.preprocessing.compute_proj_hfc(raw.info, order=2)
raw.add_proj(projs).apply_proj(verbose="error")


# %%  epoch data --------

events = mne.find_events(raw, min_duration=0.1)
epochs = mne.Epochs(
    raw, events, tmin=-0.1, tmax=0.4, baseline=(-0.1, 0.0), verbose="error",event_repeated='merge',
)
evoked = epochs.average()
t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))]


# %% plot evoked topomap --------

fig = evoked.plot_topomap(times=t_peak, ch_type="mag", time_unit="s")
print('len axes: ', len(fig.axes))

rad_picks = mne.pick_channels_regexp(evoked.info.ch_names, rad_string)
fig = evoked.copy().pick(rad_picks).plot_topomap(times=t_peak, ch_type="mag", time_unit="s")
print('len axes: ', len(fig.axes))


# %% test ICA --------

ica = mne.preprocessing.ICA(n_components=10)
ica.fit(epochs, picks=picks, decim=10)

fig = ica.plot_components()
print('len axes: ', len(fig.axes))


# %% test spectrum --------

spec = raw.compute_psd()
spec.plot_topomap()

rad_picks = mne.pick_channels_regexp(raw.info.ch_names, rad_string)
spec2 = raw.copy().pick(rad_picks).compute_psd()
spec2.plot_topomap()
```

Closes #11579


Diff:
diff --git a/doc/changes/devel/13144.newfeature.rst b/doc/changes/devel/13144.newfeature.rst
new file mode 100644
index 00000000000..3e05c5c6c06
--- /dev/null
+++ b/doc/changes/devel/13144.newfeature.rst
@@ -0,0 +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`.
\ No newline at end of file
diff --git a/doc/changes/names.inc b/doc/changes/names.inc
index 22b992a10cc..2e4027b82b7 100644
--- a/doc/changes/names.inc
+++ b/doc/changes/names.inc
@@ -107,6 +107,7 @@
 .. _Hamid Maymandi: https://github.com/HamidMandi
 .. _Hamza Abdelhedi: https://github.com/BabaSanfour
 .. _Hari Bharadwaj: https://github.com/haribharadwaj
+.. _Harrison Ritz: https://github.com/harrisonritz
 .. _Hasrat Ali Arzoo: https://github.com/hasrat17
 .. _Henrich Kolkhorst: https://github.com/hekolk
 .. _Hongjiang Ye: https://github.com/hongjiang-ye
diff --git a/mne/channels/layout.py b/mne/channels/layout.py
index 31d0650037e..fb12b509997 100644
--- a/mne/channels/layout.py
+++ b/mne/channels/layout.py
@@ -902,7 +902,7 @@ def _auto_topomap_coords(info, picks, ignore_overlap, to_sphere, sphere):
     # Use channel locations if available
     locs3d = np.array([ch["loc"][:3] for ch in chs])
 
-    # If electrode locations are not available, use digization points
+    # If electrode locations are not available, use digitization points
     if not _check_ch_locs(info=info, picks=picks):
         logging.warning(
             "Did not find any electrode locations (in the info "
@@ -1089,7 +1089,7 @@ def _pair_grad_sensors(
         return picks
 
 
-def _merge_ch_data(data, ch_type, names, method="rms"):
+def _merge_ch_data(data, ch_type, names, method="rms", *, modality="opm"):
     """Merge data from channel pairs.
 
     Parameters
@@ -1102,6 +1102,8 @@ def _merge_ch_data(data, ch_type, names, method="rms"):
         List of channel names.
     method : str
         Can be 'rms' or 'mean'.
+    modality : str
+        The modality of the data, either 'grad', 'fnirs', or 'opm'
 
     Returns
     -------
@@ -1112,9 +1114,13 @@ def _merge_ch_data(data, ch_type, names, method="rms"):
     """
     if ch_type == "grad":
         data = _merge_grad_data(data, method)
-    else:
-        assert ch_type in _FNIRS_CH_TYPES_SPLIT
+    elif modality == "fnirs" or ch_type in _FNIRS_CH_TYPES_SPLIT:
         data, names = _merge_nirs_data(data, names)
+    elif modality == "opm" and ch_type == "mag":
+        data, names = _merge_opm_data(data, names)
+    else:
+        raise ValueError(f"Unknown modality {modality} for channel type {ch_type}")
+
     return data, names
 
 
@@ -1180,6 +1186,37 @@ def _merge_nirs_data(data, merged_names):
     return data, merged_names
 
 
+def _merge_opm_data(data, merged_names):
+    """Merge data from multiple opm channel by just using the radial component.
+
+    Channel names that end in "MERGE_REMOVE" (ie non-radial channels) will be
+    removed. Only the the radial channel is kept.
+
+    Parameters
+    ----------
+    data : array, shape = (n_channels, ..., n_times)
+        Data for channels.
+    merged_names : list
+        List of strings containing the channel names. Channels that are to be
+        removed end in "MERGE_REMOVE".
+
+    Returns
+    -------
+    data : array
+        Data for channels with requested channels merged. Channels used in the
+        merge are removed from the array.
+    """
+    to_remove = np.empty(0, dtype=np.int32)
+    for idx, ch in enumerate(merged_names):
+        if ch.endswith("MERGE-REMOVE"):
+            to_remove = np.append(to_remove, idx)
+    to_remove = np.unique(to_remove)
+    for rem in sorted(to_remove, reverse=True):
+        del merged_names[rem]
+    data = np.delete(data, to_remove, axis=0)
+    return data, merged_names
+
+
 def generate_2d_layout(
     xy,
     w=0.07,
diff --git a/mne/datasets/config.py b/mne/datasets/config.py
index 75eff184cd1..98467aa7e36 100644
--- a/mne/datasets/config.py
+++ b/mne/datasets/config.py
@@ -87,7 +87,7 @@
 # update the checksum in the MNE_DATASETS dict below, and change version
 # here: ↓↓↓↓↓↓↓↓
 RELEASES = dict(
-    testing="0.156",
+    testing="0.161",
     misc="0.27",
     phantom_kit="0.2",
     ucl_opm_auditory="0.2",
@@ -115,7 +115,7 @@
 # Testing and misc are at the top as they're updated most often
 MNE_DATASETS["testing"] = dict(
     archive_name=f"{TESTING_VERSIONED}.tar.gz",
-    hash="md5:d94fe9f3abe949a507eaeb865fb84a3f",
+    hash="md5:a32cfb9e098dec39a5f3ed6c0833580d",
     url=(
         "https://codeload.github.com/mne-tools/mne-testing-data/"
         f"tar.gz/{RELEASES['testing']}"
diff --git a/mne/preprocessing/tests/test_ica.py b/mne/preprocessing/tests/test_ica.py
index d925665e48f..91dffe93078 100644
--- a/mne/preprocessing/tests/test_ica.py
+++ b/mne/preprocessing/tests/test_ica.py
@@ -1027,7 +1027,6 @@ def f(x, y):
 
 def test_get_explained_variance_ratio(tmp_path, short_raw_epochs):
     """Test ICA.get_explained_variance_ratio()."""
-    pytest.importorskip("sklearn")
     raw, epochs, _ = short_raw_epochs
     ica = ICA(max_iter=1)
 
diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py
index a3ea7b89e58..973ccc83dbc 100644
--- a/mne/viz/tests/test_ica.py
+++ b/mne/viz/tests/test_ica.py
@@ -17,8 +17,10 @@
     pick_types,
     read_cov,
     read_events,
+    read_evokeds,
 )
-from mne.io import read_raw_fif
+from mne.datasets import testing
+from mne.io import RawArray, read_raw_fif
 from mne.preprocessing import ICA, create_ecg_epochs, create_eog_epochs
 from mne.utils import _record_warnings, catch_logging
 from mne.viz.ica import _create_properties_layout, plot_ica_properties
@@ -32,6 +34,9 @@
 event_id, tmin, tmax = 1, -0.1, 0.2
 raw_ctf_fname = base_dir / "test_ctf_raw.fif"
 
+testing_path = testing.data_path(download=False)
+opm_fname = testing_path / "OPM" / "opm-evoked-ave.fif"
+
 pytest.importorskip("sklearn")
 
 
@@ -526,3 +531,15 @@ def test_plot_instance_components(browser_backend):
     fig._fake_click((x, y), xform="data")
     fig._click_ch_name(ch_index=0, button=1)
     fig._fake_keypress("escape")
+
+
+@pytest.mark.slowtest
+@pytest.mark.filterwarnings("ignore:.*did not converge.*:")
+@testing.requires_testing_data
+def test_plot_components_opm():
+    """Test for gh-12934."""
+    evoked = read_evokeds(opm_fname, kind="average")[0]
+    ica = ICA(max_iter=1, random_state=0, n_components=10)
+    ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error")
+    fig = ica.plot_components()
+    assert len(fig.axes) == 10
diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py
index b87d0d39f89..64a13b46006 100644
--- a/mne/viz/tests/test_topomap.py
+++ b/mne/viz/tests/test_topomap.py
@@ -62,6 +62,8 @@
 subjects_dir = data_dir / "subjects"
 ecg_fname = data_dir / "MEG" / "sample" / "sample_audvis_ecg-proj.fif"
 triux_fname = data_dir / "SSS" / "TRIUX" / "triux_bmlhus_erm_raw.fif"
+opm_fname = data_dir / "OPM" / "opm-evoked-ave.fif"
+
 
 base_dir = Path(__file__).parents[2] / "io" / "tests" / "data"
 evoked_fname = base_dir / "test-ave.fif"
@@ -776,6 +778,19 @@ def test_plot_topomap_bads_grad():
     plot_topomap(data, info, res=8)
 
 
+@testing.requires_testing_data
+def test_plot_topomap_opm():
+    """Test plotting topomap with OPM data."""
+    # load data
+    evoked = read_evokeds(opm_fname, kind="average")[0]
+
+    # plot evoked topomap
+    fig_evoked = evoked.plot_topomap(
+        times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False
+    )
+    assert len(fig_evoked.axes) == 5
+
+
 def test_plot_topomap_nirs_overlap(fnirs_epochs):
     """Test plotting nirs topomap with overlapping channels (gh-7414)."""
     fig = fnirs_epochs["A"].average(picks="hbo").plot_topomap()
diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py
index bb180a3f299..375b7256957 100644
--- a/mne/viz/topomap.py
+++ b/mne/viz/topomap.py
@@ -20,6 +20,7 @@
 from scipy.spatial import Delaunay, Voronoi
 from scipy.spatial.distance import pdist, squareform
 
+from .._fiff.constants import FIFF
 from .._fiff.meas_info import Info, _simplify_info
 from .._fiff.pick import (
     _MEG_CH_TYPES_SPLIT,
@@ -76,6 +77,7 @@
 )
 
 _fnirs_types = ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od")
+_opm_coils = (FIFF.FIFFV_COIL_QUSPIN_ZFOPM_MAG, FIFF.FIFFV_COIL_QUSPIN_ZFOPM_MAG2)
 
 
 # 3.8+ uses a single Collection artist rather than .collections
@@ -123,6 +125,13 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None):
     info["bads"] = _clean_names(info["bads"])
     info._check_consistency()
 
+    if any(ch["coil_type"] in _opm_coils for ch in info["chs"]):
+        modality = "opm"
+    elif ch_type in _fnirs_types:
+        modality = "fnirs"
+    else:
+        modality = "other"
+
     # special case for merging grad channels
     layout = find_layout(info)
     if (
@@ -136,10 +145,9 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None):
         picks, _ = _pair_grad_sensors(info, layout)
         pos = _find_topomap_coords(info, picks[::2], sphere=sphere)
         merge_channels = True
-    elif ch_type in _fnirs_types:
-        # fNIRS data commonly has overlapping channels, so deal with separately
-        picks, pos, merge_channels, overlapping_channels = _average_fnirs_overlaps(
-            info, ch_type, sphere
+    elif modality != "other":
+        picks, pos, merge_channels, overlapping_channels = _find_overlaps(
+            info, ch_type, sphere, modality=modality
         )
     else:
         merge_channels = False
@@ -162,7 +170,7 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None):
         pos = _find_topomap_coords(info, picks, sphere=sphere)
 
     ch_names = [info["ch_names"][k] for k in picks]
-    if ch_type in _fnirs_types:
+    if modality == "fnirs":
         # Remove the chroma label type for cleaner labeling.
         ch_names = [k[:-4] for k in ch_names]
 
@@ -171,24 +179,38 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None):
             # change names so that vectorview combined grads appear as MEG014x
             # instead of MEG0142 or MEG0143 which are the 2 planar grads.
             ch_names = [ch_names[k][:-1] + "x" for k in range(0, len(ch_names), 2)]
-        else:
-            assert ch_type in _fnirs_types
-            # Modify the nirs channel names to indicate they are to be merged
+        elif modality == "fnirs":
+            # Modify the channel names to indicate they are to be merged
             # New names will have the form  S1_D1xS2_D2
             # More than two channels can overlap and be merged
             for set_ in overlapping_channels:
                 idx = ch_names.index(set_[0][:-4])
                 new_name = "x".join(s[:-4] for s in set_)
                 ch_names[idx] = new_name
+        elif modality == "opm":
+            # indicate that non-radial changes are to be removed
+            for set_ in overlapping_channels:
+                for set_ch in set_[1:]:
+                    idx = ch_names.index(set_ch)
+                    new_name = set_ch + "_MERGE-REMOVE"
+                    ch_names[idx] = new_name
 
     pos = np.array(pos)[:, :2]  # 2D plot, otherwise interpolation bugs
     return picks, pos, merge_channels, ch_names, ch_type, sphere, clip_origin
 
 
-def _average_fnirs_overlaps(info, ch_type, sphere):
+def _find_overlaps(info, ch_type, sphere, modality="fnirs"):
+    """Find overlapping channels."""
     from ..channels.layout import _find_topomap_coords
 
-    picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads")
+    if modality == "fnirs":
+        picks = pick_types(
+            info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
+        )
+    elif modality == "opm":
+        picks = pick_types(info, meg=True, ref_meg=False, exclude="bads")
+    else:
+        raise ValueError(f"Invalid modality for colocated sensors: {modality}")
     chs = [info["chs"][i] for i in picks]
     locs3d = np.array([ch["loc"][:3] for ch in chs])
     dist = pdist(locs3d)
@@ -212,33 +234,79 @@ def _average_fnirs_overlaps(info, ch_type, sphere):
                 overlapping_set = [
                     chs[i]["ch_name"] for i in np.where(overlapping_mask[chan_idx])[0]
                 ]
-                overlapping_set = np.insert(
-                    overlapping_set, 0, (chs[chan_idx]["ch_name"])
-                )
+                if modality == "fnirs":
+                    overlapping_set = np.insert(
+                        overlapping_set, 0, (chs[chan_idx]["ch_name"])
+                    )
+                elif modality == "opm":
+                    overlapping_set = np.insert(
+                        overlapping_set, 0, (chs[chan_idx]["ch_name"])
+                    )
+                    rad_channel = _find_radial_channel(info, overlapping_set)
+                    # Make sure the radial channel is first in the overlapping set
+                    overlapping_set = np.array(
+                        [ch for ch in overlapping_set if ch != rad_channel]
+                    )
+                    overlapping_set = np.insert(overlapping_set, 0, rad_channel)
                 overlapping_channels.append(overlapping_set)
                 channels_to_exclude.append(overlapping_set[1:])
 
         exclude = list(itertools.chain.from_iterable(channels_to_exclude))
         [exclude.append(bad) for bad in info["bads"]]
-        picks = pick_types(
-            info, meg=False, ref_meg=False, fnirs=ch_type, exclude=exclude
-        )
-        pos = _find_topomap_coords(info, picks, sphere=sphere)
-        picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type)
+        if modality == "fnirs":
+            picks = pick_types(
+                info, meg=False, ref_meg=False, fnirs=ch_type, exclude=exclude
+            )
+            pos = _find_topomap_coords(info, picks, sphere=sphere)
+            picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type)
+        elif modality == "opm":
+            picks = pick_types(info, meg=True, ref_meg=False, exclude=exclude)
+            pos = _find_topomap_coords(info, picks, sphere=sphere)
+            picks = pick_types(info, meg=True, ref_meg=False)
+
         # Overload the merge_channels variable as this is returned to calling
         # function and indicates that merging of data is required
         merge_channels = overlapping_channels
 
     else:
-        picks = pick_types(
-            info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
-        )
+        if modality == "fnirs":
+            picks = pick_types(
+                info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
+            )
+        elif modality == "opm":
+            picks = pick_types(info, meg=True, ref_meg=False, exclude="bads")
+
         merge_channels = False
         pos = _find_topomap_coords(info, picks, sphere=sphere)
 
     return picks, pos, merge_channels, overlapping_channels
 
 
+def _find_radial_channel(info, overlapping_set):
+    """Find the most radial channel in the overlapping set."""
+    if len(overlapping_set) == 1:
+        return overlapping_set[0]
+    elif len(overlapping_set) < 1:
+        raise ValueError("No overlapping channels found.")
+
+    radial_score = np.zeros(len(overlapping_set))
+    for s, sens in enumerate(overlapping_set):
+        ch_idx = pick_channels(info["ch_names"], [sens])[0]
+        radial_direction = info["chs"][ch_idx]["loc"][0:3].copy()
+        radial_direction /= np.linalg.norm(radial_direction)
+
+        orientation_vector = info["chs"][ch_idx]["loc"][9:12]
+        if info["dev_head_t"] is not None:
+            orientation_vector = apply_trans(
+                info["dev_head_t"], orientation_vector, move=False
+            )
+        radial_score[s] = np.abs(np.dot(radial_direction, orientation_vector))
+
+    radial_sensor = overlapping_set[np.argmax(radial_score)]
+
+    return radial_sensor
+
+
 def _plot_update_evoked_topomap(params, bools):
     """Update topomaps."""
     from ..channels.layout import _merge_ch_data
@@ -1636,9 +1704,8 @@ def plot_ica_components(
             sphere,
             clip_origin,
         ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere)
-
         cmap = _setup_cmap(cmap, n_axes=len(picks))
-        names = _prepare_sensor_names(names, show_names)
+        disp_names = _prepare_sensor_names(names, show_names)
         outlines = _make_head_outlines(sphere, pos, outlines, clip_origin)
 
         data = np.dot(
@@ -1675,7 +1742,7 @@ def plot_ica_components(
                 pos,
                 ch_type=ch_type,
                 sensors=sensors,
-                names=names,
+                names=disp_names,
                 contours=contours,
                 outlines=outlines,
                 sphere=sphere,
@@ -2296,8 +2363,17 @@ def plot_evoked_topomap(
     # apply scalings and merge channels
     data *= scaling
     if merge_channels:
-        data, ch_names = _merge_ch_data(data, ch_type, ch_names)
-        if ch_type in _fnirs_types:
+        # check modality
+        if any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]):
+            modality = "opm"
+        elif ch_type in _fnirs_types:
+            modality = "fnirs"
+        else:
+            modality = "other"
+        # merge data
+        data, ch_names = _merge_ch_data(data, ch_type, ch_names, modality=modality)
+        # if ch_type in _fnirs_types:
+        if modality != "other":
             merge_channels = False
     # apply mask if requested
     if mask is not None:
diff --git a/tutorials/preprocessing/80_opm_processing.py b/tutorials/preprocessing/80_opm_processing.py
index 25c30778a42..ba44a797a51 100644
--- a/tutorials/preprocessing/80_opm_processing.py
+++ b/tutorials/preprocessing/80_opm_processing.py
@@ -243,8 +243,7 @@
 )
 evoked = epochs.average()
 t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))]
-fig = evoked.plot()
-fig.axes[0].axvline(t_peak, color="red", ls="--", lw=1)
+fig = evoked.plot_joint(picks="mag")
 
 # %%
 # Visualizing coregistration


Reviews:
user: login: larsoner
body: Just some quick comments based on a preliminary look. Not 100% sure I followed the rename-then-drop mechanics, but had an idea that might make sense to simplify it and potentially make it more robust given that OPM names could contain `.` and ` x ` already. But if I'm off base on this then feel free to ignore (maybe after adding a test that uses `rename_channels` to add what I thought could be problematic names and shows they are not problematic 😄 ). I won't be able to do a deeper review for another week or so but wanted to give some feedback/food for thought in the meantime...
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-22T15:50:34Z
user: login: larsoner
body: Just two minor comments which I'll commit then mark for merge-when-green. Thanks in advance @harrisonritz !
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T18:19:00Z

Review comments:
diff_hunk: @@ -1089,7 +1089,7 @@ def _pair_grad_sensors(
         return picks
 
 
-def _merge_ch_data(data, ch_type, names, method="rms"):
+def _merge_ch_data(data, ch_type, names, method="rms", modality="opm"):
path: mne/channels/layout.py
user: login: larsoner
body: We've been trying to add these places in our code nowadays
```suggestion
def _merge_ch_data(data, ch_type, names, method="rms", *, modality="opm"):
```
created_at: 2025-03-22T15:35:49Z
author_association: MEMBER
diff_hunk: @@ -1180,6 +1186,42 @@ def _merge_nirs_data(data, merged_names):
     return data, merged_names
 
 
+def _merge_opm_data(data, merged_names):
+    """Merge data from multiple opm channel using the mean.
+
+    Channel names that have an x in them will be merged. The first channel in
+    the name is replaced with the mean of all listed channels. The other
+    channels are removed.
+
+    Parameters
+    ----------
+    data : array, shape = (n_channels, ..., n_times)
+        Data for channels.
+    merged_names : list
+        List of strings containing the channel names. Channels that are to be
+        merged contain an x between them.
+
+    Returns
+    -------
+    data : array
+        Data for channels with requested channels merged. Channels used in the
+        merge are removed from the array.
+    """
+    to_remove = np.empty(0, dtype=np.int32)
+    for idx, ch in enumerate(merged_names):
+        if "." in ch:
+            indices = np.empty(0, dtype=np.int32)
+            channels = ch.split(".")
+            for sub_ch in channels[1:]:
+                indices = np.append(indices, merged_names.index(sub_ch))
+            to_remove = np.append(to_remove, indices)
+    to_remove = np.unique(to_remove)
+    for rem in sorted(to_remove, reverse=True):
+        del merged_names[rem]
+        data = np.delete(data, rem, 0)
path: mne/channels/layout.py
user: login: larsoner
body: Tiny bit more efficient because it will only allocate one new array instead of a bunch of them
```suggestion
    for rem in sorted(to_remove, reverse=True):
        del merged_names[rem]
    data = np.delete(data, to_remove, axis=0)
```
created_at: 2025-03-22T15:37:28Z
author_association: MEMBER
diff_hunk: @@ -1180,6 +1186,42 @@ def _merge_nirs_data(data, merged_names):
     return data, merged_names
 
 
+def _merge_opm_data(data, merged_names):
+    """Merge data from multiple opm channel using the mean.
+
+    Channel names that have an x in them will be merged. The first channel in
+    the name is replaced with the mean of all listed channels. The other
+    channels are removed.
path: mne/channels/layout.py
user: login: larsoner
body: Is this description correct? I don't think it involves `x` or taking a mean?
created_at: 2025-03-22T15:39:18Z
author_association: MEMBER
diff_hunk: @@ -76,6 +76,7 @@
 )
 
 _fnirs_types = ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od")
+_opm_coils = (8002,)
path: mne/viz/topomap.py
user: login: larsoner
body: Don't use the `int` but rather the corresponding `FIFF.FIFFV_` instead. And there should be more entries, no?
created_at: 2025-03-22T15:40:23Z
author_association: MEMBER
diff_hunk: @@ -171,24 +178,38 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None):
             # change names so that vectorview combined grads appear as MEG014x
             # instead of MEG0142 or MEG0143 which are the 2 planar grads.
             ch_names = [ch_names[k][:-1] + "x" for k in range(0, len(ch_names), 2)]
-        else:
-            assert ch_type in _fnirs_types
-            # Modify the nirs channel names to indicate they are to be merged
+        elif modality == "fnirs":
+            # Modify the channel names to indicate they are to be merged
             # New names will have the form  S1_D1xS2_D2
             # More than two channels can overlap and be merged
             for set_ in overlapping_channels:
                 idx = ch_names.index(set_[0][:-4])
                 new_name = "x".join(s[:-4] for s in set_)
                 ch_names[idx] = new_name
+        elif modality == "opm":
+            # Modify the channel names to indicate they are to be merged
+            # New names will have the form  S1xS2
+            for set_ in overlapping_channels:
+                idx = ch_names.index(set_[0])
+                new_name = ".".join(s for s in set_)
path: mne/viz/topomap.py
user: login: larsoner
body: If we're going to join based on a `.` and then check it later, better raise an error here if there is already a `.` in the name. But maybe I'm missing a check somewhere else. And maybe a moot point if you take my idea above
created_at: 2025-03-22T15:46:32Z
author_association: MEMBER
diff_hunk: @@ -171,24 +178,38 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None):
             # change names so that vectorview combined grads appear as MEG014x
             # instead of MEG0142 or MEG0143 which are the 2 planar grads.
             ch_names = [ch_names[k][:-1] + "x" for k in range(0, len(ch_names), 2)]
-        else:
-            assert ch_type in _fnirs_types
-            # Modify the nirs channel names to indicate they are to be merged
+        elif modality == "fnirs":
+            # Modify the channel names to indicate they are to be merged
             # New names will have the form  S1_D1xS2_D2
             # More than two channels can overlap and be merged
             for set_ in overlapping_channels:
                 idx = ch_names.index(set_[0][:-4])
                 new_name = "x".join(s[:-4] for s in set_)
                 ch_names[idx] = new_name
+        elif modality == "opm":
+            # Modify the channel names to indicate they are to be merged
+            # New names will have the form  S1xS2
path: mne/viz/topomap.py
user: login: larsoner
body: Looks like it would be `S1.S2`, no? And maybe the overlapping ones should just end with `OPM-MERGE-REMOVE` or something, and the ones that we keep should just have the original channel names. Then to figure out which ones to remove you do a `.endswith("OPM-MERGE-REMOVE")` (which seems way safer than checking for a ` x ` or `.` in the name, which could happen in real data) and keep the original names.
created_at: 2025-03-22T15:46:33Z
author_association: MEMBER
diff_hunk: @@ -212,33 +234,77 @@ def _average_fnirs_overlaps(info, ch_type, sphere):
                 overlapping_set = [
                     chs[i]["ch_name"] for i in np.where(overlapping_mask[chan_idx])[0]
                 ]
-                overlapping_set = np.insert(
-                    overlapping_set, 0, (chs[chan_idx]["ch_name"])
-                )
+                if modality == "fnirs":
+                    overlapping_set = np.insert(
+                        overlapping_set, 0, (chs[chan_idx]["ch_name"])
+                    )
+                elif modality == "opm":
+                    overlapping_set = np.insert(
+                        overlapping_set, 0, (chs[chan_idx]["ch_name"])
+                    )
+                    rad_channel = _find_radial_channel(info, overlapping_set)
+                    # Make sure the radial channel is first in the overlapping set
+                    overlapping_set = np.array(
+                        [ch for ch in overlapping_set if ch != rad_channel]
+                    )
+                    overlapping_set = np.insert(overlapping_set, 0, rad_channel)
                 overlapping_channels.append(overlapping_set)
                 channels_to_exclude.append(overlapping_set[1:])
 
         exclude = list(itertools.chain.from_iterable(channels_to_exclude))
         [exclude.append(bad) for bad in info["bads"]]
-        picks = pick_types(
-            info, meg=False, ref_meg=False, fnirs=ch_type, exclude=exclude
-        )
-        pos = _find_topomap_coords(info, picks, sphere=sphere)
-        picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type)
+        if modality == "fnirs":
+            picks = pick_types(
+                info, meg=False, ref_meg=False, fnirs=ch_type, exclude=exclude
+            )
+            pos = _find_topomap_coords(info, picks, sphere=sphere)
+            picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type)
+        elif modality == "opm":
+            picks = pick_types(info, meg=True, ref_meg=False, exclude=exclude)
+            pos = _find_topomap_coords(info, picks, sphere=sphere)
+            picks = pick_types(info, meg=True, ref_meg=False)
+
         # Overload the merge_channels variable as this is returned to calling
         # function and indicates that merging of data is required
         merge_channels = overlapping_channels
 
     else:
-        picks = pick_types(
-            info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
-        )
+        if modality == "fnirs":
+            picks = pick_types(
+                info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
+            )
+        elif modality == "opm":
+            picks = pick_types(info, meg=True, ref_meg=False, exclude="bads")
+
         merge_channels = False
         pos = _find_topomap_coords(info, picks, sphere=sphere)
 
     return picks, pos, merge_channels, overlapping_channels
 
 
+def _find_radial_channel(info, overlapping_set):
+    """Find the most radial channel in the overlapping set."""
+    if len(overlapping_set) == 1:
+        return overlapping_set[0]
+    elif len(overlapping_set) < 1:
+        raise ValueError("No overlapping channels found.")
+
+    radial_score = np.zeros(len(overlapping_set))
+    for s, sens in enumerate(overlapping_set):
+        ch_idx = pick_channels(info["ch_names"], [sens])[0]
+        radial_direction = copy.copy(info["chs"][ch_idx]["loc"][0:3])
path: mne/viz/topomap.py
user: login: larsoner
body: This is always an `ndarray` so let's just use its method
```suggestion
        radial_direction = info["chs"][ch_idx]["loc"][0:3].copy()
```
created_at: 2025-04-29T18:16:34Z
author_association: MEMBER
diff_hunk: @@ -212,33 +234,77 @@ def _average_fnirs_overlaps(info, ch_type, sphere):
                 overlapping_set = [
                     chs[i]["ch_name"] for i in np.where(overlapping_mask[chan_idx])[0]
                 ]
-                overlapping_set = np.insert(
-                    overlapping_set, 0, (chs[chan_idx]["ch_name"])
-                )
+                if modality == "fnirs":
+                    overlapping_set = np.insert(
+                        overlapping_set, 0, (chs[chan_idx]["ch_name"])
+                    )
+                elif modality == "opm":
+                    overlapping_set = np.insert(
+                        overlapping_set, 0, (chs[chan_idx]["ch_name"])
+                    )
+                    rad_channel = _find_radial_channel(info, overlapping_set)
+                    # Make sure the radial channel is first in the overlapping set
+                    overlapping_set = np.array(
+                        [ch for ch in overlapping_set if ch != rad_channel]
+                    )
+                    overlapping_set = np.insert(overlapping_set, 0, rad_channel)
                 overlapping_channels.append(overlapping_set)
                 channels_to_exclude.append(overlapping_set[1:])
 
         exclude = list(itertools.chain.from_iterable(channels_to_exclude))
         [exclude.append(bad) for bad in info["bads"]]
-        picks = pick_types(
-            info, meg=False, ref_meg=False, fnirs=ch_type, exclude=exclude
-        )
-        pos = _find_topomap_coords(info, picks, sphere=sphere)
-        picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type)
+        if modality == "fnirs":
+            picks = pick_types(
+                info, meg=False, ref_meg=False, fnirs=ch_type, exclude=exclude
+            )
+            pos = _find_topomap_coords(info, picks, sphere=sphere)
+            picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type)
+        elif modality == "opm":
+            picks = pick_types(info, meg=True, ref_meg=False, exclude=exclude)
+            pos = _find_topomap_coords(info, picks, sphere=sphere)
+            picks = pick_types(info, meg=True, ref_meg=False)
+
         # Overload the merge_channels variable as this is returned to calling
         # function and indicates that merging of data is required
         merge_channels = overlapping_channels
 
     else:
-        picks = pick_types(
-            info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
-        )
+        if modality == "fnirs":
+            picks = pick_types(
+                info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads"
+            )
+        elif modality == "opm":
+            picks = pick_types(info, meg=True, ref_meg=False, exclude="bads")
+
         merge_channels = False
         pos = _find_topomap_coords(info, picks, sphere=sphere)
 
     return picks, pos, merge_channels, overlapping_channels
 
 
+def _find_radial_channel(info, overlapping_set):
+    """Find the most radial channel in the overlapping set."""
+    if len(overlapping_set) == 1:
+        return overlapping_set[0]
+    elif len(overlapping_set) < 1:
+        raise ValueError("No overlapping channels found.")
+
+    radial_score = np.zeros(len(overlapping_set))
+    for s, sens in enumerate(overlapping_set):
+        ch_idx = pick_channels(info["ch_names"], [sens])[0]
+        radial_direction = copy.copy(info["chs"][ch_idx]["loc"][0:3])
+        radial_direction /= np.linalg.norm(radial_direction)
+
+        orientation_vector = info["chs"][ch_idx]["loc"][9:12]
+        if info["dev_head_t"] is not None:
+            orientation_vector = apply_trans(info["dev_head_t"], orientation_vector)
path: mne/viz/topomap.py
user: login: larsoner
body: We don't want to have the translation in there, just the rotation
```suggestion
        if info["dev_head_t"] is not None:
            orientation_vector = apply_trans(info["dev_head_t"], orientation_vector, move=False)
```
And I'm not 100% sure we need it (for other MEG systems we don't do this) but I guess it's okay. It makes sense if we think of OPMs as more EEG-like in their sensor positioning, which we probably should.
created_at: 2025-04-29T18:17:55Z
author_association: MEMBER

PR comments:
user: login: welcome[bot]
created_at: 2025-03-05T16:51:09Z
body: Hello! 👋 Thanks for opening your first pull request here! ❤️ We will try to get back to you soon. 🚴

author_association: NONE
user: login: larsoner
created_at: 2025-03-05T17:24:59Z
body: > I've written an opm test, but would like guidance about how to access mne.datasets.ucl_opm_auditory. Test won't work as is (eg can't update UCL dataset, and wanted to confirm proper method), but the test is structured on the fnirs overlap test so probably won't need much more

The best thing to do is probably to take a file from that dataset, crop it to be very short (or maybe better, just save a clean evoked response as it's already probably ~1MB!) and add it to [mne-testing-data](https://github.com/mne-tools/mne-testing-data?tab=readme-ov-file#add-or-change-files-in-the-repo-for-use-with-mne). We don't want to make tests depend on another dataset, but we can/should update that one.

> for the UCL dataset, most of the *-RAD channels are the most radial, but in some cases both RAD and TAN have low radial scores, and TAN is the most radial sensors

Yikes, so the ones that are nominally radial aren't actually the most radial? Do the sensors get turned a lot? Seems like it would need to be off by 45 degrees at least, which is a lot.

I didn't expect this to be the case in practice. We could in theory triage based on system type and channel name, but that doesn't seem all that satisfying. Does the resulting topomap look reasonable? Does it look better if you use your projection-onto-normal method (i.e., a weighted average of the colocated sensors in the direction of the ideal outward normal)?
author_association: MEMBER
user: login: harrisonritz
created_at: 2025-03-06T01:17:38Z
body: > The best thing to do is probably to take a file from that dataset, crop it to be very short (or maybe better, just save a clean evoked response as it's already probably ~1MB!) and add it to [mne-testing-data](https://github.com/mne-tools/mne-testing-data?tab=readme-ov-file#add-or-change-files-in-the-repo-for-use-with-mne). We don't want to make tests depend on another dataset, but we can/should update that one.

Cool, I created a PR to add an evoked OPM dataset to `mne_testing_data`: https://github.com/mne-tools/mne-testing-data/pull/120

> Yikes, so the ones that are nominally radial aren't actually the most radial? Do the sensors get turned a lot? Seems like it would need to be off by 45 degrees at least, which is a lot.
>
> I didn't expect this to be the case in practice. We could in theory triage based on system type and channel name, but that doesn't seem all that satisfying. Does the resulting topomap look reasonable? Does it look better if you use your projection-onto-normal method (i.e., a weighted average of the colocated sensors in the direction of the ideal outward normal)?

Yes, the radial-identified sensors look pretty similar to just picking the sensors with the RAD label (see below). It's only 2 / 43 where RAD-labelled sensors weren't identified as the most radial. They are right next to each other on the side of the head, could have easily been a funny placement or helmet (@georgeoneill, @neurofractal). NBD.

In our data, the max-radial picks the Z sensor 100% of the time, so I think it's a reasonable heuristic. What will be annoying is if different loc triplets should be used in different sensors types (eg fieldline). Guess it'll rely on people raising issues when it doesn't work. Down the line, maybe worth having somewhere in the `info` structure to store that kind of information (even lists of radial sensors).

It's actually pretty annoying to do the weighted average. Sensor radial scores are computed in a part of `topomaps` that just has access to `info`, and the combination of sensors happens in a part of `layouts` that just had access to `data`. So might require a bit of coordination to combine them.

![compare_methods](https://github.com/user-attachments/assets/2f1cb82e-a1ab-4c12-8d1a-6f21d9e54c84)
![sensor_locs](https://github.com/user-attachments/assets/868c0f93-ff9a-4374-8326-7b4229611516)



author_association: CONTRIBUTOR
user: login: georgeoneill
created_at: 2025-03-06T11:21:33Z
body: Good morning @harrisonritz, @larsoner! Just looking at the UCL dataset, it appears that we've got some false friends in the naming of the channels. The -RAD channels have a tangential orientation, whilst the -TAN have a radial profile. 

At this point in the development of the UCL system, we laid the sensors on their side for a lower-profile helmet, but the labelling of the sensors never changed to reflect that. Apologies for the confustion!

![image](https://github.com/user-attachments/assets/fc4688e3-036e-42af-a824-0d531e166181)

author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-03-06T12:25:55Z
body: No worries, anything to get them to work!

When I used the x-axis orientation (`loc[3:6]`), the RAD sensors were (mostly) radial. I think you mentioned in the issue that we should use the y-axis orientation (`loc[6:9]`)? I'll see how that looks.

This does highlight the trickiness of inferring the right orientation axis from `info`. Maybe we should add a 'radial sensors' field?
author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-03-06T13:57:59Z
body: oh great, picking the most-radial z-axis orientation matches the `*-TAN` sensors (and alignment is above .85 for all sensors), so can use that orientation by default.


![Screenshot 2025-03-06 at 8 54 38 AM](https://github.com/user-attachments/assets/1fe3a435-5096-4da2-b403-8bca7b894aef)

author_association: CONTRIBUTOR
user: login: georgeoneill
created_at: 2025-03-06T14:07:56Z
body: `loc[6:9]` are the orientations for each channel. '`loc[0:5]` are redundant for our needs with the FIL format and are used to make visualisations of the sensors make sense. 
author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-03-10T17:41:20Z
body: @georgeoneill I found that picking radial sensors using `loc[9:12]` selected the `*-TAN` sensors, which is the same as our system (for us, picking radial sensors based on `loc[9:12]` selects the `z` sensors). Just double checking, because you wrote `loc[6:9]` above.
author_association: CONTRIBUTOR
user: login: georgeoneill
created_at: 2025-03-10T19:48:22Z
body: Hi @harrisonritz, my mistake, you are correct! I forgot what the order of the 12 elements of the `loc` array corresponded to. 

0-2: Position
3-5: Redundant (except for visualisation)
6-8: Redundant (except for visualisation)
9-11: Orientation

author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-03-10T19:49:55Z
body: all good, thanks! just double-checking
author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-03-17T20:31:15Z
body: OK -- looks like it's passing major checks, sorry about the wait.
author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-03-22T22:46:32Z
body: These look super helpful, thank you!

I'll take a pass over the next week or so (going to a week-long conference on weds).

I discovered draft mode, so hopefully now you won't get pinged for every pull.
author_association: CONTRIBUTOR
user: login: harrisonritz
created_at: 2025-04-23T17:19:18Z
body: Alright, sorry this got delayed.
We had a class of grad students use this branch for an assignments, so its now been stress-tested :) 

- better look-up of OPM coil types using the FIFF constants
- added the MERGE-REMOVE mechanism recommended by @larsoner. Even if names shouldn't have '.' in them, simpler is better.
author_association: CONTRIBUTOR
user: login: larsoner
created_at: 2025-04-28T15:02:01Z
body: Changes look reasonable!

Can you add a `doc/changes/13144.newfeature.rst` using the `:newcontrib:` directive and your name in `doc/changes/names.inc`?

Any chance to modify (and hopefully simplify!) some existing OPM tutorial/example to show off this functionality? Like maybe something can now be a `.plot` instead of a `.copy().pick(...).plot` or something
author_association: MEMBER
user: login: harrisonritz
created_at: 2025-04-28T19:14:09Z
body: thanks @larsoner.

updated changelog & name.

afaik none of the opm tutorials use topomaps (for good reason). So I updated the opm preprocessing tutorial to use an `evoked.plot_joint()` instead of an `evoked.plot()`. 
https://output.circle-artifacts.com/output/job/1eb93b6a-621b-4728-a59f-9b2d0e28f958/artifacts/0/html/auto_tutorials/preprocessing/80_opm_processing.html#generating-an-evoked-response

sidebar: should I keep updating the PR to match the base branch as we go, or wait until its merged?
author_association: CONTRIBUTOR
user: login: larsoner
created_at: 2025-04-28T19:33:02Z
body: > sidebar: should I keep updating the PR to match the base branch as we go, or wait until its merged?

No need, we squash+merge at the end of the day. We generally only `Update branch` (i.e., merge `main` into your branch via the UI) when we change some CI `.yaml` that requires it. Otherwise the CIs always test against the merged version of the code anyway so no real benefit to updating!

I should be able to look tomorrow!


author_association: MEMBER
user: login: harrisonritz
created_at: 2025-04-29T14:54:13Z
body: not sure why ubuntu is failing; doesn't look like its due to this PR
author_association: CONTRIBUTOR
user: login: larsoner
created_at: 2025-04-29T18:25:00Z
body: ... actually, just to make sure I can sleep well at night I'm going to push a quick smoke test for #12934
author_association: MEMBER
user: login: welcome[bot]
created_at: 2025-04-29T20:41:25Z
body: 🎉 Congrats on merging your first pull request! 🥳 Looking forward to seeing more from you in the future! 💪

author_association: NONE