Mujoco KDL Wrapper  0.3.18
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.pace()
210 robot.update()
211 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
212
213
214def measured_force(robot: mjk.Robot, state: dict) -> list[float]:
215 """External force on the tool in world frame, gravity-tared, deadbanded.
216
217 The MuJoCo force sensor reports the reaction wrench at the site, so the
218 external push the user applies is the negated, bias-removed reading. The
219 bias is the gripper's static gravity load captured after the gripper closes
220 and the wrist load settles (see settle_and_tare); expressed in
221 the world frame this is just the distal weight (mg, downward) and is
222 invariant to the arm configuration, so a single tare stays valid as the TCP
223 translates around home. Sub-deadband residue (noise, settling transients)
224 is rejected to zero.
225 """
226 wrench = robot.ft_sensor("wrist_ft")
227 f_world = xyz(robot.ft_sensor_frame("wrist_ft").M * wrench.force)
228 bias = state["bias"]
229 f_ext = [bias[i] - f_world[i] for i in range(3)]
230 if vnorm(f_ext) < FORCE_DEADBAND:
231 return [0.0, 0.0, 0.0]
232 return f_ext
233
234
235def tare_force(robot: mjk.Robot) -> list[float]:
236 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
237
238
239def admittance_update(state: dict, force: list[float], dt: float) -> None:
240 # offset = integral of velocity, so with K = 0 it is a pure integrator: the
241 # moment the push stops (force deadbanded to zero) we kill the velocity so
242 # motion stops dead and the offset (pose) is held exactly where it was left.
243 if force == [0.0, 0.0, 0.0]:
244 state["vel"] = [0.0, 0.0, 0.0]
245 return
246 acc = [
247 (force[i] - D_ADM * state["vel"][i] - K_ADM * state["offset"][i]) / M_ADM
248 for i in range(3)
249 ]
250 state["vel"] = vclamp(vadd(state["vel"], vscale(acc, dt)), MAX_VEL)
251 state["offset"] = vclamp(vadd(state["offset"], vscale(state["vel"], dt)), MAX_OFFSET)
252
253
254def spiral_force(t: float) -> list[float]:
255 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
256
257 The force is D_ADM times the velocity of a helical path, so a mass-damper
258 admittance (steady state v = F / D) turns it into helical motion. Applied to
259 the tool and sensed by the FT sensor, this drives the intro helix.
260 """
261 if t < 0.0 or t > TEACH_TIME:
262 return [0.0, 0.0, 0.0]
263 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
264 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
265 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
266 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
267 vz = TEACH_RISE / TEACH_TIME
268 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
269
270
271def admittance_step(env, robot, nominal, state, force):
272 """One admittance tick: force -> offset (outer loop) -> position-tracked TCP.
273
274 robot.update() must have run this step so the FT read behind `force` is
275 current. Returns the commanded target frame (for tracing).
276 """
277 ik = state["ik"]
278 fk = state["fk"]
279 admittance_update(state, force, env.timestep())
280 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state["offset"]))
281 state["q_des"] = ik_step(ik, fk, state["q_des"], target, robot.joint_limits)
282 hold(robot, state["q_des"])
283 return target
284
285
286def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> None:
287 """Admittance control for the whole run (POSITION inner loop throughout).
288
289 For the first TEACH_TIME seconds a scripted helical force drives the
290 admittance, so the TCP traces a helix. After that the scripted force stops
291 and you can ctrl + right-drag the gripper to apply your own force, which the
292 FT senses; the same admittance responds and holds on release.
293 """
294 viewer = mjk.SimulateViewer.open(robot, "ex_admittance_ft.py")
295 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
296 prev = env.time()
297 start = env.time()
298 handoff_tared = False
299 target_prev: list[float] | None = None
300 tcp_prev: list[float] | None = None
301 trace_step = 0
302 try:
303 while viewer.is_running():
304 if env.time() < prev - 1e-6:
305 env.reset()
306 start = env.time()
307 handoff_tared = False
308 state["offset"] = [0.0, 0.0, 0.0]
309 state["vel"] = [0.0, 0.0, 0.0]
310 state["q_des"] = HOME[:]
311 target_prev = tcp_prev = None
312 prev = env.time()
313 t = env.time() - start
314 robot.update()
315 close_gripper(env)
316 # Intro: the scripted helical force IS the external force the demo
317 # applies (fed straight in -- also shoving the body would double-
318 # actuate it). After: the FT-measured force, so a hand-drag is sensed.
319 if t < TEACH_TIME:
320 force = spiral_force(t)
321 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
322 force = [0.0, 0.0, 0.0]
323 else:
324 if not handoff_tared:
325 state["bias"] = tare_force(robot)
326 handoff_tared = True
327 force = measured_force(robot, state)
328 target = admittance_step(env, robot, nominal, state, force)
329
330 # Draw the commanded (yellow) and actual measured TCP (green) paths.
331 # Both poses are in the base_link frame, so map them through the base
332 # body's world pose before tracing or the trail lands at the wrong
333 # place (down by the base) and looks skewed.
334 trace_step += 1
335 world_base = env.body_frame("base_link")
336 target_xyz = frame_point(world_base, target.p)
337 tcp_xyz = frame_point(world_base, robot.fk_frame().p)
338 if target_prev and trace_step % 5 == 0:
339 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
340 if tcp_prev and trace_step % 5 == 0:
341 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
342 target_prev = target_xyz
343 tcp_prev = tcp_xyz
344
345 if not viewer.step():
346 break
347 viewer.pace()
348 finally:
349 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
350 viewer.close()
351
352
353def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
354 """Headless exercise of the same admittance law the GUI uses. Returns metrics.
355
356 Phase A: the scripted helical force drives the admittance (intro behaviour).
357 Phase B: a physical +Y wrench is sensed by the FT and yielded to, then
358 released. Verifies the admittance reacts to both force sources and holds
359 when force stops.
360 """
361 t0 = env.time()
362 helix_react = 0.0
363 helix_track_err = 0.0
364 while env.time() - t0 < TEACH_TIME:
365 t = env.time() - t0
366 robot.update()
367 close_gripper(env)
368 target = admittance_step(env, robot, nominal, state, spiral_force(t))
369 tcp = robot.fk_frame()
370 err = [tcp.p[i] - target.p[i] for i in range(3)]
371 helix_react = max(helix_react, vnorm(state["offset"]))
372 helix_track_err = max(helix_track_err, vnorm(err))
373 if not robot.step():
374 break
375 robot.pace()
376
377 handoff_force = 0.0
378 t_handoff = env.time()
379 while env.time() - t_handoff < HANDOFF_TARE_TIME:
380 robot.update()
381 close_gripper(env)
382 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
383 tcp = robot.fk_frame()
384 err = [tcp.p[i] - target.p[i] for i in range(3)]
385 helix_track_err = max(helix_track_err, vnorm(err))
386 if not robot.step():
387 break
388 robot.pace()
389 robot.update()
390 state["bias"] = tare_force(robot)
391 for _ in range(100):
392 robot.update()
393 close_gripper(env)
394 force = measured_force(robot, state)
395 handoff_force = max(handoff_force, vnorm(force))
396 admittance_step(env, robot, nominal, state, force)
397 if not robot.step():
398 break
399 robot.pace()
400
401 helix_settle_err = 0.0
402 t_settle = env.time()
403 while env.time() - t_settle < 0.5:
404 robot.update()
405 close_gripper(env)
406 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
407 tcp = robot.fk_frame()
408 err = [tcp.p[i] - target.p[i] for i in range(3)]
409 helix_settle_err = max(helix_settle_err, vnorm(err))
410 if not robot.step():
411 break
412 robot.pace()
413
414 pre_push = state["offset"][:]
415 t1 = env.time()
416 settled: list[float] | None = None
417 push_recovery_err: float | None = None
418 while env.time() - t1 < 4.0:
419 t = env.time() - t1
420 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH if t < 1.0 else (0.0, 0.0, 0.0))
421 robot.update()
422 close_gripper(env)
423 target = admittance_step(env, robot, nominal, state, measured_force(robot, state))
424 tcp = robot.fk_frame()
425 err = [tcp.p[i] - target.p[i] for i in range(3)]
426 if push_recovery_err is None and t >= 2.0:
427 push_recovery_err = vnorm(err)
428 if settled is None and t >= 2.5:
429 settled = state["offset"][:]
430 if not robot.step():
431 break
432 robot.pace()
433 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
434 return {
435 "helix_react": helix_react,
436 "helix_track_err": helix_track_err,
437 "helix_settle_err": helix_settle_err,
438 "handoff_force": handoff_force,
439 "push_response": vnorm([(settled or pre_push)[i] - pre_push[i] for i in range(3)]),
440 "push_dy": (settled or pre_push)[1] - pre_push[1],
441 "push_recovery_err": push_recovery_err or 0.0,
442 "hold_drift": vnorm([state["offset"][i] - (settled or pre_push)[i] for i in range(3)]),
443 }
444
445
446def main() -> int:
447 parser = argparse.ArgumentParser()
448 parser.add_argument("--gui", action="store_true")
449 args = parser.parse_args()
450
451 env, robot = build_env()
452 try:
453 chain = robot.kdl_chain()
454 fk = kdl.ChainFkSolverPos_recursive(chain)
455 ik = kdl.ChainIkSolverVel_wdls(chain)
456 ik.setLambda(0.05)
457 robot.ctrl_mode = mjk.CtrlMode.POSITION # inner loop of the admittance controller
458
459 state = {
460 "bias": [0.0, 0.0, 0.0],
461 "offset": [0.0, 0.0, 0.0],
462 "vel": [0.0, 0.0, 0.0],
463 "q_des": HOME[:],
464 "ik": ik,
465 "fk": fk,
466 }
467
468 def on_reset(ctx):
469 robot.set_joint_pos(HOME, call_forward=False)
470 state["offset"] = [0.0, 0.0, 0.0]
471 state["vel"] = [0.0, 0.0, 0.0]
472 state["q_des"] = HOME[:]
473 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
474
475 env.on_reset = on_reset
476 env.reset()
477 # Single tare after settling. Orientation is held during hand-guiding so
478 # the world-frame gravity bias stays ~constant; a slow auto-tare would be
479 # needed only if drift exceeded the deadband during large reorientations.
480 state["bias"] = settle_and_tare(env, robot)
481 nominal = robot.fk_frame()
482
483 print(f"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
484 if args.gui:
485 run_gui(env, robot, nominal, state)
486 print(
487 "final offset: "
488 f"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
489 )
490 else:
491 m = run_selfcheck(env, robot, nominal, state)
492 print(f"helix force response (max offset): {m['helix_react']:.4f} m")
493 print(f"helix TCP tracking error: {m['helix_track_err']:.4f} m")
494 print(f"helix settle error: {m['helix_settle_err']:.4f} m")
495 print(f"FT handoff residual force: {m['handoff_force']:.4f} N")
496 print(f"FT push response (offset norm): {m['push_response']:.4f} m")
497 print(f"FT push response (offset dY): {m['push_dy']:.4f} m")
498 print(f"push release recovery error: {m['push_recovery_err']:.4f} m")
499 print(f"hold drift after push released: {m['hold_drift']:.4f} m")
500 assert m["helix_react"] > 0.05, "admittance did not respond to the helical force"
501 assert m["helix_track_err"] < 0.006, "TCP did not track the commanded helix"
502 assert m["helix_settle_err"] < 0.004, "TCP did not settle cleanly after the helix"
503 assert m["handoff_force"] == 0.0, "FT handoff produced a false external force"
504 assert m["push_response"] > 0.05, "admittance did not yield to the FT-sensed push"
505 assert m["push_recovery_err"] < 0.006, "TCP did not recover quickly after the push"
506 assert m["hold_drift"] < 0.01, "pose did not hold after the push stopped"
507 print("OK: admittance responded to helix + FT push and held on release")
508 finally:
509 env.close()
510 return 0
511
512
513if __name__ == "__main__":
514 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)