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,
seen_busy: bool,
triggered_position: f64,
triggered_load: f64,
dead_band: 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,
Halt = 50,
}
impl Default for MoveToLoad {
fn default() -> Self {
Self {
state: StateMachine::new(),
moving_negative: false,
position_limit: 0.0,
target_load: 0.0,
seen_busy: false,
triggered_position: f64::NAN,
triggered_load: f64::NAN,
dead_band: 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, position_limit: f64) {
self.state.clear_error();
self.target_load = target_load;
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 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 cfg = axis.config();
axis.move_absolute(self.position_limit, cfg.jog_speed, cfg.jog_accel, cfg.jog_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();
self.state.set_error(150, "Reached position limit without hitting target load");
self.state.index = MtlState::Idle as i32;
}
}
Some(MtlState::Stopping) => {
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::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,
}
impl MockAxis {
fn new() -> Self {
let mut cfg = AxisConfig::new(1000);
cfg.jog_speed = 10.0;
cfg.jog_accel = 100.0;
cfg.jog_decel = 100.0;
Self {
position: 0.0, busy: false, error: false, config: cfg,
halt_called: 0, last_move_target: 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, _: f64, _: f64) {
self.last_move_target = p;
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, 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, 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, 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, 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, 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, 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, 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, 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, 50.0);
fb.tick(&mut axis, 100.0); assert_eq!(fb.triggered_load(), 100.0);
fb.start(50.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, 50.0);
assert_eq!(fb.dead_band(), 3.0);
fb.start(50.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, 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, 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, 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 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, 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");
}
}