Skip to content

Commit 58f29d8

Browse files
committed
chore: commit workspace changes
1 parent 6707170 commit 58f29d8

99 files changed

Lines changed: 20702 additions & 1396 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.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import time
2+
import numpy as np
3+
from pylsl import StreamInfo, StreamOutlet, local_clock
4+
import argparse
5+
6+
7+
def cosine_publisher(
8+
name="CosineWave",
9+
stype="EMG",
10+
fs=2000,
11+
f0=1.0,
12+
amp=0.5,
13+
offset=0.0,
14+
chunk=64,
15+
phase_deg=0.0, # 0.0 => identical channels; set e.g. 90 for a phase shift on ch1
16+
):
17+
"""
18+
Publish a 2-channel cosine on LSL using chunked writes + explicit timestamps.
19+
Channel 0: base cosine. Channel 1: same cosine (optionally phase-shifted).
20+
"""
21+
n_channels = 2
22+
info = StreamInfo(name, stype, n_channels, fs, 'float32', 'cosine-2ch')
23+
24+
# (optional) add channel labels to metadata
25+
chs = info.desc().append_child("channels")
26+
for i, label in enumerate(("ch0", "ch1")):
27+
ch = chs.append_child("channel")
28+
ch.append_child_value("label", label)
29+
ch.append_child_value("unit", "a.u.")
30+
31+
outlet = StreamOutlet(info, chunk_size=chunk, max_buffered=int(fs * 10))
32+
print(f"[publisher] '{name}': fs={fs} Hz, f0={f0} Hz, chunk={chunk}, channels={n_channels}")
33+
34+
t0 = local_clock()
35+
n_sent = 0
36+
phase = np.deg2rad(phase_deg)
37+
38+
try:
39+
while True:
40+
# Monotonic sample indices -> perfect timebase
41+
idx = np.arange(n_sent, n_sent + chunk, dtype=np.int64)
42+
t = idx / fs
43+
44+
# Build the two channels
45+
y0 = offset + amp * np.cos(2 * np.pi * f0 * t)
46+
y1 = offset + amp * np.cos(2 * np.pi * f0 * t + phase) # same if phase=0
47+
48+
# LSL expects shape (n_samples, n_channels)
49+
outlet.push_chunk(
50+
np.column_stack((y0, y1)).astype(np.float32),
51+
(t0 + t).tolist() # One timestamp per row
52+
)
53+
54+
# Sleep until the theoretical time of the next block
55+
n_sent += chunk
56+
next_wakeup = t0 + (n_sent / fs)
57+
wait = next_wakeup - local_clock()
58+
if wait > 0:
59+
time.sleep(wait)
60+
except KeyboardInterrupt:
61+
print(f"[publisher] '{name}': stopped after sending {n_sent} samples.")
62+
63+
64+
if __name__ == "__main__":
65+
ap = argparse.ArgumentParser(description="Publish a 2-ch cosine over LSL.")
66+
ap.add_argument("--name", default="CosineWave")
67+
ap.add_argument("--type", default="EMG")
68+
ap.add_argument("--fs", type=int, default=2000)
69+
ap.add_argument("--f0", type=float, default=1.0)
70+
ap.add_argument("--amp", type=float, default=0.5)
71+
ap.add_argument("--offset", type=float, default=0.0)
72+
ap.add_argument("--chunk", type=int, default=64)
73+
ap.add_argument("--phase", type=float, default=0.0, help="deg phase shift on ch1")
74+
args = ap.parse_args()
75+
cosine_publisher(args.name, args.type, args.fs, args.f0, args.amp, args.offset, args.chunk, args.phase)

examples/LSL/lsl_stacked_plot.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import sys
2+
import argparse
3+
from PyQt5.QtWidgets import QApplication
4+
from pyoephys.interface import LSLClient
5+
from pyoephys.plotting import StackedPlot
6+
from pyoephys.io import parse_numeric_args
7+
8+
9+
if __name__ == "__main__":
10+
parser = argparse.ArgumentParser(description="Launch real-time EMG stacked plot from LSL stream.")
11+
parser.add_argument("--channels", nargs="+", default=["0", "1", "2", "3"],
12+
help="Channels to plot: e.g., --channels 0 1 2 or --channels 0:64 or --channels all")
13+
parser.add_argument("--stream_name", type=str, default=None, help="LSL stream name to look for (default: None)")
14+
parser.add_argument("--stream_type", type=str, default=None, help="LSL stream type to look for (default: None)")
15+
parser.add_argument("--ylim", type=float, nargs=2, default=[-1.0, 1.0], help="Y-axis limits for the plot (default: [-1.0, 1.0])")
16+
parser.add_argument("--downsample", type=int, default=1, help="Downsample factor (e.g., 2, 5, 10)")
17+
args = parser.parse_args()
18+
19+
# Parse channel selection
20+
channels = parse_numeric_args(args.channels)
21+
print(f"Channels to plot: {channels}")
22+
23+
# Launch the Qt Application
24+
app = QApplication(sys.argv)
25+
26+
client = LSLClient(stream_name=args.stream_name, stream_type=args.stream_type, channels=channels)
27+
client.start()
28+
29+
# Create and launch the stacked plotter
30+
plotter = StackedPlot(
31+
client=client,
32+
auto_ylim=False,
33+
)
34+
plotter.show()
35+
sys.exit(app.exec_())

