Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d79b383
Bump davidslusser/actions_python_bandit from 1.0.0 to 1.0.1
dependabot[bot] Mar 11, 2024
8aa3e7a
Merge pull request #4 from CarinaFo/dependabot/github_actions/davidsl…
CarinaFo Aug 23, 2025
81e73c8
Merge branch 'mne-tools:main' into main
CarinaFo Aug 23, 2025
1cb10a1
Merge branch 'mne-tools:main' into main
CarinaFo Sep 22, 2025
92cdef1
Merge branch 'mne-tools:main' into main
CarinaFo Nov 23, 2025
25e3990
diataxis
CarinaFo Jan 10, 2026
9624517
first draft using diataxis framework
CarinaFo Jan 24, 2026
174d36d
added changelog
CarinaFo Jan 25, 2026
aa4ca19
changelog update
CarinaFo Jan 25, 2026
37ef2ad
wrong PR
CarinaFo Jan 25, 2026
e31c386
deleted changelog with wrong PR
CarinaFo Jan 25, 2026
efb2765
Merge branch 'main' into diataxis
CarinaFo Feb 14, 2026
ab6f3c5
added explanations
CarinaFo Feb 14, 2026
c398ec7
formatting for docs
CarinaFo Feb 14, 2026
0be4f48
more formatting
CarinaFo Feb 14, 2026
c5e8c3f
final formatting
CarinaFo Feb 15, 2026
9ccff3f
add blank lines around directives
CarinaFo Feb 15, 2026
9fc7c4a
add blank line at file end
CarinaFo Feb 15, 2026
e8299e2
fix sphinx error log
CarinaFo Feb 16, 2026
35949f4
fix sphinx formatting error
CarinaFo Feb 21, 2026
7ea90a6
Merge branch 'main' into diataxis
CarinaFo Feb 28, 2026
139d273
address dan's review
CarinaFo Mar 29, 2026
bd52896
fix t_peak
CarinaFo Mar 29, 2026
d39b6e5
fixed peak amplitude estimation
CarinaFo Mar 29, 2026
bd754f8
prettified GOF plot
CarinaFo Mar 29, 2026
fa6976a
single evokeds instead of evoked with dipoles as timepoints
CarinaFo Mar 29, 2026
f2a079b
add TODO
CarinaFo Mar 29, 2026
4e2cdc6
add formatting TODO
CarinaFo Mar 29, 2026
5046a47
removed plotting label
CarinaFo Apr 6, 2026
93be3a6
fix 3D plotting (very slow, discuss with Dan)
CarinaFo Apr 6, 2026
722692e
second dipole object instead of heavy loop
CarinaFo Apr 6, 2026
01aa3f0
final formatting (ask Dan regarding ouputs)
CarinaFo Apr 6, 2026
e22e205
quick formatting
CarinaFo Apr 6, 2026
b175b50
removed sphere model reference
CarinaFo Apr 6, 2026
2b1b4a3
sphinx error
CarinaFo Apr 6, 2026
e27ac56
fix doc build sphinx error
CarinaFo Apr 6, 2026
111066e
final formatting
CarinaFo Apr 11, 2026
b66d7f0
fix docs fail
CarinaFo Apr 11, 2026
7a62a34
CI: trigger rerun
CarinaFo Apr 18, 2026
1a99317
Update 80_brainstorm_phantom_elekta.py
CarinaFo Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/13584.other.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adopting the diataxis framework by improving the phantom dipole tutorial, by `Carina Forster`_.
144 changes: 89 additions & 55 deletions tutorials/inverse/80_brainstorm_phantom_elekta.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
Brainstorm Elekta phantom dataset tutorial
==========================================

Here we compute the evoked from raw for the Brainstorm Elekta phantom
tutorial dataset. For comparison, see :footcite:`TadelEtAl2011` and
`the original Brainstorm tutorial
This tutorial provides a step-by-step guide to
importing and processing Elekta-Neuromag current phantom recordings.
The aim of this tutorial is to show the user how to use phantom recordings to
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The aim of this tutorial is to show the user how to use phantom recordings to
The aim of this tutorial is to learn how to use phantom recordings to

evaluate source localisation methods by comparing estimated vs real dipole positions.

For comparison, see :footcite:`TadelEtAl2011` and
`the original Brainstorm tutorial with an explanation of phantom recordings
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worthwhile to add 1 sentence at start of this paragraph, explaining what a phantom is? Something like "Phantoms are devices that fit inside a MEG measurement volume, and can generate electric dipoles with known current amplitudes and locations."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a sentence after mentioning phantom recordings the first time

