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    
73    /// Target speed of the move in user units. Slower is more precise.
74    /// Faster usually means damage
75    target_speed: f64,
76    /// Target accel of the move in user units.
77    /// This setting does not exact the stop decel, only the initial acceleration.
78    target_accel : f64,
79
80    /// True after `axis.is_busy()` has been observed `true` at least once
81    /// during the current move. Prevents the moving-state branch from
82    /// treating the one-tick window between issuing `move_absolute` and
83    /// the drive flipping its busy bit as "stopped without reaching load."
84    seen_busy:           bool,
85    /// Position at the instant the load threshold was crossed.
86    /// `f64::NAN` until the FB triggers at least once.
87    triggered_position:  f64,
88    /// Load reading at the instant the threshold was crossed.
89    /// `f64::NAN` until the FB triggers at least once.
90    triggered_load:      f64,
91    /// Early-trigger margin in load units. The threshold fires when the
92    /// load is within `dead_band` of `target_load` in the direction of
93    /// motion, so the post-halt deceleration brings the load to the
94    /// actual target rather than past it. Configured via
95    /// [`set_dead_band`](Self::set_dead_band); defaults to `0.0`.
96    /// Persists across [`start`](Self::start) calls.
97    dead_band:           f64,
98    /// Deceleration (user units / s²) passed to `move_absolute` for the
99    /// drive-side motion profile. `None` means "use `target_accel`."
100    /// Typically set once at init from the drive's 0x6085 (quick-stop
101    /// deceleration) SDO by the owning process — see the type-level docs.
102    /// Persists across [`start`](Self::start) calls.
103    stop_decel:          Option<f64>,
104
105    /// Speed (user units / s) for the optional post-halt settle move.
106    /// When paired with a non-zero `settle_tolerance`, the FB will issue
107    /// a slow corrective move in the direction opposite to the primary
108    /// motion if the measured load has overshot the target. Set to 0 to
109    /// disable. Persists across [`start`](Self::start) calls.
110    settle_speed:        f64,
111    /// Load tolerance (same units as `target_load`) used to decide
112    /// whether the post-halt reading is acceptable. If
113    /// `|current_load − target_load| > settle_tolerance` after the axis
114    /// stops, the FB enters a `Settling` state and moves slowly against
115    /// the original motion until the load is back inside the tolerance
116    /// band. Set to 0 to disable. Persists across [`start`](Self::start) calls.
117    settle_tolerance:    f64,
118}
119
120/// Slack on the position-limit comparison. In axis user units (mm / deg /
121/// counts depending on `AxisConfig`). Treats positions within this many
122/// units of the limit as "at the limit," so floating-point round-off
123/// doesn't miss an exact-match crossing.
124const POSITION_LIMIT_TOLERANCE: f64 = 1e-4;
125
126#[repr(i32)]
127#[derive(Copy, Clone, PartialEq, Debug)]
128enum MtlState {
129    Idle     = 0,
130    Start    = 1,
131    Moving   = 10,
132    Stopping = 20,
133    /// Post-halt corrective move when the settle feature is enabled and
134    /// the load has overshot target beyond `settle_tolerance`. Axis is
135    /// commanded in the direction opposite to the primary motion at
136    /// `settle_speed`. Exits to `StoppingSettle` once the load is back
137    /// inside the tolerance band.
138    Settling = 30,
139    /// Waiting for the axis to come to rest after the settle move halts.
140    /// Returns to `Idle` once `axis.is_busy()` is false.
141    StoppingSettle = 40,
142    /// Externally-set: requests an immediate halt, returns to Idle.
143    Halt     = 50,
144}
145
146impl Default for MoveToLoad {
147    fn default() -> Self {
148        Self {
149            state:              StateMachine::new(),
150            moving_negative:    false,
151            position_limit:     0.0,
152            target_load:        0.0,
153            target_speed: 0.0,
154            target_accel : 0.0,
155            seen_busy:          false,
156            triggered_position: f64::NAN,
157            triggered_load:     f64::NAN,
158            dead_band:          0.0,
159            stop_decel:         None,
160            settle_speed:       0.0,
161            settle_tolerance:   0.0,
162        }
163    }
164}
165
166impl MoveToLoad {
167    /// Constructor.
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    /// Abort the operation. The axis will be halted by the next [`tick`](Self::tick).
173    pub fn abort(&mut self) {
174        self.state.set_error(200, "Abort called");
175        self.state.index = MtlState::Idle as i32;
176    }
177
178    /// Start the move-to-load operation.
179    ///
180    /// `target_load` and `position_limit` are latched on this call and used
181    /// by every subsequent [`tick`](Self::tick) until the FB returns to idle.
182    /// Any previous trigger position / load values are cleared.
183    pub fn start(
184        &mut self, 
185        target_load: f64, 
186        target_speed : f64, 
187        target_accel : f64, 
188        position_limit: f64
189    ) {
190        self.state.clear_error();
191        self.target_load        = target_load;
192        self.target_speed = target_speed;
193        self.target_accel = target_accel;
194        self.position_limit     = position_limit;
195        self.seen_busy          = false;
196        self.triggered_position = f64::NAN;
197        self.triggered_load     = f64::NAN;
198        self.state.index        = MtlState::Start as i32;
199    }
200
201    /// Reset the state machine to Idle. Does not halt the axis (call
202    /// [`abort`](Self::abort) for that, or halt the axis directly).
203    pub fn reset(&mut self) {
204        self.state.clear_error();
205        self.state.index = MtlState::Idle as i32;
206    }
207
208    /// True if the FB encountered an error during the last command.
209    pub fn is_error(&self) -> bool {
210        self.state.is_error()
211    }
212
213    /// Returns the error message for the last command.
214    pub fn error_message(&self) -> String {
215        return self.state.error_message.clone();
216    }    
217
218
219    /// True if the FB is currently executing a command.
220    pub fn is_busy(&self) -> bool {
221        self.state.index > MtlState::Idle as i32
222    }
223
224    /// Axis position at the moment the load threshold was crossed.
225    /// Returns `f64::NAN` until the FB has triggered at least once.
226    /// Cleared by [`start`](Self::start).
227    pub fn triggered_position(&self) -> f64 {
228        self.triggered_position
229    }
230
231    /// Load reading at the moment the threshold was crossed.
232    /// Returns `f64::NAN` until the FB has triggered at least once.
233    /// Cleared by [`start`](Self::start).
234    pub fn triggered_load(&self) -> f64 {
235        self.triggered_load
236    }
237
238    /// Set the early-trigger margin in load units. See the type-level
239    /// docs for what this is and when to use it.
240    ///
241    /// Negative values are clamped to `0.0`. The new value is read on
242    /// every [`tick`](Self::tick), so it can be changed mid-move if the
243    /// caller needs to.
244    pub fn set_dead_band(&mut self, value: f64) {
245        self.dead_band = value.max(0.0);
246    }
247
248    /// Currently-configured early-trigger margin.
249    pub fn dead_band(&self) -> f64 {
250        self.dead_band
251    }
252
253    /// Set the deceleration (user units / s²) used for the drive-side motion
254    /// profile. Typically populated once at process startup by SDO-reading
255    /// the drive's quick-stop deceleration (0x6085) and converting counts/s²
256    /// to user units.
257    ///
258    /// Pass `None` to revert to the default behavior (use `target_accel` as
259    /// the deceleration). Values ≤ 0 are treated as `None`. Persists across
260    /// [`start`](Self::start) calls.
261    pub fn set_stop_decel(&mut self, value: Option<f64>) {
262        self.stop_decel = value.filter(|v| *v > 0.0);
263    }
264
265    /// Currently-configured deceleration, or `None` if the FB is falling
266    /// back to `target_accel`.
267    pub fn stop_decel(&self) -> Option<f64> {
268        self.stop_decel
269    }
270
271    /// Enable the post-halt settle correction by setting both
272    /// `settle_speed` and `settle_tolerance` to non-zero values. Either
273    /// value at 0 disables the feature. See the `Settling` state docs.
274    ///
275    /// Values clamped to `>= 0`. Persists across [`start`](Self::start).
276    pub fn set_settle(&mut self, speed: f64, tolerance: f64) {
277        self.settle_speed     = speed.max(0.0);
278        self.settle_tolerance = tolerance.max(0.0);
279    }
280
281    /// Currently-configured settle speed (user units / s). Zero = disabled.
282    pub fn settle_speed(&self) -> f64 {
283        self.settle_speed
284    }
285
286    /// Currently-configured settle load tolerance. Zero = disabled.
287    pub fn settle_tolerance(&self) -> f64 {
288        self.settle_tolerance
289    }
290
291    /// Is the settle feature enabled? (Both speed and tolerance must be
292    /// > 0 for the Settling state to engage.)
293    fn settle_enabled(&self) -> bool {
294        self.settle_speed > 0.0 && self.settle_tolerance > 0.0
295    }
296
297    /// Execute the function block.
298    ///
299    /// - `axis`: the axis being driven.
300    /// - `current_load`: the latest load reading. Assumed already filtered
301    ///   by the load card — see the type-level docs on noise rejection.
302    pub fn tick(
303        &mut self,
304        axis:         &mut impl AxisHandle,
305        current_load: f64,
306    ) {
307        // Safety: a fault on the axis aborts any in-progress command.
308        if axis.is_error() && self.state.index > MtlState::Idle as i32 {
309            self.state.set_error(120, "Axis is in error state");
310            self.state.index = MtlState::Idle as i32;
311        }
312
313        match MtlState::from_index(self.state.index) {
314            Some(MtlState::Idle) => {
315                // do nothing
316            }
317            Some(MtlState::Start) => {
318                self.state.clear_error();
319                self.seen_busy = false;
320                // Direction is determined strictly so the dead_band region
321                // doesn't make direction undefined when current ≈ target.
322                self.moving_negative = current_load > self.target_load;
323
324                // Already at or past the load threshold (or within the
325                // dead-band of it)? Done — no point starting a move.
326                let reached = self.threshold_reached(current_load);
327
328                if reached {
329                    self.triggered_position = axis.position();
330                    self.triggered_load     = current_load;
331                    self.state.index        = MtlState::Idle as i32;
332                } else if self.already_past_limit(axis) {
333                    self.state.set_error(110, "Axis already past position limit before starting");
334                    self.state.index = MtlState::Idle as i32;
335                } else {
336                    let decel = self.stop_decel.unwrap_or(self.target_accel);
337                    axis.move_absolute(
338                        self.position_limit,
339                        self.target_speed,
340                        self.target_accel,
341                        decel,
342                    );
343                    self.state.index = MtlState::Moving as i32;
344                }
345            }
346            Some(MtlState::Moving) => {
347                // Latch is_busy=true once we see it so a one-tick
348                // command-acceptance window can't false-trigger the
349                // "stopped before reaching load" error below.
350                if axis.is_busy() {
351                    self.seen_busy = true;
352                }
353
354                let reached = self.threshold_reached(current_load);
355
356                if reached {
357                    self.triggered_position = axis.position();
358                    self.triggered_load     = current_load;
359                    axis.halt();
360                    self.state.index = MtlState::Stopping as i32;
361                    return;
362                }
363
364                let hit_limit = if self.moving_negative {
365                    axis.position() <= self.position_limit + POSITION_LIMIT_TOLERANCE
366                } else {
367                    axis.position() >= self.position_limit - POSITION_LIMIT_TOLERANCE
368                };
369                let stopped_unexpectedly = self.seen_busy && !axis.is_busy();
370
371                if hit_limit || stopped_unexpectedly {
372                    axis.halt();
373                    if hit_limit {
374                        
375                        self.state.set_error(150, 
376                            format!("[FB MoveToLoad] Reached position limit {} {} without hitting target load",
377                            if self.moving_negative {"moving NEG"} else {"moving POS"},
378                            self.position_limit)
379                        );
380
381                    }
382                    else {
383                        self.state.set_error(151, 
384                            "[FB MoveToLoad] Stoped unexpectedly without hitting target load."
385                        );                        
386                    }
387                    self.state.index = MtlState::Idle as i32;
388                }
389            }
390            Some(MtlState::Stopping) => {
391                if !axis.is_busy() {
392                    // Axis has come to rest after the primary halt. Decide
393                    // whether to trigger the settle correction. The feature
394                    // is only engaged if both config values are positive AND
395                    // the load has overshot target by more than
396                    // settle_tolerance. If the load is already close enough,
397                    // skip straight to Idle.
398                    if self.settle_enabled()
399                        && (current_load - self.target_load).abs() > self.settle_tolerance
400                    {
401                        // Reverse the direction of motion. "Moving negative"
402                        // in the primary move means load was decreasing, so
403                        // overshoot means the load went below target; we need
404                        // to go positive to raise it again. And vice versa.
405                        let settle_pos_limit = if self.moving_negative {
406                            // Went too far down; move up, but no farther than
407                            // the starting side of the primary move.
408                            // Use a conservative bound — the opposite of the
409                            // primary position_limit isn't known to us, so we
410                            // just use axis.position() + a generous window.
411                            axis.position() + self.settle_tolerance.abs() * 1.0e3
412                        } else {
413                            axis.position() - self.settle_tolerance.abs() * 1.0e3
414                        };
415                        let decel = self.stop_decel.unwrap_or(self.target_accel);
416                        axis.move_absolute(
417                            settle_pos_limit,
418                            self.settle_speed,
419                            self.target_accel,
420                            decel,
421                        );
422                        self.seen_busy     = false;
423                        self.state.index   = MtlState::Settling as i32;
424                    } else {
425                        self.state.index = MtlState::Idle as i32;
426                    }
427                }
428            }
429            Some(MtlState::Settling) => {
430                // Latch axis.is_busy() once; see Moving state for the same
431                // reasoning about the one-tick command-acceptance window.
432                if axis.is_busy() {
433                    self.seen_busy = true;
434                }
435
436                // Target direction for the settle move is the opposite of
437                // the primary move. When moving_negative was true, the
438                // primary move was decreasing the load; during settle we're
439                // INCREASING it back up, so we've settled once load has
440                // climbed back within tolerance OR gone past target.
441                let within_tolerance =
442                    (current_load - self.target_load).abs() <= self.settle_tolerance;
443
444                // Safety: if axis reports not busy before we've converged,
445                // either it hit a physical limit or the move was rejected.
446                // Either way, bail rather than spinning.
447                let stopped_unexpectedly = self.seen_busy && !axis.is_busy();
448
449                if within_tolerance {
450                    axis.halt();
451                    // Re-latch the trigger record to the settled values so
452                    // the caller gets what they actually ended up at.
453                    self.triggered_position = axis.position();
454                    self.triggered_load     = current_load;
455                    self.state.index = MtlState::StoppingSettle as i32;
456                } else if stopped_unexpectedly {
457                    axis.halt();
458                    self.state.set_error(
459                        160,
460                        "[FB MoveToLoad] Settle move stopped before load returned to tolerance",
461                    );
462                    self.state.index = MtlState::Idle as i32;
463                }
464            }
465            Some(MtlState::StoppingSettle) => {
466                if !axis.is_busy() {
467                    self.state.index = MtlState::Idle as i32;
468                }
469            }
470            Some(MtlState::Halt) => {
471                axis.halt();
472                self.state.index = MtlState::Idle as i32;
473            }
474            None => {
475                self.state.index = MtlState::Idle as i32;
476            }
477        }
478
479        self.state.call();
480    }
481
482    fn already_past_limit(&self, axis: &impl AxisHandle) -> bool {
483        if self.moving_negative {
484            axis.position() <= self.position_limit
485        } else {
486            axis.position() >= self.position_limit
487        }
488    }
489
490    /// Has the load reached (or come within `dead_band` of) the target,
491    /// from the direction we're moving? Used in both Start and Moving.
492    fn threshold_reached(&self, current_load: f64) -> bool {
493        if self.moving_negative {
494            // Load decreasing toward target — trip slightly above target
495            // so deceleration brings us down to it.
496            current_load <= self.target_load + self.dead_band
497        } else {
498            // Load increasing toward target — trip slightly below target.
499            current_load >= self.target_load - self.dead_band
500        }
501    }
502}
503
504impl MtlState {
505    fn from_index(idx: i32) -> Option<Self> {
506        match idx {
507            x if x == Self::Idle as i32            => Some(Self::Idle),
508            x if x == Self::Start as i32           => Some(Self::Start),
509            x if x == Self::Moving as i32          => Some(Self::Moving),
510            x if x == Self::Stopping as i32        => Some(Self::Stopping),
511            x if x == Self::Settling as i32        => Some(Self::Settling),
512            x if x == Self::StoppingSettle as i32  => Some(Self::StoppingSettle),
513            x if x == Self::Halt as i32            => Some(Self::Halt),
514            _ => None,
515        }
516    }
517}
518
519// -------------------------------------------------------------------------
520// Tests
521// -------------------------------------------------------------------------
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::motion::axis_config::AxisConfig;
527
528    /// Mock axis: records move/halt calls; the test mutates `position` and
529    /// `busy` to simulate motion.
530    struct MockAxis {
531        position:         f64,
532        busy:             bool,
533        error:            bool,
534        config:           AxisConfig,
535        halt_called:      u32,
536        last_move_target: f64,
537        last_move_accel:  f64,
538        last_move_decel:  f64,
539    }
540
541    impl MockAxis {
542        fn new() -> Self {
543            let cfg = AxisConfig::new(1000);
544            Self {
545                position: 0.0, busy: false, error: false, config: cfg,
546                halt_called: 0, last_move_target: 0.0,
547                last_move_accel: 0.0, last_move_decel: 0.0,
548            }
549        }
550    }
551
552    impl AxisHandle for MockAxis {
553        fn position(&self) -> f64 { self.position }
554        fn config(&self) -> &AxisConfig { &self.config }
555        fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
556        fn move_absolute(&mut self, p: f64, _: f64, accel: f64, decel: f64) {
557            self.last_move_target = p;
558            self.last_move_accel = accel;
559            self.last_move_decel = decel;
560            self.busy = true;
561        }
562        fn halt(&mut self) { self.halt_called += 1; self.busy = false; }
563        fn is_busy(&self) -> bool { self.busy }
564        fn is_error(&self) -> bool { self.error }
565        fn motor_on(&self) -> bool { true }
566    }
567
568    #[test]
569    fn already_at_load_completes_without_moving() {
570        let mut fb = MoveToLoad::new();
571        let mut axis = MockAxis::new();
572        axis.position = 5.0;
573
574        fb.start(100.0, 10.0, 100.0, 50.0);              // moving positive
575        fb.tick(&mut axis, 100.0);          // already at target
576
577        assert!(!fb.is_busy());
578        assert!(!fb.is_error());
579        assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
580        assert_eq!(fb.triggered_position(), 5.0);
581        assert_eq!(fb.triggered_load(), 100.0);
582    }
583
584    #[test]
585    fn already_past_limit_errors_immediately() {
586        let mut fb = MoveToLoad::new();
587        let mut axis = MockAxis::new();
588        axis.position = 60.0;               // already past the +50 limit
589
590        fb.start(100.0,10.0, 100.0, 50.0);
591        fb.tick(&mut axis, 0.0);            // load not reached
592
593        assert!(fb.is_error());
594        assert!(!fb.is_busy());
595        assert_eq!(axis.last_move_target, 0.0);
596    }
597
598    #[test]
599    fn moves_positive_then_triggers_on_load_threshold() {
600        let mut fb = MoveToLoad::new();
601        let mut axis = MockAxis::new();
602        axis.position = 0.0;
603
604        // Load below target → should command +position move.
605        fb.start(100.0,10.0, 100.0, 50.0);
606        fb.tick(&mut axis, 0.0);
607        assert_eq!(axis.last_move_target, 50.0);
608        assert!(axis.busy);
609
610        // Tick a few times below threshold — no halt.
611        axis.position = 10.0; fb.tick(&mut axis, 50.0);
612        axis.position = 20.0; fb.tick(&mut axis, 80.0);
613        assert_eq!(axis.halt_called, 0);
614
615        // Cross the threshold — halt + record trigger values.
616        axis.position = 25.0; fb.tick(&mut axis, 100.5);
617        assert_eq!(axis.halt_called, 1);
618        assert_eq!(fb.triggered_position(), 25.0);
619        assert_eq!(fb.triggered_load(), 100.5);
620
621        // Stopping → Idle once axis flips !busy. (halt() in the mock
622        // already cleared busy.)
623        fb.tick(&mut axis, 100.5);
624        assert!(!fb.is_busy());
625        assert!(!fb.is_error());
626    }
627
628    #[test]
629    fn moves_negative_when_load_above_target() {
630        let mut fb = MoveToLoad::new();
631        let mut axis = MockAxis::new();
632        axis.position = 100.0;
633
634        // Load above target → should command -position move.
635        fb.start(50.0, 10.0, 100.0,0.0);
636        fb.tick(&mut axis, 100.0);
637        assert_eq!(axis.last_move_target, 0.0);
638
639        axis.position = 50.0; fb.tick(&mut axis, 49.0);  // crossed (descending)
640        assert_eq!(axis.halt_called, 1);
641        assert_eq!(fb.triggered_load(), 49.0);
642    }
643
644    #[test]
645    fn position_limit_without_load_triggers_error() {
646        let mut fb = MoveToLoad::new();
647        let mut axis = MockAxis::new();
648        axis.position = 0.0;
649
650        fb.start(100.0, 10.0, 100.0,50.0);
651        fb.tick(&mut axis, 0.0);            // start → Moving
652        axis.position = 50.0;
653        fb.tick(&mut axis, 10.0);           // hit limit, load not reached
654
655        assert!(fb.is_error());
656        assert_eq!(axis.halt_called, 1);
657    }
658
659    #[test]
660    fn startup_busy_race_does_not_false_trigger() {
661        // Reproduces the original bug: between move_absolute and the drive
662        // flipping is_busy=true, the FB used to interpret !is_busy as
663        // "axis stopped without reaching load" and erroneously errored.
664        let mut fb = MoveToLoad::new();
665        let mut axis = MockAxis::new();
666        axis.position = 0.0;
667        axis.busy = false;                  // drive briefly reports !busy
668
669        fb.start(100.0,10.0, 100.0, 50.0);
670        fb.tick(&mut axis, 10.0);           // start → Moving; busy goes true via move_absolute
671        axis.busy = false;                  // simulate drive briefly reporting !busy
672        fb.tick(&mut axis, 20.0);           // would have errored before the fix
673
674        // Without seen_busy latching, this would now be in error.
675        assert!(!fb.is_error(), "must not error during the busy-acceptance window");
676    }
677
678    #[test]
679    fn abort_sets_error_and_returns_idle() {
680        let mut fb = MoveToLoad::new();
681        let mut axis = MockAxis::new();
682        fb.start(100.0,10.0, 100.0, 50.0);
683        fb.tick(&mut axis, 0.0);            // Moving
684        assert!(fb.is_busy());
685
686        fb.abort();
687        assert!(!fb.is_busy());
688        assert!(fb.is_error());
689    }
690
691    #[test]
692    fn external_halt_state_halts_axis() {
693        let mut fb = MoveToLoad::new();
694        let mut axis = MockAxis::new();
695        axis.busy = true;
696
697        // Set the Halt state index directly (this is how external code
698        // requests the halt path — the FB doesn't expose a public method
699        // for it; halting the axis directly is also fine).
700        fb.state.index = MtlState::Halt as i32;
701        fb.tick(&mut axis, 0.0);
702
703        assert_eq!(axis.halt_called, 1);
704        assert!(!fb.is_busy());
705    }
706
707    #[test]
708    fn axis_fault_aborts_in_progress_command() {
709        let mut fb = MoveToLoad::new();
710        let mut axis = MockAxis::new();
711        fb.start(100.0,10.0, 100.0, 50.0);
712        fb.tick(&mut axis, 0.0);            // Moving
713        axis.error = true;
714        fb.tick(&mut axis, 0.0);
715
716        assert!(fb.is_error());
717        assert!(!fb.is_busy());
718    }
719
720    #[test]
721    fn triggered_values_clear_on_restart() {
722        let mut fb = MoveToLoad::new();
723        let mut axis = MockAxis::new();
724
725        fb.start(100.0,10.0, 100.0, 50.0);
726        fb.tick(&mut axis, 100.0);          // already at target → triggered
727        assert_eq!(fb.triggered_load(), 100.0);
728
729        fb.start(50.0,10.0, 100.0, 0.0);                // restart
730        assert!(fb.triggered_load().is_nan());
731        assert!(fb.triggered_position().is_nan());
732    }
733
734    // ── dead_band (Pass B) ─────────────────────────────────────────────
735
736    #[test]
737    fn default_dead_band_is_zero() {
738        let fb = MoveToLoad::new();
739        assert_eq!(fb.dead_band(), 0.0);
740    }
741
742    #[test]
743    fn set_dead_band_clamps_negative_to_zero() {
744        let mut fb = MoveToLoad::new();
745        fb.set_dead_band(-5.0);
746        assert_eq!(fb.dead_band(), 0.0);
747        fb.set_dead_band(2.5);
748        assert_eq!(fb.dead_band(), 2.5);
749    }
750
751    #[test]
752    fn dead_band_persists_across_start_calls() {
753        let mut fb = MoveToLoad::new();
754        fb.set_dead_band(3.0);
755        fb.start(100.0,10.0, 100.0, 50.0);
756        assert_eq!(fb.dead_band(), 3.0);
757        fb.start(50.0,10.0, 100.0, 0.0);
758        assert_eq!(fb.dead_band(), 3.0, "configuration must outlive a single move");
759    }
760
761    #[test]
762    fn dead_band_triggers_early_for_positive_motion() {
763        let mut fb = MoveToLoad::new();
764        let mut axis = MockAxis::new();
765
766        // Without dead_band: would trigger at current >= 100.
767        // With dead_band = 5: triggers at current >= 95.
768        fb.set_dead_band(5.0);
769        fb.start(100.0,10.0, 100.0, 50.0);
770        fb.tick(&mut axis, 0.0);            // start moving
771        assert!(axis.busy);
772
773        // 94 — not yet inside the dead-band.
774        axis.position = 10.0; fb.tick(&mut axis, 94.0);
775        assert_eq!(axis.halt_called, 0);
776
777        // 95.5 — within dead_band of target → trip.
778        axis.position = 11.0; fb.tick(&mut axis, 95.5);
779        assert_eq!(axis.halt_called, 1);
780        assert_eq!(fb.triggered_load(), 95.5);
781    }
782
783    #[test]
784    fn dead_band_triggers_early_for_negative_motion() {
785        let mut fb = MoveToLoad::new();
786        let mut axis = MockAxis::new();
787        axis.position = 100.0;
788
789        // Target 50, current 100, dead_band 5:
790        // Without dead_band: triggers at current <= 50.
791        // With dead_band = 5: triggers at current <= 55.
792        fb.set_dead_band(5.0);
793        fb.start(50.0,10.0, 100.0, 0.0);
794        fb.tick(&mut axis, 100.0);          // start moving negative
795
796        axis.position = 75.0; fb.tick(&mut axis, 60.0);   // not yet in dead-band
797        assert_eq!(axis.halt_called, 0);
798
799        axis.position = 70.0; fb.tick(&mut axis, 54.5);   // inside dead_band → trip
800        assert_eq!(axis.halt_called, 1);
801        assert_eq!(fb.triggered_load(), 54.5);
802    }
803
804    #[test]
805    fn within_dead_band_at_start_completes_immediately() {
806        let mut fb = MoveToLoad::new();
807        let mut axis = MockAxis::new();
808        axis.position = 5.0;
809
810        // Target 100, dead_band 10. Current 92 is within dead_band → done.
811        fb.set_dead_band(10.0);
812        fb.start(100.0,10.0, 100.0, 50.0);
813        fb.tick(&mut axis, 92.0);
814
815        assert!(!fb.is_busy());
816        assert!(!fb.is_error());
817        assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
818        assert_eq!(fb.triggered_load(), 92.0);
819    }
820
821    // ── stop_decel ─────────────────────────────────────────────────────
822
823    #[test]
824    fn default_stop_decel_is_none_and_falls_back_to_target_accel() {
825        let mut fb = MoveToLoad::new();
826        let mut axis = MockAxis::new();
827        assert_eq!(fb.stop_decel(), None);
828
829        fb.start(100.0, 10.0, 250.0, 50.0);
830        fb.tick(&mut axis, 0.0);                 // Start → Moving
831        assert_eq!(axis.last_move_accel, 250.0);
832        assert_eq!(axis.last_move_decel, 250.0, "without set_stop_decel, decel == accel");
833    }
834
835    #[test]
836    fn set_stop_decel_forwards_to_move_absolute() {
837        let mut fb = MoveToLoad::new();
838        let mut axis = MockAxis::new();
839        fb.set_stop_decel(Some(800.0));
840        assert_eq!(fb.stop_decel(), Some(800.0));
841
842        fb.start(100.0, 10.0, 250.0, 50.0);
843        fb.tick(&mut axis, 0.0);
844        assert_eq!(axis.last_move_accel, 250.0);
845        assert_eq!(axis.last_move_decel, 800.0, "explicit stop_decel must flow through");
846    }
847
848    #[test]
849    fn set_stop_decel_none_reverts_to_fallback() {
850        let mut fb = MoveToLoad::new();
851        let mut axis = MockAxis::new();
852        fb.set_stop_decel(Some(800.0));
853        fb.set_stop_decel(None);
854        assert_eq!(fb.stop_decel(), None);
855
856        fb.start(100.0, 10.0, 250.0, 50.0);
857        fb.tick(&mut axis, 0.0);
858        assert_eq!(axis.last_move_decel, 250.0);
859    }
860
861    #[test]
862    fn set_stop_decel_rejects_non_positive() {
863        let mut fb = MoveToLoad::new();
864        fb.set_stop_decel(Some(0.0));
865        assert_eq!(fb.stop_decel(), None);
866        fb.set_stop_decel(Some(-5.0));
867        assert_eq!(fb.stop_decel(), None);
868    }
869
870    #[test]
871    fn stop_decel_persists_across_start_calls() {
872        let mut fb = MoveToLoad::new();
873        let mut axis = MockAxis::new();
874        fb.set_stop_decel(Some(800.0));
875
876        fb.start(100.0, 10.0, 250.0, 50.0);
877        fb.tick(&mut axis, 0.0);
878        assert_eq!(axis.last_move_decel, 800.0);
879
880        fb.start(50.0, 10.0, 250.0, 0.0);
881        assert_eq!(fb.stop_decel(), Some(800.0), "configuration must outlive a single move");
882    }
883
884    #[test]
885    fn dead_band_zero_matches_strict_pass_a_behavior() {
886        // Regression: dead_band = 0 must reproduce the strict-equality
887        // behavior tested in Pass A.
888        let mut fb = MoveToLoad::new();
889        let mut axis = MockAxis::new();
890
891        fb.set_dead_band(0.0);
892        fb.start(100.0,10.0, 100.0, 50.0);
893        fb.tick(&mut axis, 0.0);
894
895        axis.position = 10.0; fb.tick(&mut axis, 99.99);
896        assert_eq!(axis.halt_called, 0, "must not trip at 99.99 with dead_band=0");
897
898        axis.position = 11.0; fb.tick(&mut axis, 100.0);
899        assert_eq!(axis.halt_called, 1, "exact equality must trip");
900    }
901
902    // ---------------------------------------------------------------------
903    // Settle feature
904    // ---------------------------------------------------------------------
905
906    #[test]
907    fn settle_disabled_skips_settling_state() {
908        // Default settle_speed=0 / settle_tolerance=0 means the feature
909        // is off: even with a huge load mismatch after halt, we should
910        // go Stopping → Idle without visiting Settling.
911        let mut fb = MoveToLoad::new();
912        let mut axis = MockAxis::new();
913
914        fb.start(100.0, 10.0, 100.0, 50.0);
915        fb.tick(&mut axis, 0.0);                 // Start → Moving, issues move
916        axis.position = 5.0; fb.tick(&mut axis, 100.0); // trigger
917        assert_eq!(axis.halt_called, 1);
918
919        // axis finishes decelerating; load overshot to 150 N.
920        axis.busy = false;
921        fb.tick(&mut axis, 150.0);
922        assert!(!fb.is_busy(), "should return to Idle without settling");
923        assert_eq!(axis.halt_called, 1, "no second halt from settle");
924    }
925
926    #[test]
927    fn settle_inside_tolerance_skips_settling_state() {
928        // Feature enabled but the post-halt load is already within
929        // settle_tolerance — should still skip Settling.
930        let mut fb = MoveToLoad::new();
931        let mut axis = MockAxis::new();
932
933        fb.set_settle(1.0, 5.0);                 // 5 N tolerance
934        fb.start(100.0, 10.0, 100.0, 50.0);
935        fb.tick(&mut axis, 0.0);
936        axis.position = 5.0; fb.tick(&mut axis, 100.0);
937        axis.busy = false;
938        fb.tick(&mut axis, 102.0);               // overshoot 2 N, within tol
939        assert!(!fb.is_busy(), "should go straight Idle");
940    }
941
942    #[test]
943    fn settle_triggers_corrective_move_on_overshoot() {
944        let mut fb = MoveToLoad::new();
945        let mut axis = MockAxis::new();
946
947        fb.set_settle(1.0, 5.0);                 // slow, 5 N tolerance
948        fb.start(100.0, 10.0, 100.0, 50.0);      // moving positive (load increasing)
949        fb.tick(&mut axis, 0.0);                 // Start → Moving
950        axis.position = 5.0; fb.tick(&mut axis, 100.0); // trigger at exact target
951        assert_eq!(axis.halt_called, 1);
952
953        // Overshot to 120 N while decelerating.
954        axis.busy = false;
955        let settle_calls_before = axis.halt_called;
956        fb.tick(&mut axis, 120.0);
957
958        assert!(fb.is_busy(), "should enter Settling");
959        assert_eq!(axis.halt_called, settle_calls_before,
960            "no halt yet; Settling issues a slow move first");
961
962        // Simulate the axis retreating — load drops back into tolerance.
963        axis.position = 4.7; fb.tick(&mut axis, 103.0);
964        assert_eq!(axis.halt_called, settle_calls_before + 1,
965            "settle converged, halt issued");
966
967        axis.busy = false;
968        fb.tick(&mut axis, 103.0);               // StoppingSettle → Idle
969        assert!(!fb.is_busy());
970        assert_eq!(fb.triggered_load(), 103.0,
971            "triggered values re-latched to settled state");
972    }
973
974    #[test]
975    fn settle_unexpected_stop_is_an_error() {
976        // If the axis stops during Settling before the load gets back into
977        // tolerance, emit an error — probably hit a physical limit.
978        let mut fb = MoveToLoad::new();
979        let mut axis = MockAxis::new();
980
981        fb.set_settle(1.0, 5.0);
982        fb.start(100.0, 10.0, 100.0, 50.0);
983        fb.tick(&mut axis, 0.0);
984        axis.position = 5.0; fb.tick(&mut axis, 100.0);
985        axis.busy = false;
986        fb.tick(&mut axis, 120.0);               // Stopping → Settling
987
988        // Tick once with busy=true so seen_busy latches.
989        fb.tick(&mut axis, 118.0);
990
991        // Now axis reports not busy without the load reaching tolerance.
992        axis.busy = false;
993        fb.tick(&mut axis, 118.0);
994
995        assert!(fb.is_error());
996        assert!(!fb.is_busy());
997        assert!(fb.error_message().contains("Settle"));
998    }
999
1000    #[test]
1001    fn set_settle_clamps_negative_values() {
1002        let mut fb = MoveToLoad::new();
1003        fb.set_settle(-5.0, -1.0);
1004        assert_eq!(fb.settle_speed(), 0.0);
1005        assert_eq!(fb.settle_tolerance(), 0.0);
1006    }
1007}