Skip to content

Commit 6bd3b24

Browse files
authored
Merge pull request #15 from ezmsg-org/dev
CoordinateAxis for irregular rate streams
2 parents c789600 + 1e5ce4f commit 6bd3b24

11 files changed

+568
-327
lines changed

.github/workflows/python-tests.yml

+33-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
build:
1212
strategy:
1313
matrix:
14-
python-version: [3.9, "3.10", "3.11", "3.12"]
14+
python-version: ["3.10", "3.11", "3.12"]
1515
os:
1616
- "ubuntu-latest"
1717
- "windows-latest"
@@ -21,6 +21,32 @@ jobs:
2121
steps:
2222
- uses: actions/checkout@v4
2323

24+
- name: Install linux dependencies
25+
if: ${{ runner.os == 'Linux' }}
26+
shell: bash
27+
run: |
28+
sudo apt update
29+
sudo apt install -y binutils libpugixml-dev qtbase5-dev qt5-qmake
30+
31+
- name: Install liblsl (linux)
32+
if: ${{ runner.os == 'Linux' }}
33+
shell: bash
34+
run: |
35+
curl -L https://github.com/sccn/liblsl/releases/download/v1.16.2/liblsl-1.16.2-jammy_amd64.deb -o liblsl-1.16.2-jammy_amd64.deb
36+
sudo apt install -y ./liblsl-1.16.2-jammy_amd64.deb
37+
rm liblsl-1.16.2-jammy_amd64.deb
38+
39+
- name: Install liblsl (macOS)
40+
if: ${{ runner.os == 'macOS' }}
41+
shell: bash
42+
run: |
43+
curl -L https://github.com/sccn/liblsl/releases/download/v1.16.0/liblsl-1.16.0-OSX_arm64.tar.bz2 -o liblsl-1.16.0-OSX_arm64.tar.bz2
44+
tar -xf liblsl-1.16.0-OSX_arm64.tar.bz2
45+
mv lib/liblsl.1.16.0.dylib .
46+
echo "PYLSL_LIB=$PWD/liblsl.1.16.0.dylib" >> $GITHUB_ENV
47+
rm -R lib include bin
48+
rm liblsl-1.16.0-OSX_arm64.tar.bz2
49+
2450
- name: Install uv
2551
uses: astral-sh/setup-uv@v2
2652
with:
@@ -33,9 +59,10 @@ jobs:
3359
- name: Install the project
3460
run: uv sync --all-extras --dev
3561

36-
- name: Lint
37-
run:
38-
uv tool run ruff check --output-format=github src
62+
- name: Ruff check
63+
uses: astral-sh/ruff-action@v1
64+
with:
65+
src: "./src"
3966

40-
# - name: Run tests
41-
# run: uv run pytest tests
67+
- name: Run tests
68+
run: uv run pytest tests

examples/lsl_inlet_example.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import Optional
22

33
import ezmsg.core as ez
4-
from ezmsg.lsl.units import LSLInletUnit, LSLInletSettings
4+
from ezmsg.lsl.units import LSLInletUnit, LSLInletSettings, LSLInfo
55
from ezmsg.util.debuglog import DebugLog, DebugLogSettings
6+
import typer
67
# from ezmsg.util.messagelogger import MessageLogger, MessageLoggerSettings
78

89

