flexiv-control 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. flexiv_control/__init__.py +117 -0
  2. flexiv_control/action_chunk.py +372 -0
  3. flexiv_control/adapters/__init__.py +5 -0
  4. flexiv_control/adapters/lerobot_robot.py +182 -0
  5. flexiv_control/backends/__init__.py +23 -0
  6. flexiv_control/backends/base.py +88 -0
  7. flexiv_control/backends/fake.py +146 -0
  8. flexiv_control/backends/flexiv_rdk.py +313 -0
  9. flexiv_control/backends/mujoco.py +234 -0
  10. flexiv_control/cli.py +123 -0
  11. flexiv_control/client/__init__.py +5 -0
  12. flexiv_control/client/remote_robot.py +295 -0
  13. flexiv_control/config.py +162 -0
  14. flexiv_control/configs/control/cartesian_impedance.yaml +22 -0
  15. flexiv_control/configs/robots/fake.yaml +9 -0
  16. flexiv_control/configs/robots/rizon4s_actahead_lab.yaml +30 -0
  17. flexiv_control/configs/robots/rizon4s_lab.yaml +18 -0
  18. flexiv_control/configs/safety/actahead_lab.yaml +28 -0
  19. flexiv_control/configs/safety/contact_manipulation.yaml +27 -0
  20. flexiv_control/configs/safety/free_space_fast.yaml +26 -0
  21. flexiv_control/configs/safety/rl_conservative.yaml +26 -0
  22. flexiv_control/configs/safety/tabletop_safe.yaml +29 -0
  23. flexiv_control/envs/__init__.py +5 -0
  24. flexiv_control/envs/gym_env.py +247 -0
  25. flexiv_control/interpolation.py +139 -0
  26. flexiv_control/policy_client.py +58 -0
  27. flexiv_control/recede.py +80 -0
  28. flexiv_control/robot.py +343 -0
  29. flexiv_control/safety.py +242 -0
  30. flexiv_control/server/__init__.py +20 -0
  31. flexiv_control/server/control_loop.py +272 -0
  32. flexiv_control/server/host_lock.py +123 -0
  33. flexiv_control/server/lease.py +94 -0
  34. flexiv_control/server/protocol.py +302 -0
  35. flexiv_control/server/server.py +387 -0
  36. flexiv_control/sim/__init__.py +5 -0
  37. flexiv_control/sim/build_rizon_gn01.py +119 -0
  38. flexiv_control/sim/grav_gn01.xml +92 -0
  39. flexiv_control/teleop/__init__.py +17 -0
  40. flexiv_control/teleop/spacemouse.py +224 -0
  41. flexiv_control/transforms.py +122 -0
  42. flexiv_control/types.py +220 -0
  43. flexiv_control-0.1.0.dist-info/METADATA +233 -0
  44. flexiv_control-0.1.0.dist-info/RECORD +49 -0
  45. flexiv_control-0.1.0.dist-info/WHEEL +5 -0
  46. flexiv_control-0.1.0.dist-info/entry_points.txt +2 -0
  47. flexiv_control-0.1.0.dist-info/licenses/LICENSE +201 -0
  48. flexiv_control-0.1.0.dist-info/licenses/NOTICE +17 -0
  49. flexiv_control-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,117 @@
