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):
225 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
229 """External force on the tool in world frame, gravity-tared, deadbanded.
231 The MuJoCo force sensor reports the reaction wrench at the site, so the
232 external push the user applies is the negated, bias-removed reading. The
233 bias is the gripper's static gravity load captured after the gripper closes
234 and the wrist load settles (see settle_and_tare); expressed in the world
235 frame this is just the distal weight (mg, downward) and is invariant to the
236 arm configuration, so a single tare stays valid as the TCP translates around
237 home. Sub-deadband residue (noise, settling transients) is rejected to zero.
239 wrench = robot.ft_sensor(
"wrist_ft")
240 f_world =
xyz(robot.ft_sensor_frame(
"wrist_ft").M * wrench.force)
242 f_ext = [bias[i] - f_world[i]
for i
in range(3)]
243 force_norm =
vnorm(f_ext)
244 if force_norm < FORCE_DEADBAND:
245 return [0.0, 0.0, 0.0]
250 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
257 if force == [0.0, 0.0, 0.0]:
258 state[
"vel"] = [0.0, 0.0, 0.0]
261 (force[i] - D_ADM * state[
"vel"][i] - K_ADM * state[
"offset"][i]) / M_ADM
265 state[
"offset"] =
vclamp(
vadd(state[
"offset"],
vscale(state[
"vel"], dt)), MAX_OFFSET)
269 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
271 The force is D_ADM times the velocity of a helical path, so a mass-damper
272 admittance (steady state v = F / D) turns it into helical motion. Fed into
273 the admittance, this drives the intro helix.
275 if t < 0.0
or t > TEACH_TIME:
276 return [0.0, 0.0, 0.0]
277 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
278 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
279 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
280 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
281 vz = TEACH_RISE / TEACH_TIME
282 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
286 """One admittance tick: force -> offset (outer loop) -> RNEA-tracked TCP.
288 robot.update() must have run this step so the FT read behind `force` is
289 current. Returns the commanded target frame (for tracing).
292 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state[
"offset"]))
297def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) ->
None:
298 """Admittance control for the whole run (RNEA computed-torque inner loop).
300 For the first TEACH_TIME seconds a scripted helical force drives the
301 admittance, so the TCP traces a helix. After that the scripted force stops
302 and you can ctrl + right-drag the gripper to apply your own force, which the
303 FT senses; the same admittance responds and holds on release.
305 viewer = mjk.SimulateViewer.open(robot,
"ex_admittance_ft_rnea.py")
306 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
309 handoff_tared =
False
310 target_prev: list[float] |
None =
None
311 tcp_prev: list[float] |
None =
None
314 while viewer.is_running():
315 if env.time() < prev - 1e-6:
318 handoff_tared =
False
319 state[
"offset"] = [0.0, 0.0, 0.0]
320 state[
"vel"] = [0.0, 0.0, 0.0]
321 target_prev = tcp_prev =
None
323 t = env.time() - start
331 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
332 force = [0.0, 0.0, 0.0]
334 if not handoff_tared:
345 world_base = env.body_frame(
"base_link")
347 tcp_xyz =
frame_point(world_base, robot.fk_frame().p)
348 if target_prev
and trace_step % 5 == 0:
349 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
350 if tcp_prev
and trace_step % 5 == 0:
351 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
352 target_prev = target_xyz
355 if not viewer.step():
359 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
363def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
364 """Headless exercise of the same admittance law the GUI uses. Returns metrics.
366 Phase A: the scripted helical force drives the admittance (intro behaviour).
367 Phase B: a physical +Y wrench is sensed by the FT and yielded to, then
368 released. Verifies the admittance reacts to both force sources and holds
373 helix_track_err = 0.0
374 while env.time() - t0 < TEACH_TIME:
379 tcp = robot.fk_frame()
380 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
381 helix_react = max(helix_react,
vnorm(state[
"offset"]))
382 helix_track_err = max(helix_track_err,
vnorm(err))
388 t_handoff = env.time()
389 while env.time() - t_handoff < HANDOFF_TARE_TIME:
393 tcp = robot.fk_frame()
394 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
395 helix_track_err = max(helix_track_err,
vnorm(err))
405 handoff_force = max(handoff_force,
vnorm(force))
411 helix_settle_err = 0.0
412 t_settle = env.time()
413 while env.time() - t_settle < 0.5:
417 tcp = robot.fk_frame()
418 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
419 helix_settle_err = max(helix_settle_err,
vnorm(err))
424 pre_push = state[
"offset"][:]
426 settled: list[float] |
None =
None
427 push_recovery_err: float |
None =
None
428 while env.time() - t1 < 4.0:
430 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH
if t < 1.0
else (0.0, 0.0, 0.0))
434 tcp = robot.fk_frame()
435 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
436 if push_recovery_err
is None and t >= 2.0:
437 push_recovery_err =
vnorm(err)
440 if settled
is None and t >= 2.5:
441 settled = state[
"offset"][:]
445 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
447 "helix_react": helix_react,
448 "helix_track_err": helix_track_err,
449 "helix_settle_err": helix_settle_err,
450 "handoff_force": handoff_force,
451 "push_response":
vnorm([(settled
or pre_push)[i] - pre_push[i]
for i
in range(3)]),
452 "push_dy": (settled
or pre_push)[1] - pre_push[1],
453 "push_recovery_err": push_recovery_err
or 0.0,
454 "hold_drift":
vnorm([state[
"offset"][i] - (settled
or pre_push)[i]
for i
in range(3)]),
459 parser = argparse.ArgumentParser()
460 parser.add_argument(
"--gui", action=
"store_true")
461 args = parser.parse_args()
465 chain = robot.kdl_chain()
466 acc_ik = kdl.ChainIkSolverVel_wdls(chain)
467 acc_ik.setLambda(0.05)
468 robot.ctrl_mode = mjk.CtrlMode.TORQUE
471 "bias": [0.0, 0.0, 0.0],
472 "offset": [0.0, 0.0, 0.0],
473 "vel": [0.0, 0.0, 0.0],
474 "jac_solver": kdl.ChainJntToJacSolver(chain),
476 "id_solver": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
477 "n_seg": chain.getNrOfSegments(),
481 robot.set_joint_pos(HOME, call_forward=
False)
482 state[
"offset"] = [0.0, 0.0, 0.0]
483 state[
"vel"] = [0.0, 0.0, 0.0]
484 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
486 env.on_reset = on_reset
492 nominal = robot.fk_frame()
494 print(f
"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
496 run_gui(env, robot, nominal, state)
499 f
"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
503 print(f
"helix force response (max offset): {m['helix_react']:.4f} m")
504 print(f
"helix TCP tracking error: {m['helix_track_err']:.4f} m")
505 print(f
"helix settle error: {m['helix_settle_err']:.4f} m")
506 print(f
"FT handoff residual force: {m['handoff_force']:.4f} N")
507 print(f
"FT push response (offset norm): {m['push_response']:.4f} m")
508 print(f
"FT push response (offset dY): {m['push_dy']:.4f} m")
509 print(f
"push release recovery error: {m['push_recovery_err']:.4f} m")
510 print(f
"hold drift after push released: {m['hold_drift']:.4f} m")
511 assert m[
"helix_react"] > 0.05,
"admittance did not respond to the helical force"
512 assert m[
"helix_track_err"] < 0.006,
"TCP did not track the commanded helix"
513 assert m[
"helix_settle_err"] < 0.004,
"TCP did not settle cleanly after the helix"
514 assert m[
"handoff_force"] == 0.0,
"FT handoff produced a false external force"
515 assert m[
"push_response"] > 0.05,
"admittance did not yield to the FT-sensed push"
516 assert m[
"push_recovery_err"] < 0.006,
"TCP did not recover quickly after the push"
517 assert m[
"hold_drift"] < 0.01,
"pose did not hold after the push stopped"
518 print(
"OK: admittance responded to helix + FT push and held on release")
524if __name__ ==
"__main__":
525 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()