Typed metadata

The episode metadata bag has always been a free-form dict. That's still true - and now there's a thin layer of typed classes on top of it for the six payloads that show up in every ROS 2 / LeRobot / Gymnasium integration we've seen.

Construct them like ordinary dataclasses, pass them into metadata={...}, and the SDK serializes each one to a JSON object tagged with "__type": "robotrace.<name>". The portal recognizes the tag and renders with a per-shape widget - joint sparklines, pose grids, battery pills, outcome stats - instead of a stringified JSON line.

Pure superset: existing customers passing plain dicts see zero behaviour change. The new classes are an additive convenience, not a migration.

When to reach for it

You want to…Reach for
Record joint positions / velocities / efforts on every episodeJointState
Stamp the end-effector pose (or any TF frame) at episode closePose3D
Log the commanded twist (/cmd_vel) or measured base velocityTwist
Snapshot IMU accel + gyro (+ optional orientation)Imu
Track battery state at the end of a runBattery
Report success / reward / collisions / time-to-goalEpisodeOutcome

The shapes

import robotrace as rt
 
js = rt.JointState(
    positions=[0.0, 1.5, -0.3, 0.0, 0.2, -1.1, 0.0],
    velocities=[0.01, 0.0, -0.02, 0.0, 0.0, 0.0, 0.0],
    efforts=None,
    names=["shoulder_pan", "shoulder_lift", "elbow_joint",
           "wrist_1", "wrist_2", "wrist_3", "gripper"],
)
 
ee = rt.Pose3D(
    translation=[0.421, -0.103, 0.198],          # meters
    rotation=[0.0, 0.707, 0.0, 0.707],           # quaternion [x, y, z, w]
)
 
cmd = rt.Twist(linear=[0.5, 0.0, 0.0], angular=[0.0, 0.0, 0.1])
 
imu = rt.Imu(
    linear_acceleration=[0.0, 0.0, 9.81],        # m/s^2
    angular_velocity=[0.0, 0.0, 0.0],            # rad/s
    orientation=[0.0, 0.0, 0.0, 1.0],            # optional
)
 
batt = rt.Battery(percent=42.0, voltage_v=12.4, current_a=1.8, charging=False)
 
outcome = rt.EpisodeOutcome(
    success=True,
    reward_total=12.34,
    collision_count=0,
    time_to_goal_s=18.7,
)

Conventions (locked)

  • Distances in meters; angles in radians.
  • Quaternions ordered [x, y, z, w] (ROS 2 / Eigen). Not [w, x, y, z] (PyTorch3D / numpy-quaternion). If you have the other order, reorder before constructing.
  • JointState follows sensor_msgs/JointState. velocities / efforts / names must be the same length as positions when present - the SDK validates this client-side and the server re-checks.
  • Battery.percent is in [0, 100], not [0, 1]. current_a is positive when discharging by convention.

Using them

Pass typed instances straight into the metadata={...} kwarg on either entrypoint:

import robotrace as rt
 
rt.init()
 
rt.log_episode(
    name="pick_and_place v3 - morning warmup",
    policy_version="pap-v3.2.1",
    seed=8124,
    video="./run.mp4",
    metadata={
        "task": "pick_and_place",
        "joints_at_close": rt.JointState(positions=[0.1, 0.2, 0.3]),
        "ee_at_close": rt.Pose3D(
            translation=[0.4, -0.1, 0.2],
            rotation=[0, 0, 0, 1],
        ),
        "outcome": rt.EpisodeOutcome(success=True, reward_total=12.3),
    },
)

The SDK runs the metadata mapping through robotrace.types.encode(...) on the way out. The wire format is a __type-tagged dict:

{
  "task": "pick_and_place",
  "outcome": {
    "__type": "robotrace.EpisodeOutcome",
    "success": true,
    "reward_total": 12.3,
    "collision_count": null,
    "time_to_goal_s": null
  }
}

You can mix typed values and plain dicts freely. Nested typed values inside lists or sub-dicts get encoded recursively.

Forward compatibility

The server validates known __type values strictly (the SDK and server share the same shape table). Unknown __type values pass through untouched - so a future SDK release that adds, say, robotrace.Wrench works against today's server without a coordinated release.

The portal renderer follows the same rule: known tags get a custom widget, unknown tags fall back to the existing JSON line. Upgrade the portal when you're ready; nothing breaks in the meantime.

Reference

@dataclass(frozen=True)
class JointState:
    positions: Sequence[float]                       # required, len >= 1
    velocities: Sequence[float] | None = None        # same length as positions
    efforts: Sequence[float] | None = None           # same length as positions
    names: Sequence[str] | None = None               # same length as positions
 
@dataclass(frozen=True)
class Pose3D:
    translation: Sequence[float]                     # [x, y, z] in meters, len == 3
    rotation: Sequence[float]                        # quaternion [x, y, z, w], len == 4
 
@dataclass(frozen=True)
class Twist:
    linear: Sequence[float]                          # [x, y, z] m/s, len == 3
    angular: Sequence[float]                         # [x, y, z] rad/s, len == 3
 
@dataclass(frozen=True)
class Imu:
    linear_acceleration: Sequence[float]             # m/s^2, len == 3
    angular_velocity: Sequence[float]                # rad/s, len == 3
    orientation: Sequence[float] | None = None       # quaternion [x, y, z, w]
 
@dataclass(frozen=True)
class Battery:
    percent: float | None = None                     # [0, 100]
    voltage_v: float | None = None
    current_a: float | None = None                   # positive = discharging
    charging: bool | None = None
 
@dataclass(frozen=True)
class EpisodeOutcome:
    success: bool | None = None
    reward_total: float | None = None
    collision_count: int | None = None               # >= 0
    time_to_goal_s: float | None = None              # >= 0

All classes are frozen - construct once, pass into metadata={...}. For the encoder itself (recursive walk + __type tagging), see robotrace.types.encode(value).