"""Single-dipole functions and classes.""" # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. import functools import re from copy import deepcopy from functools import partial import numpy as np from scipy.linalg import eigh from scipy.optimize import fmin_cobyla from ._fiff.constants import FIFF from ._fiff.pick import pick_types from ._fiff.proj import _needs_eeg_average_ref_proj, make_projector from ._freesurfer import _get_aseg, head_to_mni, head_to_mri, read_freesurfer_lut from .bem import _bem_find_surface, _bem_surf_name, _fit_sphere from .cov import _ensure_cov, compute_whitener from .evoked import _aspect_rev, _read_evoked, _write_evokeds from .fixes import _safe_svd from .forward._compute_forward import _compute_forwards_meeg, _prep_field_computation from .forward._make_forward import ( _get_trans, _prep_eeg_channels, _prep_meg_channels, _setup_bem, ) from .parallel import parallel_func from .source_space._source_space import SourceSpaces, _make_volume_source_space from .surface import _compute_nearest, _points_outside_surface, transform_surface_to from .transforms import _coord_frame_name, _print_coord_trans, apply_trans from .utils import ( ExtendedTimeMixin, TimeMixin, _check_fname, _check_option, _get_blas_funcs, _pl, _repeated_svd, _svd_lwork, _time_mask, _validate_type, _verbose_safe_false, check_fname, copy_function_doc_to_method_doc, fill_doc, logger, pinvh, verbose, warn, ) from .viz import plot_dipole_amplitudes, plot_dipole_locations from .viz.evoked import _plot_evoked @fill_doc class Dipole(TimeMixin): """Dipole class for sequential dipole fits. .. note:: This class should usually not be instantiated directly via ``mne.Dipole(...)``. Instead, use one of the functions listed in the See Also section below. Used to store positions, orientations, amplitudes, times, goodness of fit of dipoles, typically obtained with Neuromag/xfit, mne_dipole_fit or certain inverse solvers. Note that dipole position vectors are given in the head coordinate frame. Parameters ---------- times : array, shape (n_dipoles,) The time instants at which each dipole was fitted (s). pos : array, shape (n_dipoles, 3) The dipoles positions (m) in head coordinates. amplitude : array, shape (n_dipoles,) The amplitude of the dipoles (Am). ori : array, shape (n_dipoles, 3) The dipole orientations (normalized to unit length). gof : array, shape (n_dipoles,) The goodness of fit. name : str | None Name of the dipole. conf : dict Confidence limits in dipole orientation for "vol" in m^3 (volume), "depth" in m (along the depth axis), "long" in m (longitudinal axis), "trans" in m (transverse axis), "qlong" in Am, and "qtrans" in Am (currents). The current confidence limit in the depth direction is assumed to be zero (although it can be non-zero when a BEM is used). .. versionadded:: 0.15 khi2 : array, shape (n_dipoles,) The χ^2 values for the fits. .. versionadded:: 0.15 nfree : array, shape (n_dipoles,) The number of free parameters for each fit. .. versionadded:: 0.15 %(verbose)s See Also -------- fit_dipole DipoleFixed read_dipole Notes ----- This class is for sequential dipole fits, where the position changes as a function of time. For fixed dipole fits, where the position is fixed as a function of time, use :class:`mne.DipoleFixed`. """ @verbose def __init__( self, times, pos, amplitude, ori, gof, name=None, conf=None, khi2=None, nfree=None, *, verbose=None, ): self._set_times(np.array(times)) self.pos = np.array(pos) self.amplitude = np.array(amplitude) self.ori = np.array(ori) self.gof = np.array(gof) self.name = name self.conf = dict() if conf is not None: for key, value in conf.items(): self.conf[key] = np.array(value) self.khi2 = np.array(khi2) if khi2 is not None else None self.nfree = np.array(nfree) if nfree is not None else None def __repr__(self): # noqa: D105 s = f"n_times : {len(self.times)}" s += f", tmin : {np.min(self.times):0.3f}" s += f", tmax : {np.max(self.times):0.3f}" return f"" @verbose def save(self, fname, overwrite=False, *, verbose=None): """Save dipole in a ``.dip`` or ``.bdip`` file. Parameters ---------- fname : path-like The name of the ``.dip`` or ``.bdip`` file. %(overwrite)s .. versionadded:: 0.20 %(verbose)s Notes ----- .. versionchanged:: 0.20 Support for writing bdip (Xfit binary) files. """ # obligatory fields fname = _check_fname(fname, overwrite=overwrite) if fname.suffix == ".bdip": _write_dipole_bdip(fname, self) else: _write_dipole_text(fname, self) @verbose def crop(self, tmin=None, tmax=None, include_tmax=True, verbose=None): """Crop data to a given time interval. Parameters ---------- tmin : float | None Start time of selection in seconds. tmax : float | None End time of selection in seconds. %(include_tmax)s %(verbose)s Returns ------- self : instance of Dipole The cropped instance. """ sfreq = None if len(self.times) > 1: sfreq = 1.0 / np.median(np.diff(self.times)) mask = _time_mask( self.times, tmin, tmax, sfreq=sfreq, include_tmax=include_tmax ) self._set_times(self.times[mask]) for attr in ("pos", "gof", "amplitude", "ori", "khi2", "nfree"): if getattr(self, attr) is not None: setattr(self, attr, getattr(self, attr)[mask]) for key in self.conf.keys(): self.conf[key] = self.conf[key][mask] return self def copy(self): """Copy the Dipoles object. Returns ------- dip : instance of Dipole The copied dipole instance. """ return deepcopy(self) @verbose @copy_function_doc_to_method_doc(plot_dipole_locations) def plot_locations( self, trans, subject, subjects_dir=None, mode="orthoview", coord_frame="mri", idx="gof", show_all=True, ax=None, block=False, show=True, scale=None, color=None, *, highlight_color="r", fig=None, title=None, head_source="seghead", surf="pial", width=None, verbose=None, ): return plot_dipole_locations( self, trans, subject, subjects_dir, mode, coord_frame, idx, show_all, ax, block, show, scale=scale, color=color, highlight_color=highlight_color, fig=fig, title=title, head_source=head_source, surf=surf, width=width, ) @verbose def to_mni(self, subject, trans, subjects_dir=None, verbose=None): """Convert dipole location from head to MNI coordinates. Parameters ---------- %(subject)s %(trans_not_none)s %(subjects_dir)s %(verbose)s Returns ------- pos_mni : array, shape (n_pos, 3) The MNI coordinates (in mm) of pos. """ mri_head_t, trans = _get_trans(trans) return head_to_mni( self.pos, subject, mri_head_t, subjects_dir=subjects_dir, verbose=verbose ) @verbose def to_mri(self, subject, trans, subjects_dir=None, verbose=None): """Convert dipole location from head to MRI surface RAS coordinates. Parameters ---------- %(subject)s %(trans_not_none)s %(subjects_dir)s %(verbose)s Returns ------- pos_mri : array, shape (n_pos, 3) The Freesurfer surface RAS coordinates (in mm) of pos. """ mri_head_t, trans = _get_trans(trans) return head_to_mri( self.pos, subject, mri_head_t, subjects_dir=subjects_dir, verbose=verbose, kind="mri", ) @verbose def to_volume_labels( self, trans, subject="fsaverage", aseg="aparc+aseg", subjects_dir=None, verbose=None, ): """Find an ROI in atlas for the dipole positions. Parameters ---------- %(trans)s .. versionchanged:: 0.19 Support for 'fsaverage' argument. %(subject)s %(aseg)s %(subjects_dir)s %(verbose)s Returns ------- labels : list List of anatomical region names from anatomical segmentation atlas. Notes ----- .. versionadded:: 0.24 """ aseg_img, aseg_data = _get_aseg(aseg, subject, subjects_dir) mri_vox_t = np.linalg.inv(aseg_img.header.get_vox2ras_tkr()) # Load freesurface atlas LUT lut_inv = read_freesurfer_lut()[0] lut = {v: k for k, v in lut_inv.items()} # transform to voxel space from head space pos = self.to_mri(subject, trans, subjects_dir=subjects_dir, verbose=verbose) pos = apply_trans(mri_vox_t, pos) pos = np.rint(pos).astype(int) # Get voxel value and label from LUT labels = [lut.get(aseg_data[tuple(coord)], "Unknown") for coord in pos] return labels def plot_amplitudes(self, color="k", show=True): """Plot the dipole amplitudes as a function of time. Parameters ---------- color : matplotlib color Color to use for the trace. show : bool Show figure if True. Returns ------- fig : matplotlib.figure.Figure The figure object containing the plot. """ return plot_dipole_amplitudes([self], [color], show) def __getitem__(self, item): """Get a time slice. Parameters ---------- item : array-like or slice The slice of time points to use. Returns ------- dip : instance of Dipole The sliced dipole. """ if isinstance(item, int): # make sure attributes stay 2d item = [item] selected_times = self.times[item].copy() selected_pos = self.pos[item, :].copy() selected_amplitude = self.amplitude[item].copy() selected_ori = self.ori[item, :].copy() selected_gof = self.gof[item].copy() selected_name = self.name selected_conf = dict() for key in self.conf.keys(): selected_conf[key] = self.conf[key][item] selected_khi2 = self.khi2[item] if self.khi2 is not None else None selected_nfree = self.nfree[item] if self.nfree is not None else None return Dipole( selected_times, selected_pos, selected_amplitude, selected_ori, selected_gof, selected_name, selected_conf, selected_khi2, selected_nfree, ) def __len__(self): """Return the number of dipoles. Returns ------- len : int The number of dipoles. Examples -------- This can be used as:: >>> len(dipoles) # doctest: +SKIP 10 """ return self.pos.shape[0] def _read_dipole_fixed(fname): """Read a fixed dipole FIF file.""" logger.info(f"Reading {fname} ...") info, nave, aspect_kind, comment, times, data, _ = _read_evoked(fname) return DipoleFixed(info, data, times, nave, aspect_kind, comment=comment) @fill_doc class DipoleFixed(ExtendedTimeMixin): """Dipole class for fixed-position dipole fits. .. note:: This class should usually not be instantiated directly via ``mne.DipoleFixed(...)``. Instead, use one of the functions listed in the See Also section below. Parameters ---------- %(info_not_none)s data : array, shape (n_channels, n_times) The dipole data. times : array, shape (n_times,) The time points. nave : int Number of averages. aspect_kind : int The kind of data. comment : str The dipole comment. %(verbose)s See Also -------- read_dipole Dipole fit_dipole Notes ----- This class is for fixed-position dipole fits, where the position (and maybe orientation) is static over time. For sequential dipole fits, where the position can change a function of time, use :class:`mne.Dipole`. .. versionadded:: 0.12 """ @verbose def __init__( self, info, data, times, nave, aspect_kind, comment="", *, verbose=None ): self.info = info self.nave = nave self._aspect_kind = aspect_kind self.kind = _aspect_rev.get(aspect_kind, "unknown") self.comment = comment self._set_times(np.array(times)) self.data = data self.preload = True self._update_first_last() def __repr__(self): # noqa: D105 s = f"n_times : {len(self.times)}" s += f", tmin : {np.min(self.times)}" s += f", tmax : {np.max(self.times)}" return f"" def copy(self): """Copy the DipoleFixed object. Returns ------- inst : instance of DipoleFixed The copy. Notes ----- .. versionadded:: 0.16 """ return deepcopy(self) @property def ch_names(self): """Channel names.""" return self.info["ch_names"] @verbose def save(self, fname, verbose=None): """Save dipole in a .fif file. Parameters ---------- fname : path-like The name of the .fif file. Must end with ``'.fif'`` or ``'.fif.gz'`` to make it explicit that the file contains dipole information in FIF format. %(verbose)s """ check_fname( fname, "DipoleFixed", ( "-dip.fif", "-dip.fif.gz", "_dip.fif", "_dip.fif.gz", ), (".fif", ".fif.gz"), ) _write_evokeds(fname, self, check=False) def plot(self, show=True, time_unit="s"): """Plot dipole data. Parameters ---------- show : bool Call pyplot.show() at the end or not. time_unit : str The units for the time axis, can be "ms" or "s" (default). .. versionadded:: 0.16 Returns ------- fig : instance of matplotlib.figure.Figure The figure containing the time courses. """ return _plot_evoked( self, picks=None, exclude=(), unit=True, show=show, ylim=None, xlim="tight", proj=False, hline=None, units=None, scalings=None, titles=None, axes=None, gfp=False, window_title=None, spatial_colors=False, plot_type="butterfly", selectable=False, time_unit=time_unit, ) # ############################################################################# # IO @verbose def read_dipole(fname, verbose=None): """Read ``.dip`` file from Neuromag/xfit or MNE. Parameters ---------- fname : path-like The name of the ``.dip`` or ``.fif`` file. %(verbose)s Returns ------- %(dipole)s See Also -------- Dipole DipoleFixed fit_dipole Notes ----- .. versionchanged:: 0.20 Support for reading bdip (Xfit binary) format. """ fname = _check_fname(fname, overwrite="read", must_exist=True) if fname.suffix == ".fif" or fname.name.endswith(".fif.gz"): return _read_dipole_fixed(fname) elif fname.suffix == ".bdip": return _read_dipole_bdip(fname) else: return _read_dipole_text(fname) def _read_dipole_text(fname): """Read a dipole text file.""" # Figure out the special fields need_header = True def_line = name = None # There is a bug in older np.loadtxt regarding skipping fields, # so just read the data ourselves (need to get name and header anyway) data = list() with open(fname) as fid: for line in fid: if not (line.startswith("%") or line.startswith("#")): need_header = False data.append(line.strip().split()) else: if need_header: def_line = line if line.startswith("##") or line.startswith("%%"): m = re.search('Name "(.*) dipoles"', line) if m: name = m.group(1) del line data = np.atleast_2d(np.array(data, float)) if def_line is None: raise OSError( "Dipole text file is missing field definition comment, cannot parse " f"{fname}" ) # actually parse the fields def_line = def_line.lstrip("%").lstrip("#").strip() # MNE writes it out differently than Elekta, let's standardize them... fields = re.sub( r"([X|Y|Z] )\(mm\)", # "X (mm)", etc. lambda match: match.group(1).strip() + "/mm", def_line, ) fields = re.sub( r"\((.*?)\)", lambda match: "/" + match.group(1), fields, # "Q(nAm)", etc. ) fields = re.sub( "(begin|end) ", # "begin" and "end" with no units lambda match: match.group(1) + "/ms", fields, ) fields = fields.lower().split() required_fields = ( "begin/ms", "x/mm", "y/mm", "z/mm", "q/nam", "qx/nam", "qy/nam", "qz/nam", "g/%", ) optional_fields = ( "khi^2", "free", # standard ones # now the confidence fields (up to 5!) "vol/mm^3", "depth/mm", "long/mm", "trans/mm", "qlong/nam", "qtrans/nam", ) conf_scales = [1e-9, 1e-3, 1e-3, 1e-3, 1e-9, 1e-9] missing_fields = sorted(set(required_fields) - set(fields)) if len(missing_fields) > 0: raise RuntimeError( f"Could not find necessary fields in header: {missing_fields}" ) handled_fields = set(required_fields) | set(optional_fields) assert len(handled_fields) == len(required_fields) + len(optional_fields) ignored_fields = sorted(set(fields) - set(handled_fields) - {"end/ms"}) if len(ignored_fields) > 0: warn(f"Ignoring extra fields in dipole file: {ignored_fields}") if len(fields) != data.shape[1]: raise OSError( f"More data fields ({len(fields)}) found than data columns ({data.shape[1]}" f"): {fields}" ) logger.info(f"{len(data)} dipole(s) found") if "end/ms" in fields: if np.diff( data[:, [fields.index("begin/ms"), fields.index("end/ms")]], 1, -1 ).any(): warn( "begin and end fields differed, but only begin will be used " "to store time values" ) # Find the correct column in our data array, then scale to proper units idx = [fields.index(field) for field in required_fields] assert len(idx) >= 9 times = data[:, idx[0]] / 1000.0 pos = 1e-3 * data[:, idx[1:4]] # put data in meters amplitude = data[:, idx[4]] norm = amplitude.copy() amplitude /= 1e9 norm[norm == 0] = 1 ori = data[:, idx[5:8]] / norm[:, np.newaxis] gof = data[:, idx[8]] # Deal with optional fields optional = [None] * 2 for fi, field in enumerate(optional_fields[:2]): if field in fields: optional[fi] = data[:, fields.index(field)] khi2, nfree = optional conf = dict() for field, scale in zip(optional_fields[2:], conf_scales): # confidence if field in fields: conf[field.split("/")[0]] = scale * data[:, fields.index(field)] return Dipole(times, pos, amplitude, ori, gof, name, conf, khi2, nfree) def _write_dipole_text(fname, dip): fmt = " %7.1f %7.1f %8.2f %8.2f %8.2f %8.3f %8.3f %8.3f %8.3f %6.2f" header = ( "# begin end X (mm) Y (mm) Z (mm)" " Q(nAm) Qx(nAm) Qy(nAm) Qz(nAm) g/%" ) t = dip.times[:, np.newaxis] * 1000.0 gof = dip.gof[:, np.newaxis] amp = 1e9 * dip.amplitude[:, np.newaxis] out = (t, t, dip.pos / 1e-3, amp, dip.ori * amp, gof) # optional fields fmts = dict( khi2=(" khi^2", " %8.1f", 1.0), nfree=(" free", " %5d", 1), vol=(" vol/mm^3", " %9.3f", 1e9), depth=(" depth/mm", " %9.3f", 1e3), long=(" long/mm", " %8.3f", 1e3), trans=(" trans/mm", " %9.3f", 1e3), qlong=(" Qlong/nAm", " %10.3f", 1e9), qtrans=(" Qtrans/nAm", " %11.3f", 1e9), ) for key in ("khi2", "nfree"): data = getattr(dip, key) if data is not None: header += fmts[key][0] fmt += fmts[key][1] out += (data[:, np.newaxis] * fmts[key][2],) for key in ("vol", "depth", "long", "trans", "qlong", "qtrans"): data = dip.conf.get(key) if data is not None: header += fmts[key][0] fmt += fmts[key][1] out += (data[:, np.newaxis] * fmts[key][2],) out = np.concatenate(out, axis=-1) # NB CoordinateSystem is hard-coded as Head here with open(fname, "wb") as fid: fid.write(b'# CoordinateSystem "Head"\n') fid.write((header + "\n").encode("utf-8")) np.savetxt(fid, out, fmt=fmt) if dip.name is not None: fid.write((f'## Name "{dip.name} dipoles" Style "Dipoles"').encode()) _BDIP_ERROR_KEYS = ("depth", "long", "trans", "qlong", "qtrans") def _read_dipole_bdip(fname): name = None nfree = None with open(fname, "rb") as fid: # Which dipole in a multi-dipole set times = list() pos = list() amplitude = list() ori = list() gof = list() conf = dict(vol=list()) khi2 = list() has_errors = None while True: num = np.frombuffer(fid.read(4), ">i4") if len(num) == 0: break times.append(np.frombuffer(fid.read(4), ">f4")[0]) fid.read(4) # end fid.read(12) # r0 pos.append(np.frombuffer(fid.read(12), ">f4")) Q = np.frombuffer(fid.read(12), ">f4") amplitude.append(np.linalg.norm(Q)) ori.append(Q / amplitude[-1]) gof.append(100 * np.frombuffer(fid.read(4), ">f4")[0]) this_has_errors = bool(np.frombuffer(fid.read(4), ">i4")[0]) if has_errors is None: has_errors = this_has_errors for key in _BDIP_ERROR_KEYS: conf[key] = list() assert has_errors == this_has_errors fid.read(4) # Noise level used for error computations limits = np.frombuffer(fid.read(20), ">f4") # error limits for key, lim in zip(_BDIP_ERROR_KEYS, limits): conf[key].append(lim) fid.read(100) # (5, 5) fully describes the conf. ellipsoid conf["vol"].append(np.frombuffer(fid.read(4), ">f4")[0]) khi2.append(np.frombuffer(fid.read(4), ">f4")[0]) fid.read(4) # prob fid.read(4) # total noise estimate return Dipole(times, pos, amplitude, ori, gof, name, conf, khi2, nfree) def _write_dipole_bdip(fname, dip): with open(fname, "wb+") as fid: for ti, t in enumerate(dip.times): fid.write(np.zeros(1, ">i4").tobytes()) # int dipole fid.write(np.array([t, 0]).astype(">f4").tobytes()) fid.write(np.zeros(3, ">f4").tobytes()) # r0 fid.write(dip.pos[ti].astype(">f4").tobytes()) # pos Q = dip.amplitude[ti] * dip.ori[ti] fid.write(Q.astype(">f4").tobytes()) fid.write(np.array(dip.gof[ti] / 100.0, ">f4").tobytes()) has_errors = int(bool(len(dip.conf))) fid.write(np.array(has_errors, ">i4").tobytes()) # has_errors fid.write(np.zeros(1, ">f4").tobytes()) # noise level for key in _BDIP_ERROR_KEYS: val = dip.conf[key][ti] if key in dip.conf else 0.0 assert val.shape == () fid.write(np.array(val, ">f4").tobytes()) fid.write(np.zeros(25, ">f4").tobytes()) conf = dip.conf["vol"][ti] if "vol" in dip.conf else 0.0 fid.write(np.array(conf, ">f4").tobytes()) khi2 = dip.khi2[ti] if dip.khi2 is not None else 0 fid.write(np.array(khi2, ">f4").tobytes()) fid.write(np.zeros(1, ">f4").tobytes()) # prob fid.write(np.zeros(1, ">f4").tobytes()) # total noise est # ############################################################################# # Fitting def _dipole_forwards(*, sensors, fwd_data, whitener, rr, n_jobs=None): """Compute the forward solution and do other nice stuff.""" B = _compute_forwards_meeg( rr, sensors=sensors, fwd_data=fwd_data, n_jobs=n_jobs, silent=True ) B = np.concatenate(list(B.values()), axis=1) assert np.isfinite(B).all() B_orig = B.copy() # Apply projection and whiten (cov has projections already) _, _, dgemm = _get_ddot_dgemv_dgemm() B = dgemm(1.0, B, whitener.T) # column normalization doesn't affect our fitting, so skip for now # S = np.sum(B * B, axis=1) # across channels # scales = np.repeat(3. / np.sqrt(np.sum(np.reshape(S, (len(rr), 3)), # axis=1)), 3) # B *= scales[:, np.newaxis] scales = np.ones(3) return B, B_orig, scales @verbose def _make_guesses(surf, grid, exclude, mindist, n_jobs=None, verbose=None): """Make a guess space inside a sphere or BEM surface.""" if "rr" in surf: logger.info( "Guess surface ({}) is in {} coordinates".format( _bem_surf_name[surf["id"]], _coord_frame_name(surf["coord_frame"]) ) ) else: logger.info( "Making a spherical guess space with radius {:7.1f} mm...".format( 1000 * surf["R"] ) ) logger.info("Filtering (grid = %6.f mm)..." % (1000 * grid)) src = _make_volume_source_space( surf, grid, exclude, 1000 * mindist, do_neighbors=False, n_jobs=n_jobs )[0] assert "vertno" in src # simplify the result to make things easier later src = dict( rr=src["rr"][src["vertno"]], nn=src["nn"][src["vertno"]], nuse=src["nuse"], coord_frame=src["coord_frame"], vertno=np.arange(src["nuse"]), type="discrete", ) return SourceSpaces([src]) def _fit_eval(rd, B, B2, *, sensors, fwd_data, whitener, lwork, fwd_svd): """Calculate the residual sum of squares.""" if fwd_svd is None: assert sensors is not None fwd = _dipole_forwards( sensors=sensors, fwd_data=fwd_data, whitener=whitener, rr=rd[np.newaxis, :] )[0] uu, sing, vv = _repeated_svd(fwd, lwork, overwrite_a=True) else: uu, sing, vv = fwd_svd gof = _dipole_gof(uu, sing, vv, B, B2)[0] # mne-c uses fitness=B2-Bm2, but ours (1-gof) is just a normalized version return 1.0 - gof @functools.lru_cache(None) def _get_ddot_dgemv_dgemm(): return _get_blas_funcs(np.float64, ("dot", "gemv", "gemm")) def _dipole_gof(uu, sing, vv, B, B2): """Calculate the goodness of fit from the forward SVD.""" ddot, dgemv, _ = _get_ddot_dgemv_dgemm() ncomp = 3 if sing[2] / (sing[0] if sing[0] > 0 else 1.0) > 0.2 else 2 one = dgemv(1.0, vv[:ncomp], B) # np.dot(vv[:ncomp], B) Bm2 = ddot(one, one) # np.sum(one * one) gof = Bm2 / B2 return gof, one def _fit_Q(*, sensors, fwd_data, whitener, B, B2, B_orig, rd, ori=None): """Fit the dipole moment once the location is known.""" if "fwd" in fwd_data: # should be a single precomputed "guess" (i.e., fixed position) assert rd is None fwd = fwd_data["fwd"] assert fwd.shape[0] == 3 fwd_orig = fwd_data["fwd_orig"] assert fwd_orig.shape[0] == 3 scales = fwd_data["scales"] assert scales.shape == (3,) fwd_svd = fwd_data["fwd_svd"][0] else: fwd, fwd_orig, scales = _dipole_forwards( sensors=sensors, fwd_data=fwd_data, whitener=whitener, rr=rd[np.newaxis, :] ) fwd_svd = None if ori is None: if fwd_svd is None: fwd_svd = _safe_svd(fwd, full_matrices=False) uu, sing, vv = fwd_svd gof, one = _dipole_gof(uu, sing, vv, B, B2) ncomp = len(one) one /= sing[:ncomp] Q = np.dot(one, uu.T[:ncomp]) else: fwd = np.dot(ori[np.newaxis], fwd) sing = np.linalg.norm(fwd) one = np.dot(fwd / sing, B) gof = (one * one)[0] / B2 Q = ori * np.sum(one / sing) ncomp = 3 # Counteract the effect of column normalization Q *= scales[0] B_residual_noproj = B_orig - np.dot(fwd_orig.T, Q) return Q, gof, B_residual_noproj, ncomp def _fit_dipoles( fun, min_dist_to_inner_skull, data, times, guess_rrs, guess_data, *, sensors, fwd_data, whitener, ori, n_jobs, rank, rhoend, ): """Fit a single dipole to the given whitened, projected data.""" parallel, p_fun, n_jobs = parallel_func(fun, n_jobs) # parallel over time points res = parallel( p_fun( min_dist_to_inner_skull, B, t, guess_rrs, guess_data, sensors=sensors, fwd_data=fwd_data, whitener=whitener, fmin_cobyla=fmin_cobyla, ori=ori, rank=rank, rhoend=rhoend, ) for B, t in zip(data.T, times) ) pos = np.array([r[0] for r in res]) amp = np.array([r[1] for r in res]) ori = np.array([r[2] for r in res]) gof = np.array([r[3] for r in res]) * 100 # convert to percentage conf = None if res[0][4] is not None: conf = np.array([r[4] for r in res]) keys = ["vol", "depth", "long", "trans", "qlong", "qtrans"] conf = {key: conf[:, ki] for ki, key in enumerate(keys)} khi2 = np.array([r[5] for r in res]) nfree = np.array([r[6] for r in res]) residual_noproj = np.array([r[7] for r in res]).T return pos, amp, ori, gof, conf, khi2, nfree, residual_noproj '''Simplex code in case we ever want/need it for testing def _make_tetra_simplex(): """Make the initial tetrahedron""" # # For this definition of a regular tetrahedron, see # # http://mathworld.wolfram.com/Tetrahedron.html # x = np.sqrt(3.0) / 3.0 r = np.sqrt(6.0) / 12.0 R = 3 * r d = x / 2.0 simplex = 1e-2 * np.array([[x, 0.0, -r], [-d, 0.5, -r], [-d, -0.5, -r], [0., 0., R]]) return simplex def try_(p, y, psum, ndim, fun, ihi, neval, fac): """Helper to try a value""" ptry = np.empty(ndim) fac1 = (1.0 - fac) / ndim fac2 = fac1 - fac ptry = psum * fac1 - p[ihi] * fac2 ytry = fun(ptry) neval += 1 if ytry < y[ihi]: y[ihi] = ytry psum[:] += ptry - p[ihi] p[ihi] = ptry return ytry, neval def _simplex_minimize(p, ftol, stol, fun, max_eval=1000): """Minimization with the simplex algorithm Modified from Numerical recipes""" y = np.array([fun(s) for s in p]) ndim = p.shape[1] assert p.shape[0] == ndim + 1 mpts = ndim + 1 neval = 0 psum = p.sum(axis=0) loop = 1 while(True): ilo = 1 if y[1] > y[2]: ihi = 1 inhi = 2 else: ihi = 2 inhi = 1 for i in range(mpts): if y[i] < y[ilo]: ilo = i if y[i] > y[ihi]: inhi = ihi ihi = i elif y[i] > y[inhi]: if i != ihi: inhi = i rtol = 2 * np.abs(y[ihi] - y[ilo]) / (np.abs(y[ihi]) + np.abs(y[ilo])) if rtol < ftol: break if neval >= max_eval: raise RuntimeError('Maximum number of evaluations exceeded.') if stol > 0: # Has the simplex collapsed? dsum = np.sqrt(np.sum((p[ilo] - p[ihi]) ** 2)) if loop > 5 and dsum < stol: break ytry, neval = try_(p, y, psum, ndim, fun, ihi, neval, -1.) if ytry <= y[ilo]: ytry, neval = try_(p, y, psum, ndim, fun, ihi, neval, 2.) elif ytry >= y[inhi]: ysave = y[ihi] ytry, neval = try_(p, y, psum, ndim, fun, ihi, neval, 0.5) if ytry >= ysave: for i in range(mpts): if i != ilo: psum[:] = 0.5 * (p[i] + p[ilo]) p[i] = psum y[i] = fun(psum) neval += ndim psum = p.sum(axis=0) loop += 1 ''' def _fit_confidence(*, rd, Q, ori, whitener, fwd_data, sensors): # As describedd in the Xfit manual, confidence intervals can be calculated # by examining a linearization of model at the best-fitting location, # i.e. taking the Jacobian and using the whitener: # # J = [∂b/∂x ∂b/∂y ∂b/∂z ∂b/∂Qx ∂b/∂Qy ∂b/∂Qz] # C = (J.T C^-1 J)^-1 # # And then the confidence interval is the diagonal of C, scaled by 1.96 # (for 95% confidence). direction = np.empty((3, 3)) # The coordinate system has the x axis aligned with the dipole orientation, direction[0] = ori # the z axis through the origin of the sphere model rvec = rd - fwd_data["inner_skull"]["r0"] direction[2] = rvec - ori * np.dot(ori, rvec) # orthogonalize direction[2] /= np.linalg.norm(direction[2]) # and the y axis perpendical with these forming a right-handed system. direction[1] = np.cross(direction[2], direction[0]) assert np.allclose(np.dot(direction, direction.T), np.eye(3)) # Get spatial deltas in dipole coordinate directions deltas = (-1e-4, 1e-4) J = np.empty((whitener.shape[0], 6)) for ii in range(3): fwds = [] for delta in deltas: this_r = rd[np.newaxis] + delta * direction[ii] fwds.append( np.dot( Q, _dipole_forwards( sensors=sensors, fwd_data=fwd_data, whitener=whitener, rr=this_r )[0], ) ) J[:, ii] = np.diff(fwds, axis=0)[0] / np.diff(deltas)[0] # Get current (Q) deltas in the dipole directions deltas = np.array([-0.01, 0.01]) * np.linalg.norm(Q) this_fwd = _dipole_forwards( sensors=sensors, fwd_data=fwd_data, whitener=whitener, rr=rd[np.newaxis] )[0] for ii in range(3): fwds = [] for delta in deltas: fwds.append(np.dot(Q + delta * direction[ii], this_fwd)) J[:, ii + 3] = np.diff(fwds, axis=0)[0] / np.diff(deltas)[0] # J is already whitened, so we don't need to do np.dot(whitener, J). # However, the units in the Jacobian are potentially quite different, # so we need to do some normalization during inversion, then revert. direction_norm = np.linalg.norm(J[:, :3]) Q_norm = np.linalg.norm(J[:, 3:5]) # omit possible zero Z norm = np.array([direction_norm] * 3 + [Q_norm] * 3) J /= norm J = np.dot(J.T, J) C = pinvh(J, rtol=1e-14) C /= norm C /= norm[:, np.newaxis] conf = 1.96 * np.sqrt(np.diag(C)) # The confidence volume of the dipole location is obtained from by # taking the eigenvalues of the upper left submatrix and computing # v = 4π/3 √(c^3 λ1 λ2 λ3) with c = 7.81, or: vol_conf = ( 4 * np.pi / 3.0 * np.sqrt(476.379541 * np.prod(eigh(C[:3, :3], eigvals_only=True))) ) conf = np.concatenate([conf, [vol_conf]]) # Now we reorder and subselect the proper columns: # vol, depth, long, trans, Qlong, Qtrans (discard Qdepth, assumed zero) conf = conf[[6, 2, 0, 1, 3, 4]] return conf def _surface_constraint(rd, surf, min_dist_to_inner_skull): """Surface fitting constraint.""" dist = _compute_nearest(surf["rr"], rd[np.newaxis, :], return_dists=True)[1][0] if _points_outside_surface(rd[np.newaxis, :], surf, 1)[0]: dist *= -1.0 # Once we know the dipole is below the inner skull, # let's check if its distance to the inner skull is at least # min_dist_to_inner_skull. This can be enforced by adding a # constrain proportional to its distance. dist -= min_dist_to_inner_skull return dist def _sphere_constraint(rd, r0, R_adj): """Sphere fitting constraint.""" return R_adj - np.sqrt(np.sum((rd - r0) ** 2)) def _fit_dipole( min_dist_to_inner_skull, B_orig, t, guess_rrs, guess_data, *, sensors, fwd_data, whitener, fmin_cobyla, ori, rank, rhoend, ): """Fit a single bit of data.""" B = np.dot(whitener, B_orig) # make constraint function to keep the solver within the inner skull if "rr" in fwd_data["inner_skull"]: # bem surf = fwd_data["inner_skull"] constraint = partial( _surface_constraint, surf=surf, min_dist_to_inner_skull=min_dist_to_inner_skull, ) else: # sphere surf = None constraint = partial( _sphere_constraint, r0=fwd_data["inner_skull"]["r0"], R_adj=fwd_data["inner_skull"]["R"] - min_dist_to_inner_skull, ) # Find a good starting point (find_best_guess in C) B2 = np.dot(B, B) if B2 == 0: warn(f"Zero field found for time {t}") return np.zeros(3), 0, np.zeros(3), 0, B idx = np.argmin( [ _fit_eval( guess_rrs[[fi], :], B, B2, fwd_svd=fwd_svd, fwd_data=None, sensors=None, whitener=None, lwork=None, ) for fi, fwd_svd in enumerate(guess_data["fwd_svd"]) ] ) x0 = guess_rrs[idx] lwork = _svd_lwork((3, B.shape[0])) fun = partial( _fit_eval, B=B, B2=B2, fwd_data=fwd_data, whitener=whitener, lwork=lwork, sensors=sensors, fwd_svd=None, ) # Tested minimizers: # Simplex, BFGS, CG, COBYLA, L-BFGS-B, Powell, SLSQP, TNC # Several were similar, but COBYLA won for having a handy constraint # function we can use to ensure we stay inside the inner skull / # smallest sphere rd_final = fmin_cobyla( fun, x0, (constraint,), consargs=(), rhobeg=5e-2, rhoend=rhoend, disp=False ) # simplex = _make_tetra_simplex() + x0 # _simplex_minimize(simplex, 1e-4, 2e-4, fun) # rd_final = simplex[0] # Compute the dipole moment at the final point Q, gof, residual_noproj, n_comp = _fit_Q( sensors=sensors, fwd_data=fwd_data, whitener=whitener, B=B, B2=B2, B_orig=B_orig, rd=rd_final, ori=ori, ) khi2 = (1 - gof) * B2 nfree = rank - n_comp amp = np.sqrt(np.dot(Q, Q)) norm = 1.0 if amp == 0.0 else amp ori = Q / norm conf = _fit_confidence( sensors=sensors, rd=rd_final, Q=Q, ori=ori, whitener=whitener, fwd_data=fwd_data ) msg = "---- Fitted : %7.1f ms" % (1000.0 * t) if surf is not None: dist_to_inner_skull = _compute_nearest( surf["rr"], rd_final[np.newaxis, :], return_dists=True )[1][0] msg += ", distance to inner skull : %2.4f mm" % (dist_to_inner_skull * 1000.0) logger.info(msg) return rd_final, amp, ori, gof, conf, khi2, nfree, residual_noproj def _fit_dipole_fixed( min_dist_to_inner_skull, B_orig, t, guess_rrs, guess_data, *, sensors, fwd_data, whitener, fmin_cobyla, ori, rank, rhoend, ): """Fit a data using a fixed position.""" B = np.dot(whitener, B_orig) B2 = np.dot(B, B) if B2 == 0: warn(f"Zero field found for time {t}") return np.zeros(3), 0, np.zeros(3), 0, np.zeros(6) # Compute the dipole moment Q, gof, residual_noproj = _fit_Q( fwd_data=guess_data, whitener=whitener, B=B, B2=B2, B_orig=B_orig, sensors=sensors, rd=None, ori=ori, )[:3] if ori is None: amp = np.sqrt(np.dot(Q, Q)) norm = 1.0 if amp == 0.0 else amp ori = Q / norm else: amp = np.dot(Q, ori) rd_final = guess_rrs[0] # This will be slow, and we don't use it anyway, so omit it for now: # conf = _fit_confidence(rd_final, Q, ori, whitener, fwd_data) conf = khi2 = nfree = None # No corresponding 'logger' message here because it should go *very* fast return rd_final, amp, ori, gof, conf, khi2, nfree, residual_noproj @verbose def fit_dipole( evoked, cov, bem, trans=None, min_dist=5.0, n_jobs=None, pos=None, ori=None, rank=None, accuracy="normal", tol=5e-5, verbose=None, ): """Fit a dipole. Parameters ---------- evoked : instance of Evoked The dataset to fit. cov : str | instance of Covariance The noise covariance. bem : path-like | instance of ConductorModel The BEM filename (str) or conductor model. trans : path-like | None The head<->MRI transform filename. Must be provided unless BEM is a sphere model. min_dist : float Minimum distance (in millimeters) from the dipole to the inner skull. Must be positive. Note that because this is a constraint passed to a solver it is not strict but close, i.e. for a ``min_dist=5.`` the fits could be 4.9 mm from the inner skull. %(n_jobs)s It is used in field computation and fitting. pos : ndarray, shape (3,) | None Position of the dipole to use. If None (default), sequential fitting (different position and orientation for each time instance) is performed. If a position (in head coords) is given as an array, the position is fixed during fitting. .. versionadded:: 0.12 ori : ndarray, shape (3,) | None Orientation of the dipole to use. If None (default), the orientation is free to change as a function of time. If an orientation (in head coordinates) is given as an array, ``pos`` must also be provided, and the routine computes the amplitude and goodness of fit of the dipole at the given position and orientation for each time instant. .. versionadded:: 0.12 %(rank_none)s .. versionadded:: 0.20 accuracy : str Can be ``"normal"`` (default) or ``"accurate"``, which gives the most accurate coil definition but is typically not necessary for real-world data. .. versionadded:: 0.24 tol : float Final accuracy of the optimization (see ``rhoend`` argument of :func:`scipy.optimize.fmin_cobyla`). .. versionadded:: 0.24 %(verbose)s Returns ------- dip : instance of Dipole or DipoleFixed The dipole fits. A :class:`mne.DipoleFixed` is returned if ``pos`` and ``ori`` are both not None, otherwise a :class:`mne.Dipole` is returned. residual : instance of Evoked The M-EEG data channels with the fitted dipolar activity removed. See Also -------- mne.beamformer.rap_music Dipole DipoleFixed read_dipole Notes ----- .. versionadded:: 0.9.0 """ # This could eventually be adapted to work with other inputs, these # are what is needed: evoked = evoked.copy() _validate_type(accuracy, str, "accuracy") _check_option("accuracy", accuracy, ("accurate", "normal")) # Determine if a list of projectors has an average EEG ref if _needs_eeg_average_ref_proj(evoked.info): raise ValueError("EEG average reference is mandatory for dipole fitting.") if min_dist < 0: raise ValueError(f"min_dist should be positive. Got {min_dist}") if ori is not None and pos is None: raise ValueError("pos must be provided if ori is not None") data = evoked.data if not np.isfinite(data).all(): raise ValueError("Evoked data must be finite") info = evoked.info times = evoked.times.copy() comment = evoked.comment # Convert the min_dist to meters min_dist_to_inner_skull = min_dist / 1000.0 del min_dist # Figure out our inputs neeg = len(pick_types(info, meg=False, eeg=True, ref_meg=False, exclude=[])) if isinstance(bem, str): bem_extra = bem else: bem_extra = repr(bem) logger.info(f"BEM : {bem_extra}") mri_head_t, trans = _get_trans(trans) logger.info(f"MRI transform : {trans}") safe_false = _verbose_safe_false() bem = _setup_bem(bem, bem_extra, neeg, mri_head_t, verbose=safe_false) if not bem["is_sphere"]: # Find the best-fitting sphere inner_skull = _bem_find_surface(bem, "inner_skull") inner_skull = inner_skull.copy() R, r0 = _fit_sphere(inner_skull["rr"], disp=False) # r0 back to head frame for logging r0 = apply_trans(mri_head_t["trans"], r0[np.newaxis, :])[0] inner_skull["r0"] = r0 logger.info( f"Head origin : {1000 * r0[0]:6.1f} {1000 * r0[1]:6.1f} " f"{1000 * r0[2]:6.1f} mm rad = {1000 * R:6.1f} mm." ) del R, r0 else: r0 = bem["r0"] if len(bem.get("layers", [])) > 0: R = bem["layers"][0]["rad"] kind = "rad" else: # MEG-only # Use the minimum distance to the MEG sensors as the radius then R = np.dot( np.linalg.inv(info["dev_head_t"]["trans"]), np.hstack([r0, [1.0]]) )[:3] # r0 -> device R = R - [ info["chs"][pick]["loc"][:3] for pick in pick_types(info, meg=True, exclude=[]) ] if len(R) == 0: raise RuntimeError( "No MEG channels found, but MEG-only sphere model used" ) R = np.min(np.sqrt(np.sum(R * R, axis=1))) # use dist to sensors kind = "max_rad" logger.info( f"Sphere model : origin at ({1000 * r0[0]: 7.2f} {1000 * r0[1]: 7.2f} " f"{1000 * r0[2]: 7.2f}) mm, {kind} = {R:6.1f} mm" ) inner_skull = dict(R=R, r0=r0) # NB sphere model defined in head frame del R, r0 # Deal with DipoleFixed cases here if pos is not None: fixed_position = True pos = np.array(pos, float) if pos.shape != (3,): raise ValueError(f"pos must be None or a 3-element array-like, got {pos}") logger.info( "Fixed position : {:6.1f} {:6.1f} {:6.1f} mm".format(*tuple(1000 * pos)) ) if ori is not None: ori = np.array(ori, float) if ori.shape != (3,): raise ValueError( f"oris must be None or a 3-element array-like, got {ori}" ) norm = np.sqrt(np.sum(ori * ori)) if not np.isclose(norm, 1): raise ValueError(f"ori must be a unit vector, got length {norm}") logger.info( "Fixed orientation : {:6.4f} {:6.4f} {:6.4f} mm".format(*tuple(ori)) ) else: logger.info("Free orientation : ") fit_n_jobs = 1 # only use 1 job to do the guess fitting else: fixed_position = False # Eventually these could be parameters, but they are just used for # the initial grid anyway guess_grid = 0.02 # MNE-C uses 0.01, but this is faster w/similar perf guess_mindist = max(0.005, min_dist_to_inner_skull) guess_exclude = 0.02 logger.info(f"Guess grid : {1000 * guess_grid:6.1f} mm") if guess_mindist > 0.0: logger.info(f"Guess mindist : {1000 * guess_mindist:6.1f} mm") if guess_exclude > 0: logger.info(f"Guess exclude : {1000 * guess_exclude:6.1f} mm") logger.info(f"Using {accuracy} MEG coil definitions.") fit_n_jobs = n_jobs cov = _ensure_cov(cov) logger.info("") _print_coord_trans(mri_head_t) _print_coord_trans(info["dev_head_t"]) logger.info(f"{len(info['bads'])} bad channels total") # Forward model setup (setup_forward_model from setup.c) ch_types = evoked.get_channel_types() sensors = dict() if "grad" in ch_types or "mag" in ch_types: sensors["meg"] = _prep_meg_channels( info, exclude="bads", accuracy=accuracy, verbose=verbose ) if "eeg" in ch_types: sensors["eeg"] = _prep_eeg_channels(info, exclude="bads", verbose=verbose) # Ensure that MEG and/or EEG channels are present if len(sensors) == 0: raise RuntimeError("No MEG or EEG channels found.") # Whitener for the data logger.info("Decomposing the sensor noise covariance matrix...") picks = pick_types(info, meg=True, eeg=True, ref_meg=False) # In case we want to more closely match MNE-C for debugging: # from ._fiff.pick import pick_info # from .cov import prepare_noise_cov # info_nb = pick_info(info, picks) # cov = prepare_noise_cov(cov, info_nb, info_nb['ch_names'], verbose=False) # nzero = (cov['eig'] > 0) # n_chan = len(info_nb['ch_names']) # whitener = np.zeros((n_chan, n_chan), dtype=np.float64) # whitener[nzero, nzero] = 1.0 / np.sqrt(cov['eig'][nzero]) # whitener = np.dot(whitener, cov['eigvec']) whitener, _, rank = compute_whitener( cov, info, picks=picks, rank=rank, return_rank=True ) # Proceed to computing the fits (make_guess_data) if fixed_position: guess_src = dict(nuse=1, rr=pos[np.newaxis], inuse=np.array([True])) logger.info("Compute forward for dipole location...") else: logger.info("\n---- Computing the forward solution for the guesses...") guess_src = _make_guesses( inner_skull, guess_grid, guess_exclude, guess_mindist, n_jobs=n_jobs )[0] # grid coordinates go from mri to head frame transform_surface_to(guess_src, "head", mri_head_t) logger.info("Go through all guess source locations...") # inner_skull goes from mri to head frame if "rr" in inner_skull: transform_surface_to(inner_skull, "head", mri_head_t) if fixed_position: if "rr" in inner_skull: check = _surface_constraint(pos, inner_skull, min_dist_to_inner_skull) else: check = _sphere_constraint( pos, inner_skull["r0"], R_adj=inner_skull["R"] - min_dist_to_inner_skull ) if check <= 0: raise ValueError( f"fixed position is {-1000 * check:0.1f}mm outside the inner skull " "boundary" ) # C code computes guesses w/sphere model for speed, don't bother here fwd_data = _prep_field_computation( guess_src["rr"], sensors=sensors, bem=bem, n_jobs=n_jobs, verbose=safe_false ) fwd_data["inner_skull"] = inner_skull guess_fwd, guess_fwd_orig, guess_fwd_scales = _dipole_forwards( sensors=sensors, fwd_data=fwd_data, whitener=whitener, rr=guess_src["rr"], n_jobs=fit_n_jobs, ) # decompose ahead of time guess_fwd_svd = [ _safe_svd(fwd, full_matrices=False) for fwd in np.array_split(guess_fwd, len(guess_src["rr"])) ] guess_data = dict( fwd=guess_fwd, fwd_svd=guess_fwd_svd, fwd_orig=guess_fwd_orig, scales=guess_fwd_scales, ) del guess_fwd, guess_fwd_svd, guess_fwd_orig, guess_fwd_scales # destroyed logger.info("[done %d source%s]" % (guess_src["nuse"], _pl(guess_src["nuse"]))) # Do actual fits data = data[picks] ch_names = [info["ch_names"][p] for p in picks] proj_op = make_projector(info["projs"], ch_names, info["bads"])[0] fun = _fit_dipole_fixed if fixed_position else _fit_dipole out = _fit_dipoles( fun, min_dist_to_inner_skull, data, times, guess_src["rr"], guess_data, sensors=sensors, fwd_data=fwd_data, whitener=whitener, ori=ori, n_jobs=n_jobs, rank=rank, rhoend=tol, ) assert len(out) == 8 if fixed_position and ori is not None: # DipoleFixed data = np.array([out[1], out[3]]) out_info = deepcopy(info) loc = np.concatenate([pos, ori, np.zeros(6)]) out_info._unlocked = True out_info["chs"] = [ dict( ch_name="dip 01", loc=loc, kind=FIFF.FIFFV_DIPOLE_WAVE, coord_frame=FIFF.FIFFV_COORD_UNKNOWN, unit=FIFF.FIFF_UNIT_AM, coil_type=FIFF.FIFFV_COIL_DIPOLE, unit_mul=0, range=1, cal=1.0, scanno=1, logno=1, ), dict( ch_name="goodness", loc=np.full(12, np.nan), kind=FIFF.FIFFV_GOODNESS_FIT, unit=FIFF.FIFF_UNIT_AM, coord_frame=FIFF.FIFFV_COORD_UNKNOWN, coil_type=FIFF.FIFFV_COIL_NONE, unit_mul=0, range=1.0, cal=1.0, scanno=2, logno=100, ), ] for key in ["hpi_meas", "hpi_results", "projs"]: out_info[key] = list() for key in [ "acq_pars", "acq_stim", "description", "dig", "experimenter", "hpi_subsystem", "proj_id", "proj_name", "subject_info", ]: out_info[key] = None out_info._unlocked = False out_info["bads"] = [] out_info._update_redundant() out_info._check_consistency() dipoles = DipoleFixed( out_info, data, times, evoked.nave, evoked._aspect_kind, comment=comment ) else: dipoles = Dipole( times, out[0], out[1], out[2], out[3], comment, out[4], out[5], out[6] ) residual = evoked.copy().apply_proj() # set the projs active residual.data[picks] = np.dot(proj_op, out[-1]) logger.info("%d time points fitted" % len(dipoles.times)) return dipoles, residual # Every other row of Table 3 from OyamaEtAl2015 _OYAMA = """ 0.00 56.29 -27.50 32.50 56.29 5.00 0.00 65.00 5.00 -32.50 56.29 5.00 0.00 56.29 37.50 0.00 32.50 61.29 -56.29 0.00 -27.50 -56.29 32.50 5.00 -65.00 0.00 5.00 -56.29 -32.50 5.00 -56.29 0.00 37.50 -32.50 0.00 61.29 0.00 -56.29 -27.50 -32.50 -56.29 5.00 0.00 -65.00 5.00 32.50 -56.29 5.00 0.00 -56.29 37.50 0.00 -32.50 61.29 56.29 0.00 -27.50 56.29 -32.50 5.00 65.00 0.00 5.00 56.29 32.50 5.00 56.29 0.00 37.50 32.50 0.00 61.29 0.00 0.00 70.00 """ def get_phantom_dipoles(kind="vectorview"): """Get standard phantom dipole locations and orientations. Parameters ---------- kind : str Get the information for the given system: ``vectorview`` (default) The Neuromag VectorView phantom. ``otaniemi`` The older Neuromag phantom used at Otaniemi. ``oyama`` The phantom from :footcite:`OyamaEtAl2015`. .. versionchanged:: 1.6 Support added for ``'oyama'``. Returns ------- pos : ndarray, shape (n_dipoles, 3) The dipole positions. ori : ndarray, shape (n_dipoles, 3) The dipole orientations. See Also -------- mne.datasets.fetch_phantom Notes ----- The Elekta phantoms have a radius of 79.5mm, and HPI coil locations in the XY-plane at the axis extrema (e.g., (79.5, 0), (0, -79.5), ...). References ---------- .. footbibliography:: """ _validate_type(kind, str, "kind") _check_option("kind", kind, ["vectorview", "otaniemi", "oyama"]) if kind == "vectorview": # these values were pulled from a scanned image provided by # Elekta folks a = np.array([59.7, 48.6, 35.8, 24.8, 37.2, 27.5, 15.8, 7.9]) b = np.array([46.1, 41.9, 38.3, 31.5, 13.9, 16.2, 20.0, 19.3]) x = np.concatenate((a, [0] * 8, -b, [0] * 8)) y = np.concatenate(([0] * 8, -a, [0] * 8, b)) c = [22.9, 23.5, 25.5, 23.1, 52.0, 46.4, 41.0, 33.0] d = [44.4, 34.0, 21.6, 12.7, 62.4, 51.5, 39.1, 27.9] z = np.concatenate((c, c, d, d)) signs = ([1, -1] * 4 + [-1, 1] * 4) * 2 elif kind == "otaniemi": # these values were pulled from an Neuromag manual # (NM20456A, 13.7.1999, p.65) a = np.array([56.3, 47.6, 39.0, 30.3]) b = np.array([32.5, 27.5, 22.5, 17.5]) c = np.zeros(4) x = np.concatenate((a, b, c, c, -a, -b, c, c)) y = np.concatenate((c, c, -a, -b, c, c, b, a)) z = np.concatenate((b, a, b, a, b, a, a, b)) signs = [-1] * 8 + [1] * 16 + [-1] * 8 else: assert kind == "oyama" xyz = np.fromstring(_OYAMA.strip().replace("\n", " "), sep=" ").reshape(25, 3) xyz = np.repeat(xyz, 2, axis=0) x, y, z = xyz.T signs = [1] * 50 pos = np.vstack((x, y, z)).T / 1000.0 # For Neuromag-style phantoms, # Locs are always in XZ or YZ, and so are the oris. The oris are # also in the same plane and tangential, so it's easy to determine # the orientation. # For Oyama, vectors are orthogonal to the position vector and oriented with one # pointed toward the north pole (except for the topmost points, which are just xy). ori = list() for pi, this_pos in enumerate(pos): this_ori = np.zeros(3) idx = np.where(this_pos == 0)[0] # assert len(idx) == 1 if len(idx) == 0: # oyama idx = [np.argmin(this_pos)] idx = np.setdiff1d(np.arange(3), idx[0]) this_ori[idx] = (this_pos[idx][::-1] / np.linalg.norm(this_pos[idx])) * [1, -1] if kind == "oyama": # Ensure it's orthogonal to the position vector pos_unit = this_pos / np.linalg.norm(this_pos) this_ori -= pos_unit * np.dot(this_ori, pos_unit) this_ori /= np.linalg.norm(this_ori) # This was empirically determined by looking at the dipole fits if np.abs(this_ori[2]) >= 1e-6: # if it's not in the XY plane this_ori *= -1 * np.sign(this_ori[2]) # point downward elif np.abs(this_ori[0]) < 1e-6: # in the XY plane (at the north pole) this_ori *= -1 * np.sign(this_ori[1]) # point backward # Odd ones create a RH coordinate system with their ori if pi % 2: this_ori = np.cross(pos_unit, this_ori) else: this_ori *= signs[pi] # Now we have this quality, which we could uncomment to # double-check: # np.testing.assert_allclose(np.dot(this_ori, this_pos) / # np.linalg.norm(this_pos), 0, # atol=1e-15) ori.append(this_ori) ori = np.array(ori) return pos, ori def _concatenate_dipoles(dipoles): """Concatenate a list of dipoles.""" times, pos, amplitude, ori, gof = [], [], [], [], [] for dipole in dipoles: times.append(dipole.times) pos.append(dipole.pos) amplitude.append(dipole.amplitude) ori.append(dipole.ori) gof.append(dipole.gof) return Dipole( np.concatenate(times), np.concatenate(pos), np.concatenate(amplitude), np.concatenate(ori), np.concatenate(gof), name=None, )