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}