Skip to content

Commit 651bcad

Browse files
apply diataxis to tutorials/inverse/80_brainstorm_phantom_elekta.py (#13584)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent eb0758a commit 651bcad

3 files changed

Lines changed: 166 additions & 100 deletions

File tree

doc/_includes/forward.rst

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,9 +689,7 @@ EEG forward solution in the sphere model
689689

690690
For examples of using the sphere model when computing the forward model
691691
(using :func:`mne.make_forward_solution`), see :ref:`Brainstorm CTF phantom
692-
dataset tutorial <plt_brainstorm_phantom_ctf_eeg_sphere_geometry>`,
693-
:ref:`Brainstorm Elekta phantom dataset tutorial
694-
<plt_brainstorm_phantom_elekta_eeg_sphere_geometry>`, and
692+
dataset tutorial <plt_brainstorm_phantom_ctf_eeg_sphere_geometry>` and
695693
:ref:`tut-source-alignment-without-mri`.
696694

697695
When the sphere model is employed, the computation of the EEG solution can be

doc/changes/dev/13584.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Adopting the diataxis framework by improving the phantom dipole tutorial, by `Carina Forster`_.

tutorials/inverse/80_brainstorm_phantom_elekta.py

Lines changed: 164 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -5,176 +5,244 @@
55
Brainstorm Elekta phantom dataset tutorial
66
==========================================
77
8-
Here we compute the evoked from raw for the Brainstorm Elekta phantom
9-
tutorial dataset. For comparison, see :footcite:`TadelEtAl2011` and
8+
This tutorial provides a step-by-step guide to
9+
importing and processing Elekta-Neuromag current phantom recordings.
10+
11+
A phantom recording is a measurement obtained using a device (phantom)
12+
that generates known magnetic signals,
13+
allowing validation and benchmarking of MEG system accuracy and analysis methods.
14+
15+
The aim of this tutorial is to demonstrate how phantom recordings can be used to
16+
evaluate source localisation methods by comparing estimated and true dipole positions.
17+
18+
For comparison, see :footcite:`TadelEtAl2011` and
1019
`the original Brainstorm tutorial
1120
<https://neuroimage.usc.edu/brainstorm/Tutorials/PhantomElekta>`__.
1221
"""
1322
# sphinx_gallery_thumbnail_number = 9
1423

1524
# Authors: Eric Larson <larson.eric.d@gmail.com>
25+
# Carina Forster <carinaforster0611@gmail.com>
1626
#
1727
# License: BSD-3-Clause
1828
# Copyright the MNE-Python contributors.
1929

2030
# %%
21-
2231
import matplotlib.pyplot as plt
2332
import numpy as np
33+
from scipy.signal import find_peaks
2434

2535
import mne
2636
from mne import find_events, fit_dipole
2737
from mne.datasets import fetch_phantom
2838
from mne.datasets.brainstorm import bst_phantom_elekta
2939
from mne.io import read_raw_fif
3040

31-
print(__doc__)
32-
3341
# %%
34-
# The data were collected with an Elekta Neuromag VectorView system at 1000 Hz
35-
# and low-pass filtered at 330 Hz. Here the medium-amplitude (200 nAm) data
36-
# are read to construct instances of :class:`mne.io.Raw`.
42+
# Load and prepare the data
43+
# -------------------------
44+
# The data were collected with an Elekta Neuromag VectorView system
45+
# at 1000 Hz, low-pass filtered at 330 Hz and contain recordings
46+
# at three current amplitudes (20, 200, and 2000 nAm).
47+
# Here we load the medium-amplitude condition.
48+
49+
# Load the data
3750
data_path = bst_phantom_elekta.data_path(verbose=True)
38-
3951
raw_fname = data_path / "kojak_all_200nAm_pp_no_chpi_no_ms_raw.fif"
4052
raw = read_raw_fif(raw_fname)
4153

42-
# %%
43-
# Data channel array consisted of 204 MEG planor gradiometers,
44-
# 102 axial magnetometers, and 3 stimulus channels. Let's get the events
45-
# for the phantom, where each dipole (1-32) gets its own event:
54+
# Mark known bad channels
55+
raw.info["bads"] = ["MEG1933", "MEG2421"]
4656

57+
# The first 32 events correspond to dipole activations
4758
events = find_events(raw, "STI201")
48-
raw.plot(events=events)
49-
raw.info["bads"] = ["MEG1933", "MEG2421"] # known bad channels
59+
5060

5161
# %%
52-
# The data has strong line frequency (60 Hz and harmonics) and cHPI coil
53-
# noise (five peaks around 300 Hz). Here, we use only the first 30 seconds
54-
# to save memory:
62+
# Epoch the data and plot evokeds
63+
# -------------------------------
64+
# We epoch and baseline correct the data around the dipole events.
5565

56-
raw.compute_psd(tmax=30).plot(
57-
average=False, amplitude=False, picks="data", exclude="bads"
66+
# Epoch the data
67+
bmax = -0.05
68+
tmin, tmax = -0.1, 0.8
69+
event_id = list(range(1, 33))
70+
epochs = mne.Epochs(
71+
raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False
5872
)
5973

74+
# We drop the first and last event, it can contain dipole-switching artifacts
75+
epochs_clean = epochs[1:-1]
76+
77+
# We select the first simulated dipole for visualisation purposes
78+
epochs_firstdip = epochs_clean["1"]
79+
6080
# %%
61-
# Our phantom produces sinusoidal bursts at 20 Hz:
81+
# Let's look at the evoked response for the first clean dipole.
82+
#
83+
# We can see that the phantom was set to produce 20 Hz sinusoidal bursts of current
84+
# and the burst envelope repeats at approximately 3 Hz.
6285

63-
raw.plot(events=events)
86+
epochs_firstdip.average().plot(time_unit="s")
6487

6588
# %%
66-
# Now we epoch our data, average it, and look at the first dipole response.
67-
# The first peak appears around 3 ms. Because we low-passed at 40 Hz,
68-
# we can also decimate our data to save memory.
89+
# Determine peak activation using Global Field Power (GFP)
90+
# --------------------------------------------------------
91+
# GFP is the standard deviation across sensors at each time
92+
# point, providing a reference-independent measure of signal strength.
6993

70-
tmin, tmax = -0.1, 0.1
71-
bmax = -0.05 # Avoid capture filter ringing into baseline
72-
event_id = list(range(1, 33))
73-
epochs = mne.Epochs(
74-
raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False
75-
)
76-
epochs["1"].average().plot(time_unit="s")
94+
# Get the evoked signal of the first dipole
95+
evoked_tmp = epochs_firstdip.average()
96+
97+
# Calculate GFP
98+
gfp = np.std(evoked_tmp.data, axis=0)
7799

100+
# Restrict to first burst window
101+
times = evoked_tmp.times
102+
time_mask = (times > 0) & (times <= 0.05)
103+
104+
# Find the peak GFP indices
105+
peaks, _ = find_peaks(gfp[time_mask])
106+
peak_indices = np.where(time_mask)[0][peaks]
107+
108+
# Select the strongest peak
109+
strongest_peak_idx = peak_indices[np.argmax(gfp[peak_indices])]
110+
t_peak = times[strongest_peak_idx]
111+
print(f"Strongest peak at {t_peak * 1000:.1f} ms")
78112
# %%
79-
# .. _plt_brainstorm_phantom_elekta_eeg_sphere_geometry:
80-
#
81-
# Let's use a :ref:`sphere head geometry model <eeg_sphere_model>`
82-
# and let's see the coordinate alignment and the sphere location. The phantom
83-
# is properly modeled by a single-shell sphere with origin (0., 0., 0.).
84-
#
85-
# Even though this is a VectorView/TRIUX phantom, we can use the Otaniemi
86-
# phantom subject as a surrogate because the "head" surface (hemisphere outer
87-
# shell) has the same geometry for both phantoms, even though the internal
88-
# dipole locations differ. The phantom_otaniemi scan was aligned to the
89-
# phantom's head coordinate frame, so an identity ``trans`` is appropriate
90-
# here.
113+
# Here we select the peak amplitude timepoint and store the evoked data for each dipole.
114+
115+
evokeds = []
116+
for ii in event_id:
117+
evoked = epochs_clean[str(ii)].average().crop(t_peak, t_peak)
118+
evoked = mne.EvokedArray(np.array(evoked.data), evoked.info, tmin=0.0)
119+
evokeds.append(evoked)
120+
# %%
121+
# Next, we need to compute the noise covariance in the baseline window
122+
# to capture the sensor noise structure (for details: :ref:`tut-compute-covariance`).
123+
124+
cov = mne.compute_covariance(epochs_clean, tmax=bmax)
125+
del epochs # delete to save memory
126+
# %%
127+
# We use a :ref:`sphere head geometry model <eeg_sphere_model>`
128+
# because the Elekta phantom is designed to approximate a spherical
129+
# conductor with known dipole locations.
91130

92131
subjects_dir = data_path
93132
fetch_phantom("otaniemi", subjects_dir=subjects_dir)
94133
sphere = mne.make_sphere_model(r0=(0.0, 0.0, 0.0), head_radius=0.08)
95-
subject = "phantom_otaniemi"
96-
trans = mne.transforms.Transform("head", "mri", np.eye(4))
97-
mne.viz.plot_alignment(
98-
epochs.info,
99-
subject=subject,
100-
show_axes=True,
101-
bem=sphere,
102-
dig=True,
103-
surfaces=("head-dense", "inner_skull"),
104-
trans=trans,
105-
mri_fiducials=True,
106-
subjects_dir=subjects_dir,
107-
)
108134

109135
# %%
110-
# Let's do some dipole fits. We first compute the noise covariance,
111-
# then do the fits for each event_id taking the time instant that maximizes
112-
# the global field power.
136+
# Dipole fitting
137+
# --------------
138+
# Finally, we fit dipoles for each phantom and store them in a list.
113139

114-
# here we can get away with using method='oas' for speed (faster than "shrunk")
115-
# but in general "shrunk" is usually better
116-
cov = mne.compute_covariance(epochs, tmax=bmax)
117-
mne.viz.plot_evoked_white(epochs["1"].average(), cov)
140+
dip_all = []
118141

119-
data = []
120-
t_peak = 0.036 # true for Elekta phantom
121-
for ii in event_id:
122-
# Avoid the first and last trials -- can contain dipole-switching artifacts
123-
evoked = epochs[str(ii)][1:-1].average().crop(t_peak, t_peak)
124-
data.append(evoked.data[:, 0])
125-
evoked = mne.EvokedArray(np.array(data).T, evoked.info, tmin=0.0)
126-
del epochs
127-
dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=None)
142+
for evoked in evokeds:
143+
dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=1)
144+
dip_all.append(dip)
145+
# %%
146+
# Evaluate goodness of fit
147+
# ------------------------
148+
# The dipole object stores the goodness of fit (GOF) for each dipole.
149+
# Some dipoles have a low GOF (< 60 %).
150+
gof = [dip.gof[0] for dip in dip_all]
151+
colors = ["#E69F00" if val < 60 else "#0072B2" for val in gof]
152+
plt.bar(event_id, gof, color=colors)
153+
plt.xlabel("Phantom dipole estimation")
154+
plt.ylabel("Goodness of fit (%)")
155+
plt.show()
128156

129157
# %%
130-
# Do a quick visualization of how much variance we explained, putting the
131-
# data and residuals on the same scale (here the "time points" are the
132-
# 32 dipole peak values that we fit):
133-
134-
fig, axes = plt.subplots(2, 1)
135-
evoked.plot(axes=axes)
136-
for ax in axes:
137-
for text in list(ax.texts):
138-
text.remove()
139-
for line in ax.lines:
140-
line.set_color("#98df81")
141-
residual.plot(axes=axes)
158+
# Dipoles with low goodness of fit
159+
# --------------------------------
160+
# Why do some dipoles have a low GOF?
161+
# Here we plot the dipole locations of the dipoles with low GOF.
162+
#
163+
# We can see that dipoles with low GOF are deep in the brain which might explain
164+
# the low GOF.
165+
166+
# Get indices of low GOF dipoles
167+
low_idx = [i for i, g in enumerate(gof) if g < 60]
168+
low_event_ids = [event_id[i] for i in low_idx]
169+
170+
print("Low GOF dipoles:", low_event_ids)
171+
172+
# Let's plot the locations of the dipoles with low GOF.
173+
low_dips = [dip_all[i] for i in low_idx]
174+
175+
subject = "phantom_otaniemi"
176+
trans = mne.transforms.Transform("head", "mri", np.eye(4))
177+
178+
# Plot the position and the orientation of the dipoles with low GOF
179+
fig = mne.viz.plot_alignment(
180+
evoked.info,
181+
trans,
182+
subject,
183+
bem=sphere,
184+
surfaces={"head-dense": 0.2},
185+
coord_frame="head",
186+
meg="helmet",
187+
show_axes=True,
188+
subjects_dir=subjects_dir,
189+
)
142190

191+
fig = mne.viz.plot_dipole_locations(
192+
dipoles=low_dips, mode="arrow", subject=subject, color=(1.0, 0.2, 0.2), fig=fig
193+
)
143194
# %%
144-
# Now we can compare to the actual locations, taking the difference in mm:
195+
# Compare estimated and true dipoles
196+
# ----------------------------------
197+
# The dipole fits closely match the true phantom data,
198+
# achieving sub-centimeter accuracy (mean position error 2.4mm).
145199

200+
# We get the true dipole positions from the phantoms
146201
actual_pos, actual_ori = mne.dipole.get_phantom_dipoles()
147202
actual_amp = 100.0 # nAm
148203

204+
# Here we store the estimated dipoles
205+
dip_pos = [dip.pos[0] for dip in dip_all]
206+
dip_ori = [dip.ori[0] for dip in dip_all]
207+
dip_amplitude = [dip.amplitude[0] for dip in dip_all]
208+
149209
fig, (ax1, ax2, ax3) = plt.subplots(
150210
nrows=3, ncols=1, figsize=(6, 7), layout="constrained"
151211
)
152212

153-
diffs = 1000 * np.sqrt(np.sum((dip.pos - actual_pos) ** 2, axis=-1))
213+
# Here we calculate the euclidean distance between estimated and true positions.
214+
# We multiply by 1000 to convert from meter to millimeter.
215+
diffs = 1000 * np.sqrt(np.sum((dip_pos - actual_pos) ** 2, axis=-1))
154216
print(f"mean(position error) = {np.mean(diffs):0.1f} mm")
155217
ax1.bar(event_id, diffs)
156218
ax1.set_xlabel("Dipole index")
157219
ax1.set_ylabel("Loc. error (mm)")
158220

159-
angles = np.rad2deg(np.arccos(np.abs(np.sum(dip.ori * actual_ori, axis=1))))
221+
# Next we calculate the angle between estimated and true orientation.
222+
# We convert radians to degrees.
223+
angles = np.rad2deg(np.arccos(np.abs(np.sum(dip_ori * actual_ori, axis=1))))
160224
print(f"mean(angle error) = {np.mean(angles):0.1f}°")
161225
ax2.bar(event_id, angles)
162226
ax2.set_xlabel("Dipole index")
163227
ax2.set_ylabel("Angle error (°)")
164228

165-
amps = actual_amp - dip.amplitude / 1e-9
229+
# Here we compare amplitudes by subtracting estimated from true amplitude.
230+
amps = actual_amp - np.array(dip_amplitude) / 1e-9
166231
print(f"mean(abs amplitude error) = {np.mean(np.abs(amps)):0.1f} nAm")
167232
ax3.bar(event_id, amps)
168233
ax3.set_xlabel("Dipole index")
169234
ax3.set_ylabel("Amplitude error (nAm)")
170-
171235
# %%
172-
# Let's plot the positions and the orientations of the actual and the estimated
173-
# dipoles
236+
# Visualise estimated and true dipole locations
237+
# ---------------------------------------------
238+
# We can see that the dipoles overlap, have approximately the same magnitude
239+
# and point in the same direction.
174240

175241
actual_amp = np.ones(len(dip)) # fake amp, needed to create Dipole instance
176-
actual_gof = np.ones(len(dip)) # fake GOF, needed to create Dipole instance
242+
actual_gof = np.ones(len(dip)) # fake goodness-of-fit (GOF)
243+
# setup dipole objects for true and estimated dipoles
177244
dip_true = mne.Dipole(dip.times, actual_pos, actual_amp, actual_ori, actual_gof)
245+
dip_estimated = mne.Dipole(dip.times, dip_pos, dip_amplitude, dip_ori, actual_gof)
178246

179247
fig = mne.viz.plot_alignment(
180248
evoked.info,
@@ -188,16 +256,15 @@
188256
subjects_dir=subjects_dir,
189257
)
190258

191-
# Plot the position and the orientation of the actual dipole
259+
# Plot the position and the orientation of the true dipole in black
192260
fig = mne.viz.plot_dipole_locations(
193261
dipoles=dip_true, mode="arrow", subject=subject, color=(0.0, 0.0, 0.0), fig=fig
194262
)
195263

196-
# Plot the position and the orientation of the estimated dipole
264+
# Plot the position and the orientation of the estimated dipole in green
197265
fig = mne.viz.plot_dipole_locations(
198-
dipoles=dip, mode="arrow", subject=subject, color=(0.2, 1.0, 0.5), fig=fig
266+
dipoles=dip_estimated, mode="arrow", subject=subject, color=(0.2, 1.0, 0.5), fig=fig
199267
)
200-
201268
mne.viz.set_3d_view(figure=fig, azimuth=70, elevation=80, distance=0.5)
202269

203270
# %%

0 commit comments

Comments
 (0)