harp-python 0.2.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.
- harp_python-0.4.0/.github/workflows/build.yml +37 -0
- harp_python-0.4.0/.gitignore +23 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/PKG-INFO +21 -4
- {harp-python-0.2.0 → harp_python-0.4.0}/README.md +13 -1
- harp_python-0.4.0/harp/__init__.py +5 -0
- harp_python-0.4.0/harp/io.py +274 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/harp/model.py +7 -25
- {harp-python-0.2.0 → harp_python-0.4.0}/harp/reader.py +52 -32
- {harp-python-0.2.0 → harp_python-0.4.0}/harp/schema.py +12 -11
- harp_python-0.4.0/harp/typing.py +13 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/harp_python.egg-info/PKG-INFO +21 -4
- {harp-python-0.2.0 → harp_python-0.4.0}/harp_python.egg-info/SOURCES.txt +4 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/harp_python.egg-info/requires.txt +5 -1
- {harp-python-0.2.0 → harp_python-0.4.0}/pyproject.toml +27 -14
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/data/device.yml +6 -0
- harp_python-0.4.0/tests/data/device_44.bin +0 -0
- harp_python-0.4.0/tests/data/empty_0.bin +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/params.py +7 -2
- harp_python-0.4.0/tests/test_io.py +88 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/test_reader.py +11 -5
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/test_schema.py +1 -0
- harp-python-0.2.0/.gitignore +0 -12
- harp-python-0.2.0/harp/__init__.py +0 -3
- harp-python-0.2.0/harp/io.py +0 -129
- harp-python-0.2.0/tests/test_io.py +0 -55
- {harp-python-0.2.0 → harp_python-0.4.0}/.gitmodules +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/LICENSE +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/MANIFEST.in +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/harp/common.yml +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/harp_python.egg-info/dependency_links.txt +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/harp_python.egg-info/top_level.txt +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/setup.cfg +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/__init__.py +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/data/device_0.bin +0 -0
- {harp-python-0.2.0 → harp_python-0.4.0}/tests/data/write_0.bin +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Builds the python environment; linter and formatting via ruff; type annotations via pyright;
|
|
2
|
+
# tests via pytest; reports test coverage via pytest-cov.
|
|
3
|
+
|
|
4
|
+
name: build
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: ['*']
|
|
8
|
+
pull_request:
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build_run_tests:
|
|
13
|
+
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
if: github.event.pull_request.draft == false
|
|
16
|
+
strategy:
|
|
17
|
+
matrix:
|
|
18
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
19
|
+
python-version: [3.9, 3.12]
|
|
20
|
+
fail-fast: false
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
- name: Setup Python
|
|
25
|
+
uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
cache: 'pip'
|
|
29
|
+
- name: Install dependencies
|
|
30
|
+
run: pip install -e .[dev]
|
|
31
|
+
|
|
32
|
+
- name: ruff
|
|
33
|
+
run: ruff check .
|
|
34
|
+
- name: pyright
|
|
35
|
+
run: pyright .
|
|
36
|
+
- name: pytest
|
|
37
|
+
run: pytest --cov harp
|
|
@@ -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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: harp-python
|
|
3
|
-
Version: 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
|
|
@@ -20,15 +20,32 @@ Requires-Dist: pydantic-yaml
|
|
|
20
20
|
Requires-Dist: pandas
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Requires-Dist: datamodel-code-generator; extra == "dev"
|
|
23
|
+
Requires-Dist: pandas-stubs; extra == "dev"
|
|
23
24
|
Requires-Dist: pytest; extra == "dev"
|
|
24
|
-
Requires-Dist:
|
|
25
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
26
|
+
Requires-Dist: pyright; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Requires-Dist: codespell; extra == "dev"
|
|
25
29
|
Provides-Extra: jupyter
|
|
26
30
|
Requires-Dist: ipykernel; extra == "jupyter"
|
|
27
31
|
Requires-Dist: matplotlib; extra == "jupyter"
|
|
32
|
+
Dynamic: license-file
|
|
28
33
|
|
|
29
34
|
# harp-python
|
|
30
35
|
|
|
31
|
-
A low-level interface to data collected with the [Harp](https://harp-tech.org/
|
|
36
|
+
A low-level interface to data collected with the [Harp binary protocol](https://harp-tech.org/protocol/BinaryProtocol-8bit.html).
|
|
37
|
+
|
|
38
|
+
## How to install
|
|
39
|
+
|
|
40
|
+
The source code is currently hosted on [GitHub](https://github.com/harp-tech/harp-python).
|
|
41
|
+
|
|
42
|
+
Binary installers for the latest released version are available at the [Python Package Index (PyPI)](https://pypi.org/project/harp-python) and can be installed using `pip`.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install harp-python
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The list of changes between each release can be found [here](https://github.com/harp-tech/harp-python/releases).
|
|
32
49
|
|
|
33
50
|
## Data model
|
|
34
51
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# harp-python
|
|
2
2
|
|
|
3
|
-
A low-level interface to data collected with the [Harp](https://harp-tech.org/
|
|
3
|
+
A low-level interface to data collected with the [Harp binary protocol](https://harp-tech.org/protocol/BinaryProtocol-8bit.html).
|
|
4
|
+
|
|
5
|
+
## How to install
|
|
6
|
+
|
|
7
|
+
The source code is currently hosted on [GitHub](https://github.com/harp-tech/harp-python).
|
|
8
|
+
|
|
9
|
+
Binary installers for the latest released version are available at the [Python Package Index (PyPI)](https://pypi.org/project/harp-python) and can be installed using `pip`.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install harp-python
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The list of changes between each release can be found [here](https://github.com/harp-tech/harp-python/releases).
|
|
4
16
|
|
|
5
17
|
## Data model
|
|
6
18
|
|
|
@@ -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)
|
|
@@ -5,17 +5,9 @@
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
from enum import Enum
|
|
8
|
-
from typing import Dict, List, Optional, Union
|
|
9
|
-
from typing_extensions import Annotated
|
|
8
|
+
from typing import Annotated, Dict, List, Optional, Union
|
|
10
9
|
|
|
11
|
-
from pydantic import
|
|
12
|
-
BaseModel,
|
|
13
|
-
BeforeValidator,
|
|
14
|
-
ConfigDict,
|
|
15
|
-
Field,
|
|
16
|
-
RootModel,
|
|
17
|
-
field_serializer,
|
|
18
|
-
)
|
|
10
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, RootModel, field_serializer
|
|
19
11
|
|
|
20
12
|
|
|
21
13
|
class PayloadType(str, Enum):
|
|
@@ -136,16 +128,12 @@ class Visibility(Enum):
|
|
|
136
128
|
class Register(BaseModel):
|
|
137
129
|
address: Annotated[
|
|
138
130
|
int,
|
|
139
|
-
Field(
|
|
140
|
-
le=255, description="Specifies the unique 8-bit address of the register."
|
|
141
|
-
),
|
|
131
|
+
Field(le=255, description="Specifies the unique 8-bit address of the register."),
|
|
142
132
|
]
|
|
143
133
|
type: Annotated[PayloadType, BeforeValidator(lambda v: PayloadType[v])]
|
|
144
134
|
length: Annotated[
|
|
145
135
|
Optional[int],
|
|
146
|
-
Field(
|
|
147
|
-
ge=1, default=1, description="Specifies the length of the register payload."
|
|
148
|
-
),
|
|
136
|
+
Field(ge=1, default=1, description="Specifies the length of the register payload."),
|
|
149
137
|
]
|
|
150
138
|
access: Union[Access, List[Access]] = Field(
|
|
151
139
|
..., description="Specifies the expected use of the register."
|
|
@@ -191,12 +179,6 @@ class Registers(BaseModel):
|
|
|
191
179
|
|
|
192
180
|
class Model(Registers):
|
|
193
181
|
device: str = Field(..., description="Specifies the name of the device.")
|
|
194
|
-
whoAmI: int = Field(
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
firmwareVersion: str = Field(
|
|
198
|
-
..., description="Specifies the semantic version of the device firmware."
|
|
199
|
-
)
|
|
200
|
-
hardwareTargets: str = Field(
|
|
201
|
-
..., description="Specifies the semantic version of the device hardware."
|
|
202
|
-
)
|
|
182
|
+
whoAmI: int = Field(..., description="Specifies the unique identifier for this device type.")
|
|
183
|
+
firmwareVersion: str = Field(..., description="Specifies the semantic version of the device firmware.")
|
|
184
|
+
hardwareTargets: str = Field(..., description="Specifies the semantic version of the device hardware.")
|
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from collections import UserDict
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from functools import partial
|
|
2
6
|
from math import log2
|
|
3
7
|
from os import PathLike
|
|
4
8
|
from pathlib import Path
|
|
5
|
-
from
|
|
6
|
-
|
|
7
|
-
from dataclasses import dataclass
|
|
9
|
+
from typing import Callable, Iterable, Mapping, Optional, Protocol, Union
|
|
10
|
+
|
|
8
11
|
from numpy import dtype
|
|
9
12
|
from pandas import DataFrame, Series
|
|
10
|
-
from typing import Any, BinaryIO, Callable, Iterable, Optional, Protocol, Union
|
|
11
13
|
from pandas._typing import Axes
|
|
12
|
-
|
|
14
|
+
|
|
13
15
|
from harp.io import MessageType, read
|
|
16
|
+
from harp.model import BitMask, GroupMask, Model, PayloadMember, Register
|
|
14
17
|
from harp.schema import read_schema
|
|
18
|
+
from harp.typing import _BufferLike, _FileLike
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
@dataclass
|
|
18
22
|
class _ReaderParams:
|
|
19
|
-
|
|
23
|
+
base_path: Path
|
|
20
24
|
epoch: Optional[datetime] = None
|
|
21
25
|
keep_type: bool = False
|
|
22
26
|
|
|
@@ -24,11 +28,10 @@ class _ReaderParams:
|
|
|
24
28
|
class _ReadRegister(Protocol):
|
|
25
29
|
def __call__(
|
|
26
30
|
self,
|
|
27
|
-
|
|
31
|
+
file_or_buf: Optional[Union[_FileLike, _BufferLike]] = None,
|
|
28
32
|
epoch: Optional[datetime] = None,
|
|
29
33
|
keep_type: bool = False,
|
|
30
|
-
) -> DataFrame:
|
|
31
|
-
...
|
|
34
|
+
) -> DataFrame: ...
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
class RegisterReader:
|
|
@@ -44,13 +47,27 @@ class RegisterReader:
|
|
|
44
47
|
self.read = read
|
|
45
48
|
|
|
46
49
|
|
|
50
|
+
class RegisterMap(UserDict[str, RegisterReader]):
|
|
51
|
+
_address_map: Mapping[int, RegisterReader]
|
|
52
|
+
|
|
53
|
+
def __init__(self, registers: Mapping[str, RegisterReader]) -> None:
|
|
54
|
+
super().__init__(registers)
|
|
55
|
+
self._address_map = {value.register.address: value for value in registers.values()}
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, key: Union[str, int]) -> RegisterReader:
|
|
58
|
+
if isinstance(key, int):
|
|
59
|
+
return self._address_map[key]
|
|
60
|
+
else:
|
|
61
|
+
return super().__getitem__(key)
|
|
62
|
+
|
|
63
|
+
|
|
47
64
|
class DeviceReader:
|
|
48
65
|
device: Model
|
|
49
|
-
registers:
|
|
66
|
+
registers: RegisterMap
|
|
50
67
|
|
|
51
|
-
def __init__(self, device: Model, registers:
|
|
68
|
+
def __init__(self, device: Model, registers: Mapping[str, RegisterReader]) -> None:
|
|
52
69
|
self.device = device
|
|
53
|
-
self.registers = registers
|
|
70
|
+
self.registers = RegisterMap(registers)
|
|
54
71
|
|
|
55
72
|
def __dir__(self) -> Iterable[str]:
|
|
56
73
|
return self.registers.keys()
|
|
@@ -65,12 +82,12 @@ def _compose_parser(
|
|
|
65
82
|
params: _ReaderParams,
|
|
66
83
|
) -> Callable[..., DataFrame]:
|
|
67
84
|
def parser(
|
|
68
|
-
|
|
85
|
+
data,
|
|
69
86
|
columns: Optional[Axes] = None,
|
|
70
87
|
epoch: Optional[datetime] = params.epoch,
|
|
71
88
|
keep_type: bool = params.keep_type,
|
|
72
89
|
):
|
|
73
|
-
df = g(
|
|
90
|
+
df = g(data, columns, epoch, keep_type)
|
|
74
91
|
result = f(df)
|
|
75
92
|
type_col = df.get(MessageType.__name__)
|
|
76
93
|
if type_col is not None:
|
|
@@ -151,16 +168,16 @@ def _create_payloadmember_parser(device: Model, member: PayloadMember):
|
|
|
151
168
|
|
|
152
169
|
def _create_register_reader(register: Register, params: _ReaderParams):
|
|
153
170
|
def reader(
|
|
154
|
-
|
|
171
|
+
file_or_buf: Optional[Union[_FileLike, _BufferLike]] = None,
|
|
155
172
|
columns: Optional[Axes] = None,
|
|
156
173
|
epoch: Optional[datetime] = params.epoch,
|
|
157
174
|
keep_type: bool = params.keep_type,
|
|
158
175
|
):
|
|
159
|
-
if
|
|
160
|
-
|
|
176
|
+
if file_or_buf is None:
|
|
177
|
+
file_or_buf = f"{params.base_path}_{register.address}.bin"
|
|
161
178
|
|
|
162
179
|
data = read(
|
|
163
|
-
|
|
180
|
+
file_or_buf,
|
|
164
181
|
address=register.address,
|
|
165
182
|
dtype=dtype(register.type),
|
|
166
183
|
length=register.length,
|
|
@@ -173,7 +190,7 @@ def _create_register_reader(register: Register, params: _ReaderParams):
|
|
|
173
190
|
return reader
|
|
174
191
|
|
|
175
192
|
|
|
176
|
-
def
|
|
193
|
+
def _create_register_handler(device: Model, name: str, params: _ReaderParams):
|
|
177
194
|
register = device.registers[name]
|
|
178
195
|
reader = _create_register_reader(register, params)
|
|
179
196
|
|
|
@@ -181,29 +198,34 @@ def _create_register_parser(device: Model, name: str, params: _ReaderParams):
|
|
|
181
198
|
key = register.maskType.root
|
|
182
199
|
bitMask = None if device.bitMasks is None else device.bitMasks.get(key)
|
|
183
200
|
if bitMask is not None:
|
|
184
|
-
|
|
185
|
-
reader = _compose_parser(
|
|
201
|
+
bitmask_parser = _create_bitmask_parser(bitMask)
|
|
202
|
+
reader = _compose_parser(bitmask_parser, reader, params)
|
|
186
203
|
return RegisterReader(register, reader)
|
|
187
204
|
|
|
188
205
|
groupMask = None if device.groupMasks is None else device.groupMasks.get(key)
|
|
189
206
|
if groupMask is not None:
|
|
190
|
-
|
|
191
|
-
reader = _compose_parser(
|
|
207
|
+
groupmask_parser = _create_groupmask_parser(name, groupMask)
|
|
208
|
+
reader = _compose_parser(groupmask_parser, reader, params)
|
|
192
209
|
return RegisterReader(register, reader)
|
|
193
210
|
|
|
194
211
|
if register.payloadSpec is not None:
|
|
195
|
-
|
|
212
|
+
member_parsers = [
|
|
196
213
|
(key, _create_payloadmember_parser(device, member))
|
|
197
214
|
for key, member in register.payloadSpec.items()
|
|
198
215
|
]
|
|
199
216
|
|
|
200
|
-
def
|
|
201
|
-
return DataFrame({n: f(df) for n, f in
|
|
217
|
+
def payload_parser(df: DataFrame):
|
|
218
|
+
return DataFrame({n: f(df) for n, f in member_parsers}, index=df.index)
|
|
202
219
|
|
|
203
|
-
reader = _compose_parser(
|
|
220
|
+
reader = _compose_parser(payload_parser, reader, params)
|
|
204
221
|
return RegisterReader(register, reader)
|
|
205
222
|
|
|
206
|
-
|
|
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)
|
|
207
229
|
return RegisterReader(register, reader)
|
|
208
230
|
|
|
209
231
|
|
|
@@ -214,7 +236,7 @@ def create_reader(
|
|
|
214
236
|
keep_type: bool = False,
|
|
215
237
|
):
|
|
216
238
|
"""Creates a device reader object from the specified dataset or schema.
|
|
217
|
-
|
|
239
|
+
|
|
218
240
|
Parameters
|
|
219
241
|
----------
|
|
220
242
|
device
|
|
@@ -249,9 +271,7 @@ def create_reader(
|
|
|
249
271
|
base_path = path / device.device if is_dir else path.parent / device.device
|
|
250
272
|
|
|
251
273
|
reg_readers = {
|
|
252
|
-
name:
|
|
253
|
-
device, name, _ReaderParams(base_path, epoch, keep_type)
|
|
254
|
-
)
|
|
274
|
+
name: _create_register_handler(device, name, _ReaderParams(base_path, epoch, keep_type))
|
|
255
275
|
for name in device.registers.keys()
|
|
256
276
|
}
|
|
257
277
|
return DeviceReader(device, reg_readers)
|
|
@@ -1,21 +1,24 @@
|
|
|
1
|
+
from importlib import resources
|
|
1
2
|
from os import PathLike
|
|
2
3
|
from typing import TextIO, Union
|
|
3
|
-
|
|
4
|
+
|
|
4
5
|
from pydantic_yaml import parse_yaml_raw_as
|
|
5
|
-
|
|
6
|
+
|
|
7
|
+
from harp.model import Model, Registers
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
def _read_common_registers() -> Registers:
|
|
11
|
+
if __package__ is None:
|
|
12
|
+
raise ValueError("__package__ is None: unable to read common registers")
|
|
13
|
+
|
|
9
14
|
file = resources.files(__package__) / "common.yml"
|
|
10
|
-
with file.open("
|
|
15
|
+
with file.open("r") as fileIO:
|
|
11
16
|
return parse_yaml_raw_as(Registers, fileIO.read())
|
|
12
17
|
|
|
13
18
|
|
|
14
|
-
def read_schema(
|
|
15
|
-
file: Union[str, PathLike, TextIO], include_common_registers: bool = True
|
|
16
|
-
) -> Model:
|
|
19
|
+
def read_schema(file: Union[str, PathLike, TextIO], include_common_registers: bool = True) -> Model:
|
|
17
20
|
"""Read and parse a device schema from the specified file.
|
|
18
|
-
|
|
21
|
+
|
|
19
22
|
Parameters
|
|
20
23
|
----------
|
|
21
24
|
file
|
|
@@ -35,14 +38,12 @@ def read_schema(
|
|
|
35
38
|
return read_schema(fileIO)
|
|
36
39
|
else:
|
|
37
40
|
schema = parse_yaml_raw_as(Model, file.read())
|
|
38
|
-
if
|
|
41
|
+
if "WhoAmI" not in schema.registers and include_common_registers:
|
|
39
42
|
common = _read_common_registers()
|
|
40
43
|
schema.registers = dict(common.registers, **schema.registers)
|
|
41
44
|
if common.bitMasks:
|
|
42
45
|
schema.bitMasks = (
|
|
43
|
-
common.bitMasks
|
|
44
|
-
if schema.bitMasks is None
|
|
45
|
-
else dict(common.bitMasks, **schema.bitMasks)
|
|
46
|
+
common.bitMasks if schema.bitMasks is None else dict(common.bitMasks, **schema.bitMasks)
|
|
46
47
|
)
|
|
47
48
|
if common.groupMasks:
|
|
48
49
|
schema.groupMasks = (
|
|
@@ -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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: harp-python
|
|
3
|
-
Version: 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
|
|
@@ -20,15 +20,32 @@ Requires-Dist: pydantic-yaml
|
|
|
20
20
|
Requires-Dist: pandas
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Requires-Dist: datamodel-code-generator; extra == "dev"
|
|
23
|
+
Requires-Dist: pandas-stubs; extra == "dev"
|
|
23
24
|
Requires-Dist: pytest; extra == "dev"
|
|
24
|
-
Requires-Dist:
|
|
25
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
26
|
+
Requires-Dist: pyright; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Requires-Dist: codespell; extra == "dev"
|
|
25
29
|
Provides-Extra: jupyter
|
|
26
30
|
Requires-Dist: ipykernel; extra == "jupyter"
|
|
27
31
|
Requires-Dist: matplotlib; extra == "jupyter"
|
|
32
|
+
Dynamic: license-file
|
|
28
33
|
|
|
29
34
|
# harp-python
|
|
30
35
|
|
|
31
|
-
A low-level interface to data collected with the [Harp](https://harp-tech.org/
|
|
36
|
+
A low-level interface to data collected with the [Harp binary protocol](https://harp-tech.org/protocol/BinaryProtocol-8bit.html).
|
|
37
|
+
|
|
38
|
+
## How to install
|
|
39
|
+
|
|
40
|
+
The source code is currently hosted on [GitHub](https://github.com/harp-tech/harp-python).
|
|
41
|
+
|
|
42
|
+
Binary installers for the latest released version are available at the [Python Package Index (PyPI)](https://pypi.org/project/harp-python) and can be installed using `pip`.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install harp-python
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The list of changes between each release can be found [here](https://github.com/harp-tech/harp-python/releases).
|
|
32
49
|
|
|
33
50
|
## Data model
|
|
34
51
|
|
|
@@ -4,12 +4,14 @@ LICENSE
|
|
|
4
4
|
MANIFEST.in
|
|
5
5
|
README.md
|
|
6
6
|
pyproject.toml
|
|
7
|
+
.github/workflows/build.yml
|
|
7
8
|
harp/__init__.py
|
|
8
9
|
harp/common.yml
|
|
9
10
|
harp/io.py
|
|
10
11
|
harp/model.py
|
|
11
12
|
harp/reader.py
|
|
12
13
|
harp/schema.py
|
|
14
|
+
harp/typing.py
|
|
13
15
|
harp_python.egg-info/PKG-INFO
|
|
14
16
|
harp_python.egg-info/SOURCES.txt
|
|
15
17
|
harp_python.egg-info/dependency_links.txt
|
|
@@ -22,4 +24,6 @@ tests/test_reader.py
|
|
|
22
24
|
tests/test_schema.py
|
|
23
25
|
tests/data/device.yml
|
|
24
26
|
tests/data/device_0.bin
|
|
27
|
+
tests/data/device_44.bin
|
|
28
|
+
tests/data/empty_0.bin
|
|
25
29
|
tests/data/write_0.bin
|
|
@@ -31,9 +31,14 @@ classifiers = [
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
32
|
dev = [
|
|
33
33
|
"datamodel-code-generator",
|
|
34
|
+
"pandas-stubs",
|
|
34
35
|
"pytest",
|
|
35
|
-
"
|
|
36
|
+
"pytest-cov",
|
|
37
|
+
"pyright",
|
|
38
|
+
"ruff",
|
|
39
|
+
"codespell"
|
|
36
40
|
]
|
|
41
|
+
|
|
37
42
|
jupyter = [
|
|
38
43
|
"ipykernel",
|
|
39
44
|
"matplotlib"
|
|
@@ -55,16 +60,24 @@ include = ["harp*"]
|
|
|
55
60
|
|
|
56
61
|
[tool.setuptools_scm]
|
|
57
62
|
|
|
58
|
-
[tool.
|
|
59
|
-
line-length =
|
|
60
|
-
target-version =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 108
|
|
65
|
+
target-version = "py39"
|
|
66
|
+
exclude = [
|
|
67
|
+
"reflex-generator"
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = ["I"]
|
|
72
|
+
|
|
73
|
+
[tool.pyright]
|
|
74
|
+
venvPath = "."
|
|
75
|
+
venv = ".venv"
|
|
76
|
+
exclude = [
|
|
77
|
+
".venv/*",
|
|
78
|
+
"reflex-generator"
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.codespell]
|
|
82
|
+
skip = '.git,*.pdf,*.svg'
|
|
83
|
+
ignore-words-list = 'nd'
|
|
@@ -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.
|
|
Binary file
|
|
File without changes
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
from os import PathLike
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from dataclasses import dataclass
|
|
5
5
|
from typing import Iterable, Optional, Type, Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
6
9
|
from harp.model import Model
|
|
7
10
|
|
|
8
11
|
datapath = Path(__file__).parent
|
|
@@ -17,6 +20,8 @@ class DataFileParam:
|
|
|
17
20
|
expected_dtype: Optional[np.dtype] = None
|
|
18
21
|
expected_length: Optional[int] = None
|
|
19
22
|
expected_error: Optional[Type[BaseException]] = None
|
|
23
|
+
repeat_data: Optional[int] = None
|
|
24
|
+
epoch: Optional[datetime] = None
|
|
20
25
|
keep_type: bool = False
|
|
21
26
|
|
|
22
27
|
def __post_init__(self):
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from contextlib import nullcontext
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest import mark
|
|
7
|
+
|
|
8
|
+
from harp.io import REFERENCE_EPOCH, MessageType, read, to_buffer
|
|
9
|
+
from tests.params import DataFileParam
|
|
10
|
+
|
|
11
|
+
testdata = [
|
|
12
|
+
DataFileParam(path="data/device_0.bin", expected_rows=1),
|
|
13
|
+
DataFileParam(
|
|
14
|
+
path="data/device_0.bin",
|
|
15
|
+
expected_rows=1,
|
|
16
|
+
expected_address=1, # actual address is 0
|
|
17
|
+
expected_error=ValueError,
|
|
18
|
+
),
|
|
19
|
+
DataFileParam(
|
|
20
|
+
path="data/device_0.bin",
|
|
21
|
+
expected_rows=1,
|
|
22
|
+
expected_dtype=np.dtype("uint8"), # actual dtype is uint16
|
|
23
|
+
expected_error=ValueError,
|
|
24
|
+
),
|
|
25
|
+
DataFileParam(
|
|
26
|
+
path="data/device_0.bin",
|
|
27
|
+
expected_rows=1,
|
|
28
|
+
expected_length=2, # actual length is 1
|
|
29
|
+
expected_error=ValueError,
|
|
30
|
+
),
|
|
31
|
+
DataFileParam(path="data/write_0.bin", expected_address=0, expected_rows=4),
|
|
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),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@mark.parametrize("dataFile", testdata)
|
|
41
|
+
def test_read(dataFile: DataFileParam):
|
|
42
|
+
context = pytest.raises if dataFile.expected_error else nullcontext
|
|
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
|
+
|
|
49
|
+
data = read(
|
|
50
|
+
file_or_buf,
|
|
51
|
+
address=dataFile.expected_address,
|
|
52
|
+
dtype=dataFile.expected_dtype,
|
|
53
|
+
length=dataFile.expected_length,
|
|
54
|
+
epoch=dataFile.epoch,
|
|
55
|
+
keep_type=dataFile.keep_type,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert len(data) == dataFile.expected_rows
|
|
59
|
+
assert isinstance(data.index, pd.DatetimeIndex if dataFile.epoch else pd.Index)
|
|
60
|
+
if dataFile.keep_type:
|
|
61
|
+
assert MessageType.__name__ in data.columns and data[MessageType.__name__].dtype == "category"
|
|
62
|
+
|
|
63
|
+
if dataFile.expected_cols:
|
|
64
|
+
for col in dataFile.expected_cols:
|
|
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,5 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import pandas as pd
|
|
2
2
|
from pytest import mark
|
|
3
|
+
|
|
3
4
|
from harp.io import REFERENCE_EPOCH, MessageType
|
|
4
5
|
from harp.reader import create_reader
|
|
5
6
|
from tests.params import DeviceSchemaParam
|
|
@@ -8,12 +9,12 @@ testdata = [
|
|
|
8
9
|
DeviceSchemaParam(
|
|
9
10
|
path="data",
|
|
10
11
|
expected_whoAmI=0,
|
|
11
|
-
expected_registers=["
|
|
12
|
+
expected_registers=["AnalogData"],
|
|
12
13
|
),
|
|
13
14
|
DeviceSchemaParam(
|
|
14
15
|
path="data/device.yml",
|
|
15
16
|
expected_whoAmI=0,
|
|
16
|
-
expected_registers=["
|
|
17
|
+
expected_registers=["AnalogData"],
|
|
17
18
|
),
|
|
18
19
|
]
|
|
19
20
|
|
|
@@ -25,8 +26,13 @@ def test_create_reader(schemaFile: DeviceSchemaParam):
|
|
|
25
26
|
|
|
26
27
|
whoAmI = reader.WhoAmI.read()
|
|
27
28
|
assert reader.device.whoAmI == whoAmI.iloc[0, 0]
|
|
28
|
-
assert whoAmI.index
|
|
29
|
+
assert isinstance(whoAmI.index, pd.DatetimeIndex)
|
|
29
30
|
|
|
30
31
|
whoAmI = reader.WhoAmI.read(epoch=None, keep_type=True)
|
|
31
|
-
assert whoAmI.index
|
|
32
|
+
assert isinstance(whoAmI.index, pd.Index)
|
|
32
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)
|
harp-python-0.2.0/.gitignore
DELETED
harp-python-0.2.0/harp/io.py
DELETED
|
@@ -1,129 +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
|
-
from pandas._typing import Axes
|
|
6
|
-
import numpy as np
|
|
7
|
-
import pandas as pd
|
|
8
|
-
|
|
9
|
-
REFERENCE_EPOCH = datetime(1904, 1, 1)
|
|
10
|
-
"""The reference epoch for UTC harp time."""
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class MessageType(IntEnum):
|
|
14
|
-
"""Specifies the type of a Harp message."""
|
|
15
|
-
|
|
16
|
-
NA = 0
|
|
17
|
-
READ = 1
|
|
18
|
-
WRITE = 2
|
|
19
|
-
EVENT = 3
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
_SECONDS_PER_TICK = 32e-6
|
|
23
|
-
_messagetypes = [type.name for type in MessageType]
|
|
24
|
-
_payloadtypes = {
|
|
25
|
-
1: np.dtype(np.uint8),
|
|
26
|
-
2: np.dtype(np.uint16),
|
|
27
|
-
4: np.dtype(np.uint32),
|
|
28
|
-
8: np.dtype(np.uint64),
|
|
29
|
-
129: np.dtype(np.int8),
|
|
30
|
-
130: np.dtype(np.int16),
|
|
31
|
-
132: np.dtype(np.int32),
|
|
32
|
-
136: np.dtype(np.int64),
|
|
33
|
-
68: np.dtype(np.float32),
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def read(
|
|
38
|
-
file: Union[str, bytes, PathLike[Any], BinaryIO],
|
|
39
|
-
address: Optional[int] = None,
|
|
40
|
-
dtype: Optional[np.dtype] = None,
|
|
41
|
-
length: Optional[int] = None,
|
|
42
|
-
columns: Optional[Axes] = None,
|
|
43
|
-
epoch: Optional[datetime] = None,
|
|
44
|
-
keep_type: bool = False,
|
|
45
|
-
):
|
|
46
|
-
"""Read single-register Harp data from the specified file.
|
|
47
|
-
|
|
48
|
-
Parameters
|
|
49
|
-
----------
|
|
50
|
-
file
|
|
51
|
-
Open file object or filename containing binary data from
|
|
52
|
-
a single device register.
|
|
53
|
-
address
|
|
54
|
-
Expected register address. If specified, the address of
|
|
55
|
-
the first message in the file is used for validation.
|
|
56
|
-
dtype
|
|
57
|
-
Expected data type of the register payload. If specified, the
|
|
58
|
-
payload type of the first message in the file is used for validation.
|
|
59
|
-
length
|
|
60
|
-
Expected number of elements in register payload. If specified, the
|
|
61
|
-
payload length of the first message in the file is used for validation.
|
|
62
|
-
columns
|
|
63
|
-
The optional column labels to use for the data values.
|
|
64
|
-
epoch
|
|
65
|
-
Reference datetime at which time zero begins. If specified,
|
|
66
|
-
the result data frame will have a datetime index.
|
|
67
|
-
keep_type
|
|
68
|
-
Specifies whether to include a column with the message type.
|
|
69
|
-
|
|
70
|
-
Returns
|
|
71
|
-
-------
|
|
72
|
-
A pandas data frame containing message data, sorted by time.
|
|
73
|
-
"""
|
|
74
|
-
data = np.fromfile(file, dtype=np.uint8)
|
|
75
|
-
if len(data) == 0:
|
|
76
|
-
return pd.DataFrame(
|
|
77
|
-
columns=columns, index=pd.Index([], dtype=np.float64, name="Time")
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
if address is not None and address != data[2]:
|
|
81
|
-
raise ValueError(f"expected address {address} but got {data[2]}")
|
|
82
|
-
|
|
83
|
-
index = None
|
|
84
|
-
stride = data[1] + 2
|
|
85
|
-
nrows = len(data) // stride
|
|
86
|
-
payloadtype = data[4]
|
|
87
|
-
payloadoffset = 5
|
|
88
|
-
if payloadtype & 0x10 != 0:
|
|
89
|
-
seconds = np.ndarray(
|
|
90
|
-
nrows, dtype=np.uint32, buffer=data, offset=payloadoffset, strides=stride
|
|
91
|
-
)
|
|
92
|
-
payloadoffset += 4
|
|
93
|
-
micros = np.ndarray(
|
|
94
|
-
nrows, dtype=np.uint16, buffer=data, offset=payloadoffset, strides=stride
|
|
95
|
-
)
|
|
96
|
-
payloadoffset += 2
|
|
97
|
-
time = micros * _SECONDS_PER_TICK + seconds
|
|
98
|
-
payloadtype = payloadtype & ~0x10
|
|
99
|
-
if epoch is not None:
|
|
100
|
-
time = epoch + pd.to_timedelta(time, "s") # type: ignore
|
|
101
|
-
index = pd.Series(time)
|
|
102
|
-
index.name = "Time"
|
|
103
|
-
|
|
104
|
-
payloadsize = stride - payloadoffset - 1
|
|
105
|
-
payloadtype = _payloadtypes[payloadtype]
|
|
106
|
-
if dtype is not None and dtype != payloadtype:
|
|
107
|
-
raise ValueError(f"expected payload type {dtype} but got {payloadtype}")
|
|
108
|
-
|
|
109
|
-
elementsize = payloadtype.itemsize
|
|
110
|
-
payloadshape = (nrows, payloadsize // elementsize)
|
|
111
|
-
if length is not None and length != payloadshape[1]:
|
|
112
|
-
raise ValueError(f"expected payload length {length} but got {payloadshape[1]}")
|
|
113
|
-
|
|
114
|
-
payload = np.ndarray(
|
|
115
|
-
payloadshape,
|
|
116
|
-
dtype=payloadtype,
|
|
117
|
-
buffer=data,
|
|
118
|
-
offset=payloadoffset,
|
|
119
|
-
strides=(stride, elementsize),
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
result = pd.DataFrame(payload, index=index, columns=columns)
|
|
123
|
-
if keep_type:
|
|
124
|
-
msgtype = np.ndarray(
|
|
125
|
-
nrows, dtype=np.uint8, buffer=data, offset=0, strides=stride
|
|
126
|
-
)
|
|
127
|
-
msgtype = pd.Categorical.from_codes(msgtype, categories=_messagetypes) # type: ignore
|
|
128
|
-
result[MessageType.__name__] = msgtype
|
|
129
|
-
return result
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import numpy as np
|
|
3
|
-
from contextlib import nullcontext
|
|
4
|
-
from pytest import mark
|
|
5
|
-
from harp.io import read, MessageType
|
|
6
|
-
from tests.params import DataFileParam
|
|
7
|
-
|
|
8
|
-
testdata = [
|
|
9
|
-
DataFileParam(path="data/device_0.bin", expected_rows=1),
|
|
10
|
-
DataFileParam(
|
|
11
|
-
path="data/device_0.bin",
|
|
12
|
-
expected_rows=1,
|
|
13
|
-
expected_address=1, # actual address is 0
|
|
14
|
-
expected_error=ValueError,
|
|
15
|
-
),
|
|
16
|
-
DataFileParam(
|
|
17
|
-
path="data/device_0.bin",
|
|
18
|
-
expected_rows=1,
|
|
19
|
-
expected_dtype=np.dtype("uint8"), # actual dtype is uint16
|
|
20
|
-
expected_error=ValueError,
|
|
21
|
-
),
|
|
22
|
-
DataFileParam(
|
|
23
|
-
path="data/device_0.bin",
|
|
24
|
-
expected_rows=1,
|
|
25
|
-
expected_length=2, # actual length is 1
|
|
26
|
-
expected_error=ValueError,
|
|
27
|
-
),
|
|
28
|
-
DataFileParam(path="data/write_0.bin", expected_address=0, expected_rows=4),
|
|
29
|
-
DataFileParam(
|
|
30
|
-
path="data/write_0.bin", expected_address=0, expected_rows=4, keep_type=True
|
|
31
|
-
),
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@mark.parametrize("dataFile", testdata)
|
|
36
|
-
def test_read(dataFile: DataFileParam):
|
|
37
|
-
context = pytest.raises if dataFile.expected_error else nullcontext
|
|
38
|
-
with context(dataFile.expected_error): # type: ignore
|
|
39
|
-
data = read(
|
|
40
|
-
dataFile.path,
|
|
41
|
-
address=dataFile.expected_address,
|
|
42
|
-
dtype=dataFile.expected_dtype,
|
|
43
|
-
length=dataFile.expected_length,
|
|
44
|
-
keep_type=dataFile.keep_type,
|
|
45
|
-
)
|
|
46
|
-
assert len(data) == dataFile.expected_rows
|
|
47
|
-
if dataFile.keep_type:
|
|
48
|
-
assert (
|
|
49
|
-
MessageType.__name__ in data.columns
|
|
50
|
-
and data[MessageType.__name__].dtype == "category"
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
if dataFile.expected_cols:
|
|
54
|
-
for col in dataFile.expected_cols:
|
|
55
|
-
assert col in data.columns
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|