harp-python 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (34) hide show
  1. {harp_python-0.3.0 → harp_python-0.4.0}/.github/workflows/build.yml +1 -1
  2. harp_python-0.4.0/.gitignore +23 -0
  3. {harp_python-0.3.0 → harp_python-0.4.0}/PKG-INFO +3 -2
  4. harp_python-0.4.0/harp/__init__.py +5 -0
  5. harp_python-0.4.0/harp/io.py +274 -0
  6. {harp_python-0.3.0 → harp_python-0.4.0}/harp/model.py +1 -8
  7. {harp_python-0.3.0 → harp_python-0.4.0}/harp/reader.py +26 -20
  8. {harp_python-0.3.0 → harp_python-0.4.0}/harp/schema.py +3 -0
  9. harp_python-0.4.0/harp/typing.py +13 -0
  10. {harp_python-0.3.0 → harp_python-0.4.0}/harp_python.egg-info/PKG-INFO +3 -2
  11. {harp_python-0.3.0 → harp_python-0.4.0}/harp_python.egg-info/SOURCES.txt +3 -0
  12. {harp_python-0.3.0 → harp_python-0.4.0}/pyproject.toml +3 -0
  13. {harp_python-0.3.0 → harp_python-0.4.0}/tests/data/device.yml +6 -0
  14. harp_python-0.4.0/tests/data/device_44.bin +0 -0
  15. harp_python-0.4.0/tests/data/empty_0.bin +0 -0
  16. {harp_python-0.3.0 → harp_python-0.4.0}/tests/params.py +3 -0
  17. {harp_python-0.3.0 → harp_python-0.4.0}/tests/test_io.py +38 -2
  18. {harp_python-0.3.0 → harp_python-0.4.0}/tests/test_reader.py +10 -5
  19. harp_python-0.3.0/.gitignore +0 -12
  20. harp_python-0.3.0/harp/__init__.py +0 -5
  21. harp_python-0.3.0/harp/io.py +0 -122
  22. {harp_python-0.3.0 → harp_python-0.4.0}/.gitmodules +0 -0
  23. {harp_python-0.3.0 → harp_python-0.4.0}/LICENSE +0 -0
  24. {harp_python-0.3.0 → harp_python-0.4.0}/MANIFEST.in +0 -0
  25. {harp_python-0.3.0 → harp_python-0.4.0}/README.md +0 -0
  26. {harp_python-0.3.0 → harp_python-0.4.0}/harp/common.yml +0 -0
  27. {harp_python-0.3.0 → harp_python-0.4.0}/harp_python.egg-info/dependency_links.txt +0 -0
  28. {harp_python-0.3.0 → harp_python-0.4.0}/harp_python.egg-info/requires.txt +0 -0
  29. {harp_python-0.3.0 → harp_python-0.4.0}/harp_python.egg-info/top_level.txt +0 -0
  30. {harp_python-0.3.0 → harp_python-0.4.0}/setup.cfg +0 -0
  31. {harp_python-0.3.0 → harp_python-0.4.0}/tests/__init__.py +0 -0
  32. {harp_python-0.3.0 → harp_python-0.4.0}/tests/data/device_0.bin +0 -0
  33. {harp_python-0.3.0 → harp_python-0.4.0}/tests/data/write_0.bin +0 -0
  34. {harp_python-0.3.0 → harp_python-0.4.0}/tests/test_schema.py +0 -0
@@ -16,7 +16,7 @@ jobs:
16
16
  strategy:
17
17
  matrix:
18
18
  os: [ubuntu-latest, windows-latest, macos-latest]
19
- python-version: [3.9, 3.11]
19
+ python-version: [3.9, 3.12]
20
20
  fail-fast: false
21
21
  steps:
22
22
  - name: Checkout
