Skip to main content

franka/
otg.rs

1//! Online trajectory generation: a smooth 1 kHz command from a stream of stepped targets.
2//!
3//! [`Otg`] turns a target position that may change at any time -- every cycle, in bursts, or
4//! not for seconds -- into a per-cycle position command whose velocity, acceleration and jerk
5//! never exceed the limits it was built with, whose acceleration is continuous (the trajectory
6//! is C2), and which reaches the target and then stays there exactly. It is the causal
7//! alternative to a spline, which needs future knots a low-rate commander cannot supply, and to
8//! a low-pass filter, whose peak speed grows with the size of the step. [`MultiOtg`] runs one
9//! per axis, optionally synchronised; [`CartesianOtg`] is the three-axis one. No allocation.
10//!
11//! # Algorithm
12//! Every call to [`Otg::step`] re-plans from the current state `(p, v, a)` to `(target, 0, 0)`
13//! and then follows that plan for one cycle. The plan is the classical seven-segment,
14//! jerk-limited profile: a time-optimal *velocity transfer* from `(v, a)` to a peak velocity
15//! `v_p` with zero acceleration (jerk `+j_max` up to a peak acceleration, hold it, jerk
16//! `-j_max` back to zero -- three segments with closed-form durations), a cruise at `v_p`, and
17//! the mirror transfer from `v_p` to rest. The only free parameter is `v_p`: it is `±v_max`
18//! with a cruise when the target is far enough, and otherwise a root of
19//! `f(v_p) = target - p`, where `f` is the displacement of the two transfers. `f` is
20//! increasing in `v_p` except for one hump next to `v_rd = v + a|a| / (2 j_max)`, the velocity
21//! reached by ramping the acceleration straight to zero (an unsaturated reversal there costs
22//! `2 sqrt(d / j_max)` of extra time at about `v_rd` for a change `d` of peak velocity), so
23//! the domain is split at `v_rd` and `v_rd ± a_max² / j_max`, every piece whose ends bracket
24//! the target is bisected, and the shortest plan found wins. A target closer than the braking
25//! distance gives a root of the opposite sign: the profile passes the target, stops and comes
26//! back, all within the limits and without a jerk spike. This is the profile structure of
27//! Haschke, Weitnauer and Ritter, *On-line planning of time-optimal, jerk-limited
28//! trajectories* (IROS 2008), evaluated one cycle at a time the way Ruckig (Berscheid and
29//! Kröger, RSS 2021) does, whose four jerk patterns are the pieces above. The acceleration
30//! along the plan is piecewise linear with slope `±j_max` or 0, so the trajectory is C2 and a
31//! finite difference of the acceleration over any cycle length, including a 2 ms cycle after a
32//! lost packet, stays within `j_max`; the velocity transfer keeps `v_rd` within `±v_max`,
33//! which is what makes the velocity bound hold through the transient of every re-plan.
34//!
35//! The target velocity is always zero: the generator is for positional targets from a planner,
36//! a vision loop or a hand, not for velocity tracking. The synchronisation in [`MultiOtg`]
37//! stretches the faster axes to the slowest one's duration by lowering their peak velocity (a
38//! second bisection, on the scale of `v_p`); an axis whose profile has no peak to lower (one
39//! that is exactly braking to its target) keeps its minimum duration.
40//!
41//! # In a control loop
42//! Three rules, without which a bridge such as `examples/nonrealtime_commander.rs` is clamped
43//! by the rate limiter behind it and then orbits at the velocity cap. The limits are
44//! **per axis**: two axes at full acceleration have a vector norm `sqrt 2` above it, so a
45//! budget that is a norm (which is what `limit_rate_cartesian_pose` bounds) needs
46//! [`OtgLimits::per_axis_for_norm`]. Step **one
47//! nominal cycle per command** (`DELTA_T`), not the measured period: the robot and the rate
48//! limiter check every packet against a 1 ms budget, so a 2 ms step after a lost packet is a
49//! doubled velocity to them. And **re-anchor on the robot's echo of the position** every
50//! cycle with [`Otg::set_position`] (`O_T_EE_c`; not its twist, see [`Otg::set_state`]), so
51//! that if anything behind the generator does alter a command, the next plan starts from
52//! what was actually sent; a rate limiter that tracks a pose it has fallen behind has no
53//! braking logic and never catches up.
54//!
55//! ```
56//! use franka::otg::{Otg, OtgLimits};
57//! let limits = OtgLimits { max_velocity: 0.3, max_acceleration: 0.5, max_jerk: 20.0 };
58//! let mut otg = Otg::new(0.0, limits).unwrap();
59//! otg.set_target(0.05).unwrap();
60//! let mut t = 0.0f64;
61//! while otg.position() != 0.05 {
62//!     otg.step(0.001);
63//!     t += 0.001;
64//! }
65//! assert!((t - 0.658).abs() < 0.01, "a 5 cm S-curve under these limits takes 0.658 s");
66//! ```
67
68use crate::error::{FrankaError, FrankaResult};
69
70/// The per-axis limits of an [`Otg`]: all three must be finite and positive.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct OtgLimits {
73    /// Maximum absolute velocity, in the position unit per second.
74    pub max_velocity: f64,
75    /// Maximum absolute acceleration, per second squared.
76    pub max_acceleration: f64,
77    /// Maximum absolute jerk, per second cubed.
78    pub max_jerk: f64,
79}
80
81impl OtgLimits {
82    /// Per-axis limits whose vector norm over `axes` axes never exceeds `self`: every limit
83    /// divided by `sqrt(axes)`. What to build a [`MultiOtg`] with when the budget is a norm.
84    pub fn per_axis_for_norm(self, axes: usize) -> Self {
85        self.scaled(1.0 / (axes.max(1) as f64).sqrt())
86    }
87
88    /// Every limit multiplied by `factor`.
89    pub fn scaled(self, factor: f64) -> Self {
90        Self {
91            max_velocity: self.max_velocity * factor,
92            max_acceleration: self.max_acceleration * factor,
93            max_jerk: self.max_jerk * factor,
94        }
95    }
96
97    /// Whether all three are finite and positive, which is what a generator needs of them:
98    /// the one check [`Otg::new`] and [`Otg::set_limits`] share.
99    fn valid(self) -> bool {
100        let ok = |x: f64| x.is_finite() && x > 0.0;
101        ok(self.max_velocity) && ok(self.max_acceleration) && ok(self.max_jerk)
102    }
103}
104
105/// One constant-jerk piece of a plan.
106#[derive(Debug, Clone, Copy, Default)]
107struct Segment {
108    jerk: f64,
109    duration: f64,
110}
111
112/// A seven-segment plan from the current state to rest at the target: the transfer to the
113/// peak velocity (three segments), the cruise, and the transfer to rest (three segments).
114#[derive(Debug, Clone, Copy, Default)]
115struct Plan {
116    segments: [Segment; 7],
117    peak_velocity: f64,
118}
119
120impl Plan {
121    fn duration(&self) -> f64 {
122        self.segments.iter().map(|s| s.duration).sum()
123    }
124}
125
126/// Advances `(p, v, a)` by `t` seconds of constant `jerk`.
127fn integrate(state: &mut (f64, f64, f64), jerk: f64, t: f64) {
128    let (p, v, a) = *state;
129    state.0 = p + v * t + a * t * t / 2.0 + jerk * t * t * t / 6.0;
130    state.1 = v + a * t + jerk * t * t / 2.0;
131    state.2 = a + jerk * t;
132}
133
134/// The time-optimal transfer from velocity `v` with acceleration `a` to velocity `v_goal`
135/// with zero acceleration under `a_max` and `j_max`, as three constant-jerk segments.
136fn transfer(v: f64, a: f64, v_goal: f64, a_max: f64, j_max: f64) -> [Segment; 3] {
137    let dv = v_goal - v;
138    // Ramping the acceleration straight to zero changes the velocity by a|a| / (2 j_max); if
139    // that alone passes the goal, the transfer has to swing the acceleration the other way.
140    let s = 1.0f64.copysign(dv - a * a.abs() / (2.0 * j_max));
141    // In the frame where the transfer accelerates, dv >= a0|a0| / (2 j_max) holds.
142    let (dv, a0) = (s * dv, s * a);
143    let a_peak = (j_max * dv + 0.5 * a0 * a0).max(0.0).sqrt().min(a_max);
144    let t1 = ((a_peak - a0) / j_max).max(0.0);
145    let t3 = a_peak / j_max;
146    let t2 = if a_peak > 0.0 {
147        ((dv - (2.0 * a_peak * a_peak - a0 * a0) / (2.0 * j_max)) / a_peak).max(0.0)
148    } else {
149        0.0
150    };
151    [(s * j_max, t1), (0.0, t2), (-s * j_max, t3)]
152        .map(|(jerk, duration)| Segment { jerk, duration })
153}
154
155/// A single-axis online trajectory generator; see the [module documentation](self).
156#[derive(Debug, Clone, Copy)]
157pub struct Otg {
158    limits: OtgLimits,
159    position: f64,
160    velocity: f64,
161    acceleration: f64,
162    target: f64,
163}
164
165impl Otg {
166    /// A generator at rest at `position`, whose target is `position`.
167    ///
168    /// # Errors
169    /// [`FrankaError::InvalidArgument`] if `position` is not finite or a limit is not finite
170    /// and positive.
171    pub fn new(position: f64, limits: OtgLimits) -> FrankaResult<Self> {
172        if !(limits.valid() && position.is_finite()) {
173            return Err(FrankaError::InvalidArgument(format!(
174                "otg: limits must be finite and positive and the position finite, got \
175                 {limits:?} at {position}"
176            )));
177        }
178        Ok(Self {
179            limits,
180            position,
181            velocity: 0.0,
182            acceleration: 0.0,
183            target: position,
184        })
185    }
186
187    /// Sets the target position; the next [`step`](Self::step) re-plans towards it.
188    ///
189    /// # Errors
190    /// [`FrankaError::InvalidArgument`], with the target unchanged, if `target` is not finite.
191    pub fn set_target(&mut self, target: f64) -> FrankaResult<()> {
192        if !target.is_finite() {
193            return Err(FrankaError::InvalidArgument(format!(
194                "otg: target must be finite, got {target}"
195            )));
196        }
197        self.target = target;
198        Ok(())
199    }
200
201    /// Re-anchors the generator on the position actually commanded -- the robot's echo of the
202    /// last command -- keeping its own velocity, acceleration and target, so that whatever
203    /// runs behind it can shape one command but never build up a lag it plans against.
204    ///
205    /// # Errors
206    /// [`FrankaError::InvalidArgument`], with the state unchanged, if `position` is not finite.
207    pub fn set_position(&mut self, position: f64) -> FrankaResult<()> {
208        self.set_state(position, self.velocity, self.acceleration)
209    }
210
211    /// Sets the whole state; the target is kept. The velocity and acceleration are clamped
212    /// into the limits. They must be the state at the *end* of the last cycle, as this
213    /// generator's are: a finite difference such as the robot's `O_dP_EE_c` is the *mean* over
214    /// the cycle, half an acceleration step behind, and re-anchoring on that every cycle
215    /// throttles the plan to a crawl -- use [`set_position`](Self::set_position) with an echo.
216    ///
217    /// # Errors
218    /// [`FrankaError::InvalidArgument`], with the state unchanged, if a value is not finite.
219    pub fn set_state(
220        &mut self,
221        position: f64,
222        velocity: f64,
223        acceleration: f64,
224    ) -> FrankaResult<()> {
225        if !(position.is_finite() && velocity.is_finite() && acceleration.is_finite()) {
226            return Err(FrankaError::InvalidArgument(format!(
227                "otg: state must be finite, got {position}, {velocity}, {acceleration}"
228            )));
229        }
230        let (v_max, a_max) = (self.limits.max_velocity, self.limits.max_acceleration);
231        self.position = position;
232        self.velocity = velocity.clamp(-v_max, v_max);
233        self.acceleration = acceleration.clamp(-a_max, a_max);
234        Ok(())
235    }
236
237    /// Replaces the limits the generator plans under, keeping its position, velocity,
238    /// acceleration and target exactly as they are: the next [`step`](Self::step) re-plans
239    /// under the new ones, and this call itself moves nothing.
240    ///
241    /// **Lowering a limit below the state the generator is already in is the caller's
242    /// problem.** Nothing here clamps -- but [`set_state`](Self::set_state), which
243    /// [`set_position`](Self::set_position) runs every cycle under the third of the
244    /// [three rules](self), clamps the stored velocity and acceleration into the limits, and
245    /// the end of every [`step`](Self::step) clamps them again. A velocity truncated by `dv`
246    /// in one cycle is an acceleration of `dv / dt` in the command -- a thousand times `dv` at
247    /// 1 kHz -- which is a discontinuity, not a re-plan. So a caller lowering a limit under a
248    /// moving generator **walks it down**: `max_velocity` no faster than the `max_acceleration`
249    /// in force, and `max_acceleration` no faster than `max_jerk`, which holds each clamp to
250    /// one cycle of the next order and so to the generator's own bound. Raising may step: the
251    /// state is already inside the wider limits, and the plan is re-derived from it anyway.
252    ///
253    /// # Errors
254    /// [`FrankaError::InvalidArgument`], with the limits unchanged, if one of them is not
255    /// finite and positive.
256    pub fn set_limits(&mut self, limits: OtgLimits) -> FrankaResult<()> {
257        if !limits.valid() {
258            return Err(FrankaError::InvalidArgument(format!(
259                "otg: limits must be finite and positive, got {limits:?}"
260            )));
261        }
262        self.limits = limits;
263        Ok(())
264    }
265
266    /// The limits the generator is planning under.
267    pub fn limits(&self) -> OtgLimits {
268        self.limits
269    }
270
271    /// Puts the generator at rest at `position`, with the target there too.
272    pub fn reset(&mut self, position: f64) {
273        (self.position, self.velocity, self.acceleration) = (position, 0.0, 0.0);
274        self.target = position;
275    }
276
277    /// Advances the state by `dt` seconds along the re-planned profile and returns the new
278    /// position. A `dt` that is not positive and finite leaves the state untouched.
279    pub fn step(&mut self, dt: f64) -> f64 {
280        self.follow(&self.fastest(), dt)
281    }
282
283    /// The current position.
284    pub fn position(&self) -> f64 {
285        self.position
286    }
287
288    /// The current velocity.
289    pub fn velocity(&self) -> f64 {
290        self.velocity
291    }
292
293    /// The current acceleration.
294    pub fn acceleration(&self) -> f64 {
295        self.acceleration
296    }
297
298    /// The current target.
299    pub fn target(&self) -> f64 {
300        self.target
301    }
302
303    /// The duration of the time-optimal profile from the current state to rest at the target;
304    /// zero when at rest there.
305    pub fn duration(&self) -> f64 {
306        self.fastest().duration()
307    }
308
309    /// The plan through `peak_velocity`, and the displacement its two transfers leave for the
310    /// cruise (negative if they alone pass the target, in which case the cruise is empty).
311    fn plan(&self, peak_velocity: f64) -> (Plan, f64) {
312        let (a_max, j_max) = (self.limits.max_acceleration, self.limits.max_jerk);
313        let (v, a) = (self.velocity, self.acceleration);
314        let mut plan = Plan {
315            peak_velocity,
316            ..Plan::default()
317        };
318        plan.segments[..3].copy_from_slice(&transfer(v, a, peak_velocity, a_max, j_max));
319        plan.segments[4..].copy_from_slice(&transfer(peak_velocity, 0.0, 0.0, a_max, j_max));
320        let mut state = (self.position, v, a);
321        for segment in &plan.segments {
322            integrate(&mut state, segment.jerk, segment.duration);
323        }
324        // Below a nanometre the residual is the integration's rounding, and divided by a peak
325        // velocity near zero it would become a cruise of milliseconds; the snap at the end of
326        // the plan absorbs it instead.
327        let residual = self.target - state.0;
328        if peak_velocity != 0.0 && residual.abs() >= 1e-9 {
329            plan.segments[3].duration = (residual / peak_velocity).max(0.0);
330        }
331        (plan, residual)
332    }
333
334    /// The time-optimal plan: the fastest peak velocity whose profile lands on the target
335    /// (see the module documentation for the pieces).
336    fn fastest(&self) -> Plan {
337        let OtgLimits {
338            max_velocity: v_max,
339            max_acceleration: a_max,
340            max_jerk: j_max,
341        } = self.limits;
342        if self.position == self.target && self.velocity == 0.0 && self.acceleration == 0.0 {
343            return Plan::default();
344        }
345        let (plan, residual) = self.plan(v_max);
346        if residual >= 0.0 {
347            return plan;
348        }
349        let (plan, residual) = self.plan(-v_max);
350        if residual <= 0.0 {
351            return plan;
352        }
353        let v_rd = self.velocity + self.acceleration * self.acceleration.abs() / (2.0 * j_max);
354        let width = a_max * a_max / j_max;
355        let bounds =
356            [-v_max, v_rd - width, v_rd, v_rd + width, v_max].map(|b| b.clamp(-v_max, v_max));
357        let mut best: Option<Plan> = None;
358        for pair in bounds.windows(2) {
359            let (mut lo, mut hi) = (pair[0], pair[1]);
360            let (r_lo, r_hi) = (self.plan(lo).1, self.plan(hi).1);
361            if lo >= hi || r_lo.signum() == r_hi.signum() {
362                continue;
363            }
364            while hi - lo > 1e-10 * v_max {
365                let mid = 0.5 * (lo + hi);
366                if self.plan(mid).1.signum() == r_lo.signum() {
367                    lo = mid;
368                } else {
369                    hi = mid;
370                }
371            }
372            let candidate = self.plan(0.5 * (lo + hi)).0;
373            if best.is_none_or(|b| candidate.duration() < b.duration()) {
374                best = Some(candidate);
375            }
376        }
377        best.unwrap_or(plan)
378    }
379
380    /// The plan through a peak velocity scaled down from `plan`'s so that it lasts `duration`
381    /// seconds (bisection on the scale; the duration grows without bound as the scale goes to
382    /// zero). Returns `plan` itself when it has no peak to lower.
383    fn stretched(&self, plan: Plan, duration: f64) -> Plan {
384        if plan.peak_velocity == 0.0 || plan.duration() >= duration {
385            return plan;
386        }
387        // A scaled peak is only usable if its transfers do not pass the target (a cruise of
388        // non-negative length brings the plan onto it); near the cusp of `f` they can, and
389        // such a plan would overshoot. Treat those like plans that are too short.
390        let long_enough = |scale: f64| {
391            let (candidate, residual) = self.plan(scale * plan.peak_velocity);
392            residual * plan.peak_velocity >= 0.0 && candidate.duration() >= duration
393        };
394        let (mut lo, mut hi) = (1e-9, 1.0);
395        if !long_enough(lo) {
396            return plan;
397        }
398        for _ in 0..40 {
399            let mid = 0.5 * (lo + hi);
400            if long_enough(mid) {
401                lo = mid;
402            } else {
403                hi = mid;
404            }
405        }
406        self.plan(lo * plan.peak_velocity).0
407    }
408
409    /// Follows `plan` for `dt` seconds, snapping to rest at the target when the plan ends
410    /// within the cycle.
411    fn follow(&mut self, plan: &Plan, dt: f64) -> f64 {
412        if !(dt > 0.0 && dt.is_finite()) {
413            return self.position;
414        }
415        if plan.duration() <= dt {
416            self.reset(self.target);
417            return self.position;
418        }
419        let mut state = (self.position, self.velocity, self.acceleration);
420        let mut left = dt;
421        for segment in &plan.segments {
422            let t = segment.duration.min(left);
423            integrate(&mut state, segment.jerk, t);
424            left -= t;
425            if left <= 0.0 {
426                break;
427            }
428        }
429        let (v_max, a_max) = (self.limits.max_velocity, self.limits.max_acceleration);
430        // The plan respects the limits; this only trims rounding in the last bit.
431        self.position = state.0;
432        self.velocity = state.1.clamp(-v_max, v_max);
433        self.acceleration = state.2.clamp(-a_max, a_max);
434        self.position
435    }
436}
437
438/// `N` [`Otg`]s with the same limits, stepped together, optionally synchronised so that all
439/// axes arrive at their targets at the same time (the faster axes are slowed down).
440#[derive(Debug, Clone, Copy)]
441pub struct MultiOtg<const N: usize> {
442    axes: [Otg; N],
443    synchronize: bool,
444}
445
446/// A three-axis [`MultiOtg`] for a Cartesian position.
447pub type CartesianOtg = MultiOtg<3>;
448
449impl<const N: usize> MultiOtg<N> {
450    /// A generator at rest at `position`, whose target is `position`.
451    ///
452    /// # Errors
453    /// [`FrankaError::InvalidArgument`] if a coordinate is not finite or a limit is not finite
454    /// and positive.
455    pub fn new(position: [f64; N], limits: OtgLimits, synchronize: bool) -> FrankaResult<Self> {
456        Self::with_limits(position, [limits; N], synchronize)
457    }
458
459    /// [`MultiOtg::new`] with limits of its own for every axis, which is what seven joints
460    /// with seven different envelopes need.
461    ///
462    /// # Errors
463    /// [`FrankaError::InvalidArgument`] if a coordinate is not finite or a limit is not finite
464    /// and positive.
465    pub fn with_limits(
466        position: [f64; N],
467        limits: [OtgLimits; N],
468        synchronize: bool,
469    ) -> FrankaResult<Self> {
470        let mut axes = [Otg::new(0.0, limits[0])?; N];
471        for (axis, (p, l)) in axes.iter_mut().zip(position.into_iter().zip(limits)) {
472            *axis = Otg::new(p, l)?;
473        }
474        Ok(Self { axes, synchronize })
475    }
476
477    /// Sets the target position; the next [`step`](Self::step) re-plans towards it.
478    ///
479    /// # Errors
480    /// [`FrankaError::InvalidArgument`], with the target unchanged, if a coordinate is not
481    /// finite.
482    pub fn set_target(&mut self, target: [f64; N]) -> FrankaResult<()> {
483        if target.iter().any(|t| !t.is_finite()) {
484            return Err(FrankaError::InvalidArgument(format!(
485                "otg: target must be finite, got {target:?}"
486            )));
487        }
488        for (axis, t) in self.axes.iter_mut().zip(target) {
489            axis.target = t;
490        }
491        Ok(())
492    }
493
494    /// Advances every axis by `dt` seconds and returns the new position. A `dt` that is not
495    /// positive and finite leaves the state untouched.
496    pub fn step(&mut self, dt: f64) -> [f64; N] {
497        let mut plans = self.axes.map(|axis| axis.fastest());
498        let slowest = plans.iter().map(Plan::duration).fold(0.0, f64::max);
499        if self.synchronize && slowest > dt {
500            for (axis, plan) in self.axes.iter().zip(plans.iter_mut()) {
501                *plan = axis.stretched(*plan, slowest);
502            }
503        }
504        let mut position = [0.0; N];
505        for ((axis, plan), p) in self.axes.iter_mut().zip(&plans).zip(position.iter_mut()) {
506            *p = axis.follow(plan, dt);
507        }
508        position
509    }
510
511    /// The current position.
512    pub fn position(&self) -> [f64; N] {
513        self.axes.map(|axis| axis.position)
514    }
515
516    /// The per-axis generators, for their velocities, accelerations and targets.
517    pub fn axes(&self) -> &[Otg; N] {
518        &self.axes
519    }
520
521    /// Re-anchors every axis on the position actually commanded; see [`Otg::set_position`].
522    ///
523    /// # Errors
524    /// [`FrankaError::InvalidArgument`], with the state unchanged, if a coordinate is not
525    /// finite.
526    pub fn set_position(&mut self, position: [f64; N]) -> FrankaResult<()> {
527        let (v, a) = (
528            self.axes.map(|x| x.velocity),
529            self.axes.map(|x| x.acceleration),
530        );
531        self.set_state(position, v, a)
532    }
533
534    /// Sets every axis's state; see [`Otg::set_state`] for what the velocity and
535    /// acceleration must be.
536    ///
537    /// # Errors
538    /// [`FrankaError::InvalidArgument`], with the state unchanged, if a value is not finite.
539    pub fn set_state(&mut self, p: [f64; N], v: [f64; N], a: [f64; N]) -> FrankaResult<()> {
540        if p.iter().chain(&v).chain(&a).any(|x| !x.is_finite()) {
541            return Err(FrankaError::InvalidArgument(format!(
542                "otg: state must be finite, got {p:?}, {v:?}, {a:?}"
543            )));
544        }
545        for (i, axis) in self.axes.iter_mut().enumerate() {
546            axis.set_state(p[i], v[i], a[i])?;
547        }
548        Ok(())
549    }
550
551    /// Replaces every axis's limits, all or nothing, keeping every axis's state; see
552    /// [`Otg::set_limits`] for what a caller owes a generator that is already moving.
553    ///
554    /// # Errors
555    /// [`FrankaError::InvalidArgument`], with **every** axis's limits unchanged, if one of
556    /// them is not finite and positive.
557    pub fn set_limits(&mut self, limits: [OtgLimits; N]) -> FrankaResult<()> {
558        if limits.iter().any(|l| !l.valid()) {
559            return Err(FrankaError::InvalidArgument(format!(
560                "otg: limits must be finite and positive, got {limits:?}"
561            )));
562        }
563        // Checked whole first, so no axis is left holding a limit a later one refused.
564        for (axis, l) in self.axes.iter_mut().zip(limits) {
565            axis.limits = l;
566        }
567        Ok(())
568    }
569
570    /// Puts every axis at rest at `position`, with the target there too.
571    pub fn reset(&mut self, position: [f64; N]) {
572        for (axis, p) in self.axes.iter_mut().zip(position) {
573            axis.reset(p);
574        }
575    }
576}
577
578#[cfg(test)]
579pub(crate) mod tests;