Skip to main content

autocore_std/motion/
move_to_load.rs

1use crate::fb::StateMachine;
2use super::axis_view::AxisHandle;
3
4/// Move To Load function block.
5///
6/// Drives an axis toward a target load (e.g. from a load cell) and halts
7/// as soon as the reading reaches that load.
8///
9/// - If `current_load > target_load`, the axis moves in the negative direction.
10/// - If `current_load < target_load`, the axis moves in the positive direction.
11///
12/// If the axis reaches `position_limit` before the load is achieved, the
13/// FB halts and enters an error state.
14///
15/// # Lifecycle
16///
17/// `is_busy()` is `true` between [`start`](Self::start) and the moment the
18/// axis stops (either by reaching the load, hitting the position limit, or
19/// being aborted). When the FB returns to idle, check [`is_error`](Self::is_error)
20/// to find out whether the move succeeded.
21///
22/// # Noise rejection
23///
24/// This FB does **single-sample threshold detection** — it compares
25/// `current_load` against the target once per [`tick`](Self::tick) and
26/// triggers the moment the threshold is crossed. There is no FB-side
27/// debounce, because adding scan-count debounce would compound latency on
28/// top of whatever filtering the load card already applies, and overshoot
29/// in load control is force on the specimen.
30///
31/// **Configure the load card's filter as the noise-rejection layer.** For
32/// example, the Beckhoff EL3356's IIR filter or the NI 9237's BAA / decimation
33/// filter both run in hardware with known phase response. By the time the
34/// reading reaches `current_load`, it should already be smooth enough that
35/// a single-sample threshold is reliable.
36///
37/// # Overshoot avoidance: `dead_band`
38///
39/// Even with a perfectly clean signal, there is unavoidable latency between
40/// trigger and stop: the trigger fires, the FB calls `axis.halt()`, and
41/// the axis decelerates over some distance — during which the load
42/// continues to change. The result is overshoot past the requested target.
43///
44/// The `dead_band` configuration trips the threshold *early* by `dead_band`
45/// units in the direction of motion so the deceleration phase brings the
46/// load to the actual target rather than past it:
47///
48/// ```text
49///     load (moving_negative = true, load decreasing)
50///       │
51/// start ●───────────╮          trigger at target + dead_band
52///                   │          ↓
53///                   ●·······╮  halt() called
54///                            ╲
55///         target ────────────●◀── final load (close to target)
56///                              ╲ → undershoot here without dead_band
57///                  └─ time ─────────────▶
58/// ```
59///
60/// `dead_band` defaults to `0.0` (strict triggering, fine for slow moves
61/// or where the position-limit safety net catches overshoot). To configure
62/// it, measure the worst-case overshoot during commissioning and set
63/// `dead_band` to that value via [`set_dead_band`](Self::set_dead_band).
64/// It persists across [`start`](Self::start) calls.
65#[derive(Debug, Clone)]
66pub struct MoveToLoad {
67    /// Internal state machine.
68    state:               StateMachine,
69    moving_negative:     bool,
70    position_limit:      f64,
71    target_load:         f64,
72    /// True after `axis.is_busy()` has been observed `true` at least once
73    /// during the current move. Prevents the moving-state branch from
74    /// treating the one-tick window between issuing `move_absolute` and
75    /// the drive flipping its busy bit as "stopped without reaching load."
76    seen_busy:           bool,
77    /// Position at the instant the load threshold was crossed.
78    /// `f64::NAN` until the FB triggers at least once.
79    triggered_position:  f64,
80    /// Load reading at the instant the threshold was crossed.
81    /// `f64::NAN` until the FB triggers at least once.
82    triggered_load:      f64,
83    /// Early-trigger margin in load units. The threshold fires when the
84    /// load is within `dead_band` of `target_load` in the direction of
85    /// motion, so the post-halt deceleration brings the load to the
86    /// actual target rather than past it. Configured via
87    /// [`set_dead_band`](Self::set_dead_band); defaults to `0.0`.
88    /// Persists across [`start`](Self::start) calls.
89    dead_band:           f64,
90}
91
92/// Slack on the position-limit comparison. In axis user units (mm / deg /
93/// counts depending on `AxisConfig`). Treats positions within this many
94/// units of the limit as "at the limit," so floating-point round-off
95/// doesn't miss an exact-match crossing.
96const POSITION_LIMIT_TOLERANCE: f64 = 1e-4;
97
98#[repr(i32)]
99#[derive(Copy, Clone, PartialEq, Debug)]
100enum MtlState {
101    Idle     = 0,
102    Start    = 1,
103    Moving   = 10,
104    Stopping = 20,
105    /// Externally-set: requests an immediate halt, returns to Idle.
106    Halt     = 50,
107}
108
109impl Default for MoveToLoad {
110    fn default() -> Self {
111        Self {
112            state:              StateMachine::new(),
113            moving_negative:    false,
114            position_limit:     0.0,
115            target_load:        0.0,
116            seen_busy:          false,
117            triggered_position: f64::NAN,
118            triggered_load:     f64::NAN,
119            dead_band:          0.0,
120        }
121    }
122}
123
124impl MoveToLoad {
125    /// Constructor.
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Abort the operation. The axis will be halted by the next [`tick`](Self::tick).
131    pub fn abort(&mut self) {
132        self.state.set_error(200, "Abort called");
133        self.state.index = MtlState::Idle as i32;
134    }
135
136    /// Start the move-to-load operation.
137    ///
138    /// `target_load` and `position_limit` are latched on this call and used
139    /// by every subsequent [`tick`](Self::tick) until the FB returns to idle.
140    /// Any previous trigger position / load values are cleared.
141    pub fn start(&mut self, target_load: f64, position_limit: f64) {
142        self.state.clear_error();
143        self.target_load        = target_load;
144        self.position_limit     = position_limit;
145        self.seen_busy          = false;
146        self.triggered_position = f64::NAN;
147        self.triggered_load     = f64::NAN;
148        self.state.index        = MtlState::Start as i32;
149    }
150
151    /// Reset the state machine to Idle. Does not halt the axis (call
152    /// [`abort`](Self::abort) for that, or halt the axis directly).
153    pub fn reset(&mut self) {
154        self.state.clear_error();
155        self.state.index = MtlState::Idle as i32;
156    }
157
158    /// True if the FB encountered an error during the last command.
159    pub fn is_error(&self) -> bool {
160        self.state.is_error()
161    }
162
163    /// Returns the error message for the last command.
164    pub fn error_message(&self) -> String {
165        return self.state.error_message.clone();
166    }    
167
168
169    /// True if the FB is currently executing a command.
170    pub fn is_busy(&self) -> bool {
171        self.state.index > MtlState::Idle as i32
172    }
173
174    /// Axis position at the moment the load threshold was crossed.
175    /// Returns `f64::NAN` until the FB has triggered at least once.
176    /// Cleared by [`start`](Self::start).
177    pub fn triggered_position(&self) -> f64 {
178        self.triggered_position
179    }
180
181    /// Load reading at the moment the threshold was crossed.
182    /// Returns `f64::NAN` until the FB has triggered at least once.
183    /// Cleared by [`start`](Self::start).
184    pub fn triggered_load(&self) -> f64 {
185        self.triggered_load
186    }
187
188    /// Set the early-trigger margin in load units. See the type-level
189    /// docs for what this is and when to use it.
190    ///
191    /// Negative values are clamped to `0.0`. The new value is read on
192    /// every [`tick`](Self::tick), so it can be changed mid-move if the
193    /// caller needs to.
194    pub fn set_dead_band(&mut self, value: f64) {
195        self.dead_band = value.max(0.0);
196    }
197
198    /// Currently-configured early-trigger margin.
199    pub fn dead_band(&self) -> f64 {
200        self.dead_band
201    }
202
203    /// Execute the function block.
204    ///
205    /// - `axis`: the axis being driven.
206    /// - `current_load`: the latest load reading. Assumed already filtered
207    ///   by the load card — see the type-level docs on noise rejection.
208    pub fn tick(
209        &mut self,
210        axis:         &mut impl AxisHandle,
211        current_load: f64,
212    ) {
213        // Safety: a fault on the axis aborts any in-progress command.
214        if axis.is_error() && self.state.index > MtlState::Idle as i32 {
215            self.state.set_error(120, "Axis is in error state");
216            self.state.index = MtlState::Idle as i32;
217        }
218
219        match MtlState::from_index(self.state.index) {
220            Some(MtlState::Idle) => {
221                // do nothing
222            }
223            Some(MtlState::Start) => {
224                self.state.clear_error();
225                self.seen_busy = false;
226                // Direction is determined strictly so the dead_band region
227                // doesn't make direction undefined when current ≈ target.
228                self.moving_negative = current_load > self.target_load;
229
230                // Already at or past the load threshold (or within the
231                // dead-band of it)? Done — no point starting a move.
232                let reached = self.threshold_reached(current_load);
233
234                if reached {
235                    self.triggered_position = axis.position();
236                    self.triggered_load     = current_load;
237                    self.state.index        = MtlState::Idle as i32;
238                } else if self.already_past_limit(axis) {
239                    self.state.set_error(110, "Axis already past position limit before starting");
240                    self.state.index = MtlState::Idle as i32;
241                } else {
242                    let cfg = axis.config();
243                    axis.move_absolute(self.position_limit, cfg.jog_speed, cfg.jog_accel, cfg.jog_decel);
244                    self.state.index = MtlState::Moving as i32;
245                }
246            }
247            Some(MtlState::Moving) => {
248                // Latch is_busy=true once we see it so a one-tick
249                // command-acceptance window can't false-trigger the
250                // "stopped before reaching load" error below.
251                if axis.is_busy() {
252                    self.seen_busy = true;
253                }
254
255                let reached = self.threshold_reached(current_load);
256
257                if reached {
258                    self.triggered_position = axis.position();
259                    self.triggered_load     = current_load;
260                    axis.halt();
261                    self.state.index = MtlState::Stopping as i32;
262                    return;
263                }
264
265                let hit_limit = if self.moving_negative {
266                    axis.position() <= self.position_limit + POSITION_LIMIT_TOLERANCE
267                } else {
268                    axis.position() >= self.position_limit - POSITION_LIMIT_TOLERANCE
269                };
270                let stopped_unexpectedly = self.seen_busy && !axis.is_busy();
271
272                if hit_limit || stopped_unexpectedly {
273                    axis.halt();
274                    self.state.set_error(150, "Reached position limit without hitting target load");
275                    self.state.index = MtlState::Idle as i32;
276                }
277            }
278            Some(MtlState::Stopping) => {
279                if !axis.is_busy() {
280                    self.state.index = MtlState::Idle as i32;
281                }
282            }
283            Some(MtlState::Halt) => {
284                axis.halt();
285                self.state.index = MtlState::Idle as i32;
286            }
287            None => {
288                self.state.index = MtlState::Idle as i32;
289            }
290        }
291
292        self.state.call();
293    }
294
295    fn already_past_limit(&self, axis: &impl AxisHandle) -> bool {
296        if self.moving_negative {
297            axis.position() <= self.position_limit
298        } else {
299            axis.position() >= self.position_limit
300        }
301    }
302
303    /// Has the load reached (or come within `dead_band` of) the target,
304    /// from the direction we're moving? Used in both Start and Moving.
305    fn threshold_reached(&self, current_load: f64) -> bool {
306        if self.moving_negative {
307            // Load decreasing toward target — trip slightly above target
308            // so deceleration brings us down to it.
309            current_load <= self.target_load + self.dead_band
310        } else {
311            // Load increasing toward target — trip slightly below target.
312            current_load >= self.target_load - self.dead_band
313        }
314    }
315}
316
317impl MtlState {
318    fn from_index(idx: i32) -> Option<Self> {
319        match idx {
320            x if x == Self::Idle as i32     => Some(Self::Idle),
321            x if x == Self::Start as i32    => Some(Self::Start),
322            x if x == Self::Moving as i32   => Some(Self::Moving),
323            x if x == Self::Stopping as i32 => Some(Self::Stopping),
324            x if x == Self::Halt as i32     => Some(Self::Halt),
325            _ => None,
326        }
327    }
328}
329
330// -------------------------------------------------------------------------
331// Tests
332// -------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::motion::axis_config::AxisConfig;
338
339    /// Mock axis: records move/halt calls; the test mutates `position` and
340    /// `busy` to simulate motion.
341    struct MockAxis {
342        position:         f64,
343        busy:             bool,
344        error:            bool,
345        config:           AxisConfig,
346        halt_called:      u32,
347        last_move_target: f64,
348    }
349
350    impl MockAxis {
351        fn new() -> Self {
352            let mut cfg = AxisConfig::new(1000);
353            cfg.jog_speed = 10.0;
354            cfg.jog_accel = 100.0;
355            cfg.jog_decel = 100.0;
356            Self {
357                position: 0.0, busy: false, error: false, config: cfg,
358                halt_called: 0, last_move_target: 0.0,
359            }
360        }
361    }
362
363    impl AxisHandle for MockAxis {
364        fn position(&self) -> f64 { self.position }
365        fn config(&self) -> &AxisConfig { &self.config }
366        fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
367        fn move_absolute(&mut self, p: f64, _: f64, _: f64, _: f64) {
368            self.last_move_target = p;
369            self.busy = true;
370        }
371        fn halt(&mut self) { self.halt_called += 1; self.busy = false; }
372        fn is_busy(&self) -> bool { self.busy }
373        fn is_error(&self) -> bool { self.error }
374        fn motor_on(&self) -> bool { true }
375    }
376
377    #[test]
378    fn already_at_load_completes_without_moving() {
379        let mut fb = MoveToLoad::new();
380        let mut axis = MockAxis::new();
381        axis.position = 5.0;
382
383        fb.start(100.0, 50.0);              // moving positive
384        fb.tick(&mut axis, 100.0);          // already at target
385
386        assert!(!fb.is_busy());
387        assert!(!fb.is_error());
388        assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
389        assert_eq!(fb.triggered_position(), 5.0);
390        assert_eq!(fb.triggered_load(), 100.0);
391    }
392
393    #[test]
394    fn already_past_limit_errors_immediately() {
395        let mut fb = MoveToLoad::new();
396        let mut axis = MockAxis::new();
397        axis.position = 60.0;               // already past the +50 limit
398
399        fb.start(100.0, 50.0);
400        fb.tick(&mut axis, 0.0);            // load not reached
401
402        assert!(fb.is_error());
403        assert!(!fb.is_busy());
404        assert_eq!(axis.last_move_target, 0.0);
405    }
406
407    #[test]
408    fn moves_positive_then_triggers_on_load_threshold() {
409        let mut fb = MoveToLoad::new();
410        let mut axis = MockAxis::new();
411        axis.position = 0.0;
412
413        // Load below target → should command +position move.
414        fb.start(100.0, 50.0);
415        fb.tick(&mut axis, 0.0);
416        assert_eq!(axis.last_move_target, 50.0);
417        assert!(axis.busy);
418
419        // Tick a few times below threshold — no halt.
420        axis.position = 10.0; fb.tick(&mut axis, 50.0);
421        axis.position = 20.0; fb.tick(&mut axis, 80.0);
422        assert_eq!(axis.halt_called, 0);
423
424        // Cross the threshold — halt + record trigger values.
425        axis.position = 25.0; fb.tick(&mut axis, 100.5);
426        assert_eq!(axis.halt_called, 1);
427        assert_eq!(fb.triggered_position(), 25.0);
428        assert_eq!(fb.triggered_load(), 100.5);
429
430        // Stopping → Idle once axis flips !busy. (halt() in the mock
431        // already cleared busy.)
432        fb.tick(&mut axis, 100.5);
433        assert!(!fb.is_busy());
434        assert!(!fb.is_error());
435    }
436
437    #[test]
438    fn moves_negative_when_load_above_target() {
439        let mut fb = MoveToLoad::new();
440        let mut axis = MockAxis::new();
441        axis.position = 100.0;
442
443        // Load above target → should command -position move.
444        fb.start(50.0, 0.0);
445        fb.tick(&mut axis, 100.0);
446        assert_eq!(axis.last_move_target, 0.0);
447
448        axis.position = 50.0; fb.tick(&mut axis, 49.0);  // crossed (descending)
449        assert_eq!(axis.halt_called, 1);
450        assert_eq!(fb.triggered_load(), 49.0);
451    }
452
453    #[test]
454    fn position_limit_without_load_triggers_error() {
455        let mut fb = MoveToLoad::new();
456        let mut axis = MockAxis::new();
457        axis.position = 0.0;
458
459        fb.start(100.0, 50.0);
460        fb.tick(&mut axis, 0.0);            // start → Moving
461        axis.position = 50.0;
462        fb.tick(&mut axis, 10.0);           // hit limit, load not reached
463
464        assert!(fb.is_error());
465        assert_eq!(axis.halt_called, 1);
466    }
467
468    #[test]
469    fn startup_busy_race_does_not_false_trigger() {
470        // Reproduces the original bug: between move_absolute and the drive
471        // flipping is_busy=true, the FB used to interpret !is_busy as
472        // "axis stopped without reaching load" and erroneously errored.
473        let mut fb = MoveToLoad::new();
474        let mut axis = MockAxis::new();
475        axis.position = 0.0;
476        axis.busy = false;                  // drive briefly reports !busy
477
478        fb.start(100.0, 50.0);
479        fb.tick(&mut axis, 10.0);           // start → Moving; busy goes true via move_absolute
480        axis.busy = false;                  // simulate drive briefly reporting !busy
481        fb.tick(&mut axis, 20.0);           // would have errored before the fix
482
483        // Without seen_busy latching, this would now be in error.
484        assert!(!fb.is_error(), "must not error during the busy-acceptance window");
485    }
486
487    #[test]
488    fn abort_sets_error_and_returns_idle() {
489        let mut fb = MoveToLoad::new();
490        let mut axis = MockAxis::new();
491        fb.start(100.0, 50.0);
492        fb.tick(&mut axis, 0.0);            // Moving
493        assert!(fb.is_busy());
494
495        fb.abort();
496        assert!(!fb.is_busy());
497        assert!(fb.is_error());
498    }
499
500    #[test]
501    fn external_halt_state_halts_axis() {
502        let mut fb = MoveToLoad::new();
503        let mut axis = MockAxis::new();
504        axis.busy = true;
505
506        // Set the Halt state index directly (this is how external code
507        // requests the halt path — the FB doesn't expose a public method
508        // for it; halting the axis directly is also fine).
509        fb.state.index = MtlState::Halt as i32;
510        fb.tick(&mut axis, 0.0);
511
512        assert_eq!(axis.halt_called, 1);
513        assert!(!fb.is_busy());
514    }
515
516    #[test]
517    fn axis_fault_aborts_in_progress_command() {
518        let mut fb = MoveToLoad::new();
519        let mut axis = MockAxis::new();
520        fb.start(100.0, 50.0);
521        fb.tick(&mut axis, 0.0);            // Moving
522        axis.error = true;
523        fb.tick(&mut axis, 0.0);
524
525        assert!(fb.is_error());
526        assert!(!fb.is_busy());
527    }
528
529    #[test]
530    fn triggered_values_clear_on_restart() {
531        let mut fb = MoveToLoad::new();
532        let mut axis = MockAxis::new();
533
534        fb.start(100.0, 50.0);
535        fb.tick(&mut axis, 100.0);          // already at target → triggered
536        assert_eq!(fb.triggered_load(), 100.0);
537
538        fb.start(50.0, 0.0);                // restart
539        assert!(fb.triggered_load().is_nan());
540        assert!(fb.triggered_position().is_nan());
541    }
542
543    // ── dead_band (Pass B) ─────────────────────────────────────────────
544
545    #[test]
546    fn default_dead_band_is_zero() {
547        let fb = MoveToLoad::new();
548        assert_eq!(fb.dead_band(), 0.0);
549    }
550
551    #[test]
552    fn set_dead_band_clamps_negative_to_zero() {
553        let mut fb = MoveToLoad::new();
554        fb.set_dead_band(-5.0);
555        assert_eq!(fb.dead_band(), 0.0);
556        fb.set_dead_band(2.5);
557        assert_eq!(fb.dead_band(), 2.5);
558    }
559
560    #[test]
561    fn dead_band_persists_across_start_calls() {
562        let mut fb = MoveToLoad::new();
563        fb.set_dead_band(3.0);
564        fb.start(100.0, 50.0);
565        assert_eq!(fb.dead_band(), 3.0);
566        fb.start(50.0, 0.0);
567        assert_eq!(fb.dead_band(), 3.0, "configuration must outlive a single move");
568    }
569
570    #[test]
571    fn dead_band_triggers_early_for_positive_motion() {
572        let mut fb = MoveToLoad::new();
573        let mut axis = MockAxis::new();
574
575        // Without dead_band: would trigger at current >= 100.
576        // With dead_band = 5: triggers at current >= 95.
577        fb.set_dead_band(5.0);
578        fb.start(100.0, 50.0);
579        fb.tick(&mut axis, 0.0);            // start moving
580        assert!(axis.busy);
581
582        // 94 — not yet inside the dead-band.
583        axis.position = 10.0; fb.tick(&mut axis, 94.0);
584        assert_eq!(axis.halt_called, 0);
585
586        // 95.5 — within dead_band of target → trip.
587        axis.position = 11.0; fb.tick(&mut axis, 95.5);
588        assert_eq!(axis.halt_called, 1);
589        assert_eq!(fb.triggered_load(), 95.5);
590    }
591
592    #[test]
593    fn dead_band_triggers_early_for_negative_motion() {
594        let mut fb = MoveToLoad::new();
595        let mut axis = MockAxis::new();
596        axis.position = 100.0;
597
598        // Target 50, current 100, dead_band 5:
599        // Without dead_band: triggers at current <= 50.
600        // With dead_band = 5: triggers at current <= 55.
601        fb.set_dead_band(5.0);
602        fb.start(50.0, 0.0);
603        fb.tick(&mut axis, 100.0);          // start moving negative
604
605        axis.position = 75.0; fb.tick(&mut axis, 60.0);   // not yet in dead-band
606        assert_eq!(axis.halt_called, 0);
607
608        axis.position = 70.0; fb.tick(&mut axis, 54.5);   // inside dead_band → trip
609        assert_eq!(axis.halt_called, 1);
610        assert_eq!(fb.triggered_load(), 54.5);
611    }
612
613    #[test]
614    fn within_dead_band_at_start_completes_immediately() {
615        let mut fb = MoveToLoad::new();
616        let mut axis = MockAxis::new();
617        axis.position = 5.0;
618
619        // Target 100, dead_band 10. Current 92 is within dead_band → done.
620        fb.set_dead_band(10.0);
621        fb.start(100.0, 50.0);
622        fb.tick(&mut axis, 92.0);
623
624        assert!(!fb.is_busy());
625        assert!(!fb.is_error());
626        assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
627        assert_eq!(fb.triggered_load(), 92.0);
628    }
629
630    #[test]
631    fn dead_band_zero_matches_strict_pass_a_behavior() {
632        // Regression: dead_band = 0 must reproduce the strict-equality
633        // behavior tested in Pass A.
634        let mut fb = MoveToLoad::new();
635        let mut axis = MockAxis::new();
636
637        fb.set_dead_band(0.0);
638        fb.start(100.0, 50.0);
639        fb.tick(&mut axis, 0.0);
640
641        axis.position = 10.0; fb.tick(&mut axis, 99.99);
642        assert_eq!(axis.halt_called, 0, "must not trip at 99.99 with dead_band=0");
643
644        axis.position = 11.0; fb.tick(&mut axis, 100.0);
645        assert_eq!(axis.halt_called, 1, "exact equality must trip");
646    }
647}