harp-python 0.3.0__py3-none-any.whl → 0.4.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.
harp/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
- from harp.io import REFERENCE_EPOCH, MessageType, read
1
+ from harp.io import REFERENCE_EPOCH, MessageType, read, to_buffer, to_file
2
2
  from harp.reader import create_reader
3
3
  from harp.schema import read_schema
4
4
 
5
- __all__ = ["REFERENCE_EPOCH", "MessageType", "read", "create_reader", "read_schema"]
5
+ __all__ = ["REFERENCE_EPOCH", "MessageType", "read", "to_buffer", "to_file", "create_reader", "read_schema"]
harp/io.py CHANGED
@@ -4,8 +4,11 @@ from os import PathLike
4
4
  from typing import Any, BinaryIO, Optional, Union
5
5
 
6
6
  import numpy as np
7
+ import numpy.typing as npt
7
8
  import pandas as pd
8
- from pandas._typing import Axes
9
+ from pandas._typing import Axes # pyright: ignore[reportPrivateImportUsage]
10
+
11
+ from harp.typing import _BufferLike, _FileLike
9
12
 
10
13
  REFERENCE_EPOCH = datetime(1904, 1, 1)
11
14
  """The reference epoch for UTC harp time."""
@@ -21,8 +24,9 @@ class MessageType(IntEnum):
21
24
 
22
25
 
23
26
  _SECONDS_PER_TICK = 32e-6
27
+ _PAYLOAD_TIMESTAMP_MASK = 0x10
24
28
  _messagetypes = [type.name for type in MessageType]
