1920 lines
60 KiB
Python
1920 lines
60 KiB
Python
"""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"<Dipole | {s}>"
|
|
|
|
@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"<DipoleFixed | {s}>"
|
|
|
|
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 : <time-varying>")
|
|
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,
|
|
)
|