06. Analyze SEF Data with OPM#

This example demonstrates how to analyze somatosensory evoked field (SEF) data collected with optically pumped magnetometers (OPM) and compare it with superconducting quantum interference device (SQUID) data. The OPM data was obtained with the background field nulling coils we developed.

# Author: Mainak Jas <mjas@mgh.harvard.edu>

import json
import matplotlib.pyplot as plt

import mne
from opmcoils.analysis import find_events, add_ch_loc, read_opm_info

from pathlib import Path
import pooch

# sphinx_gallery_thumbnail_number = 3

First, use pooch to download the data

url = "https://osf.io/aqh27/download"

target_dir = Path.cwd() / 'data'
project_dir = target_dir / 'sef_data'

path = pooch.retrieve(
    url=url,
    known_hash=None,  # We don't know the hash
    path=target_dir,
    fname='sub-01.zip',  # Specify the filename
    processor=pooch.Unzip(extract_dir=project_dir),  # Extract to a folder named 'sub-01'
    progressbar=True
)

Then, we define the file paths

subject = 'sub-01'
date = '20240722'
cond = 'median'

subjects_dir = project_dir / subject
raw_fname = subjects_dir / 'opm_meg' / date / f'{date}_{subject}_paneltesting_{cond}_raw.fif'
raw_fname_squid = subjects_dir / 'squid_meg' / date / 'RT_median_raw.fif'
helmet_info_fname = target_dir / 'helmet_99channel_size-60.fif'
ch_mapping_fname = subjects_dir / 'opm_meg' / date / 'mapping.json'

Then, we start by processing OPM data

bads = ['01:15', '00:14', '00:15', '00:16']

raw_opm = mne.io.read_raw_fif(raw_fname, preload=True)
raw_opm.rename_channels(lambda x: x.strip('-BZ_CL'))
raw_opm.info['bads'] = bads

raw_opm.set_channel_types({'Input-1': 'stim'})
Opening raw data file /Users/mainak/Desktop/04_github_repos/opm_coils/examples/data/sef_data/sub-01/opm_meg/20240722/20240722_sub-01_paneltesting_median_raw.fif...
Isotrak not found
    Range : 0 ... 303721 =      0.000 ...   303.721 secs
Ready.
Reading 0 ... 303721  =      0.000 ...   303.721 secs...
General
Filename(s) 20240722_sub-01_paneltesting_median_raw.fif
MNE object type Raw
Measurement date 2024-07-22 at 18:06:45 UTC
Participant Unknown
Experimenter Unknown
Acquisition
Duration 00:05:04 (HH:MM:SS)
Sampling frequency 1000.00 Hz
Time points 303,722
Channels
Magnetometers and
Stimulus
Head & sensor digitization Not available
Filters
Highpass 0.00 Hz
Lowpass 500.00 Hz


Electrical median nerve stimulation creates artifacts. We will annotate the artifacts to avoid filtering the sharp transients.

events = find_events(raw_opm, min_duration=1. / raw_opm.info['sfreq'])
annot = mne.annotations_from_events(events, raw_opm.info['sfreq'],
                                    event_desc={1: 'BAD_STIM'})
# XXX: annotations_from_events should have duration option
annot.onset -= 0.002
annot.duration += 0.003

raw_opm.notch_filter(60.)

raw_opm.set_annotations(annot)
raw_opm.filter(4., None, skip_by_annotation=())
raw_opm.filter(None, 150, skip_by_annotation=('BAD_STIM'))
608 events found on stim channel Input-1
Event IDs: [1]
Filtering raw data in 1 contiguous segment
Setting up band-stop filter from 59 - 61 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandstop filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 59.35
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 59.10 Hz)
- Upper passband edge: 60.65 Hz
- Upper transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 60.90 Hz)
- Filter length: 6601 samples (6.601 s)

Filtering raw data in 1 contiguous segment
Setting up high-pass filter at 4 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal highpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 4.00
- Lower transition bandwidth: 2.00 Hz (-6 dB cutoff frequency: 3.00 Hz)
- Filter length: 1651 samples (1.651 s)

