Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The impedance backend

Target control tracks the generator’s setpoints with torques of its own by default. This page is the derivation: where the law comes from, why it is a joint law with a Cartesian term rather than the other way round, what the joint gains do to the stiffness you feel, how the Cartesian interface gets a joint goal, how the generator is anchored without an echo, what happens at the handover, and how it differs from the operational-space law of the cartesian_impedance_active_control example. The options and defaults are on the how-to page; the code is robot/target_control/impedance.rs, torque.rs and ik.rs.

The law and its provenance

Kp  = Jᵀ Kx J + diag(Kq)          Kd = Jᵀ Kxd J + diag(Kqd)
tau = Kp (q_goal − q) + Kd (dq_goal − dq) + coriolis(q, dq),   clamped to ±torque_limits

This is the structure of HybridJointImpedanceControl from polymetis (its feedback module HybridJointSpacePD forms Kp and Kd exactly so), the controller that DROID (Khazatsky et al., 2024, DROID: A Large-Scale In-The-Wild Robot Manipulation Dataset, arXiv:2403.12945) ran on its Panda arms for 76k teleoperated trajectories. ImpedanceGains::DROID is its gains as they were, and with velocity_feedforward off (dq_goal = 0, the damping on the absolute velocity) the law is polymetis’s. The default differs in two places. ImpedanceGains::CARTESIAN raises the translational damping from 37 to 50, 50, 90 Ns/m: the arm’s apparent mass at the end effector near the ready pose is about 0.94 kg along x and y and 3.9 kg along z (from the model’s mass matrix), so 37 Ns/m against 750 N/m leaves z at a damping ratio of 0.34, which rings; the defaults bring all three to about 0.8. And the damping acts on the velocity error by default, with dq_goal the goal’s velocity: the generator’s on the joint interface, the finite difference of the IK solution on the Cartesian one, zero while holding. Damping the absolute velocity resists the motion the goal asks for, and a goal moving at v is tracked Kd v / Kp behind it; on franka-sim 1.1.6 a 5 cm step peaks 12.3 mm behind without the feedforward and 3.7 mm with it. J is Model::zero_jacobian(Frame::EndEffector, state), the 6x7 base-frame Jacobian at the measured q, so Kx acts at the configured end-effector frame, the frame the O_T_EE targets are in. Gravity is compensated by the robot; coriolis is the model’s, as in every torque example of the crate. The clamp comes before the low-pass filter (cutoff_frequency, 100 Hz by default) and the torque-rate limiter that control_torques applies, in that order.

Why a joint law with a Cartesian term

Jᵀ Kx J is the Cartesian spring expressed in joint coordinates: for a small joint error Δq, J Δq is the end-effector error and Jᵀ Kx J Δq the joint torque of the spring Kx pulling on it. It dominates the stiffness: a joint whose lever arm to the end effector is 0.5 m sees 750 N/m as 750 × 0.5² ≈ 190 Nm/rad, against 25 to 50 Nm/rad in Kq. But on a seven-joint arm the 6x7 Jacobian has a one-dimensional nullspace, the motion that leaves the end effector where it is (the elbow’s swing), and Jᵀ Kx J is zero along it. diag(Kq) regularises that direction, and near a singularity, where the Cartesian term collapses in more directions, it keeps every joint held. So the Cartesian gains set the compliance you feel at the tool and the joint gains set how firmly the arm keeps its shape. The joint interface uses the same code with Kx = Kxd = 0, which leaves a joint PD with Coriolis feed-forward, the fer_joint_impedance example’s law and gains.

What the joint gains do to the stiffness you feel

diag(Kq) is not confined to the nullspace: it acts on every joint, and the part of it that J maps to the end effector adds to Kx. The stiffness felt at the tool is (J Kp⁻¹ Jᵀ)⁻¹, and with the default gains at the ready pose that is about 990 to 1180 N/m in translation against the 750 N/m set, and two to three times Kx in rotation (computed from the model). DROID ran the law this way. ImpedanceOptions::project_joint_gains replaces diag(Kq) and diag(Kqd) by N Kq N and N Kqd N, with N = I − J⁺ J the nullspace projector of the damped pseudoinverse J⁺ = Jᵀ (J Jᵀ + λ² I)⁻¹ (λ the IK’s damping, 0.05): the joint springs then hold the elbow’s swing and nothing else, and the end effector feels Kx to within the damping of the projector, away from singularities, where N widens and the projected springs take over the collapsing directions. The price is that with the projection on the Cartesian gains are all that holds the tool, so zero Kx would leave it free. It is off by default to keep the default law DROID’s in structure.

Payload

Gravity is not in the law: the robot compensates it from the load configured in Desk or by Robot::set_load. The robot’s own controller corrects a wrong load as a position error; a spring does not. An unconfigured tool becomes a constant sag of its weight over the felt stiffness, 0.5 kg → 5 N → 6.7 mm at 750 N/m (less at the felt ~1000 N/m), in every pose, and a load heavier than configured pulls the same way. set_load matters more here than in position mode.

