use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CycleState {
Idle,
PickApproach,
PickBillet,
PickLift,
CheckHaasReady,
WaitHaasReady,
DoorApproach,
ViseApproach,
VisePlace,
ViseClamp,
ViseRetreat,
SignalHaasStart,
WaitMachining,
ViseUnclamp,
PickFinished,
FinishedApproach,
PlaceDone,
CheckStock,
CycleComplete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HaasSignal {
HaasReady,
HaasBusy,
HaasCycleComplete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActuatorCommand {
GripperClose,
GripperOpen,
ViseClamp,
ViseUnclamp,
HaasCycleStart,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransitionResult {
pub from: CycleState,
pub to: CycleState,
pub commands: Vec<ActuatorCommand>,
pub zone_changed: bool,
}
const SPINDLE_ZONE_NAME: &str = "haas_spindle_zone";
#[derive(Debug, Clone)]
pub struct CycleCoordinator {
state: CycleState,
spindle_zone_active: bool,
billets_remaining: u32,
parts_completed: u32,
}
impl CycleCoordinator {
pub fn new(billets: u32) -> Self {
Self {
state: CycleState::Idle,
spindle_zone_active: true,
billets_remaining: billets,
parts_completed: 0,
}
}
pub fn state(&self) -> CycleState {
self.state
}
pub fn spindle_zone_active(&self) -> bool {
self.spindle_zone_active
}
pub fn billets_remaining(&self) -> u32 {
self.billets_remaining
}
pub fn parts_completed(&self) -> u32 {
self.parts_completed
}
pub fn zone_overrides(&self) -> HashMap<String, bool> {
let mut overrides = HashMap::new();
overrides.insert(SPINDLE_ZONE_NAME.to_string(), self.spindle_zone_active);
overrides
}
pub fn advance(&mut self, haas_signal: Option<HaasSignal>) -> Result<TransitionResult, String> {
let from = self.state;
let (next, commands, zone_changed) = match self.state {
CycleState::Idle => {
if self.billets_remaining == 0 {
return Err("no billets remaining; cannot start cycle".into());
}
(CycleState::PickApproach, vec![], false)
}
CycleState::PickApproach => (CycleState::PickBillet, vec![], false),
CycleState::PickBillet => {
self.billets_remaining = self.billets_remaining.saturating_sub(1);
(
CycleState::PickLift,
vec![ActuatorCommand::GripperClose],
false,
)
}
CycleState::PickLift => (CycleState::CheckHaasReady, vec![], false),
CycleState::CheckHaasReady => match haas_signal {
Some(HaasSignal::HaasReady) => (CycleState::DoorApproach, vec![], false),
Some(HaasSignal::HaasBusy) => (CycleState::WaitHaasReady, vec![], false),
_ => return Err("CheckHaasReady requires HaasReady or HaasBusy signal".into()),
},
CycleState::WaitHaasReady => match haas_signal {
Some(HaasSignal::HaasReady) => (CycleState::DoorApproach, vec![], false),
Some(HaasSignal::HaasBusy) => (CycleState::WaitHaasReady, vec![], false),
_ => return Err("WaitHaasReady requires HaasReady or HaasBusy signal".into()),
},
CycleState::DoorApproach => {
self.spindle_zone_active = false;
(CycleState::ViseApproach, vec![], true)
}
CycleState::ViseApproach => (CycleState::VisePlace, vec![], false),
CycleState::VisePlace => (
CycleState::ViseClamp,
vec![ActuatorCommand::GripperOpen, ActuatorCommand::ViseClamp],
false,
),
CycleState::ViseClamp => (CycleState::ViseRetreat, vec![], false),
CycleState::ViseRetreat => {
self.spindle_zone_active = true;
(CycleState::SignalHaasStart, vec![], true)
}
CycleState::SignalHaasStart => (
CycleState::WaitMachining,
vec![ActuatorCommand::HaasCycleStart],
false,
),
CycleState::WaitMachining => match haas_signal {
Some(HaasSignal::HaasCycleComplete) => {
self.spindle_zone_active = false;
(CycleState::ViseUnclamp, vec![], true)
}
Some(HaasSignal::HaasBusy) => (CycleState::WaitMachining, vec![], false),
_ => {
return Err(
"WaitMachining requires HaasCycleComplete or HaasBusy signal".into(),
)
}
},
CycleState::ViseUnclamp => (
CycleState::PickFinished,
vec![ActuatorCommand::ViseUnclamp, ActuatorCommand::GripperClose],
false,
),
CycleState::PickFinished => {
self.spindle_zone_active = true;
(CycleState::FinishedApproach, vec![], true)
}
CycleState::FinishedApproach => (CycleState::PlaceDone, vec![], false),
CycleState::PlaceDone => {
self.parts_completed += 1;
(
CycleState::CheckStock,
vec![ActuatorCommand::GripperOpen],
false,
)
}
CycleState::CheckStock => {
if self.billets_remaining > 0 {
(CycleState::PickApproach, vec![], false)
} else {
(CycleState::CycleComplete, vec![], false)
}
}
CycleState::CycleComplete => {
return Err("cycle already complete; reset to start a new cycle".into());
}
};
self.state = next;
Ok(TransitionResult {
from,
to: next,
commands,
zone_changed,
})
}
pub fn reset(&mut self, billets: u32) -> Result<(), String> {
match self.state {
CycleState::CycleComplete | CycleState::Idle => {
self.state = CycleState::Idle;
self.spindle_zone_active = true;
self.billets_remaining = billets;
Ok(())
}
_ => Err(format!(
"cannot reset from state {:?}; must be Idle or CycleComplete",
self.state
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_coordinator_starts_idle_with_active_spindle_zone() {
let coord = CycleCoordinator::new(10);
assert_eq!(coord.state(), CycleState::Idle);
assert!(coord.spindle_zone_active());
assert_eq!(coord.billets_remaining(), 10);
assert_eq!(coord.parts_completed(), 0);
}
#[test]
fn zone_overrides_reflect_spindle_state() {
let coord = CycleCoordinator::new(1);
let overrides = coord.zone_overrides();
assert_eq!(overrides.get(SPINDLE_ZONE_NAME), Some(&true));
}
#[test]
fn full_single_billet_cycle() {
let mut coord = CycleCoordinator::new(1);
let r = coord.advance(None).unwrap();
assert_eq!(r.from, CycleState::Idle);
assert_eq!(r.to, CycleState::PickApproach);
assert!(!r.zone_changed);
coord.advance(None).unwrap();
assert_eq!(coord.state(), CycleState::PickBillet);
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::PickLift);
assert!(r.commands.contains(&ActuatorCommand::GripperClose));
assert_eq!(coord.billets_remaining(), 0);
coord.advance(None).unwrap();
assert_eq!(coord.state(), CycleState::CheckHaasReady);
coord.advance(Some(HaasSignal::HaasReady)).unwrap();
assert_eq!(coord.state(), CycleState::DoorApproach);
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::ViseApproach);
assert!(r.zone_changed);
assert!(!coord.spindle_zone_active());
coord.advance(None).unwrap();
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::ViseClamp);
assert!(r.commands.contains(&ActuatorCommand::GripperOpen));
assert!(r.commands.contains(&ActuatorCommand::ViseClamp));
coord.advance(None).unwrap();
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::SignalHaasStart);
assert!(r.zone_changed);
assert!(coord.spindle_zone_active());
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::WaitMachining);
assert!(r.commands.contains(&ActuatorCommand::HaasCycleStart));
let r = coord.advance(Some(HaasSignal::HaasCycleComplete)).unwrap();
assert_eq!(r.to, CycleState::ViseUnclamp);
assert!(r.zone_changed);
assert!(!coord.spindle_zone_active());
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::PickFinished);
assert!(r.commands.contains(&ActuatorCommand::ViseUnclamp));
assert!(r.commands.contains(&ActuatorCommand::GripperClose));
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::FinishedApproach);
assert!(r.zone_changed);
assert!(coord.spindle_zone_active());
coord.advance(None).unwrap();
let r = coord.advance(None).unwrap();
assert_eq!(r.to, CycleState::CheckStock);
assert!(r.commands.contains(&ActuatorCommand::GripperOpen));
assert_eq!(coord.parts_completed(), 1);
coord.advance(None).unwrap();
assert_eq!(coord.state(), CycleState::CycleComplete);
}
#[test]
fn haas_busy_causes_wait() {
let mut coord = CycleCoordinator::new(1);
coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap();
coord.advance(Some(HaasSignal::HaasBusy)).unwrap();
assert_eq!(coord.state(), CycleState::WaitHaasReady);
coord.advance(Some(HaasSignal::HaasBusy)).unwrap();
assert_eq!(coord.state(), CycleState::WaitHaasReady);
coord.advance(Some(HaasSignal::HaasReady)).unwrap();
assert_eq!(coord.state(), CycleState::DoorApproach);
}
#[test]
fn wait_machining_stays_while_busy() {
let mut coord = CycleCoordinator::new(1);
coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(Some(HaasSignal::HaasReady)).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap();
coord.advance(Some(HaasSignal::HaasBusy)).unwrap();
assert_eq!(coord.state(), CycleState::WaitMachining);
assert!(coord.spindle_zone_active()); }
#[test]
fn multi_billet_cycle_loops() {
let mut coord = CycleCoordinator::new(2);
for signal in full_cycle_signals() {
coord.advance(signal).unwrap();
}
assert_eq!(coord.state(), CycleState::CheckStock);
assert_eq!(coord.billets_remaining(), 1);
assert_eq!(coord.parts_completed(), 1);
coord.advance(None).unwrap();
assert_eq!(coord.state(), CycleState::PickApproach);
}
#[test]
fn cannot_start_with_zero_billets() {
let mut coord = CycleCoordinator::new(0);
let result = coord.advance(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no billets"));
}
#[test]
fn cannot_advance_past_cycle_complete() {
let mut coord = CycleCoordinator::new(1);
for signal in full_cycle_signals() {
coord.advance(signal).unwrap();
}
coord.advance(None).unwrap();
let result = coord.advance(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("already complete"));
}
#[test]
fn reset_from_cycle_complete() {
let mut coord = CycleCoordinator::new(1);
for signal in full_cycle_signals() {
coord.advance(signal).unwrap();
}
coord.advance(None).unwrap();
coord.reset(5).unwrap();
assert_eq!(coord.state(), CycleState::Idle);
assert!(coord.spindle_zone_active());
assert_eq!(coord.billets_remaining(), 5);
}
#[test]
fn cannot_reset_mid_cycle() {
let mut coord = CycleCoordinator::new(1);
coord.advance(None).unwrap(); let result = coord.reset(5);
assert!(result.is_err());
}
#[test]
fn spindle_zone_disabled_during_load_and_unload() {
let mut coord = CycleCoordinator::new(1);
coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(Some(HaasSignal::HaasReady)).unwrap(); assert!(coord.spindle_zone_active());
coord.advance(None).unwrap();
assert!(!coord.spindle_zone_active());
let overrides = coord.zone_overrides();
assert_eq!(overrides.get(SPINDLE_ZONE_NAME), Some(&false));
coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); assert!(!coord.spindle_zone_active());
coord.advance(None).unwrap();
assert!(coord.spindle_zone_active());
}
#[test]
fn check_haas_ready_requires_signal() {
let mut coord = CycleCoordinator::new(1);
coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap(); coord.advance(None).unwrap();
let result = coord.advance(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("requires"));
}
#[test]
fn serde_round_trip_cycle_state() {
let state = CycleState::WaitMachining;
let json = serde_json::to_string(&state).unwrap();
let back: CycleState = serde_json::from_str(&json).unwrap();
assert_eq!(state, back);
}
fn full_cycle_signals() -> Vec<Option<HaasSignal>> {
vec![
None, None, None, None, Some(HaasSignal::HaasReady), None, None, None, None, None, None, Some(HaasSignal::HaasCycleComplete), None, None, None, None, ]
}
}