<https://neuroimage.usc.edu/brainstorm/Tutorials/PhantomElekta>`__.
"""
# sphinx_gallery_thumbnail_number = 9
Expand All @@ -31,55 +35,67 @@
print(__doc__)

# %%
# The data were collected with an Elekta Neuromag VectorView system at 1000 Hz
# and low-pass filtered at 330 Hz. Here the medium-amplitude (200 nAm) data
# are read to construct instances of :class:`mne.io.Raw`.
# The data were collected with an Elekta Neuromag VectorView system
# at 1000 Hz and low-pass filtered at 330 Hz.
# Here the medium-amplitude (200 nAm, amplitudes can be seen in raw data)
# data are accessed to construct instances of :class:`mne.io.Raw`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dataset has recordings at 3 different current amplitudes (20, 200, and 2000 nAm), here we'll load the medium-amplitude one.

data_path = bst_phantom_elekta.data_path(verbose=True)

raw_fname = data_path / "kojak_all_200nAm_pp_no_chpi_no_ms_raw.fif"
raw = read_raw_fif(raw_fname)

# %%
# Data channel array consisted of 204 MEG planor gradiometers,
# 102 axial magnetometers, and 3 stimulus channels. Let's get the events
# for the phantom, where each dipole (1-32) gets its own event:
# Let's first get an idea of the data structure we are working with.

raw.info

# %%
# The data consists of 204 MEG planor gradiometers,
# 102 axial magnetometers, and 3 stimulus channels.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is doing/showing this important to the goal of learning how to validate source localization accuracy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, not really relevant for the tutorial and thus potentially distracting.

#
# Next, let's look at the events in the phantom data for one stimulus channel.
events = find_events(raw, "STI201")
raw.plot(events=events)
raw.info["bads"] = ["MEG1933", "MEG2421"] # known bad channels
Copy link
Copy Markdown
Member

@drammock drammock Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this should come earlier (right after read_raw_fif I think); it's known a priori (not inferred from the raw.info output or the find_events call)


# %%
# The data has strong line frequency (60 Hz and harmonics) and cHPI coil
# noise (five peaks around 300 Hz). Here, we use only the first 30 seconds
# to save memory:

# There are 32 artificial dipoles stored as events.
# The remaining event IDs are not relevant for this tutorial (256, 768 ... ).
#
# Let's explore the phantom data by plotting power spectral density for each sensor.
# Here, we use only the first 30 seconds to save memory.
raw.compute_psd(tmax=30).plot(
average=False, amplitude=False, picks="data", exclude="bads"
)

# %%
# Our phantom produces sinusoidal bursts at 20 Hz:

raw.plot(events=events)

# We can see that the data has strong line frequency noise (60 Hz, 120 Hz ...)
# and cHPI (continuous head position indicator) coil noise (peaks around 300 Hz).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the filename has no_chpi in it, so it's weird that there are cHPI peaks in the data... maybe they were only on right at the beginning, and we see them because we're using the first 30 seconds? If it would help the tutorial, you could figure out how late in the file to start in order to avoid the HPI signals

Copy link
Copy Markdown
Contributor Author

@CarinaFo CarinaFo Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I haven't worked with MEG data before and assumed that is a ongoing source of noise but will investigate. There is a peak at 300 Hz throughout but less prominent and not multiple peaks around 300 Hz after 30 seconds of recoring. But I actually think this whole plot is not important for the tutorials aim, we don't filter the data and thus it's rather distracting to show the PSD I would argue.

#
# Here we plot the dipole events.
raw.plot(events=events, n_channels=10)
# %%
# Now we epoch our data, average it, and look at the first dipole response.
# The first peak appears around 3 ms. Because we low-passed at 40 Hz,
# we can also decimate our data to save memory.

# The simulated dipoles produce sinusoidal bursts at 20 Hz.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this data the phantom was programmed to produce 20 Hz sinusoidal bursts of current.

(i.e., make it clear that this was user-determined, 20 Hz is not intrinsic to the device)

#
# Next, we epoch the the data based on the dipoles events (1:32).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Next, we epoch the the data based on the dipoles events (1:32).
# Next, we epoch the the data based on the dipole events (1:32).

#
# We select 100 ms before and after the event trigger
# and baseline correct the epochs from -100 ms to -0.05 ms before stimulus onset.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# and baseline correct the epochs from -100 ms to -0.05 ms before stimulus onset.
# and baseline correct the epochs using a baseline period from -100 ms to -50 ms (before stimulus onset).

tmin, tmax = -0.1, 0.1
bmax = -0.05 # Avoid capture filter ringing into baseline
event_id = list(range(1, 33))
epochs = mne.Epochs(
raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False
)
epochs["1"].average().plot(time_unit="s")

# Here we average the epochs for the first simulated dipole
# and plot the evoked signal
epochs["1"].average().plot(time_unit="s")
# %%
# .. _plt_brainstorm_phantom_elekta_eeg_sphere_geometry:
# We averaged over 640 simulated events for the first dipole.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

between these two, this might be where you need a blank line?

# The first peak in the data appears close to the trigger onset
# at around 3 ms. The burst envelope repeats at approximately 3 Hz.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can you tell from that plot that the burst envelope repeats at 3 Hz? you'd need a much longer time window to see that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I increased epoch tmax to 1.2 seconds to see 3 bursts.

#
# Let's use a :ref:`sphere head geometry model <eeg_sphere_model>`
# and let's see the coordinate alignment and the sphere location. The phantom
# Finally, we source reconstruct the evoked simulated dipole.
# To do this we use a :ref:`sphere head geometry model <eeg_sphere_model>`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# To do this we use a :ref:`sphere head geometry model <eeg_sphere_model>`
# To do this we use a :ref:`spherical head geometry model <eeg_sphere_model>`

# and visualise the coordinate alignment and the sphere location. The phantom
# is properly modeled by a single-shell sphere with origin (0., 0., 0.).
#
# Even though this is a VectorView/TRIUX phantom, we can use the Otaniemi
Expand All @@ -88,7 +104,6 @@
# dipole locations differ. The phantom_otaniemi scan was aligned to the
# phantom's head coordinate frame, so an identity ``trans`` is appropriate
# here.

subjects_dir = data_path
fetch_phantom("otaniemi", subjects_dir=subjects_dir)
sphere = mne.make_sphere_model(r0=(0.0, 0.0, 0.0), head_radius=0.08)
Expand All @@ -105,32 +120,39 @@
mri_fiducials=True,
subjects_dir=subjects_dir,
)

# %%
# Let's do some dipole fits. We first compute the noise covariance,
# then do the fits for each event_id taking the time instant that maximizes
# the global field power.

# here we can get away with using method='oas' for speed (faster than "shrunk")
# but in general "shrunk" is usually better
# We can see that our head model aligns with the phantom head model.
#
# Let's do some dipole fits.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a critical part of the process, and deserves more emphasis (and hand-holding). Consider making this a titled subsection, or at least spelling out the step a bit more clearly. For example: "Our next step is to use the epoched data to fit dipoles, then compare them to the known dipole locations built into the phantom"

# First we compute the noise covariance for the baseline window.
# Check the covariance/whitening tutorial for details :ref:`tut-compute-covariance`.
#
# The covariance captures the sensor noise structure.
cov = mne.compute_covariance(epochs, tmax=bmax)

# The plot shows the evoked signal divided by the estimated noise standard deviation.
mne.viz.plot_evoked_white(epochs["1"].average(), cov)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure this is strictly necessary for the tutorial's stated goal


# Next, we fit the dipoles for the evoked data.
# We choose the timepoint which maximises global field power
# We have seen in the evoked plot that this is around 3 ms after dipole onset.
Comment thread
drammock marked this conversation as resolved.
Outdated
data = []
t_peak = 0.036 # true for Elekta phantom
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text says 3 ms, but t_peak is 36 ms. 3 ms is when the first wave starts right, not the peak location?

Also: might be worth going through the process of computing the peak exactly here, instead of just saying "true for Elekta phantom"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I include the peak determination

for ii in event_id:
# Avoid the first and last trials -- can contain dipole-switching artifacts
evoked = epochs[str(ii)][1:-1].average().crop(t_peak, t_peak)
data.append(evoked.data[:, 0])
evoked = mne.EvokedArray(np.array(data).T, evoked.info, tmin=0.0)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this approach of cramming a single (peak) timepoint from each of 32 separate evokeds into a single EvokedArray is very confusing. We should probably do this a more straightforward way, or at least add a lot more explanation of this code block.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fit_dipoles() expects an Evoked Object, we can store every dipole as a separate evoked, that migh be less confusing.

del epochs
del epochs # save memory
dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=None)

# %%
# Do a quick visualization of how much variance we explained, putting the
# data and residuals on the same scale (here the "time points" are the
# 32 dipole peak values that we fit):

# Whitened global field power (GFP):
# most baseline activity should fall roughly within ±1 (unit variance).
#
# Let's visualize the explained variance.
# To do this, we need to make sure that the
# data and the residuals are on the same scale
# (here the "time points" are the 32 dipole peak values that we fit).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to me this is a confusing visualization. Surely there's a better way to summarize explained variance?

Copy link
Copy Markdown
Contributor Author

@CarinaFo CarinaFo Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can plot goodness of fit for each dipole. Currently trying to figure out what values that actually is.

fig, axes = plt.subplots(2, 1)
evoked.plot(axes=axes)
for ax in axes:
Expand All @@ -139,41 +161,51 @@
for line in ax.lines:
line.set_color("#98df81")
residual.plot(axes=axes)

# %%
# Now we can compare to the actual locations, taking the difference in mm:

# Here we visualise how well the dipole explains the evoked response (green line).
# The red lines represent the residuals, the leftover noise after dipole fitting.
#
# What is a good fit? The green lines are strong and residuals are small and
# roughly flat.
#
# Finally, we compare the estimated to the true dipole locations.
actual_pos, actual_ori = mne.dipole.get_phantom_dipoles()
actual_amp = 100.0 # nAm

fig, (ax1, ax2, ax3) = plt.subplots(
nrows=3, ncols=1, figsize=(6, 7), layout="constrained"
)

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

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

# Finally we compare amplitudes by subtracting estimated from true amplitude.
amps = actual_amp - dip.amplitude / 1e-9
print(f"mean(abs amplitude error) = {np.mean(np.abs(amps)):0.1f} nAm")
ax3.bar(event_id, amps)
ax3.set_xlabel("Dipole index")
ax3.set_ylabel("Amplitude error (nAm)")

# %%
# Let's plot the positions and the orientations of the actual and the estimated
# dipoles

# The dipole fits closely match the true phantom data,
# achieving sub-centimeter accuracy (mean position error 2.6 mm).
#
# Finally, we can plot the positions and the orientations
# of the estimated and true dipoles.
actual_amp = np.ones(len(dip)) # fake amp, needed to create Dipole instance
actual_gof = np.ones(len(dip)) # fake GOF, needed to create Dipole instance
actual_gof = np.ones(len(dip)) # fake goodness-of-fit (GOF)
dip_true = mne.Dipole(dip.times, actual_pos, actual_amp, actual_ori, actual_gof)

fig = mne.viz.plot_alignment(
Expand All @@ -187,20 +219,22 @@
show_axes=True,
subjects_dir=subjects_dir,
)

# Plot the position and the orientation of the actual dipole
# Plot the position and the orientation of the true dipole in black
fig = mne.viz.plot_dipole_locations(
dipoles=dip_true, mode="arrow", subject=subject, color=(0.0, 0.0, 0.0), fig=fig
)

# Plot the position and the orientation of the estimated dipole
# Plot the position and the orientation of the estimated dipole in green
fig = mne.viz.plot_dipole_locations(
dipoles=dip, mode="arrow", subject=subject, color=(0.2, 1.0, 0.5), fig=fig
)

mne.viz.set_3d_view(figure=fig, azimuth=70, elevation=80, distance=0.5)
# %%
# We can see that the dipoles overlap and point in the same direction.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and have approximately the same magnitude


# %%
# References
# ----------
#
# .. footbibliography::
#
#
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the extra empty lines you added here. It's possible that is what causes the cryptic sphinx build error.