From 70b3a4c30823f16d7d5af61bac808e15fe34517c Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 3 Mar 2025 18:59:15 -0500 Subject: [PATCH 01/28] detect overlap; commandeer fnirs functions --- mne/viz/topomap.py | 89 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index bb180a3f299..ba771e0a39b 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -4,6 +4,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from ast import mod import copy import itertools import warnings @@ -76,6 +77,7 @@ ) _fnirs_types = ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od") +_opm_coils = (8002,) # 3.8+ uses a single Collection artist rather than .collections @@ -123,6 +125,8 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): info["bads"] = _clean_names(info["bads"]) info._check_consistency() + is_OPM = any(ch['coil_type'] in _opm_coils for ch in inst.info['chs']) + # special case for merging grad channels layout = find_layout(info) if ( @@ -138,8 +142,12 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): 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 + picks, pos, merge_channels, overlapping_channels = _find_overlaps( + info, ch_type, sphere, modality="fnirs" + ) + elif is_OPM: + picks, pos, merge_channels, overlapping_channels = _opm_overlaps( + info, ch_type, sphere, modality="opm" ) else: merge_channels = False @@ -185,10 +193,16 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): 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, mag=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 +226,78 @@ 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": + rad_channel = _find_radial_channel(info, overlapping_set) + 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.") + + radials = np.zeros(len(overlapping_set)) + for s, sens in enumerate(overlapping_set): + + ch_idx = pick_channels(info['ch_names'], sens) + + radial_direction = 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) + radials[s] = np.abs(np.dot(radial_direction, orientation_vector)) + + return overlapping_set[np.argmax(radials)] + + + + + + + def _plot_update_evoked_topomap(params, bools): """Update topomaps.""" from ..channels.layout import _merge_ch_data From 7f936b0fc274cd377d47c44ea822e23161c1de0d Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Tue, 4 Mar 2025 12:27:35 -0500 Subject: [PATCH 02/28] start to merge channels --- mne/channels/layout.py | 48 +++++++++++++++++++++++++++++++++++--- mne/viz/topomap.py | 52 ++++++++++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index 31d0650037e..ebb28ab5565 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -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 'fnirs', 'opm', or 'other' 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 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,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) + return data, merged_names + + def generate_2d_layout( xy, w=0.07, diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index ba771e0a39b..be40bcf2e76 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -125,7 +125,12 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): info["bads"] = _clean_names(info["bads"]) info._check_consistency() - is_OPM = any(ch['coil_type'] in _opm_coils for ch in inst.info['chs']) + if any(ch['coil_type'] in _opm_coils for ch in inst.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) @@ -140,14 +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 + elif modality != 'other': picks, pos, merge_channels, overlapping_channels = _find_overlaps( - info, ch_type, sphere, modality="fnirs" - ) - elif is_OPM: - picks, pos, merge_channels, overlapping_channels = _opm_overlaps( - info, ch_type, sphere, modality="opm" + info, ch_type, sphere, modality=modality ) else: merge_channels = False @@ -170,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] @@ -179,15 +179,22 @@ 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[:2] for s in set_) + ch_names[idx] = new_name + print('new_name: ', new_name) pos = np.array(pos)[:, :2] # 2D plot, otherwise interpolation bugs return picks, pos, merge_channels, ch_names, ch_type, sphere, clip_origin @@ -277,7 +284,7 @@ def _find_radial_channel(info, overlapping_set): elif len(overlapping_set) < 1: raise ValueError("No overlapping channels found.") - radials = np.zeros(len(overlapping_set)) + radial_score = np.zeros(len(overlapping_set)) for s, sens in enumerate(overlapping_set): ch_idx = pick_channels(info['ch_names'], sens) @@ -288,9 +295,11 @@ def _find_radial_channel(info, overlapping_set): 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) - radials[s] = np.abs(np.dot(radial_direction, orientation_vector)) + radial_score[s] = np.abs(np.dot(radial_direction, orientation_vector)) - return overlapping_set[np.argmax(radials)] + radial_sensor = overlapping_set[np.argmax(radial_score)] + + return radial_sensor @@ -2355,8 +2364,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: From 82979747c8f23f81d47b769ab63403908a587e41 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Tue, 4 Mar 2025 19:34:07 -0500 Subject: [PATCH 03/28] opm tests --- mne/channels/layout.py | 6 +-- mne/viz/tests/test_topomap.py | 30 ++++++++++++++- mne/viz/topomap.py | 69 +++++++++++++++++------------------ 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index ebb28ab5565..368c404eeb3 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -1089,7 +1089,7 @@ def _pair_grad_sensors( return picks -def _merge_ch_data(data, ch_type, names, method="rms", modality='opm'): +def _merge_ch_data(data, ch_type, names, method="rms", modality="opm"): """Merge data from channel pairs. Parameters @@ -1116,11 +1116,11 @@ def _merge_ch_data(data, ch_type, names, method="rms", modality='opm'): data = _merge_grad_data(data, method) elif ch_type in _FNIRS_CH_TYPES_SPLIT: data, names = _merge_nirs_data(data, names) - elif modality == 'opm' and ch_type == 'mag': + 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 diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index b87d0d39f89..65d3bbc9d42 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -20,6 +20,7 @@ compute_proj_evoked, compute_proj_raw, create_info, + find_events, find_layout, make_fixed_length_events, pick_types, @@ -38,7 +39,7 @@ read_layout, ) from mne.datasets import testing -from mne.io import RawArray, read_info, read_raw_fif +from mne.io import RawArray, read_info, read_raw_fif, read_raw_fil from mne.preprocessing import ( ICA, compute_bridged_electrodes, @@ -63,6 +64,7 @@ ecg_fname = data_dir / "MEG" / "sample" / "sample_audvis_ecg-proj.fif" triux_fname = data_dir / "SSS" / "TRIUX" / "triux_bmlhus_erm_raw.fif" + base_dir = Path(__file__).parents[2] / "io" / "tests" / "data" evoked_fname = base_dir / "test-ave.fif" raw_fname = base_dir / "test_raw.fif" @@ -70,6 +72,7 @@ ctf_fname = base_dir / "test_ctf_comp_raw.fif" layout = read_layout("Vectorview-all") cov_fname = base_dir / "test-cov.fif" +opm_fname = base_dir / "sub-002_ses-001_task-aef_run-001_meg.bin" fast_test = dict(res=8, contours=0, sensors=False) @@ -776,6 +779,31 @@ def test_plot_topomap_bads_grad(): plot_topomap(data, info, res=8) +def test_plot_topomap_opm(): + """Test plotting topomap with OPM data.""" + # load data + + raw = read_raw_fil(opm_fname, verbose="error") + raw.crop(120, 210).load_data() # crop for speed + picks = pick_types(raw.info, meg=True) + + # events and epochs + events = find_events(raw, min_duration=0.1) + epochs = Epochs(raw, events, tmin=-0.1, tmax=0.4, verbose="error") + evoked = epochs.average() + t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] + + # plot evoked topomap + fig_evoked = evoked.plot_topomap(times=t_peak, ch_type="mag", show=False) + assert len(fig_evoked.axes) == 2 + + # plot ICA + ica = ICA(n_components=2, max_iter=10) + ica.fit(epochs, picks=picks, decim=100) + fig_ica = ica.plot_components(show=False) + assert len(fig_ica.axes) == 2 + + 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 be40bcf2e76..f9b946325ce 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -4,7 +4,6 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -from ast import mod import copy import itertools import warnings @@ -125,13 +124,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 inst.info['chs']): - modality = 'opm' + if any(ch["coil_type"] in _opm_coils for ch in inst.info["chs"]): + modality = "opm" elif ch_type in _fnirs_types: - modality = 'fnirs' + modality = "fnirs" else: - modality = 'other' - + modality = "other" + # special case for merging grad channels layout = find_layout(info) if ( @@ -145,7 +144,7 @@ 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 modality != 'other': + elif modality != "other": picks, pos, merge_channels, overlapping_channels = _find_overlaps( info, ch_type, sphere, modality=modality ) @@ -170,7 +169,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 modality == 'fnirs': + if modality == "fnirs": # Remove the chroma label type for cleaner labeling. ch_names = [k[:-4] for k in ch_names] @@ -179,7 +178,7 @@ 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)] - elif modality == 'fnirs': + 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 @@ -187,14 +186,13 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): 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': + 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[:2] for s in set_) + new_name = ".".join(s for s in set_) ch_names[idx] = new_name - print('new_name: ', new_name) pos = np.array(pos)[:, :2] # 2D plot, otherwise interpolation bugs return picks, pos, merge_channels, ch_names, ch_type, sphere, clip_origin @@ -205,9 +203,11 @@ def _find_overlaps(info, ch_type, sphere, modality="fnirs"): from ..channels.layout import _find_topomap_coords if modality == "fnirs": - picks = pick_types(info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads") + picks = pick_types( + info, meg=False, ref_meg=False, fnirs=ch_type, exclude="bads" + ) elif modality == "opm": - picks = pick_types(info, mag=True, ref_meg=False, exclude="bads") + 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] @@ -240,8 +240,13 @@ def _find_overlaps(info, ch_type, sphere, modality="fnirs"): elif modality == "opm": rad_channel = _find_radial_channel(info, overlapping_set) overlapping_set = np.insert( - overlapping_set, 0, (rad_channel) + overlapping_set, 0, (chs[chan_idx]["ch_name"]) + ) + # 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:]) @@ -269,7 +274,7 @@ def _find_overlaps(info, ch_type, sphere, modality="fnirs"): ) 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) @@ -278,7 +283,6 @@ def _find_overlaps(info, ch_type, sphere, modality="fnirs"): 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: @@ -286,15 +290,14 @@ def _find_radial_channel(info, overlapping_set): radial_score = np.zeros(len(overlapping_set)) for s, sens in enumerate(overlapping_set): + ch_idx = pick_channels(info["ch_names"], sens) - ch_idx = pick_channels(info['ch_names'], sens) - - radial_direction = info['chs'][ch_idx]['loc'][0:3] + radial_direction = 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) + 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) radial_score[s] = np.abs(np.dot(radial_direction, orientation_vector)) radial_sensor = overlapping_set[np.argmax(radial_score)] @@ -302,11 +305,6 @@ def _find_radial_channel(info, overlapping_set): return radial_sensor - - - - - def _plot_update_evoked_topomap(params, bools): """Update topomaps.""" from ..channels.layout import _merge_ch_data @@ -1704,9 +1702,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( @@ -1743,7 +1740,7 @@ def plot_ica_components( pos, ch_type=ch_type, sensors=sensors, - names=names, + names=disp_names, contours=contours, outlines=outlines, sphere=sphere, @@ -2365,16 +2362,16 @@ def plot_evoked_topomap( data *= scaling if merge_channels: # check modality - if any(ch['coil_type'] in _opm_coils for ch in evoked.info['chs']): - modality = 'opm' + if any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]): + modality = "opm" elif ch_type in _fnirs_types: - modality = 'fnirs' + modality = "fnirs" else: - modality = 'other' + 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': + if modality != "other": merge_channels = False # apply mask if requested if mask is not None: From 75e40c0ac195b6192f6e7f5701bd0e62dd60dae5 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Wed, 5 Mar 2025 10:48:51 -0500 Subject: [PATCH 04/28] fix selection of radial channels; better selection of OPM sensors; super annoying alias bug --- mne/channels/layout.py | 2 +- mne/viz/topomap.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index 368c404eeb3..860abe4d2a2 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 " diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index f9b946325ce..b0223fdf128 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -238,10 +238,10 @@ def _find_overlaps(info, ch_type, sphere, modality="fnirs"): overlapping_set, 0, (chs[chan_idx]["ch_name"]) ) elif modality == "opm": - rad_channel = _find_radial_channel(info, overlapping_set) 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] @@ -290,12 +290,17 @@ def _find_radial_channel(info, overlapping_set): radial_score = np.zeros(len(overlapping_set)) for s, sens in enumerate(overlapping_set): - ch_idx = pick_channels(info["ch_names"], sens) - - radial_direction = info["chs"][ch_idx]["loc"][0:3] + 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] + # use different orientation vector for dual-axis and triaxial sensors + # risky, will have to accommodate new OPM sensors in the future + if any(key in sens for key in ["RAD", "TAN"]): + orientation_vector = info["chs"][ch_idx]["loc"][3:6] + else: + 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) radial_score[s] = np.abs(np.dot(radial_direction, orientation_vector)) From ab419e3446b90c26f8ed67182032e48013954753 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Thu, 6 Mar 2025 08:54:25 -0500 Subject: [PATCH 05/28] always use Z-axis orientation (now matches UCL's *-TAN sensors) --- mne/viz/topomap.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index b0223fdf128..4888f4166d4 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -294,13 +294,7 @@ def _find_radial_channel(info, overlapping_set): radial_direction = copy.copy(info["chs"][ch_idx]["loc"][0:3]) radial_direction /= np.linalg.norm(radial_direction) - # use different orientation vector for dual-axis and triaxial sensors - # risky, will have to accommodate new OPM sensors in the future - if any(key in sens for key in ["RAD", "TAN"]): - orientation_vector = info["chs"][ch_idx]["loc"][3:6] - else: - orientation_vector = info["chs"][ch_idx]["loc"][9:12] - + 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) radial_score[s] = np.abs(np.dot(radial_direction, orientation_vector)) From 109e1de2d4d272239277019ea5ae343bbaa521c9 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 10 Mar 2025 13:34:21 -0400 Subject: [PATCH 06/28] update test_topomap --- mne/viz/tests/test_topomap.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 65d3bbc9d42..493fc6477ba 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -63,6 +63,7 @@ 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.fif" base_dir = Path(__file__).parents[2] / "io" / "tests" / "data" @@ -72,7 +73,6 @@ ctf_fname = base_dir / "test_ctf_comp_raw.fif" layout = read_layout("Vectorview-all") cov_fname = base_dir / "test-cov.fif" -opm_fname = base_dir / "sub-002_ses-001_task-aef_run-001_meg.bin" fast_test = dict(res=8, contours=0, sensors=False) @@ -781,28 +781,14 @@ def test_plot_topomap_bads_grad(): def test_plot_topomap_opm(): """Test plotting topomap with OPM data.""" + # load data - - raw = read_raw_fil(opm_fname, verbose="error") - raw.crop(120, 210).load_data() # crop for speed - picks = pick_types(raw.info, meg=True) - - # events and epochs - events = find_events(raw, min_duration=0.1) - epochs = Epochs(raw, events, tmin=-0.1, tmax=0.4, verbose="error") - evoked = epochs.average() - t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] + evoked = read_evokeds(opm_fname, kind="average") # plot evoked topomap - fig_evoked = evoked.plot_topomap(times=t_peak, ch_type="mag", show=False) + fig_evoked = evoked.plot_topomap(ch_type="mag", show=False) assert len(fig_evoked.axes) == 2 - # plot ICA - ica = ICA(n_components=2, max_iter=10) - ica.fit(epochs, picks=picks, decim=100) - fig_ica = ica.plot_components(show=False) - assert len(fig_ica.axes) == 2 - def test_plot_topomap_nirs_overlap(fnirs_epochs): """Test plotting nirs topomap with overlapping channels (gh-7414).""" From b86351ee615b9d9ce88f6652307fc7b77d2b9c2b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 17:34:42 +0000 Subject: [PATCH 07/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/tests/test_topomap.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 493fc6477ba..7f5c81bf072 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -20,7 +20,6 @@ compute_proj_evoked, compute_proj_raw, create_info, - find_events, find_layout, make_fixed_length_events, pick_types, @@ -39,7 +38,7 @@ read_layout, ) from mne.datasets import testing -from mne.io import RawArray, read_info, read_raw_fif, read_raw_fil +from mne.io import RawArray, read_info, read_raw_fif from mne.preprocessing import ( ICA, compute_bridged_electrodes, @@ -781,7 +780,6 @@ def test_plot_topomap_bads_grad(): def test_plot_topomap_opm(): """Test plotting topomap with OPM data.""" - # load data evoked = read_evokeds(opm_fname, kind="average") From 0f92da6641765101917d532d76e150e689b35f9b Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 10 Mar 2025 14:36:27 -0400 Subject: [PATCH 08/28] update testing data --- mne/datasets/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/datasets/config.py b/mne/datasets/config.py index 75eff184cd1..a1316251418 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.160", misc="0.27", phantom_kit="0.2", ucl_opm_auditory="0.2", From dcf3e2e4593795a68132b8c5540cc0df4f1d407f Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 10 Mar 2025 15:16:18 -0400 Subject: [PATCH 09/28] update testing data hash --- mne/datasets/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/datasets/config.py b/mne/datasets/config.py index a1316251418..eb5bc8520f1 100644 --- a/mne/datasets/config.py +++ b/mne/datasets/config.py @@ -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:9f1d5c9848b1d7531bada6f2d86115b5", url=( "https://codeload.github.com/mne-tools/mne-testing-data/" f"tar.gz/{RELEASES['testing']}" From 8067e4d4603855f771ed6db72fed22e412b50eb8 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Sat, 15 Mar 2025 13:39:31 -0400 Subject: [PATCH 10/28] inst.info --> info --- mne/viz/topomap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 4888f4166d4..54167f2c0dc 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -124,7 +124,7 @@ 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 inst.info["chs"]): + if any(ch["coil_type"] in _opm_coils for ch in info["chs"]): modality = "opm" elif ch_type in _fnirs_types: modality = "fnirs" From 953e0989eaf2b58f666c9f4786684c84de7d11a8 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Sat, 15 Mar 2025 14:44:05 -0400 Subject: [PATCH 11/28] add `-ave` suffix --- mne/viz/tests/test_topomap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 7f5c81bf072..3e95d25536b 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -62,7 +62,7 @@ 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.fif" +opm_fname = data_dir / "OPM" / "opm-evoked-ave.fif" base_dir = Path(__file__).parents[2] / "io" / "tests" / "data" From 0483ad116f257fe0adefbf8ed7783d3ad868c07f Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 17 Mar 2025 11:20:56 -0400 Subject: [PATCH 12/28] update dataset config --- mne/datasets/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/datasets/config.py b/mne/datasets/config.py index eb5bc8520f1..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.160", + 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:9f1d5c9848b1d7531bada6f2d86115b5", + hash="md5:a32cfb9e098dec39a5f3ed6c0833580d", url=( "https://codeload.github.com/mne-tools/mne-testing-data/" f"tar.gz/{RELEASES['testing']}" From 5460d71ef4a2e334dfa9c5ff436317413a147cff Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 17 Mar 2025 13:50:51 -0400 Subject: [PATCH 13/28] properly load evoked --- mne/viz/tests/test_topomap.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 3e95d25536b..cf018530889 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -777,15 +777,15 @@ def test_plot_topomap_bads_grad(): assert len(info["chs"]) == 203 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") + evoked = read_evokeds(opm_fname, kind="average")[0] # plot evoked topomap - fig_evoked = evoked.plot_topomap(ch_type="mag", show=False) - assert len(fig_evoked.axes) == 2 + fig_evoked = evoked.plot_topomap(times=[-.1, 0, .1, .2], ch_type="mag", show=False) + assert len(fig_evoked.axes) == 5 def test_plot_topomap_nirs_overlap(fnirs_epochs): From 72822a4301e746a95406114d316073797783c415 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:51:10 +0000 Subject: [PATCH 14/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/tests/test_topomap.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index cf018530889..64a13b46006 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -777,6 +777,7 @@ def test_plot_topomap_bads_grad(): assert len(info["chs"]) == 203 plot_topomap(data, info, res=8) + @testing.requires_testing_data def test_plot_topomap_opm(): """Test plotting topomap with OPM data.""" @@ -784,7 +785,9 @@ def test_plot_topomap_opm(): evoked = read_evokeds(opm_fname, kind="average")[0] # plot evoked topomap - fig_evoked = evoked.plot_topomap(times=[-.1, 0, .1, .2], ch_type="mag", show=False) + fig_evoked = evoked.plot_topomap( + times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False + ) assert len(fig_evoked.axes) == 5 From a72b7f80f2f9819544d57542bd9ddc633bb7f61b Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Tue, 22 Apr 2025 11:58:02 -0400 Subject: [PATCH 15/28] Update mne/channels/layout.py Co-authored-by: Eric Larson --- mne/channels/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index 860abe4d2a2..2bf8f21902d 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -1089,7 +1089,7 @@ def _pair_grad_sensors( return picks -def _merge_ch_data(data, ch_type, names, method="rms", modality="opm"): +def _merge_ch_data(data, ch_type, names, method="rms", *, modality="opm"): """Merge data from channel pairs. Parameters From a65961ce1f61f72597c97d23564fef70a81895ef Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Tue, 22 Apr 2025 11:58:39 -0400 Subject: [PATCH 16/28] Update mne/channels/layout.py Co-authored-by: Eric Larson --- mne/channels/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index 2bf8f21902d..7f1f8ffaf77 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -1218,7 +1218,7 @@ def _merge_opm_data(data, merged_names): to_remove = np.unique(to_remove) for rem in sorted(to_remove, reverse=True): del merged_names[rem] - data = np.delete(data, rem, 0) + data = np.delete(data, to_remove, axis=0) return data, merged_names From 7343021d2c4871f9b30b97ab24eafaef23f3b647 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Tue, 22 Apr 2025 13:10:10 -0400 Subject: [PATCH 17/28] append with MERGE_REMOVE. use FIFF constants --- mne/channels/layout.py | 21 ++++++++------------- mne/viz/topomap.py | 13 +++++++------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index 7f1f8ffaf77..cec464f1ccd 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -1103,7 +1103,7 @@ def _merge_ch_data(data, ch_type, names, method="rms", *, modality="opm"): method : str Can be 'rms' or 'mean'. modality : str - The modality of the data, either 'fnirs', 'opm', or 'other' + The modality of the data, either 'grad', 'fnirs', or 'opm' Returns ------- @@ -1114,7 +1114,7 @@ def _merge_ch_data(data, ch_type, names, method="rms", *, modality="opm"): """ if ch_type == "grad": data = _merge_grad_data(data, method) - elif 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) @@ -1187,11 +1187,10 @@ def _merge_nirs_data(data, merged_names): def _merge_opm_data(data, merged_names): - """Merge data from multiple opm channel using the mean. + """Merge data from multiple opm channel by just using the radial component. - 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. + Channel names that end in "MERGE_REMOVE" (ie non-radial channels) will be + removed. Only the the radial channel is kept. Parameters ---------- @@ -1199,7 +1198,7 @@ def _merge_opm_data(data, merged_names): Data for channels. merged_names : list List of strings containing the channel names. Channels that are to be - merged contain an x between them. + removed end in "MERGE_REMOVE". Returns ------- @@ -1209,12 +1208,8 @@ def _merge_opm_data(data, merged_names): """ 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) + 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] diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 54167f2c0dc..264ba505361 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,7 +77,7 @@ ) _fnirs_types = ("hbo", "hbr", "fnirs_cw_amplitude", "fnirs_od") -_opm_coils = (8002,) +_opm_coils = (FIFF.FIFFV_COIL_QUSPIN_ZFOPM_MAG, FIFF.FIFFV_COIL_QUSPIN_ZFOPM_MAG2) # 3.8+ uses a single Collection artist rather than .collections @@ -187,12 +188,12 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): 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 + # indicate that non-radial changes are to be removed for set_ in overlapping_channels: - idx = ch_names.index(set_[0]) - new_name = ".".join(s for s in set_) - ch_names[idx] = new_name + for set_ch in set_[1:]: + idx = ch_names.index(set_ch) + new_name = set_ch.append("_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 From 217c579358894d4d979a2359343df21e2501df53 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:10:39 +0000 Subject: [PATCH 18/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/channels/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index cec464f1ccd..2b62982345b 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -1189,7 +1189,7 @@ def _merge_nirs_data(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 + Channel names that end in "MERGE_REMOVE" (ie non-radial channels) will be removed. Only the the radial channel is kept. Parameters From 0a02fb996556f62fce401b50c638ba653584e344 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Tue, 22 Apr 2025 17:24:13 -0400 Subject: [PATCH 19/28] new append --- mne/viz/topomap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 264ba505361..1e75a1b8ea4 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -192,7 +192,7 @@ def _prepare_topomap_plot(inst, ch_type, sphere=None): for set_ in overlapping_channels: for set_ch in set_[1:]: idx = ch_names.index(set_ch) - new_name = set_ch.append("_MERGE-REMOVE") + new_name = set_ch + "_MERGE-REMOVE" ch_names[idx] = new_name pos = np.array(pos)[:, :2] # 2D plot, otherwise interpolation bugs From bfb6152859a156ce8dcaf95bc9b2eaf3ed5a74b0 Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Wed, 23 Apr 2025 10:47:19 -0400 Subject: [PATCH 20/28] merge-remove --- mne/channels/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/channels/layout.py b/mne/channels/layout.py index 2b62982345b..fb12b509997 100644 --- a/mne/channels/layout.py +++ b/mne/channels/layout.py @@ -1208,7 +1208,7 @@ def _merge_opm_data(data, merged_names): """ to_remove = np.empty(0, dtype=np.int32) for idx, ch in enumerate(merged_names): - if ch.endswith("MERGE_REMOVE"): + 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): From 798d7305188161227b734e8322bd6ab7500d44aa Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 28 Apr 2025 11:02:11 -0400 Subject: [PATCH 21/28] add changelog --- doc/changes/devel/13144.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/devel/13144.newfeature.rst diff --git a/doc/changes/devel/13144.newfeature.rst b/doc/changes/devel/13144.newfeature.rst new file mode 100644 index 00000000000..84329ad3cb3 --- /dev/null +++ b/doc/changes/devel/13144.newfeature.rst @@ -0,0 +1 @@ +Allow for :func:`mne.viz.topomap` plotting of optically pumped MEG (OPM) sensors with overlapping channel locations. When channel locations overlap, plot the most radially oriented channel. By `Harrison Ritz`_. \ No newline at end of file From 7743397f315dbc6032bcbaf48f460299815e390d Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 28 Apr 2025 12:04:20 -0400 Subject: [PATCH 22/28] update docs, include topo in opm preproc tutorial --- doc/changes/devel/13144.newfeature.rst | 2 +- doc/changes/names.inc | 1 + tutorials/preprocessing/80_opm_processing.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/changes/devel/13144.newfeature.rst b/doc/changes/devel/13144.newfeature.rst index 84329ad3cb3..9621c84efb4 100644 --- a/doc/changes/devel/13144.newfeature.rst +++ b/doc/changes/devel/13144.newfeature.rst @@ -1 +1 @@ -Allow for :func:`mne.viz.topomap` plotting of optically pumped MEG (OPM) sensors with overlapping channel locations. When channel locations overlap, plot the most radially oriented channel. By `Harrison Ritz`_. \ No newline at end of file +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 ad162ee8f68..0574faa7e20 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/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 From 972e24d27a7201a426b2424cd7b53d6f1f8674ee Mon Sep 17 00:00:00 2001 From: Harrison Ritz Date: Mon, 28 Apr 2025 14:14:02 -0400 Subject: [PATCH 23/28] literal instead of link --- doc/changes/devel/13144.newfeature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/devel/13144.newfeature.rst b/doc/changes/devel/13144.newfeature.rst index 9621c84efb4..3e05c5c6c06 100644 --- a/doc/changes/devel/13144.newfeature.rst +++ b/doc/changes/devel/13144.newfeature.rst @@ -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`. \ No newline at end of file +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 From 6bbc071d1eeaa9e2733ccf610bc6c15803a54e17 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 29 Apr 2025 14:19:31 -0400 Subject: [PATCH 24/28] Apply suggestions from code review --- mne/viz/topomap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 1e75a1b8ea4..e81c7fc1cba 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -292,12 +292,12 @@ def _find_radial_channel(info, overlapping_set): 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 = 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) + 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)] From 13387a40c9da21baa7efbddc39db2452d122667d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 29 Apr 2025 18:19:50 +0000 Subject: [PATCH 25/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/topomap.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index e81c7fc1cba..375b7256957 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -297,7 +297,9 @@ def _find_radial_channel(info, overlapping_set): 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) + 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)] From 988e8f66b7a7e74184378a7528f6152da89098a0 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 29 Apr 2025 14:28:10 -0400 Subject: [PATCH 26/28] MAINT: Smoke test --- mne/viz/tests/test_topomap.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 64a13b46006..4ef6c1f4ba4 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -789,6 +789,11 @@ def test_plot_topomap_opm(): times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False ) assert len(fig_evoked.axes) == 5 + # smoke test for gh-12934 + ica = ICA(max_iter=1, random_state=0) + with pytest.warns(Warning, match="did not converge"): + ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error") + ica.plot_components() def test_plot_topomap_nirs_overlap(fnirs_epochs): From f073d0ca392cec74ab685496b65f98aa6a6a43c4 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 29 Apr 2025 14:36:07 -0400 Subject: [PATCH 27/28] FIX: Test --- mne/preprocessing/tests/test_ica.py | 1 - mne/viz/tests/test_topomap.py | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) 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_topomap.py b/mne/viz/tests/test_topomap.py index 4ef6c1f4ba4..f44ab433aff 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -789,11 +789,18 @@ def test_plot_topomap_opm(): times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False ) assert len(fig_evoked.axes) == 5 - # smoke test for gh-12934 - ica = ICA(max_iter=1, random_state=0) - with pytest.warns(Warning, match="did not converge"): - ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error") - ica.plot_components() + + +@pytest.mark.slowtest +@pytest.mark.filterwarnings("ignore:.*did not converge.*:") +def test_plot_components_opm(): + """Test for gh-12934.""" + pytest.importorskip("sklearn") + 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 def test_plot_topomap_nirs_overlap(fnirs_epochs): From 723809d3aa4960783a93128ef30ed281f000e6ef Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 29 Apr 2025 14:39:32 -0400 Subject: [PATCH 28/28] FIX: Scope --- mne/viz/tests/test_ica.py | 19 ++++++++++++++++++- mne/viz/tests/test_topomap.py | 12 ------------ 2 files changed, 18 insertions(+), 13 deletions(-) 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 f44ab433aff..64a13b46006 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -791,18 +791,6 @@ def test_plot_topomap_opm(): assert len(fig_evoked.axes) == 5 -@pytest.mark.slowtest -@pytest.mark.filterwarnings("ignore:.*did not converge.*:") -def test_plot_components_opm(): - """Test for gh-12934.""" - pytest.importorskip("sklearn") - 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 - - 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()