Skip to content

Commit cc16de1

Browse files
committed
Update Networking Test Kit
1 parent e23869e commit cc16de1

8 files changed

Lines changed: 487 additions & 5 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pyxdf
2+
import matplotlib.pyplot as plt
3+
4+
# Load the XDF file
5+
file_path = "PROVIDE THE PATH TO THE XDF FILE HERE"
6+
data, header = pyxdf.load_xdf(file_path)
7+
8+
# Find the EEG stream
9+
eeg_stream = None
10+
for stream in data:
11+
if stream['info']['type'][0] == 'EXG': # Stream with LSL type 'EXG'
12+
eeg_stream = stream
13+
break
14+
15+
if eeg_stream is None:
16+
raise ValueError("No EEG stream found in the XDF file")
17+
18+
# Extract time series and time stamps
19+
time_series = eeg_stream['time_series']
20+
time_stamps = eeg_stream['time_stamps']
21+
22+
# Check the nominal sampling rate
23+
nominal_sampling_rate = float(eeg_stream['info']['nominal_srate'][0])
24+
print(f"Nominal sampling rate: {nominal_sampling_rate} Hz")
25+
26+
# Calculate the actual sampling rate
27+
actual_sampling_rate = len(time_stamps) / (time_stamps[-1] - time_stamps[0])
28+
print(f"Actual sampling rate: {actual_sampling_rate:.2f} Hz")
29+
30+
# Plot only channel 1 of the EEG data
31+
plt.figure(figsize=(12, 6))
32+
plt.plot(time_stamps, time_series[:, 0], label='Channel 1')
33+
34+
plt.xlabel('Time (s)')
35+
plt.ylabel('Amplitude')
36+
plt.title('EEG Time Series Data - Channel 1')
37+
plt.legend()
38+
plt.show()

Networking-Test-Kit/LSL/lslStreamTest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# first resolve an EEG stream on the lab network
77
print("looking for an EEG stream...")
8-
streams = resolve_byprop('type', 'EEG')
8+
streams = resolve_byprop('name', 'obci_stream_0')
99

