Skip to content

Commit 2434a0e

Browse files
committed
Merge branch 'xfit' of github.com:wmvanvliet/mne-python into xfit
2 parents 1d7f451 + 0a4c76a commit 2434a0e

12 files changed

Lines changed: 604 additions & 157 deletions

File tree

doc/sphinxext/mne_doc_utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ def reset_modules(gallery_conf, fname, when):
144144
"""Do the reset."""
145145
import matplotlib.pyplot as plt
146146

147+
# Examples that set ``# sphinx_gallery_preserve_gui = True`` keep a single GUI open
148+
# across all of their code blocks, and the scraper (not the example's globals, which
149+
# sphinx-gallery has already dropped by the time we get here with when="after")
150+
# holds the last reference to it. Close them before the leak checks below.
151+
gui_scraper.close_preserved()
152+
147153
mne.viz.set_3d_backend("pyvistaqt")
148154
pyvista.OFF_SCREEN = False
149155
pyvista.BUILDING_GALLERY = True

mne/commands/mne_dipolefit.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,10 @@ def run():
124124
stc = op.expanduser(stc)
125125

126126
# Condition can be specified as integer index or string comment.
127-
if options.condition is not None:
128-
try:
129-
condition = int(options.condition)
130-
except ValueError:
131-
condition = options.condition
132-
else:
133-
condition = None
127+
try:
128+
condition = int(options.condition)
129+
except ValueError:
130+
condition = options.condition
134131
evoked = mne.read_evokeds(args[0], condition=condition)
135132

136133
# Parse the baseline time period

mne/commands/tests/test_commands.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,51 @@ def test_anonymize(tmp_path):
622622
assert info["meas_date"] == _stamp_to_dt((946684800, 0))
623623

624624

625-
def test_dipolefit():
625+
def test_dipolefit(monkeypatch):
626626
"""Test mne dipolefit."""
627627
check_usage(mne_dipolefit)
628+
# Don't open the GUI, just check that the arguments are passed along correctly.
629+
kwargs = dict()
630+
monkeypatch.setattr(mne.gui, "dipolefit", lambda **kw: kwargs.update(kw))
631+
ave_fname = op.join(base_dir, "test-ave.fif")
632+
args = (
633+
ave_fname,
634+
"--condition=Right Auditory",
635+
"--baseline=-0.2,0",
636+
"--channel-type=meg",
637+
"--initial-time=0.1",
638+
"--hide-density",
639+
"--subject=fake",
640+
"--subjects-dir=~/fake-subjects",
641+
"--bem=~/fake-bem-sol.fif",
642+
"--trans=~/fake-trans.fif",
643+
"--stc=~/fake-stc",
644+
)
645+
with ArgvSetter(args):
646+
mne_dipolefit.run()
647+
assert kwargs["evoked"].comment == "Right Auditory"
648+
assert kwargs["baseline"] == [-0.2, 0]
649+
assert kwargs["ch_type"] == "meg"
650+
assert kwargs["initial_time"] == 0.1
651+
assert kwargs["show_density"] is False
652+
assert kwargs["subject"] == "fake"
653+
for key, val in dict(
654+
subjects_dir="~/fake-subjects",
655+
bem="~/fake-bem-sol.fif",
656+
trans="~/fake-trans.fif",
657+
stc="~/fake-stc",
658+
).items():
659+
assert kwargs[key] == op.expanduser(val) # "~" gets expanded
660+
661+
# The condition can also be given as an index (the default being the first one).
662+
with ArgvSetter((ave_fname,)):
663+
mne_dipolefit.run()
664+
assert kwargs["evoked"].comment == "Left Auditory"
665+
assert kwargs["baseline"] is None
666+
assert kwargs["show_density"] is True
667+
assert kwargs["bem"] is kwargs["trans"] is kwargs["stc"] is None
668+
669+
# The baseline needs to be two comma-separated numbers.
670+
with ArgvSetter((ave_fname, "--baseline=0")):
671+
with pytest.raises(ValueError, match="two numbers"):
672+
mne_dipolefit.run()

mne/gui/_dipolefit.py

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
import pyvista
1111