@@ -0,0 +1,23 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+
4
+ # Distribution / packaging
5
+ dist/
6
+ _version.py
7
+ *.egg-info/
8
+ *.egg
9
+
10
+ # IDE
11
+ .vscode/*
12
+
13
+ # misc
14
+ log*.txt
15
+ scratch/
16
+ scratch*.py
17
+
18
+ # Test
19
+ .coverage
20
+
21
+ # Environment
22
+ .venv/
23
+ uv.lock
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: harp-python
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A low-level interface for loading binary Harp protocol data
5
5
  Author-email: Goncalo Lopes <g.lopes@neurogears.org>
6
6
  License: MIT License
@@ -29,6 +29,7 @@ Requires-Dist: codespell; extra == "dev"
29
29
  Provides-Extra: jupyter
30
30
  Requires-Dist: ipykernel; extra == "jupyter"
31
31
  Requires-Dist: matplotlib; extra == "jupyter"
32
+ Dynamic: license-file
32
33
 
33
34
  # harp-python
34
35
 
@@ -0,0 +1,5 @@
1
+ from harp.io import REFERENCE_EPOCH, MessageType, read, to_buffer, to_file
2
+ from harp.reader import create_reader
3
+ from harp.schema import read_schema
4
+
5
+ __all__ = ["REFERENCE_EPOCH", "MessageType", "read", "to_buffer", "to_file", "create_reader", "read_schema"]
@@ -0,0 +1,274 @@
1
+ from datetime import datetime
2
+ from enum import IntEnum
3
+ from os import PathLike
4
+ from typing import Any, BinaryIO, Optional, Union
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ import pandas as pd
9
+ from pandas._typing import Axes
10
+
11
+ from harp.typing import _BufferLike, _FileLike
12
+
13
+ REFERENCE_EPOCH = datetime(1904, 1, 1)
14
+ """The reference epoch for UTC harp time."""
15
+
16
+
17
+ class MessageType(IntEnum):
18
+ """Specifies the type of a Harp message."""
19
+
20
+ NA = 0
21
+ READ = 1
22
+ WRITE = 2
23
+ EVENT = 3
24
+
25
+
26
+ _SECONDS_PER_TICK = 32e-6
27
+ _PAYLOAD_TIMESTAMP_MASK = 0x10
28
+ _messagetypes = [type.name for type in MessageType]
29
+ _dtypefrompayloadtype = {
30
+ 1: np.dtype(np.uint8),
31
+ 2: np.dtype(np.uint16),
32
+ 4: np.dtype(np.uint32),
33
+ 8: np.dtype(np.uint64),
34
+ 129: np.dtype(np.int8),
35
+ 130: np.dtype(np.int16),
36
+ 132: np.dtype(np.int32),
37
+ 136: np.dtype(np.int64),
38
+ 68: np.dtype(np.float32),
39
+ }
40
+ _payloadtypefromdtype = {v: k for k, v in _dtypefrompayloadtype.items()}
41
+
42
+
43
+ def read(
44
+ file_or_buf: Union[_FileLike, _BufferLike],
45
+ address: Optional[int] = None,
46
+ dtype: Optional[np.dtype] = None,
47
+ length: Optional[int] = None,
48
+ columns: Optional[Axes] = None,
49
+ epoch: Optional[datetime] = None,
50
+ keep_type: bool = False,
51
+ ):
52
+ """Read single-register Harp data from the specified file or buffer.
53
+
54
+ Parameters
55
+ ----------
56
+ file_or_buf
57
+ File path, open file object, or buffer containing binary data from
58
+ a single device register.
59
+ address
60
+ Expected register address. If specified, the address of
61
+ the first message is used for validation.
62
+ dtype
63
+ Expected data type of the register payload. If specified, the
64
+ payload type of the first message is used for validation.
65
+ length
66
+ Expected number of elements in register payload. If specified, the
67
+ payload length of the first message is used for validation.
68
+ columns
69
+ The optional column labels to use for the data values.
70
+ epoch
71
+ Reference datetime at which time zero begins. If specified,
72
+ the result data frame will have a datetime index.
73
+ keep_type
74
+ Specifies whether to include a column with the message type.
75
+
76
+ Returns
77
+ -------
78
+ A pandas data frame containing message data, sorted by time.
79
+ """
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
+
87
+ if len(data) == 0:
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
+ )
94
+
95
+ if address is not None and address != data[2]:
96
+ raise ValueError(f"expected address {address} but got {data[2]}")
97
+
98
+ index = None
99
+ stride = int(data[1] + 2)
100
+ nrows = len(data) // stride
101
+ payloadtype = data[4]
102
+ payloadoffset = 5
103
+ if payloadtype & _PAYLOAD_TIMESTAMP_MASK != 0:
104
+ seconds = np.ndarray(nrows, dtype=np.uint32, buffer=data, offset=payloadoffset, strides=stride)
105
+ payloadoffset += 4
106
+ micros = np.ndarray(nrows, dtype=np.uint16, buffer=data, offset=payloadoffset, strides=stride)
107
+ payloadoffset += 2
108
+ time = micros * _SECONDS_PER_TICK + seconds
109
+ payloadtype = payloadtype & ~np.uint8(_PAYLOAD_TIMESTAMP_MASK)
110
+ if epoch is not None:
111
+ time = epoch + pd.to_timedelta(time, "s") # type: ignore
112
+ index = pd.Series(time)
113
+ index.name = "Time"
114
+
115
+ payloadsize = stride - payloadoffset - 1
116
+ payloadtype = _dtypefrompayloadtype[payloadtype]
117
+ if dtype is not None and dtype != payloadtype:
118
+ raise ValueError(f"expected payload type {dtype} but got {payloadtype}")
119
+
120
+ elementsize = payloadtype.itemsize
121
+ payloadshape = (nrows, payloadsize // elementsize)
122
+ if length is not None and length != payloadshape[1]:
123
+ raise ValueError(f"expected payload length {length} but got {payloadshape[1]}")
124
+
125
+ payload = np.ndarray(
126
+ payloadshape,
127
+ dtype=payloadtype,
128
+ buffer=data,
129
+ offset=payloadoffset,
130
+ strides=(stride, elementsize),
131
+ )
132
+
133
+ result = pd.DataFrame(payload, index=index, columns=columns)
134
+ if keep_type:
135
+ msgtype = np.ndarray(nrows, dtype=np.uint8, buffer=data, offset=0, strides=stride)
136
+ msgtype = pd.Categorical.from_codes(msgtype, categories=_messagetypes) # type: ignore
137
+ result[MessageType.__name__] = msgtype
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)
@@ -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):
@@ -6,7 +6,7 @@ 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
@@ -15,11 +15,12 @@ from pandas._typing import Axes
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,
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)
@@ -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())
@@ -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,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: harp-python
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A low-level interface for loading binary Harp protocol data
5
5
  Author-email: Goncalo Lopes <g.lopes@neurogears.org>
6
6
  License: MIT License
@@ -29,6 +29,7 @@ Requires-Dist: codespell; extra == "dev"
29
29
  Provides-Extra: jupyter
30
30
  Requires-Dist: ipykernel; extra == "jupyter"
31
31
  Requires-Dist: matplotlib; extra == "jupyter"
32
+ Dynamic: license-file
32
33
 
33
34
  # harp-python
34
35
 
@@ -11,6 +11,7 @@ harp/io.py
11
11
  harp/model.py
12
12
  harp/reader.py
13
13
  harp/schema.py
14
+ harp/typing.py
14
15
  harp_python.egg-info/PKG-INFO
15
16
  harp_python.egg-info/SOURCES.txt
16
17
  harp_python.egg-info/dependency_links.txt
@@ -23,4 +24,6 @@ tests/test_reader.py
23
24
  tests/test_schema.py
24
25
  tests/data/device.yml
25
26
  tests/data/device_0.bin
27
+ tests/data/device_44.bin
28
+ tests/data/empty_0.bin
26
29
  tests/data/write_0.bin
@@ -67,6 +67,9 @@ exclude = [
67
67
  "reflex-generator"
68
68
  ]
69
69
 
70
+ [tool.ruff.lint]
71
+ select = ["I"]
72
+
70
73
  [tool.pyright]
71
74
  venvPath = "."
72
75
  venv = ".venv"
@@ -18,6 +18,12 @@ registers:
18
18
  type: U8
19
19
  maskType: DigitalInputs
20
20
  description: Reports the state of the digital input lines.
21
+ AnalogData:
22
+ address: 44
23
+ type: S16
24
+ length: 3
25
+ access: Event
26
+ description: Reports the current values of the analog input lines.
21
27
  bitMasks:
22
28
  DigitalInputs:
23
29
  description: Specifies the state of the digital input lines.
File without changes
@@ -1,4 +1,5 @@
1
1
  from dataclasses import dataclass
2
+ from datetime import datetime
2
3
  from os import PathLike
3
4
  from pathlib import Path
4
5
  from typing import Iterable, Optional, Type, Union
@@ -19,6 +20,8 @@ class DataFileParam:
19
20
  expected_dtype: Optional[np.dtype] = None
20
21
  expected_length: Optional[int] = None
21
22
  expected_error: Optional[Type[BaseException]] = None
23
+ repeat_data: Optional[int] = None
24
+ epoch: Optional[datetime] = None
22
25
  keep_type: bool = False
23
26
 
24
27
  def __post_init__(self):
@@ -1,10 +1,11 @@
1
1
  from contextlib import nullcontext
2
2
 
3
3
  import numpy as np
4
+ import pandas as pd
4
5
  import pytest
5
6
  from pytest import mark
6
7
 
7
- from harp.io import MessageType, read
8
+ from harp.io import REFERENCE_EPOCH, MessageType, read, to_buffer
8
9
  from tests.params import DataFileParam
9
10
 
10
11
  testdata = [
@@ -29,6 +30,10 @@ testdata = [
29
30
  ),
30
31
  DataFileParam(path="data/write_0.bin", expected_address=0, expected_rows=4),
31
32
  DataFileParam(path="data/write_0.bin", expected_address=0, expected_rows=4, keep_type=True),
33
+ DataFileParam(path="data/device_0.bin", expected_rows=300, repeat_data=300),
34
+ DataFileParam(path="data/device_0.bin", expected_rows=1, epoch=REFERENCE_EPOCH),
35
+ DataFileParam(path="data/empty_0.bin", expected_rows=0, epoch=REFERENCE_EPOCH),
36
+ DataFileParam(path="data/empty_0.bin", expected_rows=0),
32
37
  ]
33
38
 
34
39
 
@@ -36,17 +41,48 @@ testdata = [
36
41
  def test_read(dataFile: DataFileParam):
37
42
  context = pytest.raises if dataFile.expected_error else nullcontext
38
43
  with context(dataFile.expected_error): # type: ignore
44
+ file_or_buf = dataFile.path
45
+ if dataFile.repeat_data:
46
+ with open(file_or_buf, "rb") as f:
47
+ file_or_buf = f.read() * dataFile.repeat_data
48
+
39
49
  data = read(
40
- dataFile.path,
50
+ file_or_buf,
41
51
  address=dataFile.expected_address,
42
52
  dtype=dataFile.expected_dtype,
43
53
  length=dataFile.expected_length,
54
+ epoch=dataFile.epoch,
44
55
  keep_type=dataFile.keep_type,
45
56
  )
57
+
46
58
  assert len(data) == dataFile.expected_rows
59
+ assert isinstance(data.index, pd.DatetimeIndex if dataFile.epoch else pd.Index)
47
60
  if dataFile.keep_type:
48
61
  assert MessageType.__name__ in data.columns and data[MessageType.__name__].dtype == "category"
49
62
 
50
63
  if dataFile.expected_cols:
51
64
  for col in dataFile.expected_cols:
52
65
  assert col in data.columns
66
+
67
+
68
+ writedata = [
69
+ DataFileParam(path="data/device_0.bin", expected_rows=1, expected_address=0, keep_type=True),
70
+ ]
71
+
72
+
73
+ @mark.parametrize("dataFile", writedata)
74
+ def test_write(dataFile: DataFileParam):
75
+ if dataFile.expected_address is None:
76
+ raise AssertionError("expected address must be defined for all write tests")
77
+
78
+ buffer = np.fromfile(dataFile.path, np.uint8)
79
+ data = read(
80
+ buffer,
81
+ address=dataFile.expected_address,
82
+ dtype=dataFile.expected_dtype,
83
+ length=dataFile.expected_length,
84
+ keep_type=dataFile.keep_type,
85
+ )
86
+ assert len(data) == dataFile.expected_rows
87
+ write_buffer = to_buffer(data, address=dataFile.expected_address, length=dataFile.expected_length)
88
+ assert np.array_equal(buffer, write_buffer)
@@ -1,4 +1,4 @@
1
- import numpy as np
1
+ import pandas as pd
2
2
  from pytest import mark
3
3
 
4
4
  from harp.io import REFERENCE_EPOCH, MessageType
@@ -9,12 +9,12 @@ testdata = [
9
9
  DeviceSchemaParam(
10
10
  path="data",
11
11
  expected_whoAmI=0,
12
- expected_registers=["DigitalInputMode"],
12
+ expected_registers=["AnalogData"],
13
13
  ),
14
14
  DeviceSchemaParam(
15
15
  path="data/device.yml",
16
16
  expected_whoAmI=0,
17
- expected_registers=["DigitalInputMode"],
17
+ expected_registers=["AnalogData"],
18
18
  ),
19
19
  ]
20
20
 
@@ -26,8 +26,13 @@ def test_create_reader(schemaFile: DeviceSchemaParam):
26
26
 
27
27
  whoAmI = reader.WhoAmI.read()
28
28
  assert reader.device.whoAmI == whoAmI.iloc[0, 0]
29
- assert whoAmI.index.dtype.type == np.datetime64
29
+ assert isinstance(whoAmI.index, pd.DatetimeIndex)
30
30
 
31
31
  whoAmI = reader.WhoAmI.read(epoch=None, keep_type=True)
32
- assert whoAmI.index.dtype.type == np.float64
32
+ assert isinstance(whoAmI.index, pd.Index)
33
33
  assert whoAmI.iloc[0, -1] == MessageType.READ.name
34
+
35
+ if schemaFile.expected_registers:
36
+ for register_name in schemaFile.expected_registers:
37
+ data = reader.registers[register_name].read()
38
+ assert isinstance(data.index, pd.DatetimeIndex)
@@ -1,12 +0,0 @@
1
- # Environment files
2
- .vscode
3
- .venv
4
-
5
- # Python temp files
6
- __pycache__
7
- *.pyc
8
- *.egg-info
9
- dist
10
-
11
- # Data files
12
- /data
@@ -1,5 +0,0 @@
1
- from harp.io import REFERENCE_EPOCH, MessageType, read
2
- from harp.reader import create_reader
3
- from harp.schema import read_schema
4
-
5
- __all__ = ["REFERENCE_EPOCH", "MessageType", "read", "create_reader", "read_schema"]
@@ -1,122 +0,0 @@
1
- from datetime import datetime
2
- from enum import IntEnum
3
- from os import PathLike
4
- from typing import Any, BinaryIO, Optional, Union
5
-
6
- import numpy as np
7
- import pandas as pd
8
- from pandas._typing import Axes
9
-
10
- REFERENCE_EPOCH = datetime(1904, 1, 1)
11
- """The reference epoch for UTC harp time."""
12
-
13
-
14
- class MessageType(IntEnum):
15
- """Specifies the type of a Harp message."""
16
-
17
- NA = 0
18
- READ = 1
19
- WRITE = 2
20
- EVENT = 3
21
-
22
-
23
- _SECONDS_PER_TICK = 32e-6
24
- _messagetypes = [type.name for type in MessageType]
25
- _payloadtypes = {
26
- 1: np.dtype(np.uint8),
27
- 2: np.dtype(np.uint16),
28
- 4: np.dtype(np.uint32),
29
- 8: np.dtype(np.uint64),
30
- 129: np.dtype(np.int8),
31
- 130: np.dtype(np.int16),
32
- 132: np.dtype(np.int32),
33
- 136: np.dtype(np.int64),
34
- 68: np.dtype(np.float32),
35
- }
36
-
37
-
38
- def read(
39
- file: Union[str, bytes, PathLike[Any], BinaryIO],
40
- address: Optional[int] = None,
41
- dtype: Optional[np.dtype] = None,
42
- length: Optional[int] = None,
43
- columns: Optional[Axes] = None,
44
- epoch: Optional[datetime] = None,
45
- keep_type: bool = False,
46
- ):
47
- """Read single-register Harp data from the specified file.
48
-
49
- Parameters
50
- ----------
51
- file
52
- Open file object or filename containing binary data from
53
- a single device register.
54
- address
55
- Expected register address. If specified, the address of
56
- the first message in the file is used for validation.
57
- dtype
58
- Expected data type of the register payload. If specified, the
59
- payload type of the first message in the file is used for validation.
60
- length
61
- Expected number of elements in register payload. If specified, the
62
- payload length of the first message in the file is used for validation.
63
- columns
64
- The optional column labels to use for the data values.
65
- epoch
66
- Reference datetime at which time zero begins. If specified,
67
- the result data frame will have a datetime index.
68
- keep_type
69
- Specifies whether to include a column with the message type.
70
-
71
- Returns
72
- -------
73
- A pandas data frame containing message data, sorted by time.
74
- """
75
- data = np.fromfile(file, dtype=np.uint8)
76
- if len(data) == 0:
77
- return pd.DataFrame(columns=columns, index=pd.Index([], dtype=np.float64, name="Time"))
78
-
79
- if address is not None and address != data[2]:
80
- raise ValueError(f"expected address {address} but got {data[2]}")
81
-
82
- index = None
83
- stride = int(data[1] + 2)
84
- nrows = len(data) // stride
85
- payloadtype = data[4]
86
- payloadoffset = 5
87
- if payloadtype & 0x10 != 0:
88
- seconds = np.ndarray(nrows, dtype=np.uint32, buffer=data, offset=payloadoffset, strides=stride)
89
- payloadoffset += 4
90
- micros = np.ndarray(nrows, dtype=np.uint16, buffer=data, offset=payloadoffset, strides=stride)
91
- payloadoffset += 2
92
- time = micros * _SECONDS_PER_TICK + seconds
93
- payloadtype = payloadtype & ~np.uint8(0x10)
94
- if epoch is not None:
95
- time = epoch + pd.to_timedelta(time, "s") # type: ignore
96
- index = pd.Series(time)
97
- index.name = "Time"
98
-
99
- payloadsize = stride - payloadoffset - 1
100
- payloadtype = _payloadtypes[payloadtype]
101
- if dtype is not None and dtype != payloadtype:
102
- raise ValueError(f"expected payload type {dtype} but got {payloadtype}")
103
-
104
- elementsize = payloadtype.itemsize
105
- payloadshape = (nrows, payloadsize // elementsize)
106
- if length is not None and length != payloadshape[1]:
107
- raise ValueError(f"expected payload length {length} but got {payloadshape[1]}")
108
-
109
- payload = np.ndarray(
110
- payloadshape,
111
- dtype=payloadtype,
112
- buffer=data,
113
- offset=payloadoffset,
114
- strides=(stride, elementsize),
115
- )
116
-
117
- result = pd.DataFrame(payload, index=index, columns=columns)
118
- if keep_type:
119
- msgtype = np.ndarray(nrows, dtype=np.uint8, buffer=data, offset=0, strides=stride)
120
- msgtype = pd.Categorical.from_codes(msgtype, categories=_messagetypes) # type: ignore
121
- result[MessageType.__name__] = msgtype
122
- return result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes