Skip to content

Commit a7c42e7

Browse files
committed
addressed Dan's review
1 parent 1a99317 commit a7c42e7

1 file changed

Lines changed: 69 additions & 26 deletions

File tree

tutorials/inverse/80_brainstorm_phantom_elekta.py

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,63 +41,75 @@
4141
# %%
4242
# Load and prepare the data
4343
# -------------------------
44-
# TODO: convert to text from code comment
4544
# The data were collected with an Elekta Neuromag VectorView system
4645
# at 1000 Hz, low-pass filtered at 330 Hz and contain recordings
4746
# at three current amplitudes (20, 200, and 2000 nAm).
48-
4947
# Here we load the medium-amplitude condition.
48+
49+
# Load the data
5050
data_path = bst_phantom_elekta.data_path(verbose=True)
5151
raw_fname = data_path / "kojak_all_200nAm_pp_no_chpi_no_ms_raw.fif"
5252
raw = read_raw_fif(raw_fname)
5353

5454
# Mark known bad channels
5555
raw.info["bads"] = ["MEG1933", "MEG2421"]
5656

57-
events = find_events(raw, "STI201")
5857
# The first 32 events correspond to dipole activations.
58+
events = find_events(raw, "STI201")
59+
5960

6061
# %%
6162
# Epoch the data and plot evokeds
6263
# -------------------------------
63-
#
6464
# We epoch and baseline correct the data around the dipole events.
6565

