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.
- flexiv_control/__init__.py +117 -0
- flexiv_control/action_chunk.py +372 -0
- flexiv_control/adapters/__init__.py +5 -0
- flexiv_control/adapters/lerobot_robot.py +182 -0
- flexiv_control/backends/__init__.py +23 -0
- flexiv_control/backends/base.py +88 -0
- flexiv_control/backends/fake.py +146 -0
- flexiv_control/backends/flexiv_rdk.py +313 -0
- flexiv_control/backends/mujoco.py +234 -0
- flexiv_control/cli.py +123 -0
- flexiv_control/client/__init__.py +5 -0
- flexiv_control/client/remote_robot.py +295 -0
- flexiv_control/config.py +162 -0
- flexiv_control/configs/control/cartesian_impedance.yaml +22 -0
- flexiv_control/configs/robots/fake.yaml +9 -0
- flexiv_control/configs/robots/rizon4s_actahead_lab.yaml +30 -0
- flexiv_control/configs/robots/rizon4s_lab.yaml +18 -0
- flexiv_control/configs/safety/actahead_lab.yaml +28 -0
- flexiv_control/configs/safety/contact_manipulation.yaml +27 -0
- flexiv_control/configs/safety/free_space_fast.yaml +26 -0
- flexiv_control/configs/safety/rl_conservative.yaml +26 -0
- flexiv_control/configs/safety/tabletop_safe.yaml +29 -0
- flexiv_control/envs/__init__.py +5 -0
- flexiv_control/envs/gym_env.py +247 -0
- flexiv_control/interpolation.py +139 -0
- flexiv_control/policy_client.py +58 -0
- flexiv_control/recede.py +80 -0
- flexiv_control/robot.py +343 -0
- flexiv_control/safety.py +242 -0
- flexiv_control/server/__init__.py +20 -0
- flexiv_control/server/control_loop.py +272 -0
- flexiv_control/server/host_lock.py +123 -0
- flexiv_control/server/lease.py +94 -0
- flexiv_control/server/protocol.py +302 -0
- flexiv_control/server/server.py +387 -0
- flexiv_control/sim/__init__.py +5 -0
- flexiv_control/sim/build_rizon_gn01.py +119 -0
- flexiv_control/sim/grav_gn01.xml +92 -0
- flexiv_control/teleop/__init__.py +17 -0
- flexiv_control/teleop/spacemouse.py +224 -0
- flexiv_control/transforms.py +122 -0
- flexiv_control/types.py +220 -0
- flexiv_control-0.1.0.dist-info/METADATA +233 -0
- flexiv_control-0.1.0.dist-info/RECORD +49 -0
- flexiv_control-0.1.0.dist-info/WHEEL +5 -0
- flexiv_control-0.1.0.dist-info/entry_points.txt +2 -0
- flexiv_control-0.1.0.dist-info/licenses/LICENSE +201 -0
- flexiv_control-0.1.0.dist-info/licenses/NOTICE +17 -0
- 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,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
|