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.
- roboapi/__init__.py +2 -0
- roboapi/calibration/__init__.py +6 -0
- roboapi/calibration/profile.py +139 -0
- roboapi/calibration/session.py +350 -0
- roboapi/cli.py +215 -0
- roboapi/config/__init__.py +22 -0
- roboapi/config/loader.py +119 -0
- roboapi/config/models.py +191 -0
- roboapi/embodiment.py +251 -0
- roboapi/errors.py +37 -0
- roboapi/geometry.py +337 -0
- roboapi/hardware/__init__.py +5 -0
- roboapi/hardware/base.py +113 -0
- roboapi/hardware/camera.py +167 -0
- roboapi/hardware/franka/__init__.py +5 -0
- roboapi/hardware/franka/arm.py +476 -0
- roboapi/hardware/franka/desk.py +555 -0
- roboapi/hardware/franka/feedback.py +196 -0
- roboapi/hardware/franka/guiding.py +104 -0
- roboapi/hardware/franka/ik.py +543 -0
- roboapi/hardware/franka/limits.py +110 -0
- roboapi/hardware/franka/settings.py +116 -0
- roboapi/hardware/robotiq.py +350 -0
- roboapi/hardware/ur/__init__.py +16 -0
- roboapi/hardware/ur/build.py +92 -0
- roboapi/hardware/ur/compliant.py +128 -0
- roboapi/hardware/ur/dashboard.py +103 -0
- roboapi/hardware/ur/feedback.py +159 -0
- roboapi/hardware/ur/ipc.py +206 -0
- roboapi/hardware/ur/native/CMakeLists.txt +37 -0
- roboapi/hardware/ur/native/dependencies.lock.json +13 -0
- roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
- roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
- roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
- roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
- roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
- roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
- roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
- roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
- roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
- roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
- roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
- roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
- roboapi/hardware/ur/native/worker.cpp +147 -0
- roboapi/hardware/ur/position.py +253 -0
- roboapi/hardware/ur/settings.py +95 -0
- roboapi/images.py +16 -0
- roboapi/initialize.py +137 -0
- roboapi/mcp.py +497 -0
- roboapi/motion.py +125 -0
- roboapi/naming.py +7 -0
- roboapi/operations.py +45 -0
- roboapi/policy/__init__.py +7 -0
- roboapi/policy/client.py +277 -0
- roboapi/policy/protocol.py +166 -0
- roboapi/policy/settings.py +79 -0
- roboapi/product-names.json +12 -0
- roboapi/records.py +149 -0
- roboapi/runtime.py +911 -0
- roboapi/semantics.py +6 -0
- roboapi/tasks.py +337 -0
- roboapi/web/__init__.py +5 -0
- roboapi/web/presets.py +44 -0
- roboapi/web/server.py +419 -0
- roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
- roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
- roboapi/web/static/assets/Inter-OFL.txt +93 -0
- roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
- roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
- roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
- roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
- roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi/web/static/assets/React-LICENSE.txt +23 -0
- roboapi/web/static/assets/Three-LICENSE.txt +23 -0
- roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
- roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
- roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
- roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
- roboapi/web/static/index.html +15 -0
- roboapi-0.4.2.dist-info/METADATA +205 -0
- roboapi-0.4.2.dist-info/RECORD +94 -0
- roboapi-0.4.2.dist-info/WHEEL +5 -0
- roboapi-0.4.2.dist-info/entry_points.txt +2 -0
- roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
- roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
- roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
- 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)))
|