Skip to main content

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};