-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
apply diataxis framework to tutorials #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 all commits
d79b383
8aa3e7a
81e73c8
1cb10a1
92cdef1
25e3990
9624517
174d36d
aa4ca19
37ef2ad
e31c386
efb2765
ab6f3c5
c398ec7
0be4f48
c5e8c3f
9ccff3f
9fc7c4a
e8299e2
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 | ||
| <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`. | ||
| 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. | ||
| # | ||
| # 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 | ||
|
|
||
| # %% | ||
| # 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). | ||
| # | ||
| # 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. | ||
| # | ||
| # Next, we epoch the the data based on the dipoles 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. | ||
| 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. | ||
| # The first peak in the data appears close to the trigger onset | ||
| # at around 3 ms. The burst envelope repeats at approximately 3 Hz. | ||
| # | ||
| # 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>` | ||
| # 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. | ||
| # 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) | ||
|
|
||
| # 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 on lines
+136
to
+138
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 this meant to be code comment or normal paragraph? If normal paragraph, needs |
||
| data = [] | ||
| t_peak = 0.036 # true for Elekta phantom | ||
| 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) | ||
| 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). | ||
| 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. | ||
|
|
||
| # %% | ||
| # References | ||
| # ---------- | ||
| # | ||
| # .. footbibliography:: | ||
| # | ||
| # | ||
|
Comment on lines
+237
to
+240
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.
between these two, this might be where you need a blank line?