ezmsg-xdf 0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ezmsg/xdf/__init__.py ADDED
File without changes
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.1'
16
+ __version_tuple__ = version_tuple = (0, 1)
ezmsg/xdf/iter.py ADDED
@@ -0,0 +1,327 @@
1
+ from dataclasses import replace
2
+ from pathlib import Path
3
+ import queue
4
+ import typing
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ import pyxdf
9
+ from ezmsg.lsl.util import AxisArray
10
+
11
+
12
+ class XDFIterator:
13
+ def __init__(
14
+ self,
15
+ filepath: typing.Union[Path, str],
16
+ select: typing.Optional[
17
+ set[str]
18
+ ] = None, # If set, then the iterator yields only AxisArray of selected stream(s).
19
+ # If None (default), then the iterator yields dicts with keys for each stream
20
+ chunk_dur: float = 1.0, # Attempt to chunk data into chunks of this duration.
21
+ start_time: typing.Optional[float] = None,
22
+ stop_time: typing.Optional[float] = None,
23
+ rezero: bool = True,
24
+ ):
25
+ """
26
+ An Iterator that yields chunks from an XDF.
27
+ A typical offline analysis might load the entire file into memory, then perform a processing step on the entire
28
+ recording duration, and the next step on the entire result of the first step, and so on. This might require a
29
+ tremendous amount of memory and, if one is not careful about memory layout, can be incredibly slow. An
30
+ alternative procedure is to load the file into memory a chunk at a time (see Note1), then pass that chunk
31
+ through the entire processing pipeline, then proceed onto the next chunk (See Note2). We create an Iterator to
32
+ provide our chunks.
33
+ > Note1: I have not written a true lazy-loader for XDF because it has not yet been necessary as the files are
34
+ all small. Thus, I use pyxdf.load_xdf which loads the entire raw data into memory. The processing is still
35
+ done chunk-by-chunk.
36
+ > Note2: It should be possible to start on chunk[ix+1] while chunk[ix] is still going through the pipeline.
37
+ Indeed, this is (optionally) how it works online. However, the overhead of setting this up for offline
38
+ analysis is not worth the gain, at least not at this stage.
39
+
40
+ Args:
41
+ filepath: The path to the file to load and iterate over.
42
+ select: (Optional) A set of stream names to select. If None, then all streams are selected.
43
+ chunk_dur: The duration of each chunk in seconds.
44
+ start_time: Start playback at this time. If rezero is True then this is relative to the file start time.
45
+ If rezero is False then this is relative to the original timestamps.
46
+ stop_time: Truncate the playback to stop at this time. If rezero is True then this is relative to the file
47
+ start time. If rezero is False then this is relative to the original timestamps.
48
+ rezero: The absolute value of timestamps in an XDF file are useful for synchronization WITHIN file, but they
49
+ are absolutely meaningless outside the exact XDF file like in an ezmsg application. Thus, by default we
50
+ rezero the timestamps to start at t=0.0 for simplicity. However, there may be rare circumstances where
51
+ one wants to compare the timestamps produced by ezmsg to timestamps produced by another XDF analysis
52
+ tool that does not rezero. In that case, set rezero=False.
53
+ """
54
+ if isinstance(filepath, str):
55
+ filepath = Path(filepath).expanduser()
56
+ self._filepath = filepath
57
+ self._select = select
58
+ self._chunk_dur = chunk_dur
59
+ self._rezero = rezero
60
+ self._n_chunks = 0
61
+ self._t0 = 0.0
62
+ self._chunk_ix = 0
63
+ self._last_time = 0.0
64
+ self._metadata = {}
65
+ self._prev_file_read_s: float = (
66
+ 0 # File read header in seconds for previous iteration
67
+ )
68
+ self._time_range: typing.Tuple[
69
+ typing.Optional[float], typing.Optional[float]
70
+ ] = (start_time, stop_time)
71
+ self._scan_file()
72
+
73
+ def _scan_file(self):
74
+ # Note: For larger datafiles we wouldn't want to load the entire thing into memory with load_xdf.
75
+ # Instead, get a file handle, then
76
+ # - Scan the file for chunk boundaries and timestamps
77
+ # - Maintain a list of chunk boundaries
78
+ # - Perform timestamp corrections (maintain corrected ts in memory or use func to correct during next pass?)
79
+ # - Iterator operates on original chunk-boundaries, but using corrected timestamps.
80
+ # However, we would need a custom file parser for that. For now, we load the relatively small
81
+ # file into memory simply with pyxdf.load_xdf then iterate over the items in memory
82
+ # at a user-defined chunk boundary (`chunk_dur`).
83
+ # Load xdf
84
+ self._streams, fileheader = pyxdf.load_xdf(
85
+ self._filepath,
86
+ select_streams=None
87
+ if (self._select is None or self._rezero)
88
+ else [{"name": _} for _ in self._select],
89
+ )
90
+ self._metadata = {}
91
+ self._file_read_s = 0
92
+ self._prev_file_read_s = 0
93
+ xdf_t0 = np.inf
94
+ xdf_tmax = 0
95
+ for strm in self._streams:
96
+ # Convert empty data to an array for easier slicing
97
+ if type(strm["time_series"]) is list:
98
+ strm["time_series"] = np.array(strm["time_series"])
99
+
100
+ # Get more digestable metadata
101
+ info = strm["info"]
102
+ new_meta = {
103
+ "name": info["name"][0],
104
+ "type": info["type"][0],
105
+ "channel_count": int(info["channel_count"][0]),
106
+ "nominal_srate": float(info["nominal_srate"][0]),
107
+ }
108
+ self._metadata[new_meta["name"]] = new_meta
109
+
110
+ # Update time range limits
111
+ tvec = strm["time_stamps"]
112
+ if len(tvec) > 0:
113
+ xdf_t0 = min(xdf_t0, tvec[0])
114
+ xdf_tmax = max(xdf_tmax, tvec[-1])
115
+
116
+ # Permanently modify streams' time stamps
117
+ if self._rezero:
118
+ for strm in self._streams:
119
+ strm["time_stamps"] = strm["time_stamps"] - xdf_t0
120
+ xdf_tmax -= xdf_t0
121
+ xdf_t0 = 0
122
+
123
+ # Adjust for provided time bounds
124
+ for strm in self._streams:
125
+ tvec = strm["time_stamps"]
126
+ if len(tvec) > 0:
127
+ b_keep = np.ones(len(tvec), dtype=bool)
128
+ if self._time_range[0] is not None:
129
+ b_keep = np.logical_and(b_keep, tvec >= self._time_range[0])
130
+ if self._time_range[1] is not None:
131
+ b_keep = np.logical_and(b_keep, tvec <= self._time_range[1])
132
+ if np.any(~b_keep):
133
+ strm["time_stamps"] = tvec[b_keep]
134
+ strm["timeseries"] = strm["timeseries"][b_keep]
135
+
136
+ # Recalculate tmax
137
+ xdf_dur = 0
138
+ for strm in self._streams:
139
+ tvec = strm["time_stamps"]
140
+ srate = float(strm["info"]["nominal_srate"][0])
141
+ adj = (1 / srate if srate > 0 else 0) - xdf_t0
142
+ if len(tvec) > 0:
143
+ xdf_dur = max(xdf_dur, tvec[-1] + adj)
144
+
145
+ # Chunking
146
+ self._n_chunks = int(np.ceil(xdf_dur / self._chunk_dur))
147
+ self._t0 = xdf_t0
148
+
149
+ # Drop streams that were not selected. (Could not drop earlier due to timestamp rezero)
150
+ if self._rezero and self._select is not None:
151
+ stream_names = [_["info"]["name"][0] for _ in self._streams]
152
+ self._streams = [self._streams[stream_names.index(_)] for _ in self._select]
153
+ self._metadata = {k: self._metadata[k] for k in self._select}
154
+
155
+ print(
156
+ f"Imported {len(self._streams)} streams from {self._filepath} "
157
+ f"spanning {xdf_dur:.2f} s beginning at t={xdf_t0:.2f}."
158
+ )
159
+
160
+ @property
161
+ def stream_meta(self) -> typing.Union[list[dict], dict]:
162
+ return self._metadata
163
+
164
+ @property
165
+ def n_chunks(self) -> int:
166
+ return self._n_chunks
167
+
168
+ def __iter__(self):
169
+ self._chunk_ix = 0
170
+ return self
171
+
172
+ def __next__(self) -> dict[str, tuple[npt.NDArray, npt.NDArray]]:
173
+ if self._chunk_ix >= self.n_chunks:
174
+ raise StopIteration
175
+ else:
176
+ out_dict = {}
177
+ t_start, t_stop = (
178
+ self._chunk_ix * self._chunk_dur + self._t0,
179
+ (self._chunk_ix + 1) * self._chunk_dur + self._t0,
180
+ )
181
+ for strm in self._streams:
182
+ b_chunk = np.logical_and(
183
+ strm["time_stamps"] >= t_start, strm["time_stamps"] < t_stop
184
+ )
185
+ out_tvec = strm["time_stamps"][b_chunk]
186
+ out_data = strm["time_series"][b_chunk]
187
+ out_dict[strm["info"]["name"][0]] = (out_data, out_tvec)
188
+ if len(out_tvec) > 0:
189
+ self._last_time = max(self._last_time, out_tvec[-1])
190
+ self._chunk_ix += 1
191
+ return out_dict
192
+
193
+
194
+ def labels_from_strm(strm: dict) -> list[str]:
195
+ desc = strm["info"]["desc"][0]
196
+ if desc is not None and "channels" in desc:
197
+ labels = [_["label"][0] for _ in desc["channels"][0]["channel"]]
198
+ else:
199
+ n_ch = int(strm["info"]["channel_count"][0])
200
+ labels = [str(_ + 1) for _ in range(n_ch)]
201
+ return labels
202
+
203
+
204
+ class XDFAxisArrayIterator(XDFIterator):
205
+ def __init__(self, *args, select: str, **kwargs):
206
+ """
207
+ This Iterator loads only a single stream and yields a single :obj:`AxisArray` object per chunk.
208
+
209
+ Args:
210
+ *args:
211
+ select: Unlike :obj:`XDFIterator`, this must be a single string, the name of the stream to select.
212
+ **kwargs:
213
+ """
214
+ kwargs["select"] = set((select,))
215
+ super().__init__(*args, **kwargs)
216
+ _sel = [_ for _ in self._select][0]
217
+ fs = self._metadata[_sel]["nominal_srate"] or 1.0
218
+ labels = labels_from_strm(self._streams[0])
219
+
220
+ self._template = AxisArray(
221
+ data=np.zeros(
222
+ (0, len(labels)), dtype=self._streams[0]["time_series"].dtype
223
+ ),
224
+ dims=["time", "ch"],
225
+ axes={
226
+ "time": AxisArray.Axis.TimeAxis(fs=fs, offset=0.0),
227
+ "ch": AxisArray.Axis.SpaceAxis(labels=labels),
228
+ },
229
+ key=self._streams[0]["info"]["name"][0],
230
+ )
231
+
232
+ def __next__(self) -> AxisArray:
233
+ result: typing.Optional[AxisArray] = None
234
+ chunk_dict = super().__next__()
235
+ # Should only be 1 in self._select. If there are more then we overwrite with the last.
236
+ for strm_name in self._select:
237
+ if strm_name in chunk_dict:
238
+ data, tvec = chunk_dict[strm_name]
239
+ result = replace(
240
+ self._template,
241
+ data=data,
242
+ axes={
243
+ **self._template.axes,
244
+ "time": replace(
245
+ self._template.axes["time"],
246
+ offset=tvec[0] if len(tvec) else self._last_time,
247
+ ),
248
+ },
249
+ )
250
+ return result
251
+
252
+
253
+ class XDFMultiAxArrIterator(XDFIterator):
254
+ def __init__(self, *args, force_single_sample: set = set(), **kwargs):
255
+ """
256
+ This Iterator loads multiple streams and yields a :obj:`AxisArray` object per iteration,
257
+ but the stream source might different between chunks.
258
+
259
+ Args:
260
+ *args:
261
+ force_single_sample: Use this to identify irregular-rate streams that might conceivably have more than one
262
+ event within the defined chunk_dur, for which :obj:`AxisArray` cannot represent timestamps properly.
263
+ **kwargs:
264
+ """
265
+ super().__init__(*args, **kwargs)
266
+ self._force_single_sample = force_single_sample
267
+ stream_names = [_["info"]["name"][0] for _ in self._streams]
268
+
269
+ # Create template messages for each stream
270
+ self._templates = {}
271
+ for stream_name, stream_meta in self._metadata.items():
272
+ stream = self._streams[stream_names.index(stream_name)]
273
+ labels = labels_from_strm(stream)
274
+ fs = stream_meta["nominal_srate"]
275
+ self._templates[stream_name] = AxisArray(
276
+ data=np.zeros(
277
+ (0, stream_meta["channel_count"]), dtype=stream["time_series"].dtype
278
+ ),
279
+ dims=["time", "ch"],
280
+ axes={
281
+ "time": AxisArray.Axis.TimeAxis(fs=fs or 1.0, offset=0.0),
282
+ "ch": AxisArray.Axis.SpaceAxis(labels=labels),
283
+ },
284
+ key=stream_name,
285
+ )
286
+ self._pubqueue: queue.SimpleQueue[AxisArray] = queue.SimpleQueue()
287
+
288
+ def __next__(self) -> typing.Optional[AxisArray]:
289
+ if self._pubqueue.empty():
290
+ chunk_dict = super().__next__()
291
+ for k, template in self._templates.items():
292
+ if k in chunk_dict and len(chunk_dict[k][1]) > 0:
293
+ data, tvec = chunk_dict[k]
294
+ if k in self._force_single_sample:
295
+ for ix, _t in enumerate(tvec):
296
+ self._pubqueue.put_nowait(
297
+ replace(
298
+ template,
299
+ data=data[ix : ix + 1],
300
+ axes={
301
+ **template.axes,
302
+ "time": replace(
303
+ template.axes["time"], offset=_t
304
+ ),
305
+ },
306
+ )
307
+ )
308
+ else:
309
+ self._pubqueue.put_nowait(
310
+ replace(
311
+ template,
312
+ data=data,
313
+ axes={
314
+ **template.axes,
315
+ "time": replace(
316
+ template.axes["time"],
317
+ offset=tvec[0]
318
+ if len(tvec)
319
+ else self._last_time,
320
+ ),
321
+ },
322
+ )
323
+ )
324
+ try:
325
+ return self._pubqueue.get_nowait()
326
+ except queue.Empty:
327
+ return None
ezmsg/xdf/source.py ADDED
@@ -0,0 +1,109 @@
1
+ import asyncio
2
+ import os
3
+ import typing
4
+ from dataclasses import field
5
+
6
+ import ezmsg.core as ez
7
+ from ezmsg.util.generator import GenState
8
+ from ezmsg.util.messages.axisarray import AxisArray
9
+
10
+ from .iter import XDFAxisArrayIterator, XDFMultiAxArrIterator
11
+
12
+
13
+ class XDFIteratorSettings(ez.Settings):
14
+ filepath: typing.Union[os.PathLike, str]
15
+ select: str
16
+ chunk_dur: float = 1.0
17
+ start_time: typing.Optional[float] = None
18
+ stop_time: typing.Optional[float] = None
19
+ rezero: bool = True
20
+ self_terminating: bool = False
21
+ """
22
+ If True, the unit will raise a :obj:`ez.NormalTermination` exception when the file is exhausted.
23
+ Note, however, that this will terminate the pipeline even if the data published by this unit are still in transit,
24
+ which will lead to the pipeline output being truncated before it has finished processing the stream.
25
+ `self_terminating` should only be used when it is not important that the pipeline finish processing data, such
26
+ as during prototyping and testing.
27
+ """
28
+
29
+
30
+ class XDFIteratorUnit(ez.Unit):
31
+ STATE = GenState
32
+ SETTINGS = XDFIteratorSettings
33
+
34
+ OUTPUT_SIGNAL = ez.OutputStream(AxisArray)
35
+ OUTPUT_TERM = ez.OutputStream(typing.Any)
36
+
37
+ def initialize(self) -> None:
38
+ self.construct_generator()
39
+
40
+ def construct_generator(self):
41
+ self.STATE.gen = XDFAxisArrayIterator(
42
+ filepath=self.SETTINGS.filepath,
43
+ select=self.SETTINGS.select,
44
+ chunk_dur=self.SETTINGS.chunk_dur,
45
+ start_time=self.SETTINGS.start_time,
46
+ stop_time=self.SETTINGS.stop_time,
47
+ rezero=self.SETTINGS.rezero,
48
+ )
49
+
50
+ @ez.publisher(OUTPUT_SIGNAL)
51
+ async def pub_chunk(self) -> typing.AsyncGenerator:
52
+ try:
53
+ while True:
54
+ msg = next(self.STATE.gen)
55
+ if msg.data.size > 0:
56
+ yield self.OUTPUT_SIGNAL, msg
57
+ else:
58
+ await asyncio.sleep(0)
59
+ except StopIteration:
60
+ ez.logger.debug(
61
+ f"File ({self.SETTINGS.filepath} :: {self.SETTINGS.select}) exhausted."
62
+ )
63
+ if self.SETTINGS.self_terminating:
64
+ raise ez.NormalTermination
65
+ yield self.OUTPUT_TERM, True
66
+
67
+
68
+ class XDFMultiIteratorUnitSettings(XDFIteratorSettings):
69
+ select: typing.Optional[set[str]] = None # Override with a default
70
+ force_single_sample: set = field(default_factory=set)
71
+
72
+
73
+ class XDFMultiIteratorUnit(ez.Unit):
74
+ STATE = GenState
75
+ SETTINGS = XDFMultiIteratorUnitSettings
76
+
77
+ OUTPUT_SIGNAL = ez.OutputStream(AxisArray)
78
+ OUTPUT_TERM = ez.OutputStream(typing.Any)
79
+
80
+ def initialize(self) -> None:
81
+ self.construct_generator()
82
+
83
+ def construct_generator(self):
84
+ self.STATE.gen = XDFMultiAxArrIterator(
85
+ filepath=self.SETTINGS.filepath,
86
+ select=self.SETTINGS.select,
87
+ chunk_dur=self.SETTINGS.chunk_dur,
88
+ start_time=self.SETTINGS.start_time,
89
+ stop_time=self.SETTINGS.stop_time,
90
+ rezero=self.SETTINGS.rezero,
91
+ force_single_sample=self.SETTINGS.force_single_sample,
92
+ )
93
+
94
+ @ez.publisher(OUTPUT_SIGNAL)
95
+ async def pub_multi(self) -> typing.AsyncGenerator:
96
+ try:
97
+ while True:
98
+ msg = next(self.STATE.gen)
99
+ if msg is not None:
100
+ yield self.OUTPUT_SIGNAL, msg
101
+ else:
102
+ await asyncio.sleep(0)
103
+ except StopIteration:
104
+ ez.logger.debug(
105
+ f"File ({self.SETTINGS.filepath} :: {self.SETTINGS.select}) exhausted."
106
+ )
107
+ if self.SETTINGS.self_terminating:
108
+ raise ez.NormalTermination
109
+ yield self.OUTPUT_TERM, True
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: ezmsg-xdf
3
+ Version: 0.1
4
+ Summary: Namespace package for ezmsg to load iterate data from xdf files
5
+ Author-email: Chadwick Boulay <chadwick.boulay@gmail.com>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: ezmsg-lsl>=0.5.0
9
+ Requires-Dist: ezmsg>=3.5.0
10
+ Requires-Dist: numpy>=2.0.2
11
+ Requires-Dist: pyxdf>=1.16.8
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=8.3.3; extra == 'test'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ezmsg.xdf
17
+
18
+ ezmsg namespace package for working with XDF files.
@@ -0,0 +1,8 @@
1
+ ezmsg/xdf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ezmsg/xdf/__version__.py,sha256=7V2ukxSfzXlOjRj21NN9XMWrUwTZJIQZggSzFRx5qs8,406
3
+ ezmsg/xdf/iter.py,sha256=7a4oJNIHoGJAobxQRl3iMYtRn-dh_XtxDMqdlqQTsy0,14396
4
+ ezmsg/xdf/source.py,sha256=jAG6WacFJWCsO8qConswwiQKEWcO50QLBZWGNJUJ_hI,3760
5
+ ezmsg_xdf-0.1.dist-info/METADATA,sha256=WxR8WM5xXjE4nnT6xd4FV0LEdDr-CF0CEougHgoNURg,517
6
+ ezmsg_xdf-0.1.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
7
+ ezmsg_xdf-0.1.dist-info/licenses/LICENSE,sha256=KImy0vSiKLkW28zWqI8rIaLwvjGKxOjE5kZmVxZDdy4,1066
8
+ ezmsg_xdf-0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.25.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ezmsg-org
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.