use crate::fb::StateMachine;
use super::axis_view::AxisHandle;
#[derive(Debug, Clone)]
pub struct MoveToLoad {
state: StateMachine,
moving_negative: bool,
position_limit: f64,
target_load: f64,
target_speed: f64,
target_accel : f64,
seen_busy: bool,
triggered_position: f64,
triggered_load: f64,
dead_band: f64,
stop_decel: Option<f64>,
settle_speed: f64,
settle_tolerance: f64,
}
const POSITION_LIMIT_TOLERANCE: f64 = 1e-4;
#[repr(i32)]
#[derive(Copy, Clone, PartialEq, Debug)]
enum MtlState {
Idle = 0,
Start = 1,
Moving = 10,
Stopping = 20,
Settling = 30,
StoppingSettle = 40,
Halt = 50,
}
impl Default for MoveToLoad {
fn default() -> Self {
Self {
state: StateMachine::new(),
moving_negative: false,
position_limit: 0.0,
target_load: 0.0,
target_speed: 0.0,
target_accel : 0.0,
seen_busy: false,
triggered_position: f64::NAN,
triggered_load: f64::NAN,
dead_band: 0.0,
stop_decel: None,
settle_speed: 0.0,
settle_tolerance: 0.0,
}
}
}
impl MoveToLoad {
pub fn new() -> Self {
Self::default()
}
pub fn abort(&mut self) {
self.state.set_error(200, "Abort called");
self.state.index = MtlState::Idle as i32;
}
pub fn start(
&mut self,
target_load: f64,
target_speed : f64,
target_accel : f64,
position_limit: f64
) {
self.state.clear_error();
self.target_load = target_load;
self.target_speed = target_speed;
self.target_accel = target_accel;
self.position_limit = position_limit;
self.seen_busy = false;
self.triggered_position = f64::NAN;
self.triggered_load = f64::NAN;
self.state.index = MtlState::Start as i32;
}
pub fn reset(&mut self) {
self.state.clear_error();
self.state.index = MtlState::Idle as i32;
}
pub fn is_error(&self) -> bool {
self.state.is_error()
}
pub fn error_message(&self) -> String {
return self.state.error_message.clone();
}
pub fn is_busy(&self) -> bool {
self.state.index > MtlState::Idle as i32
}
pub fn triggered_position(&self) -> f64 {
self.triggered_position
}
pub fn triggered_load(&self) -> f64 {
self.triggered_load
}
pub fn set_dead_band(&mut self, value: f64) {
self.dead_band = value.max(0.0);
}
pub fn dead_band(&self) -> f64 {
self.dead_band
}
pub fn set_stop_decel(&mut self, value: Option<f64>) {
self.stop_decel = value.filter(|v| *v > 0.0);
}
pub fn stop_decel(&self) -> Option<f64> {
self.stop_decel
}
pub fn set_settle(&mut self, speed: f64, tolerance: f64) {
self.settle_speed = speed.max(0.0);
self.settle_tolerance = tolerance.max(0.0);
}
pub fn settle_speed(&self) -> f64 {
self.settle_speed
}
pub fn settle_tolerance(&self) -> f64 {
self.settle_tolerance
}
fn settle_enabled(&self) -> bool {
self.settle_speed > 0.0 && self.settle_tolerance > 0.0
}
pub fn tick(
&mut self,
axis: &mut impl AxisHandle,
current_load: f64,
) {
if axis.is_error() && self.state.index > MtlState::Idle as i32 {
self.state.set_error(120, "Axis is in error state");
self.state.index = MtlState::Idle as i32;
}
match MtlState::from_index(self.state.index) {
Some(MtlState::Idle) => {
}
Some(MtlState::Start) => {
self.state.clear_error();
self.seen_busy = false;
self.moving_negative = current_load > self.target_load;
let reached = self.threshold_reached(current_load);
if reached {
self.triggered_position = axis.position();
self.triggered_load = current_load;
self.state.index = MtlState::Idle as i32;
} else if self.already_past_limit(axis) {
self.state.set_error(110, "Axis already past position limit before starting");
self.state.index = MtlState::Idle as i32;
} else {
let decel = self.stop_decel.unwrap_or(self.target_accel);
axis.move_absolute(
self.position_limit,
self.target_speed,
self.target_accel,
decel,
);
self.state.index = MtlState::Moving as i32;
}
}
Some(MtlState::Moving) => {
if axis.is_busy() {
self.seen_busy = true;
}
let reached = self.threshold_reached(current_load);
if reached {
self.triggered_position = axis.position();
self.triggered_load = current_load;
axis.halt();
self.state.index = MtlState::Stopping as i32;
return;
}
let hit_limit = if self.moving_negative {
axis.position() <= self.position_limit + POSITION_LIMIT_TOLERANCE
} else {
axis.position() >= self.position_limit - POSITION_LIMIT_TOLERANCE
};
let stopped_unexpectedly = self.seen_busy && !axis.is_busy();
if hit_limit || stopped_unexpectedly {
axis.halt();
if hit_limit {
self.state.set_error(150,
format!("[FB MoveToLoad] Reached position limit {} {} without hitting target load",
if self.moving_negative {"moving NEG"} else {"moving POS"},
self.position_limit)
);
}
else {
self.state.set_error(151,
"[FB MoveToLoad] Stoped unexpectedly without hitting target load."
);
}
self.state.index = MtlState::Idle as i32;
}
}
Some(MtlState::Stopping) => {
if !axis.is_busy() {
if self.settle_enabled()
&& (current_load - self.target_load).abs() > self.settle_tolerance
{
let settle_pos_limit = if self.moving_negative {
axis.position() + self.settle_tolerance.abs() * 1.0e3
} else {
axis.position() - self.settle_tolerance.abs() * 1.0e3
};
let decel = self.stop_decel.unwrap_or(self.target_accel);
axis.move_absolute(
settle_pos_limit,
self.settle_speed,
self.target_accel,
decel,
);
self.seen_busy = false;
self.state.index = MtlState::Settling as i32;
} else {
self.state.index = MtlState::Idle as i32;
}
}
}
Some(MtlState::Settling) => {
if axis.is_busy() {
self.seen_busy = true;
}
let within_tolerance =
(current_load - self.target_load).abs() <= self.settle_tolerance;
let stopped_unexpectedly = self.seen_busy && !axis.is_busy();
if within_tolerance {
axis.halt();
self.triggered_position = axis.position();
self.triggered_load = current_load;
self.state.index = MtlState::StoppingSettle as i32;
} else if stopped_unexpectedly {
axis.halt();
self.state.set_error(
160,
"[FB MoveToLoad] Settle move stopped before load returned to tolerance",
);
self.state.index = MtlState::Idle as i32;
}
}
Some(MtlState::StoppingSettle) => {
if !axis.is_busy() {
self.state.index = MtlState::Idle as i32;
}
}
Some(MtlState::Halt) => {
axis.halt();
self.state.index = MtlState::Idle as i32;
}
None => {
self.state.index = MtlState::Idle as i32;
}
}
self.state.call();
}
fn already_past_limit(&self, axis: &impl AxisHandle) -> bool {
if self.moving_negative {
axis.position() <= self.position_limit
} else {
axis.position() >= self.position_limit
}
}
fn threshold_reached(&self, current_load: f64) -> bool {
if self.moving_negative {
current_load <= self.target_load + self.dead_band
} else {
current_load >= self.target_load - self.dead_band
}
}
}
impl MtlState {
fn from_index(idx: i32) -> Option<Self> {
match idx {
x if x == Self::Idle as i32 => Some(Self::Idle),
x if x == Self::Start as i32 => Some(Self::Start),
x if x == Self::Moving as i32 => Some(Self::Moving),
x if x == Self::Stopping as i32 => Some(Self::Stopping),
x if x == Self::Settling as i32 => Some(Self::Settling),
x if x == Self::StoppingSettle as i32 => Some(Self::StoppingSettle),
x if x == Self::Halt as i32 => Some(Self::Halt),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::axis_config::AxisConfig;
struct MockAxis {
position: f64,
busy: bool,
error: bool,
config: AxisConfig,
halt_called: u32,
last_move_target: f64,
last_move_accel: f64,
last_move_decel: f64,
}
impl MockAxis {
fn new() -> Self {
let cfg = AxisConfig::new(1000);
Self {
position: 0.0, busy: false, error: false, config: cfg,
halt_called: 0, last_move_target: 0.0,
last_move_accel: 0.0, last_move_decel: 0.0,
}
}
}
impl AxisHandle for MockAxis {
fn position(&self) -> f64 { self.position }
fn config(&self) -> &AxisConfig { &self.config }
fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
fn move_absolute(&mut self, p: f64, _: f64, accel: f64, decel: f64) {
self.last_move_target = p;
self.last_move_accel = accel;
self.last_move_decel = decel;
self.busy = true;
}
fn halt(&mut self) { self.halt_called += 1; self.busy = false; }
fn is_busy(&self) -> bool { self.busy }
fn is_error(&self) -> bool { self.error }
fn motor_on(&self) -> bool { true }
}
#[test]
fn already_at_load_completes_without_moving() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 5.0;
fb.start(100.0, 10.0, 100.0, 50.0); fb.tick(&mut axis, 100.0);
assert!(!fb.is_busy());
assert!(!fb.is_error());
assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
assert_eq!(fb.triggered_position(), 5.0);
assert_eq!(fb.triggered_load(), 100.0);
}
#[test]
fn already_past_limit_errors_immediately() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 60.0;
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0);
assert!(fb.is_error());
assert!(!fb.is_busy());
assert_eq!(axis.last_move_target, 0.0);
}
#[test]
fn moves_positive_then_triggers_on_load_threshold() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 0.0;
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0);
assert_eq!(axis.last_move_target, 50.0);
assert!(axis.busy);
axis.position = 10.0; fb.tick(&mut axis, 50.0);
axis.position = 20.0; fb.tick(&mut axis, 80.0);
assert_eq!(axis.halt_called, 0);
axis.position = 25.0; fb.tick(&mut axis, 100.5);
assert_eq!(axis.halt_called, 1);
assert_eq!(fb.triggered_position(), 25.0);
assert_eq!(fb.triggered_load(), 100.5);
fb.tick(&mut axis, 100.5);
assert!(!fb.is_busy());
assert!(!fb.is_error());
}
#[test]
fn moves_negative_when_load_above_target() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 100.0;
fb.start(50.0, 10.0, 100.0,0.0);
fb.tick(&mut axis, 100.0);
assert_eq!(axis.last_move_target, 0.0);
axis.position = 50.0; fb.tick(&mut axis, 49.0); assert_eq!(axis.halt_called, 1);
assert_eq!(fb.triggered_load(), 49.0);
}
#[test]
fn position_limit_without_load_triggers_error() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 0.0;
fb.start(100.0, 10.0, 100.0,50.0);
fb.tick(&mut axis, 0.0); axis.position = 50.0;
fb.tick(&mut axis, 10.0);
assert!(fb.is_error());
assert_eq!(axis.halt_called, 1);
}
#[test]
fn startup_busy_race_does_not_false_trigger() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 0.0;
axis.busy = false;
fb.start(100.0,10.0, 100.0, 50.0);
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");
}
#[test]
fn abort_sets_error_and_returns_idle() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0); assert!(fb.is_busy());
fb.abort();
assert!(!fb.is_busy());
assert!(fb.is_error());
}
#[test]
fn external_halt_state_halts_axis() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.busy = true;
fb.state.index = MtlState::Halt as i32;
fb.tick(&mut axis, 0.0);
assert_eq!(axis.halt_called, 1);
assert!(!fb.is_busy());
}
#[test]
fn axis_fault_aborts_in_progress_command() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0); axis.error = true;
fb.tick(&mut axis, 0.0);
assert!(fb.is_error());
assert!(!fb.is_busy());
}
#[test]
fn triggered_values_clear_on_restart() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 100.0); assert_eq!(fb.triggered_load(), 100.0);
fb.start(50.0,10.0, 100.0, 0.0); assert!(fb.triggered_load().is_nan());
assert!(fb.triggered_position().is_nan());
}
#[test]
fn default_dead_band_is_zero() {
let fb = MoveToLoad::new();
assert_eq!(fb.dead_band(), 0.0);
}
#[test]
fn set_dead_band_clamps_negative_to_zero() {
let mut fb = MoveToLoad::new();
fb.set_dead_band(-5.0);
assert_eq!(fb.dead_band(), 0.0);
fb.set_dead_band(2.5);
assert_eq!(fb.dead_band(), 2.5);
}
#[test]
fn dead_band_persists_across_start_calls() {
let mut fb = MoveToLoad::new();
fb.set_dead_band(3.0);
fb.start(100.0,10.0, 100.0, 50.0);
assert_eq!(fb.dead_band(), 3.0);
fb.start(50.0,10.0, 100.0, 0.0);
assert_eq!(fb.dead_band(), 3.0, "configuration must outlive a single move");
}
#[test]
fn dead_band_triggers_early_for_positive_motion() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_dead_band(5.0);
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0); assert!(axis.busy);
axis.position = 10.0; fb.tick(&mut axis, 94.0);
assert_eq!(axis.halt_called, 0);
axis.position = 11.0; fb.tick(&mut axis, 95.5);
assert_eq!(axis.halt_called, 1);
assert_eq!(fb.triggered_load(), 95.5);
}
#[test]
fn dead_band_triggers_early_for_negative_motion() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 100.0;
fb.set_dead_band(5.0);
fb.start(50.0,10.0, 100.0, 0.0);
fb.tick(&mut axis, 100.0);
axis.position = 75.0; fb.tick(&mut axis, 60.0); assert_eq!(axis.halt_called, 0);
axis.position = 70.0; fb.tick(&mut axis, 54.5); assert_eq!(axis.halt_called, 1);
assert_eq!(fb.triggered_load(), 54.5);
}
#[test]
fn within_dead_band_at_start_completes_immediately() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
axis.position = 5.0;
fb.set_dead_band(10.0);
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 92.0);
assert!(!fb.is_busy());
assert!(!fb.is_error());
assert_eq!(axis.last_move_target, 0.0, "must not issue a move");
assert_eq!(fb.triggered_load(), 92.0);
}
#[test]
fn default_stop_decel_is_none_and_falls_back_to_target_accel() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
assert_eq!(fb.stop_decel(), None);
fb.start(100.0, 10.0, 250.0, 50.0);
fb.tick(&mut axis, 0.0); assert_eq!(axis.last_move_accel, 250.0);
assert_eq!(axis.last_move_decel, 250.0, "without set_stop_decel, decel == accel");
}
#[test]
fn set_stop_decel_forwards_to_move_absolute() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_stop_decel(Some(800.0));
assert_eq!(fb.stop_decel(), Some(800.0));
fb.start(100.0, 10.0, 250.0, 50.0);
fb.tick(&mut axis, 0.0);
assert_eq!(axis.last_move_accel, 250.0);
assert_eq!(axis.last_move_decel, 800.0, "explicit stop_decel must flow through");
}
#[test]
fn set_stop_decel_none_reverts_to_fallback() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_stop_decel(Some(800.0));
fb.set_stop_decel(None);
assert_eq!(fb.stop_decel(), None);
fb.start(100.0, 10.0, 250.0, 50.0);
fb.tick(&mut axis, 0.0);
assert_eq!(axis.last_move_decel, 250.0);
}
#[test]
fn set_stop_decel_rejects_non_positive() {
let mut fb = MoveToLoad::new();
fb.set_stop_decel(Some(0.0));
assert_eq!(fb.stop_decel(), None);
fb.set_stop_decel(Some(-5.0));
assert_eq!(fb.stop_decel(), None);
}
#[test]
fn stop_decel_persists_across_start_calls() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_stop_decel(Some(800.0));
fb.start(100.0, 10.0, 250.0, 50.0);
fb.tick(&mut axis, 0.0);
assert_eq!(axis.last_move_decel, 800.0);
fb.start(50.0, 10.0, 250.0, 0.0);
assert_eq!(fb.stop_decel(), Some(800.0), "configuration must outlive a single move");
}
#[test]
fn dead_band_zero_matches_strict_pass_a_behavior() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_dead_band(0.0);
fb.start(100.0,10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0);
axis.position = 10.0; fb.tick(&mut axis, 99.99);
assert_eq!(axis.halt_called, 0, "must not trip at 99.99 with dead_band=0");
axis.position = 11.0; fb.tick(&mut axis, 100.0);
assert_eq!(axis.halt_called, 1, "exact equality must trip");
}
#[test]
fn settle_disabled_skips_settling_state() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.start(100.0, 10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0); axis.position = 5.0; fb.tick(&mut axis, 100.0); assert_eq!(axis.halt_called, 1);
axis.busy = false;
fb.tick(&mut axis, 150.0);
assert!(!fb.is_busy(), "should return to Idle without settling");
assert_eq!(axis.halt_called, 1, "no second halt from settle");
}
#[test]
fn settle_inside_tolerance_skips_settling_state() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_settle(1.0, 5.0); fb.start(100.0, 10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0);
axis.position = 5.0; fb.tick(&mut axis, 100.0);
axis.busy = false;
fb.tick(&mut axis, 102.0); assert!(!fb.is_busy(), "should go straight Idle");
}
#[test]
fn settle_triggers_corrective_move_on_overshoot() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_settle(1.0, 5.0); fb.start(100.0, 10.0, 100.0, 50.0); fb.tick(&mut axis, 0.0); axis.position = 5.0; fb.tick(&mut axis, 100.0); assert_eq!(axis.halt_called, 1);
axis.busy = false;
let settle_calls_before = axis.halt_called;
fb.tick(&mut axis, 120.0);
assert!(fb.is_busy(), "should enter Settling");
assert_eq!(axis.halt_called, settle_calls_before,
"no halt yet; Settling issues a slow move first");
axis.position = 4.7; fb.tick(&mut axis, 103.0);
assert_eq!(axis.halt_called, settle_calls_before + 1,
"settle converged, halt issued");
axis.busy = false;
fb.tick(&mut axis, 103.0); assert!(!fb.is_busy());
assert_eq!(fb.triggered_load(), 103.0,
"triggered values re-latched to settled state");
}
#[test]
fn settle_unexpected_stop_is_an_error() {
let mut fb = MoveToLoad::new();
let mut axis = MockAxis::new();
fb.set_settle(1.0, 5.0);
fb.start(100.0, 10.0, 100.0, 50.0);
fb.tick(&mut axis, 0.0);
axis.position = 5.0; fb.tick(&mut axis, 100.0);
axis.busy = false;
fb.tick(&mut axis, 120.0);
fb.tick(&mut axis, 118.0);
axis.busy = false;
fb.tick(&mut axis, 118.0);
assert!(fb.is_error());
assert!(!fb.is_busy());
assert!(fb.error_message().contains("Settle"));
}
#[test]
fn set_settle_clamps_negative_values() {
let mut fb = MoveToLoad::new();
fb.set_settle(-5.0, -1.0);
assert_eq!(fb.settle_speed(), 0.0);
assert_eq!(fb.settle_tolerance(), 0.0);
}
}