Dense trajectory (.rdt) format
On this page
A .rdt file is an immutable joint program: a JSON header followed by fixed-size binary samples, one block per motion segment. The weld planner writes it, OLP and the dense trajectory daemon carry it, and rt-control admits it with prepare_program. The schema name is robot.v4.dense-joint-trajectory.v1.
Three codecs implement the format and are tested against one reference fixture, byte for byte:
| Language | Role | Source |
|---|---|---|
| Python | Encoder (the weld planner) | weld_planner/v1/python/weldplan/dense_joint_trajectory.py |
| Go | Decoder and encoder (OLP, rt-control) | offline-programming/v1/internal/densejointtraj/, rt-core/adapters/rosie/densejointtraj/ |
| C++ | Decoder (the dense trajectory daemon) | motion-server/joint-trajectory/v1/src/dense_joint_trajectory_format.hpp |
Write a file#
The Python encoder validates the plan with the same rules the decoders apply, then packs it. Run this inside the weld planner's pixi environment (pixi shell in weld_planner/v1):
from weldplan.dense_joint_trajectory import (
DensePlan, DensePlanIdentity, DenseSegment, encode,
)
rest = [0.0] * 9 # J1..J9, rad
hold = DenseSegment(
kind="dwell", source_id="hold-1",
t_s=[0.0, 0.01, 0.02], # segment-local clock, s
q_rad=[rest] * 3, qd_rad_s=[rest] * 3,
torch_on=[False] * 3,
)
plan = DensePlan(
identity=DensePlanIdentity(
plan_id="demo:1", program_id="demo",
program_digest="sha256:" + "0" * 64,
manifest_revision=1, plan_revision=1,
created_at="2026-09-29T00:00:00Z",
),
segments=[hold],
)
blob = encode(plan) # raises DenseJointTrajectoryError(reason, detail, segment_index)
open("hold.rdt", "wb").write(blob)dt_s (0.01 s), axis_mask (511) and the per-axis velocity ceiling (3.0 rad/s) default to the values in weld_planner/v1/data/dense_joint_trajectory.params.json. Pass dt_s, axis_mask and max_abs_qd_rad_s to DensePlan to override them.
Read a file#
import json, struct
blob = open("hold.rdt", "rb").read()
(header_len,) = struct.unpack(">I", blob[:4])
header = json.loads(blob[4:4 + header_len])
body = blob[4 + header_len:]
for seg in header["segments"]:
block = body[seg["block"]["byte_offset"]:][:seg["block"]["byte_length"]]
t, *rest = struct.unpack(">19dB", block[:153]) # first record
q, qd, flags = rest[:9], rest[9:18], rest[18]
print(seg["index"], seg["kind"], seg["sample_count"], t, q[0], flags)Blob layout#
[u32 BE header_len][UTF-8 JSON header, header_len bytes][block 0][block 1]...[block S-1]- All binary fields are big-endian.
- There is one block per segment, contiguous and in segment order.
byte_offsetcounts from the end of the JSON header. - The blocks must cover the body exactly: no gap, no trailing bytes.
Sample record#
Every block is an array of 153-byte records (encoding f64-be-aos.v1):
| Offset | Field | Type | Unit | Notes |
|---|---|---|---|---|
| 0 | t_s | f64 | s | Segment-local. Starts at 0.0 in every segment. |
| 8 | q_rad[9] | 9 × f64 | rad | J1..J9 positions |
| 80 | qd_rad_s[9] | 9 × f64 | rad/s | J1..J9 velocities. Mandatory in v1. |
| 152 | flags | u8 | — | Bit 0 = torch_on. Bits 1–7 are reserved and must be 0. |
The nine columns are J1–J6 (arm), J7 and J8 (positioner tables) and J9 (the H-frame turn), in that order. A six-axis robot still uses nine columns and says which ones it commands in axes.axis_mask: 0x1ff for rosie_1400_v3, 0x3f for rosie_1420_v1.
At 0.01 s per sample, 18,000 samples (3 minutes) is about 2.75 MB. The 250,000-sample ceiling is about 38 MB.
Header#
{
"schema": "robot.v4.dense-joint-trajectory.v1",
"plan_id": "demo:1",
"program_id": "demo",
"program_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"trajectory_digest": "sha256:…",
"manifest_revision": 1,
"plan_revision": 1,
"created_at": "2026-09-29T00:00:00Z",
"axes": {"axis_count": 9, "axis_mask": 511, "position_unit": "rad", "velocity_unit": "rad_s"},
"sampling": {"dt_s": 0.01, "total_sample_count": 3, "total_duration_s": 0.02},
"limits": {"max_abs_qd_rad_s": [3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0]},
"segments": [
{"index": 0, "kind": "dwell", "source_id": "hold-1", "sample_count": 3, "duration_s": 0.02,
"block": {"byte_offset": 0, "byte_length": 459, "sha256": "sha256:…"}}
],
"sample_encoding": {"encoding": "f64-be-aos.v1", "record_bytes": 153}
}| Field | Type | Required | Description |
|---|---|---|---|
schema | string | yes | Exactly robot.v4.dense-joint-trajectory.v1 |
plan_id | string | yes | Plan identity. The weld planner writes <program_id>:<first 12 hex of the request digest>. Must be non-empty for rt-control. |
program_id | string | yes | Program identity. Must be non-empty for rt-control. |
program_digest | string | yes | sha256:<64 hex>. The weld planner writes the SHA-256 of the .weldplan request the plan was made from. Must be non-empty for rt-control. |
trajectory_digest | string | yes | The content digest, sha256:<64 hex>. Recomputed and compared on every decode. |
manifest_revision | integer (u64) | yes | Must be a JSON integer, not a string |
plan_revision | integer (u64) | yes | Must be a JSON integer, not a string |
created_at | string | yes | RFC 3339 timestamp |
axes.axis_count | integer | yes | Must be 9 |
axes.axis_mask | integer | yes | One bit per commanded column, J1 = bit 0 |
axes.position_unit | string | yes | rad |
axes.velocity_unit | string | yes | rad_s |
sampling.dt_s | number | yes | The sample period in s. Must be > 0. The planner uses 0.01. |
sampling.total_sample_count | integer | yes | Sum of every segment's sample_count. At most 250,000. |
sampling.total_duration_s | number | yes | Sum of the segment durations, s |
limits.max_abs_qd_rad_s | 9 numbers | yes | Per-axis velocity ceiling in rad/s. Every sample's |qd| must be at or below it. Nine finite positive values. |
segments[] | array | yes | At least one segment; see below |
sample_encoding.encoding | string | yes | f64-be-aos.v1 |
sample_encoding.record_bytes | integer | yes | 153 |
robot_cell | object | no | What the plan was made against; see Robot and cell identity |
process_markers | array | no | Output intents attached to exact samples; read by rt-control only. See Admission by rt-control. |
Decoders ignore unknown keys. That is the forward-compatibility rule. The identity fields are not part of the content digest; they are matched separately when a program is loaded and started.
Segments#
| Field | Type | Description |
|---|---|---|
index | integer | Must equal the segment's position in the list |
kind | string | freespace, weld or dwell |
source_id | string | The planner object this segment came from, for tracing |
sample_count | integer | At least 2 |
duration_s | number | Must equal the segment's last t_s, within 1e-9 s |
block.byte_offset | integer | Offset from the end of the JSON header. Must be the running sum of the earlier blocks. |
block.byte_length | integer | sample_count × 153 |
block.sha256 | string | sha256:<64 hex> over this block's bytes exactly. Checked before any sample is parsed. |
Each segment has its own clock. The executor runs a segment to its end, holds the last position with zero velocity, then starts the next. It never interpolates across a boundary. Holds between segments are explicit dwell segments, not gaps. For that reason:
- the first and last sample of every segment must be at rest: |qd| ≤ 1e-6 rad/s on every axis
- each segment must start within 1e-3 rad of where the previous one ended, on every axis
Robot and cell identity#
The weld planner writes a robot_cell block naming the robot and cell the plan was made for:
| Field | Description |
|---|---|
model_id | Robot model, for example rosie_1400_v3 |
robot_description_sha256 | Identity of the robot description, sha256:<64 hex> |
machine_planning_calibration_sha256 | Identity of the cell's machine_planning_calibration.json |
machine_planning_calibration_source | target (read from the cell) or empty (the model's empty calibration) |
machine_configuration_sha256 | Optional. sha256: + the rt-core configuration digest the plan was made for. Recorded only. |
OLP's Load compares the first three with the selected cell before it acquires anything. A mismatch is refused with robot_cell_mismatch, a plan without the block with robot_cell_missing, and a cell that cannot say which robot it is with robot_cell_unavailable. A different machine_configuration_sha256 does not refuse the load; OLP returns a note instead. The dense trajectory daemon and rt-control do not read this block.
Content digest#
trajectory_digest is SHA-256 over a versioned layout, written "sha256:" + lowercase hex. Integers are u64 big-endian, an f64 is the u64 big-endian of its IEEE-754 bits, and a string is its u64 length followed by its UTF-8 bytes.
"robot.v4.dense-joint-trajectory.v1" 0x00
u64 axis_count (9)
u64 axis_mask
f64 dt_s
u64 segment_count
for each segment, in order:
str kind
str source_id
u64 sample_count
the segment's block bytes, exactly as stored
u64 9
f64 max_abs_qd_rad_s[0..8]The digest covers what the trajectory is, not what it is called: the identity strings (plan_id, program_id and so on) and robot_cell are not in it. v1 defines no optional sections. The layout reserves a tagged section after each block (u8 tag, u64 length, content) so that a later weld-process table can be added without changing existing digests.
Validation#
Every decoder refuses a blob that breaks any of these rules, and names the first rule it hits. The segment_index is given when one segment is at fault.
| Reason | Rule |
|---|---|
header_truncated | The blob is shorter than 4 bytes, or header_len runs past its end |
header_json_invalid | The header is not valid JSON. The C++ daemon also uses it for a missing or mistyped required field. |
schema_mismatch or dense_schema_mismatch | schema is not robot.v4.dense-joint-trajectory.v1. rt-control reports dense_schema_mismatch; the OLP and daemon codecs report schema_mismatch. |
axis_count_mismatch | axes.axis_count is not 9 |
sample_encoding_mismatch | The encoding is not f64-be-aos.v1 with 153-byte records |
limits_invalid | max_abs_qd_rad_s is not nine finite positive numbers |
time_grid_invalid | dt_s ≤ 0; a segment's first t_s is not 0; or a step differs from dt_s by more than 1e-9 s |
segments_empty | No segments |
segment_index_out_of_order | An index differs from the segment's position |
kind_invalid | kind is not freespace, weld or dwell |
segment_too_short | Fewer than 2 samples |
block_layout_invalid | Offsets are not contiguous, or a length is not sample_count × 153 |
blob_length_mismatch | The blocks do not exactly cover the body |
block_sha256_mismatch | A block's bytes do not match its sha256. Checked before its samples are parsed. |
reserved_flags_set | A flag bit other than bit 0 is set |
total_sample_count_mismatch | total_sample_count differs from the sum of the segments |
sample_count_overflow | More than 250,000 samples |
nonfinite_sample | A NaN or infinity in t_s, q_rad or qd_rad_s |
qd_limit_exceeded | A sample's |qd| is above max_abs_qd_rad_s for its axis |
torch_outside_weld | The torch bit is set in a segment that is not weld |
q_step_exceeded | A position step is larger than 1.25 × limit × dt_s on some axis |
boundary_qd_nonzero | A segment's first or last sample has |qd| above 1e-6 rad/s |
boundary_q_discontinuity | A segment starts more than 1e-3 rad from where the previous one ended |
duration_mismatch | duration_s differs from the segment's last t_s by more than 1e-9 s |
trajectory_digest_mismatch | The recomputed content digest differs from trajectory_digest |
The C++ daemon checks the content rules before the digest, and rt-control checks the digest first. When a blob breaks several rules, different components can name different ones. Don't rely on the order.
Format validity is not permission to move. It is necessary, not sufficient.
Admission by rt-control#
prepare_program (POST /v1/program) decodes the blob with the rules above, then adds its own checks against the live machine. See prepare_program.
- Size. The JSON header may be at most 1,048,576 bytes, and the whole body at most 1,048,580 + 250,000 × 153 bytes. Larger requests are refused as too large.
- Axis map.
rt-controlmaps the wire columns, in order, to the axes in its own description whose position unit is rad. A six-axis cell maps J1–J6, a nine-axis cell all nine. More mapped axes than nine, or none, is refused withdense_axis_map_requires_nine_ids. A column past the map must not be set inaxis_mask, must hold one position throughout and must have zero velocity. Otherwise the program is refused withunmapped_dense_axis. - Stationary seams. Where one segment ends at rest and the next starts at rest, the two boundary samples become one shared sample, so the executed program has
samples − segments + 1points. The adapter returns both identities:source_digest(the file's content digest) andnormalised_digest(the executed points).start_programmust present both. - Native limits. Every sample must be inside the axis's position limits and below its described velocity. Each interval, interpolated as a cubic Hermite from
qandqd, must stay inside the limits and below the lower of the axis ceiling and the header'smax_abs_qd_rad_s. If the axis describes a maximum acceleration, the finite-difference acceleration is checked too. These checks use no interpolation slack. Refusals includenative_limit_exceeded,native_segment_rate_exceeded,outside_limits_outwardandsegment_boundary_discontinuous. - Identity.
plan_id,program_idandprogram_digestmust be non-empty (program_identity_missing). - Process outputs. A set torch bit, or any
process_markersentry, marks the program as requiring process I/O. Torch output is refused everywhere in this release. OLP's dry-run Load strips the torch bits before upload. See Process I/O and sensing.
The full list of reasons is in Error codes.
Tools#
rt-core/tools/rdtcheckdecodes.rdtfiles against a single-axis bench description and prints a boundary report: per-boundary position gaps, stationarity and the adapter's verdict. It never sends motion. Rungo run ./tools/rdtcheck [--root DIR] [--json OUT] FILE.rdt...fromrt-coreon Linux.- The weld planner's motion server serves each
.rdtit wrote atGET /api/motion/dense/{trajectory_digest}. See the Weld planner HTTP API. - The dense trajectory daemon's
validaterequest runs every rule above without storing the blob. See TCP ingest.