franka/robot/target_control/impedance.rs
1//! The torque backend's impedance law, the structure of DROID / polymetis
2//! `HybridJointImpedanceControl` with the damping on the velocity error:
3//!
4//! ```text
5//! Kp = Jᵀ Kx J + diag(Kq) Kd = Jᵀ Kxd J + diag(Kqd)
6//! tau = Kp (q_goal - q) + Kd (dq_goal - dq) + coriolis clamped to torque_limits
7//! ```
8//!
9//! `J` is the zero Jacobian at the end-effector frame in the *measured* configuration, so the
10//! Cartesian spring acts at the frame `O_T_EE` targets are given in; gravity is the robot's.
11//! Without [`ImpedanceOptions::velocity_feedforward`] `dq_goal` is zero and the damping acts on
12//! the absolute velocity, which lags a moving goal by `Kd v / Kp` (DROID parity). The joint
13//! gains act unprojected, so the end effector feels `Kx` plus the joint springs reflected
14//! through `J` (30-60 % stiffer than `Kx` in translation at the ready pose, two to three
15//! times in rotation); with [`ImpedanceOptions::project_joint_gains`] they are confined to
16//! the nullspace, `N Kq N` with `N = I - J⁺ J`, and the end effector feels exactly `Kx`.
17
18use nalgebra::{SMatrix, SVector};
19
20use super::ik::IkOptions;
21use crate::error::{FrankaError, FrankaResult};
22
23/// The stiffness and damping of the law, all finite and non-negative.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ImpedanceGains {
26 /// `Kx`, base frame, translation first: N/m, then Nm/rad.
27 pub cartesian_stiffness: [f64; 6],
28 /// `Kxd`, base frame, translation first: N s/m, then Nm s/rad.
29 pub cartesian_damping: [f64; 6],
30 /// `Kq`, per joint, Nm/rad.
31 pub joint_stiffness: [f64; 7],
32 /// `Kqd`, per joint, Nm s/rad.
33 pub joint_damping: [f64; 7],
34}
35
36impl ImpedanceGains {
37 /// The default for a Cartesian target: DROID's stiffness (a medium spring, 750 N/m) and
38 /// soft joint springs that settle the redundancy, with the Cartesian damping raised to
39 /// about a damping ratio of 0.8 from the arm's apparent masses at the ready pose (0.94 kg
40 /// along x and y, 3.9 kg along z; DROID's 37 N s/m leaves z at 0.34 and ringing).
41 pub const CARTESIAN: ImpedanceGains = ImpedanceGains {
42 cartesian_stiffness: [750.0, 750.0, 750.0, 15.0, 15.0, 15.0],
43 cartesian_damping: [50.0, 50.0, 90.0, 2.0, 2.0, 2.0],
44 joint_stiffness: [40.0, 30.0, 50.0, 25.0, 35.0, 25.0, 10.0],
45 joint_damping: [4.0, 6.0, 5.0, 5.0, 3.0, 2.0, 1.0],
46 };
47
48 /// DROID's production gains as they are, the parity preset: with
49 /// [`ImpedanceOptions::velocity_feedforward`] and
50 /// [`ImpedanceOptions::project_joint_gains`] both `false` the law is polymetis's
51 /// `HybridJointImpedanceControl`, for replaying DROID-trained policies.
52 pub const DROID: ImpedanceGains = ImpedanceGains {
53 cartesian_stiffness: [750.0, 750.0, 750.0, 15.0, 15.0, 15.0],
54 cartesian_damping: [37.0, 37.0, 37.0, 2.0, 2.0, 2.0],
55 joint_stiffness: [40.0, 30.0, 50.0, 25.0, 35.0, 25.0, 10.0],
56 joint_damping: [4.0, 6.0, 5.0, 5.0, 3.0, 2.0, 1.0],
57 };
58
59 /// A joint PD for a joint target, the gains of `examples/fer_joint_impedance.rs`.
60 pub const JOINT: ImpedanceGains = ImpedanceGains {
61 cartesian_stiffness: [0.0; 6],
62 cartesian_damping: [0.0; 6],
63 joint_stiffness: [600.0, 600.0, 600.0, 600.0, 250.0, 150.0, 50.0],
64 joint_damping: [50.0, 50.0, 50.0, 50.0, 30.0, 25.0, 15.0],
65 };
66
67 /// # Errors
68 /// [`FrankaError::InvalidArgument`] if a gain is not finite or negative.
69 pub fn validate(&self) -> FrankaResult<()> {
70 let valid = |gains: &[f64]| gains.iter().all(|g| g.is_finite() && *g >= 0.0);
71 if valid(&self.cartesian_stiffness)
72 && valid(&self.cartesian_damping)
73 && valid(&self.joint_stiffness)
74 && valid(&self.joint_damping)
75 {
76 Ok(())
77 } else {
78 Err(FrankaError::InvalidArgument(format!(
79 "target control: impedance gains must be finite and non-negative, got {self:?}"
80 )))
81 }
82 }
83}
84
85/// How far the desired state may run ahead of the measured one. There is no echo of a torque
86/// command to re-anchor the generator on, so every cycle it is anchored on the measured state
87/// pulled toward the previous desired by at most this: while the arm follows, that is exactly
88/// the previous desired and nothing changes; held back (a hand, an obstacle, an unreachable
89/// target) the desired stays within the leash, the force on the arm is bounded by the
90/// stiffness times the leash, and on release the generator resumes from where the arm is
91/// under its budget. Once a stop holds, the leash pulls toward the held state instead, so an
92/// arm moved during the hold meets the same bound. The observer reports what the leash took
93/// off as `leash_alteration`.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct Leash {
96 /// Cartesian interface, translation, m. Default 0.025. The force at the leash is about
97 /// the *felt* stiffness times the leash: at the default gains at the ready pose roughly
98 /// 25-30 N (the unprojected joint springs add to `Kx`), 18.75 N with
99 /// [`project_joint_gains`](ImpedanceOptions::project_joint_gains). Set the collision
100 /// thresholds accordingly.
101 pub translation: f64,
102 /// Cartesian interface, rotation, rad. Default 0.15.
103 pub rotation: f64,
104 /// Joint interface, per joint, rad. Default 0.1. On the joint interface the torque clamp,
105 /// not the leash, bounds the torque: at the leash the [`JOINT`](ImpedanceGains::JOINT)
106 /// preset pulls 60 Nm on joints 1 to 4 (under their 86 Nm clamp) and 25 / 15 / 5 Nm on the
107 /// wrist, where joints 5 and 6 meet the 11.5 Nm clamp first; the examples' 20 Nm joint
108 /// collision threshold is reached at 0.033 rad on joints 1 to 4.
109 pub joint: f64,
110}
111
112impl Default for Leash {
113 fn default() -> Self {
114 Leash {
115 translation: 0.025,
116 rotation: 0.15,
117 joint: 0.1,
118 }
119 }
120}
121
122/// Options of [`Backend::Impedance`]; [`cartesian`](Self::cartesian) and
123/// [`joint`](Self::joint) are the documented starting points and the `with_*` methods change
124/// one field each.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct ImpedanceOptions {
127 /// The gains of the law.
128 pub gains: ImpedanceGains,
129 /// Per-joint clamp on the commanded torque, Nm, at most [`RATED_TORQUES`]. Default 86 on
130 /// joints 1-4, 11.5 on 5-7.
131 pub torque_limits: [f64; 7],
132 /// Cutoff, Hz, of the crate's first-order low-pass on the commanded torque;
133 /// [`MAX_CUTOFF_FREQUENCY`](crate::lowpass_filter::MAX_CUTOFF_FREQUENCY) switches it off.
134 /// Default 100.
135 pub cutoff_frequency: f64,
136 /// The configuration the Cartesian IK's nullspace bias pulls toward; `None`, the default,
137 /// is the configuration the loop starts in. Ignored by the joint interface.
138 pub posture: Option<[f64; 7]>,
139 /// The Cartesian interface's inverse kinematics. Ignored by the joint interface.
140 pub ik: IkOptions,
141 /// Whether the damping acts on the velocity *error*, `Kd (dq_goal - dq)`, with `dq_goal`
142 /// the goal's velocity (the generator's on the joint interface, the IK solution's finite
143 /// difference on the Cartesian one); `false` damps the absolute velocity, DROID parity,
144 /// and a goal moving at `v` is then tracked `Kd v / Kp` behind. Default `true`.
145 pub velocity_feedforward: bool,
146 /// How far the desired state may run ahead of the measured one; see [`Leash`].
147 pub leash: Leash,
148 /// Whether the joint gains are projected into the nullspace of the Jacobian (`N Kq N`,
149 /// `N Kqd N`, `N = I - J⁺ J` with the damped pseudoinverse of [`IkOptions::damping`]), so
150 /// that the Cartesian gains alone are felt at the end effector; `false` leaves them
151 /// unprojected, DROID parity. For the Cartesian interface: projected joint gains hold the
152 /// nullspace only, so with zero Cartesian gains the end effector would be free. Default
153 /// `false`.
154 pub project_joint_gains: bool,
155}
156
157/// The DROID clamp, a Nm and a half under the rated torques.
158const TORQUE_LIMITS: [f64; 7] = [86.0, 86.0, 86.0, 86.0, 11.5, 11.5, 11.5];
159
160/// The FR3's and FER's rated joint torques, Nm, the most `torque_limits` may allow.
161pub const RATED_TORQUES: [f64; 7] = [87.0, 87.0, 87.0, 87.0, 12.0, 12.0, 12.0];
162
163impl ImpedanceOptions {
164 /// The defaults of the Cartesian interface: [`ImpedanceGains::CARTESIAN`].
165 pub fn cartesian() -> Self {
166 Self::with_default_gains(ImpedanceGains::CARTESIAN)
167 }
168
169 /// The defaults of the joint interface: [`ImpedanceGains::JOINT`].
170 pub fn joint() -> Self {
171 Self::with_default_gains(ImpedanceGains::JOINT)
172 }
173
174 fn with_default_gains(gains: ImpedanceGains) -> Self {
175 ImpedanceOptions {
176 gains,
177 torque_limits: TORQUE_LIMITS,
178 cutoff_frequency: 100.0,
179 posture: None,
180 ik: IkOptions::default(),
181 velocity_feedforward: true,
182 leash: Leash::default(),
183 project_joint_gains: false,
184 }
185 }
186
187 /// Sets the gains.
188 pub fn with_gains(mut self, gains: ImpedanceGains) -> Self {
189 self.gains = gains;
190 self
191 }
192
193 /// Sets the per-joint torque clamp, Nm.
194 pub fn with_torque_limits(mut self, limits: [f64; 7]) -> Self {
195 self.torque_limits = limits;
196 self
197 }
198
199 /// Sets the torque low-pass cutoff, Hz.
200 pub fn with_cutoff_frequency(mut self, hertz: f64) -> Self {
201 self.cutoff_frequency = hertz;
202 self
203 }
204
205 /// Sets the IK's posture (`None`: the start configuration).
206 pub fn with_posture(mut self, posture: Option<[f64; 7]>) -> Self {
207 self.posture = posture;
208 self
209 }
210
211 /// Sets the IK options.
212 pub fn with_ik(mut self, ik: IkOptions) -> Self {
213 self.ik = ik;
214 self
215 }
216
217 /// Switches the damping between the velocity error (`true`) and the absolute velocity.
218 pub fn with_velocity_feedforward(mut self, on: bool) -> Self {
219 self.velocity_feedforward = on;
220 self
221 }
222
223 /// Sets the leash.
224 pub fn with_leash(mut self, leash: Leash) -> Self {
225 self.leash = leash;
226 self
227 }
228
229 /// Switches the projection of the joint gains into the Jacobian's nullspace.
230 pub fn with_project_joint_gains(mut self, on: bool) -> Self {
231 self.project_joint_gains = on;
232 self
233 }
234
235 /// Checks the options without starting anything.
236 ///
237 /// # Errors
238 /// [`FrankaError::InvalidArgument`] naming the field: a gain that is not finite or
239 /// negative, a torque limit, cutoff frequency or leash that is not finite and positive, a
240 /// posture that is not finite, or invalid [`IkOptions`].
241 pub fn validate(&self) -> FrankaResult<()> {
242 self.gains.validate()?;
243 let positive = |x: f64| x.is_finite() && x > 0.0;
244 let rated = self.torque_limits.iter().zip(&RATED_TORQUES);
245 if !rated.into_iter().all(|(t, r)| positive(*t) && t <= r) {
246 return Err(FrankaError::InvalidArgument(format!(
247 "target control: torque_limits must be positive and at most the rated \
248 {RATED_TORQUES:?} Nm, got {:?}",
249 self.torque_limits
250 )));
251 }
252 if !positive(self.cutoff_frequency) {
253 return Err(FrankaError::InvalidArgument(format!(
254 "target control: cutoff_frequency must be finite and positive, got {}",
255 self.cutoff_frequency
256 )));
257 }
258 let Leash {
259 translation,
260 rotation,
261 joint,
262 } = self.leash;
263 if !(positive(translation) && positive(rotation) && positive(joint)) {
264 return Err(FrankaError::InvalidArgument(format!(
265 "target control: leash must be finite and positive, got {:?}",
266 self.leash
267 )));
268 }
269 if let Some(posture) = self.posture {
270 if posture.iter().any(|q| !q.is_finite()) {
271 return Err(FrankaError::InvalidArgument(format!(
272 "target control: posture must be finite, got {posture:?}"
273 )));
274 }
275 }
276 self.ik.validate()
277 }
278}
279
280/// What tracks the target: the robot's controller or the crate's torques.
281#[derive(Debug, Clone, Copy, PartialEq)]
282#[allow(clippy::large_enum_variant)] // options, copied once at the start; boxing would cost `Copy`
283pub enum Backend {
284 /// The robot's own impedance controller tracks a pose / joint-position stream.
285 RobotController,
286 /// Torques from the crate's impedance law (`control_torques`).
287 Impedance(ImpedanceOptions),
288}
289
290/// The torque backend's law, offline:
291///
292/// ```text
293/// Kp = Jᵀ Kx J + diag(Kq) Kd = Jᵀ Kxd J + diag(Kqd)
294/// tau = Kp (q_goal - q) + Kd (dq_goal - dq) + coriolis clamped to torque_limits
295/// ```
296///
297/// with the gains of `options`; `dq_goal` is taken as zero without
298/// [`velocity_feedforward`](ImpedanceOptions::velocity_feedforward), and the joint gains are
299/// confined to the nullspace of `J` with
300/// [`project_joint_gains`](ImpedanceOptions::project_joint_gains). `jacobian` is the zero
301/// Jacobian at the end effector in the measured configuration, column-major 6x7 as
302/// [`Model::zero_jacobian`](crate::Model::zero_jacobian) returns it.
303pub fn impedance_torques(
304 options: &ImpedanceOptions,
305 jacobian: &[f64; 42],
306 q_goal: &[f64; 7],
307 dq_goal: &[f64; 7],
308 q: &[f64; 7],
309 dq: &[f64; 7],
310 coriolis: &[f64; 7],
311) -> [f64; 7] {
312 let gains = &options.gains;
313 let j = SMatrix::<f64, 6, 7>::from_column_slice(jacobian);
314 let kx = SVector::<f64, 6>::from(gains.cartesian_stiffness);
315 let kxd = SVector::<f64, 6>::from(gains.cartesian_damping);
316 let kq = SVector::<f64, 7>::from(gains.joint_stiffness);
317 let kqd = SVector::<f64, 7>::from(gains.joint_damping);
318 let error = SVector::<f64, 7>::from(*q_goal) - SVector::<f64, 7>::from(*q);
319 let mut velocity_error = -SVector::<f64, 7>::from(*dq);
320 if options.velocity_feedforward {
321 velocity_error += SVector::<f64, 7>::from(*dq_goal);
322 }
323 // Jᵀ Kx J e as Jᵀ (Kx ∘ J e): the diagonal gains never form a 7x7.
324 let mut tau = j.transpose()
325 * (kx.component_mul(&(j * error)) + kxd.component_mul(&(j * velocity_error)))
326 + SVector::<f64, 7>::from(*coriolis);
327 let projector = options
328 .project_joint_gains
329 .then(|| nullspace_projector(&j, options.ik.damping));
330 match projector {
331 // N is symmetric, so N Kq N e = N (Kq ∘ N e).
332 Some(n) => {
333 tau += n * (kq.component_mul(&(n * error)) + kqd.component_mul(&(n * velocity_error)))
334 }
335 None => tau += kq.component_mul(&error) + kqd.component_mul(&velocity_error),
336 }
337 let limits = &options.torque_limits;
338 std::array::from_fn(|i| tau[i].clamp(-limits[i], limits[i]))
339}
340
341/// `N = I - J⁺ J` with the damped pseudoinverse `J⁺ = Jᵀ (J Jᵀ + λ² I)⁻¹`. The 6x6 is
342/// positive definite for `λ > 0`, so the inverse only fails on non-finite input, where the
343/// identity (nothing projected) is the safe answer.
344fn nullspace_projector(j: &SMatrix<f64, 6, 7>, damping: f64) -> SMatrix<f64, 7, 7> {
345 let regularised = j * j.transpose() + SMatrix::<f64, 6, 6>::identity() * (damping * damping);
346 match regularised.try_inverse() {
347 Some(inverse) => SMatrix::<f64, 7, 7>::identity() - j.transpose() * inverse * j,
348 None => SMatrix::<f64, 7, 7>::identity(),
349 }
350}