Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/examples/create_device_module/create_device_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
#
# behavior = schema.create_device_module(yml_text, converters={"DataConverter": DataConverter()})
#
# An unresolved custom type raises `UnknownConverterError`. Pass `strict=False` to
# An unresolved custom type raises `UnknownConverterError`. Pass `require_converters=False` to
# decode it natively instead. A register marked `private` in the schema is emitted
# with an underscore-prefixed name. For the parsed schema model rather than a module,
# `parse_device_schema(yml_text)` returns that directly.
19 changes: 11 additions & 8 deletions src/packages/harp-data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,27 @@ Reading is based on a [device module](../harp-device) that describes how to deco
from harp import data

reader = data.create_dataset_reader("session.harp")
df = reader.read(AnalogData) # by register class
df = reader.read(44) # by address
everything = reader.read_all() # {register_name: DataFrame}
behavior = reader.device_module
df = reader.read(behavior.AnalogData) # by register class
df = reader.read(44) # by address
everything = reader.read_all() # {register_name: DataFrame}
```

Given a device module already in hand, either a pre-generated package or one built with `create_device_module`, pass it to `DatasetReader` directly:

```python
from pathlib import Path

from harp import data
from harp.device import schema
from harp.device import behavior

behavior = schema.create_device_module((Path("session.harp") / "device.yml").read_bytes())
reader = data.DatasetReader(behavior, "session.harp")
df = reader.read(behavior.AnalogData)
```

Timestamps are auto-detected per register and placed on the DataFrame index named `"Time"`: float seconds by default, or an absolute `DatetimeIndex` when `epoch=REFERENCE_EPOCH` is passed. Multi-chunk registers logged as `<DeviceName>_<address>_<suffix>.bin` are concatenated in filename order; pass a `resolver` to support an alternative on-disk layout, or `name=` to override the file prefix.
Timestamps are auto-detected per register and placed on the DataFrame index named `"Time"`: float seconds by default, or an absolute `DatetimeIndex` when `epoch=REFERENCE_EPOCH` is passed. Multi-chunk registers logged as `<DeviceName>_<address>_<suffix>.bin` are concatenated in filename order; pass a `resolver` to support an alternative on-disk layout.

The `<DeviceName>` 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 the folder carries a `device.yml` and the module declares an identity, 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.

## Read a single register file

Expand Down
97 changes: 80 additions & 17 deletions src/packages/harp-data/src/harp/data/_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@
from datetime import datetime
from os import PathLike
from pathlib import Path
from typing import Any
from typing import Any, Generic, TypeVar

import pandas as pd
from harp.device.schema import DeviceModuleLike, create_device_module
from harp.device.schema import (
DeviceModule,
DeviceModuleLike,
create_device_module,
parse_device_schema,
)
from harp.protocol import RegisterBase
from harp.protocol._constants import _TIMESTAMP_FLAG

from ._reader import parse_to_dataframe

M = TypeVar("M", bound=DeviceModuleLike)

RegisterKey = type[RegisterBase[Any]] | int

FileNameResolver = Callable[[Path, str], Mapping[int, list[Path]]]
Expand All @@ -31,7 +38,7 @@ def default_file_resolver(root: Path, name: str) -> dict[int, list[Path]]:
return files


class DatasetReader:
class DatasetReader(Generic[M]):
"""Reader over a de-multiplexed Harp dataset folder.

Construct from a device module and a dataset folder, then read the
Expand All @@ -43,9 +50,21 @@ class DatasetReader:
everything = reader.read_all() # {register_name: DataFrame}

``device_module`` is a device module -- a generated device package, or one built from a
schema with :func:`~harp.device.schema.create_device_module`. Its ``REGISTER_MAP`` and
``__name__`` are read on demand. ``name`` overrides the ``<DeviceName>`` file
prefix, which defaults to the module name.
schema with :func:`~harp.device.schema.create_device_module`. Its ``REGISTER_MAP`` is
read on demand.

The files are matched by a ``<DeviceName>`` prefix, taken from the ``DEVICE_NAME``
declared by the module. Pass ``name`` to override it, or to supply one when the module
declares an empty name.

When the folder carries a ``device.yml`` and the module declares an identity, their
``whoAmI`` values are checked against each other. A module paired with the wrong
folder then fails here rather than decoding the files against the wrong register
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
:attr:`device_module` at whatever precision that module offers.

File resolution defaults to the Harp file format: ``<name>_<address>.bin`` and,
when a register was logged as several ``<name>_<address>_<suffix>.bin`` chunks,
Expand All @@ -55,32 +74,70 @@ class DatasetReader:

def __init__(
self,
device_module: DeviceModuleLike,
device_module: M,
root: str | PathLike[str],
*,
name: str | None = None,
resolver: FileNameResolver = default_file_resolver,
validate: bool = True,
) -> None:
self._device_module = device_module
self._root = Path(root)
self._name_override = name
self._resolver = resolver
self._files = dict(self._resolver(self._root, self.name))
self._name = self._resolve_name()
self._files = dict(self._resolver(self._root, self._name))
if validate:
self._validate_whoami()

@property
def root(self) -> Path:
"""The dataset folder being read."""
return self._root

@property
def device_module(self) -> DeviceModuleLike:
"""The device module this reader parses against."""
def device_module(self) -> M:
"""The device module this reader parses against, as the type it was given.

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.
"""
return self._device_module

@property
def name(self) -> str:
"""The ``<DeviceName>`` prefix used to match binary files."""
return self._name_override or self._device_module.__name__
return self._name

def _validate_whoami(self) -> None:
expected = self._device_module.WHO_AM_I
if expected == 0:
return
path = self._root / DEVICE_SCHEMA_FILENAME
if not path.is_file():
return
try:
actual = parse_device_schema(path.read_bytes()).whoAmI
except ValueError:
return
if actual is None or int(actual) == expected:
return
raise ValueError(
f"WhoAmI mismatch: {self._name} expects 0x{expected:04x} but the schema in "
f"{self._root} declares 0x{int(actual):04x}."
)

def _resolve_name(self) -> str:
if self._name_override is not None:
return self._name_override
declared = self._device_module.DEVICE_NAME
if declared:
return declared
raise ValueError(
f"The device module declares an empty DEVICE_NAME, so it cannot name the "
f"files under {self._root}. Pass name= with the file prefix to read."
)

@property
def registers(self) -> Mapping[int, type[RegisterBase[Any]]]:
Expand Down Expand Up @@ -192,8 +249,9 @@ def create_dataset_reader(
name: str | None = None,
resolver: FileNameResolver = default_file_resolver,
converters: Mapping[str, Any] | None = None,
strict: bool = True,
) -> DatasetReader:
require_converters: bool = True,
validate: bool = True,
) -> DatasetReader[DeviceModule]:
"""Build a :class:`DatasetReader` for a dataset folder, device and all.

Convenience wrapper that finds the device schema inside ``root`` (``device.yml``
Expand All @@ -204,10 +262,15 @@ def create_dataset_reader(
df = reader.read(44)

``schema`` points at the schema file explicitly when it isn't ``root/device.yml``.
``converters`` and ``strict`` are forwarded to :func:`~harp.device.schema.create_device_module`
for custom ``interfaceType`` decoding; ``name`` and ``resolver`` are forwarded to
``converters`` and ``require_converters`` are forwarded to
:func:`~harp.device.schema.create_device_module` for custom ``interfaceType``
decoding; ``name``, ``resolver`` and ``validate`` are forwarded to
:class:`DatasetReader`. Use ``DatasetReader(device_module, root)`` directly given a
device module already in hand, for example a pre-generated one.

Note ``validate`` cannot rescue a damaged ``device.yml`` here, since the module is
built from that same file and fails before the reader exists. Reading such a folder
means supplying a module obtained elsewhere.
"""
root_path = Path(root)
schema_path = Path(schema) if schema is not None else root_path / DEVICE_SCHEMA_FILENAME
Expand All @@ -217,6 +280,6 @@ def create_dataset_reader(
f"or build the device module yourself and use DatasetReader(device_module, root)."
)
device_module = create_device_module(
schema_path.read_text(), converters=converters, strict=strict
schema_path.read_text(), converters=converters, require_converters=require_converters
)
return DatasetReader(device_module, root_path, name=name, resolver=resolver)
return DatasetReader(device_module, root_path, name=name, resolver=resolver, validate=validate)
2 changes: 1 addition & 1 deletion src/packages/harp-device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ reg = behavior.REGISTER_MAP[44] # or by address

The module is not registered in `sys.modules`, so it has to be bound rather than imported. Names come from the schema at runtime, so they don't autocomplete and aren't statically checked. A generated package on disk gives both.

For a custom `interfaceType`, pass its converter via `converters=`, keyed by `{InterfaceType}Converter` or `{MemberName}Converter`. An unresolved custom type raises `UnknownConverterError`, or pass `strict=False` to decode it natively:
For a custom `interfaceType`, pass its converter via `converters=`, keyed by `{InterfaceType}Converter` or `{MemberName}Converter`. An unresolved custom type raises `UnknownConverterError`, or pass `require_converters=False` to decode it natively:

```python
schema.create_device_module(yml_text, converters={"DataConverter": DataConverter()})
Expand Down
2 changes: 1 addition & 1 deletion src/packages/harp-device/src/harp/device/client/_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def _validate_whoami(self) -> None:
actual = int(self.read(WhoAmI).parsed)
if actual != expected:
raise RuntimeError(
f"WhoAmI mismatch: {module.__name__} expects 0x{expected:04x} "
f"WhoAmI mismatch: {module.DEVICE_NAME} expects 0x{expected:04x} "
f"but the device reported 0x{actual:04x}."
)