1
+ """flexiv_control -- a unified, real-time execution & safety layer for Flexiv Rizon arms.
2
+
3
+ One action contract for teleoperation, MPC, reinforcement learning, and
4
+ real-to-sim-to-real manipulation. Backend-agnostic (real Rizon via Flexiv RDK,
5
+ a dependency-free FakeBackend, or MuJoCo), with an optional cross-process server,
6
+ an optional C++ 1 kHz daemon, an optional ROS 2 overlay, a Gymnasium env, and a
7
+ LeRobot adapter.
8
+
9
+ This is a community project and is **not affiliated with Flexiv Robotics**.
10
+
11
+ Quick start (no hardware needed)::
12
+
13
+ from flexiv_control import Robot, RobotConfig, CartesianChunk
14
+
15
+ robot = Robot(RobotConfig(backend="fake"))
16
+ robot.connect()
17
+ robot.start_cartesian_impedance()
18
+ chunk = CartesianChunk.from_waypoint_array([[0.45, 0.0, 0.30, 1.0, 20],
19
+ [0.50, 0.0, 0.25, 0.0, 20]])
20
+ result = robot.execute_cartesian_chunk(chunk)
21
+ print(result.success, result.path_tracking_error)
22
+ robot.disconnect()
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ # --- core data types
30
+ from .types import ( # noqa: F401
31
+ ControlMode,
32
+ ForceControlParams,
33
+ GripperCommand,
34
+ ImpedanceParams,
35
+ JointImpedanceParams,
36
+ RobotState,
37
+ SafetyStatus,
38
+ StopReason,
39
+ )
40
+
41
+ # --- the action contract
42
+ from .action_chunk import ( # noqa: F401
43
+ CartesianChunk,
44
+ CartesianDelta,
45
+ CartesianWaypoint,
46
+ ChunkRepresentation,
47
+ ExecutionResult,
48
+ JointChunk,
49
+ JointWaypoint,
50
+ )
51
+
52
+ # --- safety + config
53
+ from .safety import SafetyFilter, SafetyProfile # noqa: F401
54
+ from .config import RobotConfig, load_safety_profile # noqa: F401
55
+
56
+ # --- backends (light ones only; heavy/optional ones load lazily by name)
57
+ from .backends import FakeBackend, RobotBackend, get_backend # noqa: F401
58
+
59
+ # --- the one facade
60
+ from .robot import LeaseError, Robot # noqa: F401
61
+
62
+ # --- receding-horizon / VLA policy-server seam
63
+ from .recede import RecedingHorizonRunner # noqa: F401
64
+ from .policy_client import RemotePolicyClient # noqa: F401
65
+
66
+ __all__ = [
67
+ "__version__",
68
+ # types
69
+ "ControlMode",
70
+ "ForceControlParams",
71
+ "GripperCommand",
72
+ "ImpedanceParams",
73
+ "JointImpedanceParams",
74
+ "RobotState",
75
+ "SafetyStatus",
76
+ "StopReason",
77
+ # action contract
78
+ "CartesianChunk",
79
+ "CartesianDelta",
80
+ "CartesianWaypoint",
81
+ "ChunkRepresentation",
82
+ "ExecutionResult",
83
+ "JointChunk",
84
+ "JointWaypoint",
85
+ # safety + config
86
+ "SafetyFilter",
87
+ "SafetyProfile",
88
+ "RobotConfig",
89
+ "load_safety_profile",
90
+ # backends
91
+ "FakeBackend",
92
+ "RobotBackend",
93
+ "get_backend",
94
+ # facade
95
+ "Robot",
96
+ "LeaseError",
97
+ # receding horizon
98
+ "RecedingHorizonRunner",
99
+ "RemotePolicyClient",
100
+ ]
101
+
102
+
103
+ def __getattr__(name: str):
104
+ """Lazily expose optional, heavier components without importing their deps eagerly."""
105
+ if name == "RemoteRobot":
106
+ from .client.remote_robot import RemoteRobot
107
+
108
+ return RemoteRobot
109
+ if name == "FlexivControlServer":
110
+ from .server.server import FlexivControlServer
111
+
112
+ return FlexivControlServer
113
+ if name == "FlexivRealEnv":
114
+ from .envs.gym_env import FlexivRealEnv
115
+
116
+ return FlexivRealEnv
117
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,372 @@
1
+ """The unified *action contract*.
2
+
3
+ This is the single most important module for cross-project reuse. Every high
4
+ level component -- a receding-horizon planner, an MPC loop, an RL policy, a
5
+ SpaceMouse teleop bridge -- emits one of these objects, and every backend knows
6
+ how to execute them. Nobody re-invents "how do I talk to the robot".
7
+
8
+ Why this exact shape
9
+ --------------------
10
+ Action-chunking and receding-horizon planners typically emit a candidate action
11
+ of the form
12
+
13
+ u = ( (x_j, y_j, z_j, w_j, n_j) )_{j=1..H}
14
+
15
+ i.e. a short sequence of Cartesian waypoints, each with a gripper command ``w_j``
16
+ and a *number of low-level control frames* ``n_j``. That ``n_j`` is precisely a
17
+ duration once you fix a control rate:
18
+
19
+ duration_j = n_frames_j / control_hz
20
+
21
+ ``CartesianChunk`` below is that object, generalised so it is also exactly what
22
+ an MPC horizon, a robosuite/MuJoCo rollout, or an RL action-chunk needs:
23
+ * positions become full SE(3) poses (orientation may be *held* -> position-only
24
+ chunks map in with zero changes),
25
+ * per-waypoint stiffness/limits so the *same* chunk can describe a free-space
26
+ reach and a contact-rich push,
27
+ * an explicit ``representation`` (absolute vs relative-to-chunk-start) and a
28
+ predict-vs-execute horizon split (``n_execute`` -> ``horizon_exec``), so the
29
+ receding-horizon "predict H_pred, execute H_exec, replan" loop (Diffusion
30
+ Policy Tp/Ta) is first-class rather than implicit,
31
+ * an explicit safety_profile name so execution is reproducible.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass, field, replace
37
+ from enum import Enum
38
+ from typing import List, Optional
39
+
40
+ import numpy as np
41
+
42
+ from .types import CART_DOF, ForceControlParams, GripperCommand, ImpedanceParams
43
+
44
+
45
+ # ----------------------------------------------------------------------------
46
+ # Cartesian
47
+ # ----------------------------------------------------------------------------
48
+ @dataclass
49
+ class CartesianWaypoint:
50
+ """One Cartesian target.
51
+
52
+ ``position`` is always required. ``quaternion`` (w, x, y, z) is optional:
53
+ leave it ``None`` to *hold the current/previous orientation*, which is what
54
+ a position-only planner (e.g. one emitting ``(x, y, z)`` waypoints) wants.
55
+
56
+ Either ``n_frames`` (a per-waypoint frame count) or ``duration`` may be given. The
57
+ interpolator converts ``n_frames`` to a duration using the active control
58
+ rate, so the same chunk runs identically whether the loop is 100 Hz or
59
+ 1 kHz, as long as ``n_frames`` is interpreted at that rate.
60
+ """
61
+
62
+ position: np.ndarray
63
+ quaternion: Optional[np.ndarray] = None # (w, x, y, z); None -> hold
64
+ gripper: Optional[GripperCommand] = None # None -> hold gripper
65
+ n_frames: Optional[int] = None # number of low-level control frames
66
+ duration: Optional[float] = None # seconds (alternative to n_frames)
67
+ frame: str = "base"
68
+
69
+ def __post_init__(self) -> None:
70
+ self.position = np.asarray(self.position, float).reshape(3)
71
+ if self.quaternion is not None:
72
+ q = np.asarray(self.quaternion, float).reshape(4)
73
+ n = np.linalg.norm(q)
74
+ if n < 1e-9:
75
+ raise ValueError("zero-norm quaternion")
76
+ self.quaternion = q / n
77
+ if self.n_frames is None and self.duration is None:
78
+ raise ValueError("CartesianWaypoint needs either n_frames or duration")
79
+ if self.n_frames is not None and self.n_frames <= 0:
80
+ raise ValueError("n_frames must be positive")
81
+
82
+ def resolve_duration(self, control_hz: float) -> float:
83
+ if self.duration is not None:
84
+ return float(self.duration)
85
+ return float(self.n_frames) / float(control_hz)
86
+
87
+
88
+ class ChunkRepresentation(str, Enum):
89
+ """How the waypoint poses in a chunk are interpreted.
90
+
91
+ * ``ABSOLUTE`` (default): each waypoint is an absolute target in the chunk's
92
+ ``frame`` (base by default) -- the ALOHA/DROID convention.
93
+ * ``RELATIVE_TO_START``: each waypoint is a pose *relative to the TCP pose at
94
+ the start of the chunk* (composed ``T_abs = T_start . T_rel``) -- the
95
+ UMI-style relative-trajectory convention that is robust to base/camera
96
+ calibration drift. It is resolved to absolute at execution time against the
97
+ live start pose, so a chunk never accumulates sequential step-to-step error.
98
+
99
+ Which is better is task/setup dependent (UMI favours relative for
100
+ calibration-free in-the-wild use; DROID ships absolute), so this is an
101
+ explicit field, not a baked-in default.
102
+ """
103
+
104
+ ABSOLUTE = "absolute"
105
+ RELATIVE_TO_START = "relative_to_start"
106
+
107
+
108
+ @dataclass
109
+ class CartesianChunk:
110
+ """A short, bounded sequence of Cartesian waypoints -- the core action type.
111
+
112
+ Used by a receding-horizon planner (execute first segment, replan), MPC (the first slice of a
113
+ horizon), scripted manipulation, and high-level RL.
114
+ """
115
+
116
+ waypoints: List[CartesianWaypoint]
117
+
118
+ # Compliance for the whole chunk (a waypoint may override later if needed).
119
+ impedance: ImpedanceParams = field(default_factory=ImpedanceParams)
120
+ force_control: Optional[ForceControlParams] = None
121
+
122
+ # Kinematic envelope enforced by the interpolator + safety filter.
123
+ max_tcp_linear_speed: float = 0.25 # m/s
124
+ max_tcp_angular_speed: float = 0.60 # rad/s
125
+ max_tcp_linear_acc: float = 1.0 # m/s^2
126
+ max_tcp_angular_acc: float = 2.0 # rad/s^2
127
+
128
+ # Contact envelope (None -> use the safety profile default).
129
+ max_contact_wrench: Optional[np.ndarray] = None # [fx,fy,fz,tx,ty,tz]
130
+
131
+ # Named safety profile to load before executing (reproducibility).
132
+ safety_profile: str = "tabletop_safe"
133
+
134
+ frame: str = "base"
135
+
136
+ # Absolute vs relative-to-chunk-start pose semantics (see ChunkRepresentation).
137
+ representation: ChunkRepresentation = ChunkRepresentation.ABSOLUTE
138
+
139
+ # Receding-horizon split: the chunk *predicts* len(waypoints) (H_pred) but the
140
+ # robot executes only the first ``n_execute`` (H_exec) before replanning.
141
+ # None -> execute the whole chunk. Diffusion-Policy reference: predict ~16,
142
+ # execute ~8.
143
+ n_execute: Optional[int] = None
144
+
145
+ def __post_init__(self) -> None:
146
+ if not self.waypoints:
147
+ raise ValueError("CartesianChunk needs at least one waypoint")
148
+ if self.max_contact_wrench is not None:
149
+ self.max_contact_wrench = np.asarray(self.max_contact_wrench, float).reshape(CART_DOF)
150
+
151
+ @property
152
+ def horizon(self) -> int:
153
+ return len(self.waypoints)
154
+
155
+ @property
156
+ def horizon_pred(self) -> int:
157
+ """Number of predicted waypoints (H_pred)."""
158
+ return len(self.waypoints)
159
+
160
+ @property
161
+ def horizon_exec(self) -> int:
162
+ """Number of waypoints actually executed before replanning (H_exec)."""
163
+ n = self.n_execute if self.n_execute is not None else len(self.waypoints)
164
+ return max(1, min(int(n), len(self.waypoints)))
165
+
166
+ def total_duration(self, control_hz: float) -> float:
167
+ return sum(w.resolve_duration(control_hz) for w in self.waypoints)
168
+
169
+ def resolve_to_absolute(self, start_pose: np.ndarray) -> "CartesianChunk":
170
+ """Return an ABSOLUTE-representation copy of this chunk.
171
+
172
+ Identity if already absolute. For ``RELATIVE_TO_START`` each waypoint is
173
+ composed onto ``start_pose`` (the live TCP pose at chunk start) as
174
+ ``T_abs = T_start . T_rel`` -- so relative chunks are re-anchored to the
175
+ current measured pose each cycle and never accumulate sequential error.
176
+ """
177
+ if self.representation == ChunkRepresentation.ABSOLUTE:
178
+ return self
179
+ from . import transforms as T
180
+
181
+ sp = np.asarray(start_pose, float).reshape(7)
182
+ s_pos, s_quat = sp[:3], sp[3:7]
183
+ new_wps: List[CartesianWaypoint] = []
184
+ for w in self.waypoints:
185
+ abs_pos = s_pos + T.quat_rotate(s_quat, w.position)
186
+ abs_quat = (
187
+ s_quat.copy()
188
+ if w.quaternion is None
189
+ else T.quat_normalize(T.quat_mul(s_quat, w.quaternion))
190
+ )
191
+ new_wps.append(
192
+ CartesianWaypoint(
193
+ position=abs_pos, quaternion=abs_quat, gripper=w.gripper,
194
+ n_frames=w.n_frames, duration=w.duration, frame=w.frame,
195
+ )
196
+ )
197
+ return replace(self, waypoints=new_wps, representation=ChunkRepresentation.ABSOLUTE)
198
+
199
+ def for_execution(self, start_pose: np.ndarray) -> "CartesianChunk":
200
+ """Resolve to absolute and slice to the execution horizon H_exec.
201
+
202
+ This is what a ``Robot`` runs: relative chunks become absolute against the
203
+ live start pose, and only the first ``n_execute`` waypoints are kept (the
204
+ rest are discarded and re-predicted next cycle -- receding horizon).
205
+ """
206
+ c = self.resolve_to_absolute(start_pose)
207
+ he = self.horizon_exec
208
+ if he >= len(c.waypoints):
209
+ return c
210
+ return replace(c, waypoints=c.waypoints[:he], n_execute=None)
211
+
212
+ # -- Convenience constructors -------------------------------------------
213
+ @classmethod
214
+ def from_waypoint_array(
215
+ cls,
216
+ u: np.ndarray,
217
+ *,
218
+ gripper_force: float = 20.0,
219
+ gripper_span: float = 0.08,
220
+ hold_orientation: bool = True,
221
+ **chunk_kwargs,
222
+ ) -> "CartesianChunk":
223
+ """Build a chunk directly from a planner's ``(H, 5)`` action array ``u``.
224
+
225
+ ``u`` is an ``(H, 5)`` array of rows ``(x, y, z, w, n)`` where ``w`` is a
226
+ *normalised* gripper command in ``[0, 1]`` (1 = open, 0 = closed) and ``n``
227
+ is the integer number of low-level control frames. ``w`` is mapped to a
228
+ physical width ``w * gripper_span`` -- pass your gripper's real stroke as
229
+ ``gripper_span`` (e.g. RDK ``GripperParams.max_width``); the 0.08 m default
230
+ is the lab GN01 and must not be assumed for other grippers. Orientation is
231
+ held from the previous pose; use :meth:`from_pose_array` to command it.
232
+ """
233
+ u = np.asarray(u, float)
234
+ if u.ndim != 2 or u.shape[1] != 5:
235
+ raise ValueError("u must have shape (H, 5): (x, y, z, w, n)")
236
+ # An (H, 5) array carries no orientation, so every waypoint holds the
237
+ # previous orientation (quaternion=None). ``hold_orientation`` is accepted
238
+ # for API symmetry but is necessarily True for this position-only format;
239
+ # use ``from_pose_array`` (H,9) if you need to command orientation.
240
+ if not hold_orientation:
241
+ raise ValueError(
242
+ "from_waypoint_array ingests position-only (H,5) actions and cannot "
243
+ "set orientation; use from_pose_array((H,9)) or build "
244
+ "CartesianWaypoint(s) with quaternions instead."
245
+ )
246
+ wpts: List[CartesianWaypoint] = []
247
+ for x, y, z, w, n in u:
248
+ grip = GripperCommand.from_normalized(w, span=gripper_span, force=gripper_force)
249
+ wpts.append(
250
+ CartesianWaypoint(
251
+ position=[x, y, z],
252
+ quaternion=None,
253
+ gripper=grip,
254
+ n_frames=max(1, int(round(n))),
255
+ )
256
+ )
257
+ return cls(waypoints=wpts, **chunk_kwargs)
258
+
259
+ @classmethod
260
+ def from_pose_array(
261
+ cls,
262
+ u: np.ndarray,
263
+ *,
264
+ gripper_force: float = 20.0,
265
+ gripper_span: float = 0.08,
266
+ **chunk_kwargs,
267
+ ) -> "CartesianChunk":
268
+ """Build a chunk from an ``(H, 9)`` array that carries orientation.
269
+
270
+ Each row is ``(x, y, z, qw, qx, qy, qz, w, n)``: an SE(3) pose (quaternion
271
+ w-first) + normalised gripper ``w`` in ``[0, 1]`` + frame count ``n``. This
272
+ is the orientation-carrying sibling of :meth:`from_waypoint_array`, for
273
+ policies/planners that emit per-step orientation (full SE(3) action
274
+ chunks, e.g. ACT/openpi-style). Combine with ``representation=`` and
275
+ ``n_execute=`` via ``chunk_kwargs`` for relative / receding-horizon use.
276
+ """
277
+ u = np.asarray(u, float)
278
+ if u.ndim != 2 or u.shape[1] != 9:
279
+ raise ValueError("u must have shape (H, 9): (x,y,z, qw,qx,qy,qz, w, n)")
280
+ wpts: List[CartesianWaypoint] = []
281
+ for row in u:
282
+ grip = GripperCommand.from_normalized(row[7], span=gripper_span, force=gripper_force)
283
+ wpts.append(
284
+ CartesianWaypoint(
285
+ position=row[:3],
286
+ quaternion=row[3:7],
287
+ gripper=grip,
288
+ n_frames=max(1, int(round(row[8]))),
289
+ )
290
+ )
291
+ return cls(waypoints=wpts, **chunk_kwargs)
292
+
293
+
294
+ # ----------------------------------------------------------------------------
295
+ # Delta (relative) servo command -- the RL / MPC / teleop workhorse
296
+ # ----------------------------------------------------------------------------
297
+ @dataclass
298
+ class CartesianDelta:
299
+ """A relative end-effector move, integrated on top of the current pose.
300
+
301
+ This is the standard RL / MPC / teleop action: ``[dx, dy, dz, rx, ry, rz]``
302
+ plus a gripper command, applied over ``duration`` seconds. The last three are
303
+ an axis-angle *rotation vector* (not roll/pitch/yaw Euler angles), matching
304
+ robosuite/MuJoCo ``OSC_POSE`` -- which is what makes sim->real transfer clean.
305
+ The translation is taken in ``frame`` ("base" or "tcp").
306
+ """
307
+
308
+ delta: np.ndarray # length-6, base or tcp frame
309
+ gripper: Optional[GripperCommand] = None
310
+ duration: float = 0.05 # 20 Hz default control step
311
+ frame: str = "base"
312
+
313
+ def __post_init__(self) -> None:
314
+ self.delta = np.asarray(self.delta, float).reshape(CART_DOF)
315
+
316
+
317
+ # ----------------------------------------------------------------------------
318
+ # Joint space (reset / home / MoveIt-plan execution)
319
+ # ----------------------------------------------------------------------------
320
+ @dataclass
321
+ class JointWaypoint:
322
+ positions: np.ndarray
323
+ n_frames: Optional[int] = None
324
+ duration: Optional[float] = None
325
+
326
+ def __post_init__(self) -> None:
327
+ self.positions = np.asarray(self.positions, float)
328
+ if self.n_frames is None and self.duration is None:
329
+ raise ValueError("JointWaypoint needs n_frames or duration")
330
+
331
+ def resolve_duration(self, control_hz: float) -> float:
332
+ if self.duration is not None:
333
+ return float(self.duration)
334
+ return float(self.n_frames) / float(control_hz)
335
+
336
+
337
+ @dataclass
338
+ class JointChunk:
339
+ waypoints: List[JointWaypoint]
340
+ max_joint_speed_scale: float = 0.3 # fraction of joint vel limits
341
+ safety_profile: str = "tabletop_safe"
342
+
343
+ def __post_init__(self) -> None:
344
+ if not self.waypoints:
345
+ raise ValueError("JointChunk needs at least one waypoint")
346
+
347
+
348
+ # ----------------------------------------------------------------------------
349
+ # Execution report -- quantifies the "execution" failure category
350
+ # ----------------------------------------------------------------------------
351
+ @dataclass
352
+ class ExecutionResult:
353
+ """Returned by every blocking execution call.
354
+
355
+ A receding-horizon planner's failure taxonomy typically has an "execution"
356
+ bucket. This object turns
357
+ that bucket into something observable and quantifiable rather than a guess:
358
+ the planner logs ``clipped``/``stop_reason``/``path_tracking_error`` and can
359
+ attribute a bad outcome to execution vs perception/ranking with evidence.
360
+ """
361
+
362
+ success: bool = True
363
+ clipped: bool = False
364
+ stop_reason: str = "none"
365
+ executed_duration: float = 0.0
366
+ path_tracking_error: float = 0.0 # max ||pose_cmd - pose_meas|| over run
367
+ max_tcp_speed: float = 0.0
368
+ max_joint_speed: float = 0.0
369
+ max_wrench: float = 0.0
370
+ gripper_width_final: float = 0.0
371
+ final_state: Optional["object"] = None # RobotState; Optional to avoid import cycle
372
+ log: dict = field(default_factory=dict)
@@ -0,0 +1,5 @@
1
+ """Adapters bridging the controller to external ecosystems (LeRobot, ...)."""
2
+
3
+ from .lerobot_robot import LeRobotFlexivAdapter, register_with_lerobot # noqa: F401
4
+
5
+ __all__ = ["LeRobotFlexivAdapter", "register_with_lerobot"]
@@ -0,0 +1,182 @@
1
+ """A LeRobot ``Robot``-interface adapter for the Flexiv controller.
2
+
3
+ LeRobot (HuggingFace) is the de-facto community hub for robot-learning data and
4
+ policies. Conforming to its "Bring Your Own Hardware" ``Robot`` interface lets a
5
+ Flexiv arm plug into LeRobot's data-collection, training, and visualization
6
+ tooling. This adapter exposes the LeRobot ``Robot`` surface; turning the recorded
7
+ frames into a ``LeRobotDataset`` (MP4 + Parquet) is a thin extra step on the
8
+ caller's side against their installed ``lerobot`` version (the dataset API moves
9
+ between releases -- see ``examples/06_lerobot_record.py``).
10
+
11
+ This adapter exposes the documented LeRobot ``Robot`` surface -- ``connect`` /
12
+ ``disconnect`` / ``get_observation`` / ``send_action`` plus ``observation_features``
13
+ and ``action_features`` -- backed by the unified controller (any backend, local
14
+ or via :class:`~flexiv_control.RemoteRobot`).
15
+
16
+ LeRobot's API has moved between versions; the exact base class / feature schema
17
+ may differ in yours. We therefore keep the adapter standalone (it does *not*
18
+ hard-subclass LeRobot) and import LeRobot lazily only if you ask to register it.
19
+ Verify the feature dict against your installed ``lerobot`` -- search points are
20
+ marked ``# VERIFY:``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Dict, Optional, Union
26
+
27
+ import numpy as np
28
+
29
+ from ..config import RobotConfig
30
+ from ..robot import Robot
31
+ from ..types import GripperCommand
32
+
33
+
34
+ class LeRobotFlexivAdapter:
35
+ """Adapts the Flexiv controller to LeRobot's ``Robot`` interface.
36
+
37
+ Action and observation are flat Cartesian-delta / state vectors by default,
38
+ matching :class:`~flexiv_control.envs.FlexivRealEnv` so a policy trained in
39
+ that env records and replays here unchanged.
40
+ """
41
+
42
+ name = "flexiv_rizon"
43
+
44
+ def __init__(
45
+ self,
46
+ robot: Optional[object] = None,
47
+ *,
48
+ config: Optional[RobotConfig] = None,
49
+ control_hz: float = 20.0,
50
+ safety_profile: str = "rl_conservative",
51
+ pos_scale: float = 0.05,
52
+ rot_scale: float = 0.20,
53
+ gripper_open_width: float = 0.08,
54
+ owner: str = "lerobot",
55
+ ):
56
+ self.robot = robot or Robot(config or RobotConfig(control_hz=control_hz))
57
+ self.control_hz = float(control_hz)
58
+ self.step_duration = 1.0 / self.control_hz
59
+ self.safety_profile = safety_profile
60
+ self.pos_scale = float(pos_scale)
61
+ self.rot_scale = float(rot_scale)
62
+ self.gripper_open_width = float(gripper_open_width)
63
+ self.owner = owner
64
+ self._connected = False
65
+
66
+ # -- LeRobot feature schema ---------------------------------------------
67
+ # LeRobot's modern Robot interface wants FLAT, bare per-scalar keys whose
68
+ # value is the Python type ``float`` (cf. SOFollower's ``{f"{m}.pos": float}``).
69
+ # LeRobot itself adds the ``observation.``/``action.`` prefix and aggregates
70
+ # the float keys into a single ``observation.state`` / ``action`` vector
71
+ # (names = the bare keys) via hw_to_dataset_features. get_observation /
72
+ # send_action below return one scalar per key so build_dataset_frame can
73
+ # index each by name.
74
+ _OBS_KEYS = (
75
+ [f"q.{i}" for i in range(7)]
76
+ + [f"dq.{i}" for i in range(7)]
77
+ + [f"tcp_pose.{i}" for i in range(7)]
78
+ + [f"wrench.{i}" for i in range(6)]
79
+ + ["gripper_width"]
80
+ )
81
+ _ACTION_KEYS = ("dx", "dy", "dz", "droll", "dpitch", "dyaw", "gripper")
82
+
83
+ @property
84
+ def observation_features(self) -> Dict[str, type]:
85
+ return {k: float for k in self._OBS_KEYS}
86
+
87
+ @property
88
+ def action_features(self) -> Dict[str, type]:
89
+ return {k: float for k in self._ACTION_KEYS}
90
+
91
+ @property
92
+ def is_connected(self) -> bool:
93
+ return self._connected
94
+
95
+ @property
96
+ def is_calibrated(self) -> bool:
97
+ # Rizon is factory-calibrated; calibrate() is a no-op, so report True to
98
+ # let LeRobot's connect() skip self.calibrate().
99
+ return True
100
+
101
+ # -- lifecycle -----------------------------------------------------------
102
+ def connect(self, calibrate: bool = True) -> None: # noqa: ARG002
103
+ if self._connected:
104
+ return
105
+ self.robot.connect()
106
+ try:
107
+ self.robot.acquire_lease(self.owner)
108
+ except TypeError:
109
+ self.robot.acquire_lease()
110
+ try:
111
+ self.robot.set_safety_profile(self.safety_profile)
112
+ except FileNotFoundError:
113
+ pass
114
+ self.robot.start_cartesian_impedance()
115
+ self._connected = True
116
+
117
+ def disconnect(self) -> None:
118
+ if not self._connected:
119
+ return
120
+ try:
121
+ self.robot.stop()
122
+ try:
123
+ self.robot.release_lease()
124
+ except Exception:
125
+ pass
126
+ self.robot.disconnect()
127
+ finally:
128
+ self._connected = False
129
+
130
+ def calibrate(self) -> None:
131
+ """No-op: the Rizon is factory-calibrated; impedance/home handle setup."""
132
+
133
+ def configure(self) -> None:
134
+ """No-op hook for LeRobot compatibility."""
135
+
136
+ # -- observation / action ------------------------------------------------
137
+ def get_observation(self) -> Dict[str, float]:
138
+ # One scalar per bare key, matching observation_features, so LeRobot's
139
+ # build_dataset_frame can index each by name into observation.state.
140
+ s = self.robot.get_state()
141
+ obs: Dict[str, float] = {}
142
+ for i in range(7):
143
+ obs[f"q.{i}"] = float(s.q[i])
144
+ for i in range(7):
145
+ obs[f"dq.{i}"] = float(s.dq[i])
146
+ for i in range(7):
147
+ obs[f"tcp_pose.{i}"] = float(s.tcp_pose[i])
148
+ for i in range(6):
149
+ obs[f"wrench.{i}"] = float(s.wrench[i])
150
+ obs["gripper_width"] = float(s.gripper_width)
151
+ return obs
152
+
153
+ def send_action(
154
+ self, action: Union[np.ndarray, Dict[str, float]]
155
+ ) -> Dict[str, float]:
156
+ # Accept a 7-vector or a per-key dict ({"dx":.., .., "gripper":..}); return
157
+ # one scalar per action key so the recorded action frame matches features.
158
+ if isinstance(action, dict):
159
+ a = np.asarray([action[k] for k in self._ACTION_KEYS], float)
160
+ else:
161
+ a = np.asarray(action, float).reshape(7)
162
+ a = np.clip(a, -1.0, 1.0)
163
+ delta = np.empty(6)
164
+ delta[:3] = a[:3] * self.pos_scale
165
+ delta[3:] = a[3:6] * self.rot_scale
166
+ width = float(np.clip((a[6] + 1.0) / 2.0, 0.0, 1.0)) * self.gripper_open_width
167
+ grip = GripperCommand(width=width, force=20.0, grasp=a[6] < 0)
168
+ self.robot.servo_cartesian_delta(delta, duration=self.step_duration, gripper=grip)
169
+ return {k: float(v) for k, v in zip(self._ACTION_KEYS, a)}
170
+
171
+
172
+ def register_with_lerobot() -> bool:
173
+ """Attempt to register the adapter with an installed LeRobot.
174
+
175
+ NOT IMPLEMENTED: current LeRobot has no stable runtime "register a robot"
176
+ call -- hardware is registered via a config dataclass decorated with
177
+ ``@RobotConfig.register_subclass("flexiv_rizon")`` plus the
178
+ ``make_robot_from_config`` factory, so there is nothing to invoke at runtime
179
+ here. Use :class:`LeRobotFlexivAdapter` directly; it implements the full
180
+ LeRobot ``Robot`` surface. Returns ``False`` (no registration performed).
181
+ """
182
+ return False