robot_msgs v1 — one domain model for robot data
2026-10-28 · /home/bam/robot_msgs · commit {{COMMIT}} · built from real runs by docs/build_report.py
main, no remote, not pushed). Nothing in bam_ws, traj_compare or like_keras uses it yet; migrating them is your call (below).
Recorded from robot_msgs.viz.animate(joints, tool) on the UR5e pick example: one JointTrajectory (626 samples, 8 ms grid) and the PoseTrajectory of tool0 from FK over its batch view. The cursor is traj.at(t).
What it is
- The vocabulary plus the functions between its parts. A pose is a point and an orientation in a frame. A trajectory is poses (or joint angles) over time. A point cloud comes from a depth image plus camera info. The relationships ship with the types:
pose.shifted(dz=-0.1, local=True),traj.joint_state_at(t),PointCloud.from_depth(depth, info). - ROS's domain model without ROS. Same packages and field names, so the ROS docs are our docs. The values are frozen, numpy-backed and full of helpers. ROS 2 wire format is an export, not the programming model.
- The bottom of the stack. Runtime dependency: numpy only, proven below. like_keras/Clank, bam_traj_gen, traj_compare and the sim can all sit on it.
| Kept from ROS | Dropped |
|---|---|
| The domain model: geometry / sensor / trajectory / vision msgs | Behaviourless structs. Offsetting, interpolation and projection were separate libraries; here they are methods. |
Field names verbatim (JointState.name/position, CameraInfo.k) | Array-of-structs as the only storage: a JointTrajectory is not 626 point messages |
Every stamped message carries time + frame (Header) | Codegen, colcon and a ROS install just to have a Pose |
Explicit frame_id / child_frame_id | Mutable messages and msg.pose.pose.position.x = … |
The Trajectory: one entity, many views
Storage is struct-of-arrays: one read-only array per field, with time on axis 0. Every other shape is a view (free, same memory) or a computed new value. Your instinct was right, and the July tuple-backed version couldn't deliver it: its to_numpy() view was a copy.
Solid = view (no copy, measured with np.shares_memory in the tests) · dashed = computes a new value. joint() copies its columns (numpy fancy indexing); its row slices are views.
How general should a type be? The function decides
Your framing is now rule 8 in docs/DESIGN.md. A function that works on anything with samples over time takes a Trajectory: resample, window, plot, concatenate. One that needs joint angles takes a JointTrajectory: joint(), limit_violations(), joint_state_at(). One that needs poses takes a PoseTrajectory: transformed(), shifted(), path_length(). The type is the validation. A new domain subclasses Trajectory and declares its sample() fields in its own file. It gets indexing, iteration, interpolation, plotting and serialisation for free, plus a generated point type (tested with a GripperTrajectory).
The UR5e pick example, end to end
examples/ur5e_pick.py turns named waypoints into a minimum-jerk JointTrajectory with UR5e limits and a gripper channel, then runs FK over the batch view into the tool's PoseTrajectory. Real output:
Six trajectory shapes in your repos → one
| Today | Where | In robot_msgs | Behaviour that must survive |
|---|---|---|---|
NumpyTrajectory (frozen, ndarray, t0 = 0) | mdp/env/sim/bam_arm/bam_arm/kinematics/contracts.py:181 + 2 byte-identical copies in like_keras | JointTrajectory, rebased() for t0 = 0 | joint_names; frame_id → header.frame_id; waypoint names/times → a sparse waypoint trajectory; trajectory_id digest is not modelled yet |
| "NpTrajectory" (mutable, all-optional, kp/kd/tau, ik_tip_path) | archived bam/msgs, bam_traj_gen/docs/ref | JointTrajectory + channels, PoseTrajectory | tau → effort; kp/kd/max_tau → channels; ik_tip_path (N,4,4) → PoseTrajectory.from_matrices; path metrics → channels |
bam_traj_gen.JointTrajectory (tuples, tau_ff, waypoints) | bam_traj_gen/src/bam_traj_gen/joint_trajectory.py:38 | JointTrajectory | q/qd/qdd → positions/velocities/accelerations; tau_ff → effort; waypoints → JointTrajectory with a name channel (tested); the numpy view becomes free |
ROS-style JointTrajectory(header, joint_names, points) | like_keras/lk/msgs/archive/ros_msgs | to_points() / robot_msgs.ros.to_ros | array-of-structs on demand, never as storage |
sim2real measured-run Trajectory | sim2real/diagnostics_ref/robotdx/diagnostics/data.py:23 | JointTrajectory + channels | q_target, i_q, temp_c, voltage interpolate linearly; mode/fault hold (ints never blend, tested) |
traj_compare.Situation | traj_compare/src/traj_compare/model.py:273 | JointTrajectory + PoseTrajectory | poses (N,4,4) → from_matrices; missing qd/qdd → with_derivatives() |
The packages
| Import | Types | Helpers that ROS left to other libraries |
|---|---|---|
robot_msgs.std | Header | now(), stamp_ns, sec/nanosec split, time_shifted() |
robot_msgs.geometry | Vector3, Point, Quaternion, Pose, Pose2D, Transform, Twist, Wrench, Accel, PoseStamped, TransformStamped | @ compose, inverse(), shifted()/rotated() (world or local), from_xyz_rpy, matrices, slerp, interpolate; batch kernel geometry.rotations |
robot_msgs.sensor | JointState, Image, CameraInfo, PointCloud, Imu | project/unproject, PointCloud.from_depth, transformed, downsampled, Image.from_array/to_rgb/crop |
robot_msgs.vision | BoundingBox2D/3D, ObjectHypothesis(WithPose), Detection2D/3D(+Array) | iou, nms, filtered, as_arrays() batch view, corners/contains |
robot_msgs.trajectory | Trajectory, JointTrajectory, PoseTrajectory, their points, JointLimits | views, at/resample/window, with_derivatives, time_scaled, limit_violations, concatenate, from_joint_states |
robot_msgs.viz [viz] | — | plot(msg) for every type, animate(...) |
robot_msgs.ros [ros] | — | to_ros/from_ros (ROS 2 Jazzy via rosbags), write_bag/read_bag (mcap) |
Visualisation: plot(msg) for every standard type
ROS 2 is an export, and it is real
robot_msgs.ros converts every type with a ROS counterpart to the actual ROS 2 Jazzy message classes (rosbags' typestore, no ROS install; vision_msgs 4.1.1 registered from upstream) and writes standard rosbag2 mcap bags that Foxglove opens. What ROS has no slot for is never dropped silently.
Proof
Tests: {{TEST_TOTAL}} collected, {{TEST_FAILURES}} failing, {{TEST_SKIPPED}} skipped
pytest summary line: {{TEST_TAIL}}
| File | Tests |
|---|
- Independent oracles: rotation, compose, euler, slerp and interpolation are checked against scipy, a test-only dependency. ROS conversions round-trip through rosbags' own CDR serializer. A bag is written and read back with rosbags' Reader.
- Contract tests over every type, via a catalog of one example each. Each must be a frozen dataclass, and must pickle, deep-copy, JSON-round-trip and npz-round-trip losslessly. Adding a type without a catalog example fails the suite.
Dependency boundary
Built wheel {{WHEEL}}, metadata:
{{REQUIRES}}
Third-party modules loaded by python -I -c "import robot_msgs" (everything outside the standard library, minus robot_msgs itself and the interpreter's own sitecustomize hook): {{PROBE}}
Decisions I made (each reversible)
| Decision | Why | Alternative |
|---|---|---|
Name robot_msgs, not lk_msgs | It sits below like_keras. Something that changes more slowly shouldn't carry the faster thing's prefix. The helpers don't need a framework name. | lk_msgs (your 2026-07-21 idea), which inverts the dependency |
JointTrajectory, not NpTrajectory | It's ROS's name. Storage is an implementation detail and shouldn't be in the name. | — |
| numpy storage (overrides the July tuple-backed FR) | "Most compact, efficient storage with views." Tuple storage makes every numpy view a copy, at 4× the memory. | Tuples plus an optional numpy view: stdlib-only, but views copy |
Header.stamp is float seconds | What all your trajectory and controller code already uses. Resolves 0.24 µs at today's epoch; stamp_ns is there for bags. | int nanoseconds (lossless, clunkier) |
xyzw quaternions everywhere; wxyz only via explicit converters | Your vault's stance (ROS/Pinocchio). MuJoCo and viser get xyzw_to_wxyz. | — |
| Own 200-line rotation kernel instead of bam_ws's tf_transformations fork | The fork pulls transforms3d and pytest at runtime and lives inside bam_ws. The kernel is scipy-oracle-tested. | Depend on the fork by path |
shifted(), not shift() | A frozen value returns a new value, so the name says so. Matches normalized/rebased/time_scaled. | shift() |
| Floats print exactly in repr | The compact format printed stamp 1761600000.25 as 1.7616e+09. Truthful rendering wins over pretty. | Rounded display |
ROS export refuses to drop channels/limits silently | ROS has no slot for them. drop_extras=True makes the loss explicit. | Drop them silently |
What needs you
- Domain: robotmsgs.com is unregistered (registry RDAP; price and premium status unknown, since there's no Cloudflare registrar token). Buy it on Cloudflare yourself. Default if silent: nothing bought.
- Which consumer migrates first? I recommend
traj_compare, whoseSituationis already JointTrajectory + PoseTrajectory in disguise, then bam_arm'sNumpyTrajectory. Default: nothing migrates until you say. - Publish? A private GitHub repo, plus reserving
robot-msgson PyPI (free today). Default: local only. - numpy over tuples reverses the July FR-TRAJ-JOINT-TRAJECTORY-VIEWS acceptance ("tuple-backed"). Default: numpy, as built.
Left, and deliberately not done
- Next, not blocked: cubic/quintic interpolation like ros2_control's JTC (v1 is linear / slerp / hold); a frame-tree type (
TFMessage+lookup("base_link", "tool0")); PoseArray, PoseWithCovariance, Odometry, LaserScan, CompressedImage, markers; distortion inCameraInfo.project; a Rerun / Clank-plugin view. - Not done on purpose: migrating bam_ws, traj_compare or like_keras (integration is your call); pushing or publishing; buying the domain; an audit (you didn't ask). The Trajectory core is the long-lived contract here, so it may be worth a
/llm-judge.