From d79b383e05ca49b833cf21896b2a787f06d7df7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Mar 2024 03:30:57 +0000 Subject: [PATCH 01/14] Bump davidslusser/actions_python_bandit from 1.0.0 to 1.0.1 Bumps [davidslusser/actions_python_bandit](https://github.com/davidslusser/actions_python_bandit) from 1.0.0 to 1.0.1. - [Release notes](https://github.com/davidslusser/actions_python_bandit/releases) - [Commits](https://github.com/davidslusser/actions_python_bandit/compare/v1.0.0...v1.0.1) --- updated-dependencies: - dependency-name: davidslusser/actions_python_bandit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5abfae43a3b..908555af797 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,7 @@ jobs: needs: style runs-on: ubuntu-latest steps: - - uses: davidslusser/actions_python_bandit@v1.0.0 + - uses: davidslusser/actions_python_bandit@v1.0.1 with: src: "mne" options: "-c pyproject.toml -ll -r" From 25e39909c57c5bb70b3c7688e1803be3d105b2bd Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 10 Jan 2026 15:00:35 +1000 Subject: [PATCH 02/14] diataxis --- tutorials/inverse/80_brainstorm_phantom_elekta.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index c76a5c5f569..1e2c69b9ad0 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -6,7 +6,9 @@ ========================================== Here we compute the evoked from raw for the Brainstorm Elekta phantom -tutorial dataset. For comparison, see :footcite:`TadelEtAl2011` and +tutorial dataset. We will compare the actual dipole positions +to the estimated dipole positions. +For comparison, see :footcite:`TadelEtAl2011` and `the original Brainstorm tutorial `__. """ From 96245171fcbc2343c1052b49a6462dbe3c07a0b4 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 24 Jan 2026 15:38:06 +1000 Subject: [PATCH 03/14] first draft using diataxis framework --- .../inverse/80_brainstorm_phantom_elekta.py | 92 ++++++++++++------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index 1e2c69b9ad0..22f531191d1 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -5,11 +5,12 @@ Brainstorm Elekta phantom dataset tutorial ========================================== -Here we compute the evoked from raw for the Brainstorm Elekta phantom -tutorial dataset. We will compare the actual dipole positions -to the estimated dipole positions. +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 +`the original Brainstorm tutorial with an explanation of phantom recordings `__. """ # sphinx_gallery_thumbnail_number = 9 @@ -33,41 +34,51 @@ 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 read 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 channel array consisted 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 +# we have 32 artificial dipoles stored as events +# the remaining event IDs are not relevant for this tutorial (256, 768 and so on) # %% -# 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: +# 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" ) +# We can see that the data has strong line frequency (60 Hz and harmonics) +# and cHPI coil noise (five peaks around 300 Hz). # %% -# Our phantom produces sinusoidal bursts at 20 Hz: +# Next we plot the dipole events raw.plot(events=events) +# We can see that the simulated dipoles produce sinusoidal bursts at 20 Hz +# (can we really see that in the plot?) # %% -# 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. +# Next, we epoch the the data based on the dipoles events (1:32) +# We select 100 ms before and 100ms after the event trigger +# and baseline correct the epochs from -100 ms to - 0.05 before stimulus onset tmin, tmax = -0.1, 0.1 bmax = -0.05 # Avoid capture filter ringing into baseline @@ -75,13 +86,19 @@ epochs = mne.Epochs( raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False ) + +# Here we average the epochs for the first simulated dipole +# and plot the evoked signal epochs["1"].average().plot(time_unit="s") +# we averaged over 640 simulated events for the first dipole +# We can see that the first peak in the data appears close to the trigger onset # %% # .. _plt_brainstorm_phantom_elekta_eeg_sphere_geometry: # -# Let's use a :ref:`sphere head geometry 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 ` +# 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 @@ -107,17 +124,22 @@ mri_fiducials=True, subjects_dir=subjects_dir, ) - +# What does this plot tell us? # %% -# 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. +# Let's do some dipole fits. +# To do this we compute the noise covariance for each epoch. +# We plot the whitened data to assess the whitening step. # here we can get away with using method='oas' for speed (faster than "shrunk") # but in general "shrunk" is usually better cov = mne.compute_covariance(epochs, tmax=bmax) mne.viz.plot_evoked_white(epochs["1"].average(), cov) +# Not sure what we see here TBH + +# Next, we fit the dipoles for the evoked data. +# We choose the timepoint which maximises global field power + data = [] t_peak = 0.036 # true for Elekta phantom for ii in event_id: @@ -129,9 +151,10 @@ 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): +# 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) @@ -142,9 +165,10 @@ line.set_color("#98df81") residual.plot(axes=axes) +# again not sure how to interpret those plots # %% -# Now we can compare to the actual locations, taking the difference in mm: - +# Finally, we compare the estimated to the true dipole locations +# To do this, we calculate the difference by ..... actual_pos, actual_ori = mne.dipole.get_phantom_dipoles() actual_amp = 100.0 # nAm @@ -170,9 +194,11 @@ ax3.set_xlabel("Dipole index") ax3.set_ylabel("Amplitude error (nAm)") +# We can see that the error magnitude depends on the position of the estimate dipole +# however, the location error is never greater than 5 mm which is good? # %% -# Let's plot the positions and the orientations of the actual and the estimated -# dipoles +# Finally, we can plot the positions and the orientations +# of the actual and the estimated 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 @@ -190,18 +216,20 @@ subjects_dir=subjects_dir, ) -# Plot the position and the orientation of the actual dipole +# Plot the position and the orientation of the actual 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) +# Here the green arrows represent the estimated dipoles, the black arrows +# the true dipole location # %% # References # ---------- From 174d36d014ac787e18aad48e816a3bd50c57ae3c Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 25 Jan 2026 11:19:35 +1000 Subject: [PATCH 04/14] added changelog --- doc/changes/dev/13585.other.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13585.other.rst diff --git a/doc/changes/dev/13585.other.rst b/doc/changes/dev/13585.other.rst new file mode 100644 index 00000000000..ae5a5a411c8 --- /dev/null +++ b/doc/changes/dev/13585.other.rst @@ -0,0 +1 @@ +Refactoring tutorial on Phantom dipoles using the diataxis framework, by `Carina Forster`_. From aa4ca199312eb76efbe35348640dcd98a5e2e59a Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 25 Jan 2026 11:25:09 +1000 Subject: [PATCH 05/14] changelog update --- doc/changes/dev/13585.other.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/dev/13585.other.rst b/doc/changes/dev/13585.other.rst index ae5a5a411c8..6fd8196d231 100644 --- a/doc/changes/dev/13585.other.rst +++ b/doc/changes/dev/13585.other.rst @@ -1 +1 @@ -Refactoring tutorial on Phantom dipoles using the diataxis framework, by `Carina Forster`_. +Adopting the diataxis framework by improving the phantom dipole tutorial, by `Carina Forster`_. From 37ef2ad06a52d9869645eb9486397fa595a4c198 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 25 Jan 2026 11:35:47 +1000 Subject: [PATCH 06/14] wrong PR --- doc/changes/dev/13584.other.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13584.other.rst diff --git a/doc/changes/dev/13584.other.rst b/doc/changes/dev/13584.other.rst new file mode 100644 index 00000000000..6fd8196d231 --- /dev/null +++ b/doc/changes/dev/13584.other.rst @@ -0,0 +1 @@ +Adopting the diataxis framework by improving the phantom dipole tutorial, by `Carina Forster`_. From e31c386e52f9748cbc10fa5accefc79d1a1a47e0 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 25 Jan 2026 11:39:22 +1000 Subject: [PATCH 07/14] deleted changelog with wrong PR --- doc/changes/dev/13585.other.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 doc/changes/dev/13585.other.rst diff --git a/doc/changes/dev/13585.other.rst b/doc/changes/dev/13585.other.rst deleted file mode 100644 index 6fd8196d231..00000000000 --- a/doc/changes/dev/13585.other.rst +++ /dev/null @@ -1 +0,0 @@ -Adopting the diataxis framework by improving the phantom dipole tutorial, by `Carina Forster`_. From ab6f3c5dd01a9b3b5a0f4f8d0e1457a42df6b2f0 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 14 Feb 2026 15:23:59 +1000 Subject: [PATCH 08/14] added explanations --- .../inverse/80_brainstorm_phantom_elekta.py | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index 22f531191d1..ec965e604ec 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -37,7 +37,7 @@ # 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 read to construct instances of :class:`mne.io.Raw`. +# 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" @@ -48,7 +48,7 @@ raw.info -# The data channel array consisted of 204 MEG planor gradiometers, +# 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: @@ -66,15 +66,14 @@ average=False, amplitude=False, picks="data", exclude="bads" ) -# We can see that the data has strong line frequency (60 Hz and harmonics) -# and cHPI coil noise (five peaks around 300 Hz). +# We can see that the data has strong line frequency (60 Hz, 120 Hz and so on) noise +# and cHPI (continuous head position indicator) coil noise (five peaks around 300 Hz). # %% # Next we plot the dipole events -raw.plot(events=events) +raw.plot(events=events, n_channels=15) # We can see that the simulated dipoles produce sinusoidal bursts at 20 Hz -# (can we really see that in the plot?) # %% # Next, we epoch the the data based on the dipoles events (1:32) # We select 100 ms before and 100ms after the event trigger @@ -92,6 +91,8 @@ epochs["1"].average().plot(time_unit="s") # we averaged over 640 simulated events for the first dipole # We can see that the first peak in the data appears close to the trigger onset +# at around 3ms, with a peak repeating every 3ms. +# Thus the burst repetition rate is 3Hz. # %% # .. _plt_brainstorm_phantom_elekta_eeg_sphere_geometry: @@ -124,21 +125,21 @@ mri_fiducials=True, subjects_dir=subjects_dir, ) -# What does this plot tell us? +# We can see that our head model aligns with the phantom head model. # %% # Let's do some dipole fits. -# To do this we compute the noise covariance for each epoch. -# We plot the whitened data to assess the whitening step. - -# here we can get away with using method='oas' for speed (faster than "shrunk") -# but in general "shrunk" is usually better +# To do this we compute the noise covariance for the window before dipole onset. cov = mne.compute_covariance(epochs, tmax=bmax) +# The covariance captures the sensor noise structure. +# We whiten the data to normalize noise across sensors before fitting dipoles +# tutorial on whitening/covariance estimation for details :ref:`tut-compute-covariance`. mne.viz.plot_evoked_white(epochs["1"].average(), cov) - -# Not sure what we see here TBH +# The plot shows the evoked signal divided by the estimated noise standard deviation. +# After whitening, most baseline activity should fall roughly within ±1 (unit variance). # 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. data = [] t_peak = 0.036 # true for Elekta phantom @@ -147,7 +148,7 @@ 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) # %% @@ -165,43 +166,53 @@ line.set_color("#98df81") residual.plot(axes=axes) -# again not sure how to interpret those plots +# 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. +# A good fit: green lines are strong and residuals are small and roughly flat. # %% # Finally, we compare the estimated to the true dipole locations # To do this, we calculate the difference by ..... +# This is our ground truth. 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)") -# We can see that the error magnitude depends on the position of the estimate dipole -# however, the location error is never greater than 5 mm which is good? +# The dipole fits closely match the phantom ground truth. +# We can achieve sub-centimeter accuracy with a mean position error of 2.6 mm. +# This demonstrates that the fitting procedure is accurate. # %% # Finally, we can plot the positions and the orientations # of the actual and the estimated 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), needed to create Dipole instance dip_true = mne.Dipole(dip.times, actual_pos, actual_amp, actual_ori, actual_gof) fig = mne.viz.plot_alignment( From c398ec76fce4f63d2e420cf03ea3c1ef1919f4a0 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 14 Feb 2026 18:12:49 +1000 Subject: [PATCH 09/14] formatting for docs --- .../inverse/80_brainstorm_phantom_elekta.py | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index ec965e604ec..fbd0afa37c8 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -9,6 +9,7 @@ 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 `__. @@ -39,7 +40,6 @@ # 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) @@ -48,12 +48,12 @@ 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: +# 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 # we have 32 artificial dipoles stored as events @@ -66,18 +66,19 @@ average=False, amplitude=False, picks="data", exclude="bads" ) -# We can see that the data has strong line frequency (60 Hz, 120 Hz and so on) noise -# and cHPI (continuous head position indicator) coil noise (five peaks around 300 Hz). +# We can see that the data has strong line frequency (60 Hz, 120 Hz ...) noise +# and cHPI (continuous head position indicator) coil noise (peaks around 300 Hz). # %% # Next we plot the dipole events -raw.plot(events=events, n_channels=15) +raw.plot(events=events, n_channels=10) # We can see that 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 100ms after the event trigger -# and baseline correct the epochs from -100 ms to - 0.05 before stimulus onset +# 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 @@ -89,8 +90,10 @@ # Here we average the epochs for the first simulated dipole # and plot the evoked signal epochs["1"].average().plot(time_unit="s") -# we averaged over 640 simulated events for the first dipole -# We can see that the first peak in the data appears close to the trigger onset +# %% + +# We averaged over 640 simulated events for the first dipole. +# The first peak in the data appears close to the trigger onset # at around 3ms, with a peak repeating every 3ms. # Thus the burst repetition rate is 3Hz. @@ -125,22 +128,23 @@ mri_fiducials=True, subjects_dir=subjects_dir, ) +# %% # We can see that our head model aligns with the phantom head model. # %% # Let's do some dipole fits. -# To do this we compute the noise covariance for the window before dipole onset. -cov = mne.compute_covariance(epochs, tmax=bmax) + +# First we compute the noise covariance for the baseline window. +# See covariance/whitening tutorial for details :ref:`tut-compute-covariance`. +# %% # The covariance captures the sensor noise structure. -# We whiten the data to normalize noise across sensors before fitting dipoles -# tutorial on whitening/covariance estimation for details :ref:`tut-compute-covariance`. -mne.viz.plot_evoked_white(epochs["1"].average(), cov) +cov = mne.compute_covariance(epochs, tmax=bmax) + # The plot shows the evoked signal divided by the estimated noise standard deviation. -# After whitening, most baseline activity should fall roughly within ±1 (unit variance). +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. - data = [] t_peak = 0.036 # true for Elekta phantom for ii in event_id: @@ -150,12 +154,17 @@ evoked = mne.EvokedArray(np.array(data).T, evoked.info, tmin=0.0) del epochs # save memory dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=None) +# %% +# 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): +# (here the "time points" are the 32 dipole peak values that we fit). fig, axes = plt.subplots(2, 1) evoked.plot(axes=axes) @@ -165,20 +174,19 @@ for line in ax.lines: line.set_color("#98df81") residual.plot(axes=axes) - +# %% # 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. # A good fit: green lines are strong and residuals are small and roughly flat. -# %% -# Finally, we compare the estimated to the true dipole locations -# To do this, we calculate the difference by ..... -# This is our ground truth. + +# 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)) @@ -201,18 +209,16 @@ ax3.bar(event_id, amps) ax3.set_xlabel("Dipole index") ax3.set_ylabel("Amplitude error (nAm)") - -# The dipole fits closely match the phantom ground truth. +# %% +# The dipole fits closely match the true phantom data. # We can achieve sub-centimeter accuracy with a mean position error of 2.6 mm. # This demonstrates that the fitting procedure is accurate. -# %% + # Finally, we can plot the positions and the orientations -# of the actual and the estimated dipoles +# 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 goodness-of-fit (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( @@ -227,7 +233,7 @@ subjects_dir=subjects_dir, ) -# Plot the position and the orientation of the actual dipole in black +# 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 ) @@ -238,9 +244,8 @@ ) mne.viz.set_3d_view(figure=fig, azimuth=70, elevation=80, distance=0.5) - -# Here the green arrows represent the estimated dipoles, the black arrows -# the true dipole location +# %% +# The dipoles overlap and point in the same direction. # %% # References # ---------- From 0be4f483e1f763ccbe21a13224f8ebb81094c746 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 14 Feb 2026 18:45:25 +1000 Subject: [PATCH 10/14] more formatting --- .../inverse/80_brainstorm_phantom_elekta.py | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index fbd0afa37c8..cc772f7bbe4 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -44,7 +44,7 @@ raw = read_raw_fif(raw_fname) # %% -# Let's first get an idea of the data structure we are working with: +# Let's first get an idea of the data structure we are working with. raw.info @@ -53,33 +53,34 @@ # 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.info["bads"] = ["MEG1933", "MEG2421"] # known bad channels - -# we have 32 artificial dipoles stored as events -# the remaining event IDs are not relevant for this tutorial (256, 768 and so on) # %% +# we have 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 +# 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" ) - +# %% # We can see that the data has strong line frequency (60 Hz, 120 Hz ...) noise # and cHPI (continuous head position indicator) coil noise (peaks around 300 Hz). -# %% -# Next we plot the dipole events +# Next we plot the dipole events +# %% raw.plot(events=events, n_channels=10) - -# We can see that the simulated dipoles produce sinusoidal bursts at 20 Hz # %% -# Next, we epoch the the data based on the dipoles events (1:32). +# We can see that 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)) @@ -91,15 +92,12 @@ # 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 3ms, with a peak repeating every 3ms. -# Thus the burst repetition rate is 3Hz. +# at around 3 ms. The burst envelope repeats at approximately 3 Hz. + -# %% -# .. _plt_brainstorm_phantom_elekta_eeg_sphere_geometry: -# # Finally, we source reconstruct the evoked simulated dipole. # To do this we use a :ref:`sphere head geometry model ` # and visualise the coordinate alignment and the sphere location. The phantom @@ -130,9 +128,7 @@ ) # %% # 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. # See covariance/whitening tutorial for details :ref:`tut-compute-covariance`. # %% @@ -156,16 +152,12 @@ dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=None) # %% # 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: @@ -178,8 +170,8 @@ # 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. # A good fit: 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 @@ -210,9 +202,8 @@ ax3.set_xlabel("Dipole index") ax3.set_ylabel("Amplitude error (nAm)") # %% -# The dipole fits closely match the true phantom data. -# We can achieve sub-centimeter accuracy with a mean position error of 2.6 mm. -# This demonstrates that the fitting procedure is accurate. +# 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. @@ -246,6 +237,7 @@ mne.viz.set_3d_view(figure=fig, azimuth=70, elevation=80, distance=0.5) # %% # The dipoles overlap and point in the same direction. + # %% # References # ---------- From c5e8c3f8cb41d3f4f0fd7899ed419e47f39ab5f1 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 15 Feb 2026 14:21:40 +1000 Subject: [PATCH 11/14] final formatting --- .../inverse/80_brainstorm_phantom_elekta.py | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index cc772f7bbe4..3c9dd957561 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -51,34 +51,30 @@ # %% # 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.info["bads"] = ["MEG1933", "MEG2421"] # known bad channels # %% -# we have 32 artificial dipoles stored as events -# the remaining event IDs are not relevant for this tutorial (256, 768 ... ). - +# 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 -# %% +# 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" ) # %% -# We can see that the data has strong line frequency (60 Hz, 120 Hz ...) noise +# 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). - -# Next we plot the dipole events -# %% +# +# Next we plot the dipole events. raw.plot(events=events, n_channels=10) # %% # We can see that 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 @@ -96,8 +92,7 @@ # 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. - - +# # Finally, we source reconstruct the evoked simulated dipole. # To do this we use a :ref:`sphere head geometry model ` # and visualise the coordinate alignment and the sphere location. The phantom @@ -109,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) @@ -128,10 +122,11 @@ ) # %% # 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. -# See covariance/whitening tutorial for details :ref:`tut-compute-covariance`. -# %% +# 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) @@ -157,7 +152,6 @@ # 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: @@ -169,9 +163,10 @@ # %% # 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. -# A good fit: green lines are strong and residuals are small and roughly flat. +# 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 @@ -204,10 +199,9 @@ # %% # 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 goodness-of-fit (GOF) dip_true = mne.Dipole(dip.times, actual_pos, actual_amp, actual_ori, actual_gof) @@ -223,20 +217,17 @@ show_axes=True, subjects_dir=subjects_dir, ) - # 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 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) # %% -# The dipoles overlap and point in the same direction. +# We can see that the dipoles overlap and point in the same direction. # %% # References From 9ccff3f19c89ae2cf550c3fe673821281f102f33 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 15 Feb 2026 14:25:45 +1000 Subject: [PATCH 12/14] add blank lines around directives --- tutorials/inverse/80_brainstorm_phantom_elekta.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index 3c9dd957561..091096a4740 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -232,4 +232,6 @@ # %% # References # ---------- +# # .. footbibliography:: +# From 9fc7c4aba4022c8706f29089e06f8c6afc9963dd Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 15 Feb 2026 14:57:57 +1000 Subject: [PATCH 13/14] add blank line at file end --- tutorials/inverse/80_brainstorm_phantom_elekta.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index 091096a4740..98db3babba2 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -68,10 +68,10 @@ # 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). # -# Next we plot the dipole events. +# Here we plot the dipole events. raw.plot(events=events, n_channels=10) # %% -# We can see that the simulated dipoles produce sinusoidal bursts at 20 Hz. +# The simulated dipoles produce sinusoidal bursts at 20 Hz. # # Next, we epoch the the data based on the dipoles events (1:32). # @@ -148,6 +148,7 @@ # %% # 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 @@ -163,6 +164,7 @@ # %% # 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. # From e8299e2f1e9b7ca2bb8486a88854e6db2a53a26a Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Mon, 16 Feb 2026 19:46:16 +1000 Subject: [PATCH 14/14] fix sphinx error log --- tutorials/inverse/80_brainstorm_phantom_elekta.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tutorials/inverse/80_brainstorm_phantom_elekta.py b/tutorials/inverse/80_brainstorm_phantom_elekta.py index 98db3babba2..5ed0162d5e9 100644 --- a/tutorials/inverse/80_brainstorm_phantom_elekta.py +++ b/tutorials/inverse/80_brainstorm_phantom_elekta.py @@ -237,3 +237,4 @@ # # .. footbibliography:: # +#