1use crate::fb::StateMachine;
2use super::axis_view::AxisHandle;
3
4#[derive(Debug, Clone)]
66pub struct MoveToLoad {
67 state: StateMachine,
69 moving_negative: bool,
70 position_limit: f64,
71 target_load: f64,
72
73 target_speed: f64,
76 target_accel : f64,
79
80 seen_busy: bool,
85 triggered_position: f64,
88 triggered_load: f64,
91 dead_band: f64,
98 stop_decel: Option<f64>,
104}
105
106const 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 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 pub fn new() -> Self {
144 Self::default()
145 }
146
147 pub fn abort(&mut self) {
149 self.state.set_error(200, "Abort called");
150 self.state.index = MtlState::Idle as i32;
151 }
152
153 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 pub fn reset(&mut self) {
179 self.state.clear_error();
180 self.state.index = MtlState::Idle as i32;
181 }
182
183 pub fn is_error(&self) -> bool {
185 self.state.is_error()
186 }
187
188 pub fn error_message(&self) -> String {
190 return self.state.error_message.clone();
191 }
192
193
194 pub fn is_busy(&self) -> bool {
196 self.state.index > MtlState::Idle as i32
197 }
198
199 pub fn triggered_position(&self) -> f64 {
203 self.triggered_position
204 }
205
206 pub fn triggered_load(&self) -> f64 {
210 self.triggered_load
211 }
212
213 pub fn set_dead_band(&mut self, value: f64) {
220 self.dead_band = value.max(0.0);
221 }
222
223 pub fn dead_band(&self) -> f64 {
225 self.dead_band
226 }
227
228 pub fn set_stop_decel(&mut self, value: Option<f64>) {
237 self.stop_decel = value.filter(|v| *v > 0.0);
238 }
239
240 pub fn stop_decel(&self) -> Option<f64> {
243 self.stop_decel
244 }
245
246 pub fn tick(
252 &mut self,
253 axis: &mut impl AxisHandle,
254 current_load: f64,
255 ) {
256 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 }
266 Some(MtlState::Start) => {
267 self.state.clear_error();
268 self.seen_busy = false;
269 self.moving_negative = current_load > self.target_load;
272
273 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 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 if !axis.is_busy() {
341 self.state.index = MtlState::Idle as i32;
342 }
343 }
344 Some(MtlState::Halt) => {
345 axis.halt();
346 self.state.index = MtlState::Idle as i32;
347 }
348 None => {
349 self.state.index = MtlState::Idle as i32;
350 }
351 }
352
353 self.state.call();
354 }
355
356 fn already_past_limit(&self, axis: &impl AxisHandle) -> bool {
357 if self.moving_negative {
358 axis.position() <= self.position_limit
359 } else {
360 axis.position() >= self.position_limit
361 }
362 }
363
364 fn threshold_reached(&self, current_load: f64) -> bool {
367 if self.moving_negative {
368 current_load <= self.target_load + self.dead_band
371 } else {
372 current_load >= self.target_load - self.dead_band
374 }
375 }
376}
377
378impl MtlState {
379 fn from_index(idx: i32) -> Option<Self> {
380 match idx {
381 x if x == Self::Idle as i32 => Some(Self::Idle),
382 x if x == Self::Start as i32 => Some(Self::Start),
383 x if x == Self::Moving as i32 => Some(Self::Moving),
384 x if x == Self::Stopping as i32 => Some(Self::Stopping),
385 x if x == Self::Halt as i32 => Some(Self::Halt),
386 _ => None,
387 }
388 }
389}
390
391#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::motion::axis_config::AxisConfig;
399
400 struct MockAxis {
403 position: f64,
404 busy: bool,
405 error: bool,
406 config: AxisConfig,
407 halt_called: u32,
408 last_move_target: f64,
409 last_move_accel: f64,
410 last_move_decel: f64,
411 }
412
413 impl MockAxis {
414 fn new() -> Self {
415 let cfg = AxisConfig::new(1000);
416 Self {
417 position: 0.0, busy: false, error: false, config: cfg,
418 halt_called: 0, last_move_target: 0.0,
419 last_move_accel: 0.0, last_move_decel: 0.0,
420 }
421 }
422 }
423
424 impl AxisHandle for MockAxis {
425 fn position(&self) -> f64 { self.position }
426 fn config(&self) -> &AxisConfig { &self.config }
427 fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
428 fn move_absolute(&mut self, p: f64, _: f64, accel: f64, decel: f64) {
429 self.last_move_target = p;
430 self.last_move_accel = accel;
431 self.last_move_decel = decel;
432 self.busy = true;
433 }
434 fn halt(&mut self) { self.halt_called += 1; self.busy = false; }
435 fn is_busy(&self) -> bool { self.busy }
436 fn is_error(&self) -> bool { self.error }
437 fn motor_on(&self) -> bool { true }
438 }
439
440 #[test]
441 fn already_at_load_completes_without_moving() {
442 let mut fb = MoveToLoad::new();
443 let mut axis = MockAxis::new();
444 axis.position = 5.0;
445
446 fb.start(100.0, 10.0, 100.0, 50.0); fb.tick(&mut axis, 100.0); assert!(!fb.is_busy());
450 assert!(!fb.is_error());
451 assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
452 assert_eq!(fb.triggered_position(), 5.0);
453 assert_eq!(fb.triggered_load(), 100.0);
454 }
455
456 #[test]
457 fn already_past_limit_errors_immediately() {
458 let mut fb = MoveToLoad::new();
459 let mut axis = MockAxis::new();
460 axis.position = 60.0; fb.start(100.0,10.0, 100.0, 50.0);
463 fb.tick(&mut axis, 0.0); assert!(fb.is_error());
466 assert!(!fb.is_busy());
467 assert_eq!(axis.last_move_target, 0.0);
468 }
469
470 #[test]
471 fn moves_positive_then_triggers_on_load_threshold() {
472 let mut fb = MoveToLoad::new();
473 let mut axis = MockAxis::new();
474 axis.position = 0.0;
475
476 fb.start(100.0,10.0, 100.0, 50.0);
478 fb.tick(&mut axis, 0.0);
479 assert_eq!(axis.last_move_target, 50.0);
480 assert!(axis.busy);
481
482 axis.position = 10.0; fb.tick(&mut axis, 50.0);
484 axis.position = 20.0; fb.tick(&mut axis, 80.0);
485 assert_eq!(axis.halt_called, 0);
486
487 axis.position = 25.0; fb.tick(&mut axis, 100.5);
489 assert_eq!(axis.halt_called, 1);
490 assert_eq!(fb.triggered_position(), 25.0);
491 assert_eq!(fb.triggered_load(), 100.5);
492
493 fb.tick(&mut axis, 100.5);
496 assert!(!fb.is_busy());
497 assert!(!fb.is_error());
498 }
499
500 #[test]
501 fn moves_negative_when_load_above_target() {
502 let mut fb = MoveToLoad::new();
503 let mut axis = MockAxis::new();
504 axis.position = 100.0;
505
506 fb.start(50.0, 10.0, 100.0,0.0);
508 fb.tick(&mut axis, 100.0);
509 assert_eq!(axis.last_move_target, 0.0);
510
511 axis.position = 50.0; fb.tick(&mut axis, 49.0); assert_eq!(axis.halt_called, 1);
513 assert_eq!(fb.triggered_load(), 49.0);
514 }
515
516 #[test]
517 fn position_limit_without_load_triggers_error() {
518 let mut fb = MoveToLoad::new();
519 let mut axis = MockAxis::new();
520 axis.position = 0.0;
521
522 fb.start(100.0, 10.0, 100.0,50.0);
523 fb.tick(&mut axis, 0.0); axis.position = 50.0;
525 fb.tick(&mut axis, 10.0); assert!(fb.is_error());
528 assert_eq!(axis.halt_called, 1);
529 }
530
531 #[test]
532 fn startup_busy_race_does_not_false_trigger() {
533 let mut fb = MoveToLoad::new();
537 let mut axis = MockAxis::new();
538 axis.position = 0.0;
539 axis.busy = false; fb.start(100.0,10.0, 100.0, 50.0);
542 fb.tick(&mut axis, 10.0); axis.busy = false; fb.tick(&mut axis, 20.0); assert!(!fb.is_error(), "must not error during the busy-acceptance window");
548 }
549
550 #[test]
551 fn abort_sets_error_and_returns_idle() {
552 let mut fb = MoveToLoad::new();
553 let mut axis = MockAxis::new();
554 fb.start(100.0,10.0, 100.0, 50.0);
555 fb.tick(&mut axis, 0.0); assert!(fb.is_busy());
557
558 fb.abort();
559 assert!(!fb.is_busy());
560 assert!(fb.is_error());
561 }
562
563 #[test]
564 fn external_halt_state_halts_axis() {
565 let mut fb = MoveToLoad::new();
566 let mut axis = MockAxis::new();
567 axis.busy = true;
568
569 fb.state.index = MtlState::Halt as i32;
573 fb.tick(&mut axis, 0.0);
574
575 assert_eq!(axis.halt_called, 1);
576 assert!(!fb.is_busy());
577 }
578
579 #[test]
580 fn axis_fault_aborts_in_progress_command() {
581 let mut fb = MoveToLoad::new();
582 let mut axis = MockAxis::new();
583 fb.start(100.0,10.0, 100.0, 50.0);
584 fb.tick(&mut axis, 0.0); axis.error = true;
586 fb.tick(&mut axis, 0.0);
587
588 assert!(fb.is_error());
589 assert!(!fb.is_busy());
590 }
591
592 #[test]
593 fn triggered_values_clear_on_restart() {
594 let mut fb = MoveToLoad::new();
595 let mut axis = MockAxis::new();
596
597 fb.start(100.0,10.0, 100.0, 50.0);
598 fb.tick(&mut axis, 100.0); assert_eq!(fb.triggered_load(), 100.0);
600
601 fb.start(50.0,10.0, 100.0, 0.0); assert!(fb.triggered_load().is_nan());
603 assert!(fb.triggered_position().is_nan());
604 }
605
606 #[test]
609 fn default_dead_band_is_zero() {
610 let fb = MoveToLoad::new();
611 assert_eq!(fb.dead_band(), 0.0);
612 }
613
614 #[test]
615 fn set_dead_band_clamps_negative_to_zero() {
616 let mut fb = MoveToLoad::new();
617 fb.set_dead_band(-5.0);
618 assert_eq!(fb.dead_band(), 0.0);
619 fb.set_dead_band(2.5);
620 assert_eq!(fb.dead_band(), 2.5);
621 }
622
623 #[test]
624 fn dead_band_persists_across_start_calls() {
625 let mut fb = MoveToLoad::new();
626 fb.set_dead_band(3.0);
627 fb.start(100.0,10.0, 100.0, 50.0);
628 assert_eq!(fb.dead_band(), 3.0);
629 fb.start(50.0,10.0, 100.0, 0.0);
630 assert_eq!(fb.dead_band(), 3.0, "configuration must outlive a single move");
631 }
632
633 #[test]
634 fn dead_band_triggers_early_for_positive_motion() {
635 let mut fb = MoveToLoad::new();
636 let mut axis = MockAxis::new();
637
638 fb.set_dead_band(5.0);
641 fb.start(100.0,10.0, 100.0, 50.0);
642 fb.tick(&mut axis, 0.0); assert!(axis.busy);
644
645 axis.position = 10.0; fb.tick(&mut axis, 94.0);
647 assert_eq!(axis.halt_called, 0);
648
649 axis.position = 11.0; fb.tick(&mut axis, 95.5);
651 assert_eq!(axis.halt_called, 1);
652 assert_eq!(fb.triggered_load(), 95.5);
653 }
654
655 #[test]
656 fn dead_band_triggers_early_for_negative_motion() {
657 let mut fb = MoveToLoad::new();
658 let mut axis = MockAxis::new();
659 axis.position = 100.0;
660
661 fb.set_dead_band(5.0);
665 fb.start(50.0,10.0, 100.0, 0.0);
666 fb.tick(&mut axis, 100.0); axis.position = 75.0; fb.tick(&mut axis, 60.0); assert_eq!(axis.halt_called, 0);
670
671 axis.position = 70.0; fb.tick(&mut axis, 54.5); assert_eq!(axis.halt_called, 1);
673 assert_eq!(fb.triggered_load(), 54.5);
674 }
675
676 #[test]
677 fn within_dead_band_at_start_completes_immediately() {
678 let mut fb = MoveToLoad::new();
679 let mut axis = MockAxis::new();
680 axis.position = 5.0;
681
682 fb.set_dead_band(10.0);
684 fb.start(100.0,10.0, 100.0, 50.0);
685 fb.tick(&mut axis, 92.0);
686
687 assert!(!fb.is_busy());
688 assert!(!fb.is_error());
689 assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
690 assert_eq!(fb.triggered_load(), 92.0);
691 }
692
693 #[test]
696 fn default_stop_decel_is_none_and_falls_back_to_target_accel() {
697 let mut fb = MoveToLoad::new();
698 let mut axis = MockAxis::new();
699 assert_eq!(fb.stop_decel(), None);
700
701 fb.start(100.0, 10.0, 250.0, 50.0);
702 fb.tick(&mut axis, 0.0); assert_eq!(axis.last_move_accel, 250.0);
704 assert_eq!(axis.last_move_decel, 250.0, "without set_stop_decel, decel == accel");
705 }
706
707 #[test]
708 fn set_stop_decel_forwards_to_move_absolute() {
709 let mut fb = MoveToLoad::new();
710 let mut axis = MockAxis::new();
711 fb.set_stop_decel(Some(800.0));
712 assert_eq!(fb.stop_decel(), Some(800.0));
713
714 fb.start(100.0, 10.0, 250.0, 50.0);
715 fb.tick(&mut axis, 0.0);
716 assert_eq!(axis.last_move_accel, 250.0);
717 assert_eq!(axis.last_move_decel, 800.0, "explicit stop_decel must flow through");
718 }
719
720 #[test]
721 fn set_stop_decel_none_reverts_to_fallback() {
722 let mut fb = MoveToLoad::new();
723 let mut axis = MockAxis::new();
724 fb.set_stop_decel(Some(800.0));
725 fb.set_stop_decel(None);
726 assert_eq!(fb.stop_decel(), None);
727
728 fb.start(100.0, 10.0, 250.0, 50.0);
729 fb.tick(&mut axis, 0.0);
730 assert_eq!(axis.last_move_decel, 250.0);
731 }
732
733 #[test]
734 fn set_stop_decel_rejects_non_positive() {
735 let mut fb = MoveToLoad::new();
736 fb.set_stop_decel(Some(0.0));
737 assert_eq!(fb.stop_decel(), None);
738 fb.set_stop_decel(Some(-5.0));
739 assert_eq!(fb.stop_decel(), None);
740 }
741
742 #[test]
743 fn stop_decel_persists_across_start_calls() {
744 let mut fb = MoveToLoad::new();
745 let mut axis = MockAxis::new();
746 fb.set_stop_decel(Some(800.0));
747
748 fb.start(100.0, 10.0, 250.0, 50.0);
749 fb.tick(&mut axis, 0.0);
750 assert_eq!(axis.last_move_decel, 800.0);
751
752 fb.start(50.0, 10.0, 250.0, 0.0);
753 assert_eq!(fb.stop_decel(), Some(800.0), "configuration must outlive a single move");
754 }
755
756 #[test]
757 fn dead_band_zero_matches_strict_pass_a_behavior() {
758 let mut fb = MoveToLoad::new();
761 let mut axis = MockAxis::new();
762
763 fb.set_dead_band(0.0);
764 fb.start(100.0,10.0, 100.0, 50.0);
765 fb.tick(&mut axis, 0.0);
766
767 axis.position = 10.0; fb.tick(&mut axis, 99.99);
768 assert_eq!(axis.halt_called, 0, "must not trip at 99.99 with dead_band=0");
769
770 axis.position = 11.0; fb.tick(&mut axis, 100.0);
771 assert_eq!(axis.halt_called, 1, "exact equality must trip");
772 }
773}