Skip to content

Add ArrayConverter for array payload members - #39

Open
glopesdev wants to merge 1 commit into
refactor-data-apifrom
fix-array-member-typing
Open

Add ArrayConverter for array payload members#39
glopesdev wants to merge 1 commit into
refactor-data-apifrom
fix-array-member-typing

Conversation

@glopesdev

Copy link
Copy Markdown
Contributor

A payload member spanning several elements of one type could not be given an honest annotation. ArrayConverter closes that, and the harp-benchmarks package that exercised the gap is brought under pyright so the next drift is caught by CI.

The member that could not be typed

A payloadSpec member declaring its own length is emitted as a passthrough over a sub-array dtype:

accelerometer: NDArray[np.float32] = Field(IdentityConverter(np.dtype((np.float32, (3,)))), offset=3)

pyright rejects the assignment with Type "void" is not assignable to declared type "NDArray[float32]", because numpy types a sub-array dtype as dtype[void], so the converter type parameter binds to np.void.

Both obvious repairs were measured on a probe and both fail:

  • IdentityConverter[np.float32](np.dtype((np.float32, (3,)))), pinning the parameter explicitly, yields Field[np.float32], and a scalar is not assignable to an array either
  • casting the dtype to np.dtype[np.float32] before passing it does the same thing for the same reason

So the element type and the length have to arrive as separate arguments, because a sub-array dtype has already discarded the element type by the time any overload could inspect it. ArrayConverter(np.float32, 3) does that and resolves as NDArray[np.float32].

Nothing about Field changes. Converter is generic in its decoded type with no bound, and decode_batch already returns Any, so an array-valued converter was expressible all along. StringConverter is the existing precedent: it takes a length, builds the identical sub-array dtype, decodes to a non-scalar, and appears in generated output today as core_id: str = Field(StringConverter(3), offset=9).

Why it routes through IdentityConverter at runtime

ArrayConverter is declared under TYPE_CHECKING and is a factory outside it, returning an IdentityConverter over the equivalent sub-array dtype. The runtime object is therefore the passthrough converter every existing code path already expects, and _payload.py has a zero-line delta.

That matters more than it looks. Sub-array column expansion, the step that renders accelerometer as accelerometer_0, accelerometer_1 and accelerometer_2, is selected by an isinstance check against IdentityConverter. A genuinely separate converter class fails that check, takes the whole-element branch, and hands pandas a 2-D column, which raises ValueError: Per-column arrays must each be 1-dimensional. Routing to the same runtime class avoids introducing that divergence at all rather than teaching each site about a second converter.

Measured after the change: the constructed object is an IdentityConverter, its dtype equals the direct spelling at itemsize 12, all fourteen registers round-trip, and harp-benchmark --head still produces six columns for AnalogData.

The cost is that isinstance(x, ArrayConverter) raises outside type checking, and the declared signature has to be kept in step with the factory by hand. Nothing in the repository does either, and the two sit adjacent in the same module.

The runtime emitter now uses it for a member longer than one element, so a module built from a schema and a generated package resolve the same member the same way.

Checking harp-benchmarks

harp-benchmarks/src was the one package src tree absent from the pyright include, and no test imports its benchmark module, only register_models. Three parse_to_dataframe calls would still have passed timestamp and raised TypeError at runtime; they now pass time_index. Two further errors in that package were real rather than noise: a masked np.int32 member was given a Python int, and the StartPulseTrain default check omitted two members that @dataclass_transform makes required. Adding the tree to the include is what catches the next rename.

The tests/conformance.py fixture enforces the typed contract. If a change causes an array member to revert to a scalar value without warning, the build process will fail.

What this does not change

tests/device/expected_device.py keeps its two array errors, because it is a byte copy of generator output and resyncs by copying rather than by hand. It sits outside the pyright include, so CI is unaffected either way. Those clear once the Python generator target emits ArrayConverter, which is the follow-up this unblocks and the point at which real device packages benefit.

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.
@glopesdev
glopesdev requested a review from bruno-f-cruz August 22, 2026 01:33
@glopesdev glopesdev added the fix Pull request that fixes an issue label Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull request that fixes an issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant