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}