Skip to main content

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}