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}