Skip to content

Commit 28b7143

Browse files
committed
Add ArrayConverter for array payload members
A payload member spanning several elements had no expressible type. The sub-array dtype IdentityConverter needs carries np.void as its scalar type, so the member resolved as void, and pinning the type parameter instead resolves it as the scalar. ArrayConverter takes the element type and a length and resolves as an NDArray of that element. It is typing-only and builds an IdentityConverter over the same sub-array dtype, so the runtime object is the passthrough converter every code path and isinstance already expects. The runtime emitter uses it for a member longer than one element, so a schema-built module and a generated package agree. harp-benchmarks was the one package src tree outside the pyright include, and no test imports its benchmark module, so three parse_to_dataframe calls still passed timestamp and raised TypeError. They now pass time_index, a masked np.int32 member no longer takes a Python int, and the StartPulseTrain default check supplies the two members that dataclass_transform makes required.
1 parent faae84d commit 28b7143

7 files changed

Lines changed: 72 additions & 14 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ include = [
118118
"src/packages/harp-device/src",
119119
"src/packages/harp-serial/src",
120120
"src/packages/harp-data/src",
121+
"src/packages/harp-benchmarks/src",
121122
"docs/examples",
122123
"tests/conformance.py",
123124
]

src/packages/harp-benchmarks/src/harp/benchmarks/benchmark.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,13 @@ def benchmark_register(reg: BenchmarkedRegister, path: Path, *, runs: int) -> Re
110110
file_bytes=file_bytes,
111111
)
112112
df_pre = _time(
113-
lambda: parse_to_dataframe(register, raw, timestamp=reg.timestamped),
113+
lambda: parse_to_dataframe(register, raw, time_index=reg.timestamped),
114114
runs=runs,
115115
frames=frames,
116116
file_bytes=file_bytes,
117117
)
118118
df_re = _time(
119-
lambda: parse_to_dataframe(register, path.read_bytes(), timestamp=reg.timestamped),
119+
lambda: parse_to_dataframe(register, path.read_bytes(), time_index=reg.timestamped),
120120
runs=runs,
121121
frames=frames,
122122
file_bytes=file_bytes,
@@ -326,7 +326,7 @@ def main() -> None:
326326
f"df={_fmt_ms(res.df_preread.mean):>9s}ms"
327327
)
328328
if args.head:
329-
df = parse_to_dataframe(reg.register, path.read_bytes(), timestamp=True)
329+
df = parse_to_dataframe(reg.register, path.read_bytes(), time_index=True)
330330
print(df.head(5))
331331
print()
332332

