diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a31f8b3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test + +on: [push, pull_request] + +jobs: + style: + name: Check style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 + - uses: chartboost/ruff-action@v1 + with: + args: 'format --check' + + test: + needs: style + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + name: Run tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Build sdist and wheel + run: | + pip install build + python -m build + - name: Install wheel + run: pip install "$(pwd)/$(echo dist/pyxdf*.whl)[dev]" + - name: Run tests + run: | + git clone https://github.com/xdf-modules/example-files.git + pip install pytest + pytest diff --git a/pyxdf/examples/playback_lsl.py b/pyxdf/examples/playback_lsl.py index 25fe1d8..11deddf 100644 --- a/pyxdf/examples/playback_lsl.py +++ b/pyxdf/examples/playback_lsl.py @@ -1,11 +1,12 @@ import argparse -import time import sys -from typing import List, Optional +import time from dataclasses import dataclass +from typing import List, Optional import numpy as np import pylsl + import pyxdf @@ -53,22 +54,33 @@ class Streamer: class LSLPlaybackClock: - def __init__(self, rate: float = 1.0, loop_time: float = 0.0, max_sample_rate: Optional[float] = None): + def __init__( + self, + rate: float = 1.0, + loop_time: float = 0.0, + max_sample_rate: Optional[float] = None, + ): if rate != 1.0: - print("WARNING!! rate != 1.0; It is impossible to synchronize playback streams " - "with real time streams.") + print( + "WARNING!! rate != 1.0; It is impossible to synchronize playback streams " + "with real time streams." + ) self.rate: float = rate # Maximum rate is loop_time / avg_update_interval, whatever that might be. self._boundary = loop_time self._max_srate = max_sample_rate decr = (1 / self._max_srate) if self._max_srate else 2 * sys.float_info.epsilon self._wall_start: float = pylsl.local_clock() - decr / 2 self._file_read_s: float = 0 # File read header in seconds - self._prev_file_read_s: float = 0 # File read header in seconds for previous iteration + self._prev_file_read_s: float = ( + 0 # File read header in seconds for previous iteration + ) self._n_loop: int = 0 def reset(self, reset_file_position: bool = False) -> None: decr = (1 / self._max_srate) if self._max_srate else 2 * sys.float_info.epsilon - self._wall_start = pylsl.local_clock() - decr / 2 - self._file_read_s / self.rate + self._wall_start = ( + pylsl.local_clock() - decr / 2 - self._file_read_s / self.rate + ) self._n_loop = 0 if reset_file_position: self._file_read_s = 0 @@ -117,8 +129,13 @@ def sleep(self, duration: Optional[float] = None) -> None: time.sleep(duration / self.rate) -def main(fname: str, playback_speed: float = 1.0, loop: bool = True, wait_for_consumer: bool = False): - streams, header = pyxdf.load_xdf(fname) +def main( + fname: str, + playback_speed: float = 1.0, + loop: bool = True, + wait_for_consumer: bool = False, +): + streams, _ = pyxdf.load_xdf(fname) # First iterate over all streams to calculate some globals. xdf_t0 = np.inf @@ -140,12 +157,22 @@ def main(fname: str, playback_speed: float = 1.0, loop: bool = True, wait_for_co tvec = strm["time_stamps"] srate = float(strm["info"]["nominal_srate"][0]) if len(tvec) > 0: - new_info: pylsl.StreamInfo = _create_info_from_xdf_stream_header(strm["info"]) + new_info: pylsl.StreamInfo = _create_info_from_xdf_stream_header( + strm["info"] + ) new_outlet: pylsl.StreamOutlet = pylsl.StreamOutlet(new_info) - streamers.append(Streamer(strm_ix, new_info.name(), tvec - xdf_t0, new_info, new_outlet, srate)) + streamers.append( + Streamer( + strm_ix, new_info.name(), tvec - xdf_t0, new_info, new_outlet, srate + ) + ) # Create timer to manage playback. - timer = LSLPlaybackClock(rate=playback_speed, loop_time=wrap_dur if loop else None, max_sample_rate=max_rate) + timer = LSLPlaybackClock( + rate=playback_speed, + loop_time=wrap_dur if loop else None, + max_sample_rate=max_rate, + ) read_heads = {_.name: 0 for _ in streamers} b_push = not wait_for_consumer # A flag to indicate we can push samples. try: @@ -153,7 +180,9 @@ def main(fname: str, playback_speed: float = 1.0, loop: bool = True, wait_for_co if not b_push: # We are looking for consumers. time.sleep(0.01) - have_consumers = [streamer.outlet.have_consumers() for streamer in streamers] + have_consumers = [ + streamer.outlet.have_consumers() for streamer in streamers + ] # b_push = any(have_consumers) b_push = all(have_consumers) if b_push: @@ -175,8 +204,9 @@ def main(fname: str, playback_speed: float = 1.0, loop: bool = True, wait_for_co # Irregular rate, like events and markers for dat_idx in range(start_idx, stop_idx): sample = streams[streamer.stream_ix]["time_series"][dat_idx] - streamer.outlet.push_sample(sample, - timestamp=timer.t0 + streamer.tvec[dat_idx]) + streamer.outlet.push_sample( + sample, timestamp=timer.t0 + streamer.tvec[dat_idx] + ) # print(f"Pushed sample: {sample}") read_heads[streamer.name] = stop_idx timer.sleep() @@ -186,14 +216,19 @@ def main(fname: str, playback_speed: float = 1.0, loop: bool = True, wait_for_co if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Playback an XDF file over LSL streams.") + parser = argparse.ArgumentParser( + description="Playback an XDF file over LSL streams." + ) + parser.add_argument("filename", type=str, help="Path to the XDF file") parser.add_argument( - "filename", - type=str, - help="Path to the XDF file" + "--playback_speed", type=float, default=1.0, help="Playback speed multiplier." ) - parser.add_argument("--playback_speed", type=float, default=1.0, help="Playback speed multiplier.") parser.add_argument("--loop", action="store_false") parser.add_argument("--wait_for_consumer", action="store_true") args = parser.parse_args() - main(args.filename, playback_speed=args.playback_speed, loop=args.loop, wait_for_consumer=args.wait_for_consumer) + main( + args.filename, + playback_speed=args.playback_speed, + loop=args.loop, + wait_for_consumer=args.wait_for_consumer, + ) diff --git a/pyxdf/examples/print_metadata.py b/pyxdf/examples/print_metadata.py index d5bcbde..05d7297 100644 --- a/pyxdf/examples/print_metadata.py +++ b/pyxdf/examples/print_metadata.py @@ -3,30 +3,33 @@ # Chadwick Boulay # # License: BSD (2-clause) -from os.path import abspath, join, dirname -import logging import argparse +import logging +from os.path import abspath, dirname, join import pyxdf def main(fname: str): logging.basicConfig(level=logging.DEBUG) # Use logging.INFO to reduce output - streams, fileheader = pyxdf.load_xdf(fname) + streams, _ = pyxdf.load_xdf(fname) print("Found {} streams:".format(len(streams))) for ix, stream in enumerate(streams): msg = "Stream {}: {} - type {} - uid {} - shape {} at {} (effective {}) Hz" - print(msg.format( - ix + 1, stream['info']['name'][0], - stream['info']['type'][0], - stream['info']['uid'][0], - (int(stream['info']['channel_count'][0]), len(stream['time_stamps'])), - stream['info']['nominal_srate'][0], - stream['info']['effective_srate']) + print( + msg.format( + ix + 1, + stream["info"]["name"][0], + stream["info"]["type"][0], + stream["info"]["uid"][0], + (int(stream["info"]["channel_count"][0]), len(stream["time_stamps"])), + stream["info"]["nominal_srate"][0], + stream["info"]["effective_srate"], + ) ) - if any(stream['time_stamps']): - duration = stream['time_stamps'][-1] - stream['time_stamps'][0] + if any(stream["time_stamps"]): + duration = stream["time_stamps"][-1] - stream["time_stamps"][0] print("\tDuration: {} s".format(duration)) print("Done.") @@ -37,7 +40,7 @@ def main(fname: str): "-f", type=str, help="Path to the XDF file", - default=abspath(join(dirname(__file__), "..", "..", "..", "xdf_sample.xdf")) + default=abspath(join(dirname(__file__), "..", "..", "..", "xdf_sample.xdf")), ) args = parser.parse_args() main(args.f) diff --git a/pyxdf/pyxdf.py b/pyxdf/pyxdf.py index 27ed985..e64bbd3 100644 --- a/pyxdf/pyxdf.py +++ b/pyxdf/pyxdf.py @@ -9,18 +9,18 @@ This function is closely following the load_xdf reference implementation. """ + +import gzip import io -import struct import itertools -import gzip -from xml.etree.ElementTree import fromstring, ParseError -from collections import OrderedDict, defaultdict import logging +import struct +from collections import OrderedDict, defaultdict from pathlib import Path +from xml.etree.ElementTree import ParseError, fromstring import numpy as np - __all__ = ["load_xdf"] logger = logging.getLogger(__name__) @@ -81,7 +81,7 @@ def load_xdf( clock_reset_threshold_offset_seconds=1, clock_reset_threshold_offset_stds=10, winsor_threshold=0.0001, - verbose=None + verbose=None, ): """Import an XDF file. @@ -210,9 +210,7 @@ def load_xdf( elif isinstance(select_streams, int): select_streams = [select_streams] elif all([isinstance(elem, dict) for elem in select_streams]): - select_streams = match_streaminfos( - resolve_streams(filename), select_streams - ) + select_streams = match_streaminfos(resolve_streams(filename), select_streams) if not select_streams: # no streams found raise ValueError("No matching streams found.") elif not all([isinstance(elem, int) for elem in select_streams]): @@ -306,9 +304,7 @@ def load_xdf( # noinspection PyBroadException try: nsamples, stamps, values = _read_chunk3(f, temp[StreamId]) - logger.debug( - " reading [%s,%s]" % (temp[StreamId].nchns, nsamples) - ) + logger.debug(" reading [%s,%s]" % (temp[StreamId].nchns, nsamples)) # optionally send through the on_chunk function if on_chunk is not None: values, stamps, streams[StreamId] = on_chunk( @@ -337,12 +333,8 @@ def load_xdf( ) elif tag == 4: # read [ClockOffset] chunk - temp[StreamId].clock_times.append( - struct.unpack(" reset_threshold_stds cond3 = time_diff - median_ival > reset_threshold_seconds @@ -588,17 +577,10 @@ def _clock_sync( # Points where a glitch in successive clock value estimates # happened - mad = ( - np.median(np.abs(value_diff - median_slope)) - + np.finfo(float).eps - ) + mad = np.median(np.abs(value_diff - median_slope)) + np.finfo(float).eps cond1 = value_diff < 0 - cond2 = ( - value_diff - median_slope - ) / mad > reset_threshold_offset_stds - cond3 = ( - value_diff - median_slope > reset_threshold_offset_seconds - ) + cond2 = (value_diff - median_slope) / mad > reset_threshold_offset_stds + cond3 = value_diff - median_slope > reset_threshold_offset_seconds value_glitch = cond1 | (cond2 & cond3) resets_at = time_glitch & value_glitch @@ -607,9 +589,7 @@ def _clock_sync( ranges = [(0, len(clock_times) - 1)] else: indices = np.where(resets_at)[0] - indices = np.hstack( - (0, indices, indices + 1, len(resets_at) - 1) - ) + indices = np.hstack((0, indices, indices + 1, len(resets_at) - 1)) ranges = np.reshape(indices, (2, -1)).T # Otherwise we just assume that there are no clock resets @@ -624,8 +604,7 @@ def _clock_sync( X = np.column_stack( [ np.ones((stop - start,)), - np.array(clock_times[start:stop]) - / winsor_threshold, + np.array(clock_times[start:stop]) / winsor_threshold, ] ) y = np.array(clock_values[start:stop]) / winsor_threshold @@ -638,9 +617,7 @@ def _clock_sync( # Apply the correction to all time stamps if len(ranges) == 1: - stream.time_stamps += coef[0][0] + ( - coef[0][1] * stream.time_stamps - ) + stream.time_stamps += coef[0][0] + (coef[0][1] * stream.time_stamps) else: for coef_i, range_i in zip(coef, ranges): r = slice(range_i[0], range_i[1]) diff --git a/pyxdf/test/test_data.py b/pyxdf/test/test_data.py index eaa0ffd..5c5b1ce 100644 --- a/pyxdf/test/test_data.py +++ b/pyxdf/test/test_data.py @@ -1,8 +1,9 @@ from pathlib import Path -from pyxdf import load_xdf -import pytest + import numpy as np +import pytest +from pyxdf import load_xdf # requires git clone https://github.com/xdf-modules/example-files.git # into the root xdf-python folder @@ -29,21 +30,26 @@ def test_load_file(file): assert streams[0]["info"]["channel_format"][0] == "int16" assert streams[0]["info"]["stream_id"] == 0 - s = np.array([[192, 255, 238], - [12, 22, 32], - [13, 23, 33], - [14, 24, 34], - [15, 25, 35], - [12, 22, 32], - [13, 23, 33], - [14, 24, 34], - [15, 25, 35]], dtype=np.int16) - t = np.array([5., 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8]) + s = np.array( + [ + [192, 255, 238], + [12, 22, 32], + [13, 23, 33], + [14, 24, 34], + [15, 25, 35], + [12, 22, 32], + [13, 23, 33], + [14, 24, 34], + [15, 25, 35], + ], + dtype=np.int16, + ) + t = np.array([5.0, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8]) np.testing.assert_array_equal(streams[0]["time_series"], s) np.testing.assert_array_almost_equal(streams[0]["time_stamps"], t) clock_times = np.asarray([6.1, 7.1]) - clock_values = np.asarray([-.1, -.1]) + clock_values = np.asarray([-0.1, -0.1]) np.testing.assert_array_equal(streams[0]["clock_times"], clock_times) np.testing.assert_array_almost_equal(streams[0]["clock_values"], clock_values) @@ -55,20 +61,24 @@ def test_load_file(file): assert streams[1]["info"]["channel_format"][0] == "string" assert streams[1]["info"]["stream_id"] == 0x02C0FFEE - s = [['LabRecorder xdfwriter' - '5.1' - '5.99' - '-.01' - '-.02' - ''], - ['Hello'], - ['World'], - ['from'], - ['LSL'], - ['Hello'], - ['World'], - ['from'], - ['LSL']] + s = [ + [ + 'LabRecorder xdfwriter' + "5.1" + "5.99" + "-.01" + "-.02" + "" + ], + ["Hello"], + ["World"], + ["from"], + ["LSL"], + ["Hello"], + ["World"], + ["from"], + ["LSL"], + ] t = np.array([5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9]) assert streams[1]["time_series"] == s np.testing.assert_array_almost_equal(streams[1]["time_stamps"], t) diff --git a/pyxdf/test/test_library_basic.py b/pyxdf/test/test_library_basic.py index ac7910e..d9896ee 100644 --- a/pyxdf/test/test_library_basic.py +++ b/pyxdf/test/test_library_basic.py @@ -1,16 +1,18 @@ +import io + +import pytest + import pyxdf import pyxdf.pyxdf -import pytest -import io -#%% test +# %% test def test_load_xdf_present(): """ Check that pyxdf has the all important load_xdf. This is nothing more than a placeholder so the CI system has a test to pass. """ - assert(hasattr(pyxdf, 'load_xdf')) + assert hasattr(pyxdf, "load_xdf") def test_read_varlen_int(): @@ -19,20 +21,19 @@ def test_read_varlen_int(): def vla(data: bytes): return pyxdf.pyxdf._read_varlen_int(io.BytesIO(data)) - assert vla(b'\x01\xfd') == 0xfd - assert vla(b'\x04\xfd\x12\x00\x34') == 0x340012fd - assert vla(b'\x08\xfd\x12\x00\x34\x12\x34\x56\x78') == 0x78563412340012fd + assert vla(b"\x01\xfd") == 0xFD + assert vla(b"\x04\xfd\x12\x00\x34") == 0x340012FD + assert vla(b"\x08\xfd\x12\x00\x34\x12\x34\x56\x78") == 0x78563412340012FD with pytest.raises(RuntimeError): - vla(b'\x00') + vla(b"\x00") def test_load_from_memory(): - testfile = b'XDF:\01\n\02\00 \00\00\00' + testfile = b"XDF:\01\n\02\00 \00\00\00" f = pyxdf.pyxdf.open_xdf(io.BytesIO(testfile)) assert isinstance(f, io.BytesIO) - assert f.read()[-4:] == b'' + assert f.read()[-4:] == b"" chunks = pyxdf.pyxdf.parse_xdf(io.BytesIO(testfile)) assert len(chunks) == 1 - assert chunks[0]['stream_id'] == 32 - + assert chunks[0]["stream_id"] == 32