Skip to main content

franka/robot/target_control/tuning/
slew.rs

1//! How a [`LiveTuning`] target is moved into force: the first-order filter the loop runs, and
2//! the same filter read off the clock for a caller that only watches.
3//!
4//! A parameter multiplies some state. Where that state is generally nonzero -- the joint error
5//! a spring acts on, the goal velocity the feedforward weighs -- a step in the parameter is a
6//! step in the torque, so those fields cross to their target over
7//! [`SLEW_TAU`](super::SLEW_TAU) instead. Which field does which is
8//! [`FieldBound::policy`](super::FieldBound::policy), and this module is the only reader of it:
9//! [`LiveTuning::slew_gains`] turns the whole table into one gain per word, once, so the cycle
10//! itself holds no `exp` and no match on a policy.
11//!
12//! Both halves are the same filter, `e^(-t/tau)`, on purpose. The loop applies it a cycle at a
13//! time ([`LiveTuning::slew_toward`]); a panel is told how much of a change is still to come by
14//! [`TuningPolicy::remaining`], which reads the elapsed time rather than the loop, so nothing has
15//! to be published back out of the realtime thread. The exact exponential, not the crate's other
16//! first-order gain ([`lowpass_filter::gain`](crate::lowpass_filter), `dt / (dt + tau)`, which
17//! differs from it in the sixth decimal here): a fraction reported off a filter the loop is not
18//! running is a fraction of nothing.
19//!
20//! The budget's velocity and acceleration are neither slewed nor stepped but *gated*
21//! ([`TuningPolicy::StepUpGateDown`]): raising them is a step, and lowering them is a ramp at
22//! the next order's limit, because what a narrower budget does to a moving generator is a clamp
23//! rather than a re-plan ([`Otg::set_limits`](crate::otg::Otg::set_limits)). That is a floor
24//! under the word rather than a filter on it, so it composes with the same
25//! [`slewed`] step the other words take and needs no second pass.
26
27use super::{LiveTuning, TuningPolicy};
28
29impl TuningPolicy {
30    /// The fraction of the remaining gap one cycle of `dt` seconds closes: `1 - e^(-dt/tau)`
31    /// for a slewed field, and 1 -- the whole gap, on the cycle it arrives -- for every other.
32    ///
33    /// A non-positive `dt` or `tau` has no filter to run and takes the value whole.
34    ///
35    /// Call it once per session, not per cycle: it holds the only `exp` on this path, and
36    /// [`LiveTuning::slew_gains`] is the form the loop wants.
37    pub fn slew_gain(self, dt: f64) -> f64 {
38        match self {
39            TuningPolicy::Slew { tau } if tau > 0.0 && dt > 0.0 => 1.0 - (-dt / tau).exp(),
40            _ => 1.0,
41        }
42    }
43
44    /// The fraction of an accepted change **still to go** `elapsed` seconds after it was
45    /// accepted: 1 at acceptance, `e^(-elapsed/tau)` from there, and never negative. It counts
46    /// *down*; a caller showing the fraction already reached would draw every slew backwards.
47    ///
48    /// Zero for [`Step`](Self::Step), which is in force on the first cycle after it is accepted
49    /// and so has nothing left to report, and zero for [`StepUpGateDown`](Self::StepUpGateDown),
50    /// which is that on the way **up**. A gated field on the way *down* is not a filter at all
51    /// but a ramp at the next order's limit, and what is left of one is seconds rather than a
52    /// fraction of an elapsed time: [`descent`](Self::descent) is that half.
53    ///
54    /// This is the loop's filter read off a clock instead of a cycle counter: `elapsed` seconds
55    /// of `dt` cycles leave `(1 - slew_gain(dt))^(elapsed/dt)` of the gap. It needs
56    /// nothing from the realtime thread, which is why the tuning slot has one writer and no
57    /// reply.
58    ///
59    /// ```
60    /// use franka::robot::target_control::{LiveTuning, TuningPolicy, SLEW_TAU};
61    ///
62    /// let policy = LiveTuning::BOUNDS[0].policy;
63    /// assert_eq!(policy, TuningPolicy::Slew { tau: SLEW_TAU });
64    /// assert_eq!(policy.remaining(0.0), 1.0);
65    /// // One time constant leaves 1/e of the change to come -- not the 1 - 1/e it has made.
66    /// assert!((policy.remaining(SLEW_TAU) - 0.367_879_441).abs() < 1e-9);
67    /// assert!(policy.remaining(3.0 * SLEW_TAU) < 0.05);
68    /// assert_eq!(TuningPolicy::Step.remaining(0.0), 0.0);
69    /// ```
70    pub fn remaining(self, elapsed: f64) -> f64 {
71        match self {
72            TuningPolicy::Slew { tau } if tau > 0.0 => (-elapsed.max(0.0) / tau).exp(),
73            _ => 0.0,
74        }
75    }
76
77    /// How long, in seconds, a [gated](Self::StepUpGateDown) word takes to walk from `from`
78    /// down to `to` at `rate` units a second: the other half of
79    /// [`remaining`](Self::remaining), for the words that ramp rather than filter.
80    ///
81    /// Zero for a raise, which is in force on the cycle it arrives, and zero for every other
82    /// policy, which has no ramp. Infinite where the rate is not a rate: a word whose next
83    /// order stands at zero never descends.
84    ///
85    /// `rate` is the value **in force** of the word [`rate_word`](Self::rate_word) names —
86    /// read it off the table rather than from the field beside it, which is the rule this
87    /// method exists so that nobody writes down twice. The answer is exact while that word is
88    /// itself at rest; a descent whose rate is moving at the same time (both budget sliders
89    /// dragged at once) walks at whatever the rate word holds each cycle, and this is then the
90    /// estimate its value at the start implies.
91    ///
92    /// ```
93    /// use franka::robot::target_control::{LiveTuning, TuningPolicy};
94    ///
95    /// // The translational budget's velocity descends at its own acceleration.
96    /// let velocity = LiveTuning::BOUNDS[19].policy;
97    /// let rate_word = velocity.rate_word().expect("the velocity budget is gated");
98    /// assert_eq!(LiveTuning::BOUNDS[rate_word].name, "budget");
99    /// assert_eq!(LiveTuning::BOUNDS[rate_word].index, Some(1));
100    ///
101    /// // From 0.9 m/s to 0.3 m/s at the default 0.5 m/s^2 is 1.2 s of ramp.
102    /// assert!((velocity.descent(0.9, 0.3, 0.5) - 1.2).abs() < 1e-12);
103    /// // Raising is not a ramp, and neither is a stepped word.
104    /// assert_eq!(velocity.descent(0.3, 0.9, 0.5), 0.0);
105    /// assert_eq!(TuningPolicy::Step.descent(0.9, 0.3, 0.5), 0.0);
106    /// ```
107    pub fn descent(self, from: f64, to: f64, rate: f64) -> f64 {
108        // Only an ordered pair going down is a ramp, so a NaN either side answers "no ramp"
109        // rather than the infinity a caller would then divide by.
110        let descending = matches!(from.partial_cmp(&to), Some(std::cmp::Ordering::Greater));
111        if !descending || self.rate_word().is_none() {
112            return 0.0;
113        }
114        if rate > 0.0 {
115            (from - to) / rate
116        } else {
117            f64::INFINITY
118        }
119    }
120}
121
122/// Per word: which word's value in force one cycle may take off it going down, which is what
123/// [`TuningPolicy::StepUpGateDown`] means, and `None` for a word that is not gated. Flattened
124/// out of [`LiveTuning::BOUNDS`]' own [`rate_word`](TuningPolicy::rate_word) at compile time,
125/// so the cycle holds no match on a policy and no second opinion about which word the rate is.
126const RATE_WORD: [Option<usize>; LiveTuning::WORDS] = {
127    let mut rate = [None; LiveTuning::WORDS];
128    let mut word = 0;
129    while word < LiveTuning::WORDS {
130        // `rate_word` is not a `const fn`: a const context cannot call it, and duplicating the
131        // match here rather than the *rule* is what keeps the table the only place the rate is
132        // named. `bounds.rs` asserts every index is in range.
133        if let TuningPolicy::StepUpGateDown { rate_word } = LiveTuning::BOUNDS[word].policy {
134            rate[word] = Some(rate_word);
135        }
136        word += 1;
137    }
138    rate
139};
140
141/// How far one cycle moves each word of a [`LiveTuning`] toward its target: the fraction of the
142/// remaining gap a slewed word closes, and the cycle time a gated word's descent is measured in.
143///
144/// Built once, at the loop's construction, by [`LiveTuning::slew_gains`], out of
145/// [`LiveTuning::BOUNDS`]' policies -- so a field whose policy changes there changes here with
146/// it, and the cycle itself holds no `exp` and no match on a policy.
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct SlewGains {
149    /// One [`TuningPolicy::slew_gain`] per word of [`LiveTuning::to_words`].
150    gain: [f64; LiveTuning::WORDS],
151    /// The cycle time those gains were built for, and what a gated word's descent rate is
152    /// multiplied by. Never negative: a cycle that does not pass moves nothing.
153    dt: f64,
154}
155
156impl LiveTuning {
157    /// The [`SlewGains`] of a loop running at `dt` seconds a cycle. Build it once, at the
158    /// loop's construction, with the cycle time the loop runs at.
159    pub fn slew_gains(dt: f64) -> SlewGains {
160        SlewGains {
161            gain: std::array::from_fn(|word| Self::BOUNDS[word].policy.slew_gain(dt)),
162            dt: dt.max(0.0),
163        }
164    }
165
166    /// Moves every word one cycle toward `target` under `gains`, and reports whether any of them
167    /// moved. `false` means the values in force *are* the targets, bit for bit, so a caller can
168    /// skip the work of putting them into effect until the next change arrives.
169    ///
170    /// A [gated](TuningPolicy::StepUpGateDown) word -- the budget's velocity and acceleration --
171    /// is not filtered at all: its target is raised to a floor one cycle of the next order's
172    /// limit below where it is, so raising it is a step and lowering it a ramp. The rate is read
173    /// off the values in force at the top of the cycle, so the order the words are walked in
174    /// cannot change the answer.
175    ///
176    /// The realtime half of the tuning path: no allocation, no lock, no fallible call, and
177    /// `Self::WORDS` multiply-adds whatever the targets are. `gains` comes from
178    /// [`slew_gains`](Self::slew_gains) so that no `exp` runs here.
179    ///
180    /// It walks the word array rather than the named fields, which is what makes it impossible
181    /// for a field to be slewed under another field's policy, or left out.
182    ///
183    /// ```
184    /// use franka::robot::target_control::{LiveTuning, TuningPolicy};
185    /// # use franka::{ImpedanceOptions, TargetControlOptions};
186    /// # let options = TargetControlOptions::default();
187    /// # let impedance = ImpedanceOptions::cartesian();
188    /// let mut current =
189    ///     LiveTuning::from_options(&impedance, options.limits, options.rotation_limits);
190    /// let target = LiveTuning {
191    ///     ik_damping: current.ik_damping * 2.0,
192    ///     ..current
193    /// };
194    /// let gains = LiveTuning::slew_gains(0.001);
195    ///
196    /// // One cycle of a 0.3 s time constant is a third of a percent of the gap, not the gap.
197    /// assert!(current.slew_toward(&target, &gains));
198    /// assert!(current.ik_damping < impedance.ik.damping * 1.01);
199    ///
200    /// // Converged, it reports that it has nothing left to do.
201    /// for _ in 0..20_000 {
202    ///     current.slew_toward(&target, &gains);
203    /// }
204    /// assert_eq!(current, target);
205    /// assert!(!current.slew_toward(&target, &gains));
206    /// ```
207    pub fn slew_toward(&mut self, target: &LiveTuning, gains: &SlewGains) -> bool {
208        let (mut words, target) = (self.to_words(), target.to_words());
209        // The values in force at the top of the cycle: a gated word descends no faster than the
210        // word `BOUNDS` names as its rate, read from this snapshot rather than from `words`, so
211        // that the answer cannot depend on the order the words are walked in.
212        let before = words;
213        let mut moved = false;
214        for word in 0..Self::WORDS {
215            let goal = match RATE_WORD[word] {
216                Some(rate) => target[word].max(words[word] - before[rate] * gains.dt),
217                None => target[word],
218            };
219            let next = slewed(words[word], goal, gains.gain[word]);
220            moved |= next.to_bits() != words[word].to_bits();
221            words[word] = next;
222        }
223        if moved {
224            *self = Self::from_words(&words);
225        }
226        moved
227    }
228}
229
230/// `current` one cycle of `gain` toward `target`.
231///
232/// The filter approaches its target and never arrives, so the last few ulps are a step too small
233/// for the float to carry: landing on the target on the cycle it can no longer move is what lets
234/// [`LiveTuning::slew_toward`]'s `false` mean "the target is in force" rather than "near enough".
235/// A `gain` of 1 or more is the whole gap and lands in one cycle by the same rule.
236///
237/// It guards nothing against a non-finite value, because none reaches it: a seed's gains are
238/// finite by [`ImpedanceOptions::validate`](super::super::ImpedanceOptions::validate) and its
239/// budget by `Otg::new`, both before the loop is built, and a target's every carried word is
240/// checked finite by [`LiveTuning::apply_update`]. A NaN here would report a move on every cycle
241/// for the session's life.
242fn slewed(current: f64, target: f64, gain: f64) -> f64 {
243    if gain >= 1.0 {
244        return target;
245    }
246    let next = current + gain * (target - current);
247    if next.to_bits() == current.to_bits() {
248        target
249    } else {
250        next
251    }
252}