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):
238 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
242 """External force on the tool in world frame, gravity-tared, deadbanded.
244 The MuJoCo force sensor reports the reaction wrench at the site, so the
245 external push the user applies is the negated, bias-removed reading. The
246 bias is the gripper's static gravity load captured after the gripper closes
247 and the wrist load settles (see settle_and_tare); expressed in the world
248 frame this is just the distal weight (mg, downward) and is invariant to the
249 arm configuration, so a single tare stays valid as the TCP translates around
250 home. Sub-deadband residue (noise, settling transients) is rejected to zero.
252 wrench = robot.ft_sensor(
"wrist_ft")
253 f_world =
xyz(robot.ft_sensor_frame(
"wrist_ft").M * wrench.force)
255 f_ext = [bias[i] - f_world[i]
for i
in range(3)]
256 if vnorm(f_ext) < FORCE_DEADBAND:
257 return [0.0, 0.0, 0.0]
262 return xyz(robot.ft_sensor_frame(
"wrist_ft").M * robot.ft_sensor(
"wrist_ft").force)
269 if force == [0.0, 0.0, 0.0]:
270 state[
"vel"] = [0.0, 0.0, 0.0]
273 (force[i] - D_ADM * state[
"vel"][i] - K_ADM * state[
"offset"][i]) / M_ADM
277 state[
"offset"] =
vclamp(
vadd(state[
"offset"],
vscale(state[
"vel"], dt)), MAX_OFFSET)
281 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
283 The force is D_ADM times the velocity of a helical path, so a mass-damper
284 admittance (steady state v = F / D) turns it into helical motion. Fed into
285 the admittance, this drives the intro helix.
287 if t < 0.0
or t > TEACH_TIME:
288 return [0.0, 0.0, 0.0]
289 theta = 2.0 * math.pi * TEACH_TURNS * t / TEACH_TIME
290 theta_dot = 2.0 * math.pi * TEACH_TURNS / TEACH_TIME
291 vx = -TEACH_RADIUS * theta_dot * math.sin(theta)
292 vy = TEACH_RADIUS * theta_dot * math.cos(theta)
293 vz = TEACH_RISE / TEACH_TIME
294 return [D_ADM * vx, D_ADM * vy, D_ADM * vz]
298 """One admittance tick: force -> offset (outer loop) -> ACHD-tracked TCP.
300 robot.update() must have run this step so the FT read behind `force` is
301 current. Returns the commanded target frame (for tracing).
304 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state[
"offset"]))
309def run_gui(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) ->
None:
310 """Admittance control for the whole run (ACHD task-space inner loop).
312 For the first TEACH_TIME seconds a scripted helical force drives the
313 admittance, so the TCP traces a helix. After that the scripted force stops
314 and you can ctrl + right-drag the gripper to apply your own force, which the
315 FT senses; the same admittance responds and holds on release.
317 viewer = mjk.SimulateViewer.open(robot,
"ex_admittance_ft_achd.py")
318 viewer.set_free_camera(1.55, 145.0, -24.0, (0.05, 0.0, TABLE_Z + 0.35))
321 handoff_tared =
False
322 target_prev: list[float] |
None =
None
323 tcp_prev: list[float] |
None =
None
326 while viewer.is_running():
327 if env.time() < prev - 1e-6:
330 handoff_tared =
False
331 state[
"offset"] = [0.0, 0.0, 0.0]
332 state[
"vel"] = [0.0, 0.0, 0.0]
333 state[
"err_prev"] = [0.0] * 6
334 state[
"first_pid"] =
True
335 target_prev = tcp_prev =
None
337 t = env.time() - start
345 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
346 force = [0.0, 0.0, 0.0]
348 if not handoff_tared:
359 world_base = env.body_frame(
"base_link")
361 tcp_xyz =
frame_point(world_base, robot.fk_frame().p)
362 if target_prev
and trace_step % 5 == 0:
363 viewer.add_trace_segment(target_prev, target_xyz, (1.0, 0.95, 0.0, 1.0))
364 if tcp_prev
and trace_step % 5 == 0:
365 viewer.add_trace_segment(tcp_prev, tcp_xyz, (0.0, 1.0, 0.2, 1.0))
366 target_prev = target_xyz
369 if not viewer.step():
373 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
377def run_selfcheck(env: mjk.Env, robot: mjk.Robot, nominal: kdl.Frame, state: dict) -> dict:
378 """Headless exercise of the same admittance law the GUI uses. Returns metrics.
380 Phase A: the scripted helical force drives the admittance (intro behaviour).
381 Phase B: a physical +Y wrench is sensed by the FT and yielded to, then
382 released. Verifies the admittance reacts to both force sources and holds
387 helix_track_err = 0.0
388 while env.time() - t0 < TEACH_TIME:
393 tcp = robot.fk_frame()
394 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
395 helix_react = max(helix_react,
vnorm(state[
"offset"]))
396 helix_track_err = max(helix_track_err,
vnorm(err))
402 t_handoff = env.time()
403 while env.time() - t_handoff < HANDOFF_TARE_TIME:
407 tcp = robot.fk_frame()
408 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
409 helix_track_err = max(helix_track_err,
vnorm(err))
419 handoff_force = max(handoff_force,
vnorm(force))
425 helix_settle_err = 0.0
426 t_settle = env.time()
427 while env.time() - t_settle < 0.5:
431 tcp = robot.fk_frame()
432 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
433 helix_settle_err = max(helix_settle_err,
vnorm(err))
438 pre_push = state[
"offset"][:]
440 settled: list[float] |
None =
None
441 push_recovery_err: float |
None =
None
442 while env.time() - t1 < 4.0:
444 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH
if t < 1.0
else (0.0, 0.0, 0.0))
448 tcp = robot.fk_frame()
449 err = [tcp.p[i] - target.p[i]
for i
in range(3)]
450 if push_recovery_err
is None and t >= 2.0:
451 push_recovery_err =
vnorm(err)
454 if settled
is None and t >= 2.5:
455 settled = state[
"offset"][:]
459 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
461 "helix_react": helix_react,
462 "helix_track_err": helix_track_err,
463 "helix_settle_err": helix_settle_err,
464 "handoff_force": handoff_force,
465 "push_response":
vnorm([(settled
or pre_push)[i] - pre_push[i]
for i
in range(3)]),
466 "push_dy": (settled
or pre_push)[1] - pre_push[1],
467 "push_recovery_err": push_recovery_err
or 0.0,
468 "hold_drift":
vnorm([state[
"offset"][i] - (settled
or pre_push)[i]
for i
in range(3)]),
473 parser = argparse.ArgumentParser()
474 parser.add_argument(
"--gui", action=
"store_true")
475 args = parser.parse_args()
479 chain = robot.kdl_chain()
480 robot.ctrl_mode = mjk.CtrlMode.TORQUE
483 "bias": [0.0, 0.0, 0.0],
484 "offset": [0.0, 0.0, 0.0],
485 "vel": [0.0, 0.0, 0.0],
488 "achd": kdl.ChainHdSolver_Vereshchagin_Fixed_Joint(
489 chain, kdl.Twist(kdl.Vector(0.0, 0.0, 9.81), kdl.Vector.Zero()), 6
491 "rnea": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
493 "n_seg": chain.getNrOfSegments(),
494 "err_prev": [0.0] * 6,
496 "dt": env.timestep(),
500 robot.set_joint_pos(HOME, call_forward=
False)
501 state[
"offset"] = [0.0, 0.0, 0.0]
502 state[
"vel"] = [0.0, 0.0, 0.0]
503 state[
"err_prev"] = [0.0] * 6
504 state[
"first_pid"] =
True
505 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
507 env.on_reset = on_reset
513 nominal = robot.fk_frame()
515 print(f
"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
517 run_gui(env, robot, nominal, state)
520 f
"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
524 print(f
"helix force response (max offset): {m['helix_react']:.4f} m")
525 print(f
"helix TCP tracking error: {m['helix_track_err']:.4f} m")
526 print(f
"helix settle error: {m['helix_settle_err']:.4f} m")
527 print(f
"FT handoff residual force: {m['handoff_force']:.4f} N")
528 print(f
"FT push response (offset norm): {m['push_response']:.4f} m")
529 print(f
"FT push response (offset dY): {m['push_dy']:.4f} m")
530 print(f
"push release recovery error: {m['push_recovery_err']:.4f} m")
531 print(f
"hold drift after push released: {m['hold_drift']:.4f} m")
532 assert m[
"helix_react"] > 0.05,
"admittance did not respond to the helical force"
533 assert m[
"helix_track_err"] < 0.006,
"TCP did not track the commanded helix"
534 assert m[
"helix_settle_err"] < 0.004,
"TCP did not settle cleanly after the helix"
535 assert m[
"handoff_force"] == 0.0,
"FT handoff produced a false external force"
536 assert m[
"push_response"] > 0.05,
"admittance did not yield to the FT-sensed push"
537 assert m[
"push_recovery_err"] < 0.006,
"TCP did not recover quickly after the push"
538 assert m[
"hold_drift"] < 0.01,
"pose did not hold after the push stopped"
539 print(
"OK: admittance responded to helix + FT push and held on release")
545if __name__ ==
"__main__":
546 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)