roboapi 0.4.2__py3-none-any.whl

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 (94) hide show
  1. roboapi/__init__.py +2 -0
  2. roboapi/calibration/__init__.py +6 -0
  3. roboapi/calibration/profile.py +139 -0
  4. roboapi/calibration/session.py +350 -0
  5. roboapi/cli.py +215 -0
  6. roboapi/config/__init__.py +22 -0
  7. roboapi/config/loader.py +119 -0
  8. roboapi/config/models.py +191 -0
  9. roboapi/embodiment.py +251 -0
  10. roboapi/errors.py +37 -0
  11. roboapi/geometry.py +337 -0
  12. roboapi/hardware/__init__.py +5 -0
  13. roboapi/hardware/base.py +113 -0
  14. roboapi/hardware/camera.py +167 -0
  15. roboapi/hardware/franka/__init__.py +5 -0
  16. roboapi/hardware/franka/arm.py +476 -0
  17. roboapi/hardware/franka/desk.py +555 -0
  18. roboapi/hardware/franka/feedback.py +196 -0
  19. roboapi/hardware/franka/guiding.py +104 -0
  20. roboapi/hardware/franka/ik.py +543 -0
  21. roboapi/hardware/franka/limits.py +110 -0
  22. roboapi/hardware/franka/settings.py +116 -0
  23. roboapi/hardware/robotiq.py +350 -0
  24. roboapi/hardware/ur/__init__.py +16 -0
  25. roboapi/hardware/ur/build.py +92 -0
  26. roboapi/hardware/ur/compliant.py +128 -0
  27. roboapi/hardware/ur/dashboard.py +103 -0
  28. roboapi/hardware/ur/feedback.py +159 -0
  29. roboapi/hardware/ur/ipc.py +206 -0
  30. roboapi/hardware/ur/native/CMakeLists.txt +37 -0
  31. roboapi/hardware/ur/native/dependencies.lock.json +13 -0
  32. roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
  33. roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
  34. roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
  35. roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
  36. roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
  37. roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
  38. roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
  39. roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
  40. roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
  41. roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
  42. roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
  43. roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
  44. roboapi/hardware/ur/native/worker.cpp +147 -0
  45. roboapi/hardware/ur/position.py +253 -0
  46. roboapi/hardware/ur/settings.py +95 -0
  47. roboapi/images.py +16 -0
  48. roboapi/initialize.py +137 -0
  49. roboapi/mcp.py +497 -0
  50. roboapi/motion.py +125 -0
  51. roboapi/naming.py +7 -0
  52. roboapi/operations.py +45 -0
  53. roboapi/policy/__init__.py +7 -0
  54. roboapi/policy/client.py +277 -0
  55. roboapi/policy/protocol.py +166 -0
  56. roboapi/policy/settings.py +79 -0
  57. roboapi/product-names.json +12 -0
  58. roboapi/records.py +149 -0
  59. roboapi/runtime.py +911 -0
  60. roboapi/semantics.py +6 -0
  61. roboapi/tasks.py +337 -0
  62. roboapi/web/__init__.py +5 -0
  63. roboapi/web/presets.py +44 -0
  64. roboapi/web/server.py +419 -0
  65. roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
  66. roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
  67. roboapi/web/static/assets/Inter-OFL.txt +93 -0
  68. roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
  69. roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
  70. roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
  71. roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
  72. roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
  73. roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
  74. roboapi/web/static/assets/React-LICENSE.txt +23 -0
  75. roboapi/web/static/assets/Three-LICENSE.txt +23 -0
  76. roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
  77. roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
  78. roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
  79. roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
  80. roboapi/web/static/index.html +15 -0
  81. roboapi-0.4.2.dist-info/METADATA +205 -0
  82. roboapi-0.4.2.dist-info/RECORD +94 -0
  83. roboapi-0.4.2.dist-info/WHEEL +5 -0
  84. roboapi-0.4.2.dist-info/entry_points.txt +2 -0
  85. roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
  86. roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
  87. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
  88. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
  89. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
  90. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
  91. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
  92. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
  93. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
  94. roboapi-0.4.2.dist-info/top_level.txt +1 -0