Expand Down
22 changes: 11 additions & 11 deletions src/packages/harp-device/src/harp/device/schema/_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,11 @@ def __init__(
self,
device: Union[DeviceModel, Registers],
converters: Optional[Mapping[str, ConverterValue]],
strict: bool,
require_converters: bool,
) -> None:
self.device = device
self.converters = dict(converters or {})
self.strict = strict
self.require_converters = require_converters
self.group_masks = device.groupMasks or {}
self.bit_masks = device.bitMasks or {}
self.enums = self._build_enums()
Expand Down Expand Up @@ -328,12 +328,12 @@ def _extension(self, symbol: str, ctx: ConverterContext) -> Converter[Any]:
value = self.converters.get(symbol)
if value is not None:
return _materialize(value, ctx)
if not self.strict:
if not self.require_converters:
return IdentityConverter(ctx.element)
raise UnknownConverterError(
f"no converter {symbol!r} in converters=; pass "
f"converters={{{symbol!r}: <Converter or (ctx) -> Converter>}} "
f"or strict=False to decode as the native type"
f"or require_converters=False to decode as the native type"
)

# -- defaults ---------------------------------------------------------
Expand Down Expand Up @@ -517,7 +517,7 @@ def create_registers(
source: str | bytes | DeviceModel | Registers,
*,
converters: Optional[Mapping[str, ConverterValue]] = None,
strict: bool = True,
require_converters: bool = True,
) -> dict[str, type[RegisterBase[Any]]]:
"""Emit runtime register classes from a device schema.

Expand All @@ -530,11 +530,11 @@ def create_registers(
:class:`~harp.protocol.Converter` instance or a factory
``(ctx: ConverterContext) -> Converter`` that builds one from the DSL
context. A custom type with no matching converter raises
``UnknownConverterError`` when ``strict`` (the default); ``strict=False``
decodes it as its native element type instead. A register whose DSL ``visibility``
is ``private`` is emitted with an underscore-prefixed class (``_Reserved0``), as the
generator emits it. Note that the converter symbol for a payload field derives from
its *verbatim* yml key, not the renamed field.
``UnknownConverterError``. Pass ``require_converters=False`` to decode it as its
native element type instead. A register whose DSL ``visibility`` is ``private`` is
emitted with an underscore-prefixed class (``_Reserved0``), as the generator emits
it. Note that the converter symbol for a payload field derives from its *verbatim*
yml key, not the renamed field.
"""
device = source if isinstance(source, Registers) else parse_device_schema(source)
return _Emitter(device, converters, strict).emit()
return _Emitter(device, converters, require_converters).emit()
37 changes: 25 additions & 12 deletions src/packages/harp-device/src/harp/device/schema/_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,34 @@ class DeviceModuleLike(Protocol):
what identifies it is describing a device. Matching structurally accepts both it
and :class:`DeviceModule`, and rejects the common register set, which carries
registers but is not a device.

