franka/robot/target_control/tuning/bounds.rs
1//! How far each scalar of a [`LiveTuning`] may be moved, and how the loop is to move it.
2//!
3//! This table is the gate, not a description of one: [`LiveTuning::apply_update`] clamps by
4//! walking it, so a caller that serialises it into a schema is serialising the code that runs.
5
6use super::super::MIN_FEEDFORWARD_CUTOFF;
7use super::LiveTuning;
8
9/// How the loop is to move an accepted value into effect. Carried here rather than in the
10/// loop so that a published schema can explain a slider's behaviour without a second table.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum TuningPolicy {
13 /// First-order slew toward the target over `tau` seconds. The parameter multiplies a
14 /// generally-nonzero state, so a step in it is a step in the torque.
15 Slew {
16 /// The time constant, s.
17 tau: f64,
18 },
19 /// Applied whole on the cycle it arrives: either nothing nonzero is multiplied, or the
20 /// product is bounded by the ripple the parameter exists to shape.
21 Step,
22 /// Raised on the cycle it arrives, lowered no faster than the next order's limit: the
23 /// budget's velocity at the acceleration in force, its acceleration at the jerk.
24 ///
25 /// The generator re-plans continuously into a *wider* budget -- it stores no plan, only
26 /// `(p, v, a, target, limits)` -- but it clamps its stored velocity and acceleration into a
27 /// *narrower* one, on the next cycle's re-anchoring and again at the end of every step
28 /// ([`Otg::set_limits`](crate::otg::Otg::set_limits) says where). A velocity truncated by
29 /// `dv` in one cycle is `dv / dt` of acceleration in the command, which is an impulse
30 /// rather than a re-plan. Descending no faster than the next order holds each clamp to one
31 /// cycle of that order, which is the generator's own bound.
32 ///
33 /// [`LiveTuning::slew_toward`] runs the gate on the budget as a norm, and whoever hands the
34 /// generator its per-axis limits scales the gated value. That is the same walk: the scaling
35 /// is one positive factor per axis, so it carries the rate with it.
36 StepUpGateDown {
37 /// Which word's value in force the descent is measured at: the index into
38 /// [`LiveTuning::BOUNDS`] of the next order of the same budget, whose unit is this
39 /// word's per second. Named here rather than left to the reader as "the next word",
40 /// because a caller that watches a descent -- [`descent`](Self::descent), a published
41 /// schema, a panel drawing the ramp -- would otherwise have to know that rule too, and
42 /// then there would be two of it.
43 rate_word: usize,
44 },
45}
46
47impl TuningPolicy {
48 /// Which word of [`LiveTuning::to_words`] holds this one's descent rate, for the gated
49 /// words; `None` for every other policy, which has no ramp.
50 pub fn rate_word(self) -> Option<usize> {
51 match self {
52 TuningPolicy::StepUpGateDown { rate_word } => Some(rate_word),
53 _ => None,
54 }
55 }
56}
57
58/// The slew time constant of every slewed field, s.
59pub const SLEW_TAU: f64 = 0.3;
60
61/// One scalar of [`LiveTuning`]: what it is called, what it means, and how far it may go.
62///
63/// Read, never built, outside this crate: `#[non_exhaustive]` so that the fields
64/// a later parameter needs can be added without a major version.
65#[derive(Debug, Clone, Copy, PartialEq)]
66#[non_exhaustive]
67pub struct FieldBound {
68 /// The field's name, as a caller's schema and an update name it.
69 pub name: &'static str,
70 /// The element, for the array fields; `None` for the scalars.
71 pub index: Option<usize>,
72 /// The smallest value left in force. Anything below it is clamped up to it, negatives
73 /// included — the values that are refused instead of clamped are the ones
74 /// [`reject_non_positive`](Self::reject_non_positive) marks. `joint_damping` has a second,
75 /// higher floor that depends on the spring it damps
76 /// ([`LiveTuning::joint_damping_floor`]).
77 pub min: f64,
78 /// The largest value left in force *for an update that carries this field*; anything above
79 /// it is then clamped down to it. A field an update does not carry is never lowered, so a
80 /// value seeded above this survives until someone moves it; [`LiveTuning::apply_update`] is
81 /// the authority on both.
82 pub max: f64,
83 /// The unit, for a caller's schema; empty where the quantity is dimensionless.
84 pub unit: &'static str,
85 /// How the loop moves an accepted value into effect.
86 pub policy: TuningPolicy,
87 /// Whether the range spans enough decades that a caller should draw it logarithmically.
88 pub log_slider: bool,
89 /// Whether a value of zero or less is *rejected* rather than clamped up to `min`. Set
90 /// where zero is not a soft version of the parameter but a different thing entirely, so
91 /// that clamping would silently change the meaning of the request.
92 pub reject_non_positive: bool,
93 /// Which set of controls this one belongs beside. Presentation, and here so that a
94 /// publisher of this table needs no field list of its own: the moment one exists, a field
95 /// added here is a field that table forgets.
96 pub group: &'static str,
97 /// How much care moving this one wants, if any.
98 pub danger: Option<TuningDanger>,
99 /// A value below [`max`](Self::max) that a slider may usefully stop at, where the top of
100 /// the range is reachable but rarely wanted; `None` draws the whole range. The bound is
101 /// still `max`: this narrows the slider, never the gate.
102 pub slider_max: Option<f64>,
103 /// The value at which this parameter means "off" rather than "very small" or "very large",
104 /// for a caller that wants to say so beside the control: the cutoff that is the filter's
105 /// off switch, the gain that is the term's.
106 pub off_at: Option<f64>,
107 /// Whether this word is one axis of a budget expressed as a *norm* rather than per axis,
108 /// so a caller shows the per-axis share beside it. The [OTG's first
109 /// rule](crate::otg#in-a-control-loop) is why the difference matters.
110 pub norm: bool,
111}
112
113/// How much care moving a [`FieldBound`] wants. Advice for the operator, enforced by whoever
114/// publishes the table -- this crate clamps, and has no opinion beyond the clamp.
115#[derive(Debug, Clone, Copy, PartialEq)]
116pub enum TuningDanger {
117 /// Worth a second look: the field reaches the torque directly.
118 Advise,
119 /// Above this value the field widens what the arm may do, so a publisher should ask for a
120 /// deliberate confirmation *on the way up* -- the value stored crossing the threshold from
121 /// at or below it. Already past it, moving within or down, there is nothing new to confirm.
122 ConfirmAbove(f64),
123}
124
125impl FieldBound {
126 const fn new(
127 name: &'static str,
128 index: Option<usize>,
129 group: &'static str,
130 min: f64,
131 max: f64,
132 unit: &'static str,
133 policy: TuningPolicy,
134 ) -> FieldBound {
135 FieldBound {
136 name,
137 index,
138 min,
139 max,
140 unit,
141 policy,
142 log_slider: false,
143 reject_non_positive: false,
144 group,
145 danger: None,
146 slider_max: None,
147 off_at: None,
148 norm: false,
149 }
150 }
151
152 const fn log(mut self) -> FieldBound {
153 self.log_slider = true;
154 self
155 }
156
157 const fn non_zero(mut self) -> FieldBound {
158 self.reject_non_positive = true;
159 self
160 }
161
162 const fn advise(mut self) -> FieldBound {
163 self.danger = Some(TuningDanger::Advise);
164 self
165 }
166
167 const fn confirm_above(mut self, value: f64) -> FieldBound {
168 self.danger = Some(TuningDanger::ConfirmAbove(value));
169 self
170 }
171
172 const fn slider_max(mut self, value: f64) -> FieldBound {
173 self.slider_max = Some(value);
174 self
175 }
176
177 const fn off_at(mut self, value: f64) -> FieldBound {
178 self.off_at = Some(value);
179 self
180 }
181
182 const fn norm(mut self) -> FieldBound {
183 self.norm = true;
184 self
185 }
186}
187
188const SLEW: TuningPolicy = TuningPolicy::Slew { tau: SLEW_TAU };
189
190/// The law's springs and dampers.
191const GAINS: &str = "gains";
192/// The goal velocity's weight and the filter on it.
193const FEEDFORWARD: &str = "feedforward";
194/// What the inverse kinematics does with a redundant arm.
195const IK: &str = "ik";
196/// How fast the plan may move, which is what the operator feels first.
197const ENVELOPE: &str = "envelope";
198
199impl LiveTuning {
200 /// How far each scalar may be moved, in the order of [`to_words`](Self::to_words).
201 ///
202 /// Publish this; never copy the numbers, and publish
203 /// [`joint_damping_floor`](Self::joint_damping_floor) with it. The joint damping ceilings
204 /// are the reference joint-impedance stack's, less the crate's own velocity barrier gain,
205 /// which is added on top of the law's damping and is what the stability bound applies to,
206 /// and lower still on the wrist; the budget ceilings sit just past where the robot's own
207 /// joint-velocity guard can fire.
208 pub const BOUNDS: &'static [FieldBound] = &{
209 let blank = FieldBound::new("", None, "", 0.0, 0.0, "", TuningPolicy::Step);
210 let mut rows = [blank; Self::WORDS];
211 let mut i = 0;
212 while i < 7 {
213 // Above the preset's own ceiling the torque clamp binds before the spring does.
214 rows[i] = FieldBound::new(
215 "joint_stiffness",
216 Some(i),
217 GAINS,
218 0.0,
219 1200.0,
220 "Nm/rad",
221 SLEW,
222 )
223 .advise();
224 // The ceiling is 60 rather than the reference joint-impedance stack's 80 because
225 // near the joint's velocity limit `VELOCITY_BARRIER_GAIN` is added to this, and it
226 // is the sum the barrier's stability rule bounds: `K × 1 ms / I < 1` (the book's
227 // *The impedance backend*, "The joint velocity envelope"). The wrist (joints 5 to 7)
228 // gets 40: on its 0.074 kg m², 60 + 20 gives 1.08 and 40 + 20 gives 0.81.
229 // Away from that limit the barrier contributes nothing, so this `min` of zero is
230 // not the floor in force: `joint_damping_floor` is.
231 let ceiling = if i < 4 { 60.0 } else { 40.0 };
232 rows[7 + i] = FieldBound::new(
233 "joint_damping",
234 Some(i),
235 GAINS,
236 0.0,
237 ceiling,
238 "Nm s/rad",
239 SLEW,
240 )
241 .advise();
242 i += 1;
243 }
244 // Four times the preset; past it the joint-side IK acceleration binds, not the spring.
245 // Zero is rejected: the derived damping scales with its square root, so a zero or
246 // negative stiffness is not a soft spring but a different sign.
247 rows[14] = FieldBound::new(
248 "cartesian_stiffness",
249 None,
250 GAINS,
251 50.0,
252 3000.0,
253 "N/m",
254 SLEW,
255 )
256 .log()
257 .non_zero()
258 .advise();
259 // The damped-least-squares gain is bounded by 1/2λ, so the floor is already a 500-fold
260 // amplification near a singularity. Zero is rejected rather than clamped: the IK errors
261 // on it, and snapping it to the floor would silently answer a different request.
262 rows[15] = FieldBound::new("ik_damping", None, IK, 1e-3, 1.0, "", SLEW)
263 .log()
264 .non_zero();
265 // Zero is a valid, and used, setting -- it is the posture term switched off. The pull is
266 // capped at `MAX_POSTURE_RATE` whatever the gain, so the ceiling only bounds how fast
267 // the cap is reached, and a slider spanning the useful end is worth more than one that
268 // reaches it.
269 rows[16] = FieldBound::new("ik_nullspace_gain", None, IK, 0.0, 20.0, "1/s", SLEW)
270 .slider_max(5.0)
271 .off_at(0.0);
272 rows[17] = FieldBound::new(
273 "velocity_feedforward_gain",
274 None,
275 FEEDFORWARD,
276 0.0,
277 1.0,
278 "",
279 SLEW,
280 )
281 .confirm_above(0.0)
282 .off_at(0.0);
283 // The library's own floor and ceiling, taken from it rather than restated: the ceiling
284 // is the filter's off switch, and below the floor the filter is a near-integrator.
285 rows[18] = FieldBound::new(
286 "velocity_feedforward_cutoff",
287 None,
288 FEEDFORWARD,
289 MIN_FEEDFORWARD_CUTOFF,
290 crate::lowpass_filter::MAX_CUTOFF_FREQUENCY,
291 "Hz",
292 TuningPolicy::Step,
293 )
294 .log()
295 .off_at(crate::lowpass_filter::MAX_CUTOFF_FREQUENCY);
296 // A gated word descends at the next word's value, which is the next order of the same
297 // budget; `rate_word` says so rather than leaving it to be rediscovered.
298 const fn gate(word: usize) -> TuningPolicy {
299 TuningPolicy::StepUpGateDown {
300 rate_word: word + 1,
301 }
302 }
303 // The robot's own joint-velocity guard can fire near a translational budget of
304 // [1.0, 15, 600], depending on pose and path, so these ceilings sit just past that edge
305 // with the guard behind them. The confirmations sit lower, at `[0.85, 10, 400]`, the
306 // fastest set the stack has been validated at: going past it is a decision rather than a
307 // drag of a slider. The rotational set is the
308 // same fractions of its own ceilings.
309 rows[19] = FieldBound::new("budget", Some(0), ENVELOPE, 0.05, 1.2, "m/s", gate(19))
310 .confirm_above(0.85)
311 .norm();
312 rows[20] = FieldBound::new("budget", Some(1), ENVELOPE, 0.1, 20.0, "m/s^2", gate(20))
313 .confirm_above(10.0)
314 .norm();
315 rows[21] = FieldBound::new(
316 "budget",
317 Some(2),
318 ENVELOPE,
319 1.0,
320 800.0,
321 "m/s^3",
322 TuningPolicy::Step,
323 )
324 .confirm_above(400.0)
325 .norm();
326 rows[22] = FieldBound::new(
327 "rotation_budget",
328 Some(0),
329 ENVELOPE,
330 0.05,
331 2.5,
332 "rad/s",
333 gate(22),
334 )
335 .confirm_above(1.0)
336 .norm();
337 rows[23] = FieldBound::new(
338 "rotation_budget",
339 Some(1),
340 ENVELOPE,
341 0.1,
342 30.0,
343 "rad/s^2",
344 gate(23),
345 )
346 .confirm_above(15.0)
347 .norm();
348 rows[24] = FieldBound::new(
349 "rotation_budget",
350 Some(2),
351 ENVELOPE,
352 1.0,
353 800.0,
354 "rad/s^3",
355 TuningPolicy::Step,
356 )
357 .confirm_above(400.0)
358 .norm();
359 rows
360 };
361
362 /// The least damping, Nm s/rad, a joint spring of `stiffness` Nm/rad is allowed to run
363 /// with. [`apply_update`](Self::apply_update) raises `joint_damping[i]` to this, and a
364 /// caller that publishes [`BOUNDS`](Self::BOUNDS) has to publish this rule beside it: it
365 /// is the one limit in this file that a per-field row cannot express.
366 ///
367 /// Why it exists: the pair is the only one where both halves are exposed and independent,
368 /// and `joint_damping`'s own floor is zero, so without it one slider move reaches a spring
369 /// of up to 1200 Nm/rad with no damping at all. The
370 /// [`VELOCITY_BARRIER_GAIN`](super::super::VELOCITY_BARRIER_GAIN) the damping *ceiling* is derived
371 /// against is no help here — it only engages beyond
372 /// [`velocity_barrier_fraction`](super::super::ImpedanceOptions::velocity_barrier_fraction) of the
373 /// joint's velocity limit, so at working speed it contributes nothing, and what is left is
374 /// an undamped spring behind a low-pass that adds lag rather than damping.
375 ///
376 /// [`MIN_JOINT_DAMPING_RATIO`] is below every shipped preset's own `D / sqrt(K)`, so this
377 /// forbids the corner and no configuration anyone runs today; it is not a tuning opinion
378 /// and it is far enough under the ceiling never to fight it.
379 pub fn joint_damping_floor(stiffness: f64) -> f64 {
380 MIN_JOINT_DAMPING_RATIO * stiffness.sqrt()
381 }
382}
383
384/// The least `joint_damping[i] / sqrt(joint_stiffness[i])` a running session is left at. The
385/// shipped presets sit between 0.32 ([`ImpedanceGains::CARTESIAN`](super::super::ImpedanceGains)'s
386/// wrist) and 2.12 (`JOINT`'s), so this is under every one of them with room to spare: it
387/// forbids the undamped corner and nothing else. Like every number in
388/// [`LiveTuning::BOUNDS`](LiveTuning::BOUNDS) it is a derivation on paper until it is swept.
389pub const MIN_JOINT_DAMPING_RATIO: f64 = 0.25;
390
391// The clamp walks `BOUNDS` against the word array, so the two must describe the same
392// scalars in the same order. This catches a field bounded twice or not at all.
393const _: () = assert!(LiveTuning::BOUNDS.len() == LiveTuning::WORDS);
394
395// Every row is a row a publisher can render, and every gated word's rate is a word that
396// exists. A row left half-built -- the blank the table starts from -- fails here rather than
397// reaching a schema as a field with no group and a descent rate off the end of the array.
398const _: () = {
399 let mut word = 0;
400 while word < LiveTuning::WORDS {
401 assert!(
402 !LiveTuning::BOUNDS[word].name.is_empty() && !LiveTuning::BOUNDS[word].group.is_empty(),
403 "a bound with no name or no group"
404 );
405 if let TuningPolicy::StepUpGateDown { rate_word } = LiveTuning::BOUNDS[word].policy {
406 assert!(
407 rate_word < LiveTuning::WORDS,
408 "a descent rate off the table"
409 );
410 // Strictly after, which rules out a word descending at its own rate and buys the
411 // crossing a second property: `slew_toward` walks the words in this order, so a
412 // rate word sitting after the word it paces has not moved yet when it is read.
413 // Its snapshot of the values in force is then provably the values in force, for
414 // this table, rather than a defence whose only evidence is the reader's argument.
415 // The snapshot stays: it is what keeps that true of a table ordered differently.
416 assert!(rate_word > word, "a descent rate at or before its own word");
417 }
418 word += 1;
419 }
420};