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() {
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 fn threshold_reached(&self, current_load: f64) -> bool {
368 if self.moving_negative {
369 current_load <= self.target_load + self.dead_band
372 } else {
373 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#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::motion::axis_config::AxisConfig;
400
401 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); fb.tick(&mut axis, 100.0); 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; fb.start(100.0,10.0, 100.0, 50.0);
464 fb.tick(&mut axis, 0.0); 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 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 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 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 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 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); 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); axis.position = 50.0;
526 fb.tick(&mut axis, 10.0); 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 let mut fb = MoveToLoad::new();
538 let mut axis = MockAxis::new();
539 axis.position = 0.0;
540 axis.busy = false; fb.start(100.0,10.0, 100.0, 50.0);
543 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");
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); 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 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); 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); assert_eq!(fb.triggered_load(), 100.0);
601
602 fb.start(50.0,10.0, 100.0, 0.0); assert!(fb.triggered_load().is_nan());
604 assert!(fb.triggered_position().is_nan());
605 }
606
607 #[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 fb.set_dead_band(5.0);
642 fb.start(100.0,10.0, 100.0, 50.0);
643 fb.tick(&mut axis, 0.0); assert!(axis.busy);
645
646 axis.position = 10.0; fb.tick(&mut axis, 94.0);
648 assert_eq!(axis.halt_called, 0);
649
650 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 fb.set_dead_band(5.0);
666 fb.start(50.0,10.0, 100.0, 0.0);
667 fb.tick(&mut axis, 100.0); axis.position = 75.0; fb.tick(&mut axis, 60.0); assert_eq!(axis.halt_called, 0);
671
672 axis.position = 70.0; fb.tick(&mut axis, 54.5); 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 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 #[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); 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 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}