Mujoco KDL Wrapper  0.5.1
MuJoCo + KDL bridge for robot kinematics and dynamics
Loading...
Searching...
No Matches
ex_table_pour.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Pour balls into a tabletop receiver; Python counterpart of src/examples/ex_table_pour.cpp.
3
4Headless, it exits 1 if fewer than MIN_IN_RECEIVER balls land in the receiver; --record [out.mp4]
5also writes a 1080p MP4 through VideoRecorder (EGL + ffmpeg).
6"""
7
8from __future__ import annotations
9
10import argparse
11import math
12from dataclasses import dataclass
13
14import PyKDL as kdl
15import mj_kdl_wrapper as mjk
16
17HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
18TABLE_Z = 0.70
19ROBOT_BACK_X = -0.26
20JUG_X = 0.30
21JUG_Y = 0.14
22RETREAT_X = JUG_X - 0.08
23RETREAT_Y = JUG_Y - 0.08
24JUG_RADIUS = 0.028
25JUG_HEIGHT = 0.084
26BALL_RADIUS = 0.007
27NUM_BALLS = 36
28POUR_TILT_RAD = 1.95
29TILT_OUTLET_Z = TABLE_Z + 0.18
30GRIPPER_CLOSED = 0.82 # [rad] top of the 2F-85's ctrlrange (its driver joint stops at 0.8)
31MIN_IN_RECEIVER = 24
32IK_TOL = 3e-3
33KP = [120.0, 220.0, 120.0, 220.0, 110.0, 190.0, 90.0]
34KD = [12.0, 22.0, 12.0, 22.0, 11.0, 18.0, 9.0]
35
36
37@dataclass(frozen=True)
38class Phase:
39 name: str
40 target: list[float]
41 duration: float
42 timeout: float
43 settle_tol: float
44 gripper: float
45
46
47class ResetRequested(Exception):
48 """Raised when the simulate UI reset is detected, to restart the sequence."""
49
50
51def gripper_attachment(gripper_path: str) -> mjk.AttachmentSpec:
52 attach = mjk.AttachmentSpec()
53 attach.mjcf_path = gripper_path
54 attach.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "pinch_site")
55 attach.prefix = "g_"
56 return attach
57
58
59def bottle_attachment(bottle_path: str) -> mjk.AttachmentSpec:
60 attach = mjk.AttachmentSpec()
61 attach.mjcf_path = bottle_path
62 attach.attach_to = mjk.AttachTarget(mjk.AttachKind.Body, "g_base")
63 attach.prefix = "pour_"
64 return attach
65
66
67def table_object(table_path: str) -> mjk.SceneObject:
68 table = mjk.SceneObject()
69 table.name = "table"
70 table.mjcf_path = table_path
71 table.pos = [0.0, 0.0, TABLE_Z]
72 table.fixed = True
73 return table
74
75
76def receiver_object(receiver_path: str) -> mjk.SceneObject:
77 recv = mjk.SceneObject()
78 recv.name = "recv"
79 recv.mjcf_path = receiver_path
80 recv.pos = [JUG_X, JUG_Y, TABLE_Z]
81 return recv
82
83
84def ball_object(index: int) -> mjk.SceneObject:
85 ball = mjk.SceneObject()
86 ball.name = f"grain_{index:02d}"
87 ball.shape = mjk.Shape.SPHERE
88 ball.size = [BALL_RADIUS, 0.0, 0.0]
89 ball.pos = [0.0, 0.0, TABLE_Z + 0.40 + index * 2.0 * BALL_RADIUS]
90 ball.rgba = [1.0, 0.84, 0.30, 1.0]
91 ball.mass = 0.006
92 ball.condim = mjk.Condim.Torsional
93 ball.friction = [0.5, 0.02, 0.001]
94 return ball
95
96
97def build_env() -> tuple[mjk.Env, mjk.Robot]:
98 spec = mjk.SceneSpec()
99 spec.timestep = 0.002
100 spec.add_floor = True
101 spec.add_skybox = True
102 spec.objects = [
103 table_object(str(mjk.ASSETS_DIR / "table.xml")),
104 *[ball_object(i) for i in range(NUM_BALLS)],
105 receiver_object(str(mjk.ASSETS_DIR / "mug_table.xml")),
106 ]
107
108 robot_spec = mjk.RobotSpec()
109 robot_spec.path = str(mjk.ASSETS_DIR / "kinova_gen3/gen3.xml")
110 robot_spec.pos = [ROBOT_BACK_X, 0.0, TABLE_Z]
111 robot_spec.attachments = [
112 gripper_attachment(str(mjk.ASSETS_DIR / "robotiq_2f85/2f85.xml")),
113 bottle_attachment(str(mjk.ASSETS_DIR / "mug.xml")),
114 ]
115 spec.robots = [robot_spec]
116
117 env = mjk.Env.build(spec)
118 tool = mjk.ToolFrameSpec()
119 tool.tool_body = "g_base_mount"
120 tool.tcp_site = "g_pinch"
121 robot = env.create_robot("base_link", "bracelet_link", tool=tool)
122 return env, robot
123
124
125def jnt(values: list[float]) -> kdl.JntArray:
126 q = kdl.JntArray(len(values))
127 for i, value in enumerate(values):
128 q[i] = value
129 return q
130
131
132def as_list(q: kdl.JntArray) -> list[float]:
133 return [q[i] for i in range(q.rows())]
134
135
136def clamp(value: float, low: float, high: float) -> float:
137 return max(low, min(high, value))
138
139
140def joint_limit_arrays(robot: mjk.Robot) -> tuple[kdl.JntArray, kdl.JntArray]:
141 q_min = kdl.JntArray(robot.n_joints)
142 q_max = kdl.JntArray(robot.n_joints)
143 for i, (low, high) in enumerate(robot.joint_limits):
144 if math.isfinite(low) and math.isfinite(high) and high > low:
145 q_min[i], q_max[i] = low, high
146 else:
147 q_min[i], q_max[i] = -2.0 * math.pi, 2.0 * math.pi
148 return q_min, q_max
149
150
151def build_waypoints(env: mjk.Env, robot: mjk.Robot) -> dict[str, list[float]]:
152 chain = robot.kdl_chain()
153 n = robot.n_joints
154 fk = kdl.ChainFkSolverPos_recursive(chain)
155 q_min, q_max = joint_limit_arrays(robot)
156 ik_vel = kdl.ChainIkSolverVel_pinv(chain)
157 ik_nr = kdl.ChainIkSolverPos_NR_JL(chain, q_min, q_max, fk, ik_vel, 2000, 1e-5)
158 ik_lma = kdl.ChainIkSolverPos_LMA(chain, 1e-5, 2000)
159
160 q_home = jnt(HOME)
161 home_fk = kdl.Frame()
162 fk.JntToCart(q_home, home_fk)
163 # Carry the bottle at the home orientation, tilted slightly forward.
164 carry_tcp = home_fk.M * kdl.Rotation.RotY(-0.05)
165
166 world_T_base = kdl.Frame(kdl.Rotation.Identity(), kdl.Vector(ROBOT_BACK_X, 0.0, TABLE_Z))
167 base_T_world = world_T_base.Inverse()
168
169 # Constant TCP->outlet offset, measured at the live home configuration.
170 robot.set_joint_pos(HOME)
171 world_T_outlet = env.site_frame("pour_outlet")
172 world_T_tcp = env.site_frame("g_pinch")
173 tcp_outlet = world_T_tcp.Inverse() * world_T_outlet.p
174
175 def outlet_target_to_tcp_target(tcp_rot: kdl.Rotation, outlet_pos: kdl.Vector) -> kdl.Frame:
176 return kdl.Frame(tcp_rot, outlet_pos - tcp_rot * tcp_outlet)
177
178 def solve(name: str, seed_values: list[float], outlet_pos: kdl.Vector) -> list[float]:
179 target = base_T_world * outlet_target_to_tcp_target(carry_tcp, outlet_pos)
180 seed = jnt(seed_values)
181 out = kdl.JntArray(n)
182 ok = ik_nr.CartToJnt(seed, target, out) >= 0
183 if not ok:
184 ok = ik_lma.CartToJnt(seed, target, out) >= 0
185 if not ok:
186 raise RuntimeError(f"IK failed for {name}")
187 fk_out = kdl.Frame()
188 fk.JntToCart(out, fk_out)
189 if (target.p - fk_out.p).Norm() > IK_TOL:
190 raise RuntimeError(f"IK pose error for {name}")
191 return as_list(out)
192
193 # Gen3's wrist joint is continuous: the tilt may run past pi, so it is not clamped.
194 def tilted(q_pour: list[float]) -> list[float]:
195 q_tilt = q_pour[:]
196 q_tilt[-1] += POUR_TILT_RAD
197 return q_tilt
198
199 q_pre_pour = solve("pre-pour", HOME, kdl.Vector(JUG_X, JUG_Y, TABLE_Z + 0.27))
200 pour_pos = kdl.Vector(JUG_X, JUG_Y, TABLE_Z + 0.20)
201 q_pour = solve("pour", q_pre_pour, pour_pos)
202 q_tilt = tilted(q_pour)
203 # Tilting swings the outlet away; shift the pour pose until the tilted outlet is on target.
204 for _ in range(4):
205 robot.set_joint_pos(q_tilt)
206 outlet = env.site_frame("pour_outlet").p
207 err = kdl.Vector(JUG_X, JUG_Y, TILT_OUTLET_Z) - outlet
208 if err.Norm() < 5e-3:
209 break
210 pour_pos = pour_pos + err
211 q_pour = solve("pour", q_pre_pour, pour_pos)
212 q_tilt = tilted(q_pour)
213 q_retreat = solve("retreat", q_pour, kdl.Vector(RETREAT_X, RETREAT_Y, TABLE_Z + 0.27))
214 return {
215 "home": HOME[:],
216 "pre_pour": q_pre_pour,
217 "pour": q_pour,
218 "tilt": q_tilt,
219 "retreat": q_retreat,
220 }
221
222
223def gravity(dyn: kdl.ChainDynParam, q_values) -> list[float]:
224 g = kdl.JntArray(len(q_values))
225 dyn.JntToGravity(jnt(q_values), g)
226 return [g[i] for i in range(g.rows())]
227
228
229def pd_gravity(robot: mjk.Robot, dyn: kdl.ChainDynParam, target: list[float]) -> None:
230 q, qd = robot.jnt_pos_msr, robot.jnt_vel_msr
231 g = gravity(dyn, q)
232 robot.jnt_trq_cmd = [
233 g[i] + KP[i] * (target[i] - q[i]) - KD[i] * qd[i] for i in range(robot.n_joints)
234 ]
235
236
237def max_abs_joint_err(robot: mjk.Robot, target: list[float]) -> float:
238 return max(abs(target[i] - robot.jnt_pos_msr[i]) for i in range(robot.n_joints))
239
240
241def lerp(start: list[float], target: list[float], alpha: float) -> list[float]:
242 return [a + alpha * (b - a) for a, b in zip(start, target)]
243
244
245def place_balls_in_bottle(env: mjk.Env, robot: mjk.Robot) -> None:
246 robot.set_joint_pos(HOME)
247 center = env.site_frame("pour_center")
248 spacing = 2.0 * BALL_RADIUS
249 for i in range(NUM_BALLS):
250 layer = i // 9
251 slot = i % 9
252 ix = float(slot % 3) - 1.0
253 iy = float(slot // 3) - 1.0
254 local = kdl.Vector(ix * spacing, iy * spacing, -0.026 + layer * spacing)
255 world = center * local
256 env.set_body_pose(f"grain_{i:02d}", [world.x(), world.y(), world.z()])
257
258
259def balls_in_receiver(env: mjk.Env) -> tuple[int, list[float]]:
260 count = 0
261 centroid = [0.0, 0.0, 0.0]
262 for i in range(NUM_BALLS):
263 frame = env.body_frame(f"grain_{i:02d}")
264 pos = [frame.p.x(), frame.p.y(), frame.p.z()]
265 centroid = [a + b for a, b in zip(centroid, pos)]
266 if (
267 abs(pos[0] - JUG_X) < JUG_RADIUS - 0.012
268 and abs(pos[1] - JUG_Y) < JUG_RADIUS - 0.012
269 and TABLE_Z + 0.006 < pos[2] < TABLE_Z + JUG_HEIGHT + 0.04
270 ):
271 count += 1
272 return count, [value / NUM_BALLS for value in centroid]
273
274
275def run_phase(env, robot, fingers, dyn, phase: Phase, state: dict) -> bool:
276 """One update() per step: it reads the state and applies the previous cycle's command."""
277 print(f"State: {phase.name}")
278 start = robot.jnt_pos_msr[:]
279 t0 = env.data.time
280 while True:
281 env.update()
282 elapsed = env.data.time - t0
283 alpha = clamp(elapsed / phase.duration, 0.0, 1.0) if phase.duration > 0.0 else 1.0
284 pd_gravity(robot, dyn, lerp(start, phase.target, alpha))
285 fingers.ctrl[0] = phase.gripper
286
287 err = max_abs_joint_err(robot, phase.target)
288 done_pose = phase.settle_tol < 0.0 or err <= phase.settle_tol
289 if (elapsed >= phase.duration and done_pose) or elapsed >= phase.timeout:
290 return True
291 if not env.step():
292 return False
293 if state["reset"]:
294 state["reset"] = False
295 raise ResetRequested()
296 env.pace()
297 state["steps"] += 1
298 recorder = state["recorder"]
299 if recorder is not None and state["steps"] % state["record_every"] == 0:
300 if not recorder.record_frame():
301 raise RuntimeError(f"record_frame() failed at step {state['steps']}")
302
303
304def main() -> int:
305 parser = argparse.ArgumentParser()
306 parser.add_argument("--gui", action="store_true")
307 parser.add_argument("--record", nargs="?", const="table_pour.mp4")
308 args = parser.parse_args()
309
310 env, robot = build_env()
311 recorder = None
312 try:
313 robot.set_control_mode(mjk.CtrlMode.TORQUE)
314 fingers = env.data.actuator("g_fingers_actuator")
315 chain = robot.kdl_chain()
316 dyn = kdl.ChainDynParam(chain, kdl.Vector(0.0, 0.0, env.spec.gravity_z))
317 fps = 60
318 state = {"reset": False, "steps": 0, "recorder": None}
319 state["record_every"] = max(1, int(1.0 / (fps * env.model.opt.timestep)))
320
321 def on_reset(ctx):
322 place_balls_in_bottle(env, robot) # also re-homes the arm
323 fingers.ctrl[0] = GRIPPER_CLOSED
324 robot.jnt_trq_cmd = gravity(dyn, HOME)
325 state["reset"] = True
326
327 env.on_reset = on_reset
328 env.reset()
329 waypoints = build_waypoints(env, robot)
330 env.reset() # waypoint search moved the arm; start the run from home again
331 state["reset"] = False
332 g = GRIPPER_CLOSED
333 phases = [
334 Phase("HOME", waypoints["home"], 1.0, 2.5, 0.08, g),
335 Phase("PRE_POUR", waypoints["pre_pour"], 4.0, 6.5, 0.08, g),
336 Phase("POUR", waypoints["pour"], 3.5, 5.5, 0.07, g),
337 Phase("TILT", waypoints["tilt"], 7.0, 10.0, 0.07, g),
338 Phase("POUR_HOLD", waypoints["tilt"], 9.0, 9.0, -1.0, g),
339 Phase("RETREAT", waypoints["retreat"], 2.0, 4.0, 0.08, g),
340 Phase("HOLD", waypoints["retreat"], 1.0, 1.0, -1.0, g),
341 ]
342
343 if args.record:
344 recorder = mjk.VideoRecorder.open_preset(
345 env, args.record, mjk.VideoResolution.R1080p, fps
346 )
347 state["recorder"] = recorder
348 if args.gui:
349 env.open_viewer("ex_table_pour.py")
350 completed = False
351 while not completed:
352 try:
353 completed = all(run_phase(env, robot, fingers, dyn, p, state) for p in phases)
354 break
355 except ResetRequested:
356 continue
357 env.update()
358
359 in_receiver, centroid = balls_in_receiver(env)
360 print(f"balls in transparent receiver: {in_receiver}/{NUM_BALLS} (min {MIN_IN_RECEIVER})")
361 print(f"grain centroid: {[round(v, 3) for v in centroid]} receiver center={[JUG_X, JUG_Y]}")
362 if recorder is not None:
363 print(f"recorded: {args.record}")
364 finally:
365 if recorder is not None:
366 recorder.close()
367 env.close()
368 if args.gui:
369 return 0
370 ok = completed and in_receiver >= MIN_IN_RECEIVER
371 print(f"{'PASS' if ok else 'FAIL'}: the balls were poured into the receiver")
372 return 0 if ok else 1
373
374
375if __name__ == "__main__":
376 raise SystemExit(main())
tuple[mjk.Env, mjk.Robot] build_env()
None pd_gravity(mjk.Robot robot, kdl.ChainDynParam dyn, list[float] target)
list[float] gravity(kdl.ChainDynParam dyn, q_values)
tuple[int, list[float]] balls_in_receiver(mjk.Env env)
list[float] lerp(list[float] start, list[float] target, float alpha)
mjk.AttachmentSpec bottle_attachment(str bottle_path)
mjk.AttachmentSpec gripper_attachment(str gripper_path)
mjk.SceneObject ball_object(int index)
dict[str, list[float]] build_waypoints(mjk.Env env, mjk.Robot robot)
float clamp(float value, float low, float high)
kdl.JntArray jnt(list[float] values)
None place_balls_in_bottle(mjk.Env env, mjk.Robot robot)
mjk.SceneObject receiver_object(str receiver_path)
mjk.SceneObject table_object(str table_path)
tuple[kdl.JntArray, kdl.JntArray] joint_limit_arrays(mjk.Robot robot)
float max_abs_joint_err(mjk.Robot robot, list[float] target)
list[float] as_list(kdl.JntArray q)
bool run_phase(env, robot, fingers, dyn, Phase phase, dict state)