1010import pyvista
1111
1212from .._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+ )
1419from ..cov import _ensure_cov , make_ad_hoc_cov
1520from ..dipole import Dipole , fit_dipole
1621from ..evoked import Evoked
3641from ..viz import EvokedField , create_3d_figure
3742from ..viz ._3d import _plot_head_surface , _plot_sensors_3d
3843from ..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
4045from ..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