1212
from .._fiff.pick import pick_types
13-
from ..bem import ConductorModel, _ensure_bem_surfaces, make_sphere_model
13+
from ..bem import (
14+
ConductorModel,
15+
_ensure_bem_surfaces,
16+
make_sphere_model,
17+
read_bem_solution,
18+
)
1419
from ..cov import _ensure_cov, make_ad_hoc_cov
1520
from ..dipole import Dipole, fit_dipole
1621
from ..evoked import Evoked
@@ -36,7 +41,7 @@
3641
from ..viz import EvokedField, create_3d_figure
3742
from ..viz._3d import _plot_head_surface, _plot_sensors_3d
3843
from ..viz.backends._utils import _qt_app_exec
39-
from ..viz.ui_events import ChannelsSelect, link, publish, subscribe
44+
from ..viz.ui_events import ChannelsSelect, TimeChange, link, publish, subscribe
4045
from ..viz.utils import _get_color_list
4146

4247

@@ -53,8 +58,9 @@ class DipoleFitUI:
5358
Noise covariance matrix. If ``None``, an ad-hoc covariance matrix is used with
5459
default values for the diagonal elements (see Notes). If ``"baseline"``, the
5560
diagonal elements is estimated from the baseline period of the evoked data.
56-
bem : instance of ConductorModel | None
57-
Boundary element model to use in forward calculations. If ``None``, a spherical
61+
bem : instance of ConductorModel | path-like | None
62+
Boundary element model to use in forward calculations, or a path to the BEM
63+
solution file (``"-bem-sol.fif"``) to read it from. If ``None``, a spherical
5864
model is used.
5965
initial_time : float | None
6066
Initial time point to show. If ``None``, the time point of the maximum field
@@ -115,12 +121,18 @@ def __init__(
115121
verbose=None,
116122
):
117123
_validate_type(evoked, Evoked, "evoked")
118-
evoked.apply_baseline(baseline)
124+
if baseline is not None:
125+
evoked = evoked.copy().apply_baseline(baseline)
119126

