Skip to content

Commit 49672f4

Browse files
authored
Add dataset reader to harp-data (#15)
DatasetReader reads a de-multiplexed dataset folder driven by a device, by register class or by address, with read_all returning every register that has a file present. Timestamps are auto-detected per register and become a Time index, float seconds by default or an absolute DatetimeIndex given an epoch, matching harp-python. create_dataset_reader finds device.yml inside the folder and returns a reader ready to read. Closes #10
1 parent 94c7ad9 commit 49672f4

16 files changed

Lines changed: 685 additions & 39 deletions

File tree

README.md

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,42 +52,48 @@ pip install harp-data
5252

5353
## Quickstart
5454

55-
Have only a device's `device.yml`? `create_device` compiles it into a typed
56-
`Device` at runtime — no code-generation step — giving you the device's registers
57-
(keyed by address) and its identity:
55+
There are two ways you'll typically use `harp`: talking to a **live device** over a
56+
serial connection, or reading **data recorded to disk**.
57+
58+
**Talk to a live device.** Open a connection and read/write registers by class:
5859

5960
```python
60-
from pathlib import Path
61-
from harp.device import create_device
61+
from harp.device import Device, WhoAmI, OperationControl, OperationControlPayload, OperationMode
62+
from harp.serial import open_serial_device
6263

63-
Behavior = create_device(Path("device.yml").read_text())
64-
Behavior.__whoami__ # device identity from the schema
65-
AnalogData = Behavior.REGISTER_MAP[44] # registers are reached by address
64+
# Use "COMx" on Windows, "/dev/ttyUSBx" on Linux.
65+
with open_serial_device(Device, port="/dev/ttyUSB0") as device:
66+
print("WhoAmI:", device.read(WhoAmI).parsed)
67+
device.write(OperationControl, OperationControlPayload(operation_mode=OperationMode.ACTIVE))
6668
```
6769

68-
The generated device works like any other. **Talk to hardware** over a serial
69-
transport — `read`/`write` take a register class:
70+
**Read a recorded session.** Point a `DatasetReader` at a dataset folder and read
71+
registers into pandas DataFrames — no hardware required:
7072

7173
```python
72-
from harp.serial import open_serial_device
74+
from harp.data import create_dataset_reader
7375

74-
# Use "COMx" on Windows, "/dev/ttyUSBx" on Linux.
75-
with open_serial_device(Behavior, port="/dev/ttyUSB0") as device:
76-
print(device.read(AnalogData).parsed)
76+
# Finds device.yml in the folder, builds the device, returns a ready-to-use reader.
77+
reader = create_dataset_reader("session.harp")
78+
df = reader.read(44) # one register, by address (or pass its class)
79+
everything = reader.read_all() # {register_name: DataFrame}
7780
```
7881

79-
...or use the same register classes to **decode recorded data** into a pandas
80-
DataFrame:
82+
Both paths are driven by a device schema. If you have only a `device.yml` and no
83+
pre-generated package, `create_device` compiles it into a typed `Device` at runtime —
84+
no code-generation step — which is exactly what `create_dataset_reader` does under
85+
the hood:
8186

8287
```python
83-
from harp.data import parse_to_dataframe
88+
from pathlib import Path
89+
from harp.device import create_device
8490

85-
df = parse_to_dataframe(AnalogData, "Behavior_44.bin")
91+
Behavior = create_device(Path("device.yml").read_text())
92+
AnalogData = Behavior.REGISTER_MAP[44] # registers are reached by address
8693
```
8794

88-
See the [Examples](https://harp-tech.org/pyharp/examples/) for full walkthroughs,
89-
including reading device info, subscribing to events, and working with custom
90-
interface-type converters.
95+
See the [Examples](https://harp-tech.org/pyharp/examples/) for the full walkthroughs,
96+
including subscribing to device events and working with custom interface-type converters.
9197

9298
## Contributing
9399

docs/api/data.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,11 @@
22

33
---
44

5+
::: harp.data.create_dataset_reader
6+
::: harp.data.DatasetReader
7+
::: harp.data.default_file_resolver
58
::: harp.data.parse_to_dataframe
69
::: harp.data.payload_to_dataframe
10+
::: harp.data.to_file
11+
::: harp.data.to_buffer
12+
::: harp.data.REFERENCE_EPOCH

docs/examples/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ Talking to a device:
1414

1515
Reading recorded data:
1616

17-
- [Reading Data into a DataFrame](./read_data_to_dataframe/read_data_to_dataframe.md) - load a register's binary data file into a pandas DataFrame with `harp.data`.
17+
- [Reading a Whole Dataset Folder](./read_dataset/read_dataset.md) - load an entire recorded session folder into pandas DataFrames with `DatasetReader`.
18+
- [Reading Data into a DataFrame](./read_data_to_dataframe/read_data_to_dataframe.md) - decode a single register's binary file into a pandas DataFrame.

docs/examples/read_data_to_dataframe/read_data_to_dataframe.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
# Reading Data into a DataFrame
22

3-
This example demonstrates how to load a Harp register's binary data file into a
4-
pandas DataFrame using `harp.data`. The register definition tells `parse_to_dataframe`
5-
how to decode each frame, so you get named columns (and decoded enums) for free.
3+
This example demonstrates how to load a **single** Harp register's binary data
4+
file into a pandas DataFrame using `harp.data`. The register definition tells
5+
`parse_to_dataframe` how to decode each frame, so you get named columns (and
6+
decoded enums) for free.
7+
8+
!!! tip
9+
Have a whole recorded session folder rather than one loose file? Use
10+
[`DatasetReader`](../read_dataset/read_dataset.md), which reads every register
11+
in a dataset folder driven by the device schema.
612

713
<!--codeinclude-->
814
```python
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
from harp.data import parse_to_dataframe
22
from harp.device import OperationControl
33

4-
# Parse a register's binary dump into a pandas DataFrame — one row per frame,
5-
# one column per field, plus a leading "timestamp" column.
6-
df = parse_to_dataframe(OperationControl, "OperationControl.bin", timestamp=True)
4+
# Parse a single register's binary dump into a pandas DataFrame — one row per
5+
# frame, one column per field. The register class tells `parse_to_dataframe` how
6+
# to decode each frame, so you get named columns (and decoded enums) for free.
7+
df = parse_to_dataframe(OperationControl, "OperationControl.bin")
78
print(df.head())
89

10+
# When the frames are timestamped (the default), the Harp time becomes the
11+
# DataFrame index, named "Time" — float seconds from device start.
12+
print(df.index.name, df.index[:3].to_list())
13+
914
# `parse_to_dataframe` also accepts raw bytes or an open binary file object:
1015
with open("OperationControl.bin", "rb") as f:
1116
df = parse_to_dataframe(OperationControl, f)
17+
18+
# To read a whole recorded session folder at once (many registers, driven by the
19+
# device schema) use `harp.data.DatasetReader` — see the "Reading a Whole Dataset
20+
# Folder" example.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Reading a Whole Dataset Folder
2+
3+
A Harp acquisition is usually saved as a **de-multiplexed dataset folder**: one
4+
binary file per register, named `<DeviceName>_<address>.bin`, next to the device's
5+
`device.yml` schema. `harp.data.DatasetReader` reads that whole folder into pandas
6+
DataFrames, driven by a [generated device](../../api/device.md) that describes how
7+
to decode each register.
8+
9+
This is the recommended entry point when you have a recorded session on disk. To
10+
decode a single loose `.bin` file instead, see
11+
[Reading Data into a DataFrame](../read_data_to_dataframe/read_data_to_dataframe.md).
12+
13+
The quickest way in is `create_dataset_reader(folder)`: it finds the `device.yml`
14+
inside the folder, builds the device for you, and returns a reader ready to go.
15+
(If you already have a device class — e.g. from a pre-generated package — construct
16+
`DatasetReader(Device, folder)` directly instead.) You then read a register by
17+
class or by address, or read every register at once with `read_all()`. Timestamps
18+
are detected automatically and placed on the `"Time"` index (float seconds, or an
19+
absolute `DatetimeIndex` when you pass an `epoch`).
20+
21+
<!--codeinclude-->
22+
```python
23+
[](./read_dataset.py)
24+
```
25+
<!--/codeinclude-->
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from harp.data import REFERENCE_EPOCH, create_dataset_reader
2+
from harp.device import OperationControl
3+
4+
# A Harp acquisition is usually saved as a de-multiplexed dataset folder — one
5+
# `.bin` file per register, named "<DeviceName>_<address>.bin", next to the
6+
# device's `device.yml` schema:
7+
#
8+
# 📦 session.harp
9+
# ┣ 📜 Behavior_0.bin
10+
# ┣ 📜 Behavior_44.bin
11+
# ┣ ...
12+
# ┗ 📜 device.yml
13+
#
14+
# `create_dataset_reader` does the right thing: it finds `device.yml` inside the
15+
# folder, builds the device that knows how to decode each register, and hands back
16+
# a reader ready to go.
17+
reader = create_dataset_reader("session.harp")
18+
19+
# Read one register into a DataFrame — by register class (any register in the
20+
# device's map, including the common ones like `OperationControl`)...
21+
df = reader.read(OperationControl)
22+
23+
# ...or by address. Timestamps are auto-detected from the frames, and when present
24+
# they become the DataFrame index, named "Time" (float seconds from device start).
25+
df = reader.read(44)
26+
print(df.head())
27+
28+
# Read every register that has a file on disk at once, keyed by register name.
29+
everything = reader.read_all()
30+
print(list(everything))
31+
32+
# Pass an epoch to turn the "Time" index into an absolute `DatetimeIndex` instead
33+
# of float seconds. `REFERENCE_EPOCH` is time zero of the Harp clock (UTC).
34+
absolute = reader.read(44, epoch=REFERENCE_EPOCH)
35+
print(absolute.index[:3])
36+
37+
# --- Already have a device class? -------------------------------------------
38+
# A pre-generated device package, or one you built yourself with `create_device`,
39+
# can drive the reader directly — construct `DatasetReader(Device, folder)`:
40+
#
41+
# from harp.data import DatasetReader
42+
# from harp.device import create_device
43+
# from pathlib import Path
44+
#
45+
# Behavior = create_device((Path("session.harp") / "device.yml").read_text())
46+
# reader = DatasetReader(Behavior, "session.harp")

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ nav:
7777
- Getting Device Info: examples/get_info/get_info.md
7878
- Read and Write from Registers: examples/read_and_write_from_registers/read_and_write_from_registers.md
7979
- Subscribing to Events: examples/subscribing_to_events/subscribing_to_events.md
80+
- Reading a Whole Dataset Folder: examples/read_dataset/read_dataset.md
8081
- Reading Data into a DataFrame: examples/read_data_to_dataframe/read_data_to_dataframe.md
8182
- API:
8283
- Protocol: api/protocol.md

src/packages/harp-data/README.md

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,59 @@ Load Harp register data into pandas DataFrames. This is the package that pulls
44
in `pandas`[`harp-protocol`](../harp-protocol) stays numpy-only and exposes a
55
pandas-free `ColumnData` view that this package assembles into a DataFrame.
66

7-
## Read a register from a file
7+
There are two ways in, depending on what you have on disk:
8+
9+
- a whole **dataset folder** (many registers) → `DatasetReader`
10+
- a single **register file** or buffer → `parse_to_dataframe`
11+
12+
## Read a whole dataset folder
13+
14+
A Harp acquisition is usually saved as a de-multiplexed folder — one binary file
15+
per register, named `<DeviceName>_<address>.bin`, alongside the device's
16+
`device.yml` schema:
17+
18+
```text
19+
📦 session.harp
20+
┣ 📜 Behavior_0.bin
21+
┣ 📜 Behavior_44.bin
22+
┣ ...
23+
┗ 📜 device.yml
24+
```
25+
26+
Reading is driven by a generated
27+
[`harp.device.Device`](../harp-device) that describes how to decode each register.
28+
`create_dataset_reader` does that for you — it finds the `device.yml` in the folder,
29+
builds the device, and returns a ready-to-use reader:
30+
31+
```python
32+
from harp.data import create_dataset_reader
33+
34+
reader = create_dataset_reader("session.harp")
35+
df = reader.read(AnalogData) # by register class
36+
df = reader.read(44) # by address
37+
everything = reader.read_all() # {register_name: DataFrame}
38+
```
39+
40+
Already have a device class (e.g. a pre-generated package, or one built with
41+
`create_device`)? Drive `DatasetReader` with it directly:
42+
43+
```python
44+
from pathlib import Path
45+
from harp.data import DatasetReader
46+
from harp.device import create_device
47+
48+
Behavior = create_device((Path("session.harp") / "device.yml").read_text())
49+
reader = DatasetReader(Behavior, "session.harp")
50+
```
51+
52+
Timestamps are auto-detected per register and placed on the DataFrame index
53+
(named `"Time"`): float seconds by default, or an absolute `DatetimeIndex` when
54+
you pass `epoch=REFERENCE_EPOCH`. Multi-chunk registers logged as
55+
`<DeviceName>_<address>_<suffix>.bin` are concatenated in filename order; pass a
56+
`resolver` to support an alternative on-disk layout, or `name=` to override the
57+
file prefix.
58+
59+
## Read a single register file
860

961
`parse_to_dataframe` takes a register and a source (path, bytes, or open binary
1062
file) and returns one row per frame:
@@ -17,7 +69,10 @@ df = parse_to_dataframe(AnalogData, "AnalogData.bin")
1769
df = parse_to_dataframe(AnalogData, raw, timestamp=True, message_type=False, decode_enums=True)
1870
```
1971

20-
Enum fields decode to `pd.Categorical` (`decode_enums=False` keeps raw codes).
72+
With `timestamp=True` (the default) the Harp time becomes the DataFrame index,
73+
named `"Time"` — float seconds, or an absolute `DatetimeIndex` when you also pass
74+
`epoch=REFERENCE_EPOCH`. Enum fields decode to `pd.Categorical`
75+
(`decode_enums=False` keeps raw codes).
2176

2277
## From an already-parsed payload
2378

@@ -30,3 +85,14 @@ from harp.data import payload_to_dataframe
3085
_data, timestamps, _msg, payload = AnalogData.parse_bulk(raw)
3186
df = payload_to_dataframe(payload)
3287
```
88+
89+
## Write data back out
90+
91+
`to_file` / `to_buffer` are the inverse of the readers — encode values as Harp
92+
frames. Handy for round-tripping data or generating test corpora:
93+
94+
```python
95+
from harp.data import to_file
96+
97+
to_file(AnalogData, values, "AnalogData.bin", timestamps=seconds)
98+
```

src/packages/harp-data/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "Load Harp device data into pandas DataFrames"
55
requires-python = ">=3.11"
66
dependencies = [
77
"harp-protocol",
8+
"harp-device",
89
"numpy>=1.24",
910
"pandas>=2.0",
1011
]

0 commit comments

Comments
 (0)