2"""Admittance control with an ACHD (Vereshchagin) task-space inner loop, FT-driven.
4Same outer admittance loop as ex_admittance_ft.py / ex_admittance_ft_rnea.py,
5but a different inner loop. Admittance is an outer force->position loop wrapped
6around an inner motion controller. The siblings use an ideal POSITION loop and a
7joint-space RNEA computed-torque loop; here the inner loop is TASK SPACE:
9 beta = Cartesian PD on the TCP pose error (desired Cartesian acceleration)
10 qddot = ACHD(q, qdot, alpha, beta) (constrained hybrid dynamics, KDL
11 ChainHdSolver_Vereshchagin)
12 tau = RNEA(q, qdot, qddot) (inverse dynamics for the torque)
13 apply tau in TORQUE mode
15The acceleration-constrained hybrid-dynamics (ACHD) solver consumes the desired
16Cartesian acceleration directly, so the admittance's Cartesian target feeds it
17without an IK step (alpha = identity constrains all 6 TCP DOF). It resolves the
18joint accelerations through the full arm dynamics, so tracking bandwidth is
19uniform across directions (a plain PD+gravity law lags/inverts the soft axis).
21Outer admittance law per Cartesian axis (no position stiffness):
24 v += a * dt (clamped to MAX_VEL)
25 offset += v * dt (clamped to MAX_OFFSET)
27The logical FT sensor sits between the Kinova wrist and the Robotiq gripper.
28After closing the gripper and letting the wrist load settle, the controller
29tares it (the gripper's ~10 N static load only appears once it has closed).
31The run has two sources of external force, both handled by the same law:
32 - Intro: a scripted force whose direction sweeps a helix (spiral_force) drives
33 the admittance, so the TCP traces a helix.
34 - After the helix: the scripted force stops; the controller stays in
35 admittance and responds to the FT-measured force, so in the GUI you can
36 ctrl + right-drag the gripper. With K = 0 there is no equilibrium to spring
37 back to: when force stops, damping bleeds v -> 0 and the pose holds.
40from __future__
import annotations
46import mj_kdl_wrapper
as mjk
48HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
53KP_LIN, KD_LIN = 5760.0, 378.0
54KP_ROT, KD_ROT = 3600.0, 441.0
55BETA_LIN_MAX, BETA_ROT_MAX, TAU_MAX = 3600.0, 2520.0, 212.4
59M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
64GRIPPER_ACTUATOR =
"g_fingers_actuator"
66HANDOFF_TARE_TIME = 1.0
67SELFCHECK_PUSH = (8.0, 12.0, 6.0)
76def jnt(values: list[float]) -> kdl.JntArray:
77 out = kdl.JntArray(len(values))
78 for i, value
in enumerate(values):
83def clamp(value: float, low: float, high: float) -> float:
84 return max(low, min(high, value))
87def vadd(a: list[float], b: list[float]) -> list[float]:
88 return [a[i] + b[i]
for i
in range(3)]
91def vscale(a: list[float], s: float) -> list[float]:
92 return [s * a[i]
for i
in range(3)]
95def vclamp(a: list[float], limit: float) -> list[float]:
96 return [
clamp(x, -limit, limit)
for x
in a]
99def vnorm(a: list[float]) -> float:
100 return math.sqrt(sum(x * x
for x
in a))
103def xyz(v: kdl.Vector) -> list[float]:
104 return [v.x(), v.y(), v.z()]
107def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
108 return xyz(frame * point)
112 """Constraint matrix: all 6 TCP DOF are controlled (one beta per row)."""
113 alpha = kdl.Jacobian(6)
120 spec = mjk.AttachmentSpec()
121 spec.mjcf_path = mjk.menagerie.asset_path(
"ft_sensor.xml", env_var=
"MJ_KDL_FT_SENSOR")
122 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site,
"pinch_site")
127 spec = mjk.AttachmentSpec()
128 spec.mjcf_path = mjk.menagerie.asset_path(
"robotiq_2f85/2f85.xml", env_var=
"MJ_KDL_GRIPPER")
129 spec.attach_to = mjk.AttachTarget(mjk.AttachKind.Site,
"wrist_ft_site")
135 table = mjk.SceneObject()
137 table.mjcf_path = mjk.menagerie.asset_path(
"table.xml", env_var=
"MJ_KDL_TABLE")
138 table.pos = [0.0, 0.0, TABLE_Z]
145 spec = mjk.SceneSpec()
146 spec.timestep = 0.002
147 spec.add_floor =
True
148 spec.add_skybox =
True
149 spec.objects = [table]
151 robot_spec = mjk.RobotSpec()
152 robot_spec.path = mjk.menagerie.model_path(
"kinova_gen3", env_var=
"MJ_KDL_MODEL")
153 robot_spec.attach_to = mjk.AttachTarget(
154 mjk.AttachKind.Site, mjk.scene_object_site_name(table,
"table_top")
157 spec.robots = [robot_spec]
159 env = mjk.Env.build(spec)
161 ft = mjk.ForceTorqueSensorSpec()
163 ft.frame_site =
"wrist_ft_site"
165 tool = mjk.ToolFrameSpec()
166 tool.tool_body =
"g_base"
167 tool.tcp_site =
"g_pinch"
168 tool.ft_sensors = [ft]
170 robot = env.create_robot(
"base_link",
"bracelet_link", tool=tool)
174def achd_track(robot: mjk.Robot, state: dict, target: kdl.Frame) ->
None:
175 """Inner loop: task-space control via ACHD constrained hybrid dynamics.
177 A Cartesian PD on the TCP pose error is the desired acceleration (beta) for
178 all 6 constrained DOF (alpha = identity); ACHD resolves it into joint
179 accelerations through the arm dynamics, then RNEA maps those to torques.
180 No IK is needed -- the Cartesian target feeds the solver directly.
181 robot.update() must have refreshed jnt_pos_msr/jnt_vel_msr this step.
184 q =
jnt(robot.jnt_pos_msr)
185 qd =
jnt(robot.jnt_vel_msr)
187 err = kdl.diff(robot.fk_frame(), target)
188 e = [err.vel.x(), err.vel.y(), err.vel.z(), err.rot.x(), err.rot.y(), err.rot.z()]
189 if state[
"first_pid"]:
190 state[
"err_prev"] = e[:]
191 state[
"first_pid"] =
False
192 de = [(e[i] - state[
"err_prev"][i]) / state[
"dt"]
for i
in range(6)]
193 state[
"err_prev"] = e[:]
195 beta = kdl.JntArray(6)
197 beta[i] =
clamp(KP_LIN * e[i] + KD_LIN * de[i], -BETA_LIN_MAX, BETA_LIN_MAX)
198 for i
in range(3, 6):
199 beta[i] =
clamp(KP_ROT * e[i] + KD_ROT * de[i], -BETA_ROT_MAX, BETA_ROT_MAX)
201 qdd = kdl.JntArray(n)
203 constraint_tau = kdl.JntArray(n)
204 f_ext = [kdl.Wrench.Zero()
for _
in range(state[
"n_seg"])]
205 if state[
"achd"].CartToJnt(q, qd, qdd, state[
"alpha"], beta, f_ext, ff, constraint_tau) < 0:
206 raise RuntimeError(
"ACHD hybrid dynamics failed")
208 tau = kdl.JntArray(n)
209 if state[
"rnea"].CartToJnt(q, qd, qdd, f_ext, tau) < 0:
210 raise RuntimeError(
"RNEA inverse dynamics failed")
211 robot.jnt_trq_cmd = [
clamp(tau[i], -TAU_MAX, TAU_MAX)
for i
in range(n)]
215 if env.has_actuator(GRIPPER_ACTUATOR):
216 env.set_actuator_ctrl(GRIPPER_ACTUATOR, 255.0)
220 """Close the gripper, hold the home pose until the wrist load settles, tare.
222 The gripper's static load shows up at the FT site only once it has closed
223 and settled (~10 N here). Taring before that (right after reset, gripper
224 open) leaves a large constant bias error that an integrating (K=0)
225 admittance turns into permanent drift. So we hold the closed-gripper home
226 pose for a moment first, then capture the bias.
229 home = robot.fk_frame()
230 for _
in range(SETTLE_STEPS):
237 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
241 """External force on the tool in world frame, gravity-tared, deadbanded.
243 The MuJoCo force sensor reports the reaction wrench at the site, so the
244 external push the user applies is the negated, bias-removed reading. The
245 bias is the gripper's static gravity load captured after the gripper closes
246 and the wrist load settles (see settle_and_tare); expressed in the world
247 frame this is just the distal weight (mg, downward) and is invariant to the
248 arm configuration, so a single tare stays valid as the TCP translates around
249 home. Sub-deadband residue (noise, settling transients) is rejected to zero.
251 wrench = robot.ft_sensor(
"wrist_ft")
252 f_world =
xyz(robot.ft_sensor_frame(
"wrist_ft").M * wrench.force)
254 f_ext = [bias[i] - f_world[i]
for i
in range(3)]
255 if vnorm(f_ext) < FORCE_DEADBAND:
256 return [0.0, 0.0, 0.0]
261 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
268 if force == [0.0, 0.0, 0.0]:
269 state[
"vel"] = [0.0, 0.0, 0.0]
272 (force[i] - D_ADM * state[
"vel"][i] - K_ADM * state[
"offset"][i]) / M_ADM
276 state[
"offset"] =
vclamp(
vadd(state[
"offset"],
vscale(state[
"vel"], dt)), MAX_OFFSET)
280 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
282 The force is D_ADM times the velocity of a helical path, so a mass-damper
283 admittance (steady state v = F / D) turns it into helical motion. Fed into
284 the admittance, this drives the intro helix.
286 if t < 0.0
or t > TEACH_TIME:
287 return [0.0, 0.0, 0.0]
288 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
289 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
290 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
291 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
292 vz = TEACH_RISE / TEACH_TIME
293 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
297 """One admittance tick: force -> offset (outer loop) -> ACHD-tracked TCP.
299 robot.update() must have run this step so the FT read behind `force` is
300 current. Returns the commanded target frame (for tracing).
303 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state[
"offset"]))
308def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) ->
None:
309 """Admittance control for the whole run (ACHD task-space inner loop).
311 For the first TEACH_TIME seconds a scripted helical force drives the
312 admittance, so the TCP traces a helix. After that the scripted force stops
313 and you can ctrl + right-drag the gripper to apply your own force, which the
314 FT senses; the same admittance responds and holds on release.
316 viewer = mjk.SimulateViewer.open(robot,
"ex_admittance_ft_achd.py")
317 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
320 handoff_tared =
False
321 target_prev: list[float] |
None =
None
322 tcp_prev: list[float] |
None =
None
325 while viewer.is_running():
326 if env.time() < prev - 1e-6:
329 handoff_tared =
False
330 state[
"offset"] = [0.0, 0.0, 0.0]
331 state[
"vel"] = [0.0, 0.0, 0.0]
332 state[
"err_prev"] = [0.0] * 6
333 state[
"first_pid"] =
True
334 target_prev = tcp_prev =
None
336 t = env.time() - start
344 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
345 force = [0.0, 0.0, 0.0]
347 if not handoff_tared:
358 world_base = env.body_frame(
"base_link")
360 tcp_xyz =
frame_point(world_base, robot.fk_frame().p)
361 if target_prev
and trace_step % 5 == 0:
362 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
363 if tcp_prev
and trace_step % 5 == 0:
364 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
365 target_prev = target_xyz
368 if not viewer.step():
371 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
375def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
376 """Headless exercise of the same admittance law the GUI uses. Returns metrics.
378 Phase A: the scripted helical force drives the admittance (intro behaviour).
379 Phase B: a physical +Y wrench is sensed by the FT and yielded to, then
380 released. Verifies the admittance reacts to both force sources and holds
385 helix_track_err = 0.0
386 while env.time() - t0 < TEACH_TIME:
391 tcp = robot.fk_frame()
392 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
393 helix_react = max(helix_react,
vnorm(state[
"offset"]))
394 helix_track_err = max(helix_track_err,
vnorm(err))
399 t_handoff = env.time()
400 while env.time() - t_handoff < HANDOFF_TARE_TIME:
404 tcp = robot.fk_frame()
405 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
406 helix_track_err = max(helix_track_err,
vnorm(err))
415 handoff_force = max(handoff_force,
vnorm(force))
420 helix_settle_err = 0.0
421 t_settle = env.time()
422 while env.time() - t_settle < 0.5:
426 tcp = robot.fk_frame()
427 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
428 helix_settle_err = max(helix_settle_err,
vnorm(err))
432 pre_push = state[
"offset"][:]
434 settled: list[float] |
None =
None
435 push_recovery_err: float |
None =
None
436 while env.time() - t1 < 4.0:
438 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH
if t < 1.0
else (0.0, 0.0, 0.0))
442 tcp = robot.fk_frame()
443 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
444 if push_recovery_err
is None and t >= 2.0:
445 push_recovery_err =
vnorm(err)
448 if settled
is None and t >= 2.5:
449 settled = state[
"offset"][:]
452 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
454 "helix_react": helix_react,
455 "helix_track_err": helix_track_err,
456 "helix_settle_err": helix_settle_err,
457 "handoff_force": handoff_force,
458 "push_response":
vnorm([(settled
or pre_push)[i] - pre_push[i]
for i
in range(3)]),
459 "push_dy": (settled
or pre_push)[1] - pre_push[1],
460 "push_recovery_err": push_recovery_err
or 0.0,
461 "hold_drift":
vnorm([state[
"offset"][i] - (settled
or pre_push)[i]
for i
in range(3)]),
466 parser = argparse.ArgumentParser()
467 parser.add_argument(
"--gui", action=
"store_true")
468 args = parser.parse_args()
472 chain = robot.kdl_chain()
473 robot.ctrl_mode = mjk.CtrlMode.TORQUE
476 "bias": [0.0, 0.0, 0.0],
477 "offset": [0.0, 0.0, 0.0],
478 "vel": [0.0, 0.0, 0.0],
481 "achd": kdl.ChainHdSolver_Vereshchagin_Fixed_Joint(
482 chain, kdl.Twist(kdl.Vector(0.0, 0.0, 9.81), kdl.Vector.Zero()), 6
484 "rnea": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
486 "n_seg": chain.getNrOfSegments(),
487 "err_prev": [0.0] * 6,
489 "dt": env.timestep(),
493 robot.set_joint_pos(HOME, call_forward=
False)
494 state[
"offset"] = [0.0, 0.0, 0.0]
495 state[
"vel"] = [0.0, 0.0, 0.0]
496 state[
"err_prev"] = [0.0] * 6
497 state[
"first_pid"] =
True
498 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
500 env.on_reset = on_reset
506 nominal = robot.fk_frame()
508 print(f
"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
510 run_gui(env, robot, nominal, state)
513 f
"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
517 print(f
"helix force response (max offset): {m['helix_react']:.4f} m")
518 print(f
"helix TCP tracking error: {m['helix_track_err']:.4f} m")
519 print(f
"helix settle error: {m['helix_settle_err']:.4f} m")
520 print(f
"FT handoff residual force: {m['handoff_force']:.4f} N")
521 print(f
"FT push response (offset norm): {m['push_response']:.4f} m")
522 print(f
"FT push response (offset dY): {m['push_dy']:.4f} m")
523 print(f
"push release recovery error: {m['push_recovery_err']:.4f} m")
524 print(f
"hold drift after push released: {m['hold_drift']:.4f} m")
525 assert m[
"helix_react"] > 0.05,
"admittance did not respond to the helical force"
526 assert m[
"helix_track_err"] < 0.006,
"TCP did not track the commanded helix"
527 assert m[
"helix_settle_err"] < 0.004,
"TCP did not settle cleanly after the helix"
528 assert m[
"handoff_force"] == 0.0,
"FT handoff produced a false external force"
529 assert m[
"push_response"] > 0.05,
"admittance did not yield to the FT-sensed push"
530 assert m[
"push_recovery_err"] < 0.006,
"TCP did not recover quickly after the push"
531 assert m[
"hold_drift"] < 0.01,
"pose did not hold after the push stopped"
532 print(
"OK: admittance responded to helix + FT push and held on release")
538if __name__ ==
"__main__":
539 raise SystemExit(
main())
None admittance_update(dict state, list[float] force, float dt)
list[float] xyz(kdl.Vector v)
kdl.JntArray jnt(list[float] values)
admittance_step(env, robot, nominal, state, force)
tuple[mjk.Env, mjk.Robot] build_env()
float clamp(float value, float low, float high)
None achd_track(mjk.Robot robot, dict state, kdl.Frame target)
None close_gripper(mjk.Env env)
float vnorm(list[float] a)
list[float] settle_and_tare(mjk.Env env, mjk.Robot robot, dict state)
list[float] spiral_force(float t)
dict run_selfcheck(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
list[float] frame_point(kdl.Frame frame, kdl.Vector point)
mjk.SceneObject table_object()
list[float] vadd(list[float] a, list[float] b)
list[float] vscale(list[float] a, float s)
mjk.AttachmentSpec gripper_attachment()
kdl.Jacobian alpha_identity()
None run_gui(mjk.Env env, mjk.Robot robot, kdl.Frame nominal, dict state)
list[float] measured_force(mjk.Robot robot, dict state)
list[float] vclamp(list[float] a, float limit)
mjk.AttachmentSpec ft_attachment()
list[float] tare_force(mjk.Robot robot)