The differential inverse kinematics

The joint interface feeds the generator’s q in as q_goal. The Cartesian interface needs a joint goal for a pose, and gets one incrementally: from the previous q_goal, up to IkOptions::iterations (3) damped-least-squares steps toward the desired pose per cycle,

e   = [ p_des − p(q_goal) ;  log(R_des R(q_goal)ᵀ) ]
dq  = J_gᵀ (J_g J_gᵀ + λ² I)⁻¹ e  +  (I − J_g⁺ J_g) k_null (posture − q_goal) dt

with J_g the zero Jacobian at q_goal (not at the measured q), λ = 0.05 the damping that keeps the step finite at a singularity, and the second term a drift toward posture (the start configuration unless set) at k_null = 1 /s, capped at 0.5 rad/s so that a far posture is approached rather than jumped at, projected into the nullspace so it never moves the end effector. If any joint of the step exceeds max_step (0.01 rad, so 10 rad/s) the whole step is scaled so that the largest component equals it; then the result is clamped to the joint position limits (rate_limiting::JOINT_POSITION_LIMITS for the FR3, rate_limiting::fer::JOINT_POSITION_LIMITS for the FER, from the URDFs in the repository) inset by limit_margin (0.02 rad), and iteration stops below tolerance (1e-6). A posture outside those inset limits, like a joint target outside them, is refused with InvalidArgument before anything starts. The generator moves the pose by at most 0.3 mm a cycle under the default budget, so a step or three from the previous solution keeps the residual near zero (tolerance ends the iteration early on a landed target); the observer sees the residual as CartesianSent::ik_error. A pose out of reach or through a singularity leaves a residual and q_goal moves toward it at most max_step a cycle instead of jumping, so the impedance never gets a step to track.

Anchoring without an echo: the leash

In the robot-controller backend the third rule of the generator re-anchors the plan every cycle on the robot’s echo of the last command (O_T_EE_c, q_d), so that the backstop behind the generator can shape a command but never accumulate a lag. There is no echo of a torque command: the robot reports tau_J_d, not a pose or joint goal. Neither extreme works as a substitute. Anchoring on the measured pose would fold the spring’s deflection into the plan every cycle, so that a load would stall the plan short of the target and a push on the arm would move it, since a compliant arm is meant to sit off its setpoint under a force. Anchoring on the generator’s own last output runs it open loop: held back by a hand, an obstacle or an unreachable pose, the arm falls arbitrarily far behind a plan that goes on without it, the spring force grows with the distance until a collision reflex ends the session, and on release the arm springs to wherever the plan has got to.

The Leash is the middle: each cycle the anchor is the measured pose (the model’s, for the measured q, the frame the desired pose and the IK live in) pulled toward the previous desired pose by at most leash.translation (0.025 m) and leash.rotation (0.15 rad),

e_t = p_des − p_meas        e_r = log(R_des R_measᵀ)
s   = min(1, leash.translation / |e_t|, leash.rotation / |e_r|)
anchor = ( p_meas + s e_t,  exp(s e_r) R_meas )

and on the joint interface each joint’s goal clamped to within leash.joint (0.1 rad) of the measured joint, the feedforward velocity being the finite difference of that leashed goal. While the arm follows, s = 1 and the anchor is the previous desired, so the generator runs from its own output and its budget is the whole budget, as before. Held back, the desired stays within the leash of the arm, so the spring force on whoever holds it is bounded by the felt stiffness times the leash: roughly 25 to 30 N at the default gains at the ready pose (990 to 1180 N/m × 0.025 m on the FER model), 18.75 N with project_joint_gains (750 × 0.025). On the joint interface it is the torque clamp, not the leash, that bounds the torque: the JOINT preset’s 600 Nm/rad × 0.1 rad is 60 Nm on joints 1 to 4, under their 86 Nm clamp but far over the 20 Nm joint threshold the examples set, which such a joint reaches at 0.033 rad of error; on the wrist (250 / 150 / 50 Nm/rad) joints 5 and 6 meet the 11.5 Nm clamp before the leash does. Target control sets no collision thresholds; see Collision thresholds. When the arm is let go the generator resumes from where the arm is, under its budget. The leash keeps acting during the stop’s hold, so an arm held during stop() is not pulled harder either. A deflection under load smaller than the leash costs nothing, and the from-start deviation guard is unchanged. What the leash took off is reported to the observer as leash_alteration (m, or rad on the joint interface) and leash_angular_alteration (rad), zero in normal tracking. The backstop role otherwise passes to the torque clamp, the filter and the torque-rate limiter, which shape the torque and leave the setpoint alone.

The handover at start and stop

