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;