roboapi/embodiment.py ADDED
@@ -0,0 +1,251 @@
1
+ """Physical device composition and access through the shared coordinate mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from contextlib import contextmanager
8
+ from dataclasses import replace
9
+
10
+ import numpy as np
11
+
12
+ from .config import Config
13
+ from .errors import CalibrationRequired, ControlNotReady, NotAlive
14
+ from .semantics import TRAINING_FRAME
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Embodiment:
20
+ """One arm, gripper and camera; device lifecycle and mapped Cartesian access."""
21
+
22
+ def __init__(self, cfg: Config, arm, gripper, camera) -> None:
23
+ self.cfg = cfg
24
+ self.arm = arm
25
+ self.gripper = gripper
26
+ self.camera = camera
27
+ self.command_lock = threading.Lock()
28
+ self.connected = False
29
+ self.calibrated = False
30
+ self.control_ready = False
31
+ self.feedback = None
32
+ self.preview_errors = {}
33
+ self.preview_notices = {}
34
+
35
+ def new_feedback_reader(self):
36
+ arm = self.cfg.arm
37
+ if arm.type == "ur":
38
+ from .hardware.ur.feedback import URFeedbackReader
39
+ return URFeedbackReader(arm.ip)
40
+ from .hardware.franka.feedback import FrankaFeedbackReader
41
+ return FrankaFeedbackReader(arm)
42
+
43
+ def new_guiding_session(self):
44
+ if self.cfg.arm.type == "franka":
45
+ from .hardware.franka.guiding import FrankaGuiding
46
+ return FrankaGuiding(self.cfg.arm)
47
+ return None
48
+
49
+ def close_feedback(self) -> None:
50
+ reader, self.feedback = self.feedback, None
51
+ if reader is not None:
52
+ try:
53
+ reader.disconnect()
54
+ except BaseException:
55
+ self.feedback = reader
56
+ raise
57
+
58
+ @contextmanager
59
+ def prepare_feedback(self):
60
+ """Publish feedback only after connection and caller checks succeed."""
61
+ self.close_feedback()
62
+ reader = self.new_feedback_reader()
63
+ try:
64
+ reader.connect()
65
+ yield reader
66
+ except BaseException:
67
+ self.feedback = reader
68
+ try:
69
+ self.close_feedback()
70
+ except Exception:
71
+ logger.warning("Feedback cleanup failed", exc_info=True)
72
+ raise
73
+ self.preview_errors.pop("arm", None)
74
+ self.preview_notices.pop("arm", None)
75
+ self.feedback = reader
76
+
77
+ def connect_preview(self) -> None:
78
+ """Connect independent read-only sources without initializing arm or gripper control."""
79
+ for name in ("camera", "arm"):
80
+ try:
81
+ if name == "camera":
82
+ self.camera.connect()
83
+ elif not self.connected and not getattr(self.feedback, "live", False):
84
+ with self.prepare_feedback():
85
+ pass
86
+ self.preview_errors.pop(name, None)
87
+ self.preview_notices.pop(name, None)
88
+ except ControlNotReady as exc:
89
+ self.preview_errors.pop(name, None)
90
+ self.preview_notices[name] = str(exc)
91
+ logger.info("%s preview: %s", name, exc)
92
+ except Exception as exc:
93
+ self.preview_notices.pop(name, None)
94
+ self.preview_errors[name] = str(exc)
95
+ logger.warning("%s preview unavailable: %s", name, exc)
96
+
97
+ def connect(self) -> None:
98
+ if self.connected:
99
+ return
100
+ if not self.calibrated:
101
+ raise CalibrationRequired("finish installation calibration before connecting control")
102
+ self.arm.check_motion_ready()
103
+ self.close_feedback()
104
+ self.preview_errors.clear()
105
+ self.preview_notices.clear()
106
+ connected = []
107
+
108
+ def rollback_part(part) -> None:
109
+ try:
110
+ part.disconnect()
111
+ except Exception as exc: # rollback must not hide the original failure
112
+ logger.warning("connect rollback(%s) failed: %s", type(part).__name__, exc)
113
+
114
+ try:
115
+ # Arm last: it acquires the Desk token and starts torque control.
116
+ for part in (self.gripper, self.camera, self.arm):
117
+ try:
118
+ part.connect()
119
+ connected.append(part)
120
+ except Exception:
121
+ # A component can allocate resources before connect() reports failure (the
122
+ # camera starts its reader thread before its first-frame timeout). Clean up
123
+ # the failing component as well as all earlier components.
124
+ rollback_part(part)
125
+ raise
126
+ except Exception:
127
+ for part in reversed(connected):
128
+ rollback_part(part)
129
+ raise
130
+ self.connected = True
131
+
132
+ def disconnect_control(self) -> None:
133
+ """Leave control before teaching, preserving camera preview and surfacing failures."""
134
+ self.control_ready = False
135
+ self.arm.disconnect()
136
+ self.gripper.disconnect()
137
+ self.connected = False
138
+
139
+ def disconnect(self) -> None:
140
+ self.close_feedback()
141
+ for part in (self.arm, self.camera, self.gripper):
142
+ try:
143
+ part.disconnect()
144
+ except Exception as exc: # teardown must not mask the original failure
145
+ logger.warning("disconnect(%s) failed: %s", type(part).__name__, exc)
146
+ self.connected = False
147
+ self.preview_errors.clear()
148
+ self.preview_notices.clear()
149
+
150
+ def require_control(self) -> None:
151
+ if not self.control_ready:
152
+ raise CalibrationRequired("calibration_required: finish coordinate setup before using robot tools")
153
+
154
+ def status(self) -> dict:
155
+ arm_control_connected = bool(self.connected and self.arm.live)
156
+ arm_connected = arm_control_connected or bool(getattr(self.feedback, "live", False))
157
+ gripper_connected = bool(self.connected and self.gripper.live)
158
+ camera_connected = bool(self.camera.live)
159
+ age = None
160
+ if camera_connected:
161
+ try:
162
+ _, age = self.camera.read()
163
+ except Exception:
164
+ camera_connected = False
165
+ connected = arm_connected and gripper_connected and camera_connected
166
+ errors = self.device_errors
167
+ return {"connected": connected,
168
+ "alive": bool(self.connected and connected and age is not None and age <= self.cfg.camera.stale_after_s),
169
+ "arm_connected": arm_connected, "gripper_connected": gripper_connected,
170
+ "arm_control_connected": arm_control_connected,
171
+ "camera_connected": camera_connected,
172
+ "camera_age_ms": age * 1e3 if age is not None else None,
173
+ "camera_stale_after_ms": self.cfg.camera.stale_after_s * 1e3,
174
+ "device_notices": dict(self.preview_notices),
175
+ "device_errors": errors,
176
+ "last_error": next(filter(None, errors.values()), None)}
177
+
178
+ @property
179
+ def alive(self) -> bool:
180
+ return self.status()["alive"]
181
+
182
+ @property
183
+ def device_errors(self) -> dict:
184
+ return {"arm": self.arm.last_error if self.connected else (
185
+ getattr(self.feedback, "last_error", None) or self.preview_errors.get("arm")),
186
+ "gripper": self.gripper.last_error if self.connected else None,
187
+ "camera": getattr(self.camera, "last_error", None) or self.preview_errors.get("camera")}
188
+
189
+ @property
190
+ def last_error(self) -> str | None:
191
+ return next(filter(None, self.device_errors.values()), None)
192
+
193
+ def require_alive(self) -> None:
194
+ self.require_control()
195
+ if not self.alive:
196
+ raise NotAlive(f"not_alive: {self.last_error or 'camera stale or disconnected'}. Call recover().")
197
+
198
+ def get_state(self) -> dict:
199
+ """Full state. `joint_states` lives here and in the log, but not on the MCP surface:
200
+ the agent cannot act on six radians, and the policy observation does not contain them."""
201
+ return {
202
+ "joint_states": self.arm.get_joints().tolist(),
203
+ "eef_pose": self.arm.get_eef_pose().tolist(),
204
+ "eef_frame": TRAINING_FRAME,
205
+ "gripper_state": float(self.gripper.get_ratio()),
206
+ "alive": self.alive,
207
+ }
208
+
209
+ def get_camera(self) -> tuple[np.ndarray, float]:
210
+ return self.camera.read()
211
+
212
+ def hold(self) -> None:
213
+ """Atomically stop UR/Robotiq; other arms retain their measured-state hold."""
214
+ with self.command_lock:
215
+ try:
216
+ if hold := getattr(self.arm, "hold", None):
217
+ hold()
218
+ elif self.arm.live:
219
+ self.arm.servo_pose(self.arm.get_eef_pose(), np.zeros(6))
220
+ finally:
221
+ if hold := getattr(self.gripper, "hold", None):
222
+ hold()
223
+
224
+
225
+ def build_embodiment(cfg: Config) -> Embodiment:
226
+ """Assemble one synchronous robot stack with lazy optional-driver imports."""
227
+ cfg.validate()
228
+ if cfg.arm.type == "ur":
229
+ from .hardware.ur import create_arm
230
+ arm = create_arm(cfg.arm)
231
+ else:
232
+ from .hardware.franka import FrankaArm
233
+ arm = FrankaArm(cfg.arm)
234
+
235
+ from .geometry import FrameAdapter
236
+ from .hardware.base import FramedArm
237
+
238
+ identity = [0, 0, 0, 1, 0, 0, 0]
239
+ arm = FramedArm(arm, FrameAdapter(identity, identity))
240
+
241
+ if cfg.gripper.type == "urcap":
242
+ from .hardware.robotiq import RobotiqURCap
243
+ gripper = RobotiqURCap(replace(cfg.gripper, ip=cfg.gripper.ip or cfg.arm.ip))
244
+ else:
245
+ from .hardware.robotiq import RobotiqModbus
246
+ gripper = RobotiqModbus(cfg.gripper)
247
+
248
+ from .hardware.camera import V4L2Camera
249
+ camera = V4L2Camera(cfg.camera.device)
250
+
251
+ return Embodiment(cfg, arm, gripper, camera)
roboapi/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Expected runtime conditions exposed by all operation interfaces."""
2
+
3
+
4
+ class PolicyUnavailable(RuntimeError):
5
+ """The model API cannot complete the current operation."""
6
+
7
+
8
+ class RobotError(RuntimeError):
9
+ """Base for expected conditions returned through the MCP surface."""
10
+
11
+ error = "robot_error"
12
+ hint = ""
13
+
14
+
15
+ class NotAlive(RobotError):
16
+ error = "not_alive"
17
+ hint = "call recover()"
18
+
19
+
20
+ class ControlNotReady(RobotError):
21
+ error = "control_not_ready"
22
+ hint = "follow the controller instruction and retry the same operation"
23
+
24
+
25
+ class LoopRunning(RobotError):
26
+ error = "loop_running"
27
+ hint = "call stop() first"
28
+
29
+
30
+ class CalibrationRequired(RobotError):
31
+ error = "calibration_required"
32
+ hint = "open Devices > Calibrate in the web interface, confirm safety and finish coordinate calibration"
33
+
34
+
35
+ class CalibrationError(RobotError):
36
+ error = "calibration_action_failed"
37
+ hint = "follow the installation phase and inspect detail"
roboapi/geometry.py ADDED
@@ -0,0 +1,337 @@
1
+ """Pose representations and the conversions between them.
2
+
3
+ Three representations meet in this project and they are easy to confuse:
4
+
5
+ * **pose7d (ours, public)** -- ``[x, y, z, qw, qx, qy, qz]``. Everything above the driver
6
+ boundary speaks this: `get_state`, `servo_pose`, the MCP surface.
7
+ * **pose7d (policy wire)** -- ``[x, y, z, qx, qy, qz, qw]``. scipy order. The
8
+ observation we send the policy server uses THIS, so `obs_pose` reorders at that seam.
9
+
10
+ Also **rotvec6** ``[x, y, z, rx, ry, rz]`` (axis-angle), which is what UR's ``servoL`` and
11
+ ``getActualTCPPose`` speak.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import NamedTuple
17
+
18
+ import numpy as np
19
+ from scipy.spatial.transform import Rotation, Slerp
20
+
21
+
22
+ def quat_wxyz_to_xyzw(q: np.ndarray) -> np.ndarray:
23
+ q = np.asarray(q, dtype=np.float64)
24
+ return np.concatenate([q[..., 1:4], q[..., 0:1]], axis=-1)
25
+
26
+
27
+ def quat_xyzw_to_wxyz(q: np.ndarray) -> np.ndarray:
28
+ q = np.asarray(q, dtype=np.float64)
29
+ return np.concatenate([q[..., 3:4], q[..., 0:3]], axis=-1)
30
+
31
+
32
+ def normalize_quat(q: np.ndarray) -> np.ndarray:
33
+ q = np.asarray(q, dtype=np.float64)
34
+ n = np.linalg.norm(q, axis=-1, keepdims=True)
35
+ if np.any(n == 0.0):
36
+ raise ValueError("zero-norm quaternion")
37
+ return q / n
38
+
39
+
40
+ def pose7_to_matrix(pose7: np.ndarray) -> np.ndarray:
41
+ """Our pose7d (wxyz) -> a homogeneous transform."""
42
+ p = np.asarray(pose7, dtype=np.float64)
43
+ if p.shape != (7,) or not np.all(np.isfinite(p)):
44
+ raise ValueError("pose7 must contain 7 finite values")
45
+ out = np.eye(4)
46
+ out[:3, :3] = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(p[3:7]))).as_matrix()
47
+ out[:3, 3] = p[:3]
48
+ return out
49
+
50
+
51
+ def matrix_to_pose7(matrix: np.ndarray) -> np.ndarray:
52
+ """A homogeneous transform -> our pose7d (wxyz)."""
53
+ transform = np.asarray(matrix, dtype=np.float64)
54
+ if transform.shape != (4, 4) or not np.all(np.isfinite(transform)):
55
+ raise ValueError("transform must be a finite 4x4 matrix")
56
+ if not np.allclose(transform[3], [0.0, 0.0, 0.0, 1.0], atol=1e-9):
57
+ raise ValueError("transform has an invalid homogeneous row")
58
+ quat = Rotation.from_matrix(transform[:3, :3]).as_quat()
59
+ return np.concatenate([transform[:3, 3], quat_xyzw_to_wxyz(quat)])
60
+
61
+
62
+ class FrameAdapter:
63
+ """Map poses and point velocities between native and training coordinates.
64
+
65
+ Tool rotation changes the axes at the flange centre, never the control point.
66
+ Geometric twists describe velocity at that point, not at the world origin.
67
+ """
68
+
69
+ def __init__(self, task_from_base_pose7, native_from_tool_pose7):
70
+ self.task_from_base = pose7_to_matrix(task_from_base_pose7)
71
+ self.native_from_tool = pose7_to_matrix(native_from_tool_pose7)
72
+ if np.any(np.abs(self.native_from_tool[:3, 3]) > 1e-12):
73
+ raise ValueError("tool translation is not supported; control point is the flange centre")
74
+ self.base_from_task = np.linalg.inv(self.task_from_base)
75
+ self.tool_from_native = np.linalg.inv(self.native_from_tool)
76
+
77
+ def to_task(self, pose7) -> np.ndarray:
78
+ return matrix_to_pose7(self.task_from_base @ pose7_to_matrix(pose7) @ self.native_from_tool)
79
+
80
+ def to_native(self, pose7) -> np.ndarray:
81
+ return matrix_to_pose7(self.base_from_task @ pose7_to_matrix(pose7) @ self.tool_from_native)
82
+
83
+ def twist_to_native(self, twist) -> np.ndarray:
84
+ values = np.asarray(twist, dtype=float)
85
+ if values.shape != (6,) or not np.all(np.isfinite(values)):
86
+ raise ValueError("twist must contain 6 finite values")
87
+ rotation = self.base_from_task[:3, :3]
88
+ return np.concatenate([rotation @ values[:3], rotation @ values[3:]])
89
+
90
+
91
+ def pose_twists(poses: np.ndarray, dt: float) -> np.ndarray:
92
+ """World-frame central-difference twists with zero chunk endpoints."""
93
+ values = np.asarray(poses, dtype=np.float64)
94
+ if values.ndim != 2 or values.shape[1] != 7 or not np.all(np.isfinite(values)):
95
+ raise ValueError("poses must be an Nx7 finite array")
96
+ if not np.isfinite(dt) or dt <= 0.0:
97
+ raise ValueError("dt must be positive")
98
+ twists = np.zeros((len(values), 6), dtype=np.float64)
99
+ if len(values) < 3:
100
+ return twists
101
+ twists[1:-1, :3] = (values[2:, :3] - values[:-2, :3]) / (2.0 * dt)
102
+ rotations = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(values[:, 3:7])))
103
+ for i in range(1, len(values) - 1):
104
+ twists[i, 3:] = (rotations[i + 1] * rotations[i - 1].inv()).as_rotvec() / (2.0 * dt)
105
+ return twists
106
+
107
+
108
+ def obs_pose(pose7: np.ndarray) -> np.ndarray:
109
+ """Our pose7d (wxyz) -> the policy server's pose7d (xyzw)."""
110
+ p = np.asarray(pose7, dtype=np.float64)
111
+ return np.concatenate([p[..., 0:3], quat_wxyz_to_xyzw(p[..., 3:7])], axis=-1)
112
+
113
+
114
+ def pose7_to_rotvec6(pose7: np.ndarray) -> np.ndarray:
115
+ """Our pose7d (wxyz) -> UR's ``[x, y, z, rx, ry, rz]``."""
116
+ p = np.asarray(pose7, dtype=np.float64)
117
+ if p.shape[-1] != 7:
118
+ raise ValueError(f"pose7 expects last dim 7, got {p.shape[-1]}")
119
+ rotvec = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(p[..., 3:7]))).as_rotvec()
120
+ return np.concatenate([p[..., 0:3], rotvec], axis=-1)
121
+
122
+
123
+ def rotvec6_to_pose7(vec6: np.ndarray) -> np.ndarray:
124
+ """UR's ``[x, y, z, rx, ry, rz]`` -> our pose7d (wxyz)."""
125
+ v = np.asarray(vec6, dtype=np.float64)
126
+ if v.shape[-1] != 6:
127
+ raise ValueError(f"rotvec6 expects last dim 6, got {v.shape[-1]}")
128
+ quat = Rotation.from_rotvec(v[..., 3:6]).as_quat() # xyzw
129
+ return np.concatenate([v[..., 0:3], quat_xyzw_to_wxyz(quat)], axis=-1)
130
+
131
+
132
+ def pose_delta(a: np.ndarray, b: np.ndarray) -> tuple[float, float]:
133
+ """(translation distance in m, rotation angle in rad) between two pose7d."""
134
+ a = np.asarray(a, dtype=np.float64)
135
+ b = np.asarray(b, dtype=np.float64)
136
+ ra = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(a[3:7])))
137
+ rb = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(b[3:7])))
138
+ return float(np.linalg.norm(b[0:3] - a[0:3])), float((rb * ra.inv()).magnitude())
139
+
140
+
141
+ # -- the agent-facing rotation representation ----------------------------------
142
+ #
143
+ # Internal poses use quaternions. MCP exposes roll/pitch/yaw in degrees.
144
+ #
145
+ # Roll is about fixed base X, pitch about fixed Y, yaw about fixed Z,
146
+ # composed as R = Rz(yaw) @ Ry(pitch) @ Rx(roll). That is scipy's extrinsic "xyz".
147
+ #
148
+ # CAVEAT: near pitch = +/-90 deg roll and yaw become degenerate (gimbal lock) and the values
149
+ # read back are unstable. Relative moves avoid it entirely because they compose rotations
150
+ # rather than adding angles; absolute moves near vertical-pitch do not.
151
+
152
+ def pose7_to_xyzrpy(pose7: np.ndarray) -> np.ndarray:
153
+ """pose7d (wxyz) -> ``[x, y, z, roll, pitch, yaw]`` with the angles in degrees."""
154
+ p = np.asarray(pose7, dtype=np.float64)
155
+ rpy = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(p[3:7]))).as_euler("xyz", degrees=True)
156
+ return np.concatenate([p[0:3], rpy])
157
+
158
+
159
+ def xyzrpy_to_pose7(xyzrpy: np.ndarray) -> np.ndarray:
160
+ """``[x, y, z, roll, pitch, yaw]`` (degrees) -> pose7d (wxyz)."""
161
+ v = np.asarray(xyzrpy, dtype=np.float64)
162
+ if v.shape[-1] != 6:
163
+ raise ValueError(f"xyzrpy expects last dim 6, got {v.shape[-1]}")
164
+ quat = Rotation.from_euler("xyz", v[3:6], degrees=True).as_quat()
165
+ return np.concatenate([v[0:3], quat_xyzw_to_wxyz(quat)])
166
+
167
+
168
+ def rotate_pose(pose7: np.ndarray, rpy_deg) -> np.ndarray:
169
+ """Apply a base-frame rpy delta to a pose: ``R_new = R_delta @ R_current``.
170
+
171
+ Composed, not added. Adding Euler angles is wrong for anything but tiny deltas, and it is
172
+ what makes naive relative rotation drift.
173
+ """
174
+ p = np.asarray(pose7, dtype=np.float64)
175
+ delta = Rotation.from_euler("xyz", np.asarray(rpy_deg, dtype=np.float64), degrees=True)
176
+ cur = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(p[3:7])))
177
+ return np.concatenate([p[0:3], quat_xyzw_to_wxyz((delta * cur).as_quat())])
178
+
179
+
180
+ # Peak-to-average speed ratio for smootherstep. Scale duration by this factor
181
+ # to respect the configured peak speed while easing in and out at the endpoints.
182
+ SMOOTHERSTEP_PEAK = 1.875
183
+
184
+
185
+ def smootherstep(n: int) -> np.ndarray:
186
+ """`n` samples of the smootherstep easing over (0, 1].
187
+
188
+ Shared by every interpolated track, so the gripper stays in step with the pose instead of
189
+ following a second, subtly different curve.
190
+ """
191
+ tau = np.arange(1, n + 1, dtype=np.float64) / n
192
+ return tau * tau * tau * (tau * (tau * 6.0 - 15.0) + 10.0)
193
+
194
+
195
+ def interpolate_pose(start7: np.ndarray, end7: np.ndarray, n: int) -> np.ndarray:
196
+ """`n` pose7d samples from start to end: smootherstep in time, lerp + slerp in space."""
197
+ a = np.asarray(start7, dtype=np.float64)
198
+ b = np.asarray(end7, dtype=np.float64)
199
+ s = smootherstep(n)
200
+ xyz = a[0:3] + np.outer(s, b[0:3] - a[0:3])
201
+ rots = Rotation.from_quat(
202
+ np.stack([quat_wxyz_to_xyzw(normalize_quat(a[3:7])), quat_wxyz_to_xyzw(normalize_quat(b[3:7]))])
203
+ )
204
+ quats = quat_xyzw_to_wxyz(Slerp([0.0, 1.0], rots)(s).as_quat())
205
+ return np.concatenate([xyz, quats], axis=1)
206
+
207
+
208
+ def target_pose(current7: np.ndarray, x=None, y=None, z=None,
209
+ roll=None, pitch=None, yaw=None, relative: bool = False) -> np.ndarray:
210
+ """Build a pose7d target from partially specified agent input.
211
+
212
+ Omitted axes hold their current value, which is what makes "lift 5 cm" and "move over the
213
+ bin" one-liners instead of a full pose the caller has to reconstruct correctly.
214
+ Relative rotation COMPOSES in the base frame (``R_delta @ R_current``); it does not add
215
+ Euler angles.
216
+ """
217
+ cur = np.asarray(current7, dtype=np.float64)
218
+ cur_xyzrpy = pose7_to_xyzrpy(cur)
219
+ if relative:
220
+ xyz = cur_xyzrpy[0:3] + np.array([x or 0.0, y or 0.0, z or 0.0], dtype=np.float64)
221
+ rotated = rotate_pose(cur, [roll or 0.0, pitch or 0.0, yaw or 0.0])
222
+ return np.concatenate([xyz, rotated[3:7]])
223
+ picked = [
224
+ cur_xyzrpy[i] if v is None else float(v)
225
+ for i, v in enumerate((x, y, z, roll, pitch, yaw))
226
+ ]
227
+ return xyzrpy_to_pose7(picked)
228
+
229
+
230
+ # -- waypoint paths --------------------------------------------------------------
231
+
232
+ # A leg shorter than this is a repeated point. Below it the arc-length parameterisation would
233
+ # divide by nearly nothing and a gripper change on it would plan an absurd duration.
234
+ MIN_LEG_M = 1e-4
235
+
236
+
237
+ class Path(NamedTuple):
238
+ poses: np.ndarray # (T, 7) pose7d, one frame per `dt`
239
+ grips: np.ndarray # (T,)
240
+ passed: np.ndarray # (T,) int: waypoints passed once frame i has been sent
241
+ blends: np.ndarray # (n,) corner radius actually used at each waypoint (last is 0)
242
+ path_m: float
243
+ planned_s: float
244
+
245
+
246
+ def plan_path(start7, targets7, g_start: float, grips, blends, *, speed: float,
247
+ angular_speed: float, gripper_travel_s: float, dt: float, max_s: float = 120.0) -> Path:
248
+ """Sparse waypoints -> one dense track at `dt`, the same shape a policy chunk has.
249
+
250
+ Geometry: the polyline through the points, with every interior corner replaced by a
251
+ quadratic Bezier of radius `blend`, clamped to half the shorter adjacent leg (as UR's
252
+ moveL does). That makes the path C1 and keeps it INSIDE each corner: a curve that passes
253
+ through a corner point at speed must leave the polyline's hull somewhere, and an
254
+ interpolating spline leaves it on the outside by an amount set by the neighbours -- on an
255
+ LLM-authored "descend to the object" that is a dip below the object. A corner with blend 0,
256
+ and the last point, are stops: the track comes to rest there and starts again. A leg of
257
+ zero length (a repeated point that changes the gripper) is bracketed by stops by the clamp
258
+ alone, so a dwell needs no special case.
259
+
260
+ Timing: between stops one smootherstep profile runs over the polyline arc length, and the
261
+ Bezier is parameterised on that same arc length. The velocity is then continuous
262
+ everywhere and the arm only slows INSIDE a corner (to cos(turn/2) of the profile at the
263
+ apex), never above the profile. Rotation and gripper are slerp / lerp per leg on the same
264
+ parameter, so each waypoint's orientation and gripper value are met as it is passed.
265
+
266
+ Ponytail: rotation and gripper are slaved to arc length, so one leg that is rotation- or
267
+ gripper-limited slows the whole stretch between stops, and a short leg carrying a big
268
+ rotation slows it a lot. `max_s` bounds each planned stretch, independently of policy run
269
+ duration. It refuses the absurd cases with a hint; the upgrade path is
270
+ per-leg time allocation with a rate ramp across each blend. Not needed at cm-scale legs.
271
+ """
272
+ P = np.vstack([np.asarray(start7, dtype=np.float64)[None, :], np.asarray(targets7, dtype=np.float64)])
273
+ G = np.r_[float(g_start), np.asarray(grips, dtype=np.float64)]
274
+ n = len(P) - 1
275
+ if n < 1:
276
+ raise ValueError("a path needs at least one waypoint")
277
+ L = np.linalg.norm(np.diff(P[:, :3], axis=0), axis=1) # leg j: P[j] -> P[j+1]
278
+ L[L < MIN_LEG_M] = 0.0
279
+ dth = np.array([pose_delta(P[j], P[j + 1])[1] for j in range(n)])
280
+ dg = np.abs(np.diff(G))
281
+ r = np.zeros(n + 1) # corner radius at point i
282
+ for i in range(1, n):
283
+ r[i] = min(float(blends[i - 1]), L[i - 1] / 2.0, L[i] / 2.0)
284
+ stops = [0] + [i for i in range(1, n + 1) if r[i] == 0.0]
285
+
286
+ poses, gr, passed = [], [], []
287
+ planned = 0.0
288
+ for a, b in zip(stops[:-1], stops[1:]): # one run: points a..b, legs a..b-1
289
+ legs = slice(a, b)
290
+ cum = np.r_[0.0, np.cumsum(L[legs])]
291
+ total = float(cum[-1])
292
+ if total == 0.0: # a repeated point: dwell
293
+ T = SMOOTHERSTEP_PEAK * max(dth[a] / angular_speed, dg[a] * gripper_travel_s)
294
+ m = _frames(T, dt, max_s, a)
295
+ s = smootherstep(m)
296
+ pos = interpolate_pose(P[a], P[a + 1], m)
297
+ g = G[a] + s * (G[a + 1] - G[a])
298
+ done = np.where(s >= 1.0, b, a)
299
+ else:
300
+ nominal = np.maximum.reduce([L[legs] / speed, dth[legs] / angular_speed, dg[legs] * gripper_travel_s])
301
+ slowest = int(np.argmax(nominal * total / L[legs]))
302
+ T = SMOOTHERSTEP_PEAK * float(nominal[slowest] * total / L[legs][slowest])
303
+ m = _frames(T, dt, max_s, a + slowest)
304
+ sigma = smootherstep(m) * total
305
+ j = np.clip(np.searchsorted(cum, sigma, side="right") - 1, 0, b - a - 1)
306
+ frac = (sigma - cum[j]) / L[a + j]
307
+ xyz = P[a + j, :3] + frac[:, None] * (P[a + j + 1, :3] - P[a + j, :3])
308
+ for i in range(a + 1, b): # interior corners, all blended
309
+ lo = cum[i - a] - r[i]
310
+ mask = (sigma >= lo) & (sigma <= cum[i - a] + r[i])
311
+ if not mask.any():
312
+ continue
313
+ t = ((sigma[mask] - lo) / (2.0 * r[i]))[:, None]
314
+ A = P[i, :3] - r[i] * (P[i, :3] - P[i - 1, :3]) / L[i - 1]
315
+ B = P[i, :3] + r[i] * (P[i + 1, :3] - P[i, :3]) / L[i]
316
+ xyz[mask] = (1 - t) ** 2 * A + 2 * (1 - t) * t * P[i, :3] + t**2 * B
317
+ rots = Rotation.from_quat(quat_wxyz_to_xyzw(normalize_quat(P[a:b + 1, 3:7])))
318
+ quat = quat_xyzw_to_wxyz(Slerp(cum, rots)(sigma).as_quat())
319
+ pos = np.concatenate([xyz, quat], axis=1)
320
+ g = np.interp(sigma, cum, G[a:b + 1])
321
+ done = a + np.searchsorted(cum[1:], sigma, side="right")
322
+ poses.append(pos)
323
+ gr.append(g)
324
+ passed.append(done)
325
+ planned += T
326
+ return Path(np.concatenate(poses), np.concatenate(gr), np.concatenate(passed).astype(int),
327
+ r[1:], float(L.sum()), planned)
328
+
329
+
330
+ def _frames(T: float, dt: float, max_s: float, leg: int) -> int:
331
+ if T > max_s:
332
+ raise ValueError(
333
+ f"the stretch around waypoint {leg + 1} plans {T:.0f} s, over the {max_s:.0f} s ceiling: a "
334
+ f"short leg carrying a large rotation or gripper change slows everything between "
335
+ f"stops. Give that waypoint blend=0, or its own repeated point."
336
+ )
337
+ return max(1, int(np.ceil(T / dt)))
@@ -0,0 +1,5 @@
1
+ """Hardware interfaces and composable device adapters."""
2
+
3
+ from .base import Arm, FramedArm, Gripper
4
+
5
+ __all__ = ["Arm", "FramedArm", "Gripper"]