robot-msgs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. robot_msgs-0.1.0/.github/workflows/publish.yml +31 -0
  2. robot_msgs-0.1.0/.github/workflows/test.yml +16 -0
  3. robot_msgs-0.1.0/.gitignore +7 -0
  4. robot_msgs-0.1.0/.python-version +1 -0
  5. robot_msgs-0.1.0/LICENSE +26 -0
  6. robot_msgs-0.1.0/PKG-INFO +122 -0
  7. robot_msgs-0.1.0/README.md +95 -0
  8. robot_msgs-0.1.0/docs/DESIGN.md +305 -0
  9. robot_msgs-0.1.0/docs/build_report.py +219 -0
  10. robot_msgs-0.1.0/docs/report_template.html +220 -0
  11. robot_msgs-0.1.0/examples/gallery.py +208 -0
  12. robot_msgs-0.1.0/examples/ros_export.py +46 -0
  13. robot_msgs-0.1.0/examples/tour.py +26 -0
  14. robot_msgs-0.1.0/examples/ur5e_pick.py +135 -0
  15. robot_msgs-0.1.0/pyproject.toml +46 -0
  16. robot_msgs-0.1.0/src/robot_msgs/__init__.py +29 -0
  17. robot_msgs-0.1.0/src/robot_msgs/_core.py +352 -0
  18. robot_msgs-0.1.0/src/robot_msgs/compare/__init__.py +18 -0
  19. robot_msgs-0.1.0/src/robot_msgs/compare/_dispatch.py +92 -0
  20. robot_msgs-0.1.0/src/robot_msgs/compare/align.py +53 -0
  21. robot_msgs-0.1.0/src/robot_msgs/compare/comparison.py +52 -0
  22. robot_msgs-0.1.0/src/robot_msgs/compare/difference.py +116 -0
  23. robot_msgs-0.1.0/src/robot_msgs/compare/error_stats.py +62 -0
  24. robot_msgs-0.1.0/src/robot_msgs/compare/metrics.py +66 -0
  25. robot_msgs-0.1.0/src/robot_msgs/geometry/__init__.py +31 -0
  26. robot_msgs-0.1.0/src/robot_msgs/geometry/accel.py +52 -0
  27. robot_msgs-0.1.0/src/robot_msgs/geometry/inertia.py +99 -0
  28. robot_msgs-0.1.0/src/robot_msgs/geometry/point.py +79 -0
  29. robot_msgs-0.1.0/src/robot_msgs/geometry/pose.py +90 -0
  30. robot_msgs-0.1.0/src/robot_msgs/geometry/pose2d.py +33 -0
  31. robot_msgs-0.1.0/src/robot_msgs/geometry/pose_stamped.py +46 -0
  32. robot_msgs-0.1.0/src/robot_msgs/geometry/quaternion.py +177 -0
  33. robot_msgs-0.1.0/src/robot_msgs/geometry/rotations.py +251 -0
  34. robot_msgs-0.1.0/src/robot_msgs/geometry/transform.py +125 -0
  35. robot_msgs-0.1.0/src/robot_msgs/geometry/transform_stamped.py +32 -0
  36. robot_msgs-0.1.0/src/robot_msgs/geometry/twist.py +52 -0
  37. robot_msgs-0.1.0/src/robot_msgs/geometry/vector3.py +115 -0
  38. robot_msgs-0.1.0/src/robot_msgs/geometry/wrench.py +52 -0
  39. robot_msgs-0.1.0/src/robot_msgs/ros/__init__.py +19 -0
  40. robot_msgs-0.1.0/src/robot_msgs/ros/bag.py +57 -0
  41. robot_msgs-0.1.0/src/robot_msgs/ros/convert.py +939 -0
  42. robot_msgs-0.1.0/src/robot_msgs/ros/vision_msgs.py +46 -0
  43. robot_msgs-0.1.0/src/robot_msgs/sensor/__init__.py +9 -0
  44. robot_msgs-0.1.0/src/robot_msgs/sensor/camera_info.py +135 -0
  45. robot_msgs-0.1.0/src/robot_msgs/sensor/image.py +151 -0
  46. robot_msgs-0.1.0/src/robot_msgs/sensor/imu.py +36 -0
  47. robot_msgs-0.1.0/src/robot_msgs/sensor/joint_state.py +69 -0
  48. robot_msgs-0.1.0/src/robot_msgs/sensor/point_cloud.py +116 -0
  49. robot_msgs-0.1.0/src/robot_msgs/std/__init__.py +5 -0
  50. robot_msgs-0.1.0/src/robot_msgs/std/header.py +81 -0
  51. robot_msgs-0.1.0/src/robot_msgs/trajectory/__init__.py +29 -0
  52. robot_msgs-0.1.0/src/robot_msgs/trajectory/joint_limits.py +65 -0
  53. robot_msgs-0.1.0/src/robot_msgs/trajectory/joint_trajectory.py +208 -0
  54. robot_msgs-0.1.0/src/robot_msgs/trajectory/pose_trajectory.py +175 -0
  55. robot_msgs-0.1.0/src/robot_msgs/trajectory/time_repair.py +145 -0
  56. robot_msgs-0.1.0/src/robot_msgs/trajectory/trajectory.py +567 -0
  57. robot_msgs-0.1.0/src/robot_msgs/vision/__init__.py +21 -0
  58. robot_msgs-0.1.0/src/robot_msgs/vision/bounding_box2d.py +68 -0
  59. robot_msgs-0.1.0/src/robot_msgs/vision/bounding_box3d.py +50 -0
  60. robot_msgs-0.1.0/src/robot_msgs/vision/detection2d.py +43 -0
  61. robot_msgs-0.1.0/src/robot_msgs/vision/detection2d_array.py +80 -0
  62. robot_msgs-0.1.0/src/robot_msgs/vision/detection3d.py +43 -0
  63. robot_msgs-0.1.0/src/robot_msgs/vision/detection3d_array.py +104 -0
  64. robot_msgs-0.1.0/src/robot_msgs/vision/object_hypothesis.py +23 -0
  65. robot_msgs-0.1.0/src/robot_msgs/vision/object_hypothesis_with_pose.py +29 -0
  66. robot_msgs-0.1.0/src/robot_msgs/viz/__init__.py +21 -0
  67. robot_msgs-0.1.0/src/robot_msgs/viz/_common.py +167 -0
  68. robot_msgs-0.1.0/src/robot_msgs/viz/_dispatch.py +20 -0
  69. robot_msgs-0.1.0/src/robot_msgs/viz/images.py +215 -0
  70. robot_msgs-0.1.0/src/robot_msgs/viz/movie.py +280 -0
  71. robot_msgs-0.1.0/src/robot_msgs/viz/spatial.py +277 -0
  72. robot_msgs-0.1.0/src/robot_msgs/viz/trajectory.py +525 -0
  73. robot_msgs-0.1.0/tests/catalog.py +120 -0
  74. robot_msgs-0.1.0/tests/test_compare.py +271 -0
  75. robot_msgs-0.1.0/tests/test_contract.py +76 -0
  76. robot_msgs-0.1.0/tests/test_core.py +121 -0
  77. robot_msgs-0.1.0/tests/test_geometry.py +679 -0
  78. robot_msgs-0.1.0/tests/test_header_meta_and_from_samples.py +258 -0
  79. robot_msgs-0.1.0/tests/test_inertia_and_euler_matrix.py +145 -0
  80. robot_msgs-0.1.0/tests/test_joint_trajectory.py +142 -0
  81. robot_msgs-0.1.0/tests/test_pose_trajectory.py +86 -0
  82. robot_msgs-0.1.0/tests/test_ros.py +643 -0
  83. robot_msgs-0.1.0/tests/test_rotations.py +91 -0
  84. robot_msgs-0.1.0/tests/test_sensor.py +610 -0
  85. robot_msgs-0.1.0/tests/test_trajectory.py +197 -0
  86. robot_msgs-0.1.0/tests/test_vision.py +467 -0
  87. robot_msgs-0.1.0/tests/test_viz.py +672 -0
