Mujoco KDL Wrapper  0.3.18
MuJoCo + KDL bridge for robot kinematics and dynamics
Loading...
Searching...
No Matches
ex_admittance_ft_rnea.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Admittance control with an RNEA computed-torque inner loop, FT-driven.
3
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:
8
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
13
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.
18
19Outer admittance law per Cartesian axis (no position stiffness):
20
21 M * a = F_ext - D * v
22 v += a * dt (clamped to MAX_VEL)
23 offset += v * dt (clamped to MAX_OFFSET)
24
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).
28
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.
36"""
37
38from __future__ import annotations
39
40import argparse
41import math
42
43import PyKDL as kdl
44import mj_kdl_wrapper as mjk
45
46HOME = [0.0, 0.2618, 3.1416, -2.2689, 0.0, 0.9599, 1.5708]
47TABLE_Z = 0.70
48
49# Cartesian computed-torque inner-loop gains. A Cartesian PD produces desired
50# TCP acceleration; WDLS maps it to qddot; RNEA maps qddot to torque.
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
54TAU_MAX = 141.6
55
56# Admittance outer loop: virtual mass, damping, stiffness (isotropic).
57# K_ADM = 0 -> pure hand-guiding: holds pose on release. Set > 0 to self-center.
58M_ADM, D_ADM, K_ADM = 8.0, 80.0, 0.0
59FORCE_DEADBAND = 2.5 # N; rejects sensor noise and settling transients
60MAX_OFFSET = 0.20 # m; reachable workspace half-extent around home
61MAX_VEL = 0.25 # m/s
62TOOL_BODY = "g_base" # rigid gripper base; where the headless self-check pushes
63GRIPPER_ACTUATOR = "g_fingers_actuator"
64SETTLE_STEPS = 300 # ~0.6 s at dt=0.002 to close the gripper before taring
65HANDOFF_TARE_TIME = 1.0 # s; let scripted-motion transients settle before FT hand-guiding
66SELFCHECK_PUSH = (8.0, 12.0, 6.0)
67
68# Intro helical force: amplitude/shape and how long it is applied.
69TEACH_TIME = 16.0
70TEACH_RADIUS = 0.04
71TEACH_RISE = 0.10
72TEACH_TURNS = 5.0
73
74
75def jnt(values: list[float]) -> kdl.JntArray:
76 out = kdl.JntArray(len(values))
77 for i, value in enumerate(values):
78 out[i] = value
79 return out
80
81
82def clamp(value: float, low: float, high: float) -> float:
83 return max(low, min(high, value))
84
85
86def vadd(a: list[float], b: list[float]) -> list[float]:
87 return [a[i] + b[i] for i in range(3)]
88
89
90def vscale(a: list[float], s: float) -> list[float]:
91 return [s * a[i] for i in range(3)]
92
93
94def vclamp(a: list[float], limit: float) -> list[float]:
95 return [clamp(x, -limit, limit) for x in a]
96
97
98def vnorm(a: list[float]) -> float:
99 return math.sqrt(sum(x * x for x in a))
100
101
102def xyz(v: kdl.Vector) -> list[float]:
103 return [v.x(), v.y(), v.z()]
104
105
106def frame_point(frame: kdl.Frame, point: kdl.Vector) -> list[float]:
107 return xyz(frame * point)
108
109
110def ft_attachment() -> mjk.AttachmentSpec:
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")
114 return spec
115
116
117def gripper_attachment() -> mjk.AttachmentSpec:
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")
121 spec.prefix = "g_"
122 return spec
123
124
125def table_object() -> mjk.SceneObject:
126 table = mjk.SceneObject()
127 table.name = "table"
128 table.mjcf_path = mjk.menagerie.asset_path("table.xml", env_var="MJ_KDL_TABLE")
129 table.pos = [0.0, 0.0, TABLE_Z]
130 table.fixed = True
131 return table
132
133
134def build_env() -> tuple[mjk.Env, mjk.Robot]:
135 table = table_object()
136 spec = mjk.SceneSpec()
137 spec.timestep = 0.002
138 spec.add_floor = True
139 spec.add_skybox = True
140 spec.objects = [table]
141
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")
146 )
147 robot_spec.attachments = [ft_attachment(), gripper_attachment()]
148 spec.robots = [robot_spec]
149
150 env = mjk.Env.build(spec)
151
152 ft = mjk.ForceTorqueSensorSpec()
153 ft.name = "wrist_ft"
154 ft.frame_site = "wrist_ft_site"
155
156 tool = mjk.ToolFrameSpec()
157 tool.tool_body = "g_base"
158 tool.tcp_site = "g_pinch"
159 tool.ft_sensors = [ft]
160
161 robot = env.create_robot("base_link", "bracelet_link", tool=tool)
162 return env, robot
163
164
165def jacobian_twist(jac: kdl.Jacobian, qdot: kdl.JntArray) -> list[float]:
166 return [sum(jac[row, col] * qdot[col] for col in range(qdot.rows())) for row in range(6)]
167
168
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)
173
174 err = kdl.diff(robot.fk_frame(), target)
175 jac = kdl.Jacobian(robot.n_joints)
176 state["jac_solver"].JntToJac(q, jac)
177 tcp_vel = jacobian_twist(jac, qdot)
178
179 qddot = kdl.JntArray(robot.n_joints)
180 beta = kdl.Twist()
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),
185 )
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),
190 )
191 if state["acc_ik"].CartToJnt(q, beta, qddot) < 0:
192 raise RuntimeError("RNEA task acceleration solve failed")
193
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)]
199
200
201def close_gripper(env: mjk.Env) -> None:
202 if env.has_actuator(GRIPPER_ACTUATOR):
203 env.set_actuator_ctrl(GRIPPER_ACTUATOR, 255.0)
204
205
206def settle_and_tare(env: mjk.Env, robot: mjk.Robot, state: dict) -> list[float]:
207 """Close the gripper, hold home until the wrist load settles, then tare.
208
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.
214 """
215 robot.update()
216 home = robot.fk_frame()
217 for _ in range(SETTLE_STEPS):
218 robot.update()
219 close_gripper(env)
220 rnea_track(robot, state, home)
221 if not robot.step():
222 break
223 robot.pace()
224 robot.update()
225 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
226
227
228def measured_force(robot: mjk.Robot, state: dict) -> list[float]:
229 """External force on the tool in world frame, gravity-tared, deadbanded.
230
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.
238 """
239 wrench = robot.ft_sensor("wrist_ft")
240 f_world = xyz(robot.ft_sensor_frame("wrist_ft").M * wrench.force)
241 bias = state["bias"]
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]
246 return f_ext
247
248
249def tare_force(robot: mjk.Robot) -> list[float]:
250 return xyz(robot.ft_sensor_frame("wrist_ft").M * robot.ft_sensor("wrist_ft").force)
251
252
253def admittance_update(state: dict, force: list[float], dt: float) -> None:
254 # offset = integral of velocity, so with K = 0 it is a pure integrator: the
255 # moment the push stops (force deadbanded to zero) we kill the velocity so
256 # motion stops dead and the offset (pose) is held exactly where it was left.
257 if force == [0.0, 0.0, 0.0]:
258 state["vel"] = [0.0, 0.0, 0.0]
259 return
260 acc = [
261 (force[i] - D_ADM * state["vel"][i] - K_ADM * state["offset"][i]) / M_ADM
262 for i in range(3)
263 ]
264 state["vel"] = vclamp(vadd(state["vel"], vscale(acc, dt)), MAX_VEL)
265 state["offset"] = vclamp(vadd(state["offset"], vscale(state["vel"], dt)), MAX_OFFSET)
266
267
268def spiral_force(t: float) -> list[float]:
269 """Scripted external force whose direction sweeps a helix over TEACH_TIME.
270
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.
274 """
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]
283
284
285def admittance_step(env, robot, nominal, state, force):
286 """One admittance tick: force -> offset (outer loop) -> RNEA-tracked TCP.
287
288 robot.update() must have run this step so the FT read behind `force` is
289 current. Returns the commanded target frame (for tracing).
290 """
291 admittance_update(state, force, env.timestep())
292 target = kdl.Frame(nominal.M, nominal.p + kdl.Vector(*state["offset"]))
293 rnea_track(robot, state, target)
294 return target
295
296
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).
299
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.
304 """
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))
307 prev = env.time()
308 start = env.time()
309 handoff_tared = False
310 target_prev: list[float] | None = None
311 tcp_prev: list[float] | None = None
312 trace_step = 0
313 try:
314 while viewer.is_running():
315 if env.time() < prev - 1e-6:
316 env.reset()
317 start = env.time()
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
322 prev = env.time()
323 t = env.time() - start
324 robot.update()
325 close_gripper(env)
326 # Intro: the scripted helical force IS the external force the demo
327 # applies (fed straight in -- also shoving the body would double-
328 # actuate it). After: the FT-measured force, so a hand-drag is sensed.
329 if t < TEACH_TIME:
330 force = spiral_force(t)
331 elif t < TEACH_TIME + HANDOFF_TARE_TIME:
332 force = [0.0, 0.0, 0.0]
333 else:
334 if not handoff_tared:
335 state["bias"] = tare_force(robot)
336 handoff_tared = True
337 force = measured_force(robot, state)
338 target = admittance_step(env, robot, nominal, state, force)
339
340 # Draw the commanded (yellow) and actual measured TCP (green) paths.
341 # Both poses are in the base_link frame, so map them through the base
342 # body's world pose before tracing or the trail lands at the wrong
343 # place (down by the base) and looks skewed.
344 trace_step += 1
345 world_base = env.body_frame("base_link")
346 target_xyz = frame_point(world_base, target.p)
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
353 tcp_prev = tcp_xyz
354
355 if not viewer.step():
356 break
357 viewer.pace()
358 finally:
359 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
360 viewer.close()
361
362
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.
365
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
369 when force stops.
370 """
371 t0 = env.time()
372 helix_react = 0.0
373 helix_track_err = 0.0
374 while env.time() - t0 < TEACH_TIME:
375 t = env.time() - t0
376 robot.update()
377 close_gripper(env)
378 target = admittance_step(env, robot, nominal, state, spiral_force(t))
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))
383 if not robot.step():
384 break
385 robot.pace()
386
387 handoff_force = 0.0
388 t_handoff = env.time()
389 while env.time() - t_handoff < HANDOFF_TARE_TIME:
390 robot.update()
391 close_gripper(env)
392 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
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))
396 if not robot.step():
397 break
398 robot.pace()
399 robot.update()
400 state["bias"] = tare_force(robot)
401 for _ in range(100):
402 robot.update()
403 close_gripper(env)
404 force = measured_force(robot, state)
405 handoff_force = max(handoff_force, vnorm(force))
406 admittance_step(env, robot, nominal, state, force)
407 if not robot.step():
408 break
409 robot.pace()
410
411 helix_settle_err = 0.0
412 t_settle = env.time()
413 while env.time() - t_settle < 0.5:
414 robot.update()
415 close_gripper(env)
416 target = admittance_step(env, robot, nominal, state, [0.0, 0.0, 0.0])
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))
420 if not robot.step():
421 break
422 robot.pace()
423
424 pre_push = state["offset"][:]
425 t1 = env.time()
426 settled: list[float] | None = None
427 push_recovery_err: float | None = None
428 while env.time() - t1 < 4.0:
429 t = env.time() - t1
430 env.set_body_wrench(TOOL_BODY, SELFCHECK_PUSH if t < 1.0 else (0.0, 0.0, 0.0))
431 robot.update()
432 close_gripper(env)
433 target = admittance_step(env, robot, nominal, state, measured_force(robot, state))
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)
438 # Sample once the torque loop's settle transient has died (it can ring
439 # for ~1.5 s after release); hold drift is then the steady drift.
440 if settled is None and t >= 2.5:
441 settled = state["offset"][:]
442 if not robot.step():
443 break
444 robot.pace()
445 env.set_body_wrench(TOOL_BODY, (0.0, 0.0, 0.0))
446 return {
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)]),
455 }
456
457
458def main() -> int:
459 parser = argparse.ArgumentParser()
460 parser.add_argument("--gui", action="store_true")
461 args = parser.parse_args()
462
463 env, robot = build_env()
464 try:
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 # RNEA computed-torque inner loop
469
470 state = {
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),
475 "acc_ik": acc_ik,
476 "id_solver": kdl.ChainIdSolver_RNE(chain, kdl.Vector(0.0, 0.0, -9.81)),
477 "n_seg": chain.getNrOfSegments(),
478 }
479
480 def on_reset(ctx):
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))
485
486 env.on_reset = on_reset
487 env.reset()
488 # Single tare after settling. Orientation is held during hand-guiding so
489 # the world-frame gravity bias stays ~constant; a slow auto-tare would be
490 # needed only if drift exceeded the deadband during large reorientations.
491 state["bias"] = settle_and_tare(env, robot, state)
492 nominal = robot.fk_frame()
493
494 print(f"FT bias: [{state['bias'][0]:.3f}, {state['bias'][1]:.3f}, {state['bias'][2]:.3f}] N")
495 if args.gui:
496 run_gui(env, robot, nominal, state)
497 print(
498 "final offset: "
499 f"[{state['offset'][0]:.4f}, {state['offset'][1]:.4f}, {state['offset'][2]:.4f}] m"
500 )
501 else:
502 m = run_selfcheck(env, robot, nominal, state)
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")
519 finally:
520 env.close()
521 return 0
522
523
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()
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()