Refactor package for unified data and device API#5
Conversation
Dev/editable mode
Added ReadS32 and refactored WriteS32 to handle array inputs
Add threaded serial port
update wait-for-events example
|
Hi @bruno-f-cruz, I will check your latest changes as soon as possible. In the meantime, I tried to update the generated-pyharp repo with some support for complex registers. If you have the opportunity to take a look at how are things right now, I appreciate your time. Cheers! |
I took a quick look. I think this example summarizes my concerns: class ComplexConfiguration(IRegister[ComplexConfigPayload]):
spec = RegisterSpec[ComplexConfigPayload](
address=34,
payload_type=PayloadType.U8,
decode=lambda p: ComplexConfigPayload(
pwm_port=p[0],
duty_cycle=struct.unpack_from("<f", bytes(p), 4)[0],
frequency=struct.unpack_from("<f", bytes(p), 8)[0],
events_enabled=p[12] != 0,
delta=int.from_bytes(bytes(p[13:17]), "little"),
name=bytes(p[17:50]).rstrip(b"\x00").decode("utf-8"),
),
encode=lambda v: [
v.pwm_port,
0,
0,
0,
*struct.pack("<f", v.duty_cycle),
*struct.pack("<f", v.frequency),
1 if v.events_enabled else 0,
*v.delta.to_bytes(4, "little"),
*v.name.encode("utf-8").ljust(33, b"\x00"),
],
count=50,
access=RegisterAccess.WRITABLE | RegisterAccess.EVENTFUL,
payload_struct=(
StructField("pwm_port", PayloadType.U8, offset=0),
StructField("duty_cycle", PayloadType.FLOAT, offset=4),
StructField("frequency", PayloadType.FLOAT, offset=8),
StructField("events_enabled", PayloadType.U8, offset=12),
StructField("delta", PayloadType.U32, offset=13),
StructField("name", PayloadType.U8, offset=17, length=33, is_string=True),
),
)payload_struct essentially implements a subset of what this PR implements by using StructField to build numpy dtypes and back the bulk parsing (You should try to implement the full device.yml in the generators tests tho). We can discuss the differences in performance that i saw, but after ensuring parity in decisions across the two libraries (e.g: skip string conversion and do not copy pandas dataframes), the decoding time is virtually the same. This is not surprising since the backend is the same. The points that worry me:
|
|
Hi @bruno-f-cruz, I tried to simplify and unify the registers and have a single point of encode/decode. I believe that this is somewhat similar to your solution. The resulting examples are much simpler and as far as I can see, it solves your main concern on the last point you mentioned regarding the two parsing code paths. The new code in available in the generated-pyharp repo. There are a few things I also tried to add from the new protocol definition from this PR (harp-tech/protocol#215) that weren't possible before. There is still somethings that I didn't start trying to implement (e.g. interfaceType). I wanted to get your feedback before continuing. Also, as a warning, there are most likely things I misinterpreted from the definition. I've also updated the benchmark scripts, although some of the new comparisons aren't really fair with I wanted to compare performance with your implementation, especially regarding Complex registers for bulk reads but I couldn't find a benchmark on your side for that. I might have missed it though. Thanks! |
|
@MicBoucinha can you provide a link to the code you are mentioning, ideally with a pinned SHA? That would be really helpful (at least for me) to compare. What is the goal here? @bruno-f-cruz already has a fully compliant implementation here, so I implemented the generators with CI/CD to track that. Ideally I only want to do the full integration work for one implementation, so I'm a bit lost on which one we are going to go for. Bear in mind we need to support all register formats, including interface types which is why implementing all unit tests is important. But also if we are duplicating work it would probably be good to synchronise on why we are doing so, no? |
|
Hi @glopesdev, Here is the link: github.com/fchampalimaud/generated-pyharp/tree/58bdb5dd827ddb0a1e33eb890c6e900e663d87eb. The goal here was, as talked before, to show a viable option of implementation that doesn't require |
|
@MicBoucinha @glopesdev I have added an internal package to this PR to run benchmarks. I think this is useful to compare notes but also as a regression test harness. To run simply: For comparison with your implementation, I would urge you to target the EXACTLY same set of registers I am targeting since those are the tests used for the generators package. If you have questions let me know! |
|
Hi @bruno-f-cruz. I ran your benchmark on my computer and I got the attached file as a result. At the same time, I updated the generated-pyharp repo (https://github.com/fchampalimaud/generated-pyharp/tree/5f8577f2ebdba044f88401c5d322593e0a42c8c1) with the following:
To run the compare script you can use something similar to: uv run harp-benchmark-compare .\benchmark\report-generated-pyharp.md .\benchmark\report-pyharp-pr5.md -o .\benchmark\comparison.md --label-a generated-pyharp --label-b pyharp-pr5While implementing HarpVersion, I had a question regarding how these types are represented in a single column on the DataFrame, but it seemed to me (please correct me if I'm wrong) that the Hash: list[int] is expanded. I don't think I understood fully when they should be on a single column or expanded so I would appreciate your help in explaining the rationale behind it. Here are the 3 files: We can discuss it further in the meeting. I still feel that I am probably missing something in the interpretation of the interface specification. Cheers! |
|
Hi @bruno-f-cruz, Regarding what we spoke last time on the meeting, here his the explanation on how things are working right now. Everything is based on Single-message parsingSingle-message parsing occurs in 4 steps:
_UNSIGNED_CHARS = {1: "B", 2: "H", 4: "I", 8: "Q"}
def struct_char(self) -> str:
if self.is_float():
return "f"
c = _UNSIGNED_CHARS[self.type_size()]
return c.lower() if self.is_signed() else c
Bulk dump loading (harp-data)Everything also occurs in 4 steps:
def numpy_scalar_dtype(payload_type):
itemsize = int(payload_type.type_size())
if payload_type.is_float():
return np.dtype("<f4")
kind = "i" if payload_type.is_signed() else "u"
return np.dtype(f"<{kind}{itemsize}")
NotesIt is important to note, that Complex registers also go through this. The Both paths derive from the same three Sure, we have two parsing paths but the cost of maintaining this seems small since we have two small functions that base themselves on the same source. It is not like we have two parallel lookup tables or something more complex to maintain here. The big advantage is that we keep |
|
Can you point me to Step 4 for both code paths? This is the thing that I am struggling with being "low cost". I am probably missing something very obvious, but at some point you gotta have a signature: Let
I still don't see how these are 0-cost. But I may be missing a common data representation between the python native types and np.dtypes. For instance, if T is a datastruct, how do you vectorize its parsing? --- edit This is the code fork I was trying to avoid maintaining. I think your argument is that it is small enough that it won't be an issue (?). Still, you are pushing complexity into this pyharp/src/packages/harp-protocol/src/harp/protocol/_payload.py Lines 349 to 356 in d7ed4b7 So I think there are a few issues here:
Anyway, for the sake of moving this forward, I think we should just make the decision of what we are willing to support with the current team/effort and just go with it. As @glopesdev mentioned, we can hopefully always go back and remake it if necessary in a backwards compatible way with the public DSL. |
Refactor package for unified data and device API
Objective
Establish a clean, typed Python API for the Harp protocol that:
Field,BitFlag,GroupMask) that code generators for downstream device packages can emit directly fromdevice.yml(the codegen is NOT part of this PR and likely will not belong in this repository)harp-protocol), device/transport (harp-device+harp-serial), and tabular data (harp-data)Packages
The repo is a
uvworkspace; every member lives undersrc/packages/*with a uniformsrc/-layout, and the top-level project is namedharp. There are four packages:Separation of concerns
Each package owns exactly one concern, depends only on the layers below it, and isolates its heavy third-party dependency so consumers pay only for what they import:
harp-protocolField/BitFlag/GroupMask), and the pandas-freeColumn/to_columns()tabular viewnumpyonlyharp-deviceDevicebase (framing, request/reply, reader thread,read/write), theITransportprotocol, core register definitions,REGISTER_MAPharp-protocolharp-serialSerialTransport+ theopen_serial_devicefactoryharp-device,harp-protocolpyserialharp-dataread_dataframe/to_dataframeColumnsharp-protocolpandasDependency edges:
harp-device→harp-protocolharp-serial→harp-device,harp-protocolharp-data→harp-protocolA downstream device package (e.g.
harp-device-behavior) depends only onharp-protocol; it importsharp-deviceonly if it also wants the live transport,harp-serialonly if it wants serial, andharp-dataonly if it wants DataFrames. The heavy third-party dependencies (pandas,pyserial) live only in the leaf packages that need them, soharp-protocolandharp-devicestay lightweight.Major features/architecture
1. Register DSL (
harp.protocol)RegisterBase[U]is the schema object for a Harp register. Every register class carriesaddress,payload_type, andpayload_classasClassVars (pluslengthfor array registers) and exposes three uniform class-methods:parse(value)HarpMessage(or rawbytes/bytearray/memoryview) → typedUparse_bulk(source, *, parse_timestamp=True)(data, timestamps, message_types, payload)(numpy, zero-copy strided views)format(value?, ...)Readif no value,Writeotherwise)The same register class works regardless of whether the source is a serial port reply, an in-memory buffer, or a
.binfile on disk. No subclassing or reconfiguration is required.Concrete register base-classes for every Harp scalar type are provided (
RegisterU8,RegisterU16, …,RegisterFloat) as well as array variants (RegisterU16Array, …). These use a metaclass so that one-off anonymous registers can be created inline:2. Payload DSL — anonymous (scalar / array) payloads
For scalar and array registers (no internal structure),
PayloadU8,PayloadU16, …,PayloadFloatArrayare provided.RegisterBase.parseunwraps these directly to a numpy scalar or ndarray — i.e. no.valueaccessor needed.NOTE I went with numpy types rather than native python types since these preserve bitdepth and add additional typing information. We should discuss. In general, while the type hinting may break, native python types will still get implicitly cast by numpy.
3. Payload DSL — structured payloads
StructPayload+Field/BitFlag/GroupMaskdescriptors declare the layout of a structured register payload. Under@dataclass_transformthe class gets a fully type-checked keyword-only__init__for free.FieldEach member declares its byte position with
offset=(in base-element units).offsetdefaults to0, which suits a single-member payload; a payload with several members must give each an explicitoffset=or the layout overlap-check rejects it.BitFlag+GroupMask— bit-packed payloadsDescriptor types for registers whose payload packs multiple boolean flags or enum values into a single byte (the
OperationControlpattern common across all Harp devices). The right-shift for aGroupMaskis derived automatically from the mask's trailing-zero count — there is no separateshift=argument.The descriptors are shape-polymorphic: a 0-D (single) parse returns a Python scalar; a 1-D (bulk) parse returns a numpy array. The descriptor code is identical in both cases.
4. Device & transport layer (
harp-device+harp-serial)The device side uses composition over inheritance, split into two independent axes so they multiply without a diamond:
__whoami__, device logic → theDeviceaxisITransportaxisDevice(inharp.device) owns all protocol logic — framing, the request/reply correlation, the reader thread, andread/write— and drives anITransport.ITransportis atyping.Protocol(structural — any object withopen/write/read/closequalifies; failures surface asTransportError). A device must be opened (with/.open()); opening starts the reader thread and validates__whoami__(a class attribute;0x0skips the check).harp-serialprovidesSerialTransport(conforms structurally toITransport) and anopen_serial_devicefactory that — like the builtinopen— returns an already-connected device:A device definition is a pure subclass with no transport concern — exactly what a generator emits:
Because the transport is just an
ITransport, new ones drop in with one implementation + a factory and zero changes to device classes:zeromq— talk to a device (or broker) over a ZMQ socket:open_zmq_device(MyDevice, endpoint=...).http— drive a device behind an HTTP/REST bridge.harp.devicealso exposes aREGISTER_MAP: dict[int, type[RegisterBase[Any]]]of the core registers, which a downstream (generated) package spreads into its own map:5. Data layer (
harp-data)Tabular/DataFrame concerns live in
harp-data. The payload layer (harp-protocol) exposes a pandas-free, numpy-only tabular view viato_columns(), returninglist[Column]:harp-dataturns those columns into a DataFrame — buildingpd.Categorical.from_codesfor enum columns, which keeps the integer codes and copies nothing:This keeps the heavy/optional
pandasdependency out of the protocol and device layers, and givesharp-dataroom to grow into a home for further analysis utilities.Also, this opens the door to future implementations (e.g: arrow), using the same protocol exposed interface.
6. Code-generation affordances
A downstream package for a specific device only needs to emit register/payload class declarations — the entire declaration is mechanical and driven directly by
device.yml:The generator emits one
Field/BitFlag/GroupMaskper payload member, oneRegisterBasesubclass per register, and (for a device package) aDevicesubclass with__whoami__plus a spreadREGISTER_MAP. No per-device framework extension is required; the output is pure application code that composes the DSL, and the constructor is type-hinted for free.