Mujoco KDL Wrapper  0.5.1
MuJoCo + KDL bridge for robot kinematics and dynamics
Loading...
Searching...
No Matches
ex_admittance_ft.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""F/T admittance around an RNEA task-space inner loop; Python counterpart of ex_admittance_ft.cpp.
3
4Headless, it runs the self-check and exits 1 when a metric is out of its limit; with --gui the
5same sequence runs once, the mouse (ctrl + right-drag) pushing the tool after the helix.
6"""
7
8from __future__ import annotations
9
10import argparse
11import math
12
13import PyKDL as kdl
14import mj_kdl_wrapper as mjk
15
16HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
17TABLE_Z = 0.70
18
19# Inner loop: Cartesian PD gains [1/s^2], [1/s] and acceleration limits.
20KP_LIN, KD_LIN = 2500.0, 100.0
21KP_ROT, KD_ROT = 2500.0, 100.0
22BETA_LIN_MAX, BETA_ROT_MAX = 300.0, 300.0
23KP_NULL, KD_NULL = 100.0, 20.0 # posture gains in the task's null space
24
25# Admittance: virtual mass, damping, stiffness (isotropic); K = 0 holds the pose on release.
26M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
27FORCE_DEADBAND = 2.5 # N; rejects sensor noise and settling transients
28MAX_OFFSET = 0.20 # m; reachable workspace half-extent around home
29MAX_VEL = 0.25 # m/s
30TOOL_BODY = "g_base" # rigid gripper base; where the headless self-check pushes
31GRIPPER_ACTUATOR = "g_fingers_actuator"
32FT_SITE = "wrist_ft_site" # the F/T sensor's frame
33GRIPPER_CLOSED = 0.82 # rad; driver joint, the bundled 2F-85's ctrlrange is 0..0.82
34SETTLE_STEPS = 300 # ~0.6 s at dt=0.002 to close the gripper before taring
35HANDOFF_TARE_TIME = 1.0 # s; let scripted-motion transients settle before FT hand-guiding
36GUIDE_TIME = 4.7 # s; hand-guiding window, as long as the self-check's push phases
37SELFCHECK_PUSH = (8.0, 12.0, 6.0)
38ELBOW_BODY = "forearm_link" # its origin is the elbow joint
39MIN_ELBOW_HEIGHT = 0.64 # m above the table
40
41# Intro helical force: amplitude/shape and how long it is applied.
42TEACH_TIME = 16.0
43TEACH_RADIUS = 0.04
44TEACH_RISE = 0.10
45TEACH_TURNS = 5.0
46
47
48def jnt(values: list[float]) -> kdl.JntArray:
49 out = kdl.JntArray(len(values))
50 for i, value in enumerate(values):
51 out[i] = value
52 return out
53
54
55def clamp(value: float, low: float, high: float) -> float:
56 return max(low, min(high, value))
57
58
59def vadd(a: list[float], b: list[float]) -> list[float]:
60 return [a[i] + b[i] for i in range(3)]
61
62
63def vscale(a: list[float], s: float) -> list[float]:
64 return [s * a[i] for i in range(3)]
65
66
67def vclamp(a: list[float], limit: float) -> list[float]:
68 return [clamp(x, -limit, limit) for x in a]
69
70
71def vnorm(a: list[float]) -> float:
72 return math.sqrt(sum(x * x for x in a))
73
74
75def xyz(v: kdl.Vector) -> list[float]:
76 return [v.x(), v.y(), v.z()]
77
78
79def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
80 return xyz(frame * point)
81
82
83def ft_attachment() -> mjk.AttachmentSpec:
84 spec = mjk.AttachmentSpec()
85 spec.mjcf_path = str(mjk.ASSETS_DIR / "ft_sensor.xml")
86 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "pinch_site")
87 return spec
88
89
90def gripper_attachment() -> mjk.AttachmentSpec:
91 spec = mjk.AttachmentSpec()
92 spec.mjcf_path = str(mjk.ASSETS_DIR / "robotiq_2f85/2f85.xml")
93 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "wrist_ft_site")
94 spec.prefix = "g_"
95 return spec
96
97
98def table_object() -> mjk.SceneObject:
99 table = mjk.SceneObject()
100 table.name = "table"
101 table.mjcf_path = str(mjk.ASSETS_DIR / "table.xml")
102 table.pos = [0.0, 0.0, TABLE_Z]
103 table.fixed = True
104 return table
105
106
107def build_env() -> tuple[mjk.Env, mjk.Robot]:
108 table = table_object()
109 spec = mjk.SceneSpec()
110 spec.timestep = 0.002
111 spec.add_floor = True
112 spec.add_skybox = True
113 spec.objects = [table]
114
115 robot_spec = mjk.RobotSpec()
116 robot_spec.path = str(mjk.ASSETS_DIR / "kinova_gen3/gen3.xml")
117 robot_spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "table_top")
118 robot_spec.attachments = [ft_attachment(), gripper_attachment()]
119 spec.robots = [robot_spec]
120
121 env = mjk.Env.build(spec)
122
123 ft = mjk.ForceTorqueSensorSpec()
124 ft.name = "wrist_ft"
125 ft.frame_site = FT_SITE
126
127 tool = mjk.ToolFrameSpec()
128 tool.tool_body = "g_base_mount"
129 tool.tcp_site = "g_pinch"
130 tool.ft_sensors = [ft]
131
132 robot = env.create_robot("base_link", "bracelet_link", tool=tool)
133 return env, robot
134
135
136def tcp_frame(robot: mjk.Robot, state: dict) -> kdl.Frame:
137 frame = kdl.Frame()
138 state["fk"].JntToCart(jnt(robot.jnt_pos_msr), frame)
139 return frame
140
141
142def jacobian_twist(jac: kdl.Jacobian, qdot: kdl.JntArray) -> list[float]:
143 return [sum(jac[row, col] * qdot[col] for col in range(qdot.rows())) for row in range(6)]
144
145
146def rnea_track(env: mjk.Env, robot: mjk.Robot, state: dict, target: kdl.Frame) -> None:
147 """Task-space computed torque: Cartesian PD -> qddot + null-space posture -> RNEA torque."""
148 q = jnt(robot.jnt_pos_msr)
149 qdot = jnt(robot.jnt_vel_msr)
150
151 err = kdl.diff(tcp_frame(robot, state), target)
152 jac = kdl.Jacobian(robot.n_joints)
153 state["jac_solver"].JntToJac(q, jac)
154 tcp_vel = jacobian_twist(jac, qdot)
155
156 qddot = kdl.JntArray(robot.n_joints)
157 beta = kdl.Twist()
158 beta.vel = kdl.Vector(
159 clamp(KP_LIN * err.vel.x() - KD_LIN * tcp_vel[0], -BETA_LIN_MAX, BETA_LIN_MAX),
160 clamp(KP_LIN * err.vel.y() - KD_LIN * tcp_vel[1], -BETA_LIN_MAX, BETA_LIN_MAX),
161 clamp(KP_LIN * err.vel.z() - KD_LIN * tcp_vel[2], -BETA_LIN_MAX, BETA_LIN_MAX),
162 )
163 beta.rot = kdl.Vector(
164 clamp(KP_ROT * err.rot.x() - KD_ROT * tcp_vel[3], -BETA_ROT_MAX, BETA_ROT_MAX),
165 clamp(KP_ROT * err.rot.y() - KD_ROT * tcp_vel[4], -BETA_ROT_MAX, BETA_ROT_MAX),
166 clamp(KP_ROT * err.rot.z() - KD_ROT * tcp_vel[5], -BETA_ROT_MAX, BETA_ROT_MAX),
167 )
168 # qdd = J#(beta - J z) + z = J# beta + (I - J# J) z (Siciliano et al. 2009, Sec. 3.5.1).
169 z = jnt([KP_NULL * (HOME[i] - q[i]) - KD_NULL * qdot[i] for i in range(robot.n_joints)])
170 jz = jacobian_twist(jac, z)
171 beta = beta - kdl.Twist(kdl.Vector(*jz[:3]), kdl.Vector(*jz[3:]))
172 if state["acc_ik"].CartToJnt(q, beta, qddot) < 0:
173 raise RuntimeError("RNEA task acceleration solve failed")
174 for i in range(robot.n_joints):
175 qddot[i] += z[i]
176
177 tau = kdl.JntArray(robot.n_joints)
178 wrenches = [kdl.Wrench.Zero() for _ in range(state["n_seg"])]
179 if state["id_solver"].CartToJnt(q, qdot, qddot, wrenches, tau) < 0:
180 raise RuntimeError("RNEA inverse dynamics failed")
181 robot.jnt_trq_cmd = [tau[i] for i in range(robot.n_joints)]
182
183
184def close_gripper(env: mjk.Env) -> None:
185 env.data.actuator(GRIPPER_ACTUATOR).ctrl[0] = GRIPPER_CLOSED
186
187
188def settle_and_tare(env: mjk.Env, robot: mjk.Robot, state: dict) -> list[float]:
189 """Hold home while the gripper closes, then tare: its ~10 N load only appears once closed."""
190 env.update()
191 home = tcp_frame(robot, state)
192 for _ in range(SETTLE_STEPS):
193 env.update()
194 close_gripper(env)
195 rnea_track(env, robot, state, home)
196 if not env.step():
197 break
198 env.pace()
199 env.update()
200 return tare_force(env, robot)
201
202
203def measured_force(env: mjk.Env, robot: mjk.Robot, state: dict) -> list[float]:
204 """External force on the tool in the world frame: the tared reading, deadbanded."""
205 wrench = robot.ft_sensor("wrist_ft")
206 f_world = xyz(env.site_frame(FT_SITE).M * wrench.force)
207 bias = state["bias"]
208 f_ext = [f_world[i] - bias[i] for i in range(3)]
209 force_norm = vnorm(f_ext)
210 if force_norm < FORCE_DEADBAND:
211 return [0.0, 0.0, 0.0]
212 return f_ext
213
214
215def tare_force(env: mjk.Env, robot: mjk.Robot) -> list[float]:
216 return xyz(env.site_frame(FT_SITE).M * robot.ft_sensor("wrist_ft").force)
217
218
219def admittance_update(state: dict, force: list[float], dt: float) -> None:
220 # With K = 0 the offset integrates velocity, so no force stops the motion where it is.
221 if force == [0.0, 0.0, 0.0]:
222 state["vel"] = [0.0, 0.0, 0.0]
223 return
224 acc = [
225 (force[i] - D_ADM * state["vel"][i] - K_ADM * state["offset"][i]) / M_ADM
226 for i in range(3)
227 ]
228 state["vel"] = vclamp(vadd(state["vel"], vscale(acc, dt)), MAX_VEL)
229 state["offset"] = vclamp(vadd(state["offset"], vscale(state["vel"], dt)), MAX_OFFSET)
230
231
232def spiral_force(t: float) -> list[float]:
233 """D_ADM times a helix's velocity: the mass-damper admittance (v = F / D) traces the helix."""
234 if t < 0.0 or t > TEACH_TIME:
235 return [0.0, 0.0, 0.0]
236 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
237 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
238 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
239 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
240 vz = TEACH_RISE / TEACH_TIME
241 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
242
243
244def admittance_step(env, robot, nominal, state, force):
245 """Force -> offset (outer loop) -> RNEA-tracked TCP target, which it returns."""
246 admittance_update(state, force, env.model.opt.timestep)
247 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state["offset"]))
248 rnea_track(env, robot, state, target)
249 return target
250
251
252def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> None:
253 """The same sequence with the viewer; after the helix the mouse pushes the tool."""
254 env.open_viewer("ex_admittance_ft.py")
255 viewer = env.viewer
256 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
257 start = env.data.time
258 handoff_tared = False
259 target_prev: list[float] | None = None
260 tcp_prev: list[float] | None = None
261 trace_step = 0
262 try:
263 while env.data.time - start < TEACH_TIME + HANDOFF_TARE_TIME + GUIDE_TIME:
264 # on_reset flags a UI reset and has already re-seeded the admittance state.
265 if state["reset"]:
266 state["reset"] = False
267 start = env.data.time
268 handoff_tared = False
269 target_prev = tcp_prev = None
270 viewer.clear_trace()
271 t = env.data.time - start
272 env.update()
273 close_gripper(env)
274 # The helix force is fed to the admittance directly, not applied to the body.
275 if t < TEACH_TIME:
276 force = spiral_force(t)
277 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
278 force = [0.0, 0.0, 0.0]
279 else:
280 if not handoff_tared:
281 state["bias"] = tare_force(env, robot)
282 handoff_tared = True
283 force = measured_force(env, robot, state)
284 target = admittance_step(env, robot, nominal, state, force)
285
286 # Commanded (yellow) and measured (green) TCP paths; the chain's frames are base_link's.
287 trace_step += 1
288 world_base = env.body_frame("base_link")
289 target_xyz = frame_point(world_base, target.p)
290 tcp_xyz = frame_point(world_base, tcp_frame(robot, state).p)
291 if target_prev and trace_step % 5 == 0:
292 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
293 if tcp_prev and trace_step % 5 == 0:
294 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
295 target_prev = target_xyz
296 tcp_prev = tcp_xyz
297
298 if not env.step():
299 break
300 env.pace()
301 finally:
302 env.data.body(TOOL_BODY).xfrc_applied[:] = 0.0
303
304
305def elbow_height(env: mjk.Env) -> float:
306 return env.body_frame(ELBOW_BODY).p.z() - TABLE_Z
307
308
309def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
310 """The helix, the tare, then a scripted push on the tool sensed by the F/T; returns metrics."""
311 elbow_start = elbow_min = elbow_height(env)
312 t0 = env.data.time
313 helix_react = 0.0
314 helix_track_err = 0.0
315 while env.data.time - t0 < TEACH_TIME:
316 t = env.data.time - t0
317 env.update()
318 close_gripper(env)
319 target = admittance_step(env, robot, nominal, state, spiral_force(t))
320 tcp = tcp_frame(robot, state)
321 err = [tcp.p[i] - target.p[i] for i in range(3)]
322 helix_react = max(helix_react, vnorm(state["offset"]))
323 helix_track_err = max(helix_track_err, vnorm(err))
324 if not env.step():
325 break
326 elbow_min = min(elbow_min, elbow_height(env))
327 env.pace()
328
329 handoff_force = 0.0
330 t_handoff = env.data.time
331 while env.data.time - t_handoff < HANDOFF_TARE_TIME:
332 env.update()
333 close_gripper(env)
334 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
335 tcp = tcp_frame(robot, state)
336 err = [tcp.p[i] - target.p[i] for i in range(3)]
337 helix_track_err = max(helix_track_err, vnorm(err))
338 if not env.step():
339 break
340 elbow_min = min(elbow_min, elbow_height(env))
341 env.pace()
342 env.update()
343 state["bias"] = tare_force(env, robot)
344 for _ in range(100):
345 env.update()
346 close_gripper(env)
347 force = measured_force(env, robot, state)
348 handoff_force = max(handoff_force, vnorm(force))
349 admittance_step(env, robot, nominal, state, force)
350 if not env.step():
351 break
352 elbow_min = min(elbow_min, elbow_height(env))
353 env.pace()
354
355 helix_settle_err = 0.0
356 t_settle = env.data.time
357 while env.data.time - t_settle < 0.5:
358 env.update()
359 close_gripper(env)
360 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
361 tcp = tcp_frame(robot, state)
362 err = [tcp.p[i] - target.p[i] for i in range(3)]
363 helix_settle_err = max(helix_settle_err, vnorm(err))
364 if not env.step():
365 break
366 elbow_min = min(elbow_min, elbow_height(env))
367 env.pace()
368
369 pre_push = state["offset"][:]
370 t1 = env.data.time
371 settled: list[float] | None = None
372 push_recovery_err: float | None = None
373 while env.data.time - t1 < 4.0:
374 t = env.data.time - t1
375 push = SELFCHECK_PUSH if t < 1.0 else (0.0, 0.0, 0.0)
376 env.data.body(TOOL_BODY).xfrc_applied[:] = [*push, 0.0, 0.0, 0.0]
377 env.update()
378 close_gripper(env)
379 target = admittance_step(env, robot, nominal, state, measured_force(env, robot, state))
380 tcp = tcp_frame(robot, state)
381 err = [tcp.p[i] - target.p[i] for i in range(3)]
382 if push_recovery_err is None and t >= 2.0:
383 push_recovery_err = vnorm(err)
384 # Once the release transient (~1.5 s) has died; the drift is judged from here to the end.
385 if settled is None and t >= 2.5:
386 settled = state["offset"][:]
387 if not env.step():
388 break
389 elbow_min = min(elbow_min, elbow_height(env))
390 env.pace()
391 env.data.body(TOOL_BODY).xfrc_applied[:] = 0.0
392 return {
393 "helix_react": helix_react,
394 "helix_track_err": helix_track_err,
395 "helix_settle_err": helix_settle_err,
396 "handoff_force": handoff_force,
397 "push_response": vnorm([(settled or pre_push)[i] - pre_push[i] for i in range(3)]),
398 "push_dy": (settled or pre_push)[1] - pre_push[1],
399 "push_recovery_err": push_recovery_err or 0.0,
400 "hold_drift": vnorm([state["offset"][i] - (settled or pre_push)[i] for i in range(3)]),
401 "elbow_start": elbow_start,
402 "elbow_min": elbow_min,
403 }
404
405
406def main() -> int:
407 parser = argparse.ArgumentParser()
408 parser.add_argument("--gui", action="store_true")
409 args = parser.parse_args()
410
411 env, robot = build_env()
412 try:
413 chain = robot.kdl_chain()
414 acc_ik = kdl.ChainIkSolverVel_wdls(chain)
415 acc_ik.setLambda(0.10)
416 robot.set_control_mode(mjk.CtrlMode.TORQUE) # RNEA computed-torque inner loop
417
418 state = {
419 "bias": [0.0, 0.0, 0.0],
420 "offset": [0.0, 0.0, 0.0],
421 "vel": [0.0, 0.0, 0.0],
422 "fk": kdl.ChainFkSolverPos_recursive(chain),
423 "jac_solver": kdl.ChainJntToJacSolver(chain),
424 "acc_ik": acc_ik,
425 "id_solver": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
426 "n_seg": chain.getNrOfSegments(),
427 "reset": False,
428 }
429
430 def on_reset(ctx):
431 robot.set_joint_pos(HOME)
432 state["offset"] = [0.0, 0.0, 0.0]
433 state["vel"] = [0.0, 0.0, 0.0]
434 env.data.body(TOOL_BODY).xfrc_applied[:] = 0.0
435 state["reset"] = True
436
437 env.on_reset = on_reset
438 env.reset()
439 state["reset"] = False
440 state["bias"] = settle_and_tare(env, robot, state)
441 nominal = tcp_frame(robot, state)
442
443 bias = state['bias']
444 print(f"FT bias: [{bias[0]:.3f}, {bias[1]:.3f}, {bias[2]:.3f}] N")
445 if args.gui:
446 run_gui(env, robot, nominal, state)
447 print(
448 "final offset: "
449 f"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
450 )
451 else:
452 m = run_selfcheck(env, robot, nominal, state)
453 print(f"helix force response (max offset): {m['helix_react']:.4f} m")
454 print(f"helix TCP tracking error: {m['helix_track_err']:.4f} m")
455 print(f"helix settle error: {m['helix_settle_err']:.4f} m")
456 print(f"FT handoff residual force: {m['handoff_force']:.4f} N")
457 print(f"FT push response (offset norm): {m['push_response']:.4f} m")
458 print(f"FT push response (offset dY): {m['push_dy']:.4f} m")
459 print(f"push release recovery error: {m['push_recovery_err']:.4f} m")
460 print(f"hold drift after push released: {m['hold_drift']:.4f} m")
461 print(
462 f"elbow height start / lowest: {m['elbow_start']:.4f} / "
463 f"{m['elbow_min']:.4f} m (limit {MIN_ELBOW_HEIGHT:.4f} m)"
464 )
465 ok = (
466 m["helix_react"] > 0.10
467 and m["helix_track_err"] < 0.006
468 and m["helix_settle_err"] < 0.006
469 and m["handoff_force"] == 0.0
470 and m["push_response"] > 0.12
471 and m["push_recovery_err"] < 0.002
472 and m["hold_drift"] < 0.001
473 and m["elbow_min"] >= MIN_ELBOW_HEIGHT
474 )
475 goal = "the admittance responded to the helix and the FT push and held on release"
476 print(f"{'PASS' if ok else 'FAIL'}: {goal}")
477 return 0 if ok else 1
478 finally:
479 env.close()
480 return 0
481
482
483if __name__ == "__main__":
484 raise SystemExit(main())
float elbow_height(mjk.Env env)
None run_gui(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
list[float] tare_force(mjk.Env env, mjk.Robot robot)
list[float] vscale(list[float] a, float s)
list[float] measured_force(mjk.Env env, mjk.Robot robot, dict state)
list[float] jacobian_twist(kdl.Jacobian jac, kdl.JntArray qdot)
list[float] vadd(list[float] a, list[float] b)
float clamp(float value, float low, float high)
kdl.JntArray jnt(list[float] values)
mjk.AttachmentSpec ft_attachment()
None rnea_track(mjk.Env env, mjk.Robot robot, dict state, kdl.Frame target)
list[float] settle_and_tare(mjk.Env env, mjk.Robot robot, dict state)
dict run_selfcheck(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
None close_gripper(mjk.Env env)
mjk.AttachmentSpec gripper_attachment()
list[float] frame_point(kdl.Frame frame, kdl.Vector point)
list[float] spiral_force(float t)
list[float] xyz(kdl.Vector v)
float vnorm(list[float] a)
admittance_step(env, robot, nominal, state, force)
kdl.Frame tcp_frame(mjk.Robot robot, dict state)
list[float] vclamp(list[float] a, float limit)
mjk.SceneObject table_object()
tuple[mjk.Env, mjk.Robot] build_env()
None admittance_update(dict state, list[float] force, float dt)