Mujoco KDL Wrapper  0.3.18
MuJoCo + KDL bridge for robot kinematics and dynamics
Loading...
Searching...
No Matches
ex_admittance_ft_achd.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Admittance control with an ACHD (Vereshchagin) task-space inner loop, FT-driven.
3
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:
8
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
14
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).
20
21Outer admittance law per Cartesian axis (no position stiffness):
22
23 M * a = F_ext - D * v
24 v += a * dt (clamped to MAX_VEL)
25 offset += v * dt (clamped to MAX_OFFSET)
26
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).
30
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.
38"""
39
40from __future__ import annotations
41
42import argparse
43import math
44
45import PyKDL as kdl
46import mj_kdl_wrapper as mjk
47
48HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
49TABLE_Z = 0.70
50
51# Task-space inner-loop gains: Cartesian PD -> desired acceleration (beta), with
52# per-component acceleration limits and a joint-torque clamp.
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
56
57# Admittance outer loop: virtual mass, damping, stiffness (isotropic).
58# K_ADM = 0 -> pure hand-guiding: holds pose on release. Set > 0 to self-center.
59M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
60FORCE_DEADBAND = 2.5 # N; rejects sensor noise and settling transients
61MAX_OFFSET = 0.20 # m; reachable workspace half-extent around home
62MAX_VEL = 0.25 # m/s
63TOOL_BODY = "g_base" # rigid gripper base; where the headless self-check pushes
64GRIPPER_ACTUATOR = "g_fingers_actuator"
65SETTLE_STEPS = 300 # ~0.6 s at dt=0.002 to close the gripper before taring
66HANDOFF_TARE_TIME = 1.0 # s; let scripted-motion transients settle before FT hand-guiding
67SELFCHECK_PUSH = (8.0, 12.0, 6.0)
68
69# Intro helical force: amplitude/shape and how long it is applied.
70TEACH_TIME = 16.0
71TEACH_RADIUS = 0.04
72TEACH_RISE = 0.10
73TEACH_TURNS = 5.0
74
75
76def jnt(values: list[float]) -> kdl.JntArray:
77 out = kdl.JntArray(len(values))
78 for i, value in enumerate(values):
79 out[i] = value
80 return out
81
82
83def clamp(value: float, low: float, high: float) -> float:
84 return max(low, min(high, value))
85
86
87def vadd(a: list[float], b: list[float]) -> list[float]:
88 return [a[i] + b[i] for i in range(3)]
89
90
91def vscale(a: list[float], s: float) -> list[float]:
92 return [s * a[i] for i in range(3)]
93
94
95def vclamp(a: list[float], limit: float) -> list[float]:
96 return [clamp(x, -limit, limit) for x in a]
97
98
99def vnorm(a: list[float]) -> float:
100 return math.sqrt(sum(x * x for x in a))
101
102
103def xyz(v: kdl.Vector) -> list[float]:
104 return [v.x(), v.y(), v.z()]
105
106
107def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
108 return xyz(frame * point)
109
110
111def alpha_identity() -> kdl.Jacobian:
112 """Constraint matrix: all 6 TCP DOF are controlled (one beta per row)."""
113 alpha = kdl.Jacobian(6)
114 for i in range(6):
115 alpha[i, i] = 1.0
116 return alpha
117
118
119def ft_attachment() -> mjk.AttachmentSpec:
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")
123 return spec
124
125
126def gripper_attachment() -> mjk.AttachmentSpec:
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")
130 spec.prefix = "g_"
131 return spec
132
133
134def table_object() -> mjk.SceneObject:
135 table = mjk.SceneObject()
136 table.name = "table"
137 table.mjcf_path = mjk.menagerie.asset_path("table.xml", env_var="MJ_KDL_TABLE")
138 table.pos = [0.0, 0.0, TABLE_Z]
139 table.fixed = True
140 return table
141
142
143def build_env() -> tuple[mjk.Env, mjk.Robot]:
144 table = table_object()
145 spec = mjk.SceneSpec()
146 spec.timestep = 0.002
147 spec.add_floor = True
148 spec.add_skybox = True
149 spec.objects = [table]
150
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")
155 )
156 robot_spec.attachments = [ft_attachment(), gripper_attachment()]
157 spec.robots = [robot_spec]
158
159 env = mjk.Env.build(spec)
160
161 ft = mjk.ForceTorqueSensorSpec()
162 ft.name = "wrist_ft"
163 ft.frame_site = "wrist_ft_site"
164
165 tool = mjk.ToolFrameSpec()
166 tool.tool_body = "g_base"
167 tool.tcp_site = "g_pinch"
168 tool.ft_sensors = [ft]
169
170 robot = env.create_robot("base_link", "bracelet_link", tool=tool)
171 return env, robot
172
173
174def achd_track(robot: mjk.Robot, state: dict, target: kdl.Frame) -> None:
175 """Inner loop: task-space control via ACHD constrained hybrid dynamics.
176
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.
182 """
183 n = robot.n_joints
184 q = jnt(robot.jnt_pos_msr)
185 qd = jnt(robot.jnt_vel_msr)
186
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[:]
194
195 beta = kdl.JntArray(6)
196 for i in range(3):
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)
200
201 qdd = kdl.JntArray(n)
202 ff = 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")
207
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)]
212
213
214def close_gripper(env: mjk.Env) -> None:
215 if env.has_actuator(GRIPPER_ACTUATOR):
216 env.set_actuator_ctrl(GRIPPER_ACTUATOR, 255.0)
217
218
219def settle_and_tare(env: mjk.Env, robot: mjk.Robot, state: dict) -> list[float]:
220 """Close the gripper, hold the home pose until the wrist load settles, tare.
221
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.
227 """
228 robot.update()
229 home = robot.fk_frame()
230 for _ in range(SETTLE_STEPS):
231 robot.update()
232 close_gripper(env)
233 achd_track(robot, state, home)
234 if not robot.step():
235 break
236 robot.pace()
237 robot.update()
238 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
239
240
241def measured_force(robot: mjk.Robot, state: dict) -> list[float]:
242 """External force on the tool in world frame, gravity-tared, deadbanded.
243
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.
251 """
252 wrench = robot.ft_sensor("wrist_ft")
253 f_world = xyz(robot.ft_sensor_frame("wrist_ft").M * wrench.force)
254 bias = state["bias"]
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]
258 return f_ext
259
260
261def tare_force(robot: mjk.Robot) -> list[float]:
262 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
263
264
265def admittance_update(state: dict, force: list[float], dt: float) -> None:
266 # offset = integral of velocity, so with K = 0 it is a pure integrator: the
267 # moment the push stops (force deadbanded to zero) we kill the velocity so
268 # motion stops dead and the offset (pose) is held exactly where it was left.
269 if force == [0.0, 0.0, 0.0]:
270 state["vel"] = [0.0, 0.0, 0.0]
271 return
272 acc = [
273 (force[i] - D_ADM * state["vel"][i] - K_ADM * state["offset"][i]) / M_ADM
274 for i in range(3)
275 ]
276 state["vel"] = vclamp(vadd(state["vel"], vscale(acc, dt)), MAX_VEL)
277 state["offset"] = vclamp(vadd(state["offset"], vscale(state["vel"], dt)), MAX_OFFSET)
278
279
280def spiral_force(t: float) -> list[float]:
281 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
282
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.
286 """
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]
295
296
297def admittance_step(env, robot, nominal, state, force):
298 """One admittance tick: force -> offset (outer loop) -> ACHD-tracked TCP.
299
300 robot.update() must have run this step so the FT read behind `force` is
301 current. Returns the commanded target frame (for tracing).
302 """
303 admittance_update(state, force, env.timestep())
304 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state["offset"]))
305 achd_track(robot, state, target)
306 return target
307
308
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).
311
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.
316 """
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))
319 prev = env.time()
320 start = env.time()
321 handoff_tared = False
322 target_prev: list[float] | None = None
323 tcp_prev: list[float] | None = None
324 trace_step = 0
325 try:
326 while viewer.is_running():
327 if env.time() < prev - 1e-6:
328 env.reset()
329 start = env.time()
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
336 prev = env.time()
337 t = env.time() - start
338 robot.update()
339 close_gripper(env)
340 # Intro: the scripted helical force IS the external force the demo
341 # applies (fed straight in -- also shoving the body would double-
342 # actuate it). After: the FT-measured force, so a hand-drag is sensed.
343 if t < TEACH_TIME:
344 force = spiral_force(t)
345 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
346 force = [0.0, 0.0, 0.0]
347 else:
348 if not handoff_tared:
349 state["bias"] = tare_force(robot)
350 handoff_tared = True
351 force = measured_force(robot, state)
352 target = admittance_step(env, robot, nominal, state, force)
353
354 # Draw the commanded (yellow) and actual measured TCP (green) paths.
355 # Both poses are in the base_link frame, so map them through the base
356 # body's world pose before tracing or the trail lands at the wrong
357 # place (down by the base) and looks skewed.
358 trace_step += 1
359 world_base = env.body_frame("base_link")
360 target_xyz = frame_point(world_base, target.p)
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
367 tcp_prev = tcp_xyz
368
369 if not viewer.step():
370 break
371 viewer.pace()
372 finally:
373 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
374 viewer.close()
375
376
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.
379
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
383 when force stops.
384 """
385 t0 = env.time()
386 helix_react = 0.0
387 helix_track_err = 0.0
388 while env.time() - t0 < TEACH_TIME:
389 t = env.time() - t0
390 robot.update()
391 close_gripper(env)
392 target = admittance_step(env, robot, nominal, state, spiral_force(t))
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))
397 if not robot.step():
398 break
399 robot.pace()
400
401 handoff_force = 0.0
402 t_handoff = env.time()
403 while env.time() - t_handoff < HANDOFF_TARE_TIME:
404 robot.update()
405 close_gripper(env)
406 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
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))
410 if not robot.step():
411 break
412 robot.pace()
413 robot.update()
414 state["bias"] = tare_force(robot)
415 for _ in range(100):
416 robot.update()
417 close_gripper(env)
418 force = measured_force(robot, state)
419 handoff_force = max(handoff_force, vnorm(force))
420 admittance_step(env, robot, nominal, state, force)
421 if not robot.step():
422 break
423 robot.pace()
424
425 helix_settle_err = 0.0
426 t_settle = env.time()
427 while env.time() - t_settle < 0.5:
428 robot.update()
429 close_gripper(env)
430 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
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))
434 if not robot.step():
435 break
436 robot.pace()
437
438 pre_push = state["offset"][:]
439 t1 = env.time()
440 settled: list[float] | None = None
441 push_recovery_err: float | None = None
442 while env.time() - t1 < 4.0:
443 t = env.time() - t1
444 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH if t < 1.0 else (0.0, 0.0, 0.0))
445 robot.update()
446 close_gripper(env)
447 target = admittance_step(env, robot, nominal, state, measured_force(robot, state))
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)
452 # Sample once the torque loop's settle transient has died (it can ring
453 # for ~1.5 s after release); hold drift is then the steady drift.
454 if settled is None and t >= 2.5:
455 settled = state["offset"][:]
456 if not robot.step():
457 break
458 robot.pace()
459 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
460 return {
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)]),
469 }
470
471
472def main() -> int:
473 parser = argparse.ArgumentParser()
474 parser.add_argument("--gui", action="store_true")
475 args = parser.parse_args()
476
477 env, robot = build_env()
478 try:
479 chain = robot.kdl_chain()
480 robot.ctrl_mode = mjk.CtrlMode.TORQUE # ACHD + RNEA computed-torque inner loop
481
482 state = {
483 "bias": [0.0, 0.0, 0.0],
484 "offset": [0.0, 0.0, 0.0],
485 "vel": [0.0, 0.0, 0.0],
486 # Vereshchagin root acceleration carries gravity as +z (its sign
487 # convention); RNEA below uses the usual -z gravity.
488 "achd": kdl.ChainHdSolver_Vereshchagin_Fixed_Joint(
489 chain, kdl.Twist(kdl.Vector(0.0, 0.0, 9.81), kdl.Vector.Zero()), 6
490 ),
491 "rnea": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
492 "alpha": alpha_identity(),
493 "n_seg": chain.getNrOfSegments(),
494 "err_prev": [0.0] * 6,
495 "first_pid": True,
496 "dt": env.timestep(),
497 }
498
499 def on_reset(ctx):
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))
506
507 env.on_reset = on_reset
508 env.reset()
509 # Single tare after settling. Orientation is held during hand-guiding so
510 # the world-frame gravity bias stays ~constant; a slow auto-tare would be
511 # needed only if drift exceeded the deadband during large reorientations.
512 state["bias"] = settle_and_tare(env, robot, state)
513 nominal = robot.fk_frame()
514
515 print(f"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
516 if args.gui:
517 run_gui(env, robot, nominal, state)
518 print(
519 "final offset: "
520 f"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
521 )
522 else:
523 m = run_selfcheck(env, robot, nominal, state)
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")
540 finally:
541 env.close()
542 return 0
543
544
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)
list[float] vadd(list[float] a, list[float] b)
list[float] vscale(list[float] a, float s)
mjk.AttachmentSpec gripper_attachment()
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)