Skip to content

Commit 26a0b14

Browse files
update documentation for release 1.13.2
1 parent abea9be commit 26a0b14

351 files changed

Lines changed: 62433 additions & 1159 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

1.13.1/_downloads/0e1f06ac031754b1722bc7b181b7568d/10_peak_detection.ipynb

Lines changed: 230 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
"""
2+
Bandpower rolling window
3+
========================
4+
5+
With a :class:`~mne_lsl.stream.StreamLSL`, we can compute the bandpower on a time
6+
rolling window. For this example, we will look at the alpha band power, between 8 and
7+
13 Hz.
8+
"""
9+
10+
# sphinx_gallery_thumbnail_path = '_static/tutorials/bp-performance.png'
11+
12+
import time
13+
import uuid
14+
15+
import numpy as np
16+
from matplotlib import colormaps
17+
from matplotlib import pyplot as plt
18+
from mne.io import read_raw_fif
19+
from mne.time_frequency import psd_array_multitaper
20+
from numpy.typing import NDArray
21+
from scipy.integrate import simpson
22+
from scipy.signal import periodogram, welch
23+
24+
from mne_lsl.datasets import sample
25+
from mne_lsl.player import PlayerLSL
26+
from mne_lsl.stream import StreamLSL
27+
28+
# dataset used in the example
29+
raw = read_raw_fif(sample.data_path() / "sample-ant-raw.fif", preload=False)
30+
raw.crop(40, 60).load_data()
31+
raw
32+
33+
# %%
34+
# Preprocessing
35+
# -------------
36+
#
37+
# In a real-time scenario, we would want to apply artifact rejection methods online to
38+
# estimate the bandpower on brain signals, not on artifacts. For this example, we will
39+
# only apply a bandpass filter to the data.
40+
#
41+
# Estimating the bandpower
42+
# ------------------------
43+
#
44+
# First, we will define the function estimating the bandpower on a time window. The
45+
# bandpower will be estimated by integrating the power spectral density (PSD) on the
46+
# frequency band of interest, using the composite Simpson's rule
47+
# (:func:`scipy.integrate.simpson`).
48+
49+
50+
def bandpower(
51+
data: NDArray[np.float64],
52+
fs: float,
53+
method: str,
54+
band: tuple[float, float],
55+
relative: bool = True,
56+
**kwargs,
57+
) -> NDArray[np.float64]:
58+
"""Compute the bandpower of the individual channels.
59+
60+
Parameters
61+
----------
62+
data : array of shape (n_channels, n_samples)
63+
Data on which the the bandpower is estimated.
64+
fs : float
65+
Sampling frequency in Hz.
66+
method : 'periodogram' | 'welch' | 'multitaper'
67+
Method used to estimate the power spectral density.
68+
band : tuple of shape (2,)
69+
Frequency band of interest in Hz as 2 floats, e.g. ``(8, 13)``. The
70+
edges are included.
71+
relative : bool
72+
If True, the relative bandpower is returned instead of the absolute
73+
bandpower.
74+
**kwargs : dict
75+
Additional keyword arguments are provided to the power spectral density
76+
estimation function.
77+
* 'periodogram': scipy.signal.periodogram
78+
* 'welch'``: scipy.signal.welch
79+
* 'multitaper': mne.time_frequency.psd_array_multitaper
80+
81+
The only provided arguments are the data array and the sampling
82+
frequency.
83+
84+
Returns
85+
-------
86+
bandpower : array of shape (n_channels,)
87+
The bandpower of each channel.
88+
"""
89+
# compute the power spectral density
90+
assert data.ndim == 2, (
91+
"The provided data must be a 2D array of shape (n_channels, n_samples)."
92+
)
93+
if method == "periodogram":
94+
freqs, psd = periodogram(data, fs, **kwargs)
95+
elif method == "welch":
96+
freqs, psd = welch(data, fs, **kwargs)
97+
elif method == "multitaper":
98+
psd, freqs = psd_array_multitaper(data, fs, verbose="ERROR", **kwargs)
99+
else:
100+
raise RuntimeError(f"The provided method '{method}' is not supported.")
101+
# compute the bandpower
102+
assert len(band) == 2, "The 'band' argument must be a 2-length tuple."
103+
assert band[0] <= band[1], (
104+
"The 'band' argument must be defined as (low, high) (in Hz)."
105+
)
106+
freq_res = freqs[1] - freqs[0]
107+
idx_band = np.logical_and(freqs >= band[0], freqs <= band[1])
108+
bandpower = simpson(psd[:, idx_band], dx=freq_res)
109+
bandpower = bandpower / simpson(psd, dx=freq_res) if relative else bandpower
110+
return bandpower
111+
112+
113+
# %%
114+
# Real-time estimation on a rolling window
115+
# ----------------------------------------
116+
#
117+
# Next, we can estimate the alpha band power on a rolling window of 4 seconds by running
118+
# an infinite loop that reads the data from the stream and computes the bandpower on the
119+
# last 4 seconds of data.
120+
#
121+
# .. note::
122+
#
123+
# A chunk size of 200 samples is used to ensure stability in our documentation
124+
# build, but in practice, a real-time application will likely publish new samples
125+
# in smaller chunks and thus at a higher frequency. Due to the large chunk size,
126+
# the acquisition delay of the connected stream is also increased to reduce the
127+
# load on the CPU.
128+
129+
source_id = uuid.uuid4().hex
130+
with PlayerLSL(raw, chunk_size=200, name="bandpower-example", source_id=source_id):
131+
stream = StreamLSL(bufsize=4, name="bandpower-example", source_id=source_id)
132+
stream.connect(acquisition_delay=0.1, processing_flags="all")
133+
stream.pick("eeg").filter(1, 30)
134+
stream.get_data() # reset the number of new samples after the filter is applied
135+
136+
datapoints, times = [], []
137+
while stream.n_new_samples < stream.n_buffer:
138+
time.sleep(0.1) # wait for the buffer to be entirely filled
139+
while len(datapoints) != 30:
140+
if stream.n_new_samples == 0:
141+
continue # wait for new samples
142+
data, ts = stream.get_data()
143+
bp = bandpower(data, stream.info["sfreq"], "periodogram", band=(8, 13))
144+
datapoints.append(bp)
145+
times.append(ts[-1])
146+
stream.disconnect()
147+
148+
# %%
149+
# Plot in function of time
150+
# ------------------------
151+
#
152+
# We can now plot the rolling-window bandpower in function of time, using the timestamps
153+
# of the last sample for each window on the X-axis. For simplicity, let's average all
154+
# channels together.
155+
156+
f, ax = plt.subplots(1, 1, layout="constrained")
157+
ax.plot(times - times[0], [np.average(dp) * 100 for dp in datapoints])
158+
ax.set_xlabel("Time (s)")
159+
ax.set_ylabel("Relative α band power (%)")
160+
plt.show()
161+
162+
# %%
163+
# Delay between 2 samples
164+
# -----------------------
165+
#
166+
# Let's also have a look at the delay between 2 data points, i.e. the overlap between
167+
# the windows.
168+
169+
f, ax = plt.subplots(1, 1, layout="constrained")
170+
timedeltas = np.diff(times - times[0]) * 1000
171+
ax.hist(timedeltas, bins=15)
172+
ax.set_xlabel("Delay between 2 samples (ms)")
173+
plt.show()
174+
175+
# %%
176+
# .. note::
177+
#
178+
# Due to the low resources available on our CIs to build the documentation, some of
179+
# those datapoints might have been computed with 2 acquisition window of delay
180+
# instead of 1, yielding a delay between 2 samples of 2 acquisition windows instead
181+
# of 1. In practice, with a large chunk size of 200 samples, we should get a delay
182+
# between 2 computed time points to 200 samples, i.e. around 195.31 ms.
183+
#
184+
# Compare power spectral density estimation methods
185+
# -------------------------------------------------
186+
#
187+
# Let's compare both the bandpower estimation and the computation time of the different
188+
# methods to estimate the power spectral density.
189+
190+
methods = ("periodogram", "welch", "multitaper")
191+
with PlayerLSL(raw, chunk_size=200, name="bandpower-example", source_id=source_id):
192+
stream = StreamLSL(bufsize=4, name="bandpower-example", source_id=source_id)
193+
stream.connect(acquisition_delay=0.1, processing_flags="all")
194+
stream.pick("eeg").filter(1, 30)
195+
stream.get_data() # reset the number of new samples after the filter is applied
196+
197+
datapoints, times = {method: [] for method in methods}, []
198+
while stream.n_new_samples < stream.n_buffer:
199+
time.sleep(0.1) # wait for the buffer to be entirely filled
200+
while len(datapoints[methods[0]]) != 30:
201+
if stream.n_new_samples == 0:
202+
continue # wait for new samples
203+
data, ts = stream.get_data()
204+
for method in methods:
205+
bp = bandpower(data, stream.info["sfreq"], method, band=(8, 13))
206+
datapoints[method].append(bp)
207+
times.append(ts[-1])
208+
stream.disconnect()
209+
210+
f, ax = plt.subplots(1, 1, layout="constrained")
211+
for k, method in enumerate(methods):
212+
ax.plot(
213+
times - times[0],
214+
[np.average(dp) * 100 for dp in datapoints[method]],
215+
label=method,
216+
color=colormaps["viridis"].colors[k * 60 + 20],
217+
)
218+
ax.set_xlabel("Time (s)")
219+
ax.set_ylabel("Relative α band power (%)")
220+
ax.legend()
221+
plt.show()
222+
223+
# %%
224+
# For the computation time and the overall loop execution speed, we need to run each
225+
# method on a separate loop.
226+
227+
methods = ("periodogram", "welch", "multitaper")
228+
with PlayerLSL(raw, chunk_size=200, name="bandpower-example", source_id=source_id):
229+
stream = StreamLSL(bufsize=4, name="bandpower-example", source_id=source_id)
230+
stream.connect(acquisition_delay=0.1, processing_flags="all")
231+
stream.pick("eeg").filter(1, 30)
232+
stream.get_data() # reset the number of new samples after the filter is applied
233+
234+
times = {method: [] for method in methods}
235+
while stream.n_new_samples < stream.n_buffer:
236+
time.sleep(0.1) # wait for the buffer to be entirely filled
237+
for k, method in enumerate(methods):
238+
while len(times[methods[k]]) != 30:
239+
if stream.n_new_samples == 0:
240+
continue # wait for new samples
241+
data, ts = stream.get_data()
242+
bp = bandpower(data, stream.info["sfreq"], method, band=(8, 13))
243+
times[method].append(ts[-1])
244+
stream.disconnect()
245+
246+
timedeltas = {
247+
method: np.diff(times[method] - times[method][0]) * 1000 for method in methods
248+
}
249+
timedeltas_average = {method: np.average(timedeltas[method]) for method in methods}
250+
251+
for method in methods:
252+
print(
253+
f"Average delay between 2 samples for {method}: "
254+
f"{timedeltas_average[method]:.2f} ms"
255+
)
256+
257+
# %%
258+
# .. note::
259+
#
260+
# For this example, the average delay between 2 estimation of the bandpower is
261+
# similar between all 3 methods because we are waiting for new samples which come in
262+
# chunks of 200 samples, i.e. every 195.31 ms at the sampling frequency of 1024 Hz.
263+
# The figure obtained for a chunk size of 1 sample and an acquisition delay of 1 ms
264+
# is shown below.
265+
#
266+
# .. code-block:: python
267+
#
268+
# f, ax = plt.subplots(1, 1, layout="constrained")
269+
# for k, method in enumerate(methods):
270+
# ax.hist(
271+
# timedeltas[method],
272+
# bins=15,
273+
# label=method,
274+
# color=colormaps["viridis"].colors[k * 60 + 20],
275+
# )
276+
# ax.set_xlabel("Delay between 2 samples (ms)")
277+
# ax.legend()
278+
# plt.show()
279+
#
280+
# .. image:: ../../_static/tutorials/bp-performance.png
281+
# :align: center
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Automatic vs Manual acquisition
3+
===============================
4+
5+
.. include:: ./../../links.inc
6+
7+
The :class:`~mne_lsl.stream.StreamLSL` object offers 2 mode of acquisition: automatic or
8+
manual. In automatic mode, the stream object acquires new chunks of samples at a
9+
regular interval. In manual mode, the user has to call the
10+
:meth:`~mne_lsl.stream.StreamLSL.acquire` to acquire new chunks of samples from the
11+
network. The automatic or manual acquisition is selected via the ``acquisition_delay``
12+
argument of :meth:`~mne_lsl.stream.StreamLSL.connect`:
13+
14+
- a non-zero positive integer value will set the acquisition to automatic mode with the
15+
specified delay in seconds.
16+
- ``0`` will set the acquisition to manual mode.
17+
18+
Automatic acquisition
19+
---------------------
20+
21+
When the stream is set to automatically acquire new samples at a regular interval, a
22+
background thread is created with :class:`concurrent.futures.ThreadPoolExecutor`. The
23+
background thread is periodically receives a job to acquire new samples from the
24+
network.
25+
26+
.. important::
27+
28+
If the main thread is hogging all of the CPU resources, the delay between two
29+
acquisition job might be longer than the specified delay. The background thread
30+
will always do its best to acquire new samples at the specified delay, but it is not
31+
able to do so if the CPU is busy.
32+
"""
33+
34+
import uuid
35+
from time import sleep
36+
37+
from matplotlib import pyplot as plt
38+
39+
from mne_lsl.datasets import sample
40+
from mne_lsl.player import PlayerLSL
41+
from mne_lsl.stream import StreamLSL
42+
43+
# create a mock LSL stream for this tutorial
44+
fname = sample.data_path() / "sample-ant-raw.fif"
45+
source_id = uuid.uuid4().hex
46+
player = PlayerLSL(fname, chunk_size=200, source_id=source_id).start()
47+
player.info
48+
49+
# %%
50+
# .. note::
51+
#
52+
# A ``chunk_size`` of 200 samples is used here to ensure stability and reliability
53+
# while building the documentation on the CI. In practice, a ``chunk_size`` of 200
54+
# samples is too large to represent a real-time application.
55+
56+
stream = StreamLSL(bufsize=2, source_id=source_id).connect(acquisition_delay=0.1)
57+
sleep(2) # wait for new samples
58+
print(f"New samples acquired: {stream.n_new_samples}")
59+
stream.disconnect()
60+
61+
# %%
62+
# Manual acquisition
63+
# -------------------
64+
#
65+
# In manual acquisition mode, the user has to call the
66+
# :meth:`~mne_lsl.stream.StreamLSL.acquire` to get new samples from the network. In this
67+
# mode, all operation happens in the main thread and the user has full control over when
68+
# to acquire new samples.
69+
70+
stream = StreamLSL(bufsize=2, source_id=source_id).connect(acquisition_delay=None)
71+
sleep(2) # wait for new samples
72+
print(f"New samples acquired (before stream.acquire()): {stream.n_new_samples}")
73+
stream.acquire()
74+
print(f"New samples acquired (after stream.acquire()): {stream.n_new_samples}")
75+
76+
# %%
77+
# However, it is also now up to the user to make sure he acquires new samples regularly
78+
# and does not miss part of the stream. The created :class:`~mne_lsl.lsl.StreamInlet`
79+
# has its buffer set to the same value as the :class:`~mne_lsl.stream.StreamLSL` object.
80+
81+
stream.acquire()
82+
data1, ts1 = stream.get_data(picks="Cz")
83+
sleep(4) # wait for 2 buffers
84+
stream.acquire()
85+
data2, ts2 = stream.get_data(picks="Cz")
86+
87+
f, ax = plt.subplots(1, 1, layout="constrained")
88+
ax.plot(ts1 - ts1[0], data1.squeeze(), color="blue", label="acq 1")
89+
ax.plot(ts2 - ts1[0], data2.squeeze(), color="red", label="acq 2")
90+
ax.legend()
91+
ax.set_xlabel("Time (s)")
92+
ax.set_ylabel("EEG amplitude")
93+
plt.show()
94+
95+
# %%
96+
# Free resources
97+
# --------------
98+
#
99+
# When you are done with a :class:`~mne_lsl.player.PlayerLSL` or
100+
# :class:`~mne_lsl.stream.StreamLSL`, don't forget to free the resources they both use
101+
# to continuously mock an LSL stream or receive new data from an LSL stream.
102+
103+
stream.disconnect()
104+
105+
# %%
106+
107+
player.stop()
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)