1010
# create a new inlet to read from the stream
1111
inlet = StreamInlet(streams[0])
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Example program to show how to read a multi-channel time series from LSL."""
2+
import time
3+
from pylsl import StreamInlet, resolve_byprop
4+
from time import sleep
5+
6+
# first resolve an EEG stream on the lab network
7+
print("looking for an EEG stream...")
8+
streams = resolve_byprop('type', 'EEG')
9+
10+
# create a new inlet to read from the stream
11+
inlet = StreamInlet(streams[0])
12+
duration = 5
13+
14+
# get the full stream info (including custom meta-data) and dissect it
15+
info = inlet.info()
16+
print("The stream's XML meta-data is: ")
17+
print(info.as_xml())
18+
print("The manufacturer is: %s" % info.desc().child_value("manufacturer"))
19+
print("Cap circumference is: %s" % info.desc().child("cap").child_value("size"))
20+
print("The channel labels are as follows:")
21+
ch = info.desc().child("channels").child("channel")
22+
for k in range(info.channel_count()):
23+
print(" " + ch.child_value("label"))
24+
ch = ch.next_sibling()
25+
26+
sleep(1)
27+
28+
def testLSLSamplingRate():
29+
start = time.time()
30+
totalNumSamples = 0
31+
validSamples = 0
32+
numChunks = 0
33+
print( "Testing Sampling Rates..." )
34+
35+
while time.time() <= start + duration:
36+
# get chunks of samples
37+
chunk, timestamp = inlet.pull_chunk()
38+
if chunk:
39+
numChunks += 1
40+
# print( len(chunk) )
41+
totalNumSamples += len(chunk)
42+
# print(chunk);
43+
for sample in chunk:
44+
print(sample)
45+
validSamples += 1
46+
47+
print( "Number of Chunks and Samples == {} , {}".format(numChunks, totalNumSamples) )
48+
print( "Valid Samples and Duration == {} / {}".format(validSamples, duration) )
49+
print( "Avg Sampling Rate == {}".format(validSamples / duration) )
50+
51+
52+
testLSLSamplingRate()
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import time
2+
from pylsl import StreamInlet, resolve_byprop
3+
from time import sleep
4+
import pandas as pd
5+
import matplotlib.pyplot as plt
6+
from datetime import datetime
7+
8+
duration_seconds = 11
9+
channel_to_plot = 0
10+
buffer = []
11+
12+
def test_lsl_sampling_rate():
13+
start = time.time()
14+
total_samples_count = 0
15+
valid_samples_count = 0
16+
chunk_count = 0
17+
global previous_timestamp
18+
previous_timestamp = 0
19+
global timestamps_out_of_order_counter
20+
timestamps_out_of_order_counter = 0
21+
print( "Testing Sampling Rates..." )
22+
23+
while time.time() <= start + duration_seconds:
24+
# get chunks of samples
25+
chunk, timestamp = inlet.pull_chunk()
26+
if chunk:
27+
offset = inlet.time_correction()
28+
print("Offset: " + str(offset))
29+
new_chunk_received_time = datetime.now()
30+
print("\nNew chunk! -- Time: " + str(new_chunk_received_time))
31+
chunk_count += 1
32+
# print( len(chunk) )
33+
total_samples_count += len(chunk)
34+
# print(chunk)
35+
i = 0
36+
for sample in chunk:
37+
# print(sample, timestamp[i])
38+
add_sample_to_buffer(buffer, timestamp[i] * 1000, sample[channel_to_plot])
39+
valid_samples_count += 1
40+
i += 1
41+
42+
print( "Number of Chunks and Samples == {} , {}".format(chunk_count, total_samples_count) )
43+
print( "Valid Samples and duration_seconds == {} / {}".format(valid_samples_count, duration_seconds) )
44+
print( "Avg Sampling Rate == {}".format(valid_samples_count / duration_seconds) )
45+
print( "Number of timestamps out of order == {}".format(timestamps_out_of_order_counter) )
46+
47+
# Function to add a new sample to the buffer
48+
def add_sample_to_buffer(buffer, timestamp, value):
49+
global new_timestamp
50+
global previous_timestamp
51+
global timestamps_out_of_order_counter
52+
new_timestamp = timestamp
53+
if new_timestamp < previous_timestamp:
54+
print("Timestamps are not in order!")
55+
timestamps_out_of_order_counter += 1
56+
previous_timestamp = new_timestamp
57+
buffer.append({'Timestamp': timestamp, 'Value': value})
58+
print(f"Sample added to buffer: {timestamp}, {value}")
59+
60+
# Function to convert buffer to DataFrame
61+
def buffer_to_dataframe(buffer):
62+
data = pd.DataFrame(buffer)
63+
data['Timestamp'] = pd.to_datetime(data['Timestamp'])
64+
return data
65+
66+
# Function to plot the time series graph
67+
def plot_time_series(data):
68+
plt.figure(figsize=(10, 6))
69+
plt.plot(data['Timestamp'], data['Value'], marker='o', linestyle='-')
70+
plt.title('Time Series Data')
71+
plt.xlabel('Timestamp')
72+
plt.ylabel('Value')
73+
plt.grid(True)
74+
plt.show()
75+
76+
# first resolve an EEG stream on the lab network
77+
print("looking for an EEG stream...")
78+
streams = resolve_byprop('name', 'obci_stream_0')
79+
80+
# create a new inlet to read from the stream
81+
inlet = StreamInlet(streams[0])
82+
83+
sleep(1)
84+
85+
test_lsl_sampling_rate()
86+
87+
# Convert buffer to DataFrame and plot the time series
88+
data = buffer_to_dataframe(buffer)
89+
if not data.empty:
90+
plot_time_series(data)
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env python
2+
"""
3+
ReceiveAndPlot example for LSL
4+
5+
This example shows data from all found outlets in realtime.
6+
It illustrates the following use cases:
7+
- efficiently pulling data, re-using buffers
8+
- automatically discarding older samples
9+
- online postprocessing
10+
"""
11+
12+
import math
13+
from typing import List
14+
15+
import numpy as np
16+
import pyqtgraph as pg
17+
from pyqtgraph.Qt import QtCore, QtGui
18+
19+
import pylsl
20+
21+
# Basic parameters for the plotting window
22+
plot_duration = 5 # how many seconds of data to show
23+
update_interval = 60 # ms between screen updates
24+
pull_interval = 500 # ms between each pull operation
25+
26+
27+
class Inlet:
28+
"""Base class to represent a plottable inlet"""
29+
30+
def __init__(self, info: pylsl.StreamInfo):
31+
# create an inlet and connect it to the outlet we found earlier.
32+
# max_buflen is set so data older the plot_duration is discarded
33+
# automatically and we only pull data new enough to show it
34+
35+
# Also, perform online clock synchronization so all streams are in the
36+
# same time domain as the local lsl_clock()
37+
# (see https://labstreaminglayer.readthedocs.io/projects/liblsl/ref/enums.html#_CPPv414proc_clocksync)
38+
# and dejitter timestamps
39+
self.inlet = pylsl.StreamInlet(
40+
info,
41+
max_buflen=plot_duration,
42+
processing_flags=pylsl.proc_clocksync | pylsl.proc_dejitter,
43+
)
44+
# store the name and channel count
45+
self.name = info.name()
46+
self.channel_count = info.channel_count()
47+
48+
def pull_and_plot(self, plot_time: float, plt: pg.PlotItem):
49+
"""Pull data from the inlet and add it to the plot.
50+
:param plot_time: lowest timestamp that's still visible in the plot
51+
:param plt: the plot the data should be shown on
52+
"""
53+
# We don't know what to do with a generic inlet, so we skip it.
54+
pass
55+
56+
57+
class DataInlet(Inlet):
58+
"""A DataInlet represents an inlet with continuous, multi-channel data that
59+
should be plotted as multiple lines."""
60+
61+
dtypes = [[], np.float32, np.float64, None, np.int32, np.int16, np.int8, np.int64]
62+
63+
def __init__(self, info: pylsl.StreamInfo, plt: pg.PlotItem):
64+
super().__init__(info)
65+
# calculate the size for our buffer, i.e. two times the displayed data
66+
bufsize = (
67+
2 * math.ceil(info.nominal_srate() * plot_duration),
68+
info.channel_count(),
69+
)
70+
self.buffer = np.empty(bufsize, dtype=self.dtypes[info.channel_format()])
71+
empty = np.array([])
72+
# create one curve object for each channel/line that will handle displaying the data
73+
self.curves = [
74+
pg.PlotCurveItem(x=empty, y=empty, autoDownsample=True)
75+
for _ in range(self.channel_count)
76+
]
77+
for curve in self.curves:
78+
plt.addItem(curve)
79+
80+
def pull_and_plot(self, plot_time, plt):
81+
# pull the data
82+
_, ts = self.inlet.pull_chunk(
83+
timeout=0.0, max_samples=self.buffer.shape[0], dest_obj=self.buffer
84+
)
85+
# ts will be empty if no samples were pulled, a list of timestamps otherwise
86+
if ts:
87+
ts = np.asarray(ts)
88+
y = self.buffer[0 : ts.size, :]
89+
this_x = None
90+
old_offset = 0
91+
new_offset = 0
92+
for ch_ix in range(self.channel_count):
93+
# we don't pull an entire screen's worth of data, so we have to
94+
# trim the old data and append the new data to it
95+
old_x, old_y = self.curves[ch_ix].getData()
96+
# the timestamps are identical for all channels, so we need to do
97+
# this calculation only once
98+
if ch_ix == 0:
99+
# find the index of the first sample that's still visible,
100+
# i.e. newer than the left border of the plot
101+
old_offset = old_x.searchsorted(plot_time)
102+
# same for the new data, in case we pulled more data than
103+
# can be shown at once
104+
new_offset = ts.searchsorted(plot_time)
105+
# append new timestamps to the trimmed old timestamps
106+
this_x = np.hstack((old_x[old_offset:], ts[new_offset:]))
107+
# append new data to the trimmed old data
108+
this_y = np.hstack((old_y[old_offset:], y[new_offset:, ch_ix] - ch_ix))
109+
# replace the old data
110+
self.curves[ch_ix].setData(this_x, this_y)
111+
112+
113+
class MarkerInlet(Inlet):
114+
"""A MarkerInlet shows events that happen sporadically as vertical lines"""
115+
116+
def __init__(self, info: pylsl.StreamInfo):
117+
super().__init__(info)
118+
119+
def pull_and_plot(self, plot_time, plt):
120+
# TODO: purge old markers
121+
strings, timestamps = self.inlet.pull_chunk(0)
122+
if timestamps:
123+
for string, ts in zip(strings, timestamps):
124+
plt.addItem(
125+
pg.InfiniteLine(ts, angle=90, movable=False, label=string[0])
126+
)
127+
128+
129+
def main():
130+
# firstly resolve all streams that could be shown
131+
inlets: List[Inlet] = []
132+
print("looking for streams")
133+
streams = pylsl.resolve_streams()
134+
135+
# Create the pyqtgraph window
136+
pw = pg.plot(title="LSL Plot")
137+
plt = pw.getPlotItem()
138+
plt.enableAutoRange(x=False, y=True)
139+
140+
# iterate over found streams, creating specialized inlet objects that will
141+
# handle plotting the data
142+
for info in streams:
143+
if info.type() == "Markers":
144+
if (
145+
info.nominal_srate() != pylsl.IRREGULAR_RATE
146+
or info.channel_format() != pylsl.cf_string
147+
):
148+
print("Invalid marker stream " + info.name())
149+
print("Adding marker inlet: " + info.name())
150+
inlets.append(MarkerInlet(info))
151+
elif (
152+
info.nominal_srate() != pylsl.IRREGULAR_RATE
153+
and info.channel_format() != pylsl.cf_string
154+
):
155+
print("Adding data inlet: " + info.name())
156+
inlets.append(DataInlet(info, plt))
157+
else:
158+
print("Don't know what to do with stream " + info.name())
159+
160+
def scroll():
161+
"""Move the view so the data appears to scroll"""
162+
# We show data only up to a timepoint shortly before the current time
163+
# so new data doesn't suddenly appear in the middle of the plot
164+
fudge_factor = pull_interval * 0.002
165+
plot_time = pylsl.local_clock()
166+
pw.setXRange(plot_time - plot_duration + fudge_factor, plot_time - fudge_factor)
167+
168+
def update():
169+
# Read data from the inlet. Use a timeout of 0.0 so we don't block GUI interaction.
170+
mintime = pylsl.local_clock() - plot_duration
171+
# call pull_and_plot for each inlet.
172+
# Special handling of inlet types (markers, continuous data) is done in
173+
# the different inlet classes.
174+
for inlet in inlets:
175+
inlet.pull_and_plot(mintime, plt)
176+
177+
# create a timer that will move the view every update_interval ms
178+
update_timer = QtCore.QTimer()
179+
update_timer.timeout.connect(scroll)
180+
update_timer.start(update_interval)
181+
182+
# create a timer that will pull and add new data occasionally
183+
pull_timer = QtCore.QTimer()
184+
pull_timer.timeout.connect(update)
185+
pull_timer.start(pull_interval)
186+
187+
import sys
188+
189+
# Start Qt event loop unless running in interactive mode or using pyside.
190+
if (sys.flags.interactive != 1) or not hasattr(QtCore, "PYQT_VERSION"):
191+
QtGui.QGuiApplication.instance().exec()
192+
193+
194+
if __name__ == "__main__":
195+
main()

0 commit comments

Comments
 (0)