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/runtime.py
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
"""Policy loop, manual motion and the shared operation gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, replace
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from . import geometry as geo
|
|
14
|
+
from . import config
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .calibration import Calibration
|
|
17
|
+
from .semantics import ACTION_DT, HOME_POSE7, TRAINING_FRAME
|
|
18
|
+
from .motion import dispatch, wait_for_pose, wait_until, workspace_targets
|
|
19
|
+
from .operations import OperationGate, exclusive
|
|
20
|
+
from .records import RunRecords
|
|
21
|
+
from .tasks import TaskArchive
|
|
22
|
+
from .embodiment import Embodiment, build_embodiment
|
|
23
|
+
from .errors import CalibrationRequired, ControlNotReady, LoopRunning
|
|
24
|
+
from .policy import ActionChunk, OpenAIChatPolicyClient, PolicyUnavailable
|
|
25
|
+
from .policy.settings import (
|
|
26
|
+
policy_token_configured, validate_policy_endpoint, write_policy_token,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
IDLE, RUNNING, PAUSED = "idle", "running", "paused"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Runtime:
|
|
35
|
+
"""The one background loop, plus the guard that keeps manual motion off the arm
|
|
36
|
+
while it runs. Everything the MCP server and the CLI call goes through here."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, emb: Embodiment, policy, *, config_path: str | Path | None = None) -> None:
|
|
39
|
+
self.emb = emb
|
|
40
|
+
self.cfg = emb.cfg
|
|
41
|
+
self.config_path = Path(config_path).expanduser().resolve() if config_path is not None else None
|
|
42
|
+
self.policy = policy
|
|
43
|
+
self.records = RunRecords(self.cfg.runtime)
|
|
44
|
+
self.tasks = TaskArchive(Path(self.cfg.runtime.log_dir).expanduser() / "tasks")
|
|
45
|
+
self.emb.camera.on_event = self.records.event
|
|
46
|
+
self._observation_after = None
|
|
47
|
+
self._control_trace = []
|
|
48
|
+
self._lock = threading.RLock()
|
|
49
|
+
self.operations = OperationGate()
|
|
50
|
+
self._mode = IDLE
|
|
51
|
+
previous_task = self.tasks.current()
|
|
52
|
+
self._instruction: str | None = previous_task["instruction"] if previous_task else None
|
|
53
|
+
self._applied_instruction: str | None = None
|
|
54
|
+
self._camera_failures = 0
|
|
55
|
+
self._run_source: str | None = None
|
|
56
|
+
self._abort = threading.Event()
|
|
57
|
+
# Aborts a BLOCKING tool (move_waypoints) rather than the loop. Separate from _abort
|
|
58
|
+
# because those run with mode == IDLE, where the loop and its abort do not exist.
|
|
59
|
+
self._thread: threading.Thread | None = None
|
|
60
|
+
self._started_at = 0.0
|
|
61
|
+
self._chunk_started_at = 0.0
|
|
62
|
+
self._chunks = 0
|
|
63
|
+
self._late = 0
|
|
64
|
+
self._stop_reason: str | None = None
|
|
65
|
+
self._stop_requested: str | None = None
|
|
66
|
+
self._arm_fault_exit = False
|
|
67
|
+
self._direction_check: str | None = None
|
|
68
|
+
self.calibration = Calibration(self)
|
|
69
|
+
|
|
70
|
+
# -- introspection -----------------------------------------------------
|
|
71
|
+
@property
|
|
72
|
+
def mode(self) -> str:
|
|
73
|
+
return self._mode
|
|
74
|
+
|
|
75
|
+
def status(self) -> dict:
|
|
76
|
+
"""One call must be worth polling for: mode, instruction, progress, health, why it stopped."""
|
|
77
|
+
with self._lock:
|
|
78
|
+
mode, instruction, run_source = self._mode, self._instruction, self._run_source
|
|
79
|
+
applied_instruction = self._applied_instruction
|
|
80
|
+
running = mode in (RUNNING, PAUSED)
|
|
81
|
+
task = self.tasks.current()
|
|
82
|
+
return {
|
|
83
|
+
"timestamp": time.time(),
|
|
84
|
+
"mode": mode,
|
|
85
|
+
"instruction": instruction,
|
|
86
|
+
"instruction_pending": running and instruction != applied_instruction,
|
|
87
|
+
"run_source": run_source,
|
|
88
|
+
"task_id": task["id"] if task else None,
|
|
89
|
+
"task_elapsed_s": task["elapsed_s"] if task else 0.0,
|
|
90
|
+
"chunk_elapsed_time": round(time.monotonic() - self._chunk_started_at, 3) if running else 0.0,
|
|
91
|
+
"elapsed_s": round(time.monotonic() - self._started_at, 2) if running else 0.0,
|
|
92
|
+
"chunks_done": self._chunks,
|
|
93
|
+
"late_frames": self._late,
|
|
94
|
+
"stop_reason": self._stop_reason,
|
|
95
|
+
"policy_ready": self.policy.ready,
|
|
96
|
+
"control_ready": self.emb.control_ready,
|
|
97
|
+
"inter_chunk_pause_s": self.cfg.runtime.inter_chunk_pause_s,
|
|
98
|
+
"workspace": self.workspace_config(),
|
|
99
|
+
**self.emb.status(),
|
|
100
|
+
"calibration": self.calibration.status(),
|
|
101
|
+
**self.records.snapshot(),
|
|
102
|
+
"session": self.policy.session_status,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@exclusive
|
|
106
|
+
def clear_monitor_errors(self) -> dict:
|
|
107
|
+
"""Dismiss previous run errors without changing a live hardware fault or moving the arm."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
if self._mode != IDLE:
|
|
110
|
+
raise LoopRunning("stop the policy before clearing previous errors")
|
|
111
|
+
self._stop_reason = None
|
|
112
|
+
self.records.clear_errors()
|
|
113
|
+
return self.status()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def workspace_config(self) -> dict:
|
|
117
|
+
return {**asdict(self.cfg.workspace), "home_xyz_m": list(HOME_POSE7[:3])}
|
|
118
|
+
|
|
119
|
+
@exclusive
|
|
120
|
+
def configure_workspace(self, *, enabled, min_xyz_m, max_xyz_m) -> dict:
|
|
121
|
+
"""Save idle installation limits without connecting or moving any device."""
|
|
122
|
+
if self.mode != IDLE or self.calibration.phase not in {"acquire", "complete"}:
|
|
123
|
+
raise LoopRunning("Stop and finish calibration before changing the workspace")
|
|
124
|
+
if self.config_path is None:
|
|
125
|
+
raise ValueError("Workspace settings require a local installation configuration path")
|
|
126
|
+
workspace = config.WorkspaceConfig(enabled, min_xyz_m, max_xyz_m)
|
|
127
|
+
workspace.validate()
|
|
128
|
+
config.save(replace(self.cfg, workspace=workspace), self.config_path)
|
|
129
|
+
self.cfg.workspace = workspace
|
|
130
|
+
self.records.event("Workspace settings saved")
|
|
131
|
+
return self.workspace_config()
|
|
132
|
+
|
|
133
|
+
def _require_workspace_start(self) -> None:
|
|
134
|
+
workspace = self.cfg.workspace
|
|
135
|
+
# A frame round-trip must not turn a point on the face into an outside start.
|
|
136
|
+
if workspace.enabled and not workspace.contains(self.emb.arm.get_eef_pose()[:3], atol=1e-9):
|
|
137
|
+
raise ControlNotReady("Robot is outside the workspace. Return Home before starting motion.")
|
|
138
|
+
|
|
139
|
+
def policy_config(self) -> dict:
|
|
140
|
+
"""Safe policy metadata; never returns the credential or a prefix of it."""
|
|
141
|
+
cfg = self.cfg.policy
|
|
142
|
+
token_file = str(Path(cfg.token_file).expanduser()) if cfg.token_file else None
|
|
143
|
+
return {
|
|
144
|
+
"type": cfg.type,
|
|
145
|
+
"url": cfg.url or None,
|
|
146
|
+
"model": cfg.model or None,
|
|
147
|
+
"token_file": token_file,
|
|
148
|
+
"api_key_configured": bool(cfg.token_file and policy_token_configured(cfg.token_file)),
|
|
149
|
+
"policy_ready": self.policy.ready,
|
|
150
|
+
"inter_chunk_pause_s": self.cfg.runtime.inter_chunk_pause_s,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@exclusive
|
|
154
|
+
def configure_policy(self, *, url: str, model: str, inter_chunk_pause_s: float,
|
|
155
|
+
api_key: str | None = None) -> dict:
|
|
156
|
+
"""Verify and save the idle model connection and run settings together."""
|
|
157
|
+
cfg = self.cfg.policy
|
|
158
|
+
if cfg.type != "openai":
|
|
159
|
+
raise ValueError("policy connection setup requires policy.type='openai'")
|
|
160
|
+
if not cfg.token_file:
|
|
161
|
+
raise ValueError("policy.token_file is not configured")
|
|
162
|
+
if self.config_path is None:
|
|
163
|
+
raise ValueError("policy settings require a local installation configuration path")
|
|
164
|
+
url, model = validate_policy_endpoint(url, model)
|
|
165
|
+
updated = replace(self.cfg, policy=replace(cfg, url=url, model=model),
|
|
166
|
+
runtime=replace(self.cfg.runtime, inter_chunk_pause_s=inter_chunk_pause_s))
|
|
167
|
+
updated.validate()
|
|
168
|
+
with self._lock:
|
|
169
|
+
if self._mode != IDLE:
|
|
170
|
+
raise LoopRunning("stop the policy before changing its settings")
|
|
171
|
+
path = Path(cfg.token_file).expanduser()
|
|
172
|
+
previous = path.read_bytes() if api_key is not None and path.exists() else None
|
|
173
|
+
previous_mode = path.stat().st_mode & 0o777 if previous is not None else None
|
|
174
|
+
if api_key is not None:
|
|
175
|
+
write_policy_token(cfg.token_file, api_key)
|
|
176
|
+
candidate = None
|
|
177
|
+
try:
|
|
178
|
+
candidate = OpenAIChatPolicyClient(
|
|
179
|
+
url, cfg.token_file, model,
|
|
180
|
+
image_height=cfg.image_height,
|
|
181
|
+
timeout_s=cfg.timeout_s,
|
|
182
|
+
use_env_proxy=cfg.use_env_proxy,
|
|
183
|
+
)
|
|
184
|
+
candidate.connect()
|
|
185
|
+
config.save(updated, self.config_path)
|
|
186
|
+
except Exception:
|
|
187
|
+
if candidate is not None:
|
|
188
|
+
try:
|
|
189
|
+
candidate.close()
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.warning("rejected policy connection did not close cleanly")
|
|
192
|
+
if api_key is not None:
|
|
193
|
+
if previous is None:
|
|
194
|
+
path.unlink(missing_ok=True)
|
|
195
|
+
else:
|
|
196
|
+
path.write_bytes(previous)
|
|
197
|
+
path.chmod(previous_mode or 0o600)
|
|
198
|
+
raise
|
|
199
|
+
previous_client = self.policy
|
|
200
|
+
cfg.url, cfg.model = url, model
|
|
201
|
+
self.cfg.runtime.inter_chunk_pause_s = inter_chunk_pause_s
|
|
202
|
+
self.policy = candidate
|
|
203
|
+
try:
|
|
204
|
+
previous_client.close()
|
|
205
|
+
except Exception:
|
|
206
|
+
logger.warning("previous policy connection did not close cleanly")
|
|
207
|
+
self.records.event("Model settings saved")
|
|
208
|
+
return {"ok": True, **self.policy_config()}
|
|
209
|
+
|
|
210
|
+
def observation(self, with_image: bool = True) -> tuple[dict, np.ndarray | None]:
|
|
211
|
+
"""Status + the arm/gripper reading + optionally the frame, sampled together.
|
|
212
|
+
|
|
213
|
+
Two separate calls would hand the agent a picture from one instant and a pose from
|
|
214
|
+
another, with no way to see the skew. `image_age_ms` reports the residual.
|
|
215
|
+
"""
|
|
216
|
+
obs = self.status()
|
|
217
|
+
if not self.emb.connected:
|
|
218
|
+
if self.emb.feedback is not None and self.emb.feedback.live and self.emb.calibrated:
|
|
219
|
+
pose = self.emb.arm.adapter.to_task(self.emb.feedback.snapshot()["pose"])
|
|
220
|
+
values = geo.pose7_to_xyzrpy(pose)
|
|
221
|
+
obs.update(eef_xyz_m=values[:3].round(4).tolist(),
|
|
222
|
+
eef_rpy_deg=values[3:].round(2).tolist(), eef_frame=TRAINING_FRAME)
|
|
223
|
+
return obs, self.emb.get_camera()[0] if with_image and obs["camera_connected"] else None
|
|
224
|
+
# Split and unit-named on purpose. A bare 7-vector invites an LLM to send radians,
|
|
225
|
+
# or a quaternion it cannot author; xyz-in-metres and rpy-in-degrees it can both read
|
|
226
|
+
# back and write. pose7d stays the internal representation everywhere else.
|
|
227
|
+
pose = geo.pose7_to_xyzrpy(self.emb.arm.get_eef_pose())
|
|
228
|
+
obs["eef_xyz_m"] = pose[0:3].round(4).tolist()
|
|
229
|
+
obs["eef_rpy_deg"] = pose[3:6].round(2).tolist()
|
|
230
|
+
obs["eef_frame"] = TRAINING_FRAME
|
|
231
|
+
obs["gripper_state"] = round(float(self.emb.gripper.get_ratio()), 4)
|
|
232
|
+
image = None
|
|
233
|
+
if with_image:
|
|
234
|
+
image, age = self.emb.camera.read_after(
|
|
235
|
+
self._observation_after, self.cfg.camera.stale_after_s,
|
|
236
|
+
)
|
|
237
|
+
obs["image_age_ms"] = round(age * 1e3, 1)
|
|
238
|
+
return obs, image
|
|
239
|
+
|
|
240
|
+
# -- loop control ------------------------------------------------------
|
|
241
|
+
@exclusive
|
|
242
|
+
def start(self, instruction: str, *, source: str = "mcp", new_task: bool = False) -> dict:
|
|
243
|
+
if source not in {"mcp", "web"}:
|
|
244
|
+
raise ValueError("run source must be mcp or web")
|
|
245
|
+
self._validate_instruction(instruction)
|
|
246
|
+
with self._lock:
|
|
247
|
+
if self._mode != IDLE:
|
|
248
|
+
raise LoopRunning(f"loop_running: already {self._mode}. Call stop() first.")
|
|
249
|
+
self._stop_requested = None
|
|
250
|
+
self._abort.clear()
|
|
251
|
+
self.emb.require_alive()
|
|
252
|
+
self._require_workspace_start()
|
|
253
|
+
self.policy.prepare_session()
|
|
254
|
+
self._instruction = instruction
|
|
255
|
+
self._applied_instruction = None
|
|
256
|
+
self._camera_failures = getattr(self.emb.camera, "failure_count", 0)
|
|
257
|
+
self._run_source = source
|
|
258
|
+
self._stop_reason = None
|
|
259
|
+
self._arm_fault_exit = False
|
|
260
|
+
self._chunks = 0
|
|
261
|
+
self._late = 0
|
|
262
|
+
self._started_at = time.monotonic()
|
|
263
|
+
self._chunk_started_at = self._started_at
|
|
264
|
+
self._mode = RUNNING
|
|
265
|
+
self._begin_task(instruction, new_task=new_task)
|
|
266
|
+
self.records.event("Run started")
|
|
267
|
+
self._thread = threading.Thread(target=self._run, name="policy-loop", daemon=True)
|
|
268
|
+
self._thread.start()
|
|
269
|
+
return self.status()
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _validate_instruction(instruction: str) -> None:
|
|
273
|
+
if not isinstance(instruction, str) or not instruction.strip():
|
|
274
|
+
raise ValueError("instruction must not be empty")
|
|
275
|
+
|
|
276
|
+
def set_instruction(self, instruction: str, *, source: str = "mcp") -> dict:
|
|
277
|
+
"""Takes effect at the NEXT inference: the loop reads it once per iteration.
|
|
278
|
+
|
|
279
|
+
Latency includes the rest of the complete chunk, frame waits, and one
|
|
280
|
+
inference. The instruction does not truncate a chunk. Use stop() to interrupt execution.
|
|
281
|
+
|
|
282
|
+
Meant for a running loop: start() takes the instruction itself, and overwrites
|
|
283
|
+
anything set beforehand.
|
|
284
|
+
"""
|
|
285
|
+
self._validate_instruction(instruction)
|
|
286
|
+
with self._lock:
|
|
287
|
+
if self._mode not in (RUNNING, PAUSED):
|
|
288
|
+
raise LoopRunning("loop_not_running: start the policy before changing its instruction")
|
|
289
|
+
self._require_source(source)
|
|
290
|
+
self._instruction = instruction
|
|
291
|
+
self.records.event("Instruction submitted")
|
|
292
|
+
return self.status()
|
|
293
|
+
|
|
294
|
+
def _require_source(self, source: str) -> None:
|
|
295
|
+
if source not in {"mcp", "web"}:
|
|
296
|
+
raise ValueError("run source must be mcp or web")
|
|
297
|
+
if self._mode != IDLE and source != self._run_source:
|
|
298
|
+
raise LoopRunning(f"policy started through {self._run_source}; stop it before using {source}")
|
|
299
|
+
|
|
300
|
+
def _begin_task(self, instruction: str, *, new_task: bool = False) -> None:
|
|
301
|
+
try:
|
|
302
|
+
self.tasks.begin(instruction, self.cfg.policy.model, self._run_source,
|
|
303
|
+
self.emb.camera, new_task=new_task)
|
|
304
|
+
except Exception:
|
|
305
|
+
logger.exception("task recording could not start")
|
|
306
|
+
self.records.event("Task recording unavailable; robot control remains active", "warning")
|
|
307
|
+
|
|
308
|
+
def _finish_task(self, reason: str) -> None:
|
|
309
|
+
try:
|
|
310
|
+
events = self.records.snapshot()["inference_events"]
|
|
311
|
+
infer_ms = events[-1]["infer_ms"] if events else None
|
|
312
|
+
self.tasks.finish(reason, infer_ms=infer_ms)
|
|
313
|
+
except Exception:
|
|
314
|
+
logger.exception("task recording could not finish")
|
|
315
|
+
self.records.event("Could not save task recording", "warning")
|
|
316
|
+
|
|
317
|
+
def pause(self, *, source: str = "mcp") -> dict:
|
|
318
|
+
with self._lock:
|
|
319
|
+
self._require_source(source)
|
|
320
|
+
if self._mode != RUNNING:
|
|
321
|
+
return self.status()
|
|
322
|
+
self._mode = PAUSED
|
|
323
|
+
self.policy.record_lifecycle("pause")
|
|
324
|
+
self.records.event("Run paused")
|
|
325
|
+
self._hold_measured()
|
|
326
|
+
with self._lock:
|
|
327
|
+
if self._mode == PAUSED:
|
|
328
|
+
self._finish_task("paused")
|
|
329
|
+
return self.status()
|
|
330
|
+
|
|
331
|
+
@exclusive
|
|
332
|
+
def resume(self, *, source: str = "mcp") -> dict:
|
|
333
|
+
self.emb.require_control()
|
|
334
|
+
with self._lock:
|
|
335
|
+
self._require_source(source)
|
|
336
|
+
if self._mode != PAUSED:
|
|
337
|
+
return self.status()
|
|
338
|
+
self._require_workspace_start()
|
|
339
|
+
self._mode = RUNNING
|
|
340
|
+
self._begin_task(self._instruction)
|
|
341
|
+
self.policy.record_lifecycle("resume")
|
|
342
|
+
self.records.event("Run resumed")
|
|
343
|
+
return self.status()
|
|
344
|
+
|
|
345
|
+
def stop(self, reason: str = "stopped") -> dict:
|
|
346
|
+
"""Stop whatever is driving the arm: the policy loop, or a blocking move.
|
|
347
|
+
|
|
348
|
+
The blocking-move abort signal is set BEFORE the idle early-return, because a
|
|
349
|
+
calibration Home runs with mode == IDLE. Drivers that support vendor cancellation
|
|
350
|
+
poll this signal and return to measured-state hold before the operation unwinds.
|
|
351
|
+
"""
|
|
352
|
+
self.operations.interrupt()
|
|
353
|
+
# Hold and signal before waiting on metadata or model setup under _lock.
|
|
354
|
+
self._stop_requested = reason
|
|
355
|
+
self._abort.set()
|
|
356
|
+
self._hold_measured()
|
|
357
|
+
thread = self._thread
|
|
358
|
+
with self._lock:
|
|
359
|
+
if self._mode == IDLE:
|
|
360
|
+
return self.status()
|
|
361
|
+
self._stop_reason = reason
|
|
362
|
+
self._arm_fault_exit = False
|
|
363
|
+
self._abort.set()
|
|
364
|
+
self._finish_task(reason)
|
|
365
|
+
if thread is not None and thread is not threading.current_thread():
|
|
366
|
+
thread.join(timeout=5.0)
|
|
367
|
+
self._hold_measured()
|
|
368
|
+
return self.status()
|
|
369
|
+
|
|
370
|
+
def _hold_measured(self) -> None:
|
|
371
|
+
self.emb.hold()
|
|
372
|
+
|
|
373
|
+
def _run(self) -> None:
|
|
374
|
+
try:
|
|
375
|
+
if not self._abort.is_set():
|
|
376
|
+
self.policy.begin_session()
|
|
377
|
+
while not self._abort.is_set():
|
|
378
|
+
if self._interrupted() and self._abort.is_set():
|
|
379
|
+
break
|
|
380
|
+
if self._mode == PAUSED:
|
|
381
|
+
# Hold: stop feeding new targets. The arm stays on the last one -- on
|
|
382
|
+
# Franka this must NOT be done by ending the joint session, which
|
|
383
|
+
# ramps to damping-only and lets the arm sag.
|
|
384
|
+
time.sleep(0.02)
|
|
385
|
+
continue
|
|
386
|
+
self._chunk_started_at = time.monotonic()
|
|
387
|
+
step_started = time.perf_counter()
|
|
388
|
+
step_at = time.time()
|
|
389
|
+
self.records.begin_inference(step_at, step_started)
|
|
390
|
+
try:
|
|
391
|
+
result = self.step(None, should_stop=self._interrupted,
|
|
392
|
+
on_phase=self.records.set_phase)
|
|
393
|
+
except PolicyUnavailable as exc:
|
|
394
|
+
self.records.record_inference(step_at, step_started, exc=exc)
|
|
395
|
+
with self._lock:
|
|
396
|
+
if not self._abort.is_set():
|
|
397
|
+
self._stop_reason = f"policy_unavailable: {exc}"
|
|
398
|
+
self._arm_fault_exit = False
|
|
399
|
+
break
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
self.records.record_inference(step_at, step_started, exc=exc)
|
|
402
|
+
# A driver can fail mid-chunk. If the rig is not alive that is a hardware
|
|
403
|
+
# fault and takes the same path as one seen at the top of the loop;
|
|
404
|
+
# anything else is a real bug and must not be swallowed as a fault.
|
|
405
|
+
with self._lock:
|
|
406
|
+
# A step can fail while an earlier stop is unwinding it.
|
|
407
|
+
# Preserve that cause and collect native errors only for arm exits.
|
|
408
|
+
if self._abort.is_set():
|
|
409
|
+
break
|
|
410
|
+
if self.emb.alive:
|
|
411
|
+
raise
|
|
412
|
+
camera_failed = getattr(self.emb.camera, "failure_count", 0) != self._camera_failures
|
|
413
|
+
self._arm_fault_exit = (
|
|
414
|
+
not camera_failed and not self.emb.arm.live and bool(self.emb.arm.last_error)
|
|
415
|
+
)
|
|
416
|
+
detail = (getattr(self.emb.camera, "last_error", None) or "camera disconnected"
|
|
417
|
+
if camera_failed else self.emb.last_error or str(exc))
|
|
418
|
+
self._stop_reason = f"not_alive: {detail}"
|
|
419
|
+
break
|
|
420
|
+
self.records.record_inference(step_at, step_started, result=result)
|
|
421
|
+
if result["chunk_len"]:
|
|
422
|
+
self._chunks += 1
|
|
423
|
+
self._late += result["late_frames"]
|
|
424
|
+
except PolicyUnavailable as exc:
|
|
425
|
+
with self._lock:
|
|
426
|
+
if not self._abort.is_set():
|
|
427
|
+
self._stop_reason = f"policy_unavailable: {exc}"
|
|
428
|
+
self._arm_fault_exit = False
|
|
429
|
+
except Exception as exc: # never let the loop thread die silently
|
|
430
|
+
logger.exception("policy loop failed")
|
|
431
|
+
with self._lock:
|
|
432
|
+
if not self._abort.is_set():
|
|
433
|
+
self._stop_reason = f"error: {exc}"
|
|
434
|
+
self._arm_fault_exit = False
|
|
435
|
+
finally:
|
|
436
|
+
# Local hold always precedes server cleanup, including a transport
|
|
437
|
+
# failure or stop that interrupted an action partway through.
|
|
438
|
+
try:
|
|
439
|
+
self._hold_measured()
|
|
440
|
+
except Exception:
|
|
441
|
+
logger.warning("local hold could not be confirmed at policy exit")
|
|
442
|
+
if self._arm_fault_exit:
|
|
443
|
+
wait_for_fault = getattr(self.emb.arm, "wait_for_fault", None)
|
|
444
|
+
if wait_for_fault is not None:
|
|
445
|
+
try:
|
|
446
|
+
# The native exception can arrive while the driver's worker
|
|
447
|
+
# unwinds. Finish that collection before recording this run.
|
|
448
|
+
error = wait_for_fault()
|
|
449
|
+
with self._lock:
|
|
450
|
+
if self._arm_fault_exit and error:
|
|
451
|
+
self._stop_reason = f"not_alive: {error}"
|
|
452
|
+
except Exception:
|
|
453
|
+
logger.warning("Could not collect the final arm fault", exc_info=True)
|
|
454
|
+
try:
|
|
455
|
+
if self._stop_requested is not None:
|
|
456
|
+
self._stop_reason = self._stop_requested
|
|
457
|
+
self._finish_task(self._stop_reason or "stopped")
|
|
458
|
+
self.policy.end_session(self._stop_reason or "stopped")
|
|
459
|
+
finally:
|
|
460
|
+
with self._lock:
|
|
461
|
+
self._mode = IDLE
|
|
462
|
+
self._run_source = None
|
|
463
|
+
if self._stop_reason is None:
|
|
464
|
+
self._stop_reason = "stopped"
|
|
465
|
+
self.records.event(f"Run stopped: {self._stop_reason}",
|
|
466
|
+
"error" if self._stop_reason.startswith(("error:", "not_alive:", "policy_unavailable:")) else "info")
|
|
467
|
+
|
|
468
|
+
def _interrupted(self) -> bool:
|
|
469
|
+
"""Interrupt frames and waits on STOP or pause.
|
|
470
|
+
|
|
471
|
+
After a pause-abort the loop parks at the top and, on resume, re-infers from a fresh
|
|
472
|
+
observation rather than replaying a plan made before the world was held still.
|
|
473
|
+
"""
|
|
474
|
+
with self._lock:
|
|
475
|
+
if self._abort.is_set():
|
|
476
|
+
return True
|
|
477
|
+
camera_failed = getattr(self.emb.camera, "failure_count", 0) != self._camera_failures
|
|
478
|
+
if camera_failed or not self.emb.alive:
|
|
479
|
+
detail = (getattr(self.emb.camera, "last_error", None) or "camera disconnected"
|
|
480
|
+
if camera_failed else self.emb.last_error or "device unavailable")
|
|
481
|
+
self._arm_fault_exit = (
|
|
482
|
+
not camera_failed and not self.emb.arm.live and bool(self.emb.arm.last_error)
|
|
483
|
+
)
|
|
484
|
+
self._stop_reason = f"not_alive: {detail}"
|
|
485
|
+
self._abort.set()
|
|
486
|
+
return self._abort.is_set() or self._mode == PAUSED
|
|
487
|
+
|
|
488
|
+
# -- guarded manual-motion surface -------------------------------------
|
|
489
|
+
def _require_idle(self) -> None:
|
|
490
|
+
self.emb.require_control()
|
|
491
|
+
if self._mode != IDLE:
|
|
492
|
+
raise LoopRunning(f"loop_running: the policy loop is {self._mode}. Call stop() first.")
|
|
493
|
+
|
|
494
|
+
_WAYPOINT_KEYS = frozenset({"x", "y", "z", "roll", "pitch", "yaw", "gripper", "relative", "blend"})
|
|
495
|
+
|
|
496
|
+
@exclusive
|
|
497
|
+
def move_waypoints(self, waypoints: list[dict]) -> dict:
|
|
498
|
+
"""Agent waypoints -> absolute targets, chained: each is completed from the one before
|
|
499
|
+
it (the first from the arm), so omitted axes hold and `relative` offsets accumulate.
|
|
500
|
+
Validation is here, at the trust boundary, before anything moves."""
|
|
501
|
+
self._require_idle()
|
|
502
|
+
if not waypoints:
|
|
503
|
+
raise ValueError("waypoints must not be empty")
|
|
504
|
+
pose = self.emb.arm.get_eef_pose()
|
|
505
|
+
grip = float(self.emb.gripper.get_ratio())
|
|
506
|
+
targets, grips, blends = [], [], []
|
|
507
|
+
for i, wp in enumerate(waypoints):
|
|
508
|
+
unknown = set(wp) - self._WAYPOINT_KEYS
|
|
509
|
+
if unknown:
|
|
510
|
+
raise ValueError(f"waypoint {i}: unknown fields {sorted(unknown)}; "
|
|
511
|
+
f"allowed: {sorted(self._WAYPOINT_KEYS)}")
|
|
512
|
+
axes = {k: wp.get(k) for k in ("x", "y", "z", "roll", "pitch", "yaw")}
|
|
513
|
+
pose = geo.target_pose(pose, **axes, relative=bool(wp.get("relative", False)))
|
|
514
|
+
# Relative waypoints start from the previous effective target, including at a wall.
|
|
515
|
+
pose = workspace_targets([pose], self.cfg.workspace)[0]
|
|
516
|
+
if wp.get("gripper") is not None:
|
|
517
|
+
grip = float(wp["gripper"])
|
|
518
|
+
if not 0.0 <= grip <= 1.0:
|
|
519
|
+
raise ValueError(f"waypoint {i}: gripper must be an opening ratio in [0, 1], got {grip}")
|
|
520
|
+
blend = self.cfg.arm.blend_radius if wp.get("blend") is None else float(wp["blend"])
|
|
521
|
+
if blend < 0.0:
|
|
522
|
+
raise ValueError(f"waypoint {i}: blend must be >= 0 m, got {blend}")
|
|
523
|
+
targets.append(pose)
|
|
524
|
+
grips.append(grip)
|
|
525
|
+
blends.append(blend)
|
|
526
|
+
targets = np.array(targets)
|
|
527
|
+
# Cleared on entry, so a stop() from before this call does not kill it; a stop() from
|
|
528
|
+
# here on is caught by the per-frame check inside the dispatch.
|
|
529
|
+
self.operations.reset_abort()
|
|
530
|
+
return self._move_path(targets, np.array(grips), np.array(blends),
|
|
531
|
+
should_stop=self.operations.abort.is_set)
|
|
532
|
+
|
|
533
|
+
def move_to_pose(self, gripper=None, **kwargs) -> dict:
|
|
534
|
+
"""One waypoint: the simple form of move_waypoints, on the same path."""
|
|
535
|
+
out = self.move_waypoints([{**kwargs, "gripper": gripper}])
|
|
536
|
+
wp = out["waypoints"][0]
|
|
537
|
+
return {**out, "target": wp["target"], "distance_m": out["path_m"],
|
|
538
|
+
"gripper": None if gripper is None else wp["gripper"]}
|
|
539
|
+
|
|
540
|
+
def _at_direction_home(self) -> bool:
|
|
541
|
+
position, angle = geo.pose_delta(HOME_POSE7, self.emb.arm.get_eef_pose())
|
|
542
|
+
# Keep each short gesture close to its recorded starting pose.
|
|
543
|
+
return bool(position <= 0.005 and angle <= np.deg2rad(2))
|
|
544
|
+
|
|
545
|
+
def direction_check_status(self) -> dict:
|
|
546
|
+
"""Read current Home readiness without connecting or moving hardware."""
|
|
547
|
+
at_home = False
|
|
548
|
+
try:
|
|
549
|
+
if (self.mode == IDLE and self.calibration.phase == "complete"
|
|
550
|
+
and self.emb.calibrated and self.emb.control_ready and self.emb.alive):
|
|
551
|
+
at_home = self._at_direction_home()
|
|
552
|
+
except Exception:
|
|
553
|
+
# Disconnected/stale feedback is normal while the device page polls.
|
|
554
|
+
pass
|
|
555
|
+
return {"at_home": at_home, "active": self._direction_check}
|
|
556
|
+
|
|
557
|
+
@exclusive
|
|
558
|
+
def check_direction(self, direction: str) -> dict:
|
|
559
|
+
"""Check task translation and rotation using the policy's full-pose executor.
|
|
560
|
+
|
|
561
|
+
Each gesture moves 5 cm and turns 30 degrees, then returns to calibrated
|
|
562
|
+
Home, keeping the measured gripper opening. Horizontal gestures tilt the
|
|
563
|
+
downward-facing tool toward travel; the upward gesture turns about task Z.
|
|
564
|
+
An explicit 'home' action returns without a gesture.
|
|
565
|
+
Stop interrupts the single path, including all remaining return targets.
|
|
566
|
+
"""
|
|
567
|
+
gestures = {"forward": {"y": -0.05, "roll": -30},
|
|
568
|
+
"left": {"x": 0.05, "pitch": -30},
|
|
569
|
+
"up": {"z": 0.05, "yaw": 30}}
|
|
570
|
+
if direction not in (*gestures, "home"):
|
|
571
|
+
raise ValueError("direction must be forward, left, up or home")
|
|
572
|
+
self.operations.reset_abort()
|
|
573
|
+
self._require_idle()
|
|
574
|
+
if not self.emb.calibrated or self.calibration.phase != "complete":
|
|
575
|
+
raise CalibrationRequired("Save coordinate calibration before checking directions")
|
|
576
|
+
self.emb.require_alive()
|
|
577
|
+
self.emb.arm.check_motion_ready()
|
|
578
|
+
if direction != "home" and not self._at_direction_home():
|
|
579
|
+
raise ControlNotReady("Return to Home before checking a direction")
|
|
580
|
+
targets = [HOME_POSE7]
|
|
581
|
+
if direction != "home":
|
|
582
|
+
targets.insert(0, geo.target_pose(HOME_POSE7, **gestures[direction], relative=True))
|
|
583
|
+
grip = float(self.emb.gripper.get_ratio())
|
|
584
|
+
self._direction_check = direction
|
|
585
|
+
try:
|
|
586
|
+
result = self._move_path(targets, [grip] * len(targets), [0.0] * len(targets),
|
|
587
|
+
should_stop=self.operations.abort.is_set, position_only=False,
|
|
588
|
+
workspace_entry=direction == "home")
|
|
589
|
+
return {**result, "direction": direction,
|
|
590
|
+
"at_home": self.direction_check_status()["at_home"]}
|
|
591
|
+
except BaseException:
|
|
592
|
+
try:
|
|
593
|
+
self.emb.hold()
|
|
594
|
+
except Exception:
|
|
595
|
+
logger.exception("Could not hold after a failed direction check")
|
|
596
|
+
raise
|
|
597
|
+
finally:
|
|
598
|
+
self._direction_check = None
|
|
599
|
+
|
|
600
|
+
@exclusive
|
|
601
|
+
def reset(self) -> dict:
|
|
602
|
+
self._require_idle()
|
|
603
|
+
self.operations.reset_abort()
|
|
604
|
+
result = self._return_home(should_stop=self.operations.abort.is_set)
|
|
605
|
+
self._clear_resolved_errors()
|
|
606
|
+
self.records.event("Returned Home")
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
@exclusive
|
|
610
|
+
def recover(self) -> dict:
|
|
611
|
+
self.emb.require_control()
|
|
612
|
+
self.stop("recover")
|
|
613
|
+
self.operations.reset_abort()
|
|
614
|
+
result = self._recover_and_home(should_stop=self.operations.abort.is_set)
|
|
615
|
+
self._clear_resolved_errors()
|
|
616
|
+
self.records.event("Devices recovered and returned Home")
|
|
617
|
+
return result
|
|
618
|
+
|
|
619
|
+
def _clear_resolved_errors(self) -> None:
|
|
620
|
+
if self.emb.alive and not self.emb.last_error and not self.operations.abort.is_set():
|
|
621
|
+
with self._lock:
|
|
622
|
+
self._stop_reason = None
|
|
623
|
+
self.records.clear_errors()
|
|
624
|
+
|
|
625
|
+
@exclusive
|
|
626
|
+
def preview(self) -> dict:
|
|
627
|
+
if self.mode != IDLE or self.calibration.phase not in {"acquire", "complete"}:
|
|
628
|
+
raise LoopRunning("finish the current operation before connecting preview")
|
|
629
|
+
self.emb.connect_preview()
|
|
630
|
+
for name, error in self.emb.preview_errors.items():
|
|
631
|
+
self.records.event(f"{name.capitalize()} preview unavailable: {error}", "error")
|
|
632
|
+
for name, notice in self.emb.preview_notices.items():
|
|
633
|
+
self.records.event(f"{name.capitalize()}: {notice}")
|
|
634
|
+
state = self.emb.status()
|
|
635
|
+
self.records.event("Preview connected" if state["arm_connected"] or state["camera_connected"] else "Preview unavailable")
|
|
636
|
+
return self.status()
|
|
637
|
+
|
|
638
|
+
@exclusive
|
|
639
|
+
def release(self) -> dict:
|
|
640
|
+
if self.mode != IDLE:
|
|
641
|
+
raise LoopRunning("finish the current operation before releasing devices")
|
|
642
|
+
calibration = self.calibration
|
|
643
|
+
calibration.close()
|
|
644
|
+
calibration.home_pose = calibration.draft = calibration.device = None
|
|
645
|
+
calibration.plane_poses = []
|
|
646
|
+
calibration.guiding_axis = None
|
|
647
|
+
calibration.phase = "complete" if calibration.profile else "acquire"
|
|
648
|
+
calibration.error = calibration.notice = None
|
|
649
|
+
self.emb.control_ready = False
|
|
650
|
+
self.disconnect()
|
|
651
|
+
self.records.event("Devices disconnected")
|
|
652
|
+
return self.status()
|
|
653
|
+
|
|
654
|
+
def connect(self) -> None:
|
|
655
|
+
if self.emb.connected:
|
|
656
|
+
return
|
|
657
|
+
self.records.open()
|
|
658
|
+
try:
|
|
659
|
+
self.policy.connect()
|
|
660
|
+
except PolicyUnavailable as exc:
|
|
661
|
+
self._close_policy()
|
|
662
|
+
logger.warning("policy is not ready; MCP setup remains available: %s", exc)
|
|
663
|
+
except Exception:
|
|
664
|
+
self._close_policy()
|
|
665
|
+
raise
|
|
666
|
+
try:
|
|
667
|
+
self.emb.connect()
|
|
668
|
+
except Exception:
|
|
669
|
+
self._close_policy()
|
|
670
|
+
raise
|
|
671
|
+
|
|
672
|
+
def _close_policy(self) -> None:
|
|
673
|
+
try:
|
|
674
|
+
self.policy.close()
|
|
675
|
+
except Exception:
|
|
676
|
+
logger.warning("policy connection did not close cleanly", exc_info=True)
|
|
677
|
+
|
|
678
|
+
def disconnect(self) -> None:
|
|
679
|
+
try:
|
|
680
|
+
self.emb.disconnect()
|
|
681
|
+
finally:
|
|
682
|
+
self._close_policy()
|
|
683
|
+
|
|
684
|
+
def close(self) -> None:
|
|
685
|
+
try:
|
|
686
|
+
self.stop("shutdown")
|
|
687
|
+
finally:
|
|
688
|
+
try:
|
|
689
|
+
self.calibration.close()
|
|
690
|
+
finally:
|
|
691
|
+
try:
|
|
692
|
+
self.disconnect()
|
|
693
|
+
finally:
|
|
694
|
+
self.tasks.close()
|
|
695
|
+
|
|
696
|
+
def _record_action(self, chunk, result, image_age, image):
|
|
697
|
+
self.records.write(chunk, result, image_age, image,
|
|
698
|
+
state=self.emb.get_state(), control_trace=self._control_trace)
|
|
699
|
+
self._control_trace = []
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def call_model(self, instruction: str | None, image: np.ndarray | None = None) -> ActionChunk:
|
|
703
|
+
self.emb.require_alive()
|
|
704
|
+
if image is None:
|
|
705
|
+
image, _ = self.emb.camera.read()
|
|
706
|
+
pose, grip = self.emb.arm.get_eef_pose(), self.emb.gripper.get_ratio()
|
|
707
|
+
if instruction is None:
|
|
708
|
+
with self._lock:
|
|
709
|
+
instruction = self._instruction or ""
|
|
710
|
+
if instruction != self._applied_instruction:
|
|
711
|
+
task = self.tasks.current()
|
|
712
|
+
if self._mode == RUNNING and task and task["instruction"] != instruction.strip():
|
|
713
|
+
self._finish_task("instruction changed")
|
|
714
|
+
self._begin_task(instruction)
|
|
715
|
+
self._applied_instruction = instruction
|
|
716
|
+
self.records.event("Instruction applied")
|
|
717
|
+
return self.policy.infer(pose, grip, image, instruction)
|
|
718
|
+
|
|
719
|
+
def apply_action(self, chunk: ActionChunk, should_stop=None, *, position_only=False,
|
|
720
|
+
workspace_entry=False) -> dict:
|
|
721
|
+
"""Dispatch the complete chunk on monotonic ABSOLUTE deadlines.
|
|
722
|
+
|
|
723
|
+
Deadline i is ``t0 + i*dt``, so scheduling error never accumulates; a late frame runs
|
|
724
|
+
immediately rather than being skipped. Skipping would jump over intermediate waypoints
|
|
725
|
+
and the inner loop would accelerate to catch up -- exactly the lunge we do not want.
|
|
726
|
+
`late` is counted and surfaced to diagnose persistent dispatch delays on hardware.
|
|
727
|
+
|
|
728
|
+
`should_stop` is checked per frame, so stop() and pause() both take effect within one
|
|
729
|
+
frame instead of waiting out a chunk. Both are human interventions; making the operator
|
|
730
|
+
watch 1.67 s of motion after asking for a hold is the wrong answer.
|
|
731
|
+
"""
|
|
732
|
+
executed = 0
|
|
733
|
+
result = None
|
|
734
|
+
|
|
735
|
+
def progress(count):
|
|
736
|
+
nonlocal executed
|
|
737
|
+
executed = count
|
|
738
|
+
|
|
739
|
+
try:
|
|
740
|
+
self.emb.require_alive()
|
|
741
|
+
if self.mode == IDLE and not workspace_entry:
|
|
742
|
+
self._require_workspace_start()
|
|
743
|
+
result = self._dispatch(chunk.arm, chunk.gripper, should_stop,
|
|
744
|
+
on_executed=progress, position_only=position_only,
|
|
745
|
+
workspace_entry=workspace_entry)
|
|
746
|
+
return result
|
|
747
|
+
finally:
|
|
748
|
+
if chunk.session_id is not None:
|
|
749
|
+
self.policy.record_execution(chunk, executed,
|
|
750
|
+
completed=result is not None and not result["aborted"])
|
|
751
|
+
|
|
752
|
+
def _dispatch(self, poses, grips, should_stop=None, *, on_executed=None, position_only=False,
|
|
753
|
+
workspace_entry=False) -> dict:
|
|
754
|
+
self.emb.arm.drain_trace()
|
|
755
|
+
try:
|
|
756
|
+
result = dispatch(self.emb.arm, poses, grips, gripper=self.emb.gripper,
|
|
757
|
+
should_stop=should_stop, on_executed=on_executed,
|
|
758
|
+
command_lock=self.emb.command_lock, position_only=position_only,
|
|
759
|
+
workspace=None if workspace_entry else self.cfg.workspace)
|
|
760
|
+
self._observation_after = result["action_period_completed_at_monotonic"] or time.monotonic()
|
|
761
|
+
return result
|
|
762
|
+
finally:
|
|
763
|
+
self._control_trace = self.emb.arm.drain_trace()
|
|
764
|
+
|
|
765
|
+
def _move_path(self, targets7, grips, blends, should_stop=None, *, position_only=False,
|
|
766
|
+
workspace_entry=False) -> dict:
|
|
767
|
+
"""Drive through absolute pose7d waypoints as ONE motion, compiled to a chunk.
|
|
768
|
+
|
|
769
|
+
Deliberately NOT one `servo_pose` per waypoint: `servoL` assumes small increments at
|
|
770
|
+
rate, so a far target makes the UR sprint at its velocity ceiling; on Franka it would
|
|
771
|
+
saturate the bounded IK and discard the intended path and timing. Streaming an
|
|
772
|
+
interpolated path instead keeps the whole move inside the compliant control loop.
|
|
773
|
+
That matters more here than
|
|
774
|
+
anywhere else: this is the one path where a pose comes straight from a language model.
|
|
775
|
+
|
|
776
|
+
The track is an `ActionChunk` and goes through `apply_action` and the jsonl log, so an
|
|
777
|
+
agent-authored motion and a policy chunk are the same thing to everything downstream.
|
|
778
|
+
"""
|
|
779
|
+
self.emb.require_alive()
|
|
780
|
+
cfg = self.cfg
|
|
781
|
+
start = self.emb.arm.get_eef_pose()
|
|
782
|
+
targets7 = workspace_targets(targets7, cfg.workspace)
|
|
783
|
+
image, image_age = self.emb.camera.read()
|
|
784
|
+
plan = geo.plan_path(
|
|
785
|
+
start, targets7, self.emb.gripper.get_ratio(), grips, blends,
|
|
786
|
+
speed=cfg.arm.cartesian_speed, angular_speed=cfg.arm.cartesian_angular_speed,
|
|
787
|
+
gripper_travel_s=cfg.gripper.full_travel_s, dt=ACTION_DT,
|
|
788
|
+
)
|
|
789
|
+
chunk = ActionChunk(plan.poses, plan.grips, 0.0, "waypoints")
|
|
790
|
+
result = self.apply_action(chunk, should_stop=should_stop, position_only=position_only,
|
|
791
|
+
workspace_entry=workspace_entry)
|
|
792
|
+
done = result["executed"]
|
|
793
|
+
out = {
|
|
794
|
+
**result,
|
|
795
|
+
"from": geo.pose7_to_xyzrpy(start).round(4).tolist(),
|
|
796
|
+
"waypoints": [
|
|
797
|
+
{"target": geo.pose7_to_xyzrpy(t).round(4).tolist(),
|
|
798
|
+
"gripper": round(float(g), 4), "blend": round(float(r), 4)}
|
|
799
|
+
for t, g, r in zip(targets7, grips, plan.blends)
|
|
800
|
+
],
|
|
801
|
+
"reached": int(plan.passed[done - 1]) if done else 0,
|
|
802
|
+
"path_m": round(plan.path_m, 4),
|
|
803
|
+
"planned_s": round(plan.planned_s, 2),
|
|
804
|
+
}
|
|
805
|
+
self._record_action(chunk, out, image_age, image)
|
|
806
|
+
return out
|
|
807
|
+
|
|
808
|
+
def step(self, instruction: str | None, should_stop=None, on_phase=None) -> dict:
|
|
809
|
+
"""One serial iteration: infer, execute, then hold before the next fresh sample."""
|
|
810
|
+
observation = self.emb.camera.read_after(
|
|
811
|
+
self._observation_after, self.cfg.camera.stale_after_s, should_stop,
|
|
812
|
+
)
|
|
813
|
+
if observation is None:
|
|
814
|
+
return {"executed": 0, "late_frames": 0, "aborted": True, "elapsed_s": 0.0,
|
|
815
|
+
"infer_ms": 0.0, "chunk_len": 0}
|
|
816
|
+
image, image_age = observation
|
|
817
|
+
if on_phase is not None:
|
|
818
|
+
on_phase("inference")
|
|
819
|
+
chunk = self.call_model(instruction, image)
|
|
820
|
+
try:
|
|
821
|
+
output = chunk.summary(self.emb.arm.get_eef_pose(), self.emb.gripper.get_ratio())
|
|
822
|
+
if on_phase is not None:
|
|
823
|
+
on_phase("execution", request_ms=chunk.request_ms)
|
|
824
|
+
except Exception:
|
|
825
|
+
if chunk.session_id is not None:
|
|
826
|
+
self.policy.record_execution(chunk, 0, completed=False)
|
|
827
|
+
raise
|
|
828
|
+
action_started = time.perf_counter()
|
|
829
|
+
result = self.apply_action(chunk, should_stop=should_stop)
|
|
830
|
+
action_ms = (time.perf_counter() - action_started) * 1e3
|
|
831
|
+
pause_s = self.cfg.runtime.inter_chunk_pause_s
|
|
832
|
+
if result["executed"] and not result["aborted"] and pause_s:
|
|
833
|
+
if on_phase is not None:
|
|
834
|
+
on_phase("inter_chunk_pause")
|
|
835
|
+
deadline = self._observation_after + pause_s
|
|
836
|
+
wait_until(deadline, should_stop)
|
|
837
|
+
# Reject frames begun during the pause. call_model samples arm/gripper only
|
|
838
|
+
# after this boundary's new camera frame arrives on the next iteration.
|
|
839
|
+
self._observation_after = time.monotonic()
|
|
840
|
+
self._record_action(chunk, result, image_age, image)
|
|
841
|
+
return {**result, "infer_ms": chunk.infer_ms, "chunk_len": len(chunk),
|
|
842
|
+
"_monitor": {"action_ms": round(action_ms, 1),
|
|
843
|
+
"request_ms": round(chunk.request_ms, 1) if chunk.request_ms is not None else None,
|
|
844
|
+
"image_age_ms": round(image_age * 1e3, 1),
|
|
845
|
+
"request_id": chunk.request_id, "output": output}}
|
|
846
|
+
|
|
847
|
+
def _return_home(self, should_stop=None) -> dict:
|
|
848
|
+
"""Open the gripper and return along the Cartesian path to training Home."""
|
|
849
|
+
self.emb.require_control()
|
|
850
|
+
self.emb.arm.check_motion_ready()
|
|
851
|
+
try:
|
|
852
|
+
self.emb.gripper.move_blocking(self.cfg.arm.home_gripper, should_stop=should_stop)
|
|
853
|
+
result = self._move_path([HOME_POSE7], [self.cfg.arm.home_gripper], [0.0],
|
|
854
|
+
should_stop=should_stop, position_only=True, workspace_entry=True)
|
|
855
|
+
if result["aborted"]:
|
|
856
|
+
raise RuntimeError("Return Home was stopped")
|
|
857
|
+
wait_for_pose(self.emb.arm, HOME_POSE7, should_stop)
|
|
858
|
+
except BaseException:
|
|
859
|
+
try:
|
|
860
|
+
self.emb.hold()
|
|
861
|
+
except Exception:
|
|
862
|
+
logger.exception("Could not confirm the stop after a failed Home return")
|
|
863
|
+
raise
|
|
864
|
+
self._observation_after = time.monotonic()
|
|
865
|
+
result = {"home_pose7": list(HOME_POSE7), "eef_frame": TRAINING_FRAME,
|
|
866
|
+
"gripper": self.cfg.arm.home_gripper}
|
|
867
|
+
if (should_stop is None or not should_stop()) and (zero_ft := getattr(self.emb.arm, "zero_ft", None)):
|
|
868
|
+
try:
|
|
869
|
+
zero_ft()
|
|
870
|
+
except Exception as exc:
|
|
871
|
+
result["warning"] = f"Home reached; force sensor zeroing failed: {exc}"
|
|
872
|
+
self.records.event(result["warning"], "warning")
|
|
873
|
+
return result
|
|
874
|
+
|
|
875
|
+
def _recover_and_home(self, should_stop=None) -> dict:
|
|
876
|
+
"""Recover the driver, then follow the Cartesian path to training Home.
|
|
877
|
+
|
|
878
|
+
Reconnect all components when control was released, so camera and policy
|
|
879
|
+
resources and the embodiment lifecycle are restored together.
|
|
880
|
+
"""
|
|
881
|
+
self.emb.require_control()
|
|
882
|
+
if should_stop is not None and should_stop():
|
|
883
|
+
raise RuntimeError("recovery_cancelled")
|
|
884
|
+
policy_ready = self.policy.ready
|
|
885
|
+
if not self.emb.connected:
|
|
886
|
+
self.connect()
|
|
887
|
+
elif not self.emb.camera.live or not policy_ready:
|
|
888
|
+
# A stale camera/policy is part of the transaction, not an isolated arm fault.
|
|
889
|
+
# Reconnect all parts in the same order used at startup and roll back together.
|
|
890
|
+
self.disconnect()
|
|
891
|
+
self.connect()
|
|
892
|
+
else:
|
|
893
|
+
if self.cfg.arm.type == "ur":
|
|
894
|
+
self.emb.arm.recover(should_stop=should_stop)
|
|
895
|
+
else:
|
|
896
|
+
self.emb.arm.recover()
|
|
897
|
+
if should_stop is not None and should_stop():
|
|
898
|
+
raise RuntimeError("recovery_cancelled")
|
|
899
|
+
self.emb.gripper.recover()
|
|
900
|
+
result = self._return_home(should_stop=should_stop)
|
|
901
|
+
return {**result, "alive": self.emb.alive, "last_error": self.emb.last_error}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def build(cfg: Config, *, config_path: str | Path | None = None) -> Runtime:
|
|
905
|
+
emb = build_embodiment(cfg)
|
|
906
|
+
policy = OpenAIChatPolicyClient(
|
|
907
|
+
cfg.policy.url, cfg.policy.token_file, cfg.policy.model,
|
|
908
|
+
image_height=cfg.policy.image_height, timeout_s=cfg.policy.timeout_s,
|
|
909
|
+
use_env_proxy=cfg.policy.use_env_proxy,
|
|
910
|
+
)
|
|
911
|
+
return Runtime(emb, policy, config_path=config_path)
|