25
- _payloadtypes = {
29
+ _dtypefrompayloadtype = {
26
30
  1: np.dtype(np.uint8),
27
31
  2: np.dtype(np.uint16),
28
32
  4: np.dtype(np.uint32),
@@ -33,10 +37,11 @@ _payloadtypes = {
33
37
  136: np.dtype(np.int64),
34
38
  68: np.dtype(np.float32),
35
39
  }
40
+ _payloadtypefromdtype = {v: k for k, v in _dtypefrompayloadtype.items()}
36
41
 
37
42
 
38
43
  def read(
39
- file: Union[str, bytes, PathLike[Any], BinaryIO],
44
+ file_or_buf: Union[_FileLike, _BufferLike],
40
45
  address: Optional[int] = None,
41
46
  dtype: Optional[np.dtype] = None,
42
47
  length: Optional[int] = None,
@@ -44,22 +49,22 @@ def read(
44
49
  epoch: Optional[datetime] = None,
45
50
  keep_type: bool = False,
46
51
  ):
47
- """Read single-register Harp data from the specified file.
52
+ """Read single-register Harp data from the specified file or buffer.
48
53
 
49
54
  Parameters
50
55
  ----------
51
- file
52
- Open file object or filename containing binary data from
56
+ file_or_buf
57
+ File path, open file object, or buffer containing binary data from
53
58
  a single device register.
54
59
  address
55
60
  Expected register address. If specified, the address of
56
- the first message in the file is used for validation.
61
+ the first message is used for validation.
57
62
  dtype
58
63
  Expected data type of the register payload. If specified, the
59
- payload type of the first message in the file is used for validation.
64
+ payload type of the first message is used for validation.
60
65
  length
61
66
  Expected number of elements in register payload. If specified, the
62
- payload length of the first message in the file is used for validation.
67
+ payload length of the first message is used for validation.
63
68
  columns
64
69
  The optional column labels to use for the data values.
65
70
  epoch
@@ -72,9 +77,20 @@ def read(
72
77
  -------
73
78
  A pandas data frame containing message data, sorted by time.
74
79
  """
75
- data = np.fromfile(file, dtype=np.uint8)
80
+ if isinstance(file_or_buf, (str, PathLike, BinaryIO)) or hasattr(file_or_buf, "readinto"):
81
+ # TODO: in the below we ignore the type as otherwise
82
+ # we have no way to runtime check _IOProtocol
83
+ data = np.fromfile(file_or_buf, dtype=np.uint8) # type: ignore
84
+ else:
85
+ data = np.frombuffer(file_or_buf, dtype=np.uint8)
86
+
76
87
  if len(data) == 0:
77
- return pd.DataFrame(columns=columns, index=pd.Index([], dtype=np.float64, name="Time"))
88
+ return pd.DataFrame(
89
+ columns=columns,
90
+ index=pd.DatetimeIndex([], name="Time")
91
+ if epoch
92
+ else pd.Index([], dtype=np.float64, name="Time"),
93
+ )
78
94
 
79
95
  if address is not None and address != data[2]:
80
96
  raise ValueError(f"expected address {address} but got {data[2]}")
@@ -84,20 +100,20 @@ def read(
84
100
  nrows = len(data) // stride
85
101
  payloadtype = data[4]
86
102
  payloadoffset = 5
87
- if payloadtype & 0x10 != 0:
103
+ if payloadtype & _PAYLOAD_TIMESTAMP_MASK != 0:
88
104
  seconds = np.ndarray(nrows, dtype=np.uint32, buffer=data, offset=payloadoffset, strides=stride)
89
105
  payloadoffset += 4
90
106
  micros = np.ndarray(nrows, dtype=np.uint16, buffer=data, offset=payloadoffset, strides=stride)
91
107
  payloadoffset += 2
92
108
  time = micros * _SECONDS_PER_TICK + seconds
93
- payloadtype = payloadtype & ~np.uint8(0x10)
109
+ payloadtype = payloadtype & ~np.uint8(_PAYLOAD_TIMESTAMP_MASK)
94
110
  if epoch is not None:
95
111
  time = epoch + pd.to_timedelta(time, "s") # type: ignore
96
112
  index = pd.Series(time)
97
113
  index.name = "Time"
98
114
 
99
115
  payloadsize = stride - payloadoffset - 1
100
- payloadtype = _payloadtypes[payloadtype]
116
+ payloadtype = _dtypefrompayloadtype[payloadtype]
101
117
  if dtype is not None and dtype != payloadtype:
102
118
  raise ValueError(f"expected payload type {dtype} but got {payloadtype}")
103
119
 
@@ -120,3 +136,139 @@ def read(
120
136
  msgtype = pd.Categorical.from_codes(msgtype, categories=_messagetypes) # type: ignore
121
137
  result[MessageType.__name__] = msgtype
122
138
  return result
139
+
140
+
141
+ def to_file(
142
+ data: pd.DataFrame,
143
+ file: _FileLike,
144
+ address: int,
145
+ dtype: Optional[np.dtype] = None,
146
+ length: Optional[int] = None,
147
+ port: Optional[int] = None,
148
+ epoch: Optional[datetime] = None,
149
+ message_type: Optional[MessageType] = None,
150
+ ):
151
+ """Write single-register Harp data to the specified file.
152
+
153
+ Parameters
154
+ ----------
155
+ data
156
+ Pandas data frame containing message payload.
157
+ file
158
+ File path, or open file object in which to store binary data from
159
+ a single device register.
160
+ address
161
+ Register address used to identify all formatted Harp messages.
162
+ dtype
163
+ Data type of the register payload. If specified, all data will
164
+ be converted before formatting the binary payload.
165
+ length
166
+ Expected number of elements in register payload. If specified, the
167
+ number of columns in the input data frame is validated.
168
+ port
169
+ Optional port value used for all formatted Harp messages.
170
+ epoch
171
+ Reference datetime at which time zero begins. If specified,
172
+ the input data frame must have a datetime index.
173
+ message_type
174
+ Optional message type used for all formatted Harp messages.
175
+ If not specified, data must contain a MessageType column.
176
+ """
177
+ buffer = to_buffer(data, address, dtype, port, length, epoch, message_type)
178
+ buffer.tofile(file)
179
+
180
+
181
+ def to_buffer(
182
+ data: pd.DataFrame,
183
+ address: int,
184
+ dtype: Optional[np.dtype] = None,
185
+ length: Optional[int] = None,
186
+ port: Optional[int] = None,
187
+ epoch: Optional[datetime] = None,
188
+ message_type: Optional[MessageType] = None,
189
+ ) -> npt.NDArray[np.uint8]:
190
+ """Convert single-register Harp data to a flat binary buffer.
191
+
192
+ Parameters
193
+ ----------
194
+ data
195
+ Pandas data frame containing message payload.
196
+ address
197
+ Register address used to identify all formatted Harp messages.
198
+ dtype
199
+ Data type of the register payload. If specified, all data will
200
+ be converted before formatting the binary payload.
201
+ length
202
+ Expected number of elements in register payload. If specified, the
203
+ number of columns in the input data frame is validated.
204
+ port
205
+ Optional port value used for all formatted Harp messages.
206
+ epoch
207
+ Reference datetime at which time zero begins. If specified,
208
+ the input data frame must have a datetime index.
209
+ message_type
210
+ Optional message type used for all formatted Harp messages.
211
+ If not specified, data must contain a MessageType column.
212
+
213
+ Returns
214
+ -------
215
+ An array object containing message data formatted according
216
+ to the Harp binary protocol.
217
+ """
218
+ nrows = len(data)
219
+ if nrows == 0:
220
+ return np.empty(0, dtype=np.uint8)
221
+
222
+ if MessageType.__name__ in data.columns:
223
+ msgtype = data[MessageType.__name__].cat.codes
224
+ payload = data[data.columns.drop(MessageType.__name__)].values
225
+ elif message_type is not None:
226
+ msgtype = message_type
227
+ payload = data.values
228
+ else:
229
+ raise ValueError(f"message type must be specified either in the data or as argument")
230
+
231
+ time = data.index
232
+ is_timestamped = True
233
+ if epoch is not None:
234
+ if not isinstance(time, pd.DatetimeIndex):
235
+ raise ValueError(f"expected datetime index to encode with epoch but got {time.inferred_type}")
236
+ time = (time - epoch).total_seconds()
237
+ elif isinstance(time, pd.RangeIndex):
238
+ is_timestamped = False
239
+
240
+ if dtype is not None:
241
+ payload = payload.astype(dtype, copy=False)
242
+
243
+ ncols = payload.shape[1]
244
+ if length is not None and ncols != length:
245
+ raise ValueError(f"expected payload length {length} but got {ncols}")
246
+
247
+ if port is None:
248
+ port = 255
249
+
250
+ payloadtype = _payloadtypefromdtype[payload.dtype]
251
+ payloadlength = ncols * payload.dtype.itemsize
252
+ stride = payloadlength + 6
253
+ if is_timestamped:
254
+ payloadtype |= _PAYLOAD_TIMESTAMP_MASK
255
+ stride += 6
256
+
257
+ buffer = np.empty((nrows, stride), dtype=np.uint8)
258
+ buffer[:, 0] = msgtype
259
+ buffer[:, 1:5] = [stride - 2, address, port, payloadtype]
260
+
261
+ payloadoffset = 5
262
+ if is_timestamped:
263
+ seconds = time.astype(np.uint32)
264
+ micros = np.around(((time - seconds) / _SECONDS_PER_TICK).values).astype(np.uint16)
265
+ buffer[:, 5:9] = np.ndarray((nrows, 4), dtype=np.uint8, buffer=seconds.values)
266
+ buffer[:, 9:11] = np.ndarray((nrows, 2), dtype=np.uint8, buffer=micros)
267
+ payloadoffset += 6
268
+
269
+ payloadstop = payloadoffset + payloadlength
270
+ buffer[:, payloadoffset:payloadstop] = np.ndarray(
271
+ (nrows, payloadlength), dtype=np.uint8, buffer=np.ascontiguousarray(payload)
272
+ )
273
+ buffer[:, -1] = np.sum(buffer[:, 0:-1], axis=1, dtype=np.uint8)
274
+ return buffer.reshape(-1)
harp/model.py CHANGED
@@ -7,14 +7,7 @@ from __future__ import annotations
7
7
  from enum import Enum
8
8
  from typing import Annotated, Dict, List, Optional, Union
9
9
 
10
- from pydantic import (
11
- BaseModel,
12
- BeforeValidator,
13
- ConfigDict,
14
- Field,
15
- RootModel,
16
- field_serializer,
17
- )
10
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, RootModel, field_serializer
18
11
 
19
12
 
20
13
  class PayloadType(str, Enum):
harp/reader.py CHANGED
@@ -6,20 +6,21 @@ from functools import partial
6
6
  from math import log2
7
7
  from os import PathLike
8
8
  from pathlib import Path
9
- from typing import Any, BinaryIO, Callable, Iterable, Mapping, Optional, Protocol, Union
9
+ from typing import Callable, Iterable, Mapping, Optional, Protocol, Union
10
10
 
11
11
  from numpy import dtype
12
12
  from pandas import DataFrame, Series
13
- from pandas._typing import Axes
13
+ from pandas._typing import Axes # pyright: ignore[reportPrivateImportUsage]
14
14
 
15
15
  from harp.io import MessageType, read
16
16
  from harp.model import BitMask, GroupMask, Model, PayloadMember, Register
17
17
  from harp.schema import read_schema
18
+ from harp.typing import _BufferLike, _FileLike
18
19
 
19
20
 
20
21
  @dataclass
21
22
  class _ReaderParams:
22
- path: Path
23
+ base_path: Path
23
24
  epoch: Optional[datetime] = None
24
25
  keep_type: bool = False
25
26
 
@@ -27,7 +28,7 @@ class _ReaderParams:
27
28
  class _ReadRegister(Protocol):
28
29
  def __call__(
29
30
  self,
30
- file: Optional[Union[str, bytes, PathLike[Any], BinaryIO]] = None,
31
+ file_or_buf: Optional[Union[_FileLike, _BufferLike]] = None,
31
32
  epoch: Optional[datetime] = None,
32
33
  keep_type: bool = False,
33
34
  ) -> DataFrame: ...
@@ -81,12 +82,12 @@ def _compose_parser(
81
82
  params: _ReaderParams,
82
83
  ) -> Callable[..., DataFrame]:
83
84
  def parser(
84
- file: Optional[Union[str, bytes, PathLike[Any], BinaryIO]] = None,
85
+ data: Optional[Union[_FileLike, _BufferLike]] = None,
85
86
  columns: Optional[Axes] = None,
86
87
  epoch: Optional[datetime] = params.epoch,
87
88
  keep_type: bool = params.keep_type,
88
89
  ):
89
- df = g(file, columns, epoch, keep_type)
90
+ df = g(data, columns, epoch, keep_type)
90
91
  result = f(df)
91
92
  type_col = df.get(MessageType.__name__)
92
93
  if type_col is not None:
@@ -167,16 +168,16 @@ def _create_payloadmember_parser(device: Model, member: PayloadMember):
167
168
 
168
169
  def _create_register_reader(register: Register, params: _ReaderParams):
169
170
  def reader(
170
- file: Optional[Union[str, bytes, PathLike[Any], BinaryIO]] = None,
171
+ file_or_buf: Optional[Union[_FileLike, _BufferLike]] = None,
171
172
  columns: Optional[Axes] = None,
172
173
  epoch: Optional[datetime] = params.epoch,
173
174
  keep_type: bool = params.keep_type,
174
175
  ):
175
- if file is None:
176
- file = f"{params.path}_{register.address}.bin"
176
+ if file_or_buf is None:
177
+ file_or_buf = f"{params.base_path}_{register.address}.bin"
177
178
 
178
179
  data = read(
179
- file,
180
+ file_or_buf,
180
181
  address=register.address,
181
182
  dtype=dtype(register.type),
182
183
  length=register.length,
@@ -189,7 +190,7 @@ def _create_register_reader(register: Register, params: _ReaderParams):
189
190
  return reader
190
191
 
191
192
 
192
- def _create_register_parser(device: Model, name: str, params: _ReaderParams):
193
+ def _create_register_handler(device: Model, name: str, params: _ReaderParams):
193
194
  register = device.registers[name]
194
195
  reader = _create_register_reader(register, params)
195
196
 
@@ -197,29 +198,34 @@ def _create_register_parser(device: Model, name: str, params: _ReaderParams):
197
198
  key = register.maskType.root
198
199
  bitMask = None if device.bitMasks is None else device.bitMasks.get(key)
199
200
  if bitMask is not None:
200
- parser = _create_bitmask_parser(bitMask)
201
- reader = _compose_parser(parser, reader, params)
201
+ bitmask_parser = _create_bitmask_parser(bitMask)
202
+ reader = _compose_parser(bitmask_parser, reader, params)
202
203
  return RegisterReader(register, reader)
203
204
 
204
205
  groupMask = None if device.groupMasks is None else device.groupMasks.get(key)
205
206
  if groupMask is not None:
206
- parser = _create_groupmask_parser(name, groupMask)
207
- reader = _compose_parser(parser, reader, params)
207
+ groupmask_parser = _create_groupmask_parser(name, groupMask)
208
+ reader = _compose_parser(groupmask_parser, reader, params)
208
209
  return RegisterReader(register, reader)
209
210
 
210
211
  if register.payloadSpec is not None:
211
- payload_parsers = [
212
+ member_parsers = [
212
213
  (key, _create_payloadmember_parser(device, member))
213
214
  for key, member in register.payloadSpec.items()
214
215
  ]
215
216
 
216
- def parser(df: DataFrame):
217
- return DataFrame({n: f(df) for n, f in payload_parsers}, index=df.index)
217
+ def payload_parser(df: DataFrame):
218
+ return DataFrame({n: f(df) for n, f in member_parsers}, index=df.index)
218
219
 
219
- reader = _compose_parser(parser, reader, params)
220
+ reader = _compose_parser(payload_parser, reader, params)
220
221
  return RegisterReader(register, reader)
221
222
 
222
- reader = partial(reader, columns=[name])
223
+ columns = (
224
+ [name]
225
+ if register.length is None or register.length == 1
226
+ else [f"{name}_{i}" for i in range(register.length)]
227
+ )
228
+ reader = partial(reader, columns=columns)
223
229
  return RegisterReader(register, reader)
224
230
 
225
231
 
@@ -265,7 +271,7 @@ def create_reader(
265
271
  base_path = path / device.device if is_dir else path.parent / device.device
266
272
 
267
273
  reg_readers = {
268
- name: _create_register_parser(device, name, _ReaderParams(base_path, epoch, keep_type))
274
+ name: _create_register_handler(device, name, _ReaderParams(base_path, epoch, keep_type))
269
275
  for name in device.registers.keys()
270
276
  }
271
277
  return DeviceReader(device, reg_readers)
harp/schema.py CHANGED
@@ -8,6 +8,9 @@ from harp.model import Model, Registers
8
8
 
9
9
 
10
10
  def _read_common_registers() -> Registers:
11
+ if __package__ is None:
12
+ raise ValueError("__package__ is None: unable to read common registers")
13
+
11
14
  file = resources.files(__package__) / "common.yml"
12
15
  with file.open("r") as fileIO:
13
16
  return parse_yaml_raw_as(Registers, fileIO.read())
harp/typing.py ADDED
@@ -0,0 +1,13 @@
1
+ import mmap
2
+ import sys
3
+ from os import PathLike
4
+ from typing import Any, BinaryIO, Union
5
+
6
+ from numpy.typing import NDArray
7
+
8
+ if sys.version_info >= (3, 12):
9
+ from collections.abc import Buffer as _BufferLike
10
+ else:
11
+ _BufferLike = Union[bytes, bytearray, memoryview, mmap.mmap, NDArray[Any]]
12
+
13
+ _FileLike = Union[str, PathLike[str], BinaryIO]
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: harp-python
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: A low-level interface for loading binary Harp protocol data
5
5
  Author-email: Goncalo Lopes <g.lopes@neurogears.org>
6
- License: MIT License
6
+ License-Expression: MIT
7
7
  Project-URL: Bug Tracker, https://github.com/harp-tech/harp-python/issues
8
8
  Project-URL: Source Code, https://github.com/harp-tech/harp-python
9
9
  Classifier: Development Status :: 4 - Beta
@@ -12,7 +12,6 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.9
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Operating System :: OS Independent
15
- Classifier: License :: OSI Approved :: MIT License
16
15
  Requires-Python: >=3.9.0
17
16
  Description-Content-Type: text/markdown
18
17
  License-File: LICENSE
@@ -29,6 +28,7 @@ Requires-Dist: codespell; extra == "dev"
29
28
  Provides-Extra: jupyter
30
29
  Requires-Dist: ipykernel; extra == "jupyter"
31
30
  Requires-Dist: matplotlib; extra == "jupyter"
31
+ Dynamic: license-file
32
32
 
33
33
  # harp-python
34
34
 
@@ -0,0 +1,12 @@
1
+ harp/__init__.py,sha256=N6fclA4pCSdBwcJELnN15p5QioU_6F01gH-8F3wr_pI,264
2
+ harp/common.yml,sha256=qcejMFu00JjI5rkubuEDdrUu_uFkN17f_Ljb6yBd-RM,4452
3
+ harp/io.py,sha256=js_DDHOBxNIacnHQxua0i42ljOEYZFew28zPSTXSyOs,9828
4
+ harp/model.py,sha256=djJ7Ah6GliwDh1rXhTYoqXSDMUJ3UI9PAKFL-F6ooAg,5769
5
+ harp/reader.py,sha256=kdrNAG53yuWMgYvlv2uAfO-8CYLREI-1PLgsE-SnE_4,8887
6
+ harp/schema.py,sha256=9tNwjnec8WtRkV8P_oblauW7btOi_y1vWFC6Tw4EWt0,1929
7
+ harp/typing.py,sha256=MCjnRwj-K3vybLfp_dFFqf7MwmJ7PmYe0yD91bt8uEk,355
8
+ harp_python-0.4.1.dist-info/licenses/LICENSE,sha256=KxnuV3gNCp4WmLUC8DYWJvCKPdxMpwmWIzAYBYdqPrM,1104
9
+ harp_python-0.4.1.dist-info/METADATA,sha256=XjRu_m2eIB9ZVN649GnNRkinWiDmszh8W8fA3p6C96U,3164
10
+ harp_python-0.4.1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
11
+ harp_python-0.4.1.dist-info/top_level.txt,sha256=ztK1iHjgwmfXqxHCpu-e2uHIwdmodywYEUaWSODMvyM,5
12
+ harp_python-0.4.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.5.0)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,11 +0,0 @@
1
- harp/__init__.py,sha256=P2PHGnd9Oz3LwrscBHKXqdYZimF7k57HcAaIaksRKc0,220
2
- harp/common.yml,sha256=qcejMFu00JjI5rkubuEDdrUu_uFkN17f_Ljb6yBd-RM,4452
3
- harp/io.py,sha256=an802KRCU-eSBXKWB7nXsomUBWcfAwEOZs9U188_MUM,4179
4
- harp/model.py,sha256=oJim5Gc42H4MJwpLwd2Y7KHsoeI_Yw3CO0o6qn7pJbQ,5805
5
- harp/reader.py,sha256=u-Taf6gmkQ5_PcsDRNskPBhZ7UsCcYL0WPlH0sEmjk0,8588
6
- harp/schema.py,sha256=bUr4WS-en03mzisIc9eAYiG7kReMmHdEvVwrhEpTboo,1816
7
- harp_python-0.3.0.dist-info/LICENSE,sha256=KxnuV3gNCp4WmLUC8DYWJvCKPdxMpwmWIzAYBYdqPrM,1104
8
- harp_python-0.3.0.dist-info/METADATA,sha256=lWiDbZSxbm8NmtXXopNpmmUN_h0wN0nIYc_LOrXr0s4,3190
9
- harp_python-0.3.0.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
10
- harp_python-0.3.0.dist-info/top_level.txt,sha256=ztK1iHjgwmfXqxHCpu-e2uHIwdmodywYEUaWSODMvyM,5
11
- harp_python-0.3.0.dist-info/RECORD,,