120127
if cov is None:
121128
logger.info("Using ad-hoc noise covariance.")
122129
cov = make_ad_hoc_cov(evoked.info)
123130
elif cov == "baseline":
131+
if evoked.baseline is None:
132+
raise ValueError(
133+
'cov="baseline" requires baseline-corrected data. Set the '
134+
"baseline parameter or baseline-correct the evoked data first."
135+
)
124136
logger.info(
125137
f"Estimating noise covariance from baseline ({evoked.baseline[0]:.3f} "
126138
f"to {evoked.baseline[1]:.3f} seconds)."
@@ -133,9 +145,13 @@ def __init__(
133145
else:
134146
cov = _ensure_cov(cov)
135147

148+
_validate_type(bem, ("path-like", ConductorModel, None), "bem")
136149
if bem is None:
137150
bem = make_sphere_model("auto", "auto", evoked.info)
138-
bem = _ensure_bem_surfaces(bem, extra_allow=(ConductorModel, None))
151+
elif not isinstance(bem, ConductorModel):
152+
# a path means a BEM solution file (cf. _make_forward._setup_bem)
153+
bem = read_bem_solution(bem)
154+
bem = _ensure_bem_surfaces(bem, extra_allow=(ConductorModel,))
139155

140156
if ch_type is not None:
141157
evoked = evoked.copy().pick(ch_type)
@@ -196,6 +212,7 @@ def __init__(
196212
self._current_time = initial_time
197213
self._dipoles = dict()
198214
self._evoked = evoked
215+
self._helmet_surf = None
199216
self._surf_maps = surf_maps
200217
self._fig_sensors = None
201218
self._multi_dipole_method = "Multi dipole (MNE)"
@@ -272,9 +289,10 @@ def _configure_main_display(self, show_sensors=True, show=True):
272289
for surf_map in fig_ef._surf_maps:
273290
if surf_map["map_kind"] == "meg":
274291
helmet_mesh = surf_map["mesh"]
275-
helmet_mesh._polydata.compute_normals() # needed later
276292
helmet_mesh._actor.prop.culling = "back"
277293
self._actors["helmet"] = helmet_mesh._actor
294+
# needed later to draw the big arrows on the helmet
295+
self._helmet_surf = surf_map["surf"]
278296
# For MEG fieldlines, we want to occlude the ones not facing us,
279297
# otherwise it's hard to interpret them. Since the "contours" object
280298
# does not support backface culling, we create an opaque mesh to put in
@@ -287,7 +305,6 @@ def _configure_main_display(self, show_sensors=True, show=True):
287305
self._actors["occlusion_surf"] = occl_act
288306
elif surf_map["map_kind"] == "eeg":
289307
head_mesh = surf_map["mesh"]
290-
head_mesh._polydata.compute_normals() # needed later
291308
head_mesh._actor.prop.culling = "back"
292309
self._actors["head"] = head_mesh._actor
293310

@@ -378,7 +395,7 @@ def _toggle_mesh(_, name, show=None):
378395
# Right dock
379396
r._dock_initialize(name="Dipole fitting", area="right")
380397
r._dock_add_button("Sensor data", self._on_sensor_data)
381-
r._dock_add_button("Fit dipole", self._on_fit_dipole)
398+
r._dock_add_button("Fit dipole", self.fit_dipole)
382399
methods = ["Multi dipole (MNE)", "Single dipole"]
383400

384401
@_auto_weakref
@@ -431,6 +448,20 @@ def toggle_mesh(self, name, show=None):
431448
act.SetVisibility(show)
432449
self._renderer._update()
433450

451+
def set_time(self, time):
452+
"""Set the time point currently shown in the GUI.
453+
454+
This is the programmatic equivalent of dragging the time slider, and is also the
455+
time at which :meth:`fit_dipole` will fit a dipole.
456+
457+
Parameters
458+
----------
459+
time : float
460+
The time to show, in seconds. Values outside the time range of the evoked
461+
data are clipped to the nearest valid time.
462+
"""
463+
publish(self._fig, TimeChange(time=float(time)))
464+
434465
def _on_time_change(self, event):
435466
new_time = np.clip(event.time, self._evoked.times[0], self._evoked.times[-1])
436467
self._current_time = new_time
@@ -440,6 +471,8 @@ def _on_time_change(self, event):
440471
self._renderer._mplcanvas.update_plot()
441472
self._update_arrows()
442473

474+
# TODO: Need to expose a public method for opening the sensor-data window and for
475+
# programmatically selecting the channels to fit dipoles to.
443476
def _on_sensor_data(self):
444477
"""Show sensor data and allow sensor selection."""
445478
if self._fig_sensors is not None:
@@ -470,8 +503,15 @@ def _on_channels_select(self, event):
470503
cloud.point_data["colors"] = colors
471504
self._renderer._update()
472505

473-
def _on_fit_dipole(self):
474-
"""Fit a single dipole."""
506+
def fit_dipole(self):
507+
"""Fit a single dipole and add it to the model.
508+
509+
This is the programmatic equivalent of pressing the "Fit dipole" button. The
510+
dipole is fitted at the time currently shown in the GUI (see :meth:`set_time`),
511+
using the sensors that are currently selected in the sensor data window (or all
512+
sensors when no selection is active). The newly fitted dipole is appended to the
513+
:attr:`dipoles` attribute.
514+
"""
475515
evoked_picked = self._evoked.copy()
476516
cov_picked = self._cov.copy()
477517
if self._fig_sensors is not None:
@@ -657,11 +697,8 @@ def _get_helmet_coords(self, dip):
657697

658698
# Get the closest vertex (=point) of the helmet mesh
659699
dip_pos = apply_trans(self._head_mri_t, dip.pos[0])
660-
helmet = self._actors["helmet"].GetMapper().GetInput()
661-
if helmet.points is None:
662-
raise ValueError("why is this happening?")
663-
points = np.array(helmet.points.data)
664-
normals = np.array(helmet.point_data.normals)
700+
points = self._helmet_surf["rr"]
701+
normals = self._helmet_surf["nn"]
665702
distances = ((points - dip_pos) * normals).sum(axis=1)
666703
closest_point = np.argmin(distances)
667704

@@ -686,6 +723,9 @@ def _fit_timecourses(self):
686723
return
687724

688725
if self._multi_dipole_method == "Multi dipole (MNE)":
726+
# TODO: When two active dipoles have (nearly) identical positions, they
727+
# collapse to a single point in the discrete source space below, which
728+
# errors out. Ideal behavior unclear: merge them, or error informatively?
689729
this_src = setup_volume_source_space(
690730
"sample",
691731
pos=dict(
@@ -857,11 +897,14 @@ def _update_arrows(self):
857897
arrow_mesh.points += dip["helmet_pos"]
858898
self._renderer._update()
859899

900+
# TODO: Need to expose a public method for setting the multi-dipole method
860901
def _on_select_method(self, method):
861902
"""Select the method to use for multi-dipole timecourse fitting."""
862903
self._multi_dipole_method = method
863904
self._fit_timecourses()
864905

906+
# TODO: Need to expose public methods for toggling, renaming, (un)fixing the
907+
# orientation of, and deleting a dipole (probably addressed by name or index).
865908
def _on_dipole_toggle(self, active, dip_num):
866909
"""Toggle a dipole on or off."""
867910
dipole = self._dipoles[dip_num]

0 commit comments

Comments
 (0)