Skip to content

Add ArrayConverter for array payload members - #39

Merged
glopesdev merged 1 commit into
mainfrom
fix-array-member-typing
Aug 22, 2026
Merged

Add ArrayConverter for array payload members#39
glopesdev merged 1 commit into
mainfrom
fix-array-member-typing

Conversation

@glopesdev

@glopesdev glopesdev commented Aug 22, 2026

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.

Preserving the runtime identity is important. 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 would fail that check, taking the whole-element branch, and handing pandas a 2D column, which would end up raising ValueError: Per-column arrays must each be 1-dimensional. Routing to the same runtime class avoids introducing that divergence at all.

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.

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 were found in that package: 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 will prevent future drift.

The tests/conformance.py fixture also 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. Once the Python generator target emits ArrayConverter, those should clear.

@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
@glopesdev
glopesdev force-pushed the fix-array-member-typing branch from 2b1bf53 to 28b7143 Compare August 22, 2026 19:43
Base automatically changed from refactor-data-api to main August 22, 2026 19:57
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 force-pushed the fix-array-member-typing branch from 28b7143 to 514c4d9 Compare August 22, 2026 19:57
@glopesdev
glopesdev merged commit a6f5073 into main Aug 22, 2026
13 checks passed
@glopesdev
glopesdev deleted the fix-array-member-typing branch August 22, 2026 21:38
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.

2 participants