2"""Kinova + Robotiq tabletop admittance control driven by a named FT sensor.
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.
10Outer admittance law per Cartesian axis (no position stiffness):
13 v += a * dt (clamped to MAX_VEL)
14 offset += v * dt (clamped to MAX_OFFSET)
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).
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.
29from __future__
import annotations
35import mj_kdl_wrapper
as mjk
37HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
42M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
47GRIPPER_ACTUATOR =
"g_fingers_actuator"
49HANDOFF_TARE_TIME = 1.0
50SELFCHECK_PUSH = (8.0, 12.0, 6.0)
59def jnt(values: list[float]) -> kdl.JntArray:
60 out = kdl.JntArray(len(values))
61 for i, value
in enumerate(values):
66def clamp(value: float, low: float, high: float) -> float:
67 return max(low, min(high, value))
70def vadd(a: list[float], b: list[float]) -> list[float]:
71 return [a[i] + b[i]
for i
in range(3)]
74def vscale(a: list[float], s: float) -> list[float]:
75 return [s * a[i]
for i
in range(3)]
78def vclamp(a: list[float], limit: float) -> list[float]:
79 return [
clamp(x, -limit, limit)
for x
in a]
82def vnorm(a: list[float]) -> float:
83 return math.sqrt(sum(x * x
for x
in a))
86def xyz(v: kdl.Vector) -> list[float]:
87 return [v.x(), v.y(), v.z()]
90def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
91 return xyz(frame * point)
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")
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")
110 table = mjk.SceneObject()
112 table.mjcf_path = mjk.menagerie.asset_path(
"table.xml", env_var=
"MJ_KDL_TABLE")
113 table.pos = [0.0, 0.0, TABLE_Z]
120 spec = mjk.SceneSpec()
121 spec.timestep = 0.002
122 spec.add_floor =
True
123 spec.add_skybox =
True
124 spec.objects = [table]
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")
132 spec.robots = [robot_spec]
134 env = mjk.Env.build(spec)
136 ft = mjk.ForceTorqueSensorSpec()
138 ft.frame_site =
"wrist_ft_site"
140 tool = mjk.ToolFrameSpec()
141 tool.tool_body =
"g_base"
142 tool.tcp_site =
"g_pinch"
143 tool.ft_sensors = [ft]
145 robot = env.create_robot(
"base_link",
"bracelet_link", tool=tool)
150 ik: kdl.ChainIkSolverVel_wdls,
151 fk: kdl.ChainFkSolverPos_recursive,
154 limits: list[tuple[float, float]],
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:
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)
177def hold(robot: mjk.Robot, q: list[float]) ->
None:
178 """Inner position loop: command and pin the arm to q (exact tracking).
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).
185 robot.jnt_pos_cmd = q
186 robot.set_joint_pos(q, call_forward=
True)
190 if env.has_actuator(GRIPPER_ACTUATOR):
191 env.set_actuator_ctrl(GRIPPER_ACTUATOR, 255.0)
195 """Close the gripper, hold home until the wrist load settles, then tare.
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.
203 for _
in range(SETTLE_STEPS):
210 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
214 """External force on the tool in world frame, gravity-tared, deadbanded.
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)
225 wrench = robot.ft_sensor(
"wrist_ft")
226 f_world =
xyz(robot.ft_sensor_frame(
"wrist_ft").M * wrench.force)
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]
235 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
242 if force == [0.0, 0.0, 0.0]:
243 state[
"vel"] = [0.0, 0.0, 0.0]
246 (force[i] - D_ADM * state[
"vel"][i] - K_ADM * state[
"offset"][i]) / M_ADM
250 state[
"offset"] =
vclamp(
vadd(state[
"offset"],
vscale(state[
"vel"], dt)), MAX_OFFSET)
254 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
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.
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]
271 """One admittance tick: force -> offset (outer loop) -> position-tracked TCP.
273 robot.update() must have run this step so the FT read behind `force` is
274 current. Returns the commanded target frame (for tracing).
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"])
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).
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.
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))
297 handoff_tared =
False
298 target_prev: list[float] |
None =
None
299 tcp_prev: list[float] |
None =
None
302 while viewer.is_running():
303 if env.time() < prev - 1e-6:
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
312 t = env.time() - start
320 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
321 force = [0.0, 0.0, 0.0]
323 if not handoff_tared:
334 world_base = env.body_frame(
"base_link")
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
344 if not viewer.step():
347 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
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.
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
361 helix_track_err = 0.0
362 while env.time() - t0 < TEACH_TIME:
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))
375 t_handoff = env.time()
376 while env.time() - t_handoff < HANDOFF_TARE_TIME:
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))
391 handoff_force = max(handoff_force,
vnorm(force))
396 helix_settle_err = 0.0
397 t_settle = env.time()
398 while env.time() - t_settle < 0.5:
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))
408 pre_push = state[
"offset"][:]
410 settled: list[float] |
None =
None
411 push_recovery_err: float |
None =
None
412 while env.time() - t1 < 4.0:
414 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH
if t < 1.0
else (0.0, 0.0, 0.0))
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"][:]
426 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
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)]),
440 parser = argparse.ArgumentParser()
441 parser.add_argument(
"--gui", action=
"store_true")
442 args = parser.parse_args()
446 chain = robot.kdl_chain()
447 fk = kdl.ChainFkSolverPos_recursive(chain)
448 ik = kdl.ChainIkSolverVel_wdls(chain)
450 robot.ctrl_mode = mjk.CtrlMode.POSITION
453 "bias": [0.0, 0.0, 0.0],
454 "offset": [0.0, 0.0, 0.0],
455 "vel": [0.0, 0.0, 0.0],
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))
468 env.on_reset = on_reset
474 nominal = robot.fk_frame()
476 print(f
"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
478 run_gui(env, robot, nominal, state)
481 f
"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
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")
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)