Mujoco KDL Wrapper  0.2.2
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"""Kinova + Robotiq tabletop admittance control driven by a named FT sensor.
3
4The robot stays in ONE control law for the whole run: admittance. Admittance
5control is an outer force->position loop wrapped around a stiff inner position
6controller (it is the position-controlled dual of impedance control, which is
7torque-based). The outer loop maps external force to a TCP position offset; the
8inner loop (POSITION mode) tracks that offset exactly.
9
10Outer admittance law per Cartesian axis (no position stiffness):
11
12 M * a = F_ext - D * v
13 v += a * dt (clamped to MAX_VEL)
14 offset += v * dt (clamped to MAX_OFFSET)
15
16The logical FT sensor sits between the Kinova wrist and the Robotiq gripper.
17After closing the gripper and letting the wrist load settle, the controller
18tares it (the gripper's ~10 N static load only appears once it has closed).
19
20The run has two sources of external force, both handled by the same law:
21 - Intro: a scripted force whose direction sweeps a helix (spiral_force) drives
22 the admittance, so the TCP traces a helix.
23 - After the helix: the scripted force stops; the controller stays in
24 admittance and responds to the FT-measured force, so in the GUI you can
25 ctrl + right-drag the gripper. With K = 0 there is no equilibrium to spring
26 back to: when force stops, damping bleeds v -> 0 and the pose holds.
27"""
28
29from __future__ import annotations
30
31import argparse
32import math
33
34import PyKDL as kdl
35import mj_kdl_wrapper as mjk
36
37HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
38TABLE_Z = 0.70
39
40# Admittance outer loop: virtual mass, damping, stiffness (isotropic).
41# K_ADM = 0 -> pure hand-guiding: holds pose on release. Set > 0 to self-center.
42M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
43FORCE_DEADBAND = 2.5 # N; rejects sensor noise and settling transients
44MAX_OFFSET = 0.20 # m; reachable workspace half-extent around home
45MAX_VEL = 0.25 # m/s
46TOOL_BODY = "g_base" # rigid gripper base; where the headless self-check pushes
47GRIPPER_ACTUATOR = "g_fingers_actuator"
48SETTLE_STEPS = 300 # ~0.6 s at dt=0.002 to close the gripper before taring
49HANDOFF_TARE_TIME = 1.0 # s; let scripted-motion transients settle before FT hand-guiding
50SELFCHECK_PUSH = (8.0, 12.0, 6.0)
51
52# Intro helical force: amplitude/shape and how long it is applied.
53TEACH_TIME = 16.0
54TEACH_RADIUS = 0.04
55TEACH_RISE = 0.10
56TEACH_TURNS = 5.0
57
58
59def jnt(values: list[float]) -> kdl.JntArray:
60 out = kdl.JntArray(len(values))
61 for i, value in enumerate(values):
62 out[i] = value
63 return out
64
65
66def clamp(value: float, low: float, high: float) -> float:
67 return max(low, min(high, value))
68
69
70def vadd(a: list[float], b: list[float]) -> list[float]:
71 return [a[i] + b[i] for i in range(3)]
72
73
74def vscale(a: list[float], s: float) -> list[float]:
75 return [s * a[i] for i in range(3)]
76
77
78def vclamp(a: list[float], limit: float) -> list[float]:
79 return [clamp(x, -limit, limit) for x in a]
80
81
82def vnorm(a: list[float]) -> float:
83 return math.sqrt(sum(x * x for x in a))
84
85
86def xyz(v: kdl.Vector) -> list[float]:
87 return [v.x(), v.y(), v.z()]
88
89
90def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
91 return xyz(frame * point)
92
93
94def ft_attachment() -> mjk.AttachmentSpec:
95 spec = mjk.AttachmentSpec()
96 spec.mjcf_path = mjk.menagerie.asset_path("ft_sensor.xml", env_var="MJ_KDL_FT_SENSOR")
97 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "pinch_site")
98 return spec
99
100
101def gripper_attachment() -> mjk.AttachmentSpec:
102 spec = mjk.AttachmentSpec()
103 spec.mjcf_path = mjk.menagerie.asset_path("robotiq_2f85/2f85.xml", env_var="MJ_KDL_GRIPPER")
104 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site, "wrist_ft_site")
105 spec.prefix = "g_"
106 return spec
107
108
109def table_object() -> mjk.SceneObject:
110 table = mjk.SceneObject()
111 table.name = "table"
112 table.mjcf_path = mjk.menagerie.asset_path("table.xml", env_var="MJ_KDL_TABLE")
113 table.pos = [0.0, 0.0, TABLE_Z]
114 table.fixed = True
115 return table
116
117
118def build_env() -> tuple[mjk.Env, mjk.Robot]:
119 table = table_object()
120 spec = mjk.SceneSpec()
121 spec.timestep = 0.002
122 spec.add_floor = True
123 spec.add_skybox = True
124 spec.objects = [table]
125
126 robot_spec = mjk.RobotSpec()
127 robot_spec.path = mjk.menagerie.model_path("kinova_gen3", env_var="MJ_KDL_MODEL")
128 robot_spec.attach_to = mjk.AttachTarget(
129 mjk.AttachKind.Site, mjk.scene_object_site_name(table, "table_top")
130 )
131 robot_spec.attachments = [ft_attachment(), gripper_attachment()]
132 spec.robots = [robot_spec]
133
134 env = mjk.Env.build(spec)
135
136 ft = mjk.ForceTorqueSensorSpec()
137 ft.name = "wrist_ft"
138 ft.frame_site = "wrist_ft_site"
139
140 tool = mjk.ToolFrameSpec()
141 tool.tool_body = "g_base"
142 tool.tcp_site = "g_pinch"
143 tool.ft_sensors = [ft]
144
145 robot = env.create_robot("base_link", "bracelet_link", tool=tool)
146 return env, robot
147
148
150 ik: kdl.ChainIkSolverVel_wdls,
151 fk: kdl.ChainFkSolverPos_recursive,
152 q_seed: list[float],
153 target: kdl.Frame,
154 limits: list[tuple[float, float]],
155) -> list[float]:
156 q = jnt(q_seed)
157 dq = kdl.JntArray(len(q_seed))
158 current = kdl.Frame()
159 fk.JntToCart(q, current)
160 dx = kdl.diff(current, target)
161 if dx.vel.Norm() > 0.03:
162 dx.vel = dx.vel * (0.03 / dx.vel.Norm())
163 if dx.rot.Norm() > 0.15:
164 dx.rot = dx.rot * (0.15 / dx.rot.Norm())
165 if ik.CartToJnt(q, dx, dq) < 0:
166 return q_seed
167 out = []
168 for i, value in enumerate(q_seed):
169 low, high = limits[i]
170 next_value = value + dq[i]
171 if math.isfinite(low) and math.isfinite(high) and high > low:
172 next_value = clamp(next_value, low, high)
173 out.append(next_value)
174 return out
175
176
177def hold(robot: mjk.Robot, q: list[float]) -> None:
178 """Inner position loop: command and pin the arm to q (exact tracking).
179
180 Admittance control is an outer force->position loop wrapped around a stiff
181 inner position controller. The Kinova's position actuators are too soft to
182 track a moving Cartesian target, so we make the inner loop ideal by also
183 setting the joint state directly (call_forward refreshes kinematics/sensors).
184 """
185 robot.jnt_pos_cmd = q
186 robot.set_joint_pos(q, call_forward=True)
187
188
189def close_gripper(env: mjk.Env) -> None:
190 if env.has_actuator(GRIPPER_ACTUATOR):
191 env.set_actuator_ctrl(GRIPPER_ACTUATOR, 255.0)
192
193
194def settle_and_tare(env: mjk.Env, robot: mjk.Robot) -> list[float]:
195 """Close the gripper, hold home until the wrist load settles, then tare.
196
197 The gripper's static load shows up at the FT site only once it has closed
198 and settled (~10 N here). Taring before that (right after reset, gripper
199 open) leaves a large constant bias error that an integrating (K=0)
200 admittance turns into permanent drift. So we hold the closed-gripper home
201 pose for a moment first, then capture the bias.
202 """
203 for _ in range(SETTLE_STEPS):
204 robot.update()
205 close_gripper(env)
206 hold(robot, HOME)
207 if not robot.step():
208 break
209 robot.update()
210 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
211
212
213def measured_force(robot: mjk.Robot, state: dict) -> list[float]:
214 """External force on the tool in world frame, gravity-tared, deadbanded.
215
216 The MuJoCo force sensor reports the reaction wrench at the site, so the
217 external push the user applies is the negated, bias-removed reading. The
218 bias is the gripper's static gravity load captured after the gripper closes
219 and the wrist load settles (see settle_and_tare); expressed in
220 the world frame this is just the distal weight (mg, downward) and is
221 invariant to the arm configuration, so a single tare stays valid as the TCP
222 translates around home. Sub-deadband residue (noise, settling transients)
223 is rejected to zero.
224 """
225 wrench = robot.ft_sensor("wrist_ft")
226 f_world = xyz(robot.ft_sensor_frame("wrist_ft").M * wrench.force)
227 bias = state["bias"]
228 f_ext = [bias[i] - f_world[i] for i in range(3)]
229 if vnorm(f_ext) < FORCE_DEADBAND:
230 return [0.0, 0.0, 0.0]
231 return f_ext
232
233
234def tare_force(robot: mjk.Robot) -> list[float]:
235 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
236
237
238def admittance_update(state: dict, force: list[float], dt: float) -> None:
239 # offset = integral of velocity, so with K = 0 it is a pure integrator: the
240 # moment the push stops (force deadbanded to zero) we kill the velocity so
241 # motion stops dead and the offset (pose) is held exactly where it was left.
242 if force == [0.0, 0.0, 0.0]:
243 state["vel"] = [0.0, 0.0, 0.0]
244 return
245 acc = [
246 (force[i] - D_ADM * state["vel"][i] - K_ADM * state["offset"][i]) / M_ADM
247 for i in range(3)
248 ]
249 state["vel"] = vclamp(vadd(state["vel"], vscale(acc, dt)), MAX_VEL)
250 state["offset"] = vclamp(vadd(state["offset"], vscale(state["vel"], dt)), MAX_OFFSET)
251
252
253def spiral_force(t: float) -> list[float]:
254 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
255
256 The force is D_ADM times the velocity of a helical path, so a mass-damper
257 admittance (steady state v = F / D) turns it into helical motion. Applied to
258 the tool and sensed by the FT sensor, this drives the intro helix.
259 """
260 if t < 0.0 or t > TEACH_TIME:
261 return [0.0, 0.0, 0.0]
262 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
263 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
264 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
265 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
266 vz = TEACH_RISE / TEACH_TIME
267 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
268
269
270def admittance_step(env, robot, nominal, state, force):
271 """One admittance tick: force -> offset (outer loop) -> position-tracked TCP.
272
273 robot.update() must have run this step so the FT read behind `force` is
274 current. Returns the commanded target frame (for tracing).
275 """
276 ik = state["ik"]
277 fk = state["fk"]
278 admittance_update(state, force, env.timestep())
279 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state["offset"]))
280 state["q_des"] = ik_step(ik, fk, state["q_des"], target, robot.joint_limits)
281 hold(robot, state["q_des"])
282 return target
283
284
285def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> None:
286 """Admittance control for the whole run (POSITION inner loop throughout).
287
288 For the first TEACH_TIME seconds a scripted helical force drives the
289 admittance, so the TCP traces a helix. After that the scripted force stops
290 and you can ctrl + right-drag the gripper to apply your own force, which the
291 FT senses; the same admittance responds and holds on release.
292 """
293 viewer = mjk.SimulateViewer.open(robot, "ex_admittance_ft.py")
294 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
295 prev = env.time()
296 start = env.time()
297 handoff_tared = False
298 target_prev: list[float] | None = None
299 tcp_prev: list[float] | None = None
300 trace_step = 0
301 try:
302 while viewer.is_running():
303 if env.time() < prev - 1e-6:
304 env.reset()
305 start = env.time()
306 handoff_tared = False
307 state["offset"] = [0.0, 0.0, 0.0]
308 state["vel"] = [0.0, 0.0, 0.0]
309 state["q_des"] = HOME[:]
310 target_prev = tcp_prev = None
311 prev = env.time()
312 t = env.time() - start
313 robot.update()
314 close_gripper(env)
315 # Intro: the scripted helical force IS the external force the demo
316 # applies (fed straight in -- also shoving the body would double-
317 # actuate it). After: the FT-measured force, so a hand-drag is sensed.
318 if t < TEACH_TIME:
319 force = spiral_force(t)
320 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
321 force = [0.0, 0.0, 0.0]
322 else:
323 if not handoff_tared:
324 state["bias"] = tare_force(robot)
325 handoff_tared = True
326 force = measured_force(robot, state)
327 target = admittance_step(env, robot, nominal, state, force)
328
329 # Draw the commanded (yellow) and actual measured TCP (green) paths.
330 # Both poses are in the base_link frame, so map them through the base
331 # body's world pose before tracing or the trail lands at the wrong
332 # place (down by the base) and looks skewed.
333 trace_step += 1
334 world_base = env.body_frame("base_link")
335 target_xyz = frame_point(world_base, target.p)
336 tcp_xyz = frame_point(world_base, robot.fk_frame().p)
337 if target_prev and trace_step % 5 == 0:
338 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
339 if tcp_prev and trace_step % 5 == 0:
340 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
341 target_prev = target_xyz
342 tcp_prev = tcp_xyz
343
344 if not viewer.step():
345 break
346 finally:
347 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
348 viewer.close()
349
350
351def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
352 """Headless exercise of the same admittance law the GUI uses. Returns metrics.
353
354 Phase A: the scripted helical force drives the admittance (intro behaviour).
355 Phase B: a physical +Y wrench is sensed by the FT and yielded to, then
356 released. Verifies the admittance reacts to both force sources and holds
357 when force stops.
358 """
359 t0 = env.time()
360 helix_react = 0.0
361 helix_track_err = 0.0
362 while env.time() - t0 < TEACH_TIME:
363 t = env.time() - t0
364 robot.update()
365 close_gripper(env)
366 target = admittance_step(env, robot, nominal, state, spiral_force(t))
367 tcp = robot.fk_frame()
368 err = [tcp.p[i] - target.p[i] for i in range(3)]
369 helix_react = max(helix_react, vnorm(state["offset"]))
370 helix_track_err = max(helix_track_err, vnorm(err))
371 if not robot.step():
372 break
373
374 handoff_force = 0.0
375 t_handoff = env.time()
376 while env.time() - t_handoff < HANDOFF_TARE_TIME:
377 robot.update()
378 close_gripper(env)
379 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
380 tcp = robot.fk_frame()
381 err = [tcp.p[i] - target.p[i] for i in range(3)]
382 helix_track_err = max(helix_track_err, vnorm(err))
383 if not robot.step():
384 break
385 robot.update()
386 state["bias"] = tare_force(robot)
387 for _ in range(100):
388 robot.update()
389 close_gripper(env)
390 force = measured_force(robot, state)
391 handoff_force = max(handoff_force, vnorm(force))
392 admittance_step(env, robot, nominal, state, force)
393 if not robot.step():
394 break
395
396 helix_settle_err = 0.0
397 t_settle = env.time()
398 while env.time() - t_settle < 0.5:
399 robot.update()
400 close_gripper(env)
401 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
402 tcp = robot.fk_frame()
403 err = [tcp.p[i] - target.p[i] for i in range(3)]
404 helix_settle_err = max(helix_settle_err, vnorm(err))
405 if not robot.step():
406 break
407
408 pre_push = state["offset"][:]
409 t1 = env.time()
410 settled: list[float] | None = None
411 push_recovery_err: float | None = None
412 while env.time() - t1 < 4.0:
413 t = env.time() - t1
414 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH if t < 1.0 else (0.0, 0.0, 0.0))
415 robot.update()
416 close_gripper(env)
417 target = admittance_step(env, robot, nominal, state, measured_force(robot, state))
418 tcp = robot.fk_frame()
419 err = [tcp.p[i] - target.p[i] for i in range(3)]
420 if push_recovery_err is None and t >= 2.0:
421 push_recovery_err = vnorm(err)
422 if settled is None and t >= 2.5:
423 settled = state["offset"][:]
424 if not robot.step():
425 break
426 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
427 return {
428 "helix_react": helix_react,
429 "helix_track_err": helix_track_err,
430 "helix_settle_err": helix_settle_err,
431 "handoff_force": handoff_force,
432 "push_response": vnorm([(settled or pre_push)[i] - pre_push[i] for i in range(3)]),
433 "push_dy": (settled or pre_push)[1] - pre_push[1],
434 "push_recovery_err": push_recovery_err or 0.0,
435 "hold_drift": vnorm([state["offset"][i] - (settled or pre_push)[i] for i in range(3)]),
436 }
437
438
439def main() -> int:
440 parser = argparse.ArgumentParser()
441 parser.add_argument("--gui", action="store_true")
442 args = parser.parse_args()
443
444 env, robot = build_env()
445 try:
446 chain = robot.kdl_chain()
447 fk = kdl.ChainFkSolverPos_recursive(chain)
448 ik = kdl.ChainIkSolverVel_wdls(chain)
449 ik.setLambda(0.05)
450 robot.ctrl_mode = mjk.CtrlMode.POSITION # inner loop of the admittance controller
451
452 state = {
453 "bias": [0.0, 0.0, 0.0],
454 "offset": [0.0, 0.0, 0.0],
455 "vel": [0.0, 0.0, 0.0],
456 "q_des": HOME[:],
457 "ik": ik,
458 "fk": fk,
459 }
460
461 def on_reset(ctx):
462 robot.set_joint_pos(HOME, call_forward=False)
463 state["offset"] = [0.0, 0.0, 0.0]
464 state["vel"] = [0.0, 0.0, 0.0]
465 state["q_des"] = HOME[:]
466 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
467
468 env.on_reset = on_reset
469 env.reset()
470 # Single tare after settling. Orientation is held during hand-guiding so
471 # the world-frame gravity bias stays ~constant; a slow auto-tare would be
472 # needed only if drift exceeded the deadband during large reorientations.
473 state["bias"] = settle_and_tare(env, robot)
474 nominal = robot.fk_frame()
475
476 print(f"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
477 if args.gui:
478 run_gui(env, robot, nominal, state)
479 print(
480 "final offset: "
481 f"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
482 )
483 else:
484 m = run_selfcheck(env, robot, nominal, state)
485 print(f"helix force response (max offset): {m['helix_react']:.4f} m")
486 print(f"helix TCP tracking error: {m['helix_track_err']:.4f} m")
487 print(f"helix settle error: {m['helix_settle_err']:.4f} m")
488 print(f"FT handoff residual force: {m['handoff_force']:.4f} N")
489 print(f"FT push response (offset norm): {m['push_response']:.4f} m")
490 print(f"FT push response (offset dY): {m['push_dy']:.4f} m")
491 print(f"push release recovery error: {m['push_recovery_err']:.4f} m")
492 print(f"hold drift after push released: {m['hold_drift']:.4f} m")
493 assert m["helix_react"] > 0.05, "admittance did not respond to the helical force"
494 assert m["helix_track_err"] < 0.006, "TCP did not track the commanded helix"
495 assert m["helix_settle_err"] < 0.004, "TCP did not settle cleanly after the helix"
496 assert m["handoff_force"] == 0.0, "FT handoff produced a false external force"
497 assert m["push_response"] > 0.05, "admittance did not yield to the FT-sensed push"
498 assert m["push_recovery_err"] < 0.006, "TCP did not recover quickly after the push"
499 assert m["hold_drift"] < 0.01, "pose did not hold after the push stopped"
500 print("OK: admittance responded to helix + FT push and held on release")
501 finally:
502 env.close()
503 return 0
504
505
506if __name__ == "__main__":
507 raise SystemExit(main())
list[float] ik_step(kdl.ChainIkSolverVel_wdls ik, kdl.ChainFkSolverPos_recursive fk, list[float] q_seed, kdl.Frame target, list[tuple[float, float]] limits)
None run_gui(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
list[float] vscale(list[float] a, float s)
list[float] vadd(list[float] a, list[float] b)
float clamp(float value, float low, float high)
kdl.JntArray jnt(list[float] values)
list[float] tare_force(mjk.Robot robot)
mjk.AttachmentSpec ft_attachment()
list[float] measured_force(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)
None hold(mjk.Robot robot, list[float] q)
list[float] xyz(kdl.Vector v)
float vnorm(list[float] a)
admittance_step(env, robot, nominal, state, force)
list[float] vclamp(list[float] a, float limit)
list[float] settle_and_tare(mjk.Env env, mjk.Robot robot)
mjk.SceneObject table_object()
tuple[mjk.Env, mjk.Robot] build_env()
None admittance_update(dict state, list[float] force, float dt)