2"""Admittance control with an RNEA computed-torque inner loop, FT-driven.
4Same outer admittance loop as ex_admittance_ft.py, but a different inner loop.
5Admittance control is an outer force->position loop wrapped around an inner
6motion controller. The sibling example uses an (ideal) POSITION inner loop; here
7the inner loop is torque-based COMPUTED TORQUE in task space:
9 beta = Cartesian PD on TCP pose error (desired TCP accel)
10 qddot_des = WDLS(beta) (resolved acceleration)
11 tau = RNEA(q, qdot, qddot_des) (KDL ChainIdSolver_RNE)
12 apply tau in TORQUE mode
14RNEA inverse dynamics maps the resolved joint acceleration to torques through
15the full arm dynamics (gravity, Coriolis, inertia). Keeping the servo in
16Cartesian space avoids the unstable joint-IK target chasing that makes FT
17hand-guiding wobble after release.
19Outer admittance law per Cartesian axis (no position stiffness):
22 v += a * dt (clamped to MAX_VEL)
23 offset += v * dt (clamped to MAX_OFFSET)
25The logical FT sensor sits between the Kinova wrist and the Robotiq gripper.
26After closing the gripper and letting the wrist load settle, the controller
27tares it (the gripper's ~10 N static load only appears once it has closed).
29The run has two sources of external force, both handled by the same law:
30 - Intro: a scripted force whose direction sweeps a helix (spiral_force) drives
31 the admittance, so the TCP traces a helix.
32 - After the helix: the scripted force stops; the controller stays in
33 admittance and responds to the FT-measured force, so in the GUI you can
34 ctrl + right-drag the gripper. With K = 0 there is no equilibrium to spring
35 back to: when force stops, damping bleeds v -> 0 and the pose holds.
38from __future__
import annotations
44import mj_kdl_wrapper
as mjk
46HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
51KP_LIN, KD_LIN = 11520.0, 514.3
52KP_ROT, KD_ROT = 7200.0, 600.1
53BETA_LIN_MAX, BETA_ROT_MAX = 7200.0, 5040.0
58M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
63GRIPPER_ACTUATOR =
"g_fingers_actuator"
65HANDOFF_TARE_TIME = 1.0
66SELFCHECK_PUSH = (8.0, 12.0, 6.0)
75def jnt(values: list[float]) -> kdl.JntArray:
76 out = kdl.JntArray(len(values))
77 for i, value
in enumerate(values):
82def clamp(value: float, low: float, high: float) -> float:
83 return max(low, min(high, value))
86def vadd(a: list[float], b: list[float]) -> list[float]:
87 return [a[i] + b[i]
for i
in range(3)]
90def vscale(a: list[float], s: float) -> list[float]:
91 return [s * a[i]
for i
in range(3)]
94def vclamp(a: list[float], limit: float) -> list[float]:
95 return [
clamp(x, -limit, limit)
for x
in a]
98def vnorm(a: list[float]) -> float:
99 return math.sqrt(sum(x * x
for x
in a))
102def xyz(v: kdl.Vector) -> list[float]:
103 return [v.x(), v.y(), v.z()]
106def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
107 return xyz(frame * point)
111 spec = mjk.AttachmentSpec()
112 spec.mjcf_path = mjk.menagerie.asset_path(
"ft_sensor.xml", env_var=
"MJ_KDL_FT_SENSOR")
113 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site,
"pinch_site")
118 spec = mjk.AttachmentSpec()
119 spec.mjcf_path = mjk.menagerie.asset_path(
"robotiq_2f85/2f85.xml", env_var=
"MJ_KDL_GRIPPER")
120 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site,
"wrist_ft_site")
126 table = mjk.SceneObject()
128 table.mjcf_path = mjk.menagerie.asset_path(
"table.xml", env_var=
"MJ_KDL_TABLE")
129 table.pos = [0.0, 0.0, TABLE_Z]
136 spec = mjk.SceneSpec()
137 spec.timestep = 0.002
138 spec.add_floor =
True
139 spec.add_skybox =
True
140 spec.objects = [table]
142 robot_spec = mjk.RobotSpec()
143 robot_spec.path = mjk.menagerie.model_path(
"kinova_gen3", env_var=
"MJ_KDL_MODEL")
144 robot_spec.attach_to = mjk.AttachTarget(
145 mjk.AttachKind.Site, mjk.scene_object_site_name(table,
"table_top")
148 spec.robots = [robot_spec]
150 env = mjk.Env.build(spec)
152 ft = mjk.ForceTorqueSensorSpec()
154 ft.frame_site =
"wrist_ft_site"
156 tool = mjk.ToolFrameSpec()
157 tool.tool_body =
"g_base"
158 tool.tcp_site =
"g_pinch"
159 tool.ft_sensors = [ft]
161 robot = env.create_robot(
"base_link",
"bracelet_link", tool=tool)
166 return [sum(jac[row, col] * qdot[col]
for col
in range(qdot.rows()))
for row
in range(6)]
169def rnea_track(robot: mjk.Robot, state: dict, target: kdl.Frame) ->
None:
170 """Task-space computed torque: Cartesian PD -> qddot -> RNEA torque."""
171 q =
jnt(robot.jnt_pos_msr)
172 qdot =
jnt(robot.jnt_vel_msr)
174 err = kdl.diff(robot.fk_frame(), target)
175 jac = kdl.Jacobian(robot.n_joints)
176 state[
"jac_solver"].JntToJac(q, jac)
179 qddot = kdl.JntArray(robot.n_joints)
181 beta.vel = kdl.Vector(
182 clamp(KP_LIN * err.vel.x() - KD_LIN * tcp_vel[0], -BETA_LIN_MAX, BETA_LIN_MAX),
183 clamp(KP_LIN * err.vel.y() - KD_LIN * tcp_vel[1], -BETA_LIN_MAX, BETA_LIN_MAX),
184 clamp(KP_LIN * err.vel.z() - KD_LIN * tcp_vel[2], -BETA_LIN_MAX, BETA_LIN_MAX),
186 beta.rot = kdl.Vector(
187 clamp(KP_ROT * err.rot.x() - KD_ROT * tcp_vel[3], -BETA_ROT_MAX, BETA_ROT_MAX),
188 clamp(KP_ROT * err.rot.y() - KD_ROT * tcp_vel[4], -BETA_ROT_MAX, BETA_ROT_MAX),
189 clamp(KP_ROT * err.rot.z() - KD_ROT * tcp_vel[5], -BETA_ROT_MAX, BETA_ROT_MAX),
191 if state[
"acc_ik"].CartToJnt(q, beta, qddot) < 0:
192 raise RuntimeError(
"RNEA task acceleration solve failed")
194 tau = kdl.JntArray(robot.n_joints)
195 wrenches = [kdl.Wrench.Zero()
for _
in range(state[
"n_seg"])]
196 if state[
"id_solver"].CartToJnt(q, qdot, qddot, wrenches, tau) < 0:
197 raise RuntimeError(
"RNEA inverse dynamics failed")
198 robot.jnt_trq_cmd = [
clamp(tau[i], -TAU_MAX, TAU_MAX)
for i
in range(robot.n_joints)]
202 if env.has_actuator(GRIPPER_ACTUATOR):
203 env.set_actuator_ctrl(GRIPPER_ACTUATOR, 255.0)
207 """Close the gripper, hold home until the wrist load settles, then tare.
209 The gripper's static load shows up at the FT site only once it has closed
210 and settled (~10 N here). Taring before that (right after reset, gripper
211 open) leaves a large constant bias error that an integrating (K=0)
212 admittance turns into permanent drift. So we hold the closed-gripper home
213 pose for a moment first, then capture the bias.
216 home = robot.fk_frame()
217 for _
in range(SETTLE_STEPS):
224 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
228 """External force on the tool in world frame, gravity-tared, deadbanded.
230 The MuJoCo force sensor reports the reaction wrench at the site, so the
231 external push the user applies is the negated, bias-removed reading. The
232 bias is the gripper's static gravity load captured after the gripper closes
233 and the wrist load settles (see settle_and_tare); expressed in the world
234 frame this is just the distal weight (mg, downward) and is invariant to the
235 arm configuration, so a single tare stays valid as the TCP translates around
236 home. Sub-deadband residue (noise, settling transients) is rejected to zero.
238 wrench = robot.ft_sensor(
"wrist_ft")
239 f_world =
xyz(robot.ft_sensor_frame(
"wrist_ft").M * wrench.force)
241 f_ext = [bias[i] - f_world[i]
for i
in range(3)]
242 force_norm =
vnorm(f_ext)
243 if force_norm < FORCE_DEADBAND:
244 return [0.0, 0.0, 0.0]
249 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
256 if force == [0.0, 0.0, 0.0]:
257 state[
"vel"] = [0.0, 0.0, 0.0]
260 (force[i] - D_ADM * state[
"vel"][i] - K_ADM * state[
"offset"][i]) / M_ADM
264 state[
"offset"] =
vclamp(
vadd(state[
"offset"],
vscale(state[
"vel"], dt)), MAX_OFFSET)
268 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
270 The force is D_ADM times the velocity of a helical path, so a mass-damper
271 admittance (steady state v = F / D) turns it into helical motion. Fed into
272 the admittance, this drives the intro helix.
274 if t < 0.0
or t > TEACH_TIME:
275 return [0.0, 0.0, 0.0]
276 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
277 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
278 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
279 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
280 vz = TEACH_RISE / TEACH_TIME
281 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
285 """One admittance tick: force -> offset (outer loop) -> RNEA-tracked TCP.
287 robot.update() must have run this step so the FT read behind `force` is
288 current. Returns the commanded target frame (for tracing).
291 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state[
"offset"]))
296def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) ->
None:
297 """Admittance control for the whole run (RNEA computed-torque inner loop).
299 For the first TEACH_TIME seconds a scripted helical force drives the
300 admittance, so the TCP traces a helix. After that the scripted force stops
301 and you can ctrl + right-drag the gripper to apply your own force, which the
302 FT senses; the same admittance responds and holds on release.
304 viewer = mjk.SimulateViewer.open(robot,
"ex_admittance_ft_rnea.py")
305 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
308 handoff_tared =
False
309 target_prev: list[float] |
None =
None
310 tcp_prev: list[float] |
None =
None
313 while viewer.is_running():
314 if env.time() < prev - 1e-6:
317 handoff_tared =
False
318 state[
"offset"] = [0.0, 0.0, 0.0]
319 state[
"vel"] = [0.0, 0.0, 0.0]
320 target_prev = tcp_prev =
None
322 t = env.time() - start
330 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
331 force = [0.0, 0.0, 0.0]
333 if not handoff_tared:
344 world_base = env.body_frame(
"base_link")
346 tcp_xyz =
frame_point(world_base, robot.fk_frame().p)
347 if target_prev
and trace_step % 5 == 0:
348 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
349 if tcp_prev
and trace_step % 5 == 0:
350 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
351 target_prev = target_xyz
354 if not viewer.step():
357 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
361def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
362 """Headless exercise of the same admittance law the GUI uses. Returns metrics.
364 Phase A: the scripted helical force drives the admittance (intro behaviour).
365 Phase B: a physical +Y wrench is sensed by the FT and yielded to, then
366 released. Verifies the admittance reacts to both force sources and holds
371 helix_track_err = 0.0
372 while env.time() - t0 < TEACH_TIME:
377 tcp = robot.fk_frame()
378 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
379 helix_react = max(helix_react,
vnorm(state[
"offset"]))
380 helix_track_err = max(helix_track_err,
vnorm(err))
385 t_handoff = env.time()
386 while env.time() - t_handoff < HANDOFF_TARE_TIME:
390 tcp = robot.fk_frame()
391 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
392 helix_track_err = max(helix_track_err,
vnorm(err))
401 handoff_force = max(handoff_force,
vnorm(force))
406 helix_settle_err = 0.0
407 t_settle = env.time()
408 while env.time() - t_settle < 0.5:
412 tcp = robot.fk_frame()
413 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
414 helix_settle_err = max(helix_settle_err,
vnorm(err))
418 pre_push = state[
"offset"][:]
420 settled: list[float] |
None =
None
421 push_recovery_err: float |
None =
None
422 while env.time() - t1 < 4.0:
424 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH
if t < 1.0
else (0.0, 0.0, 0.0))
428 tcp = robot.fk_frame()
429 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
430 if push_recovery_err
is None and t >= 2.0:
431 push_recovery_err =
vnorm(err)
434 if settled
is None and t >= 2.5:
435 settled = state[
"offset"][:]
438 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
440 "helix_react": helix_react,
441 "helix_track_err": helix_track_err,
442 "helix_settle_err": helix_settle_err,
443 "handoff_force": handoff_force,
444 "push_response":
vnorm([(settled
or pre_push)[i] - pre_push[i]
for i
in range(3)]),
445 "push_dy": (settled
or pre_push)[1] - pre_push[1],
446 "push_recovery_err": push_recovery_err
or 0.0,
447 "hold_drift":
vnorm([state[
"offset"][i] - (settled
or pre_push)[i]
for i
in range(3)]),
452 parser = argparse.ArgumentParser()
453 parser.add_argument(
"--gui", action=
"store_true")
454 args = parser.parse_args()
458 chain = robot.kdl_chain()
459 acc_ik = kdl.ChainIkSolverVel_wdls(chain)
460 acc_ik.setLambda(0.05)
461 robot.ctrl_mode = mjk.CtrlMode.TORQUE
464 "bias": [0.0, 0.0, 0.0],
465 "offset": [0.0, 0.0, 0.0],
466 "vel": [0.0, 0.0, 0.0],
467 "jac_solver": kdl.ChainJntToJacSolver(chain),
469 "id_solver": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
470 "n_seg": chain.getNrOfSegments(),
474 robot.set_joint_pos(HOME, call_forward=
False)
475 state[
"offset"] = [0.0, 0.0, 0.0]
476 state[
"vel"] = [0.0, 0.0, 0.0]
477 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
479 env.on_reset = on_reset
485 nominal = robot.fk_frame()
487 print(f
"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
489 run_gui(env, robot, nominal, state)
492 f
"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
496 print(f
"helix force response (max offset): {m['helix_react']:.4f} m")
497 print(f
"helix TCP tracking error: {m['helix_track_err']:.4f} m")
498 print(f
"helix settle error: {m['helix_settle_err']:.4f} m")
499 print(f
"FT handoff residual force: {m['handoff_force']:.4f} N")
500 print(f
"FT push response (offset norm): {m['push_response']:.4f} m")
501 print(f
"FT push response (offset dY): {m['push_dy']:.4f} m")
502 print(f
"push release recovery error: {m['push_recovery_err']:.4f} m")
503 print(f
"hold drift after push released: {m['hold_drift']:.4f} m")
504 assert m[
"helix_react"] > 0.05,
"admittance did not respond to the helical force"
505 assert m[
"helix_track_err"] < 0.006,
"TCP did not track the commanded helix"
506 assert m[
"helix_settle_err"] < 0.004,
"TCP did not settle cleanly after the helix"
507 assert m[
"handoff_force"] == 0.0,
"FT handoff produced a false external force"
508 assert m[
"push_response"] > 0.05,
"admittance did not yield to the FT-sensed push"
509 assert m[
"push_recovery_err"] < 0.006,
"TCP did not recover quickly after the push"
510 assert m[
"hold_drift"] < 0.01,
"pose did not hold after the push stopped"
511 print(
"OK: admittance responded to helix + FT push and held on release")
517if __name__ ==
"__main__":
518 raise SystemExit(
main())
list[float] vclamp(list[float] a, float limit)
dict run_selfcheck(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
list[float] measured_force(mjk.Robot robot, dict state)
list[float] tare_force(mjk.Robot robot)
mjk.AttachmentSpec gripper_attachment()
None admittance_update(dict state, list[float] force, float dt)
admittance_step(env, robot, nominal, state, force)
float vnorm(list[float] a)
list[float] vscale(list[float] a, float s)
list[float] vadd(list[float] a, list[float] b)
list[float] frame_point(kdl.Frame frame, kdl.Vector point)
tuple[mjk.Env, mjk.Robot] build_env()
mjk.SceneObject table_object()
list[float] settle_and_tare(mjk.Env env, mjk.Robot robot, dict state)
list[float] jacobian_twist(kdl.Jacobian jac, kdl.JntArray qdot)
None rnea_track(mjk.Robot robot, dict state, kdl.Frame target)
kdl.JntArray jnt(list[float] values)
list[float] xyz(kdl.Vector v)
float clamp(float value, float low, float high)
list[float] spiral_force(float t)
None close_gripper(mjk.Env env)
None run_gui(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
mjk.AttachmentSpec ft_attachment()