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):
211 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
215 """External force on the tool in world frame, gravity-tared, deadbanded.
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)
226 wrench = robot.ft_sensor(
"wrist_ft")
227 f_world =
xyz(robot.ft_sensor_frame(
"wrist_ft").M * wrench.force)
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]
236 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
243 if force == [0.0, 0.0, 0.0]:
244 state[
"vel"] = [0.0, 0.0, 0.0]
247 (force[i] - D_ADM * state[
"vel"][i] - K_ADM * state[
"offset"][i]) / M_ADM
251 state[
"offset"] =
vclamp(
vadd(state[
"offset"],
vscale(state[
"vel"], dt)), MAX_OFFSET)
255 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
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.
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]
272 """One admittance tick: force -> offset (outer loop) -> position-tracked TCP.
274 robot.update() must have run this step so the FT read behind `force` is
275 current. Returns the commanded target frame (for tracing).
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"])
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).
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.
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))
298 handoff_tared =
False
299 target_prev: list[float] |
None =
None
300 tcp_prev: list[float] |
None =
None
303 while viewer.is_running():
304 if env.time() < prev - 1e-6:
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
313 t = env.time() - start
321 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
322 force = [0.0, 0.0, 0.0]
324 if not handoff_tared:
335 world_base = env.body_frame(
"base_link")
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
345 if not viewer.step():
349 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
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.
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
363 helix_track_err = 0.0
364 while env.time() - t0 < TEACH_TIME:
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))
378 t_handoff = env.time()
379 while env.time() - t_handoff < HANDOFF_TARE_TIME:
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))
395 handoff_force = max(handoff_force,
vnorm(force))
401 helix_settle_err = 0.0
402 t_settle = env.time()
403 while env.time() - t_settle < 0.5:
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))
414 pre_push = state[
"offset"][:]
416 settled: list[float] |
None =
None
417 push_recovery_err: float |
None =
None
418 while env.time() - t1 < 4.0:
420 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH
if t < 1.0
else (0.0, 0.0, 0.0))
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"][:]
433 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
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)]),
447 parser = argparse.ArgumentParser()
448 parser.add_argument(
"--gui", action=
"store_true")
449 args = parser.parse_args()
453 chain = robot.kdl_chain()
454 fk = kdl.ChainFkSolverPos_recursive(chain)
455 ik = kdl.ChainIkSolverVel_wdls(chain)
457 robot.ctrl_mode = mjk.CtrlMode.POSITION
460 "bias": [0.0, 0.0, 0.0],
461 "offset": [0.0, 0.0, 0.0],
462 "vel": [0.0, 0.0, 0.0],
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))
475 env.on_reset = on_reset
481 nominal = robot.fk_frame()
483 print(f
"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
485 run_gui(env, robot, nominal, state)
488 f
"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
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")
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)