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 episode | JointState |
| Stamp the end-effector pose (or any TF frame) at episode close | Pose3D |
Log the commanded twist (/cmd_vel) or measured base velocity | Twist |
| Snapshot IMU accel + gyro (+ optional orientation) | Imu |
| Track battery state at the end of a run | Battery |
| Report success / reward / collisions / time-to-goal | EpisodeOutcome |
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. JointStatefollowssensor_msgs/JointState.velocities/efforts/namesmust be the same length aspositionswhen present - the SDK validates this client-side and the server re-checks.Battery.percentis in[0, 100], not[0, 1].current_ais 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 # >= 0All classes are frozen - construct once, pass into metadata={...}.
For the encoder itself (recursive walk + __type tagging), see
robotrace.types.encode(value).