Dalaran parquet ingestion
ParquetReader is a pure reader: it maps a flat table onto the Dalaran
model by turning raw columns into grouped, time-indexed chunks of struct and
scalar components. Column-name prefixes become entities, grouped columns
become a single struct component, designated columns become timelines. The
reader does not assemble archetypes anymore — mapping struct fields into
typed Dalaran components (Transform3D, Scalars, Points3D) is done with lenses on
the reader's .stream(). The whole reader job is configuration; fill in the
dalaran-data-model mapping table first, then express it through stream().
Stream mechanics after .stream() are in dalaran-chunk-processing.
The whole table is configuration, not code. If you find yourself building
Chunk.from_columns from a parquet, or munging it in pandas first, stop —
ParquetReader plus a lens almost certainly expresses it. Anything the reader
cannot express (per-row entity routing, derived values, unit conversion)
belongs in lenses downstream, not in pre-pandas munging; keep the pipeline
columnar.
The API
from dalaran.experimental import ParquetReader, DeriveLens, IndexColumn
reader = ParquetReader(table_path) # a lightweight handle over the file
stream = reader.stream(
entity_path_prefix="/world", # prepended to every entity path
column_grouping="prefix", # "prefix" | "individual" | "explicit_prefixes"
delimiter="_", # split for column_grouping="prefix"
prefixes=None, # required for "explicit_prefixes"
use_structs=True, # pack grouped columns into one struct component
static_columns=["robot_type"], # constant-per-file values, logged static
index_columns=[IndexColumn.timestamp("timestamp", input_unit="us"), IndexColumn.sequence("frame_index")],
)
The constructor takes only path; all loading options live on stream(), and every one is keyword-only.
Each stream() call is independent, so one reader can drive several differently-configured streams over the same file.
The config is validated against the file's schema at stream() (missing index/static columns raise ValueError there, not mid-iteration).
There is no column_rules kwarg — typed-component assembly moved to lenses (below).
What the reader emits
The reader turns the table into chunks, one chunk per group, then leaves the data as generic struct/scalar components for lenses to map. The naming is the key thing the rest of the pipeline keys off:
A grouped multi-column prefix
X→ entity/X, with a single struct component nameddata. The struct's fields are the column names with the prefix (and delimiter, for"prefix") stripped. SoA_pos_x,A_quat_wunder prefixAland as structdatawith fieldspos_x,quat_won entity/A.A lone column with no group → its own entity named after the column, and a raw component named after the column — not a
datastruct. So aspeedcolumn becomes entity/speed, componentspeed.A
/__propertiesmetadata chunk built from the parquet file's schema metadata. You typically drop it right after.stream():stream = reader.stream().drop(content="/__properties/**")
Column grouping: which columns share an entity
"prefix"(default): split each column name ondelimiter, group by the first segment.gripper_pos_x,gripper_pos_y→ entity/gripper, structdata{pos_x, pos_y}."explicit_prefixes": group by the exact strings inprefixes, tried longest-first; the prefix is stripped from each struct field name (a raw string match, no delimiter —foo+a→ fielda). Columns matching no prefix become individual groups. Use this when names contain the delimiter ambiguously (observation.statevsobservation.images.top: pass the full prefixes)."individual": every column is its own chunk/entity with a raw component named after the column — no struct packing at all, even for columns sharing a prefix.use_structsis ignored here. Rarely the model you want; reach for it only as a debugging baseline.
use_structs=True (default) packs a group's columns into a single Arrow
struct component (the data field) for "prefix"/"explicit_prefixes";
False emits one component per column (the pre-struct flat layout, what
queries see as separate columns).
Timelines: index_columns
Each entry is built with IndexColumn: IndexColumn.timestamp(name, input_unit=...) (since epoch), IndexColumn.duration(name, input_unit=...) (elapsed), or IndexColumn.sequence(name) (ordinal int).
input_unitdescribes what the raw integers in the column are ("ns"default,"us","ms","s"); Dalaran rescales to ns internally. Sequences take no unit.
If omitted, a synthetic row_index sequence timeline is generated. That
is almost never the timeline you want to query or align against; always name
the real time columns. Stamp both a timestamp and a sequence timeline when the
table has both (multi-rate alignment, see dalaran-data-model).
Static columns: static_columns
Listed columns are constant across all rows; they are emitted once as a single static (timeless) chunk, separate from the temporal data. A listed column that actually varies raises an error when the stream runs — that error is a data-quality signal, not a reason to drop the static declaration.
Typed components via lenses
The reader's grouped output is generic struct (data) and scalar data. A
DeriveLens reads that struct's fields, packs and casts them into real Dalaran
components, and writes them to an output entity — this is what the old
column_rules API used to do, now done downstream on the stream.
Construct a lens against the reader's struct component ("data" for grouped
prefixes, or the column name for a lone/individual column), then add one or
more .to_* builder methods. Each builder returns a fresh lens, so they chain.
| Builder | Produces | Argument order |
|---|---|---|
to_translation(x, y, z) |
Transform3D:translation |
x, y, z |
to_quaternion(x, y, z, w) |
Transform3D:quaternion |
x, y, z, w (xyzw) |
to_scale(x, y, z) |
Transform3D:scale |
x, y, z |
to_rotation_axis_angle(axis_x, axis_y, axis_z, angle) |
Transform3D:rotation_axis_angle |
axis_x, axis_y, axis_z, angle (radians) |
to_scalars(*fields) |
Scalars:scalars |
one or more field names |
to_packed_component(component, *fields) |
the given component | descriptor, then field names |
to_component(component, selector) |
the given component | descriptor, then a Selector |
to_timeline(name, type, selector) |
a timeline (not a component) | name, "sequence"/"duration_ns"/"timestamp_ns", selector |
to_packed_component packs the named struct fields (in order, at least one
required) into the fixed-size list the component expects, and by default
auto-casts f64→f32 to match component types. The to_translation,
to_quaternion, to_scale helpers are convenience wrappers over it, so they
all auto-cast. to_rotation_axis_angle builds a Struct{axis, angle} and
hard-casts axis and angle to f32 internally. to_scalars with a single field
emits a plain scalar per row (not a 1-element list); with multiple fields it
emits one scalar series per field at the same entity.
Apply lenses with .stream().lenses([lens], content="/A", output_mode="drop_unmatched"):
contentis a pre-filter on the source entity path — it scopes which chunks the lens may touch. Out-of-scope chunks pass through unchanged. Set it to the reader's grouped entity (e.g."/A").output_modedecides the fate of in-scope-but-unmatched chunks:"drop_unmatched"(default, keep only lens output),"forward_unmatched"(output replaces matched, other originals survive), or"forward_all"(output plus all originals).- The lens's own
output_entity=sets the destination entity — independent ofcontent, which gates the input side.
End-to-end Transform3D example. The reader groups A_* columns into a data
struct at /A; the lens reads the prefix-stripped field names (pos_x,
quat_w), packs and casts them, and writes a full Transform3D to /pose:
from dalaran.experimental import DeriveLens, IndexColumn, ParquetReader
lens = (
DeriveLens("data", output_entity="/pose")
.to_translation("pos_x", "pos_y", "pos_z")
.to_quaternion("quat_x", "quat_y", "quat_z", "quat_w")
)
chunks = (
ParquetReader(table_path)
.stream(index_columns=[IndexColumn.sequence("frame_index")])
.lenses([lens], content="/A", output_mode="drop_unmatched")
.to_chunks()
)
Chaining several .to_* on one lens with a shared output_entity
accumulates multiple component columns into the same archetype at that entity —
above, both Transform3D:translation and Transform3D:quaternion land on
/pose, forming a complete Transform3D. For a generic fixed-size-list
component, pass the descriptor to to_packed_component:
import dalaran as rr
from dalaran.experimental import DeriveLens, ParquetReader
lens = DeriveLens("data", output_entity="/points").to_packed_component(
rr.Points3D.descriptor_positions(), "x", "y", "z"
)
Selectors
Lens field paths use Selector, a jq-like grammar over Arrow columns
(.field to access a struct field, [] to iterate a list, [N] to index, ?
to suppress errors on absent fields, ! to assert non-null, | to pipe, and
pack(.x, .y, .z) to zip paths into a fixed-size list). The to_* helpers
build these selectors for you; reach for to_component(component, Selector(".x"))
when you need a custom field path. Field paths reference the
prefix-stripped struct field names — the lens sees pos_x, not A_pos_x.
Gotchas
- No
index_columns→ syntheticrow_indextimeline only. Queries that expect a timestamp timeline find nothing. - The
unitis the raw column's unit, not a desired output unit; a microsecond column declared"ns"lands 1000x in the past. static_columnsraises if a listed column actually varies; that error is a data-quality signal, not a reason to drop the static declaration. Uniformity is data-dependent, so it is raised lazily when the stream runs; a listed column that doesn't exist raises eagerly atstream().- A grouped prefix's struct component is named
data— that is theinput_componentstring aDeriveLensmatches against. A lone or"individual"column is instead a raw component named after the column. - Selector field paths reference the prefix-stripped struct field names
(
pos_x, notgripper_pos_x). - Drop the
/__propertiesmetadata chunk the reader emits from parquet schema metadata:.stream().drop(content="/__properties/**"). - Quaternion column order is x, y, z, w in
to_quaternion; check the source's convention before wiring fields. to_packed_component(and the transform helpers built on it) auto-castsf64→f32to match component types; this is usually what you want for parquet's double columns.- Anything the reader cannot express (per-row entity routing, derived values,
unit conversion) belongs in lenses downstream, not in pre-pandas munging;
keep the pipeline columnar (
dalaran-chunk-processing).
References
- Lens builder source with full docstrings:
dalaran/experimental/_lens.pyin the installeddalaran-sdkpackage (to_translation,to_quaternion,to_scale,to_rotation_axis_angle,to_scalars,to_packed_component,to_component,to_timeline). - Reader source:
dalaran/experimental/_parquet_reader.py, orpython -c "from dalaran.experimental import ParquetReader; help(ParquetReader)" - Canonical worked examples: the integration tests
dalaran_py/tests/integration/test_parquet_reader.py(grouping, index/static columns, and the Transform3D / Points3D / Scalars lens flows) anddalaran_py/tests/integration/test_lazy_chunk_stream.py(lens application,content/output_mode, selectors). dalaran-lerobot— LeRobot datasets store episodes as parquet; that skill covers the built-in importer route vs reading the parquet directly with this reader.dalaran-data-model(mapping decisions),dalaran-chunk-processing(stream mechanics after.stream())