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
106/// Slack on the position-limit comparison. In axis user units (mm / deg /
107/// counts depending on `AxisConfig`). Treats positions within this many
108/// units of the limit as "at the limit," so floating-point round-off
109/// doesn't miss an exact-match crossing.
110const POSITION_LIMIT_TOLERANCE: f64 = 1e-4;
111
112#[repr(i32)]
113#[derive(Copy, Clone, PartialEq, Debug)]
114enum MtlState {
115    Idle     = 0,
116    Start    = 1,
117    Moving   = 10,
118    Stopping = 20,
119    /// Externally-set: requests an immediate halt, returns to Idle.
120    Halt     = 50,
121}
122
123impl Default for MoveToLoad {
124    fn default() -> Self {
125        Self {
126            state:              StateMachine::new(),
127            moving_negative:    false,
128            position_limit:     0.0,
129            target_load:        0.0,
130            target_speed: 0.0,
131            target_accel : 0.0,
132            seen_busy:          false,
133            triggered_position: f64::NAN,
134            triggered_load:     f64::NAN,
135            dead_band:          0.0,
136            stop_decel:         None,
137        }
138    }
139}
140
141impl MoveToLoad {
142    /// Constructor.
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Abort the operation. The axis will be halted by the next [`tick`](Self::tick).
148    pub fn abort(&mut self) {
149        self.state.set_error(200, "Abort called");
150        self.state.index = MtlState::Idle as i32;
151    }
152
153    /// Start the move-to-load operation.
154    ///
155    /// `target_load` and `position_limit` are latched on this call and used
156    /// by every subsequent [`tick`](Self::tick) until the FB returns to idle.
157    /// Any previous trigger position / load values are cleared.
158    pub fn start(
159        &mut self, 
160        target_load: f64, 
161        target_speed : f64, 
162        target_accel : f64, 
163        position_limit: f64
164    ) {
165        self.state.clear_error();
166        self.target_load        = target_load;
167        self.target_speed = target_speed;
168        self.target_accel = target_accel;
169        self.position_limit     = position_limit;
170        self.seen_busy          = false;
171        self.triggered_position = f64::NAN;
172        self.triggered_load     = f64::NAN;
173        self.state.index        = MtlState::Start as i32;
174    }
175
176    /// Reset the state machine to Idle. Does not halt the axis (call
177    /// [`abort`](Self::abort) for that, or halt the axis directly).
178    pub fn reset(&mut self) {
179        self.state.clear_error();
180        self.state.index = MtlState::Idle as i32;
181    }
182
183    /// True if the FB encountered an error during the last command.
184    pub fn is_error(&self) -> bool {
185        self.state.is_error()
186    }
187
188    /// Returns the error message for the last command.
189    pub fn error_message(&self) -> String {
190        return self.state.error_message.clone();
191    }    
192
193
194    /// True if the FB is currently executing a command.
195    pub fn is_busy(&self) -> bool {
196        self.state.index > MtlState::Idle as i32
197    }
198
199    /// Axis position at the moment the load threshold was crossed.
200    /// Returns `f64::NAN` until the FB has triggered at least once.
201    /// Cleared by [`start`](Self::start).
202    pub fn triggered_position(&self) -> f64 {
203        self.triggered_position
204    }
205
206    /// Load reading at the moment the threshold was crossed.
207    /// Returns `f64::NAN` until the FB has triggered at least once.
208    /// Cleared by [`start`](Self::start).
209    pub fn triggered_load(&self) -> f64 {
210        self.triggered_load
211    }
212
213    /// Set the early-trigger margin in load units. See the type-level
214    /// docs for what this is and when to use it.
215    ///
216    /// Negative values are clamped to `0.0`. The new value is read on
217    /// every [`tick`](Self::tick), so it can be changed mid-move if the
218    /// caller needs to.
219    pub fn set_dead_band(&mut self, value: f64) {
220        self.dead_band = value.max(0.0);
221    }
222
223    /// Currently-configured early-trigger margin.
224    pub fn dead_band(&self) -> f64 {
225        self.dead_band
226    }
227
228    /// Set the deceleration (user units / s²) used for the drive-side motion
229    /// profile. Typically populated once at process startup by SDO-reading
230    /// the drive's quick-stop deceleration (0x6085) and converting counts/s²
231    /// to user units.
232    ///
233    /// Pass `None` to revert to the default behavior (use `target_accel` as
234    /// the deceleration). Values ≤ 0 are treated as `None`. Persists across
235    /// [`start`](Self::start) calls.
236    pub fn set_stop_decel(&mut self, value: Option<f64>) {
237        self.stop_decel = value.filter(|v| *v > 0.0);
238    }
239
240    /// Currently-configured deceleration, or `None` if the FB is falling
241    /// back to `target_accel`.
242    pub fn stop_decel(&self) -> Option<f64> {
243        self.stop_decel
244    }
245
246    /// Execute the function block.
247    ///
248    /// - `axis`: the axis being driven.
249    /// - `current_load`: the latest load reading. Assumed already filtered
250    ///   by the load card — see the type-level docs on noise rejection.
251    pub fn tick(
252        &mut self,
253        axis:         &mut impl AxisHandle,
254        current_load: f64,
255    ) {
256        // Safety: a fault on the axis aborts any in-progress command.
257        if axis.is_error() && self.state.index > MtlState::Idle as i32 {
258            self.state.set_error(120, "Axis is in error state");
259            self.state.index = MtlState::Idle as i32;
260        }
261
262        match MtlState::from_index(self.state.index) {
263            Some(MtlState::Idle) => {
264                // do nothing
265            }
266            Some(MtlState::Start) => {
267                self.state.clear_error();
268                self.seen_busy = false;
269                // Direction is determined strictly so the dead_band region
270                // doesn't make direction undefined when current ≈ target.
271                self.moving_negative = current_load > self.target_load;
272
273                // Already at or past the load threshold (or within the
274                // dead-band of it)? Done — no point starting a move.
275                let reached = self.threshold_reached(current_load);
276
277                if reached {
278                    self.triggered_position = axis.position();
279                    self.triggered_load     = current_load;
280                    self.state.index        = MtlState::Idle as i32;
281                } else if self.already_past_limit(axis) {
282                    self.state.set_error(110, "Axis already past position limit before starting");
283                    self.state.index = MtlState::Idle as i32;
284                } else {
285                    let decel = self.stop_decel.unwrap_or(self.target_accel);
286                    axis.move_absolute(
287                        self.position_limit,
288                        self.target_speed,
289                        self.target_accel,
290                        decel,
291                    );
292                    self.state.index = MtlState::Moving as i32;
293                }
294            }
295            Some(MtlState::Moving) => {
296                // Latch is_busy=true once we see it so a one-tick
297                // command-acceptance window can't false-trigger the
298                // "stopped before reaching load" error below.
299                if axis.is_busy() {
300                    self.seen_busy = true;
301                }
302
303                let reached = self.threshold_reached(current_load);
304
305                if reached {
306                    self.triggered_position = axis.position();
307                    self.triggered_load     = current_load;
308                    axis.halt();
309                    self.state.index = MtlState::Stopping as i32;
310                    return;
311                }
312
313                let hit_limit = if self.moving_negative {
314                    axis.position() <= self.position_limit + POSITION_LIMIT_TOLERANCE
315                } else {
316                    axis.position() >= self.position_limit - POSITION_LIMIT_TOLERANCE
317                };
318                let stopped_unexpectedly = self.seen_busy && !axis.is_busy();
319
320                if hit_limit || stopped_unexpectedly {
321                    axis.halt();
322                    if hit_limit {
323                        
324                        self.state.set_error(150, 
325                            format!("[FB MoveToLoad] Reached position limit {} {} without hitting target load",
326                            if self.moving_negative {"moving NEG"} else {"moving POS"},
327                            self.position_limit)
328                        );
329
330                    }
331                    else {
332                        self.state.set_error(151, 
333                            "[FB MoveToLoad] Stoped unexpectedly without hitting target load."
334                        );                        
335                    }
336                    self.state.index = MtlState::Idle as i32;
337                }
338            }
339            Some(MtlState::Stopping) => {
340                // Axis has come to rest after the halt — the move is complete.
341                if !axis.is_busy() {
342                    self.state.index = MtlState::Idle as i32;
343                }
344            }
345            Some(MtlState::Halt) => {
346                axis.halt();
347                self.state.index = MtlState::Idle as i32;
348            }
349            None => {
350                self.state.index = MtlState::Idle as i32;
351            }
352        }
353
354        self.state.call();
355    }
356
357    fn already_past_limit(&self, axis: &impl AxisHandle) -> bool {
358        if self.moving_negative {
359            axis.position() <= self.position_limit
360        } else {
361            axis.position() >= self.position_limit
362        }
363    }
364
365    /// Has the load reached (or come within `dead_band` of) the target,
366    /// from the direction we're moving? Used in both Start and Moving.
367    fn threshold_reached(&self, current_load: f64) -> bool {
368        if self.moving_negative {
369            // Load decreasing toward target — trip slightly above target
370            // so deceleration brings us down to it.
371            current_load <= self.target_load + self.dead_band
372        } else {
373            // Load increasing toward target — trip slightly below target.
374            current_load >= self.target_load - self.dead_band
375        }
376    }
377}
378
379impl MtlState {
380    fn from_index(idx: i32) -> Option<Self> {
381        match idx {
382            x if x == Self::Idle as i32            => Some(Self::Idle),
383            x if x == Self::Start as i32           => Some(Self::Start),
384            x if x == Self::Moving as i32          => Some(Self::Moving),
385            x if x == Self::Stopping as i32        => Some(Self::Stopping),
386            x if x == Self::Halt as i32            => Some(Self::Halt),
387            _ => None,
388        }
389    }
390}
391
392// -------------------------------------------------------------------------
393// Tests
394// -------------------------------------------------------------------------
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::motion::axis_config::AxisConfig;
400
401    /// Mock axis: records move/halt calls; the test mutates `position` and
402    /// `busy` to simulate motion.
403    struct MockAxis {
404        position:         f64,
405        busy:             bool,
406        error:            bool,
407        config:           AxisConfig,
408        halt_called:      u32,
409        last_move_target: f64,
410        last_move_accel:  f64,
411        last_move_decel:  f64,
412    }
413
414    impl MockAxis {
415        fn new() -> Self {
416            let cfg = AxisConfig::new(1000);
417            Self {
418                position: 0.0, busy: false, error: false, config: cfg,
419                halt_called: 0, last_move_target: 0.0,
420                last_move_accel: 0.0, last_move_decel: 0.0,
421            }
422        }
423    }
424
425    impl AxisHandle for MockAxis {
426        fn position(&self) -> f64 { self.position }
427        fn config(&self) -> &AxisConfig { &self.config }
428        fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
429        fn move_absolute(&mut self, p: f64, _: f64, accel: f64, decel: f64) {
430            self.last_move_target = p;
431            self.last_move_accel = accel;
432            self.last_move_decel = decel;
433            self.busy = true;
434        }
435        fn halt(&mut self) { self.halt_called += 1; self.busy = false; }
436        fn is_busy(&self) -> bool { self.busy }
437        fn is_error(&self) -> bool { self.error }
438        fn motor_on(&self) -> bool { true }
439    }
440
441    #[test]
442    fn already_at_load_completes_without_moving() {
443        let mut fb = MoveToLoad::new();
444        let mut axis = MockAxis::new();
445        axis.position = 5.0;
446
447        fb.start(100.0, 10.0, 100.0, 50.0);              // moving positive
448        fb.tick(&mut axis, 100.0);          // already at target
449
450        assert!(!fb.is_busy());
451        assert!(!fb.is_error());
452        assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
453        assert_eq!(fb.triggered_position(), 5.0);
454        assert_eq!(fb.triggered_load(), 100.0);
455    }
456
457    #[test]
458    fn already_past_limit_errors_immediately() {
459        let mut fb = MoveToLoad::new();
460        let mut axis = MockAxis::new();
461        axis.position = 60.0;               // already past the +50 limit
462
463        fb.start(100.0,10.0, 100.0, 50.0);
464        fb.tick(&mut axis, 0.0);            // load not reached
465
466        assert!(fb.is_error());
467        assert!(!fb.is_busy());
468        assert_eq!(axis.last_move_target, 0.0);
469    }
470
471    #[test]
472    fn moves_positive_then_triggers_on_load_threshold() {
473        let mut fb = MoveToLoad::new();
474        let mut axis = MockAxis::new();
475        axis.position = 0.0;
476
477        // Load below target → should command +position move.
478        fb.start(100.0,10.0, 100.0, 50.0);
479        fb.tick(&mut axis, 0.0);
480        assert_eq!(axis.last_move_target, 50.0);
481        assert!(axis.busy);
482
483        // Tick a few times below threshold — no halt.
484        axis.position = 10.0; fb.tick(&mut axis, 50.0);
485        axis.position = 20.0; fb.tick(&mut axis, 80.0);
486        assert_eq!(axis.halt_called, 0);
487
488        // Cross the threshold — halt + record trigger values.
489        axis.position = 25.0; fb.tick(&mut axis, 100.5);
490        assert_eq!(axis.halt_called, 1);
491        assert_eq!(fb.triggered_position(), 25.0);
492        assert_eq!(fb.triggered_load(), 100.5);
493
494        // Stopping → Idle once axis flips !busy. (halt() in the mock
495        // already cleared busy.)
496        fb.tick(&mut axis, 100.5);
497        assert!(!fb.is_busy());
498        assert!(!fb.is_error());
499    }
500
501    #[test]
502    fn moves_negative_when_load_above_target() {
503        let mut fb = MoveToLoad::new();
504        let mut axis = MockAxis::new();
505        axis.position = 100.0;
506
507        // Load above target → should command -position move.
508        fb.start(50.0, 10.0, 100.0,0.0);
509        fb.tick(&mut axis, 100.0);
510        assert_eq!(axis.last_move_target, 0.0);
511
512        axis.position = 50.0; fb.tick(&mut axis, 49.0);  // crossed (descending)
513        assert_eq!(axis.halt_called, 1);
514        assert_eq!(fb.triggered_load(), 49.0);
515    }
516
517    #[test]
518    fn position_limit_without_load_triggers_error() {
519        let mut fb = MoveToLoad::new();
520        let mut axis = MockAxis::new();
521        axis.position = 0.0;
522
523        fb.start(100.0, 10.0, 100.0,50.0);
524        fb.tick(&mut axis, 0.0);            // start → Moving
525        axis.position = 50.0;
526        fb.tick(&mut axis, 10.0);           // hit limit, load not reached
527
528        assert!(fb.is_error());
529        assert_eq!(axis.halt_called, 1);
530    }
531
532    #[test]
533    fn startup_busy_race_does_not_false_trigger() {
534        // Reproduces the original bug: between move_absolute and the drive
535        // flipping is_busy=true, the FB used to interpret !is_busy as
536        // "axis stopped without reaching load" and erroneously errored.
537        let mut fb = MoveToLoad::new();
538        let mut axis = MockAxis::new();
539        axis.position = 0.0;
540        axis.busy = false;                  // drive briefly reports !busy
541
542        fb.start(100.0,10.0, 100.0, 50.0);
543        fb.tick(&mut axis, 10.0);           // start → Moving; busy goes true via move_absolute
544        axis.busy = false;                  // simulate drive briefly reporting !busy
545        fb.tick(&mut axis, 20.0);           // would have errored before the fix
546
547        // Without seen_busy latching, this would now be in error.
548        assert!(!fb.is_error(), "must not error during the busy-acceptance window");
549    }
550
551    #[test]
552    fn abort_sets_error_and_returns_idle() {
553        let mut fb = MoveToLoad::new();
554        let mut axis = MockAxis::new();
555        fb.start(100.0,10.0, 100.0, 50.0);
556        fb.tick(&mut axis, 0.0);            // Moving
557        assert!(fb.is_busy());
558
559        fb.abort();
560        assert!(!fb.is_busy());
561        assert!(fb.is_error());
562    }
563
564    #[test]
565    fn external_halt_state_halts_axis() {
566        let mut fb = MoveToLoad::new();
567        let mut axis = MockAxis::new();
568        axis.busy = true;
569
570        // Set the Halt state index directly (this is how external code
571        // requests the halt path — the FB doesn't expose a public method
572        // for it; halting the axis directly is also fine).
573        fb.state.index = MtlState::Halt as i32;
574        fb.tick(&mut axis, 0.0);
575
576        assert_eq!(axis.halt_called, 1);
577        assert!(!fb.is_busy());
578    }
579
580    #[test]
581    fn axis_fault_aborts_in_progress_command() {
582        let mut fb = MoveToLoad::new();
583        let mut axis = MockAxis::new();
584        fb.start(100.0,10.0, 100.0, 50.0);
585        fb.tick(&mut axis, 0.0);            // Moving
586        axis.error = true;
587        fb.tick(&mut axis, 0.0);
588
589        assert!(fb.is_error());
590        assert!(!fb.is_busy());
591    }
592
593    #[test]
594    fn triggered_values_clear_on_restart() {
595        let mut fb = MoveToLoad::new();
596        let mut axis = MockAxis::new();
597
598        fb.start(100.0,10.0, 100.0, 50.0);
599        fb.tick(&mut axis, 100.0);          // already at target → triggered
600        assert_eq!(fb.triggered_load(), 100.0);
601
602        fb.start(50.0,10.0, 100.0, 0.0);                // restart
603        assert!(fb.triggered_load().is_nan());
604        assert!(fb.triggered_position().is_nan());
605    }
606
607    // ── dead_band (Pass B) ─────────────────────────────────────────────
608
609    #[test]
610    fn default_dead_band_is_zero() {
611        let fb = MoveToLoad::new();
612        assert_eq!(fb.dead_band(), 0.0);
613    }
614
615    #[test]
616    fn set_dead_band_clamps_negative_to_zero() {
617        let mut fb = MoveToLoad::new();
618        fb.set_dead_band(-5.0);
619        assert_eq!(fb.dead_band(), 0.0);
620        fb.set_dead_band(2.5);
621        assert_eq!(fb.dead_band(), 2.5);
622    }
623
624    #[test]
625    fn dead_band_persists_across_start_calls() {
626        let mut fb = MoveToLoad::new();
627        fb.set_dead_band(3.0);
628        fb.start(100.0,10.0, 100.0, 50.0);
629        assert_eq!(fb.dead_band(), 3.0);
630        fb.start(50.0,10.0, 100.0, 0.0);
631        assert_eq!(fb.dead_band(), 3.0, "configuration must outlive a single move");
632    }
633
634    #[test]
635    fn dead_band_triggers_early_for_positive_motion() {
636        let mut fb = MoveToLoad::new();
637        let mut axis = MockAxis::new();
638
639        // Without dead_band: would trigger at current >= 100.
640        // With dead_band = 5: triggers at current >= 95.
641        fb.set_dead_band(5.0);
642        fb.start(100.0,10.0, 100.0, 50.0);
643        fb.tick(&mut axis, 0.0);            // start moving
644        assert!(axis.busy);
645
646        // 94 — not yet inside the dead-band.
647        axis.position = 10.0; fb.tick(&mut axis, 94.0);
648        assert_eq!(axis.halt_called, 0);
649
650        // 95.5 — within dead_band of target → trip.
651        axis.position = 11.0; fb.tick(&mut axis, 95.5);
652        assert_eq!(axis.halt_called, 1);
653        assert_eq!(fb.triggered_load(), 95.5);
654    }
655
656    #[test]
657    fn dead_band_triggers_early_for_negative_motion() {
658        let mut fb = MoveToLoad::new();
659        let mut axis = MockAxis::new();
660        axis.position = 100.0;
661
662        // Target 50, current 100, dead_band 5:
663        // Without dead_band: triggers at current <= 50.
664        // With dead_band = 5: triggers at current <= 55.
665        fb.set_dead_band(5.0);
666        fb.start(50.0,10.0, 100.0, 0.0);
667        fb.tick(&mut axis, 100.0);          // start moving negative
668
669        axis.position = 75.0; fb.tick(&mut axis, 60.0);   // not yet in dead-band
670        assert_eq!(axis.halt_called, 0);
671
672        axis.position = 70.0; fb.tick(&mut axis, 54.5);   // inside dead_band → trip
673        assert_eq!(axis.halt_called, 1);
674        assert_eq!(fb.triggered_load(), 54.5);
675    }
676
677    #[test]
678    fn within_dead_band_at_start_completes_immediately() {
679        let mut fb = MoveToLoad::new();
680        let mut axis = MockAxis::new();
681        axis.position = 5.0;
682
683        // Target 100, dead_band 10. Current 92 is within dead_band → done.
684        fb.set_dead_band(10.0);
685        fb.start(100.0,10.0, 100.0, 50.0);
686        fb.tick(&mut axis, 92.0);
687
688        assert!(!fb.is_busy());
689        assert!(!fb.is_error());
690        assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
691        assert_eq!(fb.triggered_load(), 92.0);
692    }
693
694    // ── stop_decel ─────────────────────────────────────────────────────
695
696    #[test]
697    fn default_stop_decel_is_none_and_falls_back_to_target_accel() {
698        let mut fb = MoveToLoad::new();
699        let mut axis = MockAxis::new();
700        assert_eq!(fb.stop_decel(), None);
701
702        fb.start(100.0, 10.0, 250.0, 50.0);
703        fb.tick(&mut axis, 0.0);                 // Start → Moving
704        assert_eq!(axis.last_move_accel, 250.0);
705        assert_eq!(axis.last_move_decel, 250.0, "without set_stop_decel, decel == accel");
706    }
707
708    #[test]
709    fn set_stop_decel_forwards_to_move_absolute() {
710        let mut fb = MoveToLoad::new();
711        let mut axis = MockAxis::new();
712        fb.set_stop_decel(Some(800.0));
713        assert_eq!(fb.stop_decel(), Some(800.0));
714
715        fb.start(100.0, 10.0, 250.0, 50.0);
716        fb.tick(&mut axis, 0.0);
717        assert_eq!(axis.last_move_accel, 250.0);
718        assert_eq!(axis.last_move_decel, 800.0, "explicit stop_decel must flow through");
719    }
720
721    #[test]
722    fn set_stop_decel_none_reverts_to_fallback() {
723        let mut fb = MoveToLoad::new();
724        let mut axis = MockAxis::new();
725        fb.set_stop_decel(Some(800.0));
726        fb.set_stop_decel(None);
727        assert_eq!(fb.stop_decel(), None);
728
729        fb.start(100.0, 10.0, 250.0, 50.0);
730        fb.tick(&mut axis, 0.0);
731        assert_eq!(axis.last_move_decel, 250.0);
732    }
733
734    #[test]
735    fn set_stop_decel_rejects_non_positive() {
736        let mut fb = MoveToLoad::new();
737        fb.set_stop_decel(Some(0.0));
738        assert_eq!(fb.stop_decel(), None);
739        fb.set_stop_decel(Some(-5.0));
740        assert_eq!(fb.stop_decel(), None);
741    }
742
743    #[test]
744    fn stop_decel_persists_across_start_calls() {
745        let mut fb = MoveToLoad::new();
746        let mut axis = MockAxis::new();
747        fb.set_stop_decel(Some(800.0));
748
749        fb.start(100.0, 10.0, 250.0, 50.0);
750        fb.tick(&mut axis, 0.0);
751        assert_eq!(axis.last_move_decel, 800.0);
752
753        fb.start(50.0, 10.0, 250.0, 0.0);
754        assert_eq!(fb.stop_decel(), Some(800.0), "configuration must outlive a single move");
755    }
756
757    #[test]
758    fn dead_band_zero_matches_strict_pass_a_behavior() {
759        // Regression: dead_band = 0 must reproduce the strict-equality
760        // behavior tested in Pass A.
761        let mut fb = MoveToLoad::new();
762        let mut axis = MockAxis::new();
763
764        fb.set_dead_band(0.0);
765        fb.start(100.0,10.0, 100.0, 50.0);
766        fb.tick(&mut axis, 0.0);
767
768        axis.position = 10.0; fb.tick(&mut axis, 99.99);
769        assert_eq!(axis.halt_called, 0, "must not trip at 99.99 with dead_band=0");
770
771        axis.position = 11.0; fb.tick(&mut axis, 100.0);
772        assert_eq!(axis.halt_called, 1, "exact equality must trip");
773    }
774
775}