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
10 changes: 7 additions & 3 deletions src/hflow/episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ def _is_numeric_sequence(value: Any) -> bool:
if isinstance(value, np.ndarray):
return value.size > 0 and value.dtype.kind in "iuf"
if isinstance(value, (list, tuple)):
return len(value) > 0 and _is_numeric_scalar(value[0])
for item in value:
if item is not None:
return _is_numeric_scalar(item)
Comment on lines +139 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mixed list stops Arrow export

A JSON list like [null, 1.0, {"note": "bad"}] is now marked numeric because its first non-null item is a number. If every sample of that field has this shape, to_arrow() used to skip the nested field. Now it sends the mixed list to Arrow, which cannot make a numeric list from it, so the caller loses the whole table. Check the later items before marking the list numeric.

Knowledge Base Used: Episode storage and identity

Comment on lines +139 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Automatic NumPy choice fails

_is_numeric_sequence also helps to_numpy() choose a field. When the first message has a nullable position list like [None, 2.0] and a numeric scalar field, the new check makes to_numpy() pick position instead of the scalar. NumPy gives that list an object dtype, which to_numpy() rejects, so a call that could return the scalar now fails. Keep the Arrow change from altering this choice unless NumPy can use the list.

Knowledge Base Used: Episode storage and identity

return False


Expand All @@ -154,7 +156,9 @@ def _is_arrow_scalar(value: Any) -> bool:
def _is_empty_sequence(value: Any) -> bool:
if isinstance(value, np.ndarray):
return value.size == 0
return isinstance(value, (list, tuple)) and len(value) == 0
if isinstance(value, (list, tuple)):
return len(value) == 0 or all(item is None for item in value)
Comment on lines +159 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Field error loses its name

An all-null list now counts as having no shape during the column check. If another message has a scalar in the same field, to_arrow() skips its error that names the topic and field, then passes both values to Arrow. The caller loses the useful field-specific error, making the bad recording harder to fix. Keep that error for this mixed shape.

Knowledge Base Used: Episode storage and identity

return False


def _arrow_field_values(messages: Sequence[Any]) -> dict[str, list[Any]]:
Expand Down Expand Up @@ -213,7 +217,7 @@ def _arrow_column_values(topic: str, field_name: str, values: Sequence[Any]) ->
# An empty numeric array keeps its dtype, so it types the column.
saw_list = True
elif _is_empty_sequence(value):
# `[]` carries no element type; an empty numeric ndarray above
# `[]` or an all-null sequence carries no element type; an empty numeric ndarray above
# does. Keep the slot null unless a typed sample arrives.
continue
else:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,46 @@ def test_empty_channel_to_arrow(tmp_path: Path) -> None:
assert table.num_rows == 0
assert table.column_names == ["log_time_ns"]
assert table.schema.field("log_time_ns").type == pyarrow.int64()


def test_channel_to_arrow_numeric_list_with_null_elements() -> None:
import json

import numpy as np

from hflow.episode import ChannelData
from hflow.reader import TopicInfo

info = TopicInfo(
topic="/arm/joint_state",
channel_id=1,
schema_name="JointState",
schema_encoding="jsonschema",
message_encoding="json",
message_count=4,
schema_data=b"{}",
)
cd = ChannelData(
topic="/arm/joint_state",
channel_id=1,
info=info,
log_times=np.array([1000, 2000, 3000, 4000], dtype=np.int64),
publish_times=np.array([1000, 2000, 3000, 4000], dtype=np.int64),
raw=[
b'{"position": [1.0, 2.0, null], "untyped": [null, null]}',
b'{"position": [null, 2.0, 3.0], "untyped": [null, null]}',
b'{"position": [null, null, null], "untyped": [null, null]}',
b'{"position": [1.0, 2.0, 3.0], "untyped": [null, null]}',
],
decoder=lambda b: json.loads(b.decode()),
)
table = cd.to_arrow()
assert "position" in table.column_names
# Untyped all-null list column carries no element type and is omitted like an empty list
assert "untyped" not in table.column_names
assert table["position"].to_pylist() == [
[1.0, 2.0, None],
[None, 2.0, 3.0],
[None, None, None],
[1.0, 2.0, 3.0],
]
Loading