66+
# Epoch the data
6667
bmax = -0.05
6768
tmin, tmax = -0.1, 0.8
6869
event_id = list(range(1, 33))
69-
7070
epochs = mne.Epochs(
7171
raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False
7272
)
73-
# We drop the first and last epoch as they contain artefacts
74-
epochs_clean = epochs[1:-1
73+
74+
# We drop the first and last event, it can contains dipole-switching artifacts
75+
epochs_clean = epochs[1:-1]
76+
7577
# We select the first simulated dipole for visualisation purposes
7678
epochs_firstdip = epochs_clean["1"]
79+
80+
# %%
7781
# Let's look at the evoked response for the first clean dipole
7882
# We can see that the phantom was set to produce 20 Hz sinusoidal bursts of current.
7983
# and the burst envelope repeats at approximately 3 Hz.
84+
8085
epochs_firstdip.average().plot(time_unit="s")
81-
86+
8287
# %%
8388
# Determine peak activation using Global Field Power (GFP)
8489
# --------------------------------------------------------
85-
8690
# GFP is the standard deviation across sensors at each time
8791
# point, providing a reference-independent measure of signal strength.
92+
93+
# Get the evoked signal of the first dipole
8894
evoked_tmp = epochs_firstdip.average()
95+
# Calculate GFP
8996
gfp = np.std(evoked_tmp.data, axis=0)
90-
times = evoked_tmp.times
97+
9198
# Restrict to first burst window
92-
time_mask = (times > 0) & (times <= 0.1)
99+
times = evoked_tmp.times
100+
time_mask = (times > 0) & (times <= 0.05)
101+
102+
# Find the peak GFP indices
93103
peaks, _ = find_peaks(gfp[time_mask])
94104
peak_indices = np.where(time_mask)[0][peaks]
105+
95106
# Select the strongest peak
96107
strongest_peak_idx = peak_indices[np.argmax(gfp[peak_indices])]
97108
t_peak = times[strongest_peak_idx]
98109
print(f"Strongest peak at {t_peak * 1000:.1f} ms")
99110
# %%
100-
# Here we crop the data at the peak amplitude and store the evoked data for each dipole.
111+
# Here we select the peak amplitude timepoint and store the evoked data for each dipole.
112+
101113
evokeds = []
102114
for ii in event_id:
103115
evoked = epochs_clean[str(ii)].average().crop(t_peak, t_peak)
@@ -111,18 +123,20 @@
111123
cov = mne.compute_covariance(epochs_clean, tmax=bmax)
112124
del epochs # delete to save memory
113125
# %%
114-
# TODO: explain why this head model is used
126+
# %%
115127
# We use a :ref:`sphere head geometry model <eeg_sphere_model>`
116-
# to fit our phantom head model.
128+
# because the Elekta phantom is designed to approximate a spherical
129+
# conductor with known dipole locations.
130+
117131
subjects_dir = data_path
118132
fetch_phantom("otaniemi", subjects_dir=subjects_dir)
119133
sphere = mne.make_sphere_model(r0=(0.0, 0.0, 0.0), head_radius=0.08)
120134

121135
# %%
122136
# Dipole fitting
123137
# --------------
124-
125138
# Finally, we fit dipoles for each phantom and store them in a list.
139+
126140
dip_all = []
127141

128142
for evoked in evokeds:
@@ -131,28 +145,62 @@
131145
# %%
132146
# Evaluate goodness of fit
133147
# ------------------------
134-
135-
# TODO: explain drop in GOF at regular intervals
136148
# The dipole object stores the goodness of fit (GOF) for each dipole.
149+
# Some dipoles have lower GOF because...
137150
gof = [dip.gof[0] for dip in dip_all]
138151
colors = ["#E69F00" if val < 60 else "#0072B2" for val in gof]
139152
plt.bar(event_id, gof, color=colors)
140153
plt.xlabel("Phantom dipole estimation")
141154
plt.ylabel("Goodness of fit (%)")
142155
plt.show()
143-
#
156+
157+
# %%
158+
# Dipoles with low goodness of fit
159+
# --------------------------------
160+
# Why do some dipoles have a low (<60) GOF?
161+
# Here we plot the dipole locations of the dipoles with low GOF.
162+
# The dipoles with low GOF are deep in the brain which might explain
163+
# the low GOF.
164+
165+
# Get indices of low GOF dipoles
166+
low_idx = [i for i, g in enumerate(gof) if g < 60]
167+
low_event_ids = [event_id[i] for i in low_idx]
168+
169+
print("Low GOF dipoles:", low_event_ids)
170+
171+
# Let's plot the locations of the dipoles with low GOF.
172+
low_dips = [dip_all[i] for i in low_idx]
173+
174+
subject = "phantom_otaniemi"
175+
trans = mne.transforms.Transform("head", "mri", np.eye(4))
176+
177+
fig = mne.viz.plot_alignment(
178+
evoked.info,
179+
trans,
180+
subject,
181+
bem=sphere,
182+
surfaces={"head-dense": 0.2},
183+
coord_frame="head",
184+
meg="helmet",
185+
show_axes=True,
186+
subjects_dir=subjects_dir,
187+
)
188+
189+
# Plot the position and the orientation of the dipoles with low GOF
190+
fig = mne.viz.plot_dipole_locations(
191+
dipoles=low_dips, mode="arrow", subject=subject, color=(1.0, 0.2, 0.2), fig=fig
192+
)
144193
# %%
145194
# Compare estimated and true dipoles
146195
# ----------------------------------
147-
148196
# The dipole fits closely match the true phantom data,
149197
# achieving sub-centimeter accuracy (mean position error 2.7mm).
150198

151199
# We get the true dipole positions from the phantoms
152200
actual_pos, actual_ori = mne.dipole.get_phantom_dipoles()
153201
actual_amp = 100.0 # nAm
154202

155-
# estimated dipoles
203+
# Here we store the estimated dipoles
156204
dip_pos = [dip.pos[0] for dip in dip_all]
157205
dip_ori = [dip.ori[0] for dip in dip_all]
158206
dip_amplitude = [dip.amplitude[0] for dip in dip_all]
@@ -186,20 +234,15 @@
186234
# %%
187235
# Visualise estimated and true dipole locations
188236
# ---------------------------------------------
189-
190-
191237
# We can see that the dipoles overlap, have approximately the same magnitude
192238
# and point in the same direction.
193-
239+
194240
actual_amp = np.ones(len(dip)) # fake amp, needed to create Dipole instance
195241
actual_gof = np.ones(len(dip)) # fake goodness-of-fit (GOF)
196242
# setup dipole objects for true and estimated dipoles
197243
dip_true = mne.Dipole(dip.times, actual_pos, actual_amp, actual_ori, actual_gof)
198244
dip_estimated = mne.Dipole(dip.times, dip_pos, dip_amplitude, dip_ori, actual_gof)
199245

200-
subject = "phantom_otaniemi"
201-
trans = mne.transforms.Transform("head", "mri", np.eye(4))
202-
203246
fig = mne.viz.plot_alignment(
204247
evoked.info,
205248
trans,

0 commit comments

Comments
 (0)