Filtering raw data in 609 contiguous segments
Setting up low-pass filter at 1.5e+02 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal lowpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Upper passband edge: 150.00 Hz
- Upper transition bandwidth: 37.50 Hz (-6 dB cutoff frequency: 168.75 Hz)
- Filter length: 89 samples (0.089 s)
General
Filename(s) 20240722_sub-01_paneltesting_median_raw.fif
MNE object type Raw
Measurement date 2024-07-22 at 18:06:45 UTC
Participant Unknown
Experimenter Unknown
Acquisition
Duration 00:05:04 (HH:MM:SS)
Sampling frequency 1000.00 Hz
Time points 303,722
Channels
Magnetometers and
Stimulus
Head & sensor digitization Not available
Filters
Highpass 4.00 Hz
Lowpass 150.00 Hz


Then, we create evoked response

reject = None # dict(mag=11e-12)
epochs_opm = mne.Epochs(raw_opm, events, tmax=0.3,  # ISI = 300 ms
                        reject_by_annotation=False,
                        reject=reject, reject_tmin=0.03,
                        preload=True)
evoked_opm = epochs_opm.average()
Not setting metadata
608 matching events found
Setting baseline interval to [-0.2, 0.0] s
Applying baseline correction (mode: mean)
0 projection items activated
Using data from preloaded Raw for 608 events and 501 original time points ...
1 bad epochs dropped

We add channel locations for the OPM sensors and plot the evoked response with spatial colors.

Magnetometers (15 channels)
Isotrak not found

<Figure size 640x300 with 2 Axes>

We use HFC to remove background artifacts.

projs_hfc = mne.preprocessing.compute_proj_hfc(evoked_opm.info, order=1)
evoked_opm.add_proj(projs_hfc)
evoked_opm.apply_proj()

# Alternatively, one could use SSP:
# projs_ssp = mne.compute_proj_epochs(epochs_opm.copy().crop(tmax=0), n_mag=2)
# evoked_opm.add_proj(projs_ssp)
# evoked_opm.apply_proj()
3 projection items deactivated
Created an SSP operator (subspace dimension = 3)
3 projection items activated
SSP projectors applied...
General
MNE object type EvokedArray
Measurement date 2024-07-22 at 18:06:45 UTC
Participant Unknown
Experimenter Unknown
Acquisition
Aggregation average of 607 epochs
Condition 1
Time range -0.200 – 0.300 s
Baseline -0.200 – 0.000 s
Sampling frequency 1000.00 Hz
Time points 501
Channels
Magnetometers and
Head & sensor digitization Not available
Filters
Highpass 4.00 Hz
Lowpass 150.00 Hz
Projections HFC: l=1 m=-1 (on)
HFC: l=1 m=0 (on)
HFC: l=1 m=1 (on)


Let us now process the SQUID data.

raw_squid = mne.io.read_raw_fif(raw_fname_squid, raw_fname_squid, preload=True)
raw_squid.pick_types(meg='mag', stim=True)
events = mne.find_events(raw_squid, min_duration=1. / raw_opm.info['sfreq'])

annot = mne.annotations_from_events(events, raw_squid.info['sfreq'],
                                    event_desc={1: 'BAD_STIM'})
# XXX: annotations_from_events should have duration option
annot.onset -= 0.002
annot.duration += 0.003
raw_squid.set_annotations(annot)

raw_squid.filter(4., None)
raw_squid.notch_filter(60.)
raw_squid.filter(None, 150, skip_by_annotation=('BAD_STIM'))
epochs = mne.Epochs(raw_squid, events, tmax=0.3,
                    reject_by_annotation=False)
evoked_squid = epochs.average()
evoked_squid.plot(ylim=dict(mag=(-300, 300)))
Magnetometers (102 channels)
Opening raw data file /Users/mainak/Desktop/04_github_repos/opm_coils/examples/data/sef_data/sub-01/squid_meg/20240722/RT_median_raw.fif...
    Read a total of 6 projection items:
        generated with dossp-2.1 (1 x 306)  idle
        generated with dossp-2.1 (1 x 306)  idle
        generated with dossp-2.1 (1 x 306)  idle
        generated with dossp-2.1 (1 x 306)  idle
        generated with dossp-2.1 (1 x 306)  idle
        generated with dossp-2.1 (1 x 306)  idle
    Range : 45000 ... 543999 =     22.500 ...   272.000 secs
