Advanced Metal Research
GitHub Contact AMR

Dense trajectory (.rdt) format

On this page
  1. Write a file
  2. Read a file
  3. Blob layout
  4. Sample record
  5. Header
  6. Segments
  7. Robot and cell identity
  8. Content digest
  9. Validation
  10. Admission by rt-control
  11. Tools
  12. Related pages

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:

LanguageRoleSource
PythonEncoder (the weld planner)weld_planner/v1/python/weldplan/dense_joint_trajectory.py
GoDecoder 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):

make_hold.pyPython
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#

read_header.pyPython
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_offset counts 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):

OffsetFieldTypeUnitNotes
0t_sf64sSegment-local. Starts at 0.0 in every segment.
8q_rad[9]9 × f64radJ1..J9 positions
80qd_rad_s[9]9 × f64rad/sJ1..J9 velocities. Mandatory in v1.
152flagsu8—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.

{
  "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}
}
FieldTypeRequiredDescription
schemastringyesExactly robot.v4.dense-joint-trajectory.v1
plan_idstringyesPlan identity. The weld planner writes <program_id>:<first 12 hex of the request digest>. Must be non-empty for rt-control.
program_idstringyesProgram identity. Must be non-empty for rt-control.
program_digeststringyessha256:<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_digeststringyesThe content digest, sha256:<64 hex>. Recomputed and compared on every decode.
manifest_revisioninteger (u64)yesMust be a JSON integer, not a string
plan_revisioninteger (u64)yesMust be a JSON integer, not a string
created_atstringyesRFC 3339 timestamp
axes.axis_countintegeryesMust be 9
axes.axis_maskintegeryesOne bit per commanded column, J1 = bit 0
axes.position_unitstringyesrad
axes.velocity_unitstringyesrad_s
sampling.dt_snumberyesThe sample period in s. Must be > 0. The planner uses 0.01.
sampling.total_sample_countintegeryesSum of every segment's sample_count. At most 250,000.
sampling.total_duration_snumberyesSum of the segment durations, s
limits.max_abs_qd_rad_s9 numbersyesPer-axis velocity ceiling in rad/s. Every sample's |qd| must be at or below it. Nine finite positive values.
segments[]arrayyesAt least one segment; see below
sample_encoding.encodingstringyesf64-be-aos.v1
sample_encoding.record_bytesintegeryes153
robot_cellobjectnoWhat the plan was made against; see Robot and cell identity
process_markersarraynoOutput 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#

FieldTypeDescription
indexintegerMust equal the segment's position in the list
kindstringfreespace, weld or dwell
source_idstringThe planner object this segment came from, for tracing
sample_countintegerAt least 2
duration_snumberMust equal the segment's last t_s, within 1e-9 s
block.byte_offsetintegerOffset from the end of the JSON header. Must be the running sum of the earlier blocks.
block.byte_lengthintegersample_count × 153
block.sha256stringsha256:<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:

FieldDescription
model_idRobot model, for example rosie_1400_v3
robot_description_sha256Identity of the robot description, sha256:<64 hex>
machine_planning_calibration_sha256Identity of the cell's machine_planning_calibration.json
machine_planning_calibration_sourcetarget (read from the cell) or empty (the model's empty calibration)
machine_configuration_sha256Optional. 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.

ReasonRule
header_truncatedThe blob is shorter than 4 bytes, or header_len runs past its end
header_json_invalidThe header is not valid JSON. The C++ daemon also uses it for a missing or mistyped required field.
schema_mismatch or dense_schema_mismatchschema is not robot.v4.dense-joint-trajectory.v1. rt-control reports dense_schema_mismatch; the OLP and daemon codecs report schema_mismatch.
axis_count_mismatchaxes.axis_count is not 9
sample_encoding_mismatchThe encoding is not f64-be-aos.v1 with 153-byte records
limits_invalidmax_abs_qd_rad_s is not nine finite positive numbers
time_grid_invaliddt_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_emptyNo segments
segment_index_out_of_orderAn index differs from the segment's position
kind_invalidkind is not freespace, weld or dwell
segment_too_shortFewer than 2 samples
block_layout_invalidOffsets are not contiguous, or a length is not sample_count × 153
blob_length_mismatchThe blocks do not exactly cover the body
block_sha256_mismatchA block's bytes do not match its sha256. Checked before its samples are parsed.
reserved_flags_setA flag bit other than bit 0 is set
total_sample_count_mismatchtotal_sample_count differs from the sum of the segments
sample_count_overflowMore than 250,000 samples
nonfinite_sampleA NaN or infinity in t_s, q_rad or qd_rad_s
qd_limit_exceededA sample's |qd| is above max_abs_qd_rad_s for its axis
torch_outside_weldThe torch bit is set in a segment that is not weld
q_step_exceededA position step is larger than 1.25 × limit × dt_s on some axis
boundary_qd_nonzeroA segment's first or last sample has |qd| above 1e-6 rad/s
boundary_q_discontinuityA segment starts more than 1e-3 rad from where the previous one ended
duration_mismatchduration_s differs from the segment's last t_s by more than 1e-9 s
trajectory_digest_mismatchThe 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-control maps 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 with dense_axis_map_requires_nine_ids. A column past the map must not be set in axis_mask, must hold one position throughout and must have zero velocity. Otherwise the program is refused with unmapped_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 + 1 points. The adapter returns both identities: source_digest (the file's content digest) and normalised_digest (the executed points). start_program must 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 q and qd, must stay inside the limits and below the lower of the axis ceiling and the header's max_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 include native_limit_exceeded, native_segment_rate_exceeded, outside_limits_outward and segment_boundary_discontinuous.
  • Identity. plan_id, program_id and program_digest must be non-empty (program_identity_missing).
  • Process outputs. A set torch bit, or any process_markers entry, 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/rdtcheck decodes .rdt files 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. Run go run ./tools/rdtcheck [--root DIR] [--json OUT] FILE.rdt... from rt-core on Linux.
  • The weld planner's motion server serves each .rdt it wrote at GET /api/motion/dense/{trajectory_digest}. See the Weld planner HTTP API.
  • The dense trajectory daemon's validate request runs every rule above without storing the blob. See TCP ingest.