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
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Franka: native base/flange poses -> feedback-leashed IK -> joint impedance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from ... import geometry as geo
|
|
17
|
+
from ...errors import ControlNotReady
|
|
18
|
+
from .desk import DeskLeaseError, _DeskLease, apply_tool_configuration, create_desk
|
|
19
|
+
from .ik import solve_feedback_leashed_ik
|
|
20
|
+
from .limits import load_limits
|
|
21
|
+
from .settings import COLLISION_FORCE, COLLISION_TORQUE, IKConfig
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
NUM_JOINTS = 7
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _realtime_control_start(realtime_config: str, *, inherit: bool = True):
|
|
30
|
+
"""Let each new native control thread inherit FIFO, then restore its caller.
|
|
31
|
+
|
|
32
|
+
Tracking and stop motions create native threads through move().
|
|
33
|
+
Elevate only during asynchronous startup; joining and Python work run under
|
|
34
|
+
the caller's original scheduler. Robot construction can itself elevate its
|
|
35
|
+
caller, so it uses this scope only to restore that original scheduler.
|
|
36
|
+
"""
|
|
37
|
+
if realtime_config != "enforce":
|
|
38
|
+
yield
|
|
39
|
+
return
|
|
40
|
+
policy = os.sched_getscheduler(0)
|
|
41
|
+
param = os.sched_getparam(0)
|
|
42
|
+
priority = os.sched_get_priority_max(os.SCHED_FIFO)
|
|
43
|
+
try:
|
|
44
|
+
if inherit:
|
|
45
|
+
os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(priority))
|
|
46
|
+
if (os.sched_getscheduler(0) != os.SCHED_FIFO
|
|
47
|
+
or os.sched_getparam(0).sched_priority != priority):
|
|
48
|
+
raise RuntimeError("Franka control startup requires SCHED_FIFO at maximum priority")
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
os.sched_setscheduler(0, policy, param)
|
|
52
|
+
if (os.sched_getscheduler(0) != policy
|
|
53
|
+
or os.sched_getparam(0).sched_priority != param.sched_priority):
|
|
54
|
+
raise RuntimeError("Failed to restore Franka control caller's scheduling")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _JointSession:
|
|
58
|
+
"""The small part of franky's JointImpedanceTracker needed here."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, robot, franky, cfg) -> None:
|
|
61
|
+
self.robot = robot
|
|
62
|
+
self.franky = franky
|
|
63
|
+
self._realtime_config = cfg.realtime_config
|
|
64
|
+
gains = cfg.joint_impedance
|
|
65
|
+
self.motion = franky.JointImpedanceTrackingMotion(
|
|
66
|
+
stiffness=np.asarray(gains.stiffness),
|
|
67
|
+
damping=np.asarray(gains.damping),
|
|
68
|
+
cartesian_stiffness=np.asarray(gains.cartesian_stiffness),
|
|
69
|
+
cartesian_damping=np.asarray(gains.cartesian_damping),
|
|
70
|
+
max_delta_tau=gains.max_delta_tau,
|
|
71
|
+
gains_time_constant=gains.gains_time_constant,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def start(self, q: np.ndarray) -> None:
|
|
75
|
+
self.set_target(q, np.zeros(NUM_JOINTS))
|
|
76
|
+
with _realtime_control_start(self._realtime_config):
|
|
77
|
+
self.robot.move(
|
|
78
|
+
self.motion,
|
|
79
|
+
asynchronous=True,
|
|
80
|
+
limit_rate=True,
|
|
81
|
+
cutoff_frequency=100.0,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def set_target(self, q: np.ndarray, dq: np.ndarray) -> None:
|
|
85
|
+
self.motion.set_reference(self.franky.JointReference(q=q, dq=dq))
|
|
86
|
+
|
|
87
|
+
def stop(self) -> None:
|
|
88
|
+
if bool(self.robot.is_in_control):
|
|
89
|
+
motion = self.franky.TorqueStopMotion()
|
|
90
|
+
with _realtime_control_start(self._realtime_config):
|
|
91
|
+
self.robot.move(motion, asynchronous=True)
|
|
92
|
+
try:
|
|
93
|
+
self.robot.join_motion()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
# Defensive parity with franky's tracker wrapper: self-preemption means the
|
|
96
|
+
# torque loop did stop; every other ControlException remains actionable.
|
|
97
|
+
if not isinstance(exc, getattr(self.franky, "ControlException", ())) or (
|
|
98
|
+
"Move command preempted!" not in str(exc)
|
|
99
|
+
):
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class FrankaSnapshot:
|
|
105
|
+
sample_time: float
|
|
106
|
+
received_at: float
|
|
107
|
+
q: np.ndarray
|
|
108
|
+
dq: np.ndarray
|
|
109
|
+
flange_pose_wxyz: np.ndarray
|
|
110
|
+
jacobian: np.ndarray
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FrankaArm:
|
|
114
|
+
"""Owns Desk, one Franka connection, and one 50 Hz Hybrid worker."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, cfg) -> None:
|
|
117
|
+
self.cfg = cfg
|
|
118
|
+
self._franky = None
|
|
119
|
+
self._robot = None
|
|
120
|
+
self._model = None
|
|
121
|
+
self._lease: _DeskLease | None = None
|
|
122
|
+
self._session: _JointSession | None = None
|
|
123
|
+
self._thread: threading.Thread | None = None
|
|
124
|
+
self._stop = threading.Event()
|
|
125
|
+
self._started = threading.Event()
|
|
126
|
+
self._fault: str | None = None
|
|
127
|
+
self.tool_load_ready = False
|
|
128
|
+
self._target: tuple[np.ndarray, np.ndarray] | None = None
|
|
129
|
+
self._snapshot: FrankaSnapshot | None = None
|
|
130
|
+
self._snapshot_lock = threading.Lock()
|
|
131
|
+
self._trace = collections.deque(maxlen=cfg.trace_capacity)
|
|
132
|
+
self._trace_lock = threading.Lock()
|
|
133
|
+
control_period = 1.0 / float(cfg.hybrid_hz)
|
|
134
|
+
self._limits = load_limits(cfg.robot_model, None)
|
|
135
|
+
self._ik = IKConfig(
|
|
136
|
+
nominal_dt=control_period,
|
|
137
|
+
max_rotation_step=float(cfg.cartesian_angular_speed) * control_period,
|
|
138
|
+
posture_gain=0.20,
|
|
139
|
+
posture_max_velocity=0.10,
|
|
140
|
+
)
|
|
141
|
+
self._configure_limits()
|
|
142
|
+
|
|
143
|
+
def _configure_limits(self) -> None:
|
|
144
|
+
self._lower = np.asarray(self._limits.joint_lower)
|
|
145
|
+
self._upper = np.asarray(self._limits.joint_upper)
|
|
146
|
+
self._ik = replace(
|
|
147
|
+
self._ik,
|
|
148
|
+
max_joint_velocity=self._limits.joint_velocity,
|
|
149
|
+
max_joint_acceleration=self._limits.joint_acceleration,
|
|
150
|
+
max_translation_velocity=self._limits.cartesian_velocity[0],
|
|
151
|
+
max_rotation_velocity=self._limits.cartesian_velocity[1],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def live(self) -> bool:
|
|
156
|
+
return (
|
|
157
|
+
self._robot is not None
|
|
158
|
+
and self._fault is None
|
|
159
|
+
and self._thread is not None
|
|
160
|
+
and self._thread.is_alive()
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def last_error(self) -> str | None:
|
|
165
|
+
return self._fault
|
|
166
|
+
|
|
167
|
+
def connect(self) -> None:
|
|
168
|
+
self._connect(recover_from_errors=False)
|
|
169
|
+
|
|
170
|
+
def _acquire_desk(self) -> None:
|
|
171
|
+
if self._robot is not None:
|
|
172
|
+
raise RuntimeError("Franka is already connected")
|
|
173
|
+
self.tool_load_ready = False
|
|
174
|
+
if self._lease is not None:
|
|
175
|
+
# A previous failed connect may have left an authenticated lease awaiting
|
|
176
|
+
# release. Never overwrite that handle with a new one.
|
|
177
|
+
self._lease.release()
|
|
178
|
+
self._lease = None
|
|
179
|
+
import franky
|
|
180
|
+
|
|
181
|
+
password_path = Path(self.cfg.desk_password_file).expanduser()
|
|
182
|
+
password = password_path.read_text().strip()
|
|
183
|
+
if not password:
|
|
184
|
+
raise RuntimeError(f"Franka Desk password file is empty: {password_path}")
|
|
185
|
+
self._franky = franky
|
|
186
|
+
self._fault = None
|
|
187
|
+
self._lease = _DeskLease(create_desk(self.cfg, password))
|
|
188
|
+
|
|
189
|
+
def prepare(desk):
|
|
190
|
+
try:
|
|
191
|
+
apply_tool_configuration(self.cfg, desk)
|
|
192
|
+
except ControlNotReady:
|
|
193
|
+
raise
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
raise RuntimeError(f"Franka end-effector initialization failed: {exc}") from exc
|
|
196
|
+
self.tool_load_ready = True
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
self._lease.acquire(prepare=prepare)
|
|
200
|
+
except ControlNotReady:
|
|
201
|
+
raise
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
raise RuntimeError(f"Franka Desk preparation failed: {exc}") from exc
|
|
204
|
+
|
|
205
|
+
def _connect(self, *, recover_from_errors: bool) -> None:
|
|
206
|
+
if self._robot is not None:
|
|
207
|
+
raise RuntimeError("Franka is already connected")
|
|
208
|
+
try:
|
|
209
|
+
self._acquire_desk()
|
|
210
|
+
franky = self._franky
|
|
211
|
+
realtime = (
|
|
212
|
+
franky.RealtimeConfig.Enforce
|
|
213
|
+
if self.cfg.realtime_config == "enforce"
|
|
214
|
+
else franky.RealtimeConfig.Ignore
|
|
215
|
+
)
|
|
216
|
+
with _realtime_control_start(self.cfg.realtime_config, inherit=False):
|
|
217
|
+
self._robot = franky.Robot(self.cfg.host, realtime_config=realtime)
|
|
218
|
+
self._limits = load_limits(
|
|
219
|
+
self.cfg.robot_model, getattr(self._robot, "model_urdf", None),
|
|
220
|
+
)
|
|
221
|
+
self._configure_limits()
|
|
222
|
+
if recover_from_errors:
|
|
223
|
+
self._robot.recover_from_errors()
|
|
224
|
+
# This synchronous SDK call must succeed before any control worker starts.
|
|
225
|
+
self._robot.set_collision_behavior(COLLISION_TORQUE, COLLISION_FORCE)
|
|
226
|
+
logger.info(
|
|
227
|
+
"Franka collision thresholds applied: joints=%s Nm; Cartesian=%s (N,N,N,Nm,Nm,Nm)",
|
|
228
|
+
COLLISION_TORQUE, COLLISION_FORCE,
|
|
229
|
+
)
|
|
230
|
+
self._model = self._robot.model
|
|
231
|
+
snapshot = self._read_snapshot()
|
|
232
|
+
self._ik = replace(self._ik, posture_reference=tuple(snapshot.q))
|
|
233
|
+
self._cache_snapshot(snapshot)
|
|
234
|
+
self._start_worker()
|
|
235
|
+
except Exception:
|
|
236
|
+
try:
|
|
237
|
+
self._teardown()
|
|
238
|
+
except Exception as cleanup_exc:
|
|
239
|
+
logger.error("Franka connect cleanup failed; preserving original error: %s", cleanup_exc)
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
def disconnect(self) -> None:
|
|
243
|
+
self._teardown()
|
|
244
|
+
|
|
245
|
+
def _teardown(self) -> None:
|
|
246
|
+
self._stop_worker()
|
|
247
|
+
if self._robot is not None and bool(self._robot.is_in_control):
|
|
248
|
+
self._latch_fault("Franka control session is still active; Desk token was not released")
|
|
249
|
+
raise RuntimeError(self._fault)
|
|
250
|
+
self._model = None
|
|
251
|
+
self._robot = None
|
|
252
|
+
lease = self._lease
|
|
253
|
+
if lease is not None:
|
|
254
|
+
lease.release()
|
|
255
|
+
self._lease = None
|
|
256
|
+
|
|
257
|
+
def _start_worker(self) -> None:
|
|
258
|
+
self._target = None
|
|
259
|
+
self._stop.clear()
|
|
260
|
+
self._started.clear()
|
|
261
|
+
self._thread = threading.Thread(target=self._worker, name="franka-hybrid", daemon=True)
|
|
262
|
+
self._thread.start()
|
|
263
|
+
if not self._started.wait(2.0):
|
|
264
|
+
raise RuntimeError("Franka Hybrid worker did not start")
|
|
265
|
+
if self._fault is not None:
|
|
266
|
+
raise RuntimeError(self._fault)
|
|
267
|
+
|
|
268
|
+
def _stop_worker(self) -> None:
|
|
269
|
+
self._stop.set()
|
|
270
|
+
thread = self._thread
|
|
271
|
+
if thread is not None:
|
|
272
|
+
thread.join(timeout=4.0)
|
|
273
|
+
if thread.is_alive():
|
|
274
|
+
self._latch_fault("Franka Hybrid worker did not stop")
|
|
275
|
+
raise RuntimeError(self._fault)
|
|
276
|
+
self._thread = None
|
|
277
|
+
self._target = None
|
|
278
|
+
|
|
279
|
+
def _latch_fault(self, message: str) -> None:
|
|
280
|
+
if self._fault is None:
|
|
281
|
+
self._fault = message
|
|
282
|
+
|
|
283
|
+
def wait_for_fault(self, timeout: float = 4.0) -> str | None:
|
|
284
|
+
"""Collect a late native exception without delaying fault detection or polling."""
|
|
285
|
+
thread = self._thread
|
|
286
|
+
if self._fault is not None and thread is not None and thread is not threading.current_thread():
|
|
287
|
+
thread.join(timeout=timeout)
|
|
288
|
+
if thread.is_alive():
|
|
289
|
+
logger.warning("Franka fault cleanup still pending after %.1f s: %s", timeout, self._fault)
|
|
290
|
+
return self._fault
|
|
291
|
+
|
|
292
|
+
def _read_snapshot(self) -> FrankaSnapshot:
|
|
293
|
+
state = self._robot.state
|
|
294
|
+
now = time.monotonic()
|
|
295
|
+
flange = self._model.pose(self._franky.Frame.Flange, state)
|
|
296
|
+
flange_matrix = np.asarray(flange.matrix, dtype=np.float64)
|
|
297
|
+
return FrankaSnapshot(
|
|
298
|
+
sample_time=float(state.time.to_sec()),
|
|
299
|
+
received_at=now,
|
|
300
|
+
q=np.asarray(state.q, dtype=np.float64).copy(),
|
|
301
|
+
dq=np.asarray(state.dq, dtype=np.float64).copy(),
|
|
302
|
+
flange_pose_wxyz=geo.matrix_to_pose7(flange_matrix),
|
|
303
|
+
jacobian=np.asarray(
|
|
304
|
+
self._model.zero_jacobian(self._franky.Frame.Flange, state), dtype=np.float64
|
|
305
|
+
).copy(),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _check_hardware(self) -> None:
|
|
309
|
+
if bool(self._robot.has_errors):
|
|
310
|
+
raise RuntimeError("Franka reflex or robot error is active")
|
|
311
|
+
if not bool(self._robot.is_in_control):
|
|
312
|
+
raise RuntimeError("joint impedance tracker terminated unexpectedly")
|
|
313
|
+
|
|
314
|
+
def _check_freshness(
|
|
315
|
+
self, snapshot: FrankaSnapshot, last_sample: float | None, last_received: float
|
|
316
|
+
) -> tuple[float, float, float]:
|
|
317
|
+
"""Track repeated samples for diagnostics; native SDK faults end control."""
|
|
318
|
+
if last_sample is not None and snapshot.sample_time < last_sample - 1e-9:
|
|
319
|
+
raise RuntimeError("Franka state clock moved backwards")
|
|
320
|
+
if last_sample is None or snapshot.sample_time > last_sample + 1e-9:
|
|
321
|
+
last_sample = snapshot.sample_time
|
|
322
|
+
last_received = snapshot.received_at
|
|
323
|
+
sample_age = snapshot.received_at - last_received
|
|
324
|
+
return last_sample, last_received, sample_age
|
|
325
|
+
|
|
326
|
+
def _cache_snapshot(self, snapshot: FrankaSnapshot) -> None:
|
|
327
|
+
with self._snapshot_lock:
|
|
328
|
+
self._snapshot = snapshot
|
|
329
|
+
|
|
330
|
+
def _worker(self) -> None:
|
|
331
|
+
period = 1.0 / float(self.cfg.hybrid_hz)
|
|
332
|
+
previous_dq = np.zeros(NUM_JOINTS)
|
|
333
|
+
last_sample = None
|
|
334
|
+
sample_received_at = time.monotonic()
|
|
335
|
+
deadline = time.monotonic()
|
|
336
|
+
try:
|
|
337
|
+
self._lease.check()
|
|
338
|
+
snapshot = self._read_snapshot()
|
|
339
|
+
# A worker always starts from a measured, discontinuity-free reference. Keep
|
|
340
|
+
# that reference fixed until a Cartesian target replaces it. Updating the joint
|
|
341
|
+
# reference from every subsequent measurement would turn the nominal idle hold
|
|
342
|
+
# into unintended free guiding: an external push would move both the arm and its
|
|
343
|
+
# reference, leaving no restoring error.
|
|
344
|
+
idle_hold_q = snapshot.q.copy()
|
|
345
|
+
idle_hold_pose = snapshot.flange_pose_wxyz.copy()
|
|
346
|
+
self._session = _JointSession(self._robot, self._franky, self.cfg)
|
|
347
|
+
self._session.start(idle_hold_q)
|
|
348
|
+
self._started.set()
|
|
349
|
+
while not self._stop.is_set():
|
|
350
|
+
deadline += period
|
|
351
|
+
remaining = deadline - time.monotonic()
|
|
352
|
+
if remaining > 0:
|
|
353
|
+
self._stop.wait(remaining)
|
|
354
|
+
if self._stop.is_set():
|
|
355
|
+
break
|
|
356
|
+
self._lease.check()
|
|
357
|
+
self._check_hardware()
|
|
358
|
+
snapshot = self._read_snapshot()
|
|
359
|
+
last_sample, sample_received_at, sample_age = self._check_freshness(
|
|
360
|
+
snapshot, last_sample, sample_received_at
|
|
361
|
+
)
|
|
362
|
+
self._cache_snapshot(snapshot)
|
|
363
|
+
|
|
364
|
+
target = self._target
|
|
365
|
+
hold_reason = None
|
|
366
|
+
if target is None:
|
|
367
|
+
target_pose = idle_hold_pose
|
|
368
|
+
qd, dqd = idle_hold_q, np.zeros(NUM_JOINTS)
|
|
369
|
+
pose_error = np.zeros(6)
|
|
370
|
+
margin = float(np.min(np.minimum(snapshot.q - self._lower, self._upper - snapshot.q)))
|
|
371
|
+
task_scale, velocity_limited, posture_limited = 0.0, False, False
|
|
372
|
+
hold_reason = "no_target"
|
|
373
|
+
else:
|
|
374
|
+
target_pose, target_twist = target
|
|
375
|
+
solved = solve_feedback_leashed_ik(
|
|
376
|
+
q=snapshot.q,
|
|
377
|
+
current_pose7=geo.obs_pose(snapshot.flange_pose_wxyz),
|
|
378
|
+
target_pose7=geo.obs_pose(target_pose),
|
|
379
|
+
target_twist=target_twist,
|
|
380
|
+
jacobian=snapshot.jacobian,
|
|
381
|
+
lower_limits=self._lower,
|
|
382
|
+
upper_limits=self._upper,
|
|
383
|
+
dt=period,
|
|
384
|
+
config=self._ik,
|
|
385
|
+
previous_dq=previous_dq,
|
|
386
|
+
joint_velocity_bounds=self._limits.velocity_bounds(snapshot.q),
|
|
387
|
+
)
|
|
388
|
+
qd, dqd = solved.qd, solved.dq
|
|
389
|
+
pose_error = solved.pose_error
|
|
390
|
+
margin = solved.minimum_joint_margin
|
|
391
|
+
task_scale = solved.task_scale
|
|
392
|
+
velocity_limited = solved.joint_velocity_limited
|
|
393
|
+
posture_limited = solved.posture_limited
|
|
394
|
+
self._session.set_target(qd, dqd)
|
|
395
|
+
previous_dq = np.asarray(dqd).copy()
|
|
396
|
+
self._append_trace(
|
|
397
|
+
snapshot, target_pose, qd, dqd, pose_error, margin, task_scale,
|
|
398
|
+
velocity_limited, posture_limited, hold_reason, sample_age,
|
|
399
|
+
)
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
logger.exception("Franka Hybrid worker failed")
|
|
402
|
+
self._latch_fault(str(exc))
|
|
403
|
+
self._started.set()
|
|
404
|
+
finally:
|
|
405
|
+
session, self._session = self._session, None
|
|
406
|
+
if session is not None:
|
|
407
|
+
try:
|
|
408
|
+
session.stop()
|
|
409
|
+
except Exception as exc:
|
|
410
|
+
logger.exception("Franka joint impedance tracker failed during shutdown")
|
|
411
|
+
# The SDK may only expose its original control exception at join,
|
|
412
|
+
# after its cached flags have only reported a generic worker fault.
|
|
413
|
+
message = f"{type(exc).__name__}: {exc}"
|
|
414
|
+
if self._fault is not None:
|
|
415
|
+
message += f"\nEarlier worker fault: {self._fault}"
|
|
416
|
+
self._fault = message
|
|
417
|
+
|
|
418
|
+
def _append_trace(
|
|
419
|
+
self, snapshot, target, qd, dqd, pose_error, margin, scale,
|
|
420
|
+
velocity_limited, posture_limited, hold_reason, sample_age,
|
|
421
|
+
) -> None:
|
|
422
|
+
item = {
|
|
423
|
+
"t_monotonic": snapshot.received_at,
|
|
424
|
+
"sample_ms": round(sample_age * 1e3, 3),
|
|
425
|
+
"target_pose": np.asarray(target).tolist(),
|
|
426
|
+
"measured_pose": snapshot.flange_pose_wxyz.tolist(),
|
|
427
|
+
"native_frame": "franka_base",
|
|
428
|
+
"q": snapshot.q.tolist(),
|
|
429
|
+
"dq": snapshot.dq.tolist(),
|
|
430
|
+
"qd": np.asarray(qd).tolist(),
|
|
431
|
+
"dqd": np.asarray(dqd).tolist(),
|
|
432
|
+
"pose_error": np.asarray(pose_error).tolist(),
|
|
433
|
+
"minimum_joint_margin": float(margin),
|
|
434
|
+
"task_scale": float(scale),
|
|
435
|
+
"joint_velocity_limited": bool(velocity_limited),
|
|
436
|
+
"posture_limited": bool(posture_limited),
|
|
437
|
+
"ik_hold_reason": hold_reason,
|
|
438
|
+
}
|
|
439
|
+
with self._trace_lock:
|
|
440
|
+
self._trace.append(item)
|
|
441
|
+
|
|
442
|
+
def drain_trace(self) -> list[dict]:
|
|
443
|
+
with self._trace_lock:
|
|
444
|
+
trace = list(self._trace)
|
|
445
|
+
self._trace.clear()
|
|
446
|
+
return trace
|
|
447
|
+
|
|
448
|
+
def get_joints(self) -> np.ndarray:
|
|
449
|
+
with self._snapshot_lock:
|
|
450
|
+
if self._snapshot is None:
|
|
451
|
+
raise RuntimeError("Franka is not connected")
|
|
452
|
+
return self._snapshot.q.copy()
|
|
453
|
+
|
|
454
|
+
def get_eef_pose(self) -> np.ndarray:
|
|
455
|
+
with self._snapshot_lock:
|
|
456
|
+
if self._snapshot is None:
|
|
457
|
+
raise RuntimeError("Franka is not connected")
|
|
458
|
+
return self._snapshot.flange_pose_wxyz.copy()
|
|
459
|
+
|
|
460
|
+
def servo_pose(self, pose7: np.ndarray, twist: np.ndarray | None = None, *, position_only=False) -> None:
|
|
461
|
+
del position_only # The shared Cartesian targets use joint impedance without added admittance.
|
|
462
|
+
pose = np.asarray(pose7, dtype=np.float64)
|
|
463
|
+
velocity = np.zeros(6) if twist is None else np.asarray(twist, dtype=np.float64)
|
|
464
|
+
if pose.shape != (7,) or not np.all(np.isfinite(pose)):
|
|
465
|
+
raise ValueError("servo_pose expects 7 finite pose values")
|
|
466
|
+
if velocity.shape != (6,) or not np.all(np.isfinite(velocity)):
|
|
467
|
+
raise ValueError("servo_pose expects 6 finite twist values")
|
|
468
|
+
if not self.live:
|
|
469
|
+
raise RuntimeError(f"not_alive: {self.last_error}. Call recover().")
|
|
470
|
+
pose = np.concatenate([pose[:3], geo.normalize_quat(pose[3:])])
|
|
471
|
+
self._target = (pose, velocity.copy())
|
|
472
|
+
|
|
473
|
+
def recover(self) -> None:
|
|
474
|
+
"""A hardware fault is cleared only by this explicit reconnect."""
|
|
475
|
+
self._teardown()
|
|
476
|
+
self._connect(recover_from_errors=True)
|