src/packages/harp-benchmarks/src/harp/benchmarks/register_models.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from harp.protocol import (
88
AnonymousPayload,
9+
ArrayConverter,
910
BitMask,
1011
BoolConverter,
1112
Converter,
@@ -109,9 +110,7 @@ class AnalogDataPayload(StructPayload[np.float32], length=6):
109110
analog0: np.float32 = Field(IdentityConverter(np.float32), offset=0)
110111
analog1: np.float32 = Field(IdentityConverter(np.float32), offset=1)
111112
analog2: np.float32 = Field(IdentityConverter(np.float32), offset=2)
112-
accelerometer: NDArray[np.float32] = Field(
113-
IdentityConverter(np.dtype((np.float32, (3,)))), offset=3
114-
)
113+
accelerometer: NDArray[np.float32] = Field(ArrayConverter(np.float32, 3), offset=3)
115114

116115

117116
class AnalogData(RegisterBase[AnalogDataPayload]):
@@ -149,9 +148,7 @@ class VersionPayload(StructPayload[np.uint8], length=32):
149148
firmware_version: HarpVersion = Field(HarpVersionConverter(np.uint8), offset=3)
150149
hardware_version: HarpVersion = Field(HarpVersionConverter(np.uint8), offset=6)
151150
core_id: str = Field(StringConverter(3), offset=9)
152-
interface_hash: NDArray[np.uint8] = Field(
153-
IdentityConverter(np.dtype((np.uint8, (20,)))), offset=12
154-
)
151+
interface_hash: NDArray[np.uint8] = Field(ArrayConverter(np.uint8, 20), offset=12)
155152

156153

157154
class Version(RegisterBase[VersionPayload]):
@@ -375,7 +372,7 @@ def main() -> None: # pragma: no cover - manual exploration entry point
375372
assert int(p.header) == 7 and int(p.data) == -1234
376373
print("CustomMemberConverter OK")
377374

378-
p = _roundtrip(BitmaskSplitter, BitmaskSplitterPayload(low=0xA, high=0x5))
375+
p = _roundtrip(BitmaskSplitter, BitmaskSplitterPayload(low=np.int32(0xA), high=np.int32(0x5)))
379376
assert int(p.low) == 0xA and int(p.high) == 0x5
380377
assert p.payload_array.tobytes() == bytes([0x5A])
381378
print("BitmaskSplitter OK")
@@ -411,7 +408,10 @@ def main() -> None: # pragma: no cover - manual exploration entry point
411408
assert p.digital_output == PwmPort.PWM1 and int(p.pulse_width) == 300
412409
assert int(p.frequency) == 200 and int(p.pulse_count) == 50
413410
assert StartPulseTrainPayload.payload_dtype.itemsize == 4
414-
assert int(StartPulseTrainPayload(pulse_count=np.uint8(3)).frequency) == 1 # defaultValue
411+
partial = StartPulseTrainPayload(
412+
digital_output=PwmPort.PWM0, pulse_width=np.uint16(0), pulse_count=np.uint8(3)
413+
)
414+
assert int(partial.frequency) == 1 # defaultValue
415415
print("StartPulseTrain OK (4 masked members, 2 words, default frequency=1)")
416416

417417
p = _roundtrip(EncoderMode, EncoderModeMask.DISPLACEMENT)

src/packages/harp-device/src/harp/device/schema/_emit.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Field,
1616
GroupMask,
1717
HarpVersionConverter,
18+
ArrayConverter,
1819
IdentityConverter,
1920
RegisterBase,
2021
RegisterFloat,
@@ -318,7 +319,9 @@ def _resolve_converter(self, ctx: ConverterContext) -> Converter[Any]:
318319
if ctx.mask is not None:
319320
return IdentityConverter(ctx.member_dtype) # bit-field: native slice of the element
320321
if it is None:
321-
return IdentityConverter(ctx.raw_dtype) # raw passthrough / sub-array
322+
if ctx.length > 1:
323+
return ArrayConverter(ctx.element, ctx.length) # raw passthrough, sub-array
324+
return IdentityConverter(ctx.element) # raw passthrough, single element
322325
# A known primitive that didn't fit is re-interpreted per field (``{Name}Converter``);
323326
# an unknown interfaceType is a domain type (``{InterfaceType}Converter``).
324327
symbol = f"{ctx.name}Converter" if entry is not None else f"{it}Converter"

src/packages/harp-protocol/src/harp/protocol/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from ._message import HarpMessage, HarpParseError
22
from ._message_type import MessageType
33
from ._payload_converters import (
4+
ArrayConverter,
45
BoolConverter,
56
Converter,
67
EnumConverter,
@@ -78,6 +79,7 @@
7879
# Converters
7980
"Converter",
8081
"IdentityConverter",
82+
"ArrayConverter",
8183
"StringConverter",
8284
"BoolConverter",
8385
"EnumConverter",

src/packages/harp-protocol/src/harp/protocol/_payload_converters.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import enum as _enum
22
from abc import ABC, abstractmethod
3-
from typing import Any, Generic, TypeVar, cast
3+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
44
from dataclasses import dataclass
55
import numpy as np
66
from numpy.typing import NDArray
@@ -121,6 +121,33 @@ def __init__(self) -> None:
121121
super().__init__(np.float32)
122122

123123

124+
if TYPE_CHECKING:
125+
126+
class ArrayConverter(Converter["NDArray[NpScalarT]"]):
127+
"""Pass-through converter for a member spanning several elements of one type.
128+
129+
The array counterpart of :class:`IdentityConverter`, decoding to an ``NDArray``
130+
of the element type rather than to a single scalar. It exists to carry that type
131+
and builds an :class:`IdentityConverter` over the equivalent sub-array dtype, so
132+
every code path and every ``isinstance`` sees the passthrough converter it
133+
already knows. Passing that dtype directly instead types the member as a scalar,
134+
since a sub-array dtype carries ``np.void`` as its scalar type.
135+
"""
136+
137+
def __init__(self, dtype: "np.dtype[NpScalarT] | type[NpScalarT]", length: int) -> None: ...
138+
139+
def decode_scalar(self, view: np.generic) -> "NDArray[NpScalarT]": ...
140+
141+
def decode_batch(self, view: "NDArray[np.generic]") -> Any: ...
142+
143+
def encode_into(self, view: "NDArray[np.generic]", value: "NDArray[NpScalarT]") -> None: ...
144+
145+
else:
146+
147+
def ArrayConverter(dtype, length):
148+
return IdentityConverter(np.dtype((dtype, (length,))))
149+
150+
124151
class BoolConverter(Converter[bool]):
125152
"""Whole-element ``interfaceType: bool`` (or a single masked bit via ``Field(BoolConverter(), mask=...)``).
126153

tests/conformance.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@
1212
from harp.device.client import Device, ITransport
1313
from harp.device.core import OperationControl, OperationControlPayload, WhoAmI
1414
from harp.device.schema import DeviceModule, DeviceModuleLike, create_device_module
15-
from harp.protocol import HarpMessage, RegisterBase
15+
from harp.protocol import (
16+
ArrayConverter,
17+
Field,
18+
HarpMessage,
19+
IdentityConverter,
20+
RegisterBase,
21+
StructPayload,
22+
)
1623
from harp.serial import open_device
24+
from numpy.typing import NDArray
1725

1826

1927
def schema_built_registers(yml: str) -> None:
@@ -131,3 +139,20 @@ class Hybrid(Device[None]):
131139

132140
device = open_device(Hybrid, port="COM3")
133141
assert_type(device, Hybrid)
142+
143+
144+
def array_payload_members() -> None:
145+
"""A member spanning several elements resolves as an array of the element type.
146+
147+
The equivalent sub-array dtype passed to IdentityConverter produces the same bytes
148+
but resolves as a scalar, since a sub-array dtype carries np.void as its scalar
149+
type, so ArrayConverter is what carries the element type through the descriptor.
150+
"""
151+
152+
class Payload(StructPayload[np.float32], length=6):
153+
analog0: np.float32 = Field(IdentityConverter(np.float32), offset=0)
154+
accelerometer: NDArray[np.float32] = Field(ArrayConverter(np.float32, 3), offset=3)
155+
156+
payload = Payload(analog0=np.float32(0), accelerometer=np.zeros(3, dtype=np.float32))
157+
assert_type(payload.analog0, np.float32)
158+
assert_type(payload.accelerometer, NDArray[np.float32])

0 commit comments

Comments
 (0)