``DEVICE_NAME`` is required rather than optional, so a generated package always
states the name used for its recordings. A schema declaring none still
builds, since :class:`DeviceModule` declares the member and leaves it empty.
"""

__name__: str
REGISTER_MAP: dict[int, type[RegisterBase[Any]]]
DEVICE_NAME: str
WHO_AM_I: int
REGISTER_MAP: dict[int, type[RegisterBase[Any]]]


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
exist only at runtime. ``REGISTER_MAP``, ``WHO_AM_I`` and ``__all__`` are declared
here and carry their own types.
exist only at runtime. ``DEVICE_NAME``, ``REGISTER_MAP``, ``WHO_AM_I`` and
``__all__`` are declared here and carry their own types.
"""

REGISTER_MAP: dict[int, type[RegisterBase[Any]]]
"""Address -> register class, the common Harp registers merged with those of the schema."""
DEVICE_NAME: str
"""The device name declared by the schema. Empty when absent."""

WHO_AM_I: int
"""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."""

__all__: list[str]
"""The declarations of the schema, beside ``REGISTER_MAP`` and ``WHO_AM_I``."""

Expand All @@ -57,7 +64,7 @@ def create_device_module(
*,
name: Optional[str] = None,
converters: Optional[Mapping[str, ConverterValue]] = None,
strict: bool = True,
require_converters: bool = True,
) -> DeviceModule:
"""Emit a module of register classes from ``device.yml`` text.

Expand All @@ -72,8 +79,12 @@ def create_device_module(
* ``REGISTER_MAP``, the device address space, so the common 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);
* ``__name__``, the ``device`` name of the schema, or ``name`` when given
(``"Device"`` for a header-less register fragment).
* ``DEVICE_NAME``, the ``device`` name of the schema, or ``name`` when given, and
empty for a header-less register fragment. Recordings are written under this
name, so :class:`~harp.data.DatasetReader` matches files by it;
* ``__name__``, the same name, falling back to ``"Device"`` so the module is never
anonymous. This names the module rather than the device, and is not part of what
a device module promises.

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
Expand All @@ -89,9 +100,10 @@ def create_device_module(
behavior.AnalogData
"""
device = parse_device_schema(text)
emitter = _Emitter(device, converters, strict)
emitter = _Emitter(device, converters, require_converters)
registers = emitter.emit()
module_name = name or device.device or _DEFAULT_NAME
device_name = name or device.device or ""
module_name = device_name or _DEFAULT_NAME

contents: dict[str, Any] = {**emitter.enums, **emitter.payloads, **registers}
register_map = {cls.address: cls for cls in CORE_REGISTER_MAP.values()}
Expand All @@ -103,8 +115,9 @@ def create_device_module(
module = DeviceModule(module_name, f"Harp registers for {module_name}, from a schema.")
vars(module).update(
contents,
DEVICE_NAME=device_name,
REGISTER_MAP=register_map,
WHO_AM_I=int(device.whoAmI or 0),
__all__=[*sorted(contents), "REGISTER_MAP", "WHO_AM_I"],
__all__=[*sorted(contents), "DEVICE_NAME", "REGISTER_MAP", "WHO_AM_I"],
)
return module
Loading