@@ -23,8 +24,10 @@ class LSLDemoSystem(ez.Collection):
2324
def configure(self) -> None:
2425
self.INLET.apply_settings(
2526
LSLInletSettings(
26-
stream_name=self.SETTINGS.stream_name,
27-
stream_type=self.SETTINGS.stream_type,
27+
info=LSLInfo(
28+
name=self.SETTINGS.stream_name,
29+
type=self.SETTINGS.stream_type,
30+
)
2831
)
2932
)
3033
self.LOGGER.apply_settings(
@@ -38,8 +41,13 @@ def network(self) -> ez.NetworkDefinition:
3841
return ((self.INLET.OUTPUT_SIGNAL, self.LOGGER.INPUT),)
3942

4043

41-
if __name__ == "__main__":
42-
# Run the websocket system
44+
def main(stream_name: str = "", stream_type: str = "EEG"):
4345
system = LSLDemoSystem()
44-
system.apply_settings(LSLDemoSystemSettings(stream_name="", stream_type="EEG"))
46+
system.apply_settings(
47+
LSLDemoSystemSettings(stream_name=stream_name, stream_type=stream_type)
48+
)
4549
ez.run(SYSTEM=system)
50+
51+
52+
if __name__ == "__main__":
53+
typer.run(main)

examples/lsl_outlet_example.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import ezmsg.core as ez
99
from ezmsg.sigproc.synth import EEGSynth, EEGSynthSettings
1010
from ezmsg.lsl.units import LSLOutletUnit, LSLOutletSettings
11+
import typer
1112

1213

1314
class LSLDemoSystemSettings(ez.Settings):
@@ -48,10 +49,14 @@ def process_components(self) -> Tuple[ez.Component, ...]:
4849
return self.EEG, self.OUTLET
4950

5051

51-
if __name__ == "__main__":
52+
def main(stream_name: str = "ezmsg-EEGSynth", stream_type: str = "EEG"):
5253
# Run the websocket system
5354
system = LSLDemoSystem()
5455
system.apply_settings(
55-
LSLDemoSystemSettings(stream_name="ezmsg-EEGSynth", stream_type="EEG")
56+
LSLDemoSystemSettings(stream_name=stream_name, stream_type=stream_type)
5657
)
5758
ez.run(SYSTEM=system)
59+
60+
61+
if __name__ == "__main__":
62+
typer.run(main)

pyproject.toml

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ authors = [
77
readme = "README.md"
88
requires-python = ">=3.9"
99
dependencies = [
10-
"ezmsg>=3.5.0",
10+
"ezmsg>=3.6.0",
1111
"numpy>=1.26.4",
1212
"pylsl>=1.16.2",
1313
]
1414
dynamic = ["version"]
1515

1616
[project.optional-dependencies]
1717
test = [
18-
"ezmsg-sigproc>=1.3.2",
18+
"ezmsg-sigproc>=1.5.0",
1919
"flake8>=7.1.1",
2020
"pytest-cov>=5.0.0",
2121
"pytest>=8.3.3",
@@ -37,6 +37,7 @@ packages = ["src/ezmsg"]
3737
[tool.uv]
3838
dev-dependencies = [
3939
"ruff>=0.6.6",
40+
"typer>=0.13.0",
4041
]
4142

4243
[tool.uv.sources]

src/ezmsg/lsl/inlet.py

+39-71
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import asyncio
2-
from dataclasses import dataclass, field, fields, replace
2+
from dataclasses import dataclass, field, fields
33
import time
44
import typing
55

66
import ezmsg.core as ez
7+
from ezmsg.util.messages.axisarray import AxisArray
8+
from ezmsg.util.messages.util import replace
79
import numpy as np
810
import numpy.typing as npt
911
import pylsl
1012

11-
from .util import AxisArray
13+
from .util import ClockSync
1214

1315

1416
fmt2npdtype = {
@@ -76,42 +78,6 @@ class LSLInletState(ez.State):
7678
clock_offset: float = 0.0
7779

7880

79-
class ClockSync:
80-
def __init__(self, alpha: float = 0.1, min_interval: float = 0.5):
81-
self.alpha = alpha
82-
self.min_interval = min_interval
83-
84-
self.offset = 0.0
85-
self.last_update = 0.0
86-
self.count = 0
87-
88-
async def update(self, force: bool = False, burst: int = 4) -> None:
89-
dur_since_last = time.time() - self.last_update
90-
dur_until_next = self.min_interval - dur_since_last
91-
if force or dur_until_next <= 0:
92-
offsets = []
93-
for _ in range(burst):
94-
if self.count % 2:
95-
y, x = time.time(), pylsl.local_clock()
96-
else:
97-
x, y = pylsl.local_clock(), time.time()
98-
offsets.append(y - x)
99-
self.last_update = y
100-
await asyncio.sleep(0.001)
101-
offset = np.mean(offsets)
102-
103-
if self.count > 0:
104-
# Exponential decay smoothing
105-
offset = (1 - self.alpha) * self.offset + self.alpha * offset
106-
self.offset = offset
107-
self.count += burst
108-
else:
109-
await asyncio.sleep(dur_until_next)
110-
111-
def convert_timestamp(self, lsl_timestamp: float) -> float:
112-
return lsl_timestamp + self.offset
113-
114-
11581
class LSLInletUnit(ez.Unit):
11682
"""
11783
Represents a node in a graph that creates an LSL inlet and
@@ -223,22 +189,29 @@ def _reset_inlet(self) -> None:
223189
ch_labels.append(str(len(ch_labels) + 1))
224190
# Pre-allocate a message template.
225191
fs = inlet_info.nominal_srate()
192+
time_ax = (
193+
AxisArray.TimeAxis(fs=fs)
194+
if fs
195+
else AxisArray.CoordinateAxis(
196+
data=np.array([]), dims=["time"], unit="s"
197+
)
198+
)
226199
self._msg_template = AxisArray(
227200
data=np.empty((0, n_ch)),
228201
dims=["time", "ch"],
229202
axes={
230-
"time": AxisArray.Axis.TimeAxis(
231-
fs=fs if fs else 1.0
232-
), # HACK: Use 1.0 for irregular rate.
233-
"ch": AxisArray.Axis.SpaceAxis(labels=ch_labels),
203+
"time": time_ax,
204+
"ch": AxisArray.CoordinateAxis(
205+
data=np.array(ch_labels), dims=["ch"]
206+
),
234207
},
235208
key=inlet_info.name(),
236209
)
237210

238211
async def initialize(self) -> None:
239212
self._reset_resolver()
240213
self._reset_inlet()
241-
# TODO: Let the clock_sync task do its job at the beginning.
214+
await self.clock_sync.update(force=True, burst=1000)
242215

243216
def shutdown(self) -> None:
244217
if self.STATE.inlet is not None:
@@ -252,8 +225,7 @@ def shutdown(self) -> None:
252225
@ez.task
253226
async def clock_sync_task(self) -> None:
254227
while True:
255-
force = self.clock_sync.count < 1000
256-
await self.clock_sync.update(force=force, burst=1000 if force else 4)
228+
await self.clock_sync.update(force=False, burst=4)
257229

258230
@ez.subscriber(INPUT_SETTINGS)
259231
async def on_settings(self, msg: LSLInletSettings) -> None:
@@ -292,36 +264,32 @@ async def lsl_pull(self) -> typing.AsyncGenerator:
292264
if samples is None
293265
else samples
294266
)
267+
295268
if self.SETTINGS.use_arrival_time:
296-
# time.time() gives us NOW, but we want the timestamp of the 0th sample in the chunk
297-
t0 = time.time() - (timestamps[-1] - timestamps[0])
269+
timestamps = time.time() - (timestamps - timestamps[0])
298270
else:
299-
t0 = self.clock_sync.convert_timestamp(timestamps[0])
271+
timestamps = self.clock_sync.lsl2system(timestamps)
272+
300273
if self.SETTINGS.info.nominal_srate <= 0.0:
301-
# Irregular rate streams need to be streamed sample-by-sample
302-
for ts, samp in zip(timestamps, data):
303-
out_msg = replace(
304-
self._msg_template,
305-
data=samp[None, ...],
306-
axes={
307-
**self._msg_template.axes,
308-
"time": replace(
309-
self._msg_template.axes["time"],
310-
offset=t0 + (ts - timestamps[0]),
311-
),
312-
},
313-
)
314-
yield self.OUTPUT_SIGNAL, out_msg
274+
# Irregular rate stream uses CoordinateAxis for time so each sample has a timestamp.
275+
out_time_ax = replace(
276+
self._msg_template.axes["time"],
277+
data=np.array(timestamps),
278+
)
315279
else:
316-
# Regular-rate streams can go in a chunk
317-
out_msg = replace(
318-
self._msg_template,
319-
data=data,
320-
axes={
321-
**self._msg_template.axes,
322-
"time": replace(self._msg_template.axes["time"], offset=t0),
323-
},
280+
# Regular rate uses a LinearAxis for time so we only need the time of the first sample.
281+
out_time_ax = replace(
282+
self._msg_template.axes["time"], offset=timestamps[0]
324283
)
325-
yield self.OUTPUT_SIGNAL, out_msg
284+
285+
out_msg = replace(
286+
self._msg_template,
287+
data=data,
288+
axes={
289+
**self._msg_template.axes,
290+
"time": out_time_ax,
291+
},
292+
)
293+
yield self.OUTPUT_SIGNAL, out_msg
326294
else:
327295
await asyncio.sleep(0.001)

0 commit comments

Comments
 (0)