At the start the desired pose is the model’s pose of the measured configuration, not O_T_EE (the two differ by the model’s accuracy on a robot, and by 0.107 m on franka-sim, whose O_T_EE is the joint-7 frame), so the IK’s residual is zero and q_goal = q: the first command is −Kd dq + coriolis(q, dq), near zero on an arm at rest, and the robot’s controller hands over without a step. On franka-sim 1.1.6 the arm did not move over the first 500 cycles of a session (measured change 0 to within floating point). At stop() the generator lands on the last target; the loop then holds the landed setpoint for Settle::cycles (250) and sets motion_finished once every joint moves slower than REST_JOINT_VELOCITY (0.01 rad/s), or after STOP_TIMEOUT_CYCLES (5 s) more cycles, the law kept on the held goal until then. The generator’s rest is not the arm’s: an arm still closing its lag, handed to the robot’s controller at the moment of the finish, is held wherever it was (0.048 rad short on joint 7 on the simulator, before the gate). With the gate the arm is at rest on the setpoint and the torques are near zero, so the robot’s controller takes over from rest, as it does after the cartesian_impedance_active_control example’s final zero-torque command. On franka-sim 1.1.6 a 5 cm step lands 0.5 to 0.8 mm from the target. The law itself, impedance_torques, is public at the crate root for a loop of your own.

Measured on two FERs (2026-09-10)

Both Pandas of the earlier campaigns, system 4.2.1, PREEMPT_RT host, FRANKA_REALTIME=enforce, default gains, collision thresholds 40 N unless stated. No run ended in a reflex except the one that was meant to find the threshold.

runresult
5 s at rest, then stop()first-cycle torque under 0.04 Nm, peak 0.22 Nm, tracking 0.13 mm (L) / 0.23 mm (R), stop() 0.44 s
the commander’s 19 s stepped sequenceno reflex, IK residual under 1e-6, leash never bound, peak torque 4.4 Nm; tracking error at the holds 4.6 mm (L) / 2.8 mm (R), moving p95 9.6 / 8.7 mm; the robot’s own controller on the same sequence: 3.7 mm at the holds, 4.4 mm moving
the same at Kx 1500 N/m (damping 75)2.7 mm at the holds, 6.3 mm moving
the sequence with the ±15° yaw sweepno reflex, same tracking figures
joint targets (Python, JOINT preset, 20 % budget)a 0.2 rad step on joint 1 landed within 0.6 mrad, a three-joint step within 4 mrad (joint 6), stop() mid-motion 0.9 s, arm Idle
a 4 cm circle at 5, 10 and 30 Hz (Python)rate-independent, 8 to 10 mm p50 along the slow circle, back at the start within 7 to 9 mm
push tests, 40 N thresholdstwo light pushes: 16.6 mm for 12 N, felt stiffness 725 N/m along the push, back within 2 mm in 0.3 s; a fast push reached 50 N in 250 ms at 25 mm and tripped cartesian_reflex
push tests from the other side, 60 N thresholds24.8 N at 22.8 mm quasi-static (about 1090 N/m felt), the leash held the error at exactly 25.0 mm under 45 to 47 N at 0.26 m/s, no reflex; a push that dragged the hand 12 cm and turned the wrist past 0.5 rad ended the loop through the deviation guard, the arm held in place

Two things the numbers settle. The tracking error at rest scales with 1/K (4.6 mm at 750, 2.7 mm at 1500) and the robot’s own external-force estimate reads 3 to 4 N at those holds: a constant residual force of the arm (load or friction) that the robot’s own impedance controller deflects under as well; a spring has no integrator, so users who need millimetre placement raise the stiffness. And the leash bounds the position error, not the force: a fast push adds the damping term (50 to 90 N s/m times the speed), which is why 45 to 50 N appeared at 0.25 m/s. A cap on the reaction force, spring and damper together, is the follow-up. The FR3 was not reachable that day.

Compared with the operational-space law

examples/cartesian_impedance_active_control.rs is libfranka’s Cartesian impedance example:

tau = Jᵀ (−K e − D J dq) + coriolis,   e = [ p − p_d ; −R vec(q_ee⁻¹ q_d) ]

with K = diag(150, 150, 150, 10, 10, 10) and D = 2√K. The two are first-order the same in the six task directions, Jᵀ Kx J (q_goal − q) ≈ Jᵀ Kx (x_goal − x), and differ in three ways. The operational-space law measures the error in the task space, orientation through a quaternion, and needs no inverse kinematics; the hybrid law measures it in joint space and needs the joint goal, which is why the Cartesian interface runs the IK above. The operational-space law leaves the nullspace free unless a separate term fills it, as cartesian_impedance_figure_eight does with a nullspace joint spring through a projector; the hybrid law fills it with Kq in the same expression. And a joint goal makes the two interfaces one code path, with a joint target the degenerate case Kx = 0. Both add the model’s Coriolis term and leave gravity to the robot.