Ready.
Reading 0 ... 498999  =      0.000 ...   249.500 secs...
NOTE: pick_types() is a legacy function. New code should use inst.pick(...).
496 events found on stim channel STI101
Event IDs: [1]
/Users/mainak/Desktop/04_github_repos/opm_coils/examples/plot_compare_sef.py:124: RuntimeWarning: Omitted 45 annotation(s) that were outside data range.
  raw_squid.set_annotations(annot)
Filtering raw data in 1 contiguous segment
Setting up high-pass filter at 4 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal highpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 4.00
- Lower transition bandwidth: 2.00 Hz (-6 dB cutoff frequency: 3.00 Hz)
- Filter length: 3301 samples (1.651 s)

Filtering raw data in 1 contiguous segment
Setting up band-stop filter from 59 - 61 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandstop filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 59.35
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 59.10 Hz)
- Upper passband edge: 60.65 Hz
- Upper transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 60.90 Hz)
- Filter length: 13201 samples (6.601 s)

Filtering raw data in 452 contiguous segments
Setting up low-pass filter at 1.5e+02 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal lowpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Upper passband edge: 150.00 Hz
- Upper transition bandwidth: 37.50 Hz (-6 dB cutoff frequency: 168.75 Hz)
- Filter length: 177 samples (0.088 s)

Not setting metadata
496 matching events found
Setting baseline interval to [-0.2, 0.0] s
Applying baseline correction (mode: mean)
Created an SSP operator (subspace dimension = 4)
6 projection items activated

<Figure size 640x300 with 2 Axes>

“Butterfly plots” are not appropriate to compare the evoked responses between OPMs and SQUID because the sensor locations are not comparable and hide individual sensor time series.

That is why, we pick sensors in similar locations on the OPM-MEG and SQUID-MEG and compare the evoked responses.

squid_chs = ['MEG0431', 'MEG0311']
opm_chs = ['MEG30', 'MEG09']
scale = 1e15
fig, axes = plt.subplots(2, 1, sharex=True)

for i, (opm_ch, squid_ch) in enumerate(zip(opm_chs, squid_chs)):
    ax = axes[i].twinx()
    ln1 = axes[i].plot(evoked_opm.times * 1e3,
                       evoked_opm.copy().pick([opm_ch]).data[0] * scale,
                       '#e66101')
    ln2 = ax.plot(evoked_squid.times * 1e3,
                  evoked_squid.copy().pick([squid_ch]).data[0] * scale,
                  '#5e3c99')
    axes[i].set_title(f'Location #{i + 1}')
    axes[i].legend(ln1 + ln2, ['OPM', 'SQUID'], loc=1)
    ax.legend()

fig.text(0.97, 0.45, 'SQUID data (fT)', va='center',
         rotation='vertical', fontsize=12)
fig.text(0.02, 0.45, 'OPM data (fT)', va='center',
         rotation='vertical', fontsize=12)
axes[1].set_xlabel('Time (ms)')
plt.tight_layout()
plt.show()

# Add inset
squid_ch, opm_ch = squid_chs[0], opm_chs[0]
inset_ax = axes[0].inset_axes([0.25, 0.5, 0.08, 0.45],
                              xlim=(10, 30), ylim=(-150, 200),
                              xticks=[], yticks=[])
inset_ax.plot(evoked_opm.times * 1e3,
              evoked_opm.copy().pick([opm_ch]).data[0] * scale,
              '#e66101')
inset_ax.plot(evoked_squid.times * 1e3,
              evoked_squid.copy().pick([squid_ch]).data[0] * scale,
              '#5e3c99')
for spine in inset_ax.spines.values():
    spine.set_linestyle('dotted')
axes[0].indicate_inset_zoom(inset_ax, edgecolor='black', linestyle='dotted')
Location #1, Location #2
/Users/mainak/Desktop/04_github_repos/opm_coils/examples/plot_compare_sef.py:156: UserWarning: No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.
  ax.legend()

(<matplotlib.patches.Rectangle object at 0x1af679bb0>, (<matplotlib.patches.ConnectionPatch object at 0x1af679d90>, <matplotlib.patches.ConnectionPatch object at 0x1af6a6130>, <matplotlib.patches.ConnectionPatch object at 0x1af6797f0>, <matplotlib.patches.ConnectionPatch object at 0x1af6a66d0>))

Total running time of the script: (0 minutes 15.769 seconds)

Gallery generated by Sphinx-Gallery