# Dense trajectory (.rdt) format

> The robot.v4.dense-joint-trajectory.v1 binary format for immutable joint programs, with its header fields, sample records, content digest, validation rules and the extra checks rt-control applies at admission.

URL: https://advancedmetalresearch.com/docs/reference/rdt-format
Section: RosieOS docs / Reference
Last updated: 2026-10-10

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`):

make_hold.py:

```python
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.py:

```python
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

```text
[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`):

| 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

```json
{
  "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](https://advancedmetalresearch.com/docs/reference/rdt-format#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](https://advancedmetalresearch.com/docs/reference/rdt-format#robot-cell) |
| `process_markers` | array | no | Output intents attached to exact samples; read by `rt-control` only. See [Admission by rt-control](https://advancedmetalresearch.com/docs/reference/rdt-format#rt-control-admission). |

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.

```text
"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`](/docs/apis/rt-control-http#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](https://advancedmetalresearch.com/docs/concepts/process-io-and-sensing).

The full list of reasons is in [Error codes](https://advancedmetalresearch.com/docs/reference/error-codes#reasons-dense).

## 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](https://advancedmetalresearch.com/docs/apis/weld-planner-http).
- The dense trajectory daemon's `validate` request runs every rule above without storing the blob. See [TCP ingest](https://advancedmetalresearch.com/docs/apis/dense-trajectory-daemon#tcp-ingest).

## Related pages

- [Motion paths and planning](https://advancedmetalresearch.com/docs/concepts/motion-and-planning)
- [Weld planning and verification](https://advancedmetalresearch.com/docs/concepts/weld-planning-and-verification)
- [Dense trajectory daemon](https://advancedmetalresearch.com/docs/apis/dense-trajectory-daemon)
- [rt-control HTTP API](https://advancedmetalresearch.com/docs/apis/rt-control-http#prepare-program)

## Sources

Written from these files in the RosieOS repository (https://github.com/advanced-metal-research/RosieOS):

- `motion-server/joint-trajectory/v1/src/dense_joint_trajectory_format.hpp:35-67,394-445,470-531,556-665,676-914`
- `rt-core/adapters/rosie/densejointtraj/densejointtraj.go:24-139,220-384,386-459`
- `rt-core/adapters/rosie/densejointtraj/normalise.go:1-96`
- `rt-core/adapters/rosie/control/program.go:20-284`
- `rt-core/adapters/rosie/control/admission.go:20-33`
- `rt-core/ipcclient/protocol_generated.go:14`
- `rt-core/tools/rdtcheck/main.go:1-231`
- `offline-programming/v1/internal/densejointtraj/densejointtraj.go:101-111,318`
- `offline-programming/v1/internal/densejointtraj/dry_run.go:10-21`
- `offline-programming/v1/internal/denseexec/robot.go:12-104`
- `offline-programming/v1/internal/denseexec/rt_core.go:756-806`
- `weld_planner/v1/python/weldplan/dense_joint_trajectory.py:42-192,366-426`
- `weld_planner/v1/python/weld_motion_planner/io/dense_output.py:140-164`
- `weld_planner/v1/data/dense_joint_trajectory.params.json`
