Skip to main content

autocore_std/motion/
pressure_control.rs

1//! Closed-loop pressure / force controller for Profile Position axes.
2//!
3//! Drives a single axis toward a target load (e.g. from a load cell) and
4//! holds it there by issuing a small incremental `move_absolute` every
5//! tick. Uses an exponential moving-average filter on the load input, a
6//! PID with anti-windup, a per-tick step clamp, and optional position
7//! limits to keep the axis inside a safety envelope.
8//!
9//! # Lifecycle
10//!
11//! Same shape as every other domain FB in autocore-std:
12//!
13//! ```text
14//!   [Idle]  --start(target, max_load)-->  [Controlling]
15//!   [Controlling]  --stop()----------->   [Halted] -- axis idle --> [Idle]
16//!   [Controlling]  --fault or limit -->   [Error]
17//! ```
18//!
19//! `is_busy()` is `true` while the loop is running or stopping. `is_error()`
20//! goes true on axis fault or `max_load` exceedance; call `error_message()`
21//! for a short description, or inspect the underlying state machine.
22//!
23//! # Engage only when the axis has settled
24//!
25//! `start()` lazily seeds `commanded_position = axis.position()` on the
26//! first `tick()` after engagement. If you engage while the axis is still
27//! decelerating from a prior move, the seed lands in the middle of that
28//! deceleration profile and the loop's initial output will fight the
29//! drive's trajectory. The typical pattern is: run `MoveToLoad` to target,
30//! wait for it to go idle, THEN call `start()`.
31
32use crate::fb::StateMachine;
33use super::axis_view::AxisHandle;
34
35/// Configuration for the Pressure Control function block.
36#[derive(Debug, Clone)]
37pub struct PressureControlConfig {
38    /// Proportional gain (Kp).
39    pub kp: f64,
40    /// Integral gain (Ki).
41    pub ki: f64,
42    /// Derivative gain (Kd).
43    pub kd: f64,
44    /// Feed forward value added directly to the output.
45    pub feed_forward: f64,
46    /// Maximum allowed position delta (in user units) per tick.
47    /// Critical for safety to prevent crushing the load cell.
48    /// E.g., 0.001 inches per ms tick.
49    pub max_step: f64,
50    /// Sub-threshold deadband. When the computed step is smaller than
51    /// `min_step` we skip the `move_absolute` call entirely. Keeps the
52    /// EtherCAT bus quiet near steady state. Set to 0 to issue a move
53    /// every tick regardless of step size.
54    pub min_step: f64,
55    /// Maximum accumulated integral windup.
56    pub max_integral: f64,
57    /// Exponential Moving Average filter coefficient for the load cell (0.0 to 1.0).
58    /// 1.0 = No filtering (raw data).
59    /// 0.1 = Heavy filtering.
60    pub filter_alpha: f64,
61    /// If true, a positive error (need more pressure) results in a *negative* move.
62    /// Set to true if moving the axis down (negative) increases compression.
63    pub invert_direction: bool,
64    /// The acceptable load error window to be considered "in tolerance" (e.g., +/- 2.0 lbs).
65    pub tolerance: f64,
66    /// How long the load must remain within `tolerance` before reporting `in_tolerance = true`.
67    pub settling_time: f64,
68    /// Optional hard ceiling on `commanded_position` in user units. If the
69    /// loop's integrator drives the commanded position past this, the
70    /// commanded position is clamped and the integral is frozen that tick
71    /// (extra anti-windup). `None` disables the clamp.
72    pub position_limit_pos: Option<f64>,
73    /// Optional hard floor on `commanded_position`. Mirror of
74    /// `position_limit_pos`.
75    pub position_limit_neg: Option<f64>
76}
77
78impl Default for PressureControlConfig {
79    fn default() -> Self {
80        Self {
81            kp: 0.0,
82            ki: 0.0,
83            kd: 0.0,
84            feed_forward: 0.0,
85            max_step: 0.005,      // Conservative 5 thousandths max step
86            min_step: 0.0,        // Opt-in deadband; default fires every tick
87            max_integral: 100.0,
88            filter_alpha: 0.5,
89            invert_direction: false,
90            tolerance: 1.0,
91            settling_time: 0.1,
92            position_limit_pos: None,
93            position_limit_neg: None
94        }
95    }
96}
97
98#[repr(i32)]
99#[derive(Copy, Clone, PartialEq, Debug)]
100enum PcState {
101    Idle        = 0,
102    Controlling = 10,
103    /// `stop()` issued; axis has been halted, waiting on the drive to
104    /// flip its busy bit back to false before returning to Idle.
105    Halted      = 20,
106    Error       = 30,
107}
108
109impl PcState {
110    fn from_index(idx: i32) -> Option<Self> {
111        match idx {
112            x if x == Self::Idle as i32        => Some(Self::Idle),
113            x if x == Self::Controlling as i32 => Some(Self::Controlling),
114            x if x == Self::Halted as i32      => Some(Self::Halted),
115            x if x == Self::Error as i32       => Some(Self::Error),
116            _ => None,
117        }
118    }
119}
120
121/// A closed-loop PID pressure/force controller for Profile Position (PP) axes.
122#[derive(Debug, Clone)]
123pub struct PressureControl {
124    /// Internal state machine.
125    state: StateMachine,
126
127    /// True when the current load has been within `config.tolerance` of the
128    /// target for at least `config.settling_time` seconds. Cleared by
129    /// `start()`, `stop()`, and any error transition.
130    in_tolerance: bool,
131
132    // Latched on start().
133    target_load: f64,
134    max_load: f64,
135
136    // Internal PID state.
137    integral: f64,
138    prev_error: f64,
139    filtered_load: f64,
140
141    // Internal setpoint state. Accumulated to avoid the positive-feedback
142    // lag of re-reading axis.position() each tick. Reseeded from the axis
143    // on the first tick after start().
144    commanded_position: f64,
145
146    /// PID error from the most recent tick, exposed via `pid_error()`.
147    last_pid_error: f64,
148
149    // Tolerance timer.
150    settling_timer: f64,
151
152    /// True between `start()` and the first tick that consumes it — tells
153    /// the loop to seed `filtered_load` and `commanded_position` from the
154    /// axis instead of using stale values from a prior run.
155    is_first_run: bool,
156
157    pub speed :f64 ,
158    pub accel : f64    
159}
160
161impl Default for PressureControl {
162    fn default() -> Self {
163        Self {
164            state: StateMachine::new(),
165            in_tolerance: false,
166            target_load: 0.0,
167            max_load: f64::INFINITY,
168            integral: 0.0,
169            prev_error: 0.0,
170            filtered_load: 0.0,
171            commanded_position: 0.0,
172            last_pid_error: 0.0,
173            settling_timer: 0.0,
174            is_first_run: false,
175            speed : 0.0,
176            accel : 0.0
177
178        }
179    }
180}
181
182impl PressureControl {
183    /// Creates a new, idle PressureControl block.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Engage the loop. Latches `target_load` and the safety ceiling
189    /// `max_load`; clears PID state. The next `tick()` seeds
190    /// `commanded_position` from the axis's current position.
191    ///
192    /// `max_load` is checked against `|current_load|` every tick; exceeding
193    /// it transitions to `Error` and halts the axis. Pass `f64::INFINITY`
194    /// if you genuinely want no limit (not recommended).
195    pub fn start(&mut self, target_load: f64, max_load: f64, speed : f64, accel: f64) {
196        self.state.clear_error();
197        self.target_load = target_load;
198        self.max_load = max_load;
199        self.integral = 0.0;
200        self.prev_error = 0.0;
201        self.last_pid_error = 0.0;
202        self.settling_timer = 0.0;
203        self.in_tolerance = false;
204        self.is_first_run = true;
205        self.state.index = PcState::Controlling as i32;
206        self.speed = speed;
207        self.accel = accel;
208    }
209
210    /// Halt the axis and transition toward Idle. Safe to call any time;
211    /// a no-op when already Idle or Halted.
212    pub fn stop(&mut self, axis: &mut impl AxisHandle) {
213        let idx = self.state.index;
214        if idx == PcState::Idle as i32 || idx == PcState::Halted as i32 {
215            return;
216        }
217        axis.halt();
218        self.in_tolerance = false;
219        self.state.index = PcState::Halted as i32;
220    }
221
222    /// Change the target load without resetting the loop. Useful for
223    /// setpoint ramps during a test.
224    pub fn set_target(&mut self, target_load: f64) {
225        self.target_load = target_load;
226    }
227
228    /// Change the max-load safety limit without resetting the loop.
229    pub fn set_max_load(&mut self, max_load: f64) {
230        self.max_load = max_load;
231    }
232
233    /// Current loop state: running or stopping.
234    pub fn is_busy(&self) -> bool {
235        let idx = self.state.index;
236        idx == PcState::Controlling as i32 || idx == PcState::Halted as i32
237    }
238
239    /// Fault state. Check `error_message()` for the reason.
240    pub fn is_error(&self) -> bool {
241        self.state.index == PcState::Error as i32 || self.state.is_error()
242    }
243
244    /// Description of the most recent error.
245    pub fn error_message(&self) -> String {
246        self.state.error_message.clone()
247    }
248
249    /// True once the load has been within `config.tolerance` of
250    /// `target_load` for at least `config.settling_time` seconds.
251    pub fn is_in_tolerance(&self) -> bool {
252        self.in_tolerance
253    }
254
255    /// Most recent filtered load reading. Updated every `tick()`.
256    pub fn filtered_load(&self) -> f64 {
257        self.filtered_load
258    }
259
260    /// Current internal commanded position (user units). Updated every
261    /// `tick()` by adding the clamped PID step.
262    pub fn commanded_position(&self) -> f64 {
263        self.commanded_position
264    }
265
266    /// Most recent PID error: `target_load − filtered_load`. Signed; sign
267    /// is NOT inverted by `config.invert_direction`.
268    pub fn pid_error(&self) -> f64 {
269        self.last_pid_error
270    }
271
272    /// Advance the loop by one scan. No-op when Idle.
273    pub fn tick(
274        &mut self,
275        axis: &mut impl AxisHandle,
276        current_load: f64,
277        config: &PressureControlConfig,
278        dt: f64,
279    ) {
280        // Drive-side fault takes priority over anything the loop is doing.
281        if axis.is_error() && self.state.index != PcState::Idle as i32 {
282            self.state.set_error(100, "Axis is in error state");
283            self.state.index = PcState::Error as i32;
284            return;
285        }
286
287        match PcState::from_index(self.state.index) {
288            Some(PcState::Idle) | Some(PcState::Error) | None => {
289                // Idle or errored out — nothing to do. `start()` takes us
290                // back to Controlling.
291            }
292
293            Some(PcState::Halted) => {
294                // `stop()` issued the halt; wait for the drive to finish
295                // decelerating before declaring Idle.
296                if !axis.is_busy() {
297                    self.state.index = PcState::Idle as i32;
298                }
299            }
300
301            Some(PcState::Controlling) => {
302                // 1. Safety first: abort if the raw load exceeded its
303                //    ceiling. Must check the raw reading, not the filter,
304                //    because a sensor fault can spike faster than the EMA
305                //    responds.
306                if current_load.abs() > self.max_load {
307                    axis.halt();
308                    self.state.set_error(
309                        110,
310                        format!(
311                            "load {} exceeded max_load {}",
312                            current_load, self.max_load,
313                        ),
314                    );
315                    self.state.index = PcState::Error as i32;
316                    return;
317                }
318
319                // 2. First-tick seed. Align the filter and commanded
320                //    position to current reality so the loop doesn't start
321                //    chasing stale values.
322                if self.is_first_run {
323                    self.filtered_load      = current_load;
324                    self.commanded_position = axis.position();
325                    self.is_first_run       = false;
326                }
327
328                // 3. EMA filter on the load.
329                let alpha = config.filter_alpha.clamp(0.0, 1.0);
330                self.filtered_load = alpha * current_load
331                                   + (1.0 - alpha) * self.filtered_load;
332
333                // 4. PID. Integral + derivative use `dt`; caller owns it.
334                let error = self.target_load - self.filtered_load;
335                self.last_pid_error = error;
336
337                let derivative = if dt > 0.0 {
338                    (error - self.prev_error) / dt
339                } else {
340                    0.0
341                };
342                self.prev_error = error;
343
344                // Raw (pre-clamp) output — integral contribution included
345                // for now; we'll roll back the integral step if we end up
346                // saturating.
347                let integral_candidate = (self.integral + error * dt)
348                    .clamp(-config.max_integral, config.max_integral);
349                let raw_output = config.kp * error
350                               + config.ki * integral_candidate
351                               + config.kd * derivative
352                               + config.feed_forward;
353
354                // 5. Direction + step clamp. Track whether we saturated so
355                //    we can apply conditional integration.
356                let signed_output = if config.invert_direction {
357                    -raw_output
358                } else {
359                    raw_output
360                };
361                let saturated = signed_output.abs() > config.max_step;
362                let step = signed_output.clamp(-config.max_step, config.max_step);
363
364                // Conditional integration: only commit the integral step
365                // when we're NOT bumping against the saturation clamp.
366                // Keeps the integrator from winding up during long
367                // approaches where max_step is the binding constraint.
368                if !saturated {
369                    self.integral = integral_candidate;
370                }
371
372                // 6. Accumulate commanded position, clamp to optional
373                //    envelope. Clamping also freezes the integral this
374                //    tick (a second anti-windup belt-and-suspenders).
375                let next_cmd = self.commanded_position + step;
376                let clamped = match (config.position_limit_neg, config.position_limit_pos) {
377                    (Some(lo), Some(hi)) => next_cmd.clamp(lo, hi),
378                    (Some(lo), None)     => next_cmd.max(lo),
379                    (None, Some(hi))     => next_cmd.min(hi),
380                    (None, None)         => next_cmd,
381                };
382                if clamped != next_cmd {
383                    // Clamped — pull the integral back to the pre-accumulate
384                    // value so it doesn't keep winding into the wall.
385                    self.integral = self.integral - error * dt;
386                    self.integral = self.integral.clamp(-config.max_integral, config.max_integral);
387                }
388                self.commanded_position = clamped;
389
390                // 7. Issue the move, subject to the min-step deadband.
391                let issued_step = self.commanded_position - axis.position();
392                if issued_step.abs() > config.min_step {
393                    let vel = self.speed;
394                    let acc = self.accel;
395                    let dec = self.accel;
396                    axis.move_absolute(self.commanded_position, vel, acc, dec);
397                }
398
399                // log::info!("PRESSURE CONTROL: cur load: {} error {} commanded pos {} raw_output {}",
400                //     self.filtered_load, error, self.commanded_position,
401                //     raw_output
402                // );
403
404                // 8. Tolerance / settling.
405                if error.abs() <= config.tolerance {
406                    self.settling_timer += dt;
407                    if self.settling_timer >= config.settling_time {
408                        self.in_tolerance = true;
409                    }
410                } else {
411                    self.settling_timer = 0.0;
412                    self.in_tolerance = false;
413                }
414            }
415        }
416
417        self.state.call();
418    }
419}
420
421// -------------------------------------------------------------------------
422// Tests
423// -------------------------------------------------------------------------
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::motion::axis_config::AxisConfig;
429
430    struct MockAxis {
431        position:        f64,
432        busy:            bool,
433        error:           bool,
434        config:          AxisConfig,
435        halt_calls:      u32,
436        last_move_target: f64,
437        move_calls:      u32,
438        /// Simple spring model: position increases force linearly.
439        /// current_load = spring_k * (position - rest_position)
440        spring_k:        f64,
441        rest_position:   f64,
442    }
443
444    impl MockAxis {
445        fn new() -> Self {
446            let mut cfg = AxisConfig::new(10_000);
447            cfg.jog_speed = 5.0;
448            cfg.jog_accel = 50.0;
449            cfg.jog_decel = 50.0;
450            Self {
451                position: 0.0, busy: false, error: false,
452                config: cfg,
453                halt_calls: 0, last_move_target: 0.0, move_calls: 0,
454                spring_k: 0.0, rest_position: 0.0,
455            }
456        }
457        fn with_spring(mut self, k: f64, rest: f64) -> Self {
458            self.spring_k = k;
459            self.rest_position = rest;
460            self
461        }
462        fn simulated_load(&self) -> f64 {
463            self.spring_k * (self.position - self.rest_position)
464        }
465        /// Pretend the drive processed the last move_absolute: move the
466        /// axis instantly to the commanded position. Tests call this
467        /// between ticks to advance the simulation.
468        fn advance(&mut self) {
469            self.position = self.last_move_target;
470        }
471    }
472
473    impl AxisHandle for MockAxis {
474        fn position(&self) -> f64 { self.position }
475        fn config(&self) -> &AxisConfig { &self.config }
476        fn move_absolute(&mut self, p: f64, _v: f64, _a: f64, _d: f64) {
477            self.last_move_target = p;
478            self.move_calls += 1;
479            self.busy = true;
480        }
481        fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
482        fn halt(&mut self) {
483            self.halt_calls += 1;
484            self.busy = false;
485        }
486        fn is_busy(&self) -> bool { self.busy }
487        fn is_error(&self) -> bool { self.error }
488        fn motor_on(&self) -> bool { true }
489    }
490
491    fn zero_pid_config() -> PressureControlConfig {
492        PressureControlConfig { ..Default::default() }
493    }
494
495    #[test]
496    fn tick_is_noop_when_idle() {
497        let mut pc = PressureControl::new();
498        let mut axis = MockAxis::new();
499        let cfg = zero_pid_config();
500        pc.tick(&mut axis, 0.0, &cfg, 0.01);
501        assert!(!pc.is_busy());
502        assert_eq!(axis.move_calls, 0);
503        assert_eq!(axis.halt_calls, 0);
504    }
505
506    #[test]
507    fn start_then_tick_seeds_from_axis() {
508        let mut pc = PressureControl::new();
509        let mut axis = MockAxis::new();
510        axis.position = 3.25;
511        pc.start(10.0, 500.0, 10.0, 100.0);
512        assert!(pc.is_busy());
513        // is_first_run catches in this tick.
514        pc.tick(&mut axis, 0.0, &zero_pid_config(), 0.01);
515        assert_eq!(pc.commanded_position(), 3.25);
516        assert_eq!(pc.filtered_load(), 0.0);
517    }
518
519    #[test]
520    fn stop_halts_and_transitions_to_idle() {
521        let mut pc = PressureControl::new();
522        let mut axis = MockAxis::new();
523        pc.start(10.0, 500.0, 10.0, 100.0);
524        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
525        assert!(pc.is_busy());
526        pc.stop(&mut axis);
527        assert_eq!(axis.halt_calls, 1);
528        assert!(pc.is_busy(), "still busy until axis finishes halt");
529        axis.busy = false;  // drive finished deceleration
530        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
531        assert!(!pc.is_busy());
532    }
533
534    #[test]
535    fn max_load_triggers_error_and_halt() {
536        let mut pc = PressureControl::new();
537        let mut axis = MockAxis::new();
538        pc.start(10.0, 100.0, 10.0, 100.0);
539        pc.tick(&mut axis, 150.0, &zero_pid_config(), 0.01);
540        assert!(pc.is_error());
541        assert_eq!(axis.halt_calls, 1);
542        assert!(pc.error_message().contains("exceeded max_load"));
543    }
544
545    #[test]
546    fn loop_converges_toward_target() {
547        // Spring model: 100 N per mm starting at rest=0. Target 50 N → 0.5 mm.
548        let mut pc = PressureControl::new();
549        let mut axis = MockAxis::new().with_spring(100.0, 0.0);
550        let cfg = PressureControlConfig {
551            kp: 0.01,       // 0.01 mm per N of error
552            ki: 0.0,
553            kd: 0.0,
554            max_step: 0.5,
555            min_step: 0.0,
556            filter_alpha: 1.0,
557            tolerance: 1.0,
558            settling_time: 0.0,
559            ..Default::default()
560        };
561        pc.start(50.0, 500.0, 10.0, 100.0);
562        for _ in 0..200 {
563            let load = axis.simulated_load();
564            pc.tick(&mut axis, load, &cfg, 0.01);
565            axis.advance();
566        }
567        let final_load = axis.simulated_load();
568        assert!(
569            (final_load - 50.0).abs() < 1.0,
570            "expected load near 50 N, got {}", final_load,
571        );
572        assert!(pc.is_in_tolerance());
573    }
574
575    #[test]
576    fn invert_direction_flips_step_sign() {
577        // Axis where moving NEGATIVE increases compression.
578        // spring_k = -100 N/mm, rest_position = 0 → load = -100 * position.
579        // Target 50 N requires position = -0.5 mm. Without inversion the
580        // raw PID output is positive (target − current > 0 → +output); with
581        // inversion that becomes a negative step.
582        let mut pc = PressureControl::new();
583        let mut axis = MockAxis::new().with_spring(-100.0, 0.0);
584        let cfg = PressureControlConfig {
585            kp: 0.01,
586            invert_direction: true,
587            max_step: 0.5,
588            min_step: 0.0,
589            filter_alpha: 1.0,
590            tolerance: 1.0,
591            settling_time: 0.0,
592            ..Default::default()
593        };
594        pc.start(50.0, 500.0, 10.0, 100.0);
595        for _ in 0..200 {
596            let load = axis.simulated_load();
597            pc.tick(&mut axis, load, &cfg, 0.01);
598            axis.advance();
599        }
600        assert!(axis.position < 0.0, "expected negative position, got {}", axis.position);
601    }
602
603    #[test]
604    fn min_step_deadband_suppresses_move() {
605        let mut pc = PressureControl::new();
606        let mut axis = MockAxis::new();
607        let cfg = PressureControlConfig {
608            kp: 0.0001,       // tiny gain → tiny step
609            max_step: 0.5,
610            min_step: 0.01,   // step 0.0001 * 1 = 0.0001 → well below 0.01
611            filter_alpha: 1.0,
612            ..Default::default()
613        };
614        pc.start(1.0, 500.0, 10.0, 100.0);
615        pc.tick(&mut axis, 0.0, &cfg, 0.01);  // seeds
616        let first_move_calls = axis.move_calls;
617        // Steady state: step is tiny every tick → no moves.
618        for _ in 0..10 {
619            pc.tick(&mut axis, 0.0, &cfg, 0.01);
620        }
621        assert_eq!(axis.move_calls, first_move_calls,
622            "min_step should have suppressed all these moves");
623    }
624
625    #[test]
626    fn position_limit_clamps_commanded() {
627        let mut pc = PressureControl::new();
628        let mut axis = MockAxis::new();
629        let cfg = PressureControlConfig {
630            kp: 1.0,
631            max_step: 0.5,
632            min_step: 0.0,
633            filter_alpha: 1.0,
634            position_limit_pos: Some(1.0),  // Cap at 1 mm
635            tolerance: 1.0,
636            settling_time: 0.0,
637            ..Default::default()
638        };
639        pc.start(100.0, 500.0, 10.0, 100.0);  // huge error → will try to run away
640        for _ in 0..20 {
641            pc.tick(&mut axis, 0.0, &cfg, 0.01);
642        }
643        assert!(pc.commanded_position() <= 1.0 + 1e-6,
644            "commanded position should be clamped, got {}", pc.commanded_position());
645    }
646
647    #[test]
648    fn axis_error_transitions_to_error_state() {
649        let mut pc = PressureControl::new();
650        let mut axis = MockAxis::new();
651        pc.start(10.0, 500.0, 10.0, 100.0);
652        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
653        assert!(pc.is_busy());
654        axis.error = true;
655        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
656        assert!(pc.is_error());
657    }
658
659    #[test]
660    fn set_target_changes_setpoint_without_reset() {
661        let mut pc = PressureControl::new();
662        let mut axis = MockAxis::new().with_spring(100.0, 0.0);
663        let cfg = PressureControlConfig {
664            kp: 0.01, max_step: 0.5, min_step: 0.0, filter_alpha: 1.0,
665            tolerance: 0.5, settling_time: 0.0, ..Default::default()
666        };
667        pc.start(50.0, 500.0, 10.0, 100.0);
668        for _ in 0..200 {
669            let load = axis.simulated_load();
670            pc.tick(&mut axis, load, &cfg, 0.01);
671            axis.advance();
672        }
673        assert!((axis.simulated_load() - 50.0).abs() < 1.0);
674        pc.set_target(30.0);
675        for _ in 0..200 {
676            let load = axis.simulated_load();
677            pc.tick(&mut axis, load, &cfg, 0.01);
678            axis.advance();
679        }
680        assert!((axis.simulated_load() - 30.0).abs() < 1.0);
681    }
682}