Skip to main content

franka/robot/target_control/tuning/
mod.rs

1//! What a running [target control](super) session will accept a change to, and how far.
2//!
3//! [`LiveTuning`] is the set of control-law and plan parameters an operator may move while the
4//! arm is engaged; [`LiveTuning::BOUNDS`] is how far each of them may be moved. The bounds are
5//! a *second, tighter* gate than [`ImpedanceOptions::validate`](super::ImpedanceOptions::validate)
6//! and [`IkOptions::validate`](super::IkOptions::validate), which answer a different question:
7//! `validate` asks whether a session can be constructed at all and must stay permissive enough
8//! for every caller of the crate, while `BOUNDS` asks whether a *running* session may be moved
9//! somewhere by hand. Widening `validate` to carry this policy would impose it on every
10//! consumer; narrowing this table to `validate`'s permissiveness would let a slider reach 1e9.
11//!
12//! `BOUNDS` is the only description of the limits: it is the table the clamp itself walks, so
13//! a caller that publishes it — a schema for a panel, a check in another process — cannot
14//! drift from what is enforced. There must not be a second copy of these numbers anywhere.
15//! One limit does not fit a per-field row, the floor a joint spring puts under its own
16//! damping, and it has a function of its own ([`LiveTuning::joint_damping_floor`]); publishing
17//! the table means publishing that rule with it.
18//!
19//! [`LiveTuning::apply_update`] belongs to the arm thread. It allocates — a `Vec` of the
20//! bounds that bit, a message on a refusal — and must not be called from the 1 kHz loop, which
21//! only ever reads a `LiveTuning` that has already been through it.
22//!
23//! Every field is an `f64` and the struct is [`Copy`], so a rejected update costs nothing to
24//! discard and the whole set packs into a word array ([`LiveTuning::to_words`]) with no
25//! rounding and no layout question. The table itself is in [`bounds`].
26//!
27//! What the loop does with an accepted set is in [`slew`]: it seeds its own copy from the
28//! session's options ([`LiveTuning::from_options`]), moves that copy toward the published
29//! targets a cycle at a time ([`LiveTuning::slew_toward`]), and writes it into the law through
30//! [`LiveTuning::gains`]. The published set is therefore a set of *targets*, and a cycle that
31//! misses a read of it loses one cycle of the crossing, not a change.
32
33use super::{ImpedanceGains, ImpedanceOptions};
34use crate::error::{FrankaError, FrankaResult};
35use crate::otg::OtgLimits;
36
37mod bounds;
38mod slew;
39
40pub use bounds::{FieldBound, TuningDanger, TuningPolicy, MIN_JOINT_DAMPING_RATIO, SLEW_TAU};
41pub use slew::SlewGains;
42
43/// The targets a running session will accept. These are targets, not the values in force: the
44/// loop keeps its own current values and moves them here under each field's
45/// [`policy`](FieldBound::policy).
46///
47/// `cartesian_stiffness` is one number here and twelve in the law. The operator sets a single
48/// translational stiffness; whoever owns the preset derives the six stiffnesses and six
49/// dampings from it, which is what holds the damping ratio the preset was tuned for. Neither
50/// the panel nor this struct should know that rule, and it is not duplicated here.
51///
52/// Deliberately *not* `#[non_exhaustive]`, unlike [`FieldBound`] and [`TuningUpdate`]: a
53/// consumer builds one of these — it is the session's seed, and it is what an update is
54/// validated against. Closing the struct expression would leave the positional
55/// [`from_words`](Self::from_words) as the only way in, which is the mis-indexing hazard this
56/// module's tests exist to catch, promoted to the sole public constructor.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct LiveTuning {
59    /// `Kq`, per joint, Nm/rad.
60    pub joint_stiffness: [f64; 7],
61    /// `Kqd`, per joint, Nm s/rad.
62    pub joint_damping: [f64; 7],
63    /// The translational Cartesian stiffness the six-axis `Kx` and `Kxd` are derived from, N/m.
64    pub cartesian_stiffness: f64,
65    /// `λ` of the IK's damped least squares.
66    pub ik_damping: f64,
67    /// Gain, 1/s, of the IK's nullspace pull toward the posture.
68    pub ik_nullspace_gain: f64,
69    /// The weight on the goal velocity in the damping term, within [0, 1]; zero is the law
70    /// with the feedforward off.
71    pub velocity_feedforward_gain: f64,
72    /// The low-pass cutoff on that goal velocity, Hz; the maximum is the filter off.
73    pub velocity_feedforward_cutoff: f64,
74    /// The translational budget as a norm: m/s, m/s^2, m/s^3.
75    pub budget: [f64; 3],
76    /// The rotational budget as a norm: rad/s, rad/s^2, rad/s^3.
77    pub rotation_budget: [f64; 3],
78}
79
80// Trivially copyable, which is what makes the scratch copy of an update free and the word
81// packing a pure bit-pun. Rust has no `is_trivially_copyable`; this is the equivalent.
82const _: fn() = || {
83    fn assert<T: Copy + Send + Sync + 'static>() {}
84    assert::<LiveTuning>();
85};
86// One word per scalar: adding a field without extending `WORDS` fails to compile.
87const _: () = assert!(std::mem::size_of::<LiveTuning>() == LiveTuning::WORDS * 8);
88
89impl LiveTuning {
90    /// The number of scalars, and the length of both [`to_words`](Self::to_words) and
91    /// [`BOUNDS`](Self::BOUNDS).
92    pub const WORDS: usize = 25;
93
94    /// The scalars in the order of [`BOUNDS`](Self::BOUNDS).
95    pub fn to_words(&self) -> [f64; Self::WORDS] {
96        let mut words = [0.0; Self::WORDS];
97        words[..7].copy_from_slice(&self.joint_stiffness);
98        words[7..14].copy_from_slice(&self.joint_damping);
99        words[14] = self.cartesian_stiffness;
100        words[15] = self.ik_damping;
101        words[16] = self.ik_nullspace_gain;
102        words[17] = self.velocity_feedforward_gain;
103        words[18] = self.velocity_feedforward_cutoff;
104        words[19..22].copy_from_slice(&self.budget);
105        words[22..25].copy_from_slice(&self.rotation_budget);
106        words
107    }
108
109    /// The inverse of [`to_words`](Self::to_words).
110    pub fn from_words(words: &[f64; Self::WORDS]) -> LiveTuning {
111        LiveTuning {
112            joint_stiffness: std::array::from_fn(|i| words[i]),
113            joint_damping: std::array::from_fn(|i| words[7 + i]),
114            cartesian_stiffness: words[14],
115            ik_damping: words[15],
116            ik_nullspace_gain: words[16],
117            velocity_feedforward_gain: words[17],
118            velocity_feedforward_cutoff: words[18],
119            budget: std::array::from_fn(|i| words[19 + i]),
120            rotation_budget: std::array::from_fn(|i| words[22 + i]),
121        }
122    }
123
124    /// A session's seed: the values `impedance` and the two budgets are already running with,
125    /// which is what the loop slews *from*, so that the first change an operator makes starts
126    /// where the arm is and not at a default.
127    ///
128    /// The budgets are the arguments'; of what `impedance` supplies, two words are not one of
129    /// its fields read straight off.
130    ///
131    /// `cartesian_stiffness` is the first axis of
132    /// [`ImpedanceGains::cartesian_stiffness`](super::ImpedanceGains::cartesian_stiffness),
133    /// because that is the number
134    /// [`scaled_cartesian`](super::ImpedanceGains::scaled_cartesian) scales, and
135    /// [`gains`](Self::gains) derives all twelve Cartesian words back from it. A gains set that
136    /// is *not* a scaled [`CARTESIAN`](super::ImpedanceGains::CARTESIAN) therefore loses
137    /// whatever else it changed the moment anything is retuned: seeded from
138    /// [`DROID`](super::ImpedanceGains::DROID), the first retune moves the translational
139    /// damping from DROID's 37 to the preset's 50 and 90. That is the parity preset's price for
140    /// one slider, and the reason `gains` is the only mapping.
141    ///
142    /// `velocity_feedforward_gain` is zero when
143    /// [`velocity_feedforward`](super::ImpedanceOptions::velocity_feedforward) is off, which is
144    /// the same law: the weighted goal velocity is all the switch gates. Carrying the switch in
145    /// the gain is what lets it be crossed continuously rather than thrown.
146    pub fn from_options(
147        impedance: &ImpedanceOptions,
148        budget: OtgLimits,
149        rotation_budget: OtgLimits,
150    ) -> LiveTuning {
151        let norm = |limits: OtgLimits| {
152            [
153                limits.max_velocity,
154                limits.max_acceleration,
155                limits.max_jerk,
156            ]
157        };
158        LiveTuning {
159            joint_stiffness: impedance.gains.joint_stiffness,
160            joint_damping: impedance.gains.joint_damping,
161            cartesian_stiffness: impedance.gains.cartesian_stiffness[0],
162            ik_damping: impedance.ik.damping,
163            ik_nullspace_gain: impedance.ik.nullspace_gain,
164            velocity_feedforward_gain: if impedance.velocity_feedforward {
165                impedance.velocity_feedforward_gain
166            } else {
167                0.0
168            },
169            velocity_feedforward_cutoff: impedance.velocity_feedforward_cutoff,
170            budget: norm(budget),
171            rotation_budget: norm(rotation_budget),
172        }
173    }
174
175    /// The two budgets as [`OtgLimits`], which is the form a generator takes them in: the
176    /// other side of [`from_options`](Self::from_options)'s reading of them, and the only
177    /// mapping, so that nobody has to remember which of the three words is which.
178    ///
179    /// Still norms, as the fields are. Whoever hands them to a generator owes them the
180    /// per-axis division the [OTG's first rule](crate::otg#in-a-control-loop) asks for.
181    pub fn budgets(&self) -> (OtgLimits, OtgLimits) {
182        let limits = |[max_velocity, max_acceleration, max_jerk]: [f64; 3]| OtgLimits {
183            max_velocity,
184            max_acceleration,
185            max_jerk,
186        };
187        (limits(self.budget), limits(self.rotation_budget))
188    }
189
190    /// The law's gains these targets stand for: the joint springs as they are, and the six
191    /// Cartesian stiffnesses and six dampings
192    /// [`scaled_cartesian`](super::ImpedanceGains::scaled_cartesian) derives from the one
193    /// translational stiffness, which is where that rule lives and the only place it may.
194    ///
195    /// The other side of [`from_options`](Self::from_options)'s reading of the gains, and the
196    /// mapping the loop applies every cycle it moves.
197    pub fn gains(&self) -> ImpedanceGains {
198        ImpedanceGains {
199            joint_stiffness: self.joint_stiffness,
200            joint_damping: self.joint_damping,
201            ..ImpedanceGains::scaled_cartesian(self.cartesian_stiffness)
202        }
203    }
204
205    /// Applies every field `update` carries and returns the bounds that had to clamp one,
206    /// leaving `self` at the accepted, clamped values. Runs on the arm thread: it allocates,
207    /// and must not be called from the loop.
208    ///
209    /// All or nothing: the update is built on a scratch copy, and if any value it carries is
210    /// rejected — not finite, or zero where zero means something else
211    /// ([`reject_non_positive`](FieldBound::reject_non_positive)) — `self` is left bit for bit
212    /// as it was and nothing is clamped.
213    ///
214    /// A field the update does not carry keeps its current value and is not re-checked, so a
215    /// partial update is a real partial update and cannot quietly retune a field nobody moved.
216    /// A value seeded outside [`BOUNDS`](Self::BOUNDS) therefore survives until someone moves
217    /// that field; whoever builds the seed has to check it against `BOUNDS` first, and the way
218    /// to do that is this method with an update carrying every field, never a second copy of
219    /// the table.
220    ///
221    /// The pair rule is the one thing that reaches past the carried fields. An update carrying
222    /// `joint_stiffness` raises any of the seven `joint_damping` values that sit under
223    /// [`joint_damping_floor`](Self::joint_damping_floor) of the spring they damp — the pair,
224    /// not either half, is what has to stay damped. A damping the update does not carry is
225    /// only ever *raised*: its ceiling is not applied, because lowering a field nobody moved is
226    /// the thing this method exists not to do. The floor is read off the stiffness `BOUNDS`
227    /// allows rather than the raw seeded one, so a seed the table would never have accepted
228    /// cannot push the floor above the ceiling.
229    ///
230    /// Clamping is deliberate rather than a refusal, and the clamped bounds come back so that
231    /// a caller can show the value it will actually get; that echo is also what would expose a
232    /// caller whose own idea of the limits has drifted from `BOUNDS`. Read the value out of
233    /// `self`, not out of the bound: where the pair rule bit, the value in force is the floor
234    /// and `FieldBound::min` is still zero.
235    ///
236    /// # Errors
237    /// [`FrankaError::InvalidArgument`] naming the field, with `self` unchanged.
238    pub fn apply_update(
239        &mut self,
240        update: &TuningUpdate,
241    ) -> FrankaResult<Vec<&'static FieldBound>> {
242        let carried = update.carried();
243        let mut words = update.overlay(self).to_words();
244        for word in (0..Self::WORDS).filter(|w| carried[*w]) {
245            let (value, bound) = (words[word], &Self::BOUNDS[word]);
246            if !value.is_finite() {
247                return Err(reject(bound, value, "must be finite"));
248            }
249            if bound.reject_non_positive && value <= 0.0 {
250                return Err(reject(bound, value, "must be positive"));
251            }
252        }
253        // An uncarried word was never checked, so anything at all can reach the clamp; `snap`
254        // leaves a value it cannot order alone. The stiffnesses go first so the damping floor
255        // is read off what will be in force.
256        let mut clamped = Vec::new();
257        for joint in (0..7).filter(|j| carried[*j]) {
258            let bound = &Self::BOUNDS[joint];
259            clamped.extend(snap(&mut words[joint], bound, bound.min, bound.max));
260        }
261        for joint in (0..7).filter(|j| carried[*j] || carried[7 + *j]) {
262            let spring = &Self::BOUNDS[joint];
263            // Off the bounded stiffness: an uncarried one is unclamped and a seeded 60 000
264            // would otherwise put the floor above the damping's own ceiling.
265            let floor = Self::joint_damping_floor(words[joint].clamp(spring.min, spring.max));
266            let bound = &Self::BOUNDS[7 + joint];
267            let ceiling = if carried[7 + joint] {
268                bound.max
269            } else {
270                f64::INFINITY
271            };
272            clamped.extend(snap(
273                &mut words[7 + joint],
274                bound,
275                floor.max(bound.min),
276                ceiling,
277            ));
278        }
279        for word in (14..Self::WORDS).filter(|w| carried[*w]) {
280            let bound = &Self::BOUNDS[word];
281            clamped.extend(snap(&mut words[word], bound, bound.min, bound.max));
282        }
283        *self = Self::from_words(&words);
284        Ok(clamped)
285    }
286}
287
288/// `word` into `[low, high]`, naming `bound` if that moved it. Callers keep `low <= high`, and
289/// a NaN comes back untouched and unreported: an uncarried word is nobody's request to change.
290fn snap(
291    word: &mut f64,
292    bound: &'static FieldBound,
293    low: f64,
294    high: f64,
295) -> Option<&'static FieldBound> {
296    let limited = word.clamp(low, high);
297    if limited.to_bits() == word.to_bits() {
298        None
299    } else {
300        *word = limited;
301        Some(bound)
302    }
303}
304
305fn reject(bound: &FieldBound, value: f64, what: &str) -> FrankaError {
306    let index = match bound.index {
307        Some(i) => format!("[{i}]"),
308        None => String::new(),
309    };
310    FrankaError::InvalidArgument(format!(
311        "target control: tuning {}{index} {what}, got {value}",
312        bound.name
313    ))
314}
315
316/// A partial change to a [`LiveTuning`]: every field left `None` keeps the value it has.
317///
318/// `#[non_exhaustive]`: outside this crate there is no struct expression for it at all —
319/// functional update syntax included — so start from [`Default`] and assign what the update
320/// carries.
321///
322/// ```
323/// use franka::robot::target_control::TuningUpdate;
324///
325/// let mut update = TuningUpdate::default();
326/// update.ik_damping = Some(0.2);
327///
328/// assert_eq!(update.ik_damping, Some(0.2));
329/// assert_eq!(update.budget, None);
330/// ```
331///
332/// The form that reads as though it should work does not, and this is the gate that says so:
333///
334/// ```compile_fail,E0639
335/// use franka::robot::target_control::TuningUpdate;
336///
337/// let update = TuningUpdate {
338///     ik_damping: Some(0.2),
339///     ..TuningUpdate::default()
340/// };
341/// ```
342///
343/// The attribute buys the freedom to stop *reading* a field, not the freedom to add one: a new
344/// `TuningUpdate` field needs the matching [`LiveTuning`] field, which moves
345/// [`WORDS`](LiveTuning::WORDS) and the signature of [`to_words`](LiveTuning::to_words), and
346/// that is a major version either way.
347#[derive(Debug, Clone, Copy, Default, PartialEq)]
348#[non_exhaustive]
349pub struct TuningUpdate {
350    /// See [`LiveTuning::joint_stiffness`].
351    pub joint_stiffness: Option<[f64; 7]>,
352    /// See [`LiveTuning::joint_damping`].
353    pub joint_damping: Option<[f64; 7]>,
354    /// See [`LiveTuning::cartesian_stiffness`].
355    pub cartesian_stiffness: Option<f64>,
356    /// See [`LiveTuning::ik_damping`].
357    pub ik_damping: Option<f64>,
358    /// See [`LiveTuning::ik_nullspace_gain`].
359    pub ik_nullspace_gain: Option<f64>,
360    /// See [`LiveTuning::velocity_feedforward_gain`].
361    pub velocity_feedforward_gain: Option<f64>,
362    /// See [`LiveTuning::velocity_feedforward_cutoff`].
363    pub velocity_feedforward_cutoff: Option<f64>,
364    /// See [`LiveTuning::budget`].
365    pub budget: Option<[f64; 3]>,
366    /// See [`LiveTuning::rotation_budget`].
367    pub rotation_budget: Option<[f64; 3]>,
368}
369
370impl TuningUpdate {
371    /// Which words of [`LiveTuning::to_words`] this update carries.
372    fn carried(&self) -> [bool; LiveTuning::WORDS] {
373        let mut carried = [false; LiveTuning::WORDS];
374        carried[..7].fill(self.joint_stiffness.is_some());
375        carried[7..14].fill(self.joint_damping.is_some());
376        carried[14] = self.cartesian_stiffness.is_some();
377        carried[15] = self.ik_damping.is_some();
378        carried[16] = self.ik_nullspace_gain.is_some();
379        carried[17] = self.velocity_feedforward_gain.is_some();
380        carried[18] = self.velocity_feedforward_cutoff.is_some();
381        carried[19..22].fill(self.budget.is_some());
382        carried[22..25].fill(self.rotation_budget.is_some());
383        carried
384    }
385
386    /// `current` with every field this update carries written over it: the scratch copy.
387    fn overlay(&self, current: &LiveTuning) -> LiveTuning {
388        LiveTuning {
389            joint_stiffness: self.joint_stiffness.unwrap_or(current.joint_stiffness),
390            joint_damping: self.joint_damping.unwrap_or(current.joint_damping),
391            cartesian_stiffness: self
392                .cartesian_stiffness
393                .unwrap_or(current.cartesian_stiffness),
394            ik_damping: self.ik_damping.unwrap_or(current.ik_damping),
395            ik_nullspace_gain: self.ik_nullspace_gain.unwrap_or(current.ik_nullspace_gain),
396            velocity_feedforward_gain: self
397                .velocity_feedforward_gain
398                .unwrap_or(current.velocity_feedforward_gain),
399            velocity_feedforward_cutoff: self
400                .velocity_feedforward_cutoff
401                .unwrap_or(current.velocity_feedforward_cutoff),
402            budget: self.budget.unwrap_or(current.budget),
403            rotation_budget: self.rotation_budget.unwrap_or(current.rotation_budget),
404        }
405    }
406}