@@ -0,0 +1,31 @@
1
+ # Publishes to PyPI when a version tag (v*) is pushed, through PyPI trusted publishing:
2
+ # no API token is stored anywhere. One-time setup on pypi.org: a "pending publisher" for
3
+ # project robot-msgs, owner zacharyyamaoka, repository robot_msgs, workflow publish.yml,
4
+ # environment pypi.
5
+ name: publish
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+ workflow_dispatch:
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+ # WHY: test_viz writes a real .mp4 through matplotlib's FFMpegWriter; the runner image has no ffmpeg.
17
+ - run: sudo apt-get update -q && sudo apt-get install -y -q ffmpeg
18
+ - run: uv run --isolated --extra test python -m pytest -q
19
+ env:
20
+ MPLBACKEND: Agg
21
+ publish:
22
+ needs: test
23
+ runs-on: ubuntu-latest
24
+ environment: pypi
25
+ permissions:
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: astral-sh/setup-uv@v6
30
+ - run: uv build
31
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,16 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.11", "3.12", "3.13"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v6
12
+ # WHY: test_viz writes a real .mp4 through matplotlib's FFMpegWriter; the runner image has no ffmpeg.
13
+ - run: sudo apt-get update -q && sudo apt-get install -y -q ffmpeg
14
+ - run: uv run --isolated --python ${{ matrix.python }} --extra test python -m pytest -q
15
+ env:
16
+ MPLBACKEND: Agg
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ reports/media/
7
+ .pytest_cache/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zach Yamaoka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ src/robot_msgs/ros/vision_msgs.py embeds message definitions from
26
+ https://github.com/ros-perception/vision_msgs (tag 4.1.1), licensed Apache-2.0.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: robot-msgs
3
+ Version: 0.1.0
4
+ Summary: Standard robot message types as frozen Python dataclasses, with the helper functions ROS messages never had.
5
+ Project-URL: Homepage, https://robotmsgs.com
6
+ Project-URL: Source, https://github.com/zacharyyamaoka/robot_msgs
7
+ Author: Zach Yamaoka
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: dataclasses,geometry,messages,robotics,ros,trajectory
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: numpy>=1.26
17
+ Provides-Extra: ros
18
+ Requires-Dist: rosbags>=0.10; extra == 'ros'
19
+ Provides-Extra: test
20
+ Requires-Dist: matplotlib>=3.8; extra == 'test'
21
+ Requires-Dist: pytest>=8; extra == 'test'
22
+ Requires-Dist: rosbags>=0.10; extra == 'test'
23
+ Requires-Dist: scipy>=1.11; extra == 'test'
24
+ Provides-Extra: viz
25
+ Requires-Dist: matplotlib>=3.8; extra == 'viz'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # robot_msgs
29
+
30
+ A cohesive domain model for robot data: the standard message types — ROS's `geometry_msgs`,
31
+ `sensor_msgs`, `trajectory_msgs`, `vision_msgs` — as frozen Python dataclasses, **with the
32
+ functions that move between them**. Offsetting a pose, interpolating a trajectory, projecting a
33
+ point, turning a depth image into a cloud: first-class methods, not separate libraries.
34
+
35
+ - **ROS's shape, not ROS.** Same packages and field names (the ROS docs are our docs), no ROS
36
+ install, no codegen. ROS 2 messages and bags are an export (`robot_msgs.ros`).
37
+ - **Immutable values over compact numpy storage.** Every message is frozen; its arrays are
38
+ read-only, so slices, rows and columns are free views.
39
+ - **One runtime dependency: numpy.** Plots (`[viz]`) and ROS export (`[ros]`) are extras.
40
+
41
+ ## A trajectory is one entity with many views
42
+
43
+ ```python
44
+ import numpy as np
45
+
46
+ from robot_msgs.geometry import Pose
47
+ from robot_msgs.trajectory import JointTrajectory
48
+
49
+ t = np.linspace(0.0, 2.0, 201)
50
+ traj = JointTrajectory(
51
+ t=t,
52
+ joint_names=("shoulder", "elbow"),
53
+ positions=np.stack([np.sin(t), np.cos(t)], axis=1),
54
+ ).with_derivatives()
55
+
56
+ print(traj) # a summary, never 400 numbers
57
+ print(traj.positions.shape) # batch view, no copy
58
+ print(traj[100]) # one point
59
+ print(traj.at(0.123).positions) # interpolated at any time
60
+ for point in traj[:2]: # slices are views too
61
+ print(point.t, point.positions)
62
+ print(traj.joint("elbow").joint_names) # one joint, still a JointTrajectory
63
+ print(traj.resample(dt=0.5).t) # onto a fixed grid
64
+
65
+ grasp = Pose.from_xyz_rpy(0.5, 0.0, 0.2, np.pi, 0.0, 0.0) # tool pointing down
66
+ pregrasp = grasp.shifted(dz=-0.10, local=True) # back off along the tool
67
+ print(pregrasp.position)
68
+ ```
69
+
70
+ ```text
71
+ JointTrajectory(N=201, t=[0 … 2] s, joint_names=('shoulder', 'elbow'), positions=float64[201×2], velocities=float64[201×2], accelerations=float64[201×2])
72
+ (201, 2)
73
+ JointTrajectoryPoint(t=1.0, positions=[0.8415, 0.5403], velocities=[ 0.5403, -0.8415], accelerations=[-0.8414, -0.5403])
74
+ [0.12268879 0.99243461]
75
+ 0.0 [0. 1.]
76
+ 0.01 [0.00999983 0.99995 ]
77
+ ('elbow',)
78
+ [0. 0.5 1. 1.5 2. ]
79
+ Point(x=0.5, y=1.2246467991473533e-17, z=0.30000000000000004)
80
+ ```
81
+
82
+ `JointTrajectory` and `PoseTrajectory` subclass the domain-free `Trajectory` (samples at strictly
83
+ increasing times). Anything else you want to send along rides as `channels`
84
+ (`traj.with_channel("kp", kp)`); a new kind of trajectory subclasses `Trajectory` and declares
85
+ its `sample()` fields — it gets indexing, interpolation, plotting and serialisation for free.
86
+
87
+ Context ROS has no field for (an origin, units, provenance) rides `Header.meta`, a frozen
88
+ `str → str` mapping that survives every view and file and is dropped on ROS export. A recording
89
+ whose stamps repeat or step back is built with
90
+ `JointTrajectory.from_samples(..., duplicate_times="drop_later" | "average" | "error")`, which
91
+ returns `(trajectory, repair)` and writes the same receipt into `header.meta`; never silently.
92
+
93
+ ## Packages
94
+
95
+ | Import | Types |
96
+ |---|---|
97
+ | `robot_msgs.std` | `Header` |
98
+ | `robot_msgs.geometry` | `Vector3` `Point` `Quaternion` `Pose` `Pose2D` `Transform` `Twist` `Wrench` `Accel` `Inertia` `PoseStamped` `TransformStamped` · batch maths in `geometry.rotations` (incl. direct `euler_to_matrix` / `matrix_to_euler`) |
99
+ | `robot_msgs.sensor` | `JointState` `Image` `CameraInfo` `PointCloud` `Imu` |
100
+ | `robot_msgs.vision` | `BoundingBox2D` `BoundingBox3D` `ObjectHypothesis` `ObjectHypothesisWithPose` `Detection2D` `Detection2DArray` `Detection3D` `Detection3DArray` |
101
+ | `robot_msgs.trajectory` | `Trajectory` `JointTrajectory` `PoseTrajectory` (+ points) `JointLimits` `concatenate` `TimeRepair` |
102
+ | `robot_msgs.viz` `[viz]` | `plot(msg)` for every type, `animate(*trajectories, path=...)` |
103
+ | `robot_msgs.ros` `[ros]` | `to_ros` / `from_ros` (ROS 2 Jazzy via rosbags), `write_bag` / `read_bag` (mcap) |
104
+
105
+ Every message: `==` and `hash` by value, a short `repr`, `replace(...)`, `to_dict()` /
106
+ `from_dict()`, `save_npz` / `load_npz`, pickling.
107
+
108
+ ## Conventions
109
+
110
+ SI units · quaternions `(x, y, z, w)` (MuJoCo/viser `wxyz` only through `xyzw_to_wxyz`) ·
111
+ Euler = ROS roll-pitch-yaw (fixed X, Y, Z) · `a @ b` applies `b` in `a`'s frame ·
112
+ `Header.stamp` float seconds, trajectory `t` relative to it · `Header.meta` str → str · absent optional arrays are `None`.
113
+ The full contract, what we keep from ROS and what we drop, and how to extend: [docs/DESIGN.md](docs/DESIGN.md).
114
+
115
+ ## Develop
116
+
117
+ ```bash
118
+ env -u VIRTUAL_ENV uv run --directory /home/bam/robot_msgs --isolated --extra test python -m pytest -q
119
+ ```
120
+
121
+ Examples: `examples/tour.py` (above), `examples/ur5e_pick.py` (waypoints → minimum-jerk joint
122
+ trajectory → tool pose trajectory through UR5e FK), `examples/gallery.py OUTDIR` (every plot).
@@ -0,0 +1,95 @@
1
+ # robot_msgs
2
+
3
+ A cohesive domain model for robot data: the standard message types — ROS's `geometry_msgs`,
4
+ `sensor_msgs`, `trajectory_msgs`, `vision_msgs` — as frozen Python dataclasses, **with the
5
+ functions that move between them**. Offsetting a pose, interpolating a trajectory, projecting a
6
+ point, turning a depth image into a cloud: first-class methods, not separate libraries.
7
+
8
+ - **ROS's shape, not ROS.** Same packages and field names (the ROS docs are our docs), no ROS
9
+ install, no codegen. ROS 2 messages and bags are an export (`robot_msgs.ros`).
10
+ - **Immutable values over compact numpy storage.** Every message is frozen; its arrays are
11
+ read-only, so slices, rows and columns are free views.
12
+ - **One runtime dependency: numpy.** Plots (`[viz]`) and ROS export (`[ros]`) are extras.
13
+
14
+ ## A trajectory is one entity with many views
15
+
16
+ ```python
17
+ import numpy as np
18
+
19
+ from robot_msgs.geometry import Pose
20
+ from robot_msgs.trajectory import JointTrajectory
21
+
22
+ t = np.linspace(0.0, 2.0, 201)
23
+ traj = JointTrajectory(
24
+ t=t,
25
+ joint_names=("shoulder", "elbow"),
26
+ positions=np.stack([np.sin(t), np.cos(t)], axis=1),
27
+ ).with_derivatives()
28
+
29
+ print(traj) # a summary, never 400 numbers
30
+ print(traj.positions.shape) # batch view, no copy
31
+ print(traj[100]) # one point
32
+ print(traj.at(0.123).positions) # interpolated at any time
33
+ for point in traj[:2]: # slices are views too
34
+ print(point.t, point.positions)
35
+ print(traj.joint("elbow").joint_names) # one joint, still a JointTrajectory
36
+ print(traj.resample(dt=0.5).t) # onto a fixed grid
37
+
38
+ grasp = Pose.from_xyz_rpy(0.5, 0.0, 0.2, np.pi, 0.0, 0.0) # tool pointing down
39
+ pregrasp = grasp.shifted(dz=-0.10, local=True) # back off along the tool
40
+ print(pregrasp.position)
41
+ ```
42
+
43
+ ```text
44
+ JointTrajectory(N=201, t=[0 … 2] s, joint_names=('shoulder', 'elbow'), positions=float64[201×2], velocities=float64[201×2], accelerations=float64[201×2])
45
+ (201, 2)
46
+ JointTrajectoryPoint(t=1.0, positions=[0.8415, 0.5403], velocities=[ 0.5403, -0.8415], accelerations=[-0.8414, -0.5403])
47
+ [0.12268879 0.99243461]
48
+ 0.0 [0. 1.]
49
+ 0.01 [0.00999983 0.99995 ]
50
+ ('elbow',)
51
+ [0. 0.5 1. 1.5 2. ]
52
+ Point(x=0.5, y=1.2246467991473533e-17, z=0.30000000000000004)
53
+ ```
54
+
55
+ `JointTrajectory` and `PoseTrajectory` subclass the domain-free `Trajectory` (samples at strictly
56
+ increasing times). Anything else you want to send along rides as `channels`
57
+ (`traj.with_channel("kp", kp)`); a new kind of trajectory subclasses `Trajectory` and declares
58
+ its `sample()` fields — it gets indexing, interpolation, plotting and serialisation for free.
59
+
60
+ Context ROS has no field for (an origin, units, provenance) rides `Header.meta`, a frozen
61
+ `str → str` mapping that survives every view and file and is dropped on ROS export. A recording
62
+ whose stamps repeat or step back is built with
63
+ `JointTrajectory.from_samples(..., duplicate_times="drop_later" | "average" | "error")`, which
64
+ returns `(trajectory, repair)` and writes the same receipt into `header.meta`; never silently.
65
+
66
+ ## Packages
67
+
68
+ | Import | Types |
69
+ |---|---|
70
+ | `robot_msgs.std` | `Header` |
71
+ | `robot_msgs.geometry` | `Vector3` `Point` `Quaternion` `Pose` `Pose2D` `Transform` `Twist` `Wrench` `Accel` `Inertia` `PoseStamped` `TransformStamped` · batch maths in `geometry.rotations` (incl. direct `euler_to_matrix` / `matrix_to_euler`) |
72
+ | `robot_msgs.sensor` | `JointState` `Image` `CameraInfo` `PointCloud` `Imu` |
73
+ | `robot_msgs.vision` | `BoundingBox2D` `BoundingBox3D` `ObjectHypothesis` `ObjectHypothesisWithPose` `Detection2D` `Detection2DArray` `Detection3D` `Detection3DArray` |
74
+ | `robot_msgs.trajectory` | `Trajectory` `JointTrajectory` `PoseTrajectory` (+ points) `JointLimits` `concatenate` `TimeRepair` |
75
+ | `robot_msgs.viz` `[viz]` | `plot(msg)` for every type, `animate(*trajectories, path=...)` |
76
+ | `robot_msgs.ros` `[ros]` | `to_ros` / `from_ros` (ROS 2 Jazzy via rosbags), `write_bag` / `read_bag` (mcap) |
77
+
78
+ Every message: `==` and `hash` by value, a short `repr`, `replace(...)`, `to_dict()` /
79
+ `from_dict()`, `save_npz` / `load_npz`, pickling.
80
+
81
+ ## Conventions
82
+
83
+ SI units · quaternions `(x, y, z, w)` (MuJoCo/viser `wxyz` only through `xyzw_to_wxyz`) ·
84
+ Euler = ROS roll-pitch-yaw (fixed X, Y, Z) · `a @ b` applies `b` in `a`'s frame ·
85
+ `Header.stamp` float seconds, trajectory `t` relative to it · `Header.meta` str → str · absent optional arrays are `None`.
86
+ The full contract, what we keep from ROS and what we drop, and how to extend: [docs/DESIGN.md](docs/DESIGN.md).
87
+
88
+ ## Develop
89
+
90
+ ```bash
91
+ env -u VIRTUAL_ENV uv run --directory /home/bam/robot_msgs --isolated --extra test python -m pytest -q
92
+ ```
93
+
94
+ Examples: `examples/tour.py` (above), `examples/ur5e_pick.py` (waypoints → minimum-jerk joint
95
+ trajectory → tool pose trajectory through UR5e FK), `examples/gallery.py OUTDIR` (every plot).
@@ -0,0 +1,305 @@
1
+ # robot_msgs — design contract (v1)
2
+
3
+ A cohesive domain model for robot data: the vocabulary that flows between robot components,
4
+ plus the functions that move between its parts. The parts are defined by each other, like a
5
+ dictionary — a trajectory is poses over time, a pose is a point and an orientation in a frame,
6
+ a point cloud comes from a depth image and its camera info — so the relationships ship with the
7
+ types. It is the stable bottom tier: everything above it (like_keras/Clank, bam_traj_gen,
8
+ traj_compare, sim, drivers) can depend on it, and it depends on nothing but numpy.
9
+
10
+ ## What we keep from ROS, and what we drop
11
+
12
+ | Keep | Drop |
13
+ |---|---|
14
+ | The domain model: `geometry_msgs`, `sensor_msgs`, `trajectory_msgs`, `vision_msgs` — decades of agreement on what the nouns are | Behaviourless structs. Pose offsetting, interpolation, projection and FK-shaped batch maths were separate libraries (tf, image_geometry, …); here they are methods on the type |
15
+ | Field names verbatim, so ROS docs are our docs and conversion is mechanical | Array-of-structs as the only storage: a `JointTrajectory` is not 500 point messages |
16
+ | Every stamped message carries its time and frame (`Header`) | Code generation, a build system and a ROS install just to have a `Pose` |
17
+ | Explicit frames (`frame_id`, `child_frame_id`) | Verbose construction (`msg.pose.pose.position.x = …`) and mutable messages |
18
+ | Wire formats as an *export* (`robot_msgs.ros`) | The wire format as the programming model |
19
+
20
+ ## Scope
21
+
22
+ - **In:** the standard vocabulary and the standard functions between its parts — offsets
23
+ (`pose.shifted(dz=-0.1, local=True)`), composition, interpolation, resampling, projection,
24
+ depth → cloud, joint trajectory → joint state, limit checks.
25
+ - **Out:** business logic and non-standard types. The moment a type needs your application's
26
+ concepts (a bin, an order, a grasp score policy) it belongs in your code. See *Extending*.
27
+
28
+ ## Principles (each one is a rule, with its reason)
29
+
30
+ 1. **Copy ROS's shape, not ROS.** Types and field names follow the ROS 2 message of the same
31
+ name verbatim (`JointState.name/position/velocity/effort`, `CameraInfo.k/d/r/p`,
32
+ `Header.stamp/frame_id`) so the ROS docs are our docs and conversion is mechanical.
33
+ No ROS install, no rclpy, no codegen.
34
+ 2. **Behaviour lives with its type.** ROS messages are dead structs; here `pose.py` holds `Pose`
35
+ *and* `compose()`, `inverse()`, `to_matrix()`. Helper methods are the point of the package.
36
+ 3. **Immutable values.** Every message is `@dataclass(frozen=True, eq=False, repr=False)` and
37
+ subclasses `robot_msgs._core.Msg`. Methods never mutate; they return new values
38
+ (`replace()` re-validates). WHY: a message handed to three consumers can't be changed under
39
+ any of them, so logs, replays and tests are reproducible.
40
+ 4. **Numbers live in read-only numpy arrays.** `frozen_array()` copies once at construction and
41
+ marks the array read-only; views of it (slices, rows, columns) are then free and still
42
+ read-only. WHY: Zach wants "the most compact, efficient storage, with many views of it".
43
+ Tuple-backed storage (bam_traj_gen) makes every numpy "view" a copy.
44
+ 5. **Validate at construction.** Shapes, lengths, monotonic time and name counts fail in
45
+ `__post_init__` with a `ValueError` naming the field, never later in a consumer.
46
+ 6. **One type per file, named for the type** (`geometry/pose_stamped.py` → `PoseStamped`), plus
47
+ any functions that operate on it. Each domain package re-exports its types in `__init__`.
48
+ 7. **Conventions are fixed and written down** (below), never inferred.
49
+ 8. **The function decides how specific the type is.** A function that works on anything with
50
+ samples over time takes a `Trajectory` (`resample`, `window`, `plot`); one that needs joint
51
+ angles takes a `JointTrajectory`; one that needs poses takes a `PoseTrajectory`. The type is
52
+ validation: passing a `JointTrajectory` where a `PoseTrajectory` is required is an error, and
53
+ that is the point.
54
+ 9. **Methods that return a modified copy are named for the result**: `shifted`, `rotated`,
55
+ `normalized`, `rebased`, `time_shifted`, `time_scaled`, `transformed`, `with_*`. Spatial
56
+ words (`shifted`, `rotated`) move things in space; `time_*` moves them in time.
57
+ 10. **Runtime dependency: numpy only.** matplotlib (`[viz]`), rosbags (`[ros]`), scipy (tests,
58
+ as an independent oracle) are extras. A boundary test enforces this.
59
+
60
+ ## Conventions
61
+
62
+ | Thing | Convention |
63
+ |---|---|
64
+ | Units | SI: metres, radians, seconds, newtons, N·m |
65
+ | Quaternion | `(x, y, z, w)` as fields and as arrays; Hamilton product; MuJoCo/viser `wxyz` only via `xyzw_to_wxyz` / `wxyz_to_xyzw` |
66
+ | Euler | ROS RPY: fixed axes X then Y then Z, `R = Rz(yaw)·Ry(pitch)·Rx(roll)` |
67
+ | `a.compose(b)` / `a @ b` | apply `b` in `a`'s frame: `T_world_tool = T_world_base @ T_base_tool` |
68
+ | Time | `Header.stamp`: float seconds. Trajectory `t`: float seconds relative to `header.stamp` |
69
+ | Context beyond a frame | `Header.meta`, str → str (origin, units, provenance); dropped on ROS export |
70
+ | Absent optional array | `None` (ROS uses an empty array; converters map between them) |
71
+ | Matrices | `(3, 3)` rotation, `(4, 4)` homogeneous, row-major numpy |
72
+
73
+ ## Mechanics every message follows
74
+
75
+ ```python
76
+ from __future__ import annotations
77
+ from dataclasses import dataclass, field
78
+ import numpy as np
79
+ from robot_msgs._core import Array, Msg, frozen_array, optional_array, set_field
80
+ from robot_msgs.std import Header
81
+
82
+ @dataclass(frozen=True, eq=False, repr=False) # always these three flags
83
+ class JointState(Msg):
84
+ name: tuple[str, ...]
85
+ position: Array
86
+ velocity: Array | None = None
87
+ effort: Array | None = None
88
+ header: Header = field(default_factory=Header)
89
+
90
+ def __post_init__(self) -> None:
91
+ set_field(self, "name", tuple(str(n) for n in self.name))
92
+ set_field(self, "position", frozen_array(self.position)) # copies once, read-only
93
+ ...validate shapes, raise ValueError("JointState.velocity has 5 values, name has 6")
94
+ ```
95
+
96
+ - Small value types with only scalar fields (`Point`, `Quaternion`, `Vector3`) take positional
97
+ arguments like ROS (`Point(1, 2, 3)`) and store Python floats. Array-holding and trajectory
98
+ types are `kw_only=True`.
99
+ - `Msg` gives every message: array-aware `==` and `hash`, a compact `repr` (big arrays print as
100
+ `float64[500×6]`), `replace(**changes)`, `to_dict()` / `from_dict()` (JSON-safe, lossless,
101
+ `"_type": "geometry/Pose"`), and `save_npz` / `load_npz` for compact files.
102
+ - `robot_msgs.allclose(a, b)` compares messages approximately.
103
+
104
+ ## Packages and their v1 types
105
+
106
+ | Package | Types (one file each) | ROS source |
107
+ |---|---|---|
108
+ | `robot_msgs.std` | `Header` | std_msgs |
109
+ | `robot_msgs.geometry` | `Vector3`, `Point`, `Quaternion`, `Pose`, `Pose2D`, `Transform`, `Twist`, `Wrench`, `Accel`, `Inertia`, `PoseStamped`, `TransformStamped`; batch math in `rotations.py` | geometry_msgs |
110
+ | `robot_msgs.sensor` | `JointState`, `Image`, `CameraInfo`, `PointCloud`, `Imu` | sensor_msgs |
111
+ | `robot_msgs.vision` | `BoundingBox2D`, `BoundingBox3D`, `ObjectHypothesis`, `ObjectHypothesisWithPose`, `Detection2D`, `Detection2DArray`, `Detection3D`, `Detection3DArray` | vision_msgs |
112
+ | `robot_msgs.trajectory` | `Trajectory`, `TrajectoryPoint`, `JointTrajectory`, `JointTrajectoryPoint`, `PoseTrajectory`, `PoseTrajectoryPoint`, `JointLimits`, `concatenate` | trajectory_msgs, nav_msgs/Path |
113
+ | `robot_msgs.compare` | `compare(reference, measured)` → `Comparison` (error trajectory + `ErrorStats` per metric), `align`, `error_stats`; also `traj.compare(other)` — numpy only | control_msgs/JointTrajectoryControllerState (error sign) |
114
+ | `robot_msgs.viz` | `plot(msg)` for every type above (matplotlib, `[viz]` extra) | — |
115
+ | `robot_msgs.ros` | `to_ros(msg)` / `from_ros(ros_msg)` / `ros_type()` against ROS 2 Jazzy via rosbags' `typestore()` (vision_msgs 4.1.1 registered); `write_bag` / `read_bag` (mcap or sqlite3) — `[ros]` extra | — |
116
+
117
+ ## Geometry API (frozen contract — other modules import these names)
118
+
119
+ - `Vector3(x=0.0, y=0.0, z=0.0)` and `Point(x=0.0, y=0.0, z=0.0)`: `as_array() -> (3,)`,
120
+ `from_array(a)`, `+ - ` same type, `* / ` scalar, unary `-`, `norm()`, `dot()`, `cross()`
121
+ (Vector3), `distance_to()` (Point), `normalized()` (Vector3).
122
+ - `Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)`: `identity()`, `as_array()` (xyzw), `from_array(a)`,
123
+ `from_wxyz(a)`, `to_wxyz()`, `from_euler(roll, pitch, yaw)`, `to_euler() -> (r, p, y)`,
124
+ `from_matrix(R)`, `to_matrix()`, `from_axis_angle(axis, angle)`, `from_rotvec(v)`,
125
+ `to_rotvec()`, `multiply(other)` and `__mul__`, `conjugate()`, `inverse()`, `normalized()`,
126
+ `rotate(v)` (Vector3, Point or array → same kind), `slerp(other, alpha)`, `angle_to(other)`.
127
+ - `Pose(position=Point(), orientation=Quaternion())` and
128
+ `Transform(translation=Vector3(), rotation=Quaternion())`: `identity()`,
129
+ `from_xyz_rpy(x, y, z, roll, pitch, yaw)`, `from_matrix(M)`, `to_matrix()`, `compose(other)` and
130
+ `__matmul__`, `inverse()`, `transform_point(p)`, `as_array() -> (7,)` = x y z qx qy qz qw,
131
+ `from_array(a)`, `Pose.as_transform()`, `Transform.as_pose()`.
132
+ - `PoseStamped(header=Header(), pose=Pose())`, `TransformStamped(header=Header(),
133
+ child_frame_id="", transform=Transform())`, `Twist(linear=Vector3(), angular=Vector3())`,
134
+ `Wrench(force=Vector3(), torque=Vector3())`, `Accel(linear, angular)`, `Pose2D(x, y, theta)`.
135
+ - `Inertia(m=0.0, com=Vector3(), ixx, ixy, ixz, iyy, iyz, izz)` (geometry_msgs/Inertia, tensor
136
+ about the centre of mass): `to_matrix()` / `from_matrix(m, com, I)` (symmetric only),
137
+ `rotated(q)` (`R·I·Rᵀ`), `is_physical()`, `as_array() -> (10,)`.
138
+ - `rotations.euler_to_matrix(r, p, y)` / `matrix_to_euler(R)`: ROS RPY straight to and from
139
+ rotation matrices, no quaternion detour (a URDF `rpy` is printed verbatim); yaw = 0 at gimbal
140
+ lock.
141
+
142
+ ## Trajectory — one entity, many views
143
+
144
+ A trajectory is samples at strictly increasing times, in any domain. The base class holds the
145
+ time axis and the machinery; subclasses only declare their per-sample fields.
146
+
147
+ ```python
148
+ traj = JointTrajectory(t=t, joint_names=names, positions=q, velocities=qd)
149
+
150
+ traj.positions # (N, J) read-only array — the batch view, no copy
151
+ traj["positions"] # same, by name (also works for channels)
152
+ traj[10] # JointTrajectoryPoint at sample 10 (row views, no copy)
153
+ for point in traj:
154
+ ... # per-step view
155
+ traj[100:200] # JointTrajectory, sliced as views — O(1)
156
+ traj.at(1.25) # interpolated point (linear; slerp for orientations)
157
+ traj.resample(dt=0.004) # on a fixed controller grid
158
+ traj.window(1.0, 2.0) # the part between two times
159
+ traj.joint("elbow") # one joint, still a JointTrajectory
160
+ traj.channels["kp"] # any extra per-sample data you layer on top
161
+ ```
162
+
163
+ - **Per-sample fields** are declared with `sample()`; their first axis is time and they are
164
+ sliced, iterated and interpolated together. `sample(interp="slerp")` for quaternions,
165
+ `"hold"` for discrete values. Per-trajectory constants are ordinary fields
166
+ (`joint_names`, `limits`, `child_frame_id`).
167
+ - **`channels`** carries extra per-sample arrays any trajectory can grow without a new type
168
+ (`kp`, `tau_ff`, `mode`, `temp_c`). Float channels interpolate linearly, others hold.
169
+ - **New domains subclass `Trajectory`** and declare `sample()` fields; they get indexing,
170
+ iteration, interpolation, resampling, plotting and serialisation for free, plus a generated
171
+ point type.
172
+ - `t` is relative to `header.stamp`; `rebased()` moves `t[0]` to 0 and folds the offset into the
173
+ stamp. Out-of-range interpolation raises unless `extrapolate="clamp"`.
174
+ - Recordings whose stamps repeat or step back go through `from_samples(..., duplicate_times=...)`,
175
+ which says what it changed (below).
176
+
177
+ ## Header.meta — context ROS has no field for
178
+
179
+ ROS's only per-message context is `std_msgs/Header`: a stamp and a `frame_id`. There is no free
180
+ metadata, so a consumer that needs to say *what the numbers are relative to* beyond a frame name
181
+ (the origin a joint angle is measured from, its units, which recorder wrote it, what a cleaner
182
+ changed) ends up encoding it into `frame_id` (bam_rack_twin did, 2026-11-04).
183
+
184
+ **Decision:** `Header.meta: Mapping[str, str]`, default empty, frozen (`FrozenDict`), copied at
185
+ construction.
186
+
187
+ - **WHY on the header, not on each message:** the header is already the slot that says what a
188
+ message is relative to; extending it gives every stamped message and every trajectory the same
189
+ place once, and everything that keeps the header keeps meta for free: slicing, masks,
190
+ `window`, `resample`, `rebased`, `joint()`, `with_*`, `to_dict` / JSON, `save_npz`, pickling.
191
+ - **Points carry their trajectory's header** (`traj[i].header`, `traj.at(t).header`), so a point
192
+ taken out keeps its frame, origin and units. ROS's points have no header; ours are views, and
193
+ `t` is meaningless without the stamp it counts from. `from_points` takes the points' shared
194
+ header back (and refuses points whose headers differ, unless you pass `header=`).
195
+ - **WHY strings only:** meta is read by people and by other tools. A float would be `str()`-ed
196
+ silently and its spelling chosen for you, so non-strings are refused and the caller writes
197
+ `repr(x)` (render stored values truthfully). Structured values go in as JSON strings.
198
+ - **`concatenate` refuses pieces with different meta**, as it refuses different frames: two
199
+ trajectories measured from different origins are not one trajectory.
200
+ - **Dropped on ROS export** (`to_ros`), always, without `drop_extras`: std_msgs/Header has nowhere
201
+ to put it and every stamped message exports through one header converter. `from_ros` gives an
202
+ empty meta. robot_msgs' own files are the lossless form.
203
+ - **Not for per-sample data** (that is `channels`) or per-trajectory numbers a function computes
204
+ with (those are typed fields, like `limits`). Meta is description, never input.
205
+
206
+ ## Recorded stamps — `from_samples`
207
+
208
+ `t` must strictly increase, and the constructor keeps raising otherwise: interpolation, `dt`,
209
+ `window` and `searchsorted` all depend on it. Recordings break it (a loop logs one stamp twice, a
210
+ clock steps back), so there is one explicit door:
211
+
212
+ ```python
213
+ traj, repair = JointTrajectory.from_samples(
214
+ t=t, joint_names=names, positions=q, duplicate_times="drop_later" # | "average" | "error"
215
+ )
216
+ repair.dropped # (17, 230): indices into the samples as given; repair.dropped_t their stamps
217
+ str(repair) # "drop_later: dropped 2 of 1000 samples (repeated or backwards stamps) at ..."
218
+ traj.header.meta["time_repair.dropped"] # the same receipt, inside the trajectory
219
+ ```
220
+
221
+ - `duplicate_times` has **no default**: the caller decides what a repeated stamp means.
222
+ `"drop_later"` keeps a sample only if its stamp is after every earlier kept one (first of a
223
+ repeat stays, backwards stamps go, values never altered). `"average"` merges samples at exactly
224
+ equal stamps (floats averaged, quaternions on the short arc, held/discrete values must agree or
225
+ it raises) and drops backwards stamps, which have no partner. `"error"` is the constructor.
226
+ - **WHY a receipt in two places** (the returned `TimeRepair` and `header.meta["time_repair.*"]`):
227
+ silent repair is not allowed. A cleaned recording that does not say so is indistinguishable
228
+ from a clean one; the meta copy means the saved file says so too. Clean input changes nothing
229
+ and stamps nothing.
230
+
231
+ ## Comparing trajectories — `robot_msgs.compare`
232
+
233
+ Zach, 2026-11-10: *"literally this package is saying we are comparing two of these data types of
234
+ robot msgs. The whole issue with ROS msgs is that they don't have enough of the helpers at arm's
235
+ reach."* So comparison is a robot_msgs helper, built like `viz`: its own subpackage, one generic
236
+ function dispatched on the type, and a one-line method (`traj.compare(other)`) so the type files
237
+ stay the types. Viewers (traj_compare's HTML report, Rerun) live outside and read the values.
238
+
239
+ ```python
240
+ result = target.compare(real) # == compare(target, real)
241
+ result.error # JointTrajectory: target - real, on real's samples
242
+ result.metrics["positions/j2"].rms # ErrorStats: n rms mean median std min max max_abs p95_abs final
243
+ tip = lambda traj: PoseTrajectory.from_matrices(traj.t, fk(traj.positions), child_frame_id="L2 tip")
244
+ tip(target).compare(tip(real)).metrics["lag"].median # cartesian: one PoseTrajectory per point
245
+ ```
246
+
247
+ Rules, each with its reason:
248
+
249
+ 1. **Pairwise.** `compare(reference, measured)`; N runs, or sim vs real with no target, is a loop.
250
+ 2. **`error = reference - measured`**, the ROS 2 `JointTrajectoryControllerState` convention, so a
251
+ positive error is the correction still to apply and a positive `lag` means the run is behind.
252
+ 3. **The error is the same type as its inputs**, so every view, plot and serialiser already works
253
+ on it. Derived forms ride along as channels: a pose error carries `rotation_vector` (world
254
+ axes; never a difference of Euler angles), `rotation_angle`, `lag` and `cross_track`.
255
+ 4. **Joint ↔ cartesian is not compare's job.** Build a `PoseTrajectory` per point you care about
256
+ (TCP, L2 tip, coupler) with your FK, name it in `child_frame_id`, and compare those.
257
+ 5. **One clock, no extrapolation.** Both runs are placed on `header.stamp + t`; measured samples
258
+ outside the reference are dropped (`dropped`), and `coverage` says how much of the reference
259
+ was measured. A known latency is an explicit `time_offset_s`, never guessed.
260
+ 6. **Errors on the measured run's own samples**, never on a shared resampled grid.
261
+ 7. **Every decision travels with the numbers.** `Comparison` holds `time_offset_s`, `dropped` and
262
+ `coverage` beside `error` and `metrics`, and round-trips through JSON/npz like any message.
263
+ 8. **Metric names are stable strings** (`positions/<joint>`, `position`, `rotation`, `x`…`rz`,
264
+ `lag`, `cross_track`, `channels/<name>`), so adding a metric never changes a type. `rows()`
265
+ gives them as tidy rows for tables.
266
+
267
+ Not yet in (prior art: evo, trajectopy, ROS 2 `joint_trajectory_controller`, ISO 9283): exact
268
+ contour error (nearest point on the reference path), latency estimation, relative/drift error
269
+ over windows (evo RPE), tolerances with a pass/fail outcome (`path_tolerance`, `goal_tolerance`),
270
+ accuracy vs repeatability across N runs (ISO 9283). Each is a new metric or a new function over
271
+ `Comparison`s; none changes the shape above.
272
+
273
+ ## Extending without forking
274
+
275
+ 1. **Extra per-sample data → `channels`.** Gains, feed-forward torque, controller mode,
276
+ temperature, a waypoint's name: `traj.with_channel("kp", kp)`. No new type needed.
277
+ 2. **A new kind of trajectory → subclass `Trajectory` in your own file** and declare its
278
+ `sample()` fields. Indexing, iteration, interpolation, resampling, plotting and serialisation
279
+ come for free:
280
+
281
+ ```python
282
+ @dataclass(frozen=True, eq=False, repr=False, kw_only=True)
283
+ class GripperTrajectory(Trajectory):
284
+ width: Array = sample() # metres
285
+ grasping: Array | None = sample(default=None, interp="hold", dtype=bool)
286
+ ```
287
+ 3. **Business types → your own dataclasses that hold robot_msgs values** (composition). A
288
+ `GraspCandidate(target: Detection3D, approach: Pose, quality: float)` lives in your
289
+ application, not here.
290
+ 4. **A new standard type** (it exists in a ROS package and crosses a wire between domains) →
291
+ add it here, one file, with a catalog example; the contract tests then cover it.
292
+
293
+ ## Storage is the cross-language contract; the API is the views
294
+
295
+ Zach, 2026-11-10: *"I don't really care about the internal implementation, do it for the best
296
+ performance on any language. With the views it can act however we want it to be."* So:
297
+
298
+ - **What callers see is the views** — `traj.positions`, `traj[i]`, iteration, `at(t)`, slices.
299
+ They are the stable API; how a type stores its data behind them may change for speed.
300
+ - **What crosses a language boundary is the storage**, and it is chosen to be zero-copy in every
301
+ language: one contiguous, little-endian, C-ordered array per field, time on axis 0
302
+ (struct-of-arrays). That is exactly an Arrow record batch, an `Eigen::Map`, an `ndarray` view in
303
+ Rust, or a flatbuffer vector — a C++ or Rust `robot_msgs` maps the same bytes without parsing.
304
+ - **Field names, units, frames and conventions are the semantics**, bound early and identical in
305
+ every language; the technology (numpy today, C++/Rust bindings later) binds late.