Skip to main content

franka/robot/target_control/
impedance.rs

1//! The torque backend's impedance law, the structure of DROID / polymetis
2//! `HybridJointImpedanceControl`, optionally 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 (g 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//! `g` is [`ImpedanceOptions::velocity_feedforward_gain`], and zero when
12//! [`ImpedanceOptions::velocity_feedforward`] is off: the damping then acts on the absolute
13//! velocity, which lags a moving goal by `Kd v / Kp` (DROID parity). That lag is what a leash on
14//! the command turns into a speed limit, so lowering `g` costs speed as well as ripple. The joint
15//! gains act unprojected, so the end effector feels `Kx` plus the joint springs reflected
16//! through `J` (30-60 % stiffer than `Kx` in translation at the ready pose, two to three
17//! times in rotation); with [`ImpedanceOptions::project_joint_gains`] they are confined to
18//! the nullspace, `N Kq N` with `N = I - J⁺ J`, and the end effector feels exactly `Kx`.
19
20use nalgebra::{SMatrix, SVector};
21
22use super::ik::IkOptions;
23use super::position::DEFAULT_JOINT_POSITION_MARGIN;
24use crate::error::{FrankaError, FrankaResult};
25
26/// The stiffness and damping of the law, all finite and non-negative.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct ImpedanceGains {
29    /// `Kx`, base frame, translation first: N/m, then Nm/rad.
30    pub cartesian_stiffness: [f64; 6],
31    /// `Kxd`, base frame, translation first: N s/m, then Nm s/rad.
32    pub cartesian_damping: [f64; 6],
33    /// `Kq`, per joint, Nm/rad.
34    pub joint_stiffness: [f64; 7],
35    /// `Kqd`, per joint, Nm s/rad.
36    pub joint_damping: [f64; 7],
37}
38
39impl ImpedanceGains {
40    /// The default for a Cartesian target: DROID's stiffness (a medium spring, 750 N/m) and
41    /// soft joint springs that settle the redundancy, with the Cartesian damping raised to
42    /// about a damping ratio of 0.8 from the arm's apparent masses at the ready pose (0.94 kg
43    /// along x and y, 3.9 kg along z; DROID's 37 N s/m leaves z at 0.34 and ringing).
44    pub const CARTESIAN: ImpedanceGains = ImpedanceGains {
45        cartesian_stiffness: [750.0, 750.0, 750.0, 15.0, 15.0, 15.0],
46        cartesian_damping: [50.0, 50.0, 90.0, 2.0, 2.0, 2.0],
47        joint_stiffness: [40.0, 30.0, 50.0, 25.0, 35.0, 25.0, 10.0],
48        joint_damping: [4.0, 6.0, 5.0, 5.0, 3.0, 2.0, 1.0],
49    };
50
51    /// DROID's production gains as they are, the parity preset: with
52    /// [`ImpedanceOptions::velocity_feedforward`] and
53    /// [`ImpedanceOptions::project_joint_gains`] both `false` the law is polymetis's
54    /// `HybridJointImpedanceControl`, for replaying DROID-trained policies.
55    pub const DROID: ImpedanceGains = ImpedanceGains {
56        cartesian_stiffness: [750.0, 750.0, 750.0, 15.0, 15.0, 15.0],
57        cartesian_damping: [37.0, 37.0, 37.0, 2.0, 2.0, 2.0],
58        joint_stiffness: [40.0, 30.0, 50.0, 25.0, 35.0, 25.0, 10.0],
59        joint_damping: [4.0, 6.0, 5.0, 5.0, 3.0, 2.0, 1.0],
60    };
61
62    /// A joint PD for a joint target, the gains of `examples/fer_joint_impedance.rs`.
63    pub const JOINT: ImpedanceGains = ImpedanceGains {
64        cartesian_stiffness: [0.0; 6],
65        cartesian_damping: [0.0; 6],
66        joint_stiffness: [600.0, 600.0, 600.0, 600.0, 250.0, 150.0, 50.0],
67        joint_damping: [50.0, 50.0, 50.0, 50.0, 30.0, 25.0, 15.0],
68    };
69
70    /// [`CARTESIAN`](Self::CARTESIAN) rescaled to a translational stiffness of `stiffness`
71    /// N/m: the six stiffnesses in proportion, the six dampings with the square root of the
72    /// same ratio, so every axis keeps the preset's damping ratio. The joint gains are the
73    /// preset's, untouched.
74    ///
75    /// This is the one place the rule lives. A Cartesian stiffness an operator can move is a
76    /// single number, and the law needs twelve; anything that turns the one into the twelve —
77    /// a node's configuration, a live retune — calls this rather than restating it.
78    pub fn scaled_cartesian(stiffness: f64) -> ImpedanceGains {
79        let preset = ImpedanceGains::CARTESIAN;
80        let ratio = stiffness / preset.cartesian_stiffness[0];
81        ImpedanceGains {
82            cartesian_stiffness: preset.cartesian_stiffness.map(|k| k * ratio),
83            cartesian_damping: preset.cartesian_damping.map(|d| d * ratio.sqrt()),
84            ..preset
85        }
86    }
87
88    /// # Errors
89    /// [`FrankaError::InvalidArgument`] if a gain is not finite or negative.
90    pub fn validate(&self) -> FrankaResult<()> {
91        let valid = |gains: &[f64]| gains.iter().all(|g| g.is_finite() && *g >= 0.0);
92        if valid(&self.cartesian_stiffness)
93            && valid(&self.cartesian_damping)
94            && valid(&self.joint_stiffness)
95            && valid(&self.joint_damping)
96        {
97            Ok(())
98        } else {
99            Err(FrankaError::InvalidArgument(format!(
100                "target control: impedance gains must be finite and non-negative, got {self:?}"
101            )))
102        }
103    }
104}
105
106/// How far the desired state may run ahead of the measured one. There is no echo of a torque
107/// command to re-anchor the generator on, so every cycle it is anchored on the measured state
108/// pulled toward the previous desired by at most this: while the arm follows, that is exactly
109/// the previous desired and nothing changes; held back (a hand, an obstacle, an unreachable
110/// target) the desired stays within the leash, the force on the arm is bounded by the
111/// stiffness times the leash, and on release the generator resumes from where the arm is
112/// under its budget. The leash bounds the force, not the release: the arm closes the gap it
113/// was held back by under the spring alone, which can outrun the budget. Once a stop holds,
114/// the leash pulls toward the held state instead, so an arm moved during the hold meets the
115/// same bound. The observer reports what the leash took off as `leash_alteration`.
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub struct Leash {
118    /// Cartesian interface, translation, m. Default 0.025. The leash bounds the spring, so
119    /// the force on whoever holds the arm plateaus: roughly 40-50 N at the default gains in an
120    /// FER's `O_F_ext_hat_K` estimate (750 N/m × 0.025 m = 18.75 N is the translational
121    /// spring alone with [`project_joint_gains`](ImpedanceOptions::project_joint_gains), not
122    /// what the robot's estimate shows). Gentle pushes read well under 30 N; a hard, fast
123    /// push briefly exceeds 60 N. Choose a collision force of 40 N for unattended runs and
124    /// 60 N or more where people push.
125    pub translation: f64,
126    /// Cartesian interface, rotation, rad. Default 0.15.
127    pub rotation: f64,
128    /// Joint interface, per joint, rad. Default 0.1. On the joint interface the torque clamp,
129    /// not the leash, bounds the torque: at the leash the [`JOINT`](ImpedanceGains::JOINT)
130    /// preset pulls 60 Nm on joints 1 to 4 (under their 86 Nm clamp) and 25 / 15 / 5 Nm on the
131    /// wrist, where joints 5 and 6 meet the 11.5 Nm clamp first; the examples' 20 Nm joint
132    /// collision threshold is reached at 0.033 rad on joints 1 to 4.
133    pub joint: f64,
134}
135
136impl Default for Leash {
137    fn default() -> Self {
138        Leash {
139            translation: 0.025,
140            rotation: 0.15,
141            joint: 0.1,
142        }
143    }
144}
145
146/// Options of [`Backend::Impedance`]; [`cartesian`](Self::cartesian) and
147/// [`joint`](Self::joint) are the documented starting points and the `with_*` methods change
148/// one field each.
149#[derive(Debug, Clone, Copy, PartialEq)]
150pub struct ImpedanceOptions {
151    /// The gains of the law.
152    pub gains: ImpedanceGains,
153    /// Per-joint clamp on the commanded torque, Nm, at most [`RATED_TORQUES`]. Default 86 on
154    /// joints 1-4, 11.5 on 5-7.
155    pub torque_limits: [f64; 7],
156    /// Cutoff, Hz, of the crate's first-order low-pass on the commanded torque;
157    /// [`MAX_CUTOFF_FREQUENCY`](crate::lowpass_filter::MAX_CUTOFF_FREQUENCY) switches it off.
158    /// Default 100.
159    pub cutoff_frequency: f64,
160    /// The configuration the Cartesian IK's nullspace bias pulls toward; `None`, the default,
161    /// is the configuration the loop starts in. Ignored by the joint interface.
162    pub posture: Option<[f64; 7]>,
163    /// The Cartesian interface's inverse kinematics. Ignored by the joint interface.
164    pub ik: IkOptions,
165    /// Whether the damping acts on the velocity *error*, `Kd (dq_goal - dq)`, with `dq_goal`
166    /// the goal's velocity, its own finite difference on both interfaces (of the boxed step on
167    /// the joint one, of the IK solution on the Cartesian one), so a goal held at the leash
168    /// feeds nothing forward; `false` damps the absolute velocity, DROID parity,
169    /// and a goal moving at `v` is then tracked `Kd v / Kp` behind. Default `false`: on real
170    /// arms the full feedforward shook them, since `dq_goal` is a finite difference (of the IK
171    /// solution on the Cartesian interface) and `Kd` carries its noise into the torque. Try
172    /// [`velocity_feedforward_cutoff`](Self::velocity_feedforward_cutoff) against that ripple
173    /// when switching it on.
174    pub velocity_feedforward: bool,
175    /// Weight of the goal velocity in the damping term when `velocity_feedforward` is on, in
176    /// [0, 1]. At 1 the damping acts on the velocity *error* `dq_goal - dq`, so a joint at steady
177    /// speed needs no lag; at 0 it acts on `-dq` alone and holding speed `v` costs a standing
178    /// error of `(Kqd / Kq) v`, which a leash then caps into a speed limit. Values between trade
179    /// that lag against how much of the goal's own ripple the law forwards. Default 1, so
180    /// switching the feedforward on means all of it.
181    pub velocity_feedforward_gain: f64,
182    /// Cutoff, Hz, of a first-order low-pass on `dq_goal` before it is fed forward;
183    /// [`MAX_CUTOFF_FREQUENCY`](crate::lowpass_filter::MAX_CUTOFF_FREQUENCY) switches it off.
184    /// The feedforward carries the joint reference's ripple into the torque, so bounding its
185    /// bandwidth keeps the lead without the ripple. Default off.
186    pub velocity_feedforward_cutoff: f64,
187    /// How far the desired state may run ahead of the measured one; see [`Leash`].
188    pub leash: Leash,
189    /// Whether the joint gains are projected into the nullspace of the Jacobian (`N Kq N`,
190    /// `N Kqd N`, `N = I - J⁺ J` with the damped pseudoinverse of [`IkOptions::damping`]), so
191    /// that the Cartesian gains alone are felt at the end effector; `false` leaves them
192    /// unprojected, DROID parity. For the Cartesian interface: projected joint gains hold the
193    /// nullspace only, so with zero Cartesian gains the end effector would be free. Default
194    /// `false`.
195    pub project_joint_gains: bool,
196    /// The fraction of the arm's joint velocity limits
197    /// ([`max_joint_velocity`](super::max_joint_velocity), which narrows toward the position
198    /// limits on the FR3 and is flat on the FER) the joint goal may move at: a goal
199    /// step that would move any joint faster is scaled down as a whole, keeping its direction.
200    /// A joint measured faster, or faster than [`FADE_BAND`](super::FADE_BAND) under
201    /// [`velocity_barrier_fraction`](Self::velocity_barrier_fraction) if that is lower, keeps
202    /// less of the law's torque along its motion. In (0, 1]. Default 0.7.
203    pub joint_velocity_fraction: f64,
204    /// The fraction of the joint velocity limits above which a *measured* joint velocity meets
205    /// the velocity barrier, [`VELOCITY_BARRIER_GAIN`](super::VELOCITY_BARRIER_GAIN) Nm per
206    /// rad/s of the excess opposing it, at most the joint's torque limit, and none of the law's
207    /// torque along it. In [`joint_velocity_fraction`, 1]. Default 0.85.
208    pub velocity_barrier_fraction: f64,
209    /// How far, rad, the joint goal keeps from the joint position limits. Toward a limit the goal
210    /// brakes to stop here along a braking profile built on half the FER's joint acceleration
211    /// limit, the FR3's published `ddq_dec` on the FR3, under its velocity limit; a joint
212    /// *measured* inside keeps less and less of the law's torque toward the limit over
213    /// [`POSITION_FADE_BAND`](super::POSITION_FADE_BAND), and beyond that meets a spring of
214    /// [`POSITION_BARRIER_STIFFNESS`](super::POSITION_BARRIER_STIFFNESS) and the velocity barrier
215    /// with its onset brought down to the same envelope. Joint targets and postures inside it
216    /// are refused. In [0.035, 0.5]. Default 0.05.
217    pub joint_position_margin: f64,
218}
219
220/// The DROID clamp, a Nm and a half under the rated torques.
221pub(super) const TORQUE_LIMITS: [f64; 7] = [86.0, 86.0, 86.0, 86.0, 11.5, 11.5, 11.5];
222
223/// The FR3's and FER's rated joint torques, Nm, the most `torque_limits` may allow.
224pub const RATED_TORQUES: [f64; 7] = [87.0, 87.0, 87.0, 87.0, 12.0, 12.0, 12.0];
225
226/// The lowest cutoff, Hz, [`ImpedanceOptions::velocity_feedforward_cutoff`] accepts. A first-order
227/// low-pass at 1 Hz still settles inside a second; below it the filter is a near-integrator
228/// holding a velocity the goal no longer has for longer than a session runs.
229pub const MIN_FEEDFORWARD_CUTOFF: f64 = 1.0;
230
231impl ImpedanceOptions {
232    /// The defaults of the Cartesian interface: [`ImpedanceGains::CARTESIAN`].
233    pub fn cartesian() -> Self {
234        Self::with_default_gains(ImpedanceGains::CARTESIAN)
235    }
236
237    /// The defaults of the joint interface: [`ImpedanceGains::JOINT`].
238    pub fn joint() -> Self {
239        Self::with_default_gains(ImpedanceGains::JOINT)
240    }
241
242    fn with_default_gains(gains: ImpedanceGains) -> Self {
243        ImpedanceOptions {
244            gains,
245            torque_limits: TORQUE_LIMITS,
246            cutoff_frequency: 100.0,
247            posture: None,
248            ik: IkOptions::default(),
249            velocity_feedforward: false,
250            velocity_feedforward_gain: 1.0,
251            velocity_feedforward_cutoff: crate::lowpass_filter::MAX_CUTOFF_FREQUENCY,
252            leash: Leash::default(),
253            project_joint_gains: false,
254            joint_velocity_fraction: 0.7,
255            velocity_barrier_fraction: 0.85,
256            joint_position_margin: DEFAULT_JOINT_POSITION_MARGIN,
257        }
258    }
259
260    /// Sets the gains.
261    pub fn with_gains(mut self, gains: ImpedanceGains) -> Self {
262        self.gains = gains;
263        self
264    }
265
266    /// Sets the per-joint torque clamp, Nm.
267    pub fn with_torque_limits(mut self, limits: [f64; 7]) -> Self {
268        self.torque_limits = limits;
269        self
270    }
271
272    /// Sets the torque low-pass cutoff, Hz.
273    pub fn with_cutoff_frequency(mut self, hertz: f64) -> Self {
274        self.cutoff_frequency = hertz;
275        self
276    }
277
278    /// Sets the IK's posture (`None`: the start configuration).
279    pub fn with_posture(mut self, posture: Option<[f64; 7]>) -> Self {
280        self.posture = posture;
281        self
282    }
283
284    /// Sets the IK options.
285    pub fn with_ik(mut self, ik: IkOptions) -> Self {
286        self.ik = ik;
287        self
288    }
289
290    /// Switches the damping between the velocity error (`true`) and the absolute velocity.
291    pub fn with_velocity_feedforward(mut self, on: bool) -> Self {
292        self.velocity_feedforward = on;
293        self
294    }
295
296    /// Sets [`velocity_feedforward_gain`](Self::velocity_feedforward_gain).
297    pub fn with_velocity_feedforward_gain(mut self, gain: f64) -> Self {
298        self.velocity_feedforward_gain = gain;
299        self
300    }
301
302    /// Sets [`velocity_feedforward_cutoff`](Self::velocity_feedforward_cutoff).
303    pub fn with_velocity_feedforward_cutoff(mut self, hertz: f64) -> Self {
304        self.velocity_feedforward_cutoff = hertz;
305        self
306    }
307
308    /// Sets the leash.
309    pub fn with_leash(mut self, leash: Leash) -> Self {
310        self.leash = leash;
311        self
312    }
313
314    /// Switches the projection of the joint gains into the Jacobian's nullspace.
315    pub fn with_project_joint_gains(mut self, on: bool) -> Self {
316        self.project_joint_gains = on;
317        self
318    }
319
320    /// Sets the fraction of the joint velocity limits the goal may move at.
321    pub fn with_joint_velocity_fraction(mut self, fraction: f64) -> Self {
322        self.joint_velocity_fraction = fraction;
323        self
324    }
325
326    /// Sets the fraction of the joint velocity limits the velocity barrier starts at.
327    pub fn with_velocity_barrier_fraction(mut self, fraction: f64) -> Self {
328        self.velocity_barrier_fraction = fraction;
329        self
330    }
331
332    /// Sets the distance, rad, the joint goal keeps from the joint position limits.
333    pub fn with_joint_position_margin(mut self, margin: f64) -> Self {
334        self.joint_position_margin = margin;
335        self
336    }
337
338    /// Checks the options without starting anything.
339    ///
340    /// # Errors
341    /// [`FrankaError::InvalidArgument`] naming the field: a gain that is not finite or
342    /// negative, a torque limit, cutoff frequency or leash that is not finite and positive, a
343    /// posture that is not finite, a `joint_velocity_fraction` outside (0, 1] or a
344    /// `velocity_barrier_fraction` outside [`joint_velocity_fraction`, 1], a
345    /// `joint_position_margin` outside [0.035, 0.5], or invalid [`IkOptions`].
346    pub fn validate(&self) -> FrankaResult<()> {
347        self.gains.validate()?;
348        let positive = |x: f64| x.is_finite() && x > 0.0;
349        let rated = self.torque_limits.iter().zip(&RATED_TORQUES);
350        if !rated.into_iter().all(|(t, r)| positive(*t) && t <= r) {
351            return Err(FrankaError::InvalidArgument(format!(
352                "target control: torque_limits must be positive and at most the rated \
353                 {RATED_TORQUES:?} Nm, got {:?}",
354                self.torque_limits
355            )));
356        }
357        if !positive(self.cutoff_frequency) {
358            return Err(FrankaError::InvalidArgument(format!(
359                "target control: cutoff_frequency must be finite and positive, got {}",
360                self.cutoff_frequency
361            )));
362        }
363        // Floored, not merely positive: below a hertz the filter is a near-integrator that holds
364        // a velocity the goal no longer has, for longer than any session lasts.
365        if !positive(self.velocity_feedforward_cutoff)
366            || self.velocity_feedforward_cutoff < MIN_FEEDFORWARD_CUTOFF
367        {
368            return Err(FrankaError::InvalidArgument(format!(
369                "target control: velocity_feedforward_cutoff must be at least \
370                 {MIN_FEEDFORWARD_CUTOFF} Hz, got {}",
371                self.velocity_feedforward_cutoff
372            )));
373        }
374        if !self.velocity_feedforward_gain.is_finite()
375            || !(0.0..=1.0).contains(&self.velocity_feedforward_gain)
376        {
377            return Err(FrankaError::InvalidArgument(format!(
378                "target control: velocity_feedforward_gain must be within [0, 1], got {}",
379                self.velocity_feedforward_gain
380            )));
381        }
382        let Leash {
383            translation,
384            rotation,
385            joint,
386        } = self.leash;
387        if !(positive(translation) && positive(rotation) && positive(joint)) {
388            return Err(FrankaError::InvalidArgument(format!(
389                "target control: leash must be finite and positive, got {:?}",
390                self.leash
391            )));
392        }
393        if let Some(posture) = self.posture {
394            if posture.iter().any(|q| !q.is_finite()) {
395                return Err(FrankaError::InvalidArgument(format!(
396                    "target control: posture must be finite, got {posture:?}"
397                )));
398            }
399        }
400        let (cap, barrier) = (self.joint_velocity_fraction, self.velocity_barrier_fraction);
401        let fraction = |x: f64| x > 0.0 && x <= 1.0;
402        if !fraction(cap) {
403            return Err(FrankaError::InvalidArgument(format!(
404                "target control: joint_velocity_fraction must be in (0, 1], got {cap}"
405            )));
406        }
407        if !(cap..=1.0).contains(&barrier) {
408            return Err(FrankaError::InvalidArgument(format!(
409                "target control: velocity_barrier_fraction must be in \
410                 [joint_velocity_fraction, 1] = [{cap}, 1], got {barrier}"
411            )));
412        }
413        let margin = self.joint_position_margin;
414        if !(0.035..=0.5).contains(&margin) {
415            return Err(FrankaError::InvalidArgument(format!(
416                "target control: joint_position_margin must be in [0.035, 0.5] rad, got {margin}"
417            )));
418        }
419        self.ik.validate()
420    }
421}
422
423/// What tracks the target: the robot's controller or the crate's torques.
424#[derive(Debug, Clone, Copy, PartialEq)]
425#[allow(clippy::large_enum_variant)] // options, copied once at the start; boxing would cost `Copy`
426pub enum Backend {
427    /// The robot's own impedance controller tracks a pose / joint-position stream.
428    RobotController,
429    /// Torques from the crate's impedance law (`control_torques`).
430    Impedance(ImpedanceOptions),
431}
432
433/// The torque backend's law, offline:
434///
435/// ```text
436/// Kp  = Jᵀ Kx J + diag(Kq)        Kd = Jᵀ Kxd J + diag(Kqd)
437/// tau = Kp (q_goal - q) + Kd (g dq_goal - dq) + coriolis      clamped to torque_limits
438///
439/// g = velocity_feedforward_gain, 0 when velocity_feedforward is off
440/// ```
441///
442/// with the gains of `options`; `dq_goal` is taken as zero without
443/// [`velocity_feedforward`](ImpedanceOptions::velocity_feedforward), and the joint gains are
444/// confined to the nullspace of `J` with
445/// [`project_joint_gains`](ImpedanceOptions::project_joint_gains). `jacobian` is the zero
446/// Jacobian at the end effector in the measured configuration, column-major 6x7 as
447/// [`Model::zero_jacobian`](crate::Model::zero_jacobian) returns it.
448///
449/// The torque loop fades out this torque along a joint's motion as the joint speeds from the
450/// fade's start ([`FADE_BAND`](super::FADE_BAND)) to the barrier's onset, adds the velocity barrier
451/// ([`VELOCITY_BARRIER_GAIN`](super::VELOCITY_BARRIER_GAIN)) and clamps the sum to
452/// `torque_limits` again.
453pub fn impedance_torques(
454    options: &ImpedanceOptions,
455    jacobian: &[f64; 42],
456    q_goal: &[f64; 7],
457    dq_goal: &[f64; 7],
458    q: &[f64; 7],
459    dq: &[f64; 7],
460    coriolis: &[f64; 7],
461) -> [f64; 7] {
462    let gains = &options.gains;
463    let j = SMatrix::<f64, 6, 7>::from_column_slice(jacobian);
464    let kx = SVector::<f64, 6>::from(gains.cartesian_stiffness);
465    let kxd = SVector::<f64, 6>::from(gains.cartesian_damping);
466    let kq = SVector::<f64, 7>::from(gains.joint_stiffness);
467    let kqd = SVector::<f64, 7>::from(gains.joint_damping);
468    let error = SVector::<f64, 7>::from(*q_goal) - SVector::<f64, 7>::from(*q);
469    let mut velocity_error = -SVector::<f64, 7>::from(*dq);
470    if options.velocity_feedforward {
471        velocity_error += SVector::<f64, 7>::from(*dq_goal) * options.velocity_feedforward_gain;
472    }
473    // Jᵀ Kx J e as Jᵀ (Kx ∘ J e): the diagonal gains never form a 7x7.
474    let mut tau = j.transpose()
475        * (kx.component_mul(&(j * error)) + kxd.component_mul(&(j * velocity_error)))
476        + SVector::<f64, 7>::from(*coriolis);
477    let projector = options
478        .project_joint_gains
479        .then(|| nullspace_projector(&j, options.ik.damping));
480    match projector {
481        // N is symmetric, so N Kq N e = N (Kq ∘ N e).
482        Some(n) => {
483            tau += n * (kq.component_mul(&(n * error)) + kqd.component_mul(&(n * velocity_error)))
484        }
485        None => tau += kq.component_mul(&error) + kqd.component_mul(&velocity_error),
486    }
487    let limits = &options.torque_limits;
488    std::array::from_fn(|i| tau[i].clamp(-limits[i], limits[i]))
489}
490
491/// `N = I - J⁺ J` with the damped pseudoinverse `J⁺ = Jᵀ (J Jᵀ + λ² I)⁻¹`. The 6x6 is
492/// positive definite for `λ > 0`, so the inverse only fails on non-finite input, where the
493/// identity (nothing projected) is the safe answer.
494fn nullspace_projector(j: &SMatrix<f64, 6, 7>, damping: f64) -> SMatrix<f64, 7, 7> {
495    let regularised = j * j.transpose() + SMatrix::<f64, 6, 6>::identity() * (damping * damping);
496    match regularised.try_inverse() {
497        Some(inverse) => SMatrix::<f64, 7, 7>::identity() - j.transpose() * inverse * j,
498        None => SMatrix::<f64, 7, 7>::identity(),
499    }
500}