examples/LSL/lsl_viewer.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env python3
2+
import sys
3+
import argparse
4+
import numpy as np
5+
6+
from PyQt5 import QtCore
7+
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout
8+
import pyqtgraph as pg
9+
10+
from pyoephys.interface._lsl_client import LSLClient, NotReadyError
11+
from pyoephys.io import parse_numeric_args
12+
from pyoephys.logging import configure
13+
14+
def _parse_channels_arg(tokens):
15+
if tokens is None:
16+
return None
17+
if isinstance(tokens, str):
18+
s = tokens.strip().lower()
19+
if s == "all":
20+
return None
21+
s = s.replace(":", "-")
22+
return parse_numeric_args(s)
23+
if len(tokens) == 1:
24+
t0 = tokens[0].strip().lower()
25+
if t0 == "all":
26+
return None
27+
return parse_numeric_args(t0.replace(":", "-"))
28+
return parse_numeric_args([int(x) for x in tokens])
29+
30+
def _robust_span(y):
31+
"""Median ± k*MAD for a robust amplitude estimate."""
32+
med = np.median(y)
33+
mad = 1.4826 * np.median(np.abs(y - med)) # ~std for Gaussian
34+
return max(1e-6, 4.0 * mad) # 4*MAD ≈ ~97% span
35+
36+
class LSLViewer(QWidget):
37+
def __init__(
38+
self,
39+
stream_name=None,
40+
stream_type="EMG",
41+
window_s=5.0,
42+
downsample=1,
43+
ylim=None,
44+
channels=None,
45+
timeout_s=5.0,
46+
timer_ms=16,
47+
precise_timer=False,
48+
resample_hz=120.0, # visual grid Hz
49+
clip_to_view=False,
50+
stacked=False,
51+
stack_spacing=None, # float (units of signal). None = auto
52+
verbose=False,
53+
):
54+
super().__init__()
55+
self.setWindowTitle("pyoephys LSL Viewer")
56+
self.client = LSLClient(
57+
stream_name=stream_name,
58+
stream_type=stream_type or "EMG",
59+
timeout_s=timeout_s,
60+
buffer_seconds=max(window_s * 2.5, 30.0),
61+
verbose=verbose,
62+
)
63+
self.window_s = float(window_s)
64+
self.downsample = max(1, int(downsample))
65+
self.fixed_ylim = ylim
66+
self.req_channels = channels
67+
self.idx = None
68+
self.resample_hz = float(resample_hz) if resample_hz else None
69+
self.clip_to_view = bool(clip_to_view)
70+
self.stacked = bool(stacked)
71+
self.stack_spacing = stack_spacing # None => auto per frame
72+
73+
# UI
74+
layout = QVBoxLayout(self)
75+
self.plot = pg.PlotWidget()
76+
self.plot.setBackground("w")
77+
self.plot.addLegend(offset=(10, 10))
78+
if self.fixed_ylim is not None and not self.stacked:
79+
self.plot.setYRange(self.fixed_ylim[0], self.fixed_ylim[1])
80+
self.plot.showGrid(x=True, y=True, alpha=0.3)
81+
layout.addWidget(self.plot)
82+
83+
self.curves = []
84+
85+
# start client thread
86+
self.client.start()
87+
88+
# timer
89+
self.timer = pg.QtCore.QTimer(self)
90+
if precise_timer:
91+
self.timer.setTimerType(QtCore.Qt.PreciseTimer)
92+
self.timer.timeout.connect(self._on_timer)
93+
self.timer.start(int(timer_ms))
94+
95+
def closeEvent(self, e):
96+
try:
97+
self.timer.stop()
98+
except Exception:
99+
pass
100+
self.client.stop()
101+
return super().closeEvent(e)
102+
103+
def _ensure_curves(self):
104+
if not self.client.ready_event.is_set():
105+
return False
106+
if self.curves:
107+
return True
108+
109+
C = self.client.n_channels or 0
110+
if C <= 0:
111+
return False
112+
113+
# resolve channel indices
114+
if self.req_channels is None:
115+
self.idx = list(range(C))
116+
else:
117+
self.idx = [i for i in self.req_channels if 0 <= i < C] or [0]
118+
119+
palette = [pg.intColor(i, hues=len(self.idx)) for i in range(len(self.idx))]
120+
for k, ch in enumerate(self.idx):
121+
item = self.plot.plot([], [], pen=pg.mkPen(palette[k], width=1.0),
122+
name=f"ch{ch}")
123+
if self.clip_to_view:
124+
item.setClipToView(True)
125+
item.setDownsampling(auto=True, method="peak")
126+
self.curves.append(item)
127+
128+
# In stacked mode, let Y auto-range; we’ll offset traces ourselves.
129+
if self.fixed_ylim is not None and not self.stacked:
130+
self.plot.setYRange(self.fixed_ylim[0], self.fixed_ylim[1])
131+
132+
return True
133+
134+
def _resample_for_display(self, t, y):
135+
if self.resample_hz is None or t.size < 4:
136+
return t, y
137+
t0 = max(t[0], t[-1] - self.window_s)
138+
n_pts = max(64, int(self.window_s * self.resample_hz))
139+
t_uniform = np.linspace(t0, t[-1], n_pts, dtype=np.float64)
140+
y_out = np.empty((y.shape[0], n_pts), dtype=y.dtype)
141+
for i in range(y.shape[0]):
142+
y_out[i] = np.interp(t_uniform, t, y[i])
143+
return t_uniform, y_out
144+
145+
def _apply_stacking(self, y_disp):
146+
"""Return y_disp with vertical offsets per channel."""
147+
n = y_disp.shape[0]
148+
if n <= 1:
149+
return y_disp, 0.0
150+
151+
if self.stack_spacing is None:
152+
# auto spacing based on robust span of all channels in view
153+
span = max(_robust_span(y_disp[i]) for i in range(n))
154+
spacing = 1.25 * span # small gap
155+
else:
156+
spacing = float(self.stack_spacing)
157+
158+
y_off = np.empty_like(y_disp)
159+
for i in range(n):
160+
y_off[i] = y_disp[i] + (n - 1 - i) * spacing # top-down stacking
161+
return y_off, spacing
162+
163+
def _on_timer(self):
164+
if not self._ensure_curves():
165+
return
166+
try:
167+
y, t = self.client.get_window(self.window_s)
168+
except NotReadyError:
169+
return
170+
if y.size == 0:
171+
return
172+
173+
# optional decimation
174+
if self.downsample > 1:
175+
y = y[:, :: self.downsample]
176+
t = t[:: self.downsample]
177+
178+
# resample for smooth visual motion
179+
t_disp, y_disp = self._resample_for_display(t, y)
180+
181+
# stacked vs overlay
182+
if self.stacked and y_disp.shape[0] > 1:
183+
y_disp, spacing = self._apply_stacking(y_disp)
184+
# annotate left axis with stack step
185+
self.plot.setLabel("left", f"stacked (step={spacing:.3g})")
186+
else:
187+
self.plot.setLabel("left", "")
188+
189+
# update curves
190+
for k, ch in enumerate(self.idx):
191+
self.curves[k].setData(t_disp, y_disp[k])
192+
193+
self.plot.setXRange(t_disp[0], t_disp[-1], padding=0.02)
194+
195+
def main(argv=None):
196+
ap = argparse.ArgumentParser(description="Real-time LSL plot (overlay or stacked).")
197+
ap.add_argument("--stream_name", type=str, default=None)
198+
ap.add_argument("--stream_type", type=str, default="EMG")
199+
ap.add_argument("--channels", nargs="+", default=None,
200+
help='Channels: "all" | "0,1,2" | "0-7" | "0:7" | multiple tokens.')
201+
ap.add_argument("--window_s", type=float, default=5.0)
202+
ap.add_argument("--downsample", type=int, default=1)
203+
ap.add_argument("--ylim", type=float, nargs=2, default=None, help="Ignored in stacked mode")
204+
ap.add_argument("--timeout_s", type=float, default=5.0)
205+
ap.add_argument("--timer_ms", type=int, default=16)
206+
ap.add_argument("--precise_timer", action="store_true")
207+
ap.add_argument("--resample_hz", type=float, default=120.0)
208+
ap.add_argument("--opengl", action="store_true")
209+
ap.add_argument("--clip_to_view", action="store_true")
210+
ap.add_argument("--stacked", action="store_true", help="Draw channels with vertical offsets")
211+
ap.add_argument("--stack_spacing", type=float, default=None, help="Fixed spacing (signal units) between stacked traces")
212+
ap.add_argument("--verbose", action="store_true")
213+
args = ap.parse_args(argv)
214+
215+
configure("INFO" if args.verbose else "WARNING")
216+
if args.opengl:
217+
pg.setConfigOptions(useOpenGL=True)
218+
219+
chans = _parse_channels_arg(args.channels)
220+
221+
app = QApplication(sys.argv)
222+
w = LSLViewer(
223+
stream_name=args.stream_name,
224+
stream_type=args.stream_type,
225+
window_s=args.window_s,
226+
downsample=args.downsample,
227+
ylim=tuple(args.ylim) if args.ylim is not None else None,
228+
channels=chans,
229+
timeout_s=args.timeout_s,
230+
timer_ms=args.timer_ms,
231+
precise_timer=args.precise_timer,
232+
resample_hz=args.resample_hz,
233+
clip_to_view=args.clip_to_view,
234+
stacked=args.stacked,
235+
stack_spacing=args.stack_spacing,
236+
verbose=args.verbose,
237+
)
238+
w.resize(1000, 600)
239+
w.show()
240+
sys.exit(app.exec_())
241+
242+
if __name__ == "__main__":
243+
main()

0 commit comments

Comments
 (0)