-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
apply diataxis to tutorials/inverse/80_brainstorm_phantom_elekta.py #13584
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 19 commits
d79b383
8aa3e7a
81e73c8
1cb10a1
92cdef1
25e3990
9624517
174d36d
aa4ca19
37ef2ad
e31c386
efb2765
ab6f3c5
c398ec7
0be4f48
c5e8c3f
9ccff3f
9fc7c4a
e8299e2
35949f4
7ea90a6
139d273
bd52896
d39b6e5
bd754f8
fa6976a
f2a079b
4e2cdc6
5046a47
93be3a6
722692e
01aa3f0
e22e205
b175b50
2b1b4a3
e27ac56
111066e
b66d7f0
7a62a34
1a99317
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`_. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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."
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
|
|
@@ -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`. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
|
|
||||||
| # %% | ||||||
| # 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). | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| # | ||||||
| # 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>` | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| # 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 | ||||||
|
|
@@ -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) | ||||||
|
|
@@ -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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||||||
|
drammock marked this conversation as resolved.
Outdated
|
||||||
| data = [] | ||||||
| t_peak = 0.036 # true for Elekta phantom | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. text says 3 ms, but Also: might be worth going through the process of computing the peak exactly here, instead of just saying "true for Elekta phantom"
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||||||
|
|
@@ -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( | ||||||
|
|
@@ -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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and have approximately the same magnitude |
||||||
|
|
||||||
| # %% | ||||||
| # References | ||||||
| # ---------- | ||||||
| # | ||||||
| # .. footbibliography:: | ||||||
| # | ||||||
| # | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.