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}