Mujoco KDL Wrapper  0.5.1
MuJoCo + KDL bridge for robot kinematics and dynamics
Loading...
Searching...
No Matches
ex_achd_table_slide.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""ACHD table slide pressing on the table; Python counterpart of ex_achd_table_slide.cpp.
3
4The Gen3 has no gripper: the TCP is its wrist flange (the bracelet's pinch_site). A guarded
5approach (all six directions tracked) lowers it onto the table, the press ramps up, then it
6slides with linear Z free. Headless, it exits 1 if the table contact was held
7for less than CONTACT_HELD of the second half of the slide, the touchdown was faster than
8MAX_TOUCHDOWN or the elbow went below MIN_ELBOW_HEIGHT.
9"""
10
11from __future__ import annotations
12
13import argparse
14import math
15
16import mujoco
17import numpy as np
18import PyKDL as kdl
19import mj_kdl_wrapper as mjk
20
21HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
22# [m] TCP start in the base frame, flange down; the base stands on the table, so z is its height.
23START_TCP = (0.40, 0.0, 0.12)
24TABLE_Z = 0.447
25MOVE_X = 0.20
26V_MAX_LIN = 0.08 # [m/s] the tracked reference moves toward the target at this speed
27V_TOUCH = 0.02 # [m/s] slowest descent, the touchdown speed
28DESCENT_GAIN = 0.5 # [1/s] descent speed per metre above the table
29KPLIN, KDLIN = 200.0, 30.0
30KPROT, KDROT = 175.0, 28.0
31BETA_MAX = 120.0
32PRESS_FORCE = 10.0 # [N] commanded straight down at the TCP
33APPROACH_STEPS = 7500 # no contact within 15 s fails the run
34PRESS_STEPS = 250 # the press ramps up over 0.5 s
35SLIDE_STEPS = 2000
36CONTACT_HELD = 0.6 # the contact chatters while sliding
37MAX_TOUCHDOWN = 0.05 # [m/s] TCP vertical speed at first contact
38MIN_ELBOW_HEIGHT = 0.30 # [m] above the table
39ELBOW_BODY = "forearm_link" # its origin is the elbow joint
40TCP_SITE = "pinch_site" # the flange face, z out of the arm
41
42APPROACH, PRESS, SLIDE = "approach", "press", "slide"
43
44
45def build_env() -> tuple[mjk.Env, mjk.Robot]:
46 table = mjk.SceneObject()
47 table.name = "table"
48 table.mjcf_path = str(mjk.ASSETS_DIR / "table.xml")
49 table.pos = [0.0, 0.0, TABLE_Z]
50 table.fixed = True
51 spec = mjk.SceneSpec()
52 spec.timestep = 0.002
53 spec.add_floor = True
54 spec.add_skybox = True
55 spec.objects = [table]
56 robot_spec = mjk.RobotSpec()
57 robot_spec.path = str(mjk.ASSETS_DIR / "kinova_gen3/gen3.xml")
58 robot_spec.pos = [0.0, 0.0, TABLE_Z]
59 spec.robots = [robot_spec]
60 env = mjk.Env.build(spec)
61 tool = mjk.ToolFrameSpec()
62 tool.tcp_site = TCP_SITE
63 robot = env.create_robot("base_link", "bracelet_link", tool=tool)
64 return env, robot
65
66
67def jnt(values) -> kdl.JntArray:
68 out = kdl.JntArray(len(values))
69 for i, v in enumerate(values):
70 out[i] = v
71 return out
72
73
74def solve_near_seed(chain, limits, seed: list[float], target: kdl.Frame) -> list[float]:
75 """Damped-least-squares IK stepped from seed, so the answer stays on the seed's branch."""
76 fk = kdl.ChainFkSolverPos_recursive(chain)
77 ik = kdl.ChainIkSolverVel_wdls(chain, 1e-5, 150)
78 ik.setLambda(0.05)
79 q = jnt(seed)
80 dq = kdl.JntArray(q.rows())
81 for _ in range(300):
82 current = kdl.Frame()
83 fk.JntToCart(q, current)
84 dx = kdl.diff(current, target)
85 if dx.vel.Norm() <= 2e-3 and dx.rot.Norm() <= 2e-2:
86 return [q[i] for i in range(q.rows())]
87 if dx.vel.Norm() > 0.05:
88 dx.vel = dx.vel * (0.05 / dx.vel.Norm())
89 if dx.rot.Norm() > 0.20:
90 dx.rot = dx.rot * (0.20 / dx.rot.Norm())
91 if ik.CartToJnt(q, dx, dq) < 0:
92 raise RuntimeError("PyKDL IK velocity step failed")
93 # joint_limits are +-inf for an unlimited joint, so the clamp leaves it alone.
94 for i in range(q.rows()):
95 q[i] = min(limits[i][1], max(limits[i][0], q[i] + dq[i]))
96 current = kdl.Frame()
97 fk.JntToCart(q, current)
98 dx = kdl.diff(current, target)
99 if dx.vel.Norm() > 2e-3 or dx.rot.Norm() > 2e-2:
100 raise RuntimeError("PyKDL IK did not converge")
101 return [q[i] for i in range(q.rows())]
102
103
104def clamp_abs(value: float, limit: float) -> float:
105 return max(-limit, min(limit, value))
106
107
108# Sum of contact normal forces on one geom; positive pushes the bodies apart.
109def contact_normal_force(model: mujoco.MjModel, data: mujoco.MjData, geom: int) -> float:
110 total = 0.0
111 force = np.zeros(6)
112 for i in range(data.ncon):
113 contact = data.contact[i]
114 if geom not in (contact.geom1, contact.geom2):
115 continue
116 mujoco.mj_contactForce(model, data, i, force)
117 total += force[0]
118 return total
119
120
121def touches(data: mujoco.MjData, geom: int) -> bool:
122 return any(geom in (data.contact[i].geom1, data.contact[i].geom2) for i in range(data.ncon))
123
124
125def site_sink_speed(model: mujoco.MjModel, data: mujoco.MjData, site: int) -> float:
126 """Downward world-frame speed of a site."""
127 vel = np.zeros(6)
128 mujoco.mj_objectVelocity(model, data, mujoco.mjtObj.mjOBJ_SITE, site, vel, 0)
129 return -vel[5]
130
131
132def alpha_all() -> kdl.Jacobian:
133 alpha = kdl.Jacobian(6)
134 for i in range(3):
135 axis = [0.0, 0.0, 0.0]
136 axis[i] = 1.0
137 alpha.setColumn(i, kdl.Twist(kdl.Vector(*axis), kdl.Vector.Zero()))
138 alpha.setColumn(i + 3, kdl.Twist(kdl.Vector.Zero(), kdl.Vector(*axis)))
139 return alpha
140
141
142def alpha_no_linear_z() -> kdl.Jacobian:
143 alpha = kdl.Jacobian(5)
144 alpha.setColumn(0, kdl.Twist(kdl.Vector(1, 0, 0), kdl.Vector.Zero()))
145 alpha.setColumn(1, kdl.Twist(kdl.Vector(0, 1, 0), kdl.Vector.Zero()))
146 alpha.setColumn(2, kdl.Twist(kdl.Vector.Zero(), kdl.Vector(1, 0, 0)))
147 alpha.setColumn(3, kdl.Twist(kdl.Vector.Zero(), kdl.Vector(0, 1, 0)))
148 alpha.setColumn(4, kdl.Twist(kdl.Vector.Zero(), kdl.Vector(0, 0, 1)))
149 return alpha
150
151
152class Slide:
153 """The ACHD controller and the press measurement; restart() replays it from the current pose."""
154
155 def __init__(self, env: mjk.Env, robot: mjk.Robot):
156 self.env, self.robot = env, robot
157 self.chain = robot.kdl_chain()
158 self.fk = kdl.ChainFkSolverPos_recursive(self.chain)
159 gravity_z = env.model.opt.gravity[2]
160 root_acc = kdl.Twist(kdl.Vector(0.0, 0.0, -gravity_z), kdl.Vector.Zero())
161 self.achd_approach = kdl.ChainHdSolver_Vereshchagin(self.chain, root_acc, 6)
162 self.achd = kdl.ChainHdSolver_Vereshchagin(self.chain, root_acc, 5)
163 # Driver weights 1 pass the commanded wrench through to the environment.
164 self.achd.setDriverWeights(np.ones(5), np.zeros(5))
165 self.rnea = kdl.ChainIdSolver_RNE(self.chain, kdl.Vector(0.0, 0.0, gravity_z))
168 segments = self.chain.getNrOfSegments()
169 self.f_ext = [kdl.Wrench.Zero() for _ in range(segments)]
170 self.no_wrench = [kdl.Wrench.Zero() for _ in range(segments)]
171 self.table_geom = env.model.geom("top").id
172 self.tcp_site = env.model.site(TCP_SITE).id
173 self.restart()
174 self.target = kdl.Frame(self.tracked.M, self.tracked.p + kdl.Vector(MOVE_X, 0.0, 0.0))
175
176 def tcp(self) -> kdl.Frame:
177 frame = kdl.Frame()
178 self.fk.JntToCart(jnt(self.robot.jnt_pos_msr), frame)
179 return frame
180
181 def elbow_height(self) -> float:
182 return self.env.data.body(ELBOW_BODY).xpos[2] - TABLE_Z
183
184 def restart(self) -> None:
185 self.env.update()
186 self.tracked = self.tcp()
187 self.phase = APPROACH
188 self.err_prev = None
189 self.steps = 0
190 self.touchdown = math.nan # [m/s] TCP sink speed at first contact
193 self.reaction_sum = 0.0
195
196 def control(self) -> None:
197 env, robot = self.env, self.robot
198 dt = env.model.opt.timestep
199 if self.phase == APPROACH:
200 # The base stands on the table, so base-frame z is the height above it.
201 v = min(max(DESCENT_GAIN * self.tracked.p.z(), V_TOUCH), V_MAX_LIN)
202 self.tracked.p -= kdl.Vector(0.0, 0.0, v * dt)
203 elif self.phase == SLIDE:
204 to_goal = self.target.p - self.tracked.p
205 dist = to_goal.Norm()
206 if dist > 1e-4:
207 self.tracked.p += to_goal * (min(dist, V_MAX_LIN * dt) / dist)
208
209 env.update()
210 q, qd = jnt(robot.jnt_pos_msr), jnt(robot.jnt_vel_msr)
211 current = kdl.Frame()
212 self.fk.JntToCart(q, current)
213 err = kdl.diff(current, self.tracked)
214 e = [err.vel.x(), err.vel.y(), err.vel.z(), err.rot.x(), err.rot.y(), err.rot.z()]
215 prev = self.err_prev or e
216 de = [(e[i] - prev[i]) / dt for i in range(6)]
217 self.err_prev = e
218 gains = [(KPLIN, KDLIN)] * 3 + [(KPROT, KDROT)] * 3
219 b = [clamp_abs(kp * e[i] + kd * de[i], BETA_MAX) for i, (kp, kd) in enumerate(gains)]
220
221 n = robot.n_joints
222 # Gravity as feed-forward, so only the commanded press pushes the free linear Z down.
223 ff = kdl.JntArray(n)
224 if self.rnea.CartToJnt(q, kdl.JntArray(n), kdl.JntArray(n), self.no_wrench, ff) < 0:
225 raise RuntimeError("PyKDL RNEA failed")
226 qdd, constraint_tau, tau = kdl.JntArray(n), kdl.JntArray(n), kdl.JntArray(n)
227 if self.phase == APPROACH:
228 solver, alpha, beta, f_ext = self.achd_approach, self.alpha6, jnt(b), self.no_wrench
229 else:
230 ramp = min(1.0, self.steps / PRESS_STEPS) if self.phase == PRESS else 1.0
231 press = kdl.Vector(0.0, 0.0, -ramp * PRESS_FORCE)
232 self.f_ext[-1] = kdl.Wrench(press, kdl.Vector.Zero())
233 solver, alpha, f_ext = self.achd, self.alpha5, self.f_ext
234 beta = jnt([b[0], b[1], b[3], b[4], b[5]])
235 if solver.CartToJnt(q, qd, qdd, alpha, beta, f_ext, ff, constraint_tau) < 0:
236 raise RuntimeError("PyKDL ACHD failed")
237 if self.rnea.CartToJnt(q, qd, qdd, self.no_wrench, tau) < 0:
238 raise RuntimeError("PyKDL RNEA failed")
239 # update() clamps each torque to its joint's limit and reports it in jnt_saturated.
240 robot.jnt_trq_cmd = [tau[i] for i in range(n)]
241 self.steps += 1
242
243 def measure(self) -> None:
244 """Advances the phase after a step and samples the checks."""
245 model, data = self.env.model, self.env.data
246 self.elbow_min = min(self.elbow_min, self.elbow_height())
247 if self.phase == APPROACH and touches(data, self.table_geom):
248 self.touchdown = site_sink_speed(model, data, self.tcp_site)
249 self.target = kdl.Frame(self.tracked.M, self.tracked.p + kdl.Vector(MOVE_X, 0.0, 0.0))
250 self.phase, self.steps = PRESS, 0
251 elif self.phase == PRESS and self.steps >= PRESS_STEPS:
252 self.phase, self.steps = SLIDE, 0
253 # Measured over the second half of the slide: the contact chatters while the TCP moves.
254 elif self.phase == SLIDE and self.steps > SLIDE_STEPS // 2:
255 reaction = contact_normal_force(model, data, self.table_geom)
256 self.contact_steps += reaction > 0.0
257 self.reaction_sum += reaction
258 self.reaction_count += 1
259
260 def done(self) -> bool:
261 return (self.phase == APPROACH and self.steps >= APPROACH_STEPS) or self.finished()
262
263 def finished(self) -> bool:
264 return self.phase == SLIDE and self.steps >= SLIDE_STEPS
265
266
267def main() -> int:
268 parser = argparse.ArgumentParser()
269 parser.add_argument("--gui", action="store_true")
270 args = parser.parse_args()
271
272 env, robot = build_env()
273 try:
274 restarted = [False]
275 start = kdl.Frame(kdl.Rotation.RotX(math.pi), kdl.Vector(*START_TCP))
276 q_start = solve_near_seed(robot.kdl_chain(), robot.joint_limits, HOME, start)
277
278 def on_reset(ctx):
279 robot.set_joint_pos(q_start)
280 robot.jnt_pos_cmd = q_start
281 restarted[0] = True
282
283 env.on_reset = on_reset
284 env.reset()
285 robot.set_control_mode(mjk.CtrlMode.TORQUE)
286 slide = Slide(env, robot)
287
288 if args.gui:
289 # The UI's reset button runs env's reset, on_reset included.
290 env.open_viewer("ex_achd_table_slide.py")
291 restarted[0] = False
292 while not slide.done():
293 slide.control()
294 if not env.step():
295 break
296 slide.measure()
297 if restarted[0]:
298 restarted[0] = False
299 slide.restart()
300 env.pace()
301
302 env.update()
303 err = kdl.diff(slide.tcp(), slide.target)
304 xy_err = math.hypot(err.vel.x(), err.vel.y())
305 print(
306 f"tcp_xy_err_mm={xy_err * 1000:.3f} tcp_z_error_unconstrained_mm="
307 f"{err.vel.z() * 1000:.3f} tcp_rot_err_rad={err.rot.Norm():.3f}"
308 )
309 count = max(slide.reaction_count, 1)
310 contact = slide.contact_steps / count
311 mean = slide.reaction_sum / count
312 print(
313 f"table_contact_fraction={contact:.3f} mean_table_reaction_N={mean:.3f} "
314 f"commanded_press_N={PRESS_FORCE:.3f} reaction_over_command={mean / PRESS_FORCE:.3f}"
315 )
316 print(
317 f"touchdown_speed_m_s={slide.touchdown:.4f} (limit {MAX_TOUCHDOWN:.4f}) "
318 f"elbow_min_z_above_table={slide.elbow_min:.4f} (limit {MIN_ELBOW_HEIGHT:.4f})"
319 )
320 finally:
321 env.close()
322 if args.gui:
323 return 0
324 ok = (
325 slide.finished()
326 and contact >= CONTACT_HELD
327 and slide.touchdown <= MAX_TOUCHDOWN
328 and slide.elbow_min >= MIN_ELBOW_HEIGHT
329 )
330 goal = "the TCP touched down gently and held the table during the slide"
331 print(f"{'PASS' if ok else 'FAIL'}: {goal}")
332 return 0 if ok else 1
333
334
335if __name__ == "__main__":
336 raise SystemExit(main())
__init__(self, mjk.Env env, mjk.Robot robot)
list[float] solve_near_seed(chain, limits, list[float] seed, kdl.Frame target)
float site_sink_speed(mujoco.MjModel model, mujoco.MjData data, int site)
float clamp_abs(float value, float limit)
tuple[mjk.Env, mjk.Robot] build_env()
float contact_normal_force(mujoco.MjModel model, mujoco.MjData data, int geom)
bool touches(mujoco.MjData data, int geom)
kdl.JntArray jnt(values)
kdl.Jacobian alpha_no_linear_z()