Mujoco KDL Wrapper  0.5.1
MuJoCo + KDL bridge for robot kinematics and dynamics
Loading...
Searching...
No Matches
ex_rnea_pick_place.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Two arms facing each other across a table each pick and place a cube with RNEA computed
3torque; Python counterpart of ex_rnea_pick_place.cpp. Headless it exits 1 unless both cubes
4are placed, both elbows stay up, the free objects stay put, the arms never touch and RNEA
5never fails."""
6
7from __future__ import annotations
8
9import argparse
10import math
11from dataclasses import dataclass
12
13import PyKDL as kdl
14
15import mj_kdl_wrapper as mjk
16
17HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
18SURFACE_Z = 0.70
19CUBE_HS = 0.02
20PICK = (0.40, 0.00)
21PLACE = (0.40, 0.24)
22# Acceleration gains: the closed loop is qddot = Kp e - Kd qdot [1/s^2], [1/s].
23KP = [100.0, 200.0, 100.0, 200.0, 100.0, 200.0, 100.0]
24KD = [20.0, 28.0, 20.0, 28.0, 20.0, 28.0, 20.0]
25GRIPPER_CLOSED = 0.82 # [rad] top of the 2F-85's ctrlrange (its driver joint stops at 0.8)
26MAX_PLACE_ERR = 0.005 # [m] in the table plane
27MIN_ELBOW_HEIGHT = 0.45 # [m] forearm_link origin above the table
28MAX_DISTURB = 0.001 # [m] free-object displacement
29
30# Base x, y [m] and yaw [rad] on the tabletop: facing each other, each arm in its own half.
31BASES = [(-0.70, -0.12, 0.0), (0.70, 0.12, math.pi)]
32PREFIXES = ["", "r2_"]
33FREE_OBJECTS = [
34 ("red_box", mjk.Shape.BOX, 0.0, 0.42, 0.03, (1.0, 0.2, 0.2)),
35 ("green_box", mjk.Shape.BOX, 0.0, -0.42, 0.03, (0.2, 1.0, 0.2)),
36 ("yellow_box", mjk.Shape.BOX, -0.45, 0.42, 0.04, (1.0, 0.85, 0.1)),
37 ("orange_sphere", mjk.Shape.SPHERE, 0.45, -0.42, 0.035, (1.0, 0.55, 0.0)),
38 ("purple_sphere", mjk.Shape.SPHERE, 0.0, 0.0, 0.025, (0.7, 0.0, 0.9)),
39]
40# Both look at the table centre: overview from -y above, side from +y.
41CAMERAS = [
42 ("overview", [0.0, -1.3, 1.9], [0.410747, 0.0, 0.0, 0.911749], 55.0),
43 ("side", [0.0, 1.5, 1.15], [0.0, 0.633989, 0.773342, 0.0], 50.0),
44]
45
46
47class ResetRequested(Exception):
48 """Raised after a simulate UI reset, to restart the sequence."""
49
50
51@dataclass(frozen=True)
52class Phase:
53 name: str
54 target: list[float]
55 duration: float
56 timeout: float
57 settle_tol: float
58 gripper: float
59
60
61def world_T_base(arm: int) -> kdl.Frame:
62 x, y, yaw = BASES[arm]
63 return kdl.Frame(kdl.Rotation.RotZ(yaw), kdl.Vector(x, y, SURFACE_Z))
64
65
66def cube_spot(arm: int, xy) -> kdl.Vector:
67 """A cube's centre resting on the table at xy in the arm's base frame, in the world frame."""
68 return world_T_base(arm) * kdl.Vector(xy[0], xy[1], CUBE_HS)
69
70
71def cube_object(name: str, spot: kdl.Vector) -> mjk.SceneObject:
72 cube = mjk.SceneObject()
73 cube.name = name
74 cube.shape = mjk.Shape.BOX
75 cube.size = [CUBE_HS, CUBE_HS, CUBE_HS]
76 cube.pos = [spot.x(), spot.y(), spot.z()]
77 cube.rgba = [0.1, 0.35, 1.0, 1.0]
78 cube.mass = 0.1
79 cube.condim = mjk.Condim.Torsional
80 cube.friction = [0.8, 0.02, 0.001]
81 return cube
82
83
84def free_object(name, shape, x, y, half, rgb) -> mjk.SceneObject:
85 obj = mjk.SceneObject()
86 obj.name = name
87 obj.shape = shape
88 box = shape == mjk.Shape.BOX
89 obj.size = [half, half if box else 0.0, half if box else 0.0]
90 obj.pos = [x, y, SURFACE_Z + half]
91 obj.rgba = [*rgb, 1.0]
92 obj.mass = 0.1
93 obj.friction = [1.0, 0.005, 0.0001] # MuJoCo's geom default
94 return obj
95
96
97def camera(name, pos, quat, fovy) -> mjk.CameraSpec:
98 cam = mjk.CameraSpec()
99 cam.name = name
100 cam.pos = pos
101 cam.quat = quat
102 cam.fovy = fovy
103 return cam
104
105
106def build_env() -> tuple[mjk.Env, list[mjk.Robot]]:
107 table = mjk.SceneObject()
108 table.name = "table"
109 table.mjcf_path = str(mjk.ASSETS_DIR / "table.xml")
110 table.pos = [0.0, 0.0, SURFACE_Z]
111 table.fixed = True
112 gripper = mjk.AttachmentSpec()
113 gripper.mjcf_path = str(mjk.ASSETS_DIR / "robotiq_2f85/2f85.xml")
114 gripper.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "pinch_site")
115 gripper.prefix = "g_"
116 robot_specs = []
117 for (x, y, yaw), prefix in zip(BASES, PREFIXES):
118 robot_spec = mjk.RobotSpec()
119 robot_spec.path = str(mjk.ASSETS_DIR / "kinova_gen3/gen3.xml")
120 robot_spec.prefix = prefix
121 robot_spec.pos = [x, y, SURFACE_Z]
122 robot_spec.quat = [0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)]
123 robot_spec.attachments = [gripper]
124 robot_specs.append(robot_spec)
125
126 spec = mjk.SceneSpec()
127 spec.timestep = 0.002
128 spec.add_floor = True
129 spec.add_skybox = True
130 spec.robots = robot_specs
131 spec.objects = [
132 table,
133 *(cube_object(p + "cube", cube_spot(a, PICK)) for a, p in enumerate(PREFIXES)),
134 *(free_object(*o) for o in FREE_OBJECTS),
135 ]
136 spec.cameras = [camera(*c) for c in CAMERAS]
137 env = mjk.Env.build(spec)
138 tool = mjk.ToolFrameSpec()
139 tool.tool_body = "g_base_mount"
140 tool.tcp_site = "g_pinch"
141 robots = [env.create_robot("base_link", "bracelet_link", p, tool=tool) for p in PREFIXES]
142 return env, robots
143
144
145def jnt(values) -> kdl.JntArray:
146 q = kdl.JntArray(len(values))
147 for i, value in enumerate(values):
148 q[i] = value
149 return q
150
151
152def gravity(dyn: kdl.ChainDynParam, q_values) -> list[float]:
153 g = kdl.JntArray(len(q_values))
154 dyn.JntToGravity(jnt(q_values), g)
155 return [g[i] for i in range(g.rows())]
156
157
158def solve_near_seed(chain, limits, seed: list[float], target: kdl.Frame) -> list[float]:
159 """Damped-least-squares IK stepped from seed, so the answer stays on the seed's branch."""
160 fk = kdl.ChainFkSolverPos_recursive(chain)
161 ik = kdl.ChainIkSolverVel_wdls(chain, 1e-5, 150)
162 ik.setLambda(0.05)
163 q = jnt(seed)
164 dq = kdl.JntArray(q.rows())
165 for _ in range(300):
166 current = kdl.Frame()
167 fk.JntToCart(q, current)
168 dx = kdl.diff(current, target)
169 if dx.vel.Norm() <= 2e-3 and dx.rot.Norm() <= 2e-2:
170 break
171 if dx.vel.Norm() > 0.05:
172 dx.vel = dx.vel * (0.05 / dx.vel.Norm())
173 if dx.rot.Norm() > 0.20:
174 dx.rot = dx.rot * (0.20 / dx.rot.Norm())
175 if ik.CartToJnt(q, dx, dq) < 0:
176 raise RuntimeError("PyKDL IK velocity step failed")
177 # joint_limits are +-inf for an unlimited joint, so the clamp leaves it alone.
178 for i in range(q.rows()):
179 q[i] = min(limits[i][1], max(limits[i][0], q[i] + dq[i]))
180 else:
181 current = kdl.Frame()
182 fk.JntToCart(q, current)
183 dx = kdl.diff(current, target)
184 if dx.vel.Norm() > 2e-3 or dx.rot.Norm() > 2e-2:
185 raise RuntimeError("PyKDL IK did not converge")
186 return [q[i] for i in range(q.rows())]
187
188
189def pick_place_phases(robot: mjk.Robot) -> list[Phase]:
190 """Joint waypoints in the arm's base frame, so they serve every arm placed like this one."""
191 chain = robot.kdl_chain()
192 seed = HOME[:]
193
194 # Base-frame heights: the TCP at the cube centre, then above it.
195 def solve(xy, z: float) -> list[float]:
196 nonlocal seed
197 target = kdl.Frame(robot.tip_T_tcp.M, kdl.Vector(xy[0], xy[1], z))
198 seed = solve_near_seed(chain, robot.joint_limits, seed, target)
199 return seed[:]
200
201 pick_above = solve(PICK, CUBE_HS + 0.20)
202 pick = solve(PICK, CUBE_HS)
203 lift = solve(PICK, CUBE_HS + 0.30)
204 place_above = solve(PLACE, CUBE_HS + 0.20)
205 place = solve(PLACE, CUBE_HS)
206 closed = GRIPPER_CLOSED
207 return [
208 Phase("HOME", HOME, 1.0, 2.5, 0.08, 0.0),
209 Phase("PICK_ABOVE", pick_above, 5.0, 7.0, 0.08, 0.0),
210 Phase("PICK", pick, 5.0, 8.0, 0.03, 0.0),
211 Phase("CLOSE", pick, 1.5, 2.5, -1.0, closed),
212 Phase("LIFT", lift, 3.0, 5.0, 0.08, closed),
213 Phase("PLACE_ABOVE", place_above, 3.0, 5.0, 0.08, closed),
214 Phase("PLACE", place, 5.0, 8.0, 0.03, closed),
215 Phase("OPEN", place, 1.0, 2.0, -1.0, 0.0),
216 Phase("RETREAT", place_above, 2.0, 4.0, 0.08, 0.0),
217 Phase("HOLD", place_above, 1.0, 1.0, -1.0, 0.0),
218 ]
219
220
221def rnea_torque(robot, rnea, n_segments, q_des) -> list[float]:
222 n = robot.n_joints
223 q, qd = robot.jnt_pos_msr, robot.jnt_vel_msr
224 qdd = kdl.JntArray(n)
225 for i in range(n):
226 qdd[i] = KP[i] * (q_des[i] - q[i]) - KD[i] * qd[i]
227 tau = kdl.JntArray(n)
228 wrenches = [kdl.Wrench.Zero() for _ in range(n_segments)]
229 if rnea.CartToJnt(jnt(q), jnt(qd), qdd, wrenches, tau) < 0:
230 raise RuntimeError("PyKDL RNEA failed")
231 return [tau[i] for i in range(n)]
232
233
234def measure(env, state: dict) -> None:
235 """What a viewer sees: the elbows, the free objects and any arm-to-arm contact."""
236 m = state["metrics"]
237 for a, prefix in enumerate(PREFIXES):
238 z = env.body_frame(prefix + "forearm_link").p.z()
239 m["elbow_min"][a] = min(m["elbow_min"][a], z)
240 for name, rest in state["rest"].items():
241 m["disturb"] = max(m["disturb"], (env.body_frame(name).p - rest).Norm())
242 model, data, roots = env.model, env.data, state["roots"]
243 for c in range(data.ncon):
244 r0, r1 = (model.body_rootid[model.geom_bodyid[g]] for g in data.contact[c].geom)
245 if {r0, r1} == roots:
246 m["arm_contacts"] += 1
247
248
249def run_phase(env, robots, fingers, solvers, phase: Phase, state: dict) -> bool:
250 """One update() per step for both arms: it reads the state and applies the previous
251 cycle's command."""
252 print(f"State: {phase.name}")
253 starts = [r.jnt_pos_msr[:] for r in robots]
254 t0 = env.data.time
255 while True:
256 env.update()
257 elapsed = env.data.time - t0
258 alpha = min(1.0, max(0.0, elapsed / phase.duration)) if phase.duration > 0.0 else 1.0
259 err = 0.0
260 for robot, finger, (rnea, n_seg), start in zip(robots, fingers, solvers, starts):
261 q_des = [s + alpha * (t - s) for s, t in zip(start, phase.target)]
262 robot.jnt_trq_cmd = rnea_torque(robot, rnea, n_seg, q_des)
263 finger.ctrl[0] = phase.gripper
264 err = max(err, *(abs(t - m) for t, m in zip(phase.target, robot.jnt_pos_msr)))
265 done_pose = phase.settle_tol < 0.0 or err <= phase.settle_tol
266 if (elapsed >= phase.duration and done_pose) or elapsed >= phase.timeout:
267 return True
268 if not env.step():
269 return False
270 if state["reset"]:
271 state["reset"] = False
272 raise ResetRequested()
273 env.pace()
274 measure(env, state)
275
276
277def new_metrics() -> dict:
278 return {"elbow_min": [math.inf, math.inf], "disturb": 0.0, "arm_contacts": 0}
279
280
281def main() -> int:
282 parser = argparse.ArgumentParser()
283 parser.add_argument("--gui", action="store_true")
284 args = parser.parse_args()
285
286 env, robots = build_env()
287 try:
288 print(f"cameras: {' '.join(env.model.camera(i).name for i in range(env.model.ncam))}")
289 fingers = [env.data.actuator(p + "g_fingers_actuator") for p in PREFIXES]
290 gravity_vec = kdl.Vector(0.0, 0.0, env.spec.gravity_z)
291 solvers, dyns = [], []
292 for robot in robots:
293 robot.set_control_mode(mjk.CtrlMode.TORQUE)
294 chain = robot.kdl_chain()
295 solvers.append((kdl.ChainIdSolver_RNE(chain, gravity_vec), chain.getNrOfSegments()))
296 dyns.append(kdl.ChainDynParam(chain, gravity_vec))
297 # Same waypoints for both arms: each target is in its own base frame.
298 phases = pick_place_phases(robots[0])
299 state = {
300 "reset": False,
301 "metrics": new_metrics(),
302 "rest": {name: kdl.Vector(x, y, SURFACE_Z + h) for name, _, x, y, h, _ in FREE_OBJECTS},
303 "roots": {env.model.body_rootid[env.model.body(p + "base_link").id] for p in PREFIXES},
304 }
305
306 def on_reset(ctx):
307 for a, (robot, finger, dyn) in enumerate(zip(robots, fingers, dyns)):
308 robot.set_joint_pos(HOME)
309 spot = cube_spot(a, PICK)
310 env.set_body_pose(PREFIXES[a] + "cube", [spot.x(), spot.y(), spot.z()])
311 finger.ctrl[0] = 0.0
312 robot.jnt_trq_cmd = gravity(dyn, HOME)
313 state["metrics"] = new_metrics()
314 state["reset"] = True
315
316 env.on_reset = on_reset
317 env.reset()
318 state["reset"] = False
319 if args.gui:
320 env.open_viewer("ex_rnea_pick_place.py")
321
322 completed = False
323 while not completed:
324 try:
325 completed = all(run_phase(env, robots, fingers, solvers, p, state) for p in phases)
326 break
327 except ResetRequested:
328 continue
329 env.update()
330
331 m = state["metrics"]
332 ok = completed
333 for a, prefix in enumerate(PREFIXES):
334 cube = env.body_frame(prefix + "cube").p
335 spot = cube_spot(a, PLACE)
336 place_err = math.hypot(cube.x() - spot.x(), cube.y() - spot.y())
337 on_table = abs(cube.z() - spot.z()) < 0.002
338 elbow_h = m["elbow_min"][a] - SURFACE_Z
339 print(
340 f"arm {a + 1}: cube at [{cube.x():.4f}, {cube.y():.4f}, {cube.z():.4f}] "
341 f"place error {place_err * 1000:.4f} mm (limit {MAX_PLACE_ERR * 1000} mm)"
342 + ("" if on_table else ", not on the table")
343 + f"; lowest elbow above the table {elbow_h * 1000:.4f} mm "
344 f"(limit {MIN_ELBOW_HEIGHT * 1000} mm)"
345 )
346 ok = ok and on_table and place_err <= MAX_PLACE_ERR and elbow_h >= MIN_ELBOW_HEIGHT
347 print(
348 f"largest free-object displacement: {m['disturb'] * 1000:.4f} mm "
349 f"(limit {MAX_DISTURB * 1000} mm)"
350 )
351 print(f"arm-to-arm contacts: {m['arm_contacts']} (limit 0)")
352 ok = ok and m["disturb"] <= MAX_DISTURB and m["arm_contacts"] == 0
353 finally:
354 env.close()
355 if args.gui:
356 return 0
357 print(f"{'PASS' if ok else 'FAIL'}: both arms placed their cubes and left the rest alone")
358 return 0 if ok else 1
359
360
361if __name__ == "__main__":
362 raise SystemExit(main())
bool run_phase(env, robots, fingers, solvers, Phase phase, dict state)
kdl.Frame world_T_base(int arm)
list[float] solve_near_seed(chain, limits, list[float] seed, kdl.Frame target)
mjk.SceneObject cube_object(str name, kdl.Vector spot)
mjk.SceneObject free_object(name, shape, x, y, half, rgb)
tuple[mjk.Env, list[mjk.Robot]] build_env()
kdl.JntArray jnt(values)
None measure(env, dict state)
list[Phase] pick_place_phases(mjk.Robot robot)
mjk.CameraSpec camera(name, pos, quat, fovy)
list[float] gravity(kdl.ChainDynParam dyn, q_values)
list[float] rnea_torque(robot, rnea, n_segments, q_des)
kdl.Vector cube_spot(int arm, xy)