diff --git a/README.md b/README.md index 80980fd..2a3e855 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,38 @@ # harp +Python interface to [Harp](https://harp-tech.org/articles/what-is-harp.html) devices and their recorded data, implementing the [Harp binary protocol](https://harp-tech.org/protocol/BinaryProtocol-8bit.html). + +Harp is a standard for asynchronous real-time data acquisition and experimental control in neuroscience. Every command and event is hardware timestamped on the device. Devices sharing a clock line continuously self-synchronize, so events across a rig sit on one clock and need no post-hoc alignment. + This project includes four main packages: - - **harp-protocol**: Provides the core protocol definitions and utilities for the Harp protocol. See [Protocol API Documentation](https://harp-tech.org/python/api/protocol) for details. + - **harp-protocol**: Implements the Harp binary protocol in Python, with registers, messages, and payload parsing. See [Protocol API Documentation](https://harp-tech.org/python/api/protocol) for details. - - **harp-serial**: Implements serial communication functionalities for generic Harp devices. See [Serial API Documentation](https://harp-tech.org/python/api/serial) for more information. + - **harp-device**: Implements the transport-agnostic `Device` interface and the core register set. See [Device API Documentation](https://harp-tech.org/python/api/device) for details. - - **harp-device**: Implements the transport-agnostic `Device` interface, the common register map, and the shared registers and enums. See [Device API Documentation](https://harp-tech.org/python/api/device) for details. + - **harp-serial**: Connects to a `Device` over a serial COM or tty port. See [Serial API Documentation](https://harp-tech.org/python/api/serial) for details. - - **harp-data**: Parses register binary dumps into pandas DataFrames. See [Data API Documentation](https://harp-tech.org/python/api/data) for more information. + - **harp-data**: Reads logged register files into pandas DataFrames. See [Data API Documentation](https://harp-tech.org/python/api/data) for details. ## Installation -All packages are published to PyPI. The `harp` package is a metadata package with no code of its own. It depends on the four packages above, so it is the easiest way to get everything: +All packages are published to PyPI. The `harp` package is a metapackage with no code of its own. It depends on the four packages above, so it is the easiest way to get everything: ```sh pip install harp ``` -```sh -uv add harp -``` +`uv add` substitutes for `pip install` throughout. -To install only part of the stack, for example when parsing offline data dumps with no need for serial I/O, install the individual packages. Each one only pulls in what it actually depends on: +To install only part of the stack, for example when reading recorded data with no need for serial I/O, install the individual packages. Each one only pulls in what it actually depends on: -| Package | Provides | Depends on | -| --- | --- | --- | -| `harp-protocol` | Core protocol types: registers, messages, payload parsing | none | -| `harp-device` | Transport-agnostic `Device` class, common register map | `harp-protocol` | -| `harp-serial` | Serial COM or tty transport for `Device` | `harp-protocol`, `harp-device` | -| `harp-data` | Parse register binary dumps into pandas DataFrames | `harp-protocol` | +| Package | Depends on | +| --- | --- | +| `harp-protocol` | none | +| `harp-device` | `harp-protocol` | +| `harp-serial` | `harp-protocol`, `harp-device` | +| `harp-data` | `harp-protocol` | ```sh pip install harp-protocol @@ -56,7 +58,7 @@ from harp.device import behavior, core # Use "COMx" on Windows, "/dev/ttyUSBx" on Linux. with serial.open_device(behavior, port="COM3") as device: - print(device.read(core.WhoAmI).payload) # a common register + print(device.read(core.WhoAmI).payload) # a core register print(device.read(behavior.AnalogData).payload) # a device register device.write( core.OperationControl, @@ -96,15 +98,15 @@ from pathlib import Path from harp.device import schema behavior = schema.create_device_module(Path("device.yml").read_bytes()) -AnalogData = behavior.AnalogData # registers are reached by name +AnalogData = behavior.AnalogData # registers are accessed by name assert behavior.REGISTER_MAP[44] is AnalogData # or by address ``` -See the [Examples](https://harp-tech.org/python/examples/) for the full walkthroughs, including subscribing to device events and working with custom interface-type converters. +See the examples in the documentation for the full walkthroughs, including subscribing to device events and working with custom interface-type converters. ## Contributing -harp is a [uv workspace](https://docs.astral.sh/uv/concepts/workspaces/): every package under `src/packages/` is its own distribution, plus the root `harp` metadata package. Bug reports and contributions are welcome, so please open an issue or pull request. +harp is a [uv workspace](https://docs.astral.sh/uv/concepts/workspaces/): every package under `src/packages/` is its own distribution, plus the root `harp` metapackage. Bug reports and contributions are welcome, so please open an issue or pull request. Clone the repository and install everything with the `dev` dependency group: all workspace packages, editable, plus test and lint tooling. @@ -124,7 +126,7 @@ uv run pytest --cov harp # tests To add a new package, place it under `src/packages//` with its own `pyproject.toml` and add it to `[tool.uv.sources]` in the root `pyproject.toml`. If it should ship as part of `harp`, add it to the dependencies of the root package as well. -## Building the documentation +## Build the documentation Install the docs dependency group and run mkdocs through uv: diff --git a/docs/articles/device-modules.md b/docs/articles/device-modules.md new file mode 100644 index 0000000..15bdd22 --- /dev/null +++ b/docs/articles/device-modules.md @@ -0,0 +1,29 @@ +# Choose a device module + +A **device module** is what describes a Harp device to the library. It holds the register classes for that device at module level, a `REGISTER_MAP` keyed by address, and the payload and enum classes referenced by those registers. It is what `Device` needs to talk to hardware and what [`open_dataset`](../api/data.md) needs to read a recorded session. + +A device module comes from one of two places. A generated device package is a real Python module on disk, produced from a `device.yml` by the Harp code generators and installed as a dependency. A runtime module is built in memory by [`create_device_module`](../api/device.md), from a `device.yml` read at the moment it is needed. + +Both are the same kind of thing. Registers are accessed the same way in either case, by name as `behavior.AnalogData` or by address as `behavior.REGISTER_MAP[44]`. The names agree because both derive from the same schema under the same naming convention. Analysis code written against one lines up name for name against the other. + +The choice is therefore not about what the registers can do. It is about where the definitions come from, and what that costs. + +## Benefits of a generated package + +**Static typing and autocomplete.** The module exists on disk before the program runs, so an editor offers its register names and a type checker verifies them. A runtime module is built while the program runs, so neither can see it. It is not in `sys.modules` either, so it has to be bound to a name rather than imported. + +**A version that can be pinned.** A generated package is an ordinary dependency, so it can be pinned in a lock file and every install resolves the same register definitions. A runtime module is only as stable as its `device.yml`, so the same analysis code can see different field names once that file changes. + +**Converters for its own custom types.** A generated package includes the converters for any custom `interfaceType` declared by its schema. A runtime module has to be given them through `converters=`. + +## Benefits of a runtime module + +**No build step.** A `device.yml`, including one read straight off a device, becomes a working module in a single call. There is nothing to generate, install, or keep in step with the schema. + +**Coverage for any device.** No published package is needed, so unreleased, custom and one-off schemas work immediately. It is also what makes a recorded session readable when the only description of the device is the `device.yml` saved beside it. + +## Which to use + +For a widely used device with a published package, use the package. Better editor support, static typing and a version that can be pinned are worth a dependency for code that has to be maintained. + +Use `create_device_module` when no package exists, when the schema is still moving, or when a recorded session has to be read with nothing but the `device.yml` saved alongside it. See [Registers from a schema](../examples/registers-from-schema.md) for a worked example. diff --git a/docs/articles/migrating_from_harp_python.md b/docs/articles/harp-python-migration.md similarity index 52% rename from docs/articles/migrating_from_harp_python.md rename to docs/articles/harp-python-migration.md index a468f30..e8f2599 100644 --- a/docs/articles/migrating_from_harp_python.md +++ b/docs/articles/harp-python-migration.md @@ -1,44 +1,46 @@ -# Migrating from harp-python +# Migrate from harp-python -`harp-data` is the successor to `harp-python` for reading Harp binary data files into -pandas DataFrames. The core concepts of device schemas, register maps and binary files -are unchanged, but the API has been reorganized to separate data reading from device -communication. +`harp-data` is the successor to `harp-python` for reading Harp binary data files into pandas DataFrames. The core concepts of device schemas, register maps and binary files are unchanged, but the API has been reorganized to separate data reading from device communication. This guide covers the three workflows most users relied on in `harp-python`. -## Swap the package +## Installation -Replace the old dependency: +`harp-python` was a single distribution. Reading data now needs only `harp-data`, since serial transport and the device client are separate packages. -```sh title="Before" +**Before** + +```sh pip install harp-python ``` -```sh title="After" +**After** + +```sh pip install harp-data ``` -For the full toolkit of serial transport, device client and data reading, install the -umbrella package instead: +For the full toolkit of serial transport, device client and data reading, install the metapackage instead: ```sh pip install harp ``` -## Loading a device schema at runtime +## Load a device schema at runtime -In `harp-python`, `harp.create_reader()` accepted a dataset folder and handled -schema loading internally. `open_dataset` is the direct replacement, and it finds the -`device.yml` inside the folder automatically: +In `harp-python`, `harp.create_reader()` accepted a dataset folder and handled schema loading internally. `open_dataset` is the direct replacement, and it finds the `device.yml` inside the folder automatically. -```python title="Before" +**Before** + +```python import harp reader = harp.create_reader("session.harp") ``` -```python title="After" +**After** + +```python from harp.data import open_dataset reader = open_dataset("session.harp") @@ -50,35 +52,31 @@ If the schema lives outside the data folder, pass it explicitly: reader = open_dataset("session.harp", schema="/path/to/device.yml") ``` -### Finding out what a session holds +### List the session contents -`reader.contents` maps the name of every register with data in the folder to its address, -in address order, which is the quickest way to see what was recorded: +`reader.contents` maps the name of every register with data in the folder to its address, in address order, which is the quickest way to see what was recorded: ```python reader = open_dataset("session.harp") print(reader.contents) # {'WhoAmI': 0, 'DigitalInputState': 32, ...} ``` -The reader also holds the compiled device module at `reader.device_module`, so a register -class can be reached without keeping a separate variable: +The reader also holds the compiled device module at `reader.device_module`, so a register class can be accessed without keeping a separate variable: ```python df = reader.read(reader.device_module.AnalogData) ``` !!! note - The old `harp.read_schema()` had no direct equivalent that needed calling separately. - `open_dataset` handles schema loading in one step, matching the convenience - of the original API. + The old `harp.read_schema()` had no direct equivalent that needed calling separately. `open_dataset` handles schema loading in one step, matching the convenience of the original API. + +## Read a single register -## Reading a single register +The old API allowed reads through attribute access on the reader. The new API inverts this, so `reader.read()` takes the register class, its name, or its address as the argument. -The old API allowed reads through attribute access on the reader. The new -API inverts this, so `reader.read()` takes the register class, its name or -its address as the argument. +**Before** -```python title="Before" +```python # by attribute name df = reader.AnalogData.read() @@ -89,7 +87,9 @@ df = reader.registers["AnalogData"].read() df = reader.registers[44].read() ``` -```python title="After" +**After** + +```python # by register class (accessed through the reader) df = reader.read(reader.device_module.AnalogData) @@ -100,32 +100,31 @@ df = reader.read("AnalogData") df = reader.read(44) ``` -Names resolve against the device address space rather than the module namespace, so a -common register such as `reader.read("WhoAmI")` works even though a device module does -not name it. +Names resolve against the device address space rather than the module namespace, so access to a core register such as `reader.read("WhoAmI")` works even though a device module does not name it. ### Absolute timestamps -The `epoch` parameter keeps its name and moves from `create_reader` to `open_dataset`. -`harp-python` also allowed it per read. `harp-data` sets it once for the dataset, so -every register is read on the same clock. +The `epoch` parameter keeps its name and moves from `create_reader` to `open_dataset`. `harp-python` also allowed it per read. `harp-data` sets it once for the dataset, so every register is read on the same clock. + +**Before** -```python title="Before" +```python reader = harp.create_reader("session.harp", epoch=harp.REFERENCE_EPOCH) df = reader.AnalogData.read() ``` -```python title="After" +**After** + +```python from harp.data import REFERENCE_EPOCH reader = open_dataset("session.harp", epoch=REFERENCE_EPOCH) df = reader.read(reader.device_module.AnalogData) ``` -### Reading the whole session at once +### Read the whole session at once -There is no `read_all()`. Whole-session loading is a comprehension over `contents`, -which keeps the choice of what to load with the caller: +There is no `read_all()`. Whole-session loading is a comprehension over `contents`, which keeps the choice of what to load with the caller: ```python everything = {name: reader.read(name) for name in reader.contents} @@ -133,22 +132,21 @@ everything = {name: reader.read(name) for name in reader.contents} ### Bitmask registers lose their per-flag columns by default -This is the change most likely to break working code. `harp-python` always expanded a -bitmask register into one boolean column per flag, so a script could select a flag by -name. `harp-data` returns a single integer column instead, and expands the flags only -when asked: +This is the change most likely to break working code. `harp-python` always expanded a bitmask register into one boolean column per flag, so a script could select a flag by name. `harp-data` returns a single integer column instead, and expands the flags only when asked. -```python title="Before" +**Before** + +```python led = reader.DigitalOutputSet.read()["GP15"] ``` -```python title="After" +**After** + +```python led = reader.read("DigitalOutputSet", demux_bit_masks=True)["GP15"] ``` -Group masks need no such flag. `harp-python` mapped each value to its member name, and -`harp-data` decodes them by default, as a `pd.Categorical` rather than plain strings, so -a comparison against a string still reads naturally. +Group masks need no such flag. `harp-python` mapped each value to its member name, and `harp-data` decodes them by default, as a `pd.Categorical` rather than plain strings, so a comparison against a string still works. ### Parameter reference @@ -163,36 +161,35 @@ a comparison against a string still reads naturally. ## Schemaless read -For a raw `.bin` file with no schema, or a quick look at the data, the `read()` -function works the same as before. Only the import path changes: +For a raw `.bin` file with no schema, or a quick look at the data, the `read()` function works the same as before. Only the import path changes. + +**Before** -```python title="Before" +```python import harp df = harp.read("Behavior_44.bin") df = harp.read("Behavior_44.bin", keep_type=True) ``` -```python title="After" +**After** + +```python from harp.data import read df = read("Behavior_44.bin") df = read("Behavior_44.bin", keep_type=True) ``` -Both functions infer the payload type and element count from the frame, so no register -metadata is needed. The new one assumes timestamped data, which is what a device sends, -and takes `time_index=False` for the rare buffer that is not. It also takes `epoch` per -call, since a single file has no dataset to set one on. +Both functions infer the payload type and element count from the frame, so no register metadata is needed. The new one assumes timestamped data, which is what a device sends, and takes `time_index=False` for the rare buffer that is not. It also takes `epoch` per call, since a single file has no dataset to set one on. + +## Static device packages -## Going further: static device packages +Loading a YAML at runtime is convenient. However, a generated device package gives the same interface without parsing a schema at startup, and its registers resolve under IDE autocompletion and a type checker. -Loading a YAML at runtime is convenient, but for production workflows, or where IDE -autocompletion and type checking matter, a generated device package gives the same -interface without parsing a schema at startup. +It also validates what it opens. The package declares its own `WHO_AM_I`, which the reader checks against the `device.yml` in the folder. A session recorded from a different device then fails on construction rather than decoding the files against the wrong register map. A module built from that same folder cannot catch this, since it agrees with the folder by definition. -Such a package is an ordinary Python module under the `harp.device` namespace. Import -it, pass it to `open_dataset`, and the rest of the API is identical: +Such a package is an ordinary Python module under the `harp.device` namespace. Import it, pass it to `open_dataset`, and the rest of the API is identical: ```python from harp.device import behavior @@ -204,7 +201,4 @@ reader = open_dataset("session.harp", behavior) df = reader.read(behavior.AnalogData) ``` -A generated module starts up faster and resolves under a type checker, which a module -built from a schema at runtime cannot. See -[Generating Registers from a Schema](../examples/create_device_module/create_device_module.md) -for how device modules are structured. +A generated module starts up faster and resolves under a type checker, which a module built from a schema at runtime cannot. See [Registers from a schema](../examples/registers-from-schema.md) for how device modules are structured. diff --git a/docs/examples/create_device_module/create_device_module.md b/docs/examples/create_device_module/create_device_module.md deleted file mode 100644 index 8c3b8ca..0000000 --- a/docs/examples/create_device_module/create_device_module.md +++ /dev/null @@ -1,31 +0,0 @@ -# Generating Registers from a Schema - -This example demonstrates how to turn a Harp `device.yml` into a module of register classes at runtime with `create_device_module`, without a code-generation step. This is the quickest way to get started given only the schema of a device and no pre-generated package for it. - -A generated device package is a module: register classes at module level, with a `REGISTER_MAP` beside them keyed by address. `create_device_module` builds that same structure from a schema, so registers are reached the same way, either by name as `behavior.AnalogData` or by address as `behavior.REGISTER_MAP[44]`. From there they work exactly like the registers of a pre-generated package. Pass the module to [`Device`](../../api/device.md) to talk to hardware, which validates the device identity on open, or use the registers with [`parse_to_dataframe`](../../api/data.md) to decode recorded data. - -## When to use runtime generation - -`create_device_module` trades statically generated device packages for schema-driven convenience. Both sides of that trade-off are worth understanding. - -**Benefits:** - -- **No build step.** A `device.yml`, even one just pulled off a device, becomes a working module in a single call. There is nothing to generate, install, or keep in sync with the schema. -- **Coverage for any device.** No published package is needed. Unreleased, custom, or one-off schemas work immediately. -- **Names match the generated package.** Registers, fields, and enums come straight from the `device.yml`, under the same naming convention a generated package uses, so code written against either lines up name for name. - -**Limitations:** - -- **Static typing and autocomplete.** The names exist only once the module is built, so an editor cannot offer them and a type checker cannot verify them. A generated package is a real module on disk, so both work. The module is also not in `sys.modules`, so it has to be bound rather than imported. -- **Reproducibility.** A generated package is a versioned dependency, so it can be pinned in a lock file and every install resolves the same register definitions. A runtime module is built from the `device.yml`, so the same analysis code can see different field names when it changes. -- **Turn-key custom types.** A custom `interfaceType` must be injected via `converters=`, shown below, whereas a generated package ships its own converters. - -For widely-used devices a pre-generated package remains the authoritative choice, with better editor support, static typing, and a pinnable version. Reach for `create_device_module` to go from a schema to working code with no code-generation step. - -{% include-markdown "includes/serial-port.md" %} - - -```python -[](./create_device_module.py) -``` - diff --git a/docs/examples/read_dataset/read_dataset.md b/docs/examples/dataset.md similarity index 74% rename from docs/examples/read_dataset/read_dataset.md rename to docs/examples/dataset.md index d2d9540..6e4d4e6 100644 --- a/docs/examples/read_dataset/read_dataset.md +++ b/docs/examples/dataset.md @@ -1,13 +1,13 @@ -# Reading a Whole Dataset Folder +# Read a dataset folder -A Harp acquisition is usually saved as a **de-multiplexed dataset folder**: one binary file per register, named `_
.bin`, next to the `device.yml` schema for the device. `harp.data.DatasetReader` reads that whole folder into pandas DataFrames, based on a [device module](../../api/device.md) that describes how to decode each register. +A Harp acquisition is usually saved as a **de-multiplexed dataset folder**: one binary file per register, named `_
.bin`, next to the `device.yml` schema for the device. `harp.data.DatasetReader` reads that whole folder into pandas DataFrames, based on a [device module](../api/device.md) that describes how to decode each register. -This is the recommended entry point for a recorded session on disk. To decode a single loose `.bin` file instead, see [Reading Data into a DataFrame](../read_data_to_dataframe/read_data_to_dataframe.md). +This is the recommended entry point for a recorded session on disk. To decode a single loose `.bin` file instead, see [Read a single register file](register-file.md). The quickest way in is `open_dataset(folder)`. It finds the `device.yml` inside the folder, builds the device module, and returns a reader ready to go. Given a device module already in hand, for example from a pre-generated package, pass it as the second argument, `open_dataset(folder, module)`. A register is then read by class, by name, or by address. The Harp time becomes the `"Time"` index, as float seconds or an absolute `DatetimeIndex` when the dataset is opened with an `epoch`. ```python -[](./read_dataset.py) +[](./dataset.py) ``` diff --git a/docs/examples/read_dataset/read_dataset.py b/docs/examples/dataset.py similarity index 93% rename from docs/examples/read_dataset/read_dataset.py rename to docs/examples/dataset.py index aaa36ad..cfd69ff 100644 --- a/docs/examples/read_dataset/read_dataset.py +++ b/docs/examples/dataset.py @@ -17,11 +17,11 @@ reader = data.open_dataset("session.harp") # Read one register into a DataFrame by register class, which covers any register -# in the device map, including common ones such as `OperationControl`. +# in the device map, including core ones such as `OperationControl`. df = reader.read(core.OperationControl) # A register can also be read by name. Names resolve through the device register -# map rather than the module namespace, so common registers are reachable too. +# map rather than the module namespace, so core registers are accessible too. df = reader.read("OperationControl") # Or by address. The Harp time becomes the DataFrame index, named "Time", diff --git a/docs/examples/get_info/get_info.md b/docs/examples/device-info.md similarity index 65% rename from docs/examples/get_info/get_info.md rename to docs/examples/device-info.md index 3a4ae60..5793271 100644 --- a/docs/examples/get_info/get_info.md +++ b/docs/examples/device-info.md @@ -1,11 +1,11 @@ -# Getting Device Info +# Read device info -This example demonstrates how to connect to a Harp device, read its information and dump the device registers. +This example demonstrates how to connect to a Harp device, read its information, and dump the device registers. {% include-markdown "includes/serial-port.md" %} ```python -[](./get_info.py) +[](./device_info.py) ``` diff --git a/docs/examples/get_info/get_info.py b/docs/examples/device_info.py old mode 100755 new mode 100644 similarity index 100% rename from docs/examples/get_info/get_info.py rename to docs/examples/device_info.py diff --git a/docs/examples/subscribing_to_events/subscribing_to_events.md b/docs/examples/events.md similarity index 80% rename from docs/examples/subscribing_to_events/subscribing_to_events.md rename to docs/examples/events.md index 0df82f9..f24f197 100644 --- a/docs/examples/subscribing_to_events/subscribing_to_events.md +++ b/docs/examples/events.md @@ -1,6 +1,6 @@ -# Subscribing to Events +# Subscribe to events -This example demonstrates how to react to messages pushed by the device, e.g. unsolicited `Event` messages, without polling, using two subscription styles: +This example demonstrates how to react to messages pushed by the device, for example unsolicited `Event` messages, without polling, using two subscription styles: - `device.subscribe(register, handler)`, where the handler receives a `HarpMessage` typed by the payload of a single register. - `device.subscribe_all(handler)`, a catch-all handler that receives the raw `HarpMessage` for every register. @@ -11,6 +11,6 @@ Handlers run on a dedicated event thread, so they never block `read()` or `write ```python -[](./subscribing_to_events.py) +[](./events.py) ``` diff --git a/docs/examples/subscribing_to_events/subscribing_to_events.py b/docs/examples/events.py similarity index 100% rename from docs/examples/subscribing_to_events/subscribing_to_events.py rename to docs/examples/events.py diff --git a/docs/examples/index.md b/docs/examples/index.md deleted file mode 100644 index fa005ab..0000000 --- a/docs/examples/index.md +++ /dev/null @@ -1,18 +0,0 @@ -# Examples - -This section contains examples for getting started with `harp`. - -Working from a device schema: - -- [Generating Registers from a Schema](./create_device_module/create_device_module.md) - compile a `device.yml` into a module of register classes at runtime with `create_device_module`. - -Talking to a device: - -- [Getting Device Info](./get_info/get_info.md) - connect to a Harp device and read its information. -- [Read and Write from Registers](./read_and_write_from_registers/read_and_write_from_registers.md) - connect to a Harp device and read and write its registers. -- [Subscribing to Events](./subscribing_to_events/subscribing_to_events.md) - react to messages pushed by the device without polling. - -Reading recorded data: - -- [Reading a Whole Dataset Folder](./read_dataset/read_dataset.md) - read registers from a recorded session folder into pandas DataFrames, decoded against the device schema. -- [Reading Data into a DataFrame](./read_data_to_dataframe/read_data_to_dataframe.md) - decode the binary file of a single register into a pandas DataFrame. diff --git a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.md b/docs/examples/read-write-registers.md similarity index 84% rename from docs/examples/read_and_write_from_registers/read_and_write_from_registers.md rename to docs/examples/read-write-registers.md index dd03ef0..43327b0 100644 --- a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.md +++ b/docs/examples/read-write-registers.md @@ -1,4 +1,4 @@ -# Read and Write from Registers +# Read and write registers This example demonstrates how to read and write from registers, using the core registers exposed by `harp.device.core`. Device-specific registers, for example the digital I/O of a Harp Behavior device, are used the same way. Pass the register classes of that device to `read` and `write`. @@ -6,6 +6,6 @@ This example demonstrates how to read and write from registers, using the core r ```python -[](./read_and_write_from_registers.py) +[](./read_write_registers.py) ``` diff --git a/docs/examples/read_data_to_dataframe/read_data_to_dataframe.md b/docs/examples/read_data_to_dataframe/read_data_to_dataframe.md deleted file mode 100644 index 94e1479..0000000 --- a/docs/examples/read_data_to_dataframe/read_data_to_dataframe.md +++ /dev/null @@ -1,12 +0,0 @@ -# Reading Data into a DataFrame - -This example demonstrates how to load the binary data file of a **single** Harp register into a pandas DataFrame using `harp.data`. The register definition tells `parse_to_dataframe` how to decode each frame, so the result carries named columns and decoded enums. - -!!! tip - For a recorded session folder rather than one loose file, use [`open_dataset`](../read_dataset/read_dataset.md), which resolves each register against the device schema so any of them can be read by class, by name, or by address. - - -```python -[](./read_data_to_dataframe.py) -``` - diff --git a/docs/examples/read_and_write_from_registers/read_and_write_from_registers.py b/docs/examples/read_write_registers.py old mode 100755 new mode 100644 similarity index 100% rename from docs/examples/read_and_write_from_registers/read_and_write_from_registers.py rename to docs/examples/read_write_registers.py diff --git a/docs/examples/register-file.md b/docs/examples/register-file.md new file mode 100644 index 0000000..1b527d9 --- /dev/null +++ b/docs/examples/register-file.md @@ -0,0 +1,12 @@ +# Read a single register file + +This example demonstrates how to load the binary data file of a **single** Harp register into a pandas DataFrame using `harp.data`. `parse_to_dataframe` decodes each frame against the register definition, so the result carries named columns and decoded enums. + +!!! tip + For a recorded session folder rather than one loose file, use [`open_dataset`](dataset.md), which resolves each register against the device schema so any of them can be read by class, by name, or by address. + + +```python +[](./register_file.py) +``` + diff --git a/docs/examples/read_data_to_dataframe/read_data_to_dataframe.py b/docs/examples/register_file.py similarity index 82% rename from docs/examples/read_data_to_dataframe/read_data_to_dataframe.py rename to docs/examples/register_file.py index 2c24804..0dc8617 100644 --- a/docs/examples/read_data_to_dataframe/read_data_to_dataframe.py +++ b/docs/examples/register_file.py @@ -1,7 +1,7 @@ from harp import data from harp.device import core -# Parse the binary dump of a single register into a pandas DataFrame, one row per +# Parse the log file of a single register into a pandas DataFrame, one row per # frame, one column per field. The register class tells `parse_to_dataframe` how # to decode each frame, so the result carries named columns and decoded enums. df = data.parse_to_dataframe(core.OperationControl, "OperationControl.bin") @@ -16,5 +16,5 @@ df = data.parse_to_dataframe(core.OperationControl, f) # To read registers from a recorded session folder, resolved against the device -# schema, use `harp.data.open_dataset`. See the "Reading a Whole Dataset Folder" +# schema, use `harp.data.open_dataset`. See the "Read a dataset folder" # example. diff --git a/docs/examples/registers-from-schema.md b/docs/examples/registers-from-schema.md new file mode 100644 index 0000000..04dd394 --- /dev/null +++ b/docs/examples/registers-from-schema.md @@ -0,0 +1,15 @@ +# Registers from a schema + +This example demonstrates how to turn a Harp `device.yml` into a module of register classes at runtime with `create_device_module`, without a code-generation step. This is the quickest way to get started given only the schema of a device and no pre-generated package for it. + +A generated device package is a module: register classes at module level, with a `REGISTER_MAP` beside them keyed by address. `create_device_module` builds that same structure from a schema, so registers are accessed the same way, either by name as `behavior.AnalogData` or by address as `behavior.REGISTER_MAP[44]`. From there they work exactly like the registers of a pre-generated package. Pass the module to [`Device`](../api/device.md) to talk to hardware, which validates the device identity on open, or use the registers with [`parse_to_dataframe`](../api/data.md) to decode recorded data. + +For when an installed device package is the better choice, see [Choose a device module](../articles/device-modules.md). + +{% include-markdown "includes/serial-port.md" %} + + +```python +[](./registers_from_schema.py) +``` + diff --git a/docs/examples/create_device_module/create_device_module.py b/docs/examples/registers_from_schema.py similarity index 89% rename from docs/examples/create_device_module/create_device_module.py rename to docs/examples/registers_from_schema.py index b0a7636..575454d 100644 --- a/docs/examples/create_device_module/create_device_module.py +++ b/docs/examples/registers_from_schema.py @@ -14,7 +14,7 @@ behavior = schema.create_device_module(Path("device.yml").read_bytes()) print("WhoAmI:", behavior.WHO_AM_I) # device identity, taken from the schema -AnalogData = behavior.AnalogData # registers are reached by name +AnalogData = behavior.AnalogData # registers are accessed by name assert behavior.REGISTER_MAP[44] is AnalogData # or by address # Registers are ordinary register classes, so they work with `read` and `write` on @@ -23,8 +23,8 @@ with serial.open_device(behavior, port=SERIAL_PORT) as device: print("AnalogData:", device.read(AnalogData).payload) -# The same register classes also decode a recorded binary dump into a pandas -# DataFrame. See the "Reading Data into a DataFrame" example for more. +# The same register classes also decode a recorded register log file into a pandas +# DataFrame. See the "Read a single register file" example for more. df = data.parse_to_dataframe(AnalogData, "Behavior_44.bin") print(df.head()) diff --git a/mkdocs.yml b/mkdocs.yml index 089afb1..1045a5f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,17 +70,17 @@ theme: name: Switch to system preference nav: - - Home: index.md + - Introduction: index.md - Examples: - - examples/index.md - - Generating Registers from a Schema: examples/create_device_module/create_device_module.md - - Getting Device Info: examples/get_info/get_info.md - - Read and Write from Registers: examples/read_and_write_from_registers/read_and_write_from_registers.md - - Subscribing to Events: examples/subscribing_to_events/subscribing_to_events.md - - Reading a Whole Dataset Folder: examples/read_dataset/read_dataset.md - - Reading Data into a DataFrame: examples/read_data_to_dataframe/read_data_to_dataframe.md - - Articles: - - Migrating from harp-python: articles/migrating_from_harp_python.md + - Read device info: examples/device-info.md + - Read and write registers: examples/read-write-registers.md + - Subscribe to events: examples/events.md + - Read a dataset folder: examples/dataset.md + - Read a single register file: examples/register-file.md + - Registers from a schema: examples/registers-from-schema.md + - Guides: + - Choose a device module: articles/device-modules.md + - Migrate from harp-python: articles/harp-python-migration.md - API: - Protocol: api/protocol.md - Serial: api/serial.md diff --git a/src/packages/harp-benchmarks/README.md b/src/packages/harp-benchmarks/README.md index fd642fa..818758f 100644 --- a/src/packages/harp-benchmarks/README.md +++ b/src/packages/harp-benchmarks/README.md @@ -11,7 +11,7 @@ | `src/harp/benchmarks/register_models.py` | Reference models for every device.yml register, with fixtures shared with the acceptance tests. | | `src/harp/benchmarks/_registers.py` | Registry: each register plus a representative sample value, and artifact paths. | | `src/harp/benchmarks/generate.py` | Writes `./benchmark/data/_.bin`, and exposes a cache-aware `ensure_corpus`. | -| `src/harp/benchmarks/benchmark.py` | Ensures corpora exist, then times `parse_bulk`, `parse_to_dataframe`, `payload_as_columns`; writes `./benchmark/report.md`. | +| `src/harp/benchmarks/benchmark.py` | Ensures corpora exist, then times `parse_bulk`, `parse_to_dataframe`, `payload_as_columns`. Writes `./benchmark/report.md`. | All generated artifacts, both corpora and report, are written under **`./benchmark`** in the current working directory, git-ignored and fully regenerable. @@ -42,7 +42,7 @@ Equivalent module invocations: `uv run python -m harp.benchmarks.benchmark` / `u `parse_bulk` and `parse_to_dataframe` are each timed in two modes: - **pre-read**, file read once up front, so only deserialization is timed. This isolates library speed. -- **re-read**, file re-read from disk on every run, the real-world "load a dump" path, which includes disk. +- **re-read**, file re-read from disk on every run, the real-world "load a log file" path, which includes disk. The report also decomposes `parse_to_dataframe` into `parse_bulk` plus `payload_as_columns` plus pandas overhead. diff --git a/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py b/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py index d324ab0..b2aae26 100644 --- a/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py +++ b/src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py @@ -146,7 +146,7 @@ def build_report(results: list[RegisterResult], *, runs: int) -> str: lines.append("# Harp parsing benchmark\n") lines.append( "Parsing throughput for every register in `harp.benchmarks.register_models`, " - "measured over Harp wire-format dumps generated by `harp.benchmarks.generate`.\n" + "measured over binary protocol log files generated by `harp.benchmarks.generate`.\n" ) # Environment diff --git a/src/packages/harp-data/README.md b/src/packages/harp-data/README.md index 05758c8..faf537d 100644 --- a/src/packages/harp-data/README.md +++ b/src/packages/harp-data/README.md @@ -37,9 +37,9 @@ reader.contents # {'WhoAmI': 0, 'AnalogData': 33, ...} frames = {name: reader.read(name) for name in reader.contents} ``` -A name is resolved through the device register map rather than the module namespace, so the common registers are reachable by name too. +A name is resolved through the device register map rather than the module namespace, so the core registers are accessible by name too. -A register declared in the device register map with no data present in the folder reads as an empty DataFrame carrying the same columns, since the schema describes the structure of the data regardless of whether anything was recorded. `contents` is what tells the two cases apart. A register the device does not declare at all raises `KeyError`. +The schema describes the structure regardless of what was recorded. This means a register declared in the device register map with no data present in the folder reads as an empty DataFrame carrying the same columns. `contents` is what distinguishes the two cases. A register the device does not declare at all raises `KeyError`. Given a device module already in hand, either a pre-generated package or one built with `create_device_module`, pass it as the second argument: @@ -53,11 +53,11 @@ df = reader.read(behavior.AnalogData) # by register class Prefer the register class where a generated package supplies one, since it is the only form that type-checks and a misspelling is caught before the folder is read. A module built by `create_device_module` resolves its registers as `Any`, so there the class verifies no more than the name does. -The Harp time becomes the DataFrame index named `"Time"`, as float seconds by default or an absolute `DatetimeIndex` when the dataset is opened with `epoch=REFERENCE_EPOCH`. The anchor is set once for the dataset, since it describes how the recording was made rather than how one register is read. Data carrying no timestamp raise unless `time_index=False` is passed. Multi-chunk registers logged as `_
_.bin` are concatenated in filename order; pass a `resolver` to support an alternative on-disk layout. `paths` reports what the resolver found, keyed by address, which is where a custom layout or a chunked register can be checked. +The Harp time becomes the DataFrame index named `"Time"`, as float seconds by default or an absolute `DatetimeIndex` when the dataset is opened with `epoch=REFERENCE_EPOCH`. The anchor is set once for the dataset, since it describes how the recording was made rather than how one register is read. Data carrying no timestamp raise unless `time_index=False` is passed. Multi-chunk registers logged as `_
_.bin` are concatenated in filename order. Pass a `resolver` to support an alternative on-disk layout. `paths` reports what the resolver found, keyed by address, which is where a custom layout or a chunked register can be checked. The `` prefix comes from the `DEVICE_NAME` declared by the device module. Pass `name=` to override it, or to supply one when the module declares an empty name. -When a device module declaring an identity is supplied and the folder carries a `device.yml`, their `whoAmI` values are checked against each other. Reusing a module across sessions and reaching the wrong folder then fails on construction rather than decoding the files against the wrong register map. Pass `validate=False` to turn off every check the reader performs, so a folder whose `device.yml` is damaged can be read with a module obtained elsewhere. +When a device module declaring an identity is supplied and the folder carries a `device.yml`, their `whoAmI` values are checked against each other. Reusing a module across sessions and opening the wrong folder then fails on construction rather than decoding the files against the wrong register map. Pass `validate=False` to turn off every check the reader performs, so a folder whose `device.yml` is damaged can be read with a module obtained elsewhere. ## Read a single register file diff --git a/src/packages/harp-data/src/harp/data/_dataset.py b/src/packages/harp-data/src/harp/data/_dataset.py index 16d1335..976c531 100644 --- a/src/packages/harp-data/src/harp/data/_dataset.py +++ b/src/packages/harp-data/src/harp/data/_dataset.py @@ -65,7 +65,7 @@ class DatasetReader(Generic[M]): map. ``validate`` turns off every check the reader performs, so a folder whose ``device.yml`` is damaged can be read with a module obtained elsewhere. - The reader is typed on the module it was given, so registers stay reachable through + The reader is typed on the module it was given, so registers stay accessible through :attr:`device_module` at whatever precision that module offers. File resolution defaults to the Harp file format: ``_
.bin`` and, @@ -111,7 +111,7 @@ def device_module(self) -> M: A generated package resolves each register to its own class; one built by :func:`~harp.device.schema.create_device_module` resolves them collectively, - the same ceiling as reaching it directly. + the same ceiling as accessing it directly. """ return self._device_module @@ -173,7 +173,7 @@ def read( ``register`` is a register class, a register name, or an address. Names are resolved through the device register map rather than the module namespace, which - declares no common registers. Prefer the class where a generated package supplies + declares no core registers. Prefer the class where a generated package supplies one, since it is the only form a type checker can verify. A module built by :func:`~harp.device.schema.create_device_module` resolves its registers as ``Any``, so there the generated module verifies no more than the name does. diff --git a/src/packages/harp-device/README.md b/src/packages/harp-device/README.md index a1a12ac..5a7f900 100644 --- a/src/packages/harp-device/README.md +++ b/src/packages/harp-device/README.md @@ -1,6 +1,6 @@ # harp-device -The transport-agnostic device layer for the Harp protocol: the common Harp registers and a `Device` base that handles framing, request/reply and register access. It depends only on [`harp-protocol`](https://github.com/harp-tech/python/tree/main/src/packages/harp-protocol), with no transport dependencies. Pair it with a transport such as [`harp-serial`](https://github.com/harp-tech/python/tree/main/src/packages/harp-serial). +The transport-agnostic device layer for the Harp protocol: the core Harp registers and a `Device` base that handles framing, request/reply and register access. It depends only on [`harp-protocol`](https://github.com/harp-tech/python/tree/main/src/packages/harp-protocol), with no transport dependencies. Pair it with a transport such as [`harp-serial`](https://github.com/harp-tech/python/tree/main/src/packages/harp-serial). ## Read/write registers @@ -18,7 +18,7 @@ device.write(core.OperationControl, payload) # write a register An error reply raises `DeviceError`, which keeps the reply as `reply` so the frame sent by the device stays available for inspection. Pass `raise_on_error=False` to the constructor to receive such a reply as an ordinary return value instead. A transport failure raises `TransportError`, and every later request reports the same failure rather than waiting for a reply that cannot arrive. A device that never answers raises `TimeoutError` after `REPLY_TIMEOUT`, which is also what happens when `close` is called during a request. -## Extending for a specific device +## Extend for a specific device A device is described by a module. Downstream, often generated, packages record the device identity as `WHO_AM_I`, declare the register classes at module level, and expand the core `REGISTER_MAP` beside them: @@ -31,7 +31,7 @@ REGISTER_MAP = {**_CORE_REGISTER_MAP, 32: DigitalInputState, ...} This is the same structure `create_device_module` builds from a schema, so a device reads the same way whether it was generated ahead of time or compiled at runtime. A `WHO_AM_I` of `0` marks an unregistered device, used while a device is in development or outside the official registry, and identity checks are skipped for it. -A device module names only what its schema declares, the registers beside the enums and payload classes they are built from, so `REGISTER_MAP` is the device address space while the module namespace is what the device adds to it. The common registers and any core mask the schema reuses have a single definition, in `harp.device.core`, and are reached from there rather than through the device module. The core register set is not a device, so it carries no `WHO_AM_I`. +A device module names only what its schema declares, the registers beside the enums and payload classes built from them. The core registers and any core mask reused by the schema have a single definition, in `harp.device.core`, and are accessed from there rather than through the device module. The core register set is not a device, so it carries no `WHO_AM_I`. `REGISTER_MAP` covers the complete device address space, including both core and application registers. Pass the module to `Device`, or to `open_device`, to validate identity on open: @@ -39,17 +39,17 @@ Pass the module to `Device`, or to `open_device`, to validate identity on open: from harp.device import behavior, client, core with client.Device(transport, behavior) as device: - device.read(core.WhoAmI) # a common register + device.read(core.WhoAmI) # a core register device.read(behavior.DigitalInputState) # declared by the schema ``` -The `WHO_AM_I` in the module determines the check, and `0` skips it. Omitting the module skips validation. The module is not otherwise consulted: registers reach `read`, `write` and `subscribe` as arguments either way, and only a subscribed register is parsed on arrival. Common registers such as `WhoAmI` and `OperationControl` come from `harp.device.core` and are read the same way. +The `WHO_AM_I` in the module determines the check, and `0` skips it. Omitting the module skips validation. The module is not otherwise consulted: registers are passed to `read`, `write` and `subscribe` as arguments either way, and only a subscribed register is parsed on arrival. Core registers such as `WhoAmI` and `OperationControl` come from `harp.device.core` and are read the same way. A new transport is just an object implementing the `ITransport` protocol, with `open`, `write`, `read` and `close`. -## Generating registers from a `device.yml` +## Generate registers from a `device.yml` -Without a pre-generated device package, `create_device_module` builds the same structure at runtime from Harp `device.yml` text: register, enum and payload classes at module level, a `REGISTER_MAP` beside them, and the identity declared by the schema as `WHO_AM_I`. Identifiers match a generated package name for name: register, enum, and payload class names come from the yml verbatim, payload fields are `snake_case`, and enum members are `SCREAMING_SNAKE_CASE`. A `maskType` the schema does not declare resolves against the core masks, and a register marked `private` is emitted with an underscore-prefixed name. +Without a pre-generated device package, `create_device_module` builds the same structure at runtime from Harp `device.yml` text. It emits register, enum, and payload classes at module level, a `REGISTER_MAP` beside them, and the identity declared by the schema as `WHO_AM_I`. Identifiers match a generated package name for name: register, enum, and payload class names come from the yml verbatim, payload fields are `snake_case`, and enum members are `SCREAMING_SNAKE_CASE`. A `maskType` the schema does not declare resolves against the core masks, and a register marked `private` is emitted with an underscore-prefixed name. ```python from pathlib import Path diff --git a/src/packages/harp-device/src/harp/device/client/_device.py b/src/packages/harp-device/src/harp/device/client/_device.py index 90b0c3c..66345e9 100644 --- a/src/packages/harp-device/src/harp/device/client/_device.py +++ b/src/packages/harp-device/src/harp/device/client/_device.py @@ -103,7 +103,7 @@ class Device(Generic[M]): dev.read(behavior.OperationControl) Omitting ``device_module`` skips that check. The module is not otherwise - consulted: registers reach :meth:`read`, :meth:`write` and :meth:`subscribe` + consulted: registers are passed to :meth:`read`, :meth:`write` and :meth:`subscribe` as arguments either way, and only a subscribed register is parsed on arrival. A request fails in one of three ways. An error reply raises :class:`DeviceError` diff --git a/src/packages/harp-device/src/harp/device/schema/_module.py b/src/packages/harp-device/src/harp/device/schema/_module.py index 5a7170c..4b8231a 100644 --- a/src/packages/harp-device/src/harp/device/schema/_module.py +++ b/src/packages/harp-device/src/harp/device/schema/_module.py @@ -3,7 +3,7 @@ A generated device package is already a module: register classes at module level and a ``REGISTER_MAP`` beside them (see the ``harp-device`` README). :func:`create_device_module` builds that same shape at runtime from a ``device.yml``, so a -schema-driven device and a generated one are reached the same way, by name from the +schema-driven device and a generated one are accessed the same way, by name from the module or by address through ``REGISTER_MAP``. """ @@ -25,7 +25,7 @@ class DeviceModuleLike(Protocol): A generated device package is a plain module, so it cannot be named by a class; what identifies it is describing a device. Matching structurally accepts both it - and :class:`DeviceModule`, and rejects the common register set, which carries + and :class:`DeviceModule`, and rejects the core register set, which carries registers but is not a device. ``DEVICE_NAME`` is required rather than optional, so a generated package always @@ -41,7 +41,7 @@ class DeviceModuleLike(Protocol): class DeviceModule(types.ModuleType): """The type of the module returned by :func:`create_device_module`. - The declarations of the schema are reached by name and typed ``Any``, since they + The declarations of the schema are accessed by name and typed ``Any``, since they exist only at runtime. ``DEVICE_NAME``, ``REGISTER_MAP``, ``WHO_AM_I`` and ``__all__`` are declared here and carry their own types. """ @@ -53,7 +53,7 @@ class DeviceModule(types.ModuleType): """The device identity declared by the schema. ``0`` when absent.""" REGISTER_MAP: dict[int, type[RegisterBase[Any]]] - """Address -> register class, the common Harp registers merged with those of the schema.""" + """Address -> register class, the core Harp registers merged with those of the schema.""" __all__: list[str] """The declarations of the schema, beside ``REGISTER_MAP`` and ``WHO_AM_I``.""" @@ -71,12 +71,12 @@ def create_device_module( The module names what the schema declares, its registers beside the enums and payload classes they are built from, so ``behavior.AnalogData``, ``behavior.AnalogDataPayload`` and ``behavior.EncoderModeMask`` all resolve while a - common register such as ``WhoAmI`` is imported from :mod:`harp.device.core`, keeping + core register such as ``WhoAmI`` is imported from :mod:`harp.device.core`, keeping one definition of each. This is the same set a generated device package holds. A name describing two declarations is rejected rather than shadowed. Beside them it holds: - * ``REGISTER_MAP``, the device address space, so the common registers are + * ``REGISTER_MAP``, the device address space, so the core registers are present here even though the module does not name them; * ``WHO_AM_I``, the identity declared by the schema (``0`` for an unregistered device); * ``DEVICE_NAME``, the ``device`` name of the schema, or ``name`` when given, and @@ -90,11 +90,11 @@ def create_device_module( Because the names come from the schema at runtime they don't autocomplete, and each resolves as ``Any`` rather than its own type. A generated device package is a real module on disk and gives both. On an address clash the device register - replaces the common one in ``REGISTER_MAP``. + replaces the core one in ``REGISTER_MAP``. ``text`` is the schema itself rather than a path to it, matching :func:`parse_device_schema`, so read the file first. The module is **not** - registered in :data:`sys.modules`, so it cannot be reached by ``import`` and two + registered in :data:`sys.modules`, so it cannot be imported and two schemas may share a name without clashing. Bind it yourself:: behavior = create_device_module(Path("device.yml").read_bytes()) diff --git a/src/packages/harp-protocol/README.md b/src/packages/harp-protocol/README.md index 86768e2..a190f1c 100644 --- a/src/packages/harp-protocol/README.md +++ b/src/packages/harp-protocol/README.md @@ -2,11 +2,11 @@ [![PyPI version](https://badge.fury.io/py/harp-protocol.svg)](https://badge.fury.io/py/harp-protocol) -The Harp Protocol is a binary communication protocol created in order to facilitate and unify the interaction between different devices. It was designed with efficiency and ease of parsing in mind. +The Harp Protocol is a binary communication protocol created to facilitate and unify the interaction between different devices. It was designed with efficiency and ease of parsing in mind. For more detail please check the [official Harp Tech documentation](https://harp-tech.org/protocol/BinaryProtocol-8bit.html). -`harp-protocol` provides the building blocks: message framing and the typed register/payload DSL. Each register knows how to build (`format`) and decode (`parse`) its frames. +`harp-protocol` provides the building blocks: message framing and the typed register/payload DSL. Each register builds its frames with `format` and decodes them with `parse`. ```python import numpy as np @@ -28,9 +28,9 @@ np.uint16(65535) + 1 # RuntimeWarning: overflow encountered in scalar add 65535 + 1 # 65536, wider than the register can hold ``` -Numpy scalars behave like plain Python numbers in arithmetic, comparison and formatting. Use `int()` or `float()` where a built-in type is required. +Numpy scalars behave like plain Python numbers in arithmetic, comparison, and formatting. Use `int()` or `float()` where a built-in type is required. -## Reading an address no schema describes +## Read an address no schema describes A register class is normally declared with its address, as above, or generated from a `device.yml`. Calling a register base with an address instead builds a one-off register for that address, which is how a payload outside any schema is read and written: diff --git a/src/packages/harp-protocol/src/harp/protocol/_payload.py b/src/packages/harp-protocol/src/harp/protocol/_payload.py index 2bb7095..b761947 100644 --- a/src/packages/harp-protocol/src/harp/protocol/_payload.py +++ b/src/packages/harp-protocol/src/harp/protocol/_payload.py @@ -885,7 +885,7 @@ def _unwrap(cls, arr: "np.ndarray") -> Any: Struct payloads always return a typed wrapper so descriptors like ``payload.Channel0`` work. Anonymous payloads override this to return the raw numpy scalar or ndarray directly, or, for an ``AnonymousPayload`` root, - the unwrapped ``__value__`` through the single-member branch below, reached + the unwrapped ``__value__`` through the single-member branch below, entered via the ``super()`` call of the override. A struct payload never auto-unwraps. """ obj = cls._from_array(arr) diff --git a/src/packages/harp-serial/README.md b/src/packages/harp-serial/README.md index 0036f75..48ac94e 100644 --- a/src/packages/harp-serial/README.md +++ b/src/packages/harp-serial/README.md @@ -12,7 +12,7 @@ from harp.device import behavior, core # Use "COMx" on Windows, "/dev/ttyUSBx" on Linux. with serial.open_device(behavior, port="COM3") as device: - print(device.read(core.WhoAmI).payload) # a common register + print(device.read(core.WhoAmI).payload) # a core register print(device.read(behavior.AnalogData).payload) # a device register ``` diff --git a/src/packages/harp-serial/src/harp/serial/_serial.py b/src/packages/harp-serial/src/harp/serial/_serial.py index 1be209a..2297803 100644 --- a/src/packages/harp-serial/src/harp/serial/_serial.py +++ b/src/packages/harp-serial/src/harp/serial/_serial.py @@ -99,7 +99,7 @@ def open_device( from harp.device import behavior, core with open_device(behavior, port="COM3") as dev: - dev.read(core.WhoAmI) # a common register + dev.read(core.WhoAmI) # a core register dev.read(behavior.AnalogData) # declared by the schema - **Device subclass**: instantiates the subclass directly, preserving its type:: diff --git a/tests/conformance.py b/tests/conformance.py index 7d4195f..8e24b4a 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -99,16 +99,16 @@ def dataset_reader_keeps_module_type( """The reader is typed on the module it was given, not on the contract. Reading it back as ``DeviceModuleLike`` would leave only the three declarations of - the contract, so every register reached through the reader would fail to resolve. + the contract, so every register accessed through the reader would fail to resolve. """ assert_type(DatasetReader(schema_built, "session.harp").device_module, DeviceModule) assert_type(DatasetReader(generated, "session.harp").device_module, DeviceModuleLike) def dataset_reader_registers_resolve_through_the_module() -> None: - """A register stays reachable through the reader, at the precision of its module. + """A register stays accessible through the reader, at the precision of its module. - A schema-built module resolves collectively, as it does when reached directly, so + A schema-built module resolves collectively, as it does when accessed directly, so the ceiling here is the one :func:`create_device_module` documents. A generated package carries its own declarations and resolves each to its own class. """ diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index 1eec0ca..24ad3cc 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -61,7 +61,7 @@ def test_read_by_class_from_module_namespace(dataset): mod, _name, root, specs = dataset reader = DatasetReader(mod, root) for address, (cls, _buf) in specs.items(): - # The register reached by name off the module is the one at that address. + # The register accessed by name off the module is the one at that address. assert reader.read(getattr(mod, cls.__name__)).equals(reader.read(address)) @@ -81,7 +81,7 @@ def test_unknown_name_raises_key_error(dataset): def test_reads_common_registers_not_named_by_module(emitted_module, tmp_path): # A device module names only its own registers, but a session folder also holds - # files for the common ones, so the reader must still decode those. + # files for the core ones, so the reader must still decode those. mod = emitted_module assert not hasattr(mod, "WhoAmI") # imported from harp.device, not re-exported @@ -92,7 +92,7 @@ def test_reads_common_registers_not_named_by_module(emitted_module, tmp_path): reader = DatasetReader(mod, tmp_path) # By address, by the class imported from harp.device, and by name, which resolves - # through the register map and so reaches further than the module namespace. + # through the register map and so covers more than the module namespace. assert len(reader.read(WhoAmI.address)) == 4 assert reader.read(WhoAmI).equals(reader.read(WhoAmI.address)) assert reader.read("WhoAmI").equals(reader.read(WhoAmI.address)) @@ -321,7 +321,7 @@ def test_contents_sorted_by_address(dataset): def test_contents_keys_read_every_register(dataset): # The comprehension over contents is what replaces a load-everything call, so its - # keys must reach every register with data and produce the same frames as a direct read. + # keys must cover every register with data and produce the same frames as a direct read. mod, _name, root, specs = dataset reader = DatasetReader(mod, root) @@ -397,7 +397,7 @@ def test_open_dataset_accepts_explicit_schema_path(dataset, device_yml, tmp_path def test_open_dataset_accepts_device_module(dataset): - # The overload taking a module must reach the same reader as constructing one, + # The overload taking a module must produce the same reader as constructing one, # since it is the only route open to a pre-generated package here. mod, _name, root, specs = dataset reader = open_dataset(root, mod) diff --git a/tests/device/test_create_device_module.py b/tests/device/test_create_device_module.py index ccab57c..02cfac4 100644 --- a/tests/device/test_create_device_module.py +++ b/tests/device/test_create_device_module.py @@ -67,12 +67,12 @@ def test_docstring_absent_when_undeclared(test_module): assert test_module.__doc__ is None -def test_registers_are_reachable_by_name(test_module): +def test_registers_are_accessible_by_name(test_module): assert test_module.AnalogData.address == 33 assert test_module.EncoderMode.address == 103 -def test_registers_are_reachable_by_address(test_module): +def test_registers_are_accessible_by_address(test_module): reg_map = test_module.REGISTER_MAP assert reg_map[33].__name__ == "AnalogData" assert reg_map[103].__name__ == "EncoderMode" @@ -86,7 +86,7 @@ def test_register_map_spreads_core(test_module): def test_core_registers_are_not_named_by_module(test_module): - # A common register has one definition, in harp.device, so a device module does + # A core register has one definition, in harp.device, so a device module does # not re-export it. It is still in the address space the device can send from. assert not hasattr(test_module, "WhoAmI") assert test_module.REGISTER_MAP[0] is WhoAmI @@ -113,7 +113,7 @@ def test_generated_package_matches_device_protocol(): assert isinstance(expected_device, DeviceModuleLike) -def test_common_registers_are_not_device_module(): +def test_core_registers_are_not_device_module(): # They carry REGISTER_MAP but describe no device, so they cannot be passed # where a device module is required, such as to a DatasetReader. assert hasattr(harp.device.core, "REGISTER_MAP") @@ -137,7 +137,7 @@ def test_named_registers_are_subset_of_address_space(test_module): def test_device_register_overrides_core_on_clash(): - # A device register at a common address replaces it in the address space. + # A device register at a core address replaces it in the address space. mod = create_device_module( "device: Clash\nregisters:\n Shadow: {address: 0, type: U32, access: Read}\n" ) @@ -162,13 +162,13 @@ def test_all_covers_declarations_and_module_constants(test_module): assert {"REGISTER_MAP", "WHO_AM_I"} <= exported assert {"AnalogData", "EncoderMode"} <= exported assert {"EncoderModeMask", "AnalogDataPayload"} <= exported - assert "WhoAmI" not in exported # a common register is not re-exported + assert "WhoAmI" not in exported # a core register is not re-exported assert exported - MODULE_CONSTANTS == { n for n in vars(test_module) if not n.startswith("_") and n not in MODULE_CONSTANTS } -def test_all_matches_the_generated_package(test_module): +def test_all_matches_generated_package(test_module): # expected_device is generator output for the same schema, so this pins that both # paths publish exactly the same surface, not merely equivalent registers. assert set(test_module.__all__) == set(expected_device.__all__) @@ -176,7 +176,7 @@ def test_all_matches_the_generated_package(test_module): def test_reused_core_masks_are_not_named_by_module(): # A reused mask has one definition, in harp.device.core, so a device module resolves - # registers against it without naming it, as it does for the common registers. + # registers against it without naming it, as it does for the core registers. mod = create_device_module( "device: CoreMasks\n" "registers:\n" diff --git a/tests/device/test_device.py b/tests/device/test_device.py index 4350c76..e359175 100644 --- a/tests/device/test_device.py +++ b/tests/device/test_device.py @@ -30,7 +30,7 @@ def read(self) -> bytes: class _ScriptedTransport: """A transport replying with whatever ``on_write`` returns for each request. - Frames are queued from inside ``write``, which is reached only once the request is + Frames are queued from inside ``write``, which runs only once the request is registered, so a reply cannot be dispatched before there is a waiter to receive it. Setting ``failing`` makes the next read fail, as a removed port would. """ @@ -74,14 +74,14 @@ def _module(name: str, **attrs: object) -> types.ModuleType: return mod -def test_whoami_of_zero_skips_the_check(): +def test_zero_whoami_skips_validation(): # 0 marks an unregistered device, so opening must not read WhoAmI at all. device = Device(_NullTransport(), _module("Unregistered", WHO_AM_I=0, REGISTER_MAP={})) with device: assert device.module.WHO_AM_I == 0 -def test_module_is_returned_by_the_property(): +def test_module_property_returns_given_module(): module = _module("Behavior", WHO_AM_I=0, REGISTER_MAP={}) assert Device(_NullTransport(), module).module is module assert Device(_NullTransport()).module is None @@ -155,7 +155,7 @@ def fail(_: bytes) -> Iterable[bytes]: device.read(core.WhoAmI) with pytest.raises(TransportError): device.read(core.WhoAmI) - assert len(transport.writes) == 1 # the second request never reached the transport + assert len(transport.writes) == 1 # the second request was never written to the transport def test_error_reply_raises_device_error(): diff --git a/tests/device/test_emit.py b/tests/device/test_emit.py index a515cba..f51c4b4 100644 --- a/tests/device/test_emit.py +++ b/tests/device/test_emit.py @@ -119,7 +119,7 @@ def test_register_and_payload_class_names_stay_verbatim(device_registers): def test_enum_names_match_generator_for_every_enum(device_registers): - """Every enum the reference module declares has identical members in the emitter.""" + # Every enum the reference module declares has identical members in the emitter. for name, reg in _device_registers().items(): payload = reg.payload_class if payload.payload_dtype.names is None: @@ -163,7 +163,7 @@ def test_core_register_structural(name, core_yml): # --------------------------------------------------------------------------- -# Behavioural round-trips +# Behavioral round-trips # --------------------------------------------------------------------------- @@ -240,7 +240,7 @@ def test_undeclared_bit_mask_resolves_to_core(): assert _value_enum(regs["ResetFlow"]) is core.ResetFlags -def test_reused_core_mask_roundtrips_as_the_core_type(): +def test_reused_core_mask_roundtrips_as_core_type(): # Identity matters more than equal members: a value read through a runtime module # must satisfy isinstance against the same enum a generated package would use. regs = create_registers(CORE_MASKS_YML) @@ -497,7 +497,7 @@ def test_field_name_taking_reserved_prefix_raises(key): @pytest.mark.parametrize("key", ["Break", "Class", "Return"]) def test_field_name_renaming_to_keyword_raises(key): - # `Break` renames to `break`, which is only reachable through getattr and is a + # `Break` renames to `break`, which is only accessible through getattr and is a # syntax error in a statically generated module. with pytest.raises(NameCollisionError, match="is a Python keyword"): create_registers(_one_field_schema(key)) diff --git a/tests/device/test_naming.py b/tests/device/test_naming.py index 5f1cc4a..46a034a 100644 --- a/tests/device/test_naming.py +++ b/tests/device/test_naming.py @@ -2,7 +2,7 @@ Every pair below is taken from the committed expected output of the generator (``tests/ExpectedOutput/{core,device}.py`` against ``tests/Metadata/{core,device}.yml``), -so these lock the port to the C# behaviour rather than to a re-derivation of it. +so these lock the port to the C# behavior rather than to a re-derivation of it. """ import pytest diff --git a/tests/protocol/test_converter.py b/tests/protocol/test_converter.py index b58fa20..0b76918 100644 --- a/tests/protocol/test_converter.py +++ b/tests/protocol/test_converter.py @@ -188,7 +188,7 @@ class _Single(PayloadBase): def test_bitfield_payloads_ndim_aware(): - """Scalar records stay on the declared class; batches route to the auto-derived ``Batch`` twin.""" + # Scalar records stay on the declared class; batches route to the auto-derived Batch twin. class _Flags(PayloadBase): flag = BitMask(enum=_Flag, mask=0x01) @@ -209,7 +209,7 @@ class _Flags(PayloadBase): def test_bitfield_kwarg_init_round_trip(): - """PayloadBase.__init__ supports bitfield kwargs with OR-into-slot encoding.""" + # PayloadBase.__init__ supports bitfield kwargs with OR-into-slot encoding. class _Flags(PayloadBase): flag = BitMask(enum=_Flag, mask=0x01) diff --git a/tests/protocol/test_framer.py b/tests/protocol/test_framer.py index ef44513..f7d4b6d 100644 --- a/tests/protocol/test_framer.py +++ b/tests/protocol/test_framer.py @@ -42,7 +42,7 @@ def test_garbage_between_messages(): def test_bad_checksum_skipped_recovery(): - """A frame with a bad checksum should be skipped; the next valid frame parses.""" + # A frame with a bad checksum should be skipped; the next valid frame parses. bad = bytearray(make_frame_from_raw(0x01, 8, 0xFF, 0x04, b"")) bad[-1] ^= 0xFF # corrupt checksum good = make_frame_from_raw(0x02, 10, 0xFF, 0x01, b"\x05") @@ -59,7 +59,7 @@ def test_truncated_stream_returns_empty(): def test_incremental_feed(): - """Feeding data in small chunks still yields the complete message.""" + # Feeding data in small chunks still yields the complete message. frame = make_frame_from_raw(0x02, 10, 0xFF, 0x01, b"\x42") framer = HarpFramer() results = [] @@ -71,7 +71,7 @@ def test_incremental_feed(): def test_all_scalar_types(): - """Framer correctly parses messages with each PayloadType.""" + # Framer correctly parses messages with each PayloadType. from harp.protocol._payload_type import PayloadType, encode_payload_type for pt in PayloadType: diff --git a/tests/protocol/test_message.py b/tests/protocol/test_message.py index 8f70ea4..c27f20b 100644 --- a/tests/protocol/test_message.py +++ b/tests/protocol/test_message.py @@ -11,7 +11,7 @@ def test_parse_read_request(): - """Read request: no payload, no timestamp.""" + # Read request: no payload, no timestamp. frame = make_frame_from_raw(0x01, address=8, port=0xFF, payload_type=0x04, payload=b"") msg = HarpMessage.parse(frame) assert msg.message_type == MessageType.Read @@ -109,7 +109,7 @@ def _u8_frame(): ) -def test_decode_attaches_without_touching_the_frame(): +def test_decode_attaches_payload_over_same_frame(): typed = _u8_frame().decode(RegisterU8(0x0A)) assert typed.has_payload is True assert typed.payload == 5 @@ -117,21 +117,21 @@ def test_decode_attaches_without_touching_the_frame(): assert typed.address == 10 -def test_decode_leaves_the_source_undecoded(): +def test_decode_leaves_source_undecoded(): # The dispatch loop hands one frame to several places, so decoding must not mutate it. msg = _u8_frame() msg.decode(RegisterU8(0x0A)) assert msg.has_payload is False -def test_decode_rejects_a_payload_type_mismatch(): +def test_decode_rejects_payload_type_mismatch(): # Without the check the register reads the low bytes at its own width and returns a # silently wrong value, which is what parse does on its own. with pytest.raises(HarpParseError, match="declares"): _u8_frame().decode(RegisterU16(0x0A)) -def test_decode_accepts_a_register_at_another_address(): +def test_decode_accepts_register_at_another_address(): # The payload type decides whether these bytes can be read as this register at all. # The address says which register the device meant, so an identical layout decodes # either way and a frame can be read through more than one register. diff --git a/tests/protocol/test_register.py b/tests/protocol/test_register.py index be12e48..ccd4a88 100644 --- a/tests/protocol/test_register.py +++ b/tests/protocol/test_register.py @@ -81,7 +81,7 @@ def _parse_frame(frame: bytes) -> HarpMessage: ], ) def test_scalar_register_format_write(reg_cls, address, payload_type, value, dtype): - """format(value) produces a parseable Write frame with the correct payload.""" + # format(value) produces a parseable Write frame with the correct payload. reg = reg_cls(address) frame = reg.format(value) msg = _parse_frame(frame) @@ -107,7 +107,7 @@ def test_scalar_register_format_write(reg_cls, address, payload_type, value, dty ], ) def test_scalar_register_format_read(reg_cls, address, payload_type): - """format() (no value) produces a Read frame with empty payload.""" + # format() (no value) produces a Read frame with empty payload. reg = reg_cls(address) frame = reg.format() msg = _parse_frame(frame) @@ -118,7 +118,7 @@ def test_scalar_register_format_read(reg_cls, address, payload_type): @pytest.mark.parametrize("value", [0, 1, 2**32 - 1]) def test_named_register_roundtrip(value): - """TimestampSecond write frame parses back to the same value.""" + # TimestampSecond write frame parses back to the same value. frame = TimestampSecond.format(value) msg = _parse_frame(frame) parsed = TimestampSecond.parse(msg) @@ -188,7 +188,7 @@ def test_register_repr_shows_name_and_address(): ], ) def test_format_with_payload_instance(reg_cls, payload_cls, value): - """Passing a PayloadXxx instance to format() uses the bytes of the instance directly.""" + # Passing a PayloadXxx instance to format() uses the bytes of the instance directly. reg = reg_cls(0x08) payload = payload_cls(value) frame = reg.format(payload) @@ -224,7 +224,7 @@ def test_parse_names_register_on_short_payload(): def test_format_with_payload_instance_via_register(): - """format() accepts a typed PayloadU32 and encodes it correctly.""" + # format() accepts a typed PayloadU32 and encodes it correctly. payload = PayloadU32(42) frame = TimestampSecond.format(payload) msg = _parse_frame(frame) @@ -315,7 +315,7 @@ def test_s16_array_roundtrip(): def test_unnamed_register_auto_payload_class(): - """A bare RegisterU8 subclass with only address set gets an auto-generated payload class.""" + # A bare RegisterU8 subclass with only address set gets an auto-generated payload class. class MyReg(RegisterU8): address: ClassVar[int] = 0x50 @@ -327,7 +327,7 @@ class MyReg(RegisterU8): def test_explicit_payload_class_not_overwritten(): - """Explicit payload_class on AnalogData is not replaced by auto-generation.""" + # Explicit payload_class on AnalogData is not replaced by auto-generation. assert AnalogData.payload_class is AnalogDataPayload @@ -384,7 +384,7 @@ def test_format_write_with_timestamp(): ], ) def test_anonymous_payload_roundtrip(payload_cls, raw_value, np_dtype): - """Anonymous payload constructor + payload_bytes roundtrips through bytes.""" + # Anonymous payload constructor + payload_bytes roundtrips through bytes. payload = payload_cls(raw_value) assert payload.payload_array.dtype == np_dtype assert payload.payload_array.tobytes() == np.asarray(raw_value, dtype=np_dtype).tobytes() @@ -410,11 +410,9 @@ def test_structured_payload_descriptors_multi(): def test_anonymous_payload_converter_roundtrip(): - """A ``__value__`` Field codec encodes/decodes the single slot. - - Models a register that carries one value but needs a domain codec - (e.g. DeviceName -> StringConverter). - """ + # A __value__ Field codec encodes and decodes the single slot, modeling a register + # that carries one value but needs a domain codec, such as DeviceName through + # StringConverter. from harp.protocol._payload import AnonymousPayload, Field from harp.protocol._payload_converters import StringConverter @@ -450,7 +448,7 @@ class DeviceName(RegisterBase): def test_anonymous_payload_scalar_converter_roundtrip(): - """A scalar (non-sub-array) ``__value__`` codec also round-trips through unwrap.""" + # A scalar (non-sub-array) __value__ codec also round-trips through unwrap. import enum from harp.protocol._payload import AnonymousPayload, Field @@ -471,7 +469,7 @@ class PayloadColor(AnonymousPayload[np.uint8]): def test_array_register_parse_returns_ndarray(): - """parse() on an array register returns the 1-D ndarray directly (no .value).""" + # parse() on an array register returns the 1-D ndarray directly (no .value). reg = RegisterU32Array(0x08, length=3) values = np.array([10, 20, 30], dtype=np.dtype("