Skip to main content

autocore_std/motion/
sim.rs

1//! Virtual (simulated) CiA-402 drive backing a fieldbus-less axis.
2//!
3//! [`SimDrive`] implements [`AxisView`](super::AxisView) — the same seam an
4//! EtherCAT drive handle implements — so the generic [`Axis`](super::Axis)
5//! controller drives it with zero changes. It emulates a Profile Position
6//! drive: the CiA-402 state machine (Switch On Disabled → … → Operation
7//! Enabled), the set-point-acknowledge handshake, and motion integration.
8//!
9//! Because there is no bus, the runtime must advance the simulation each
10//! control cycle by calling [`SimDrive::step`] (analogous to the EtherCAT
11//! handle's PDO exchange) before the axis reads feedback.
12//!
13//! ```
14//! use autocore_std::motion::sim::SimDrive;
15//! use autocore_std::motion::{AxisView, cia402::{Cia402Control, RawControlWord}};
16//!
17//! let mut sim = SimDrive::new();
18//! // Command Operation Enabled (CW 0x000F) and step the sim a few cycles.
19//! let mut cw = RawControlWord(0); cw.cmd_enable_operation();
20//! sim.set_control_word(cw.raw());
21//! sim.set_modes_of_operation(1); // PP
22//! for _ in 0..3 { sim.step(0.01); }
23//! // Status word bit 2 = Operation Enabled.
24//! assert!(sim.status_word() & (1 << 2) != 0);
25//! ```
26
27use super::axis_view::AxisView;
28use super::cia402::Cia402State;
29
30/// Status-word base pattern for a CiA-402 state (bits 0–6), matching the
31/// decode in [`Cia402Status::state`](super::cia402::Cia402Status::state).
32fn status_base(state: Cia402State) -> u16 {
33    match state {
34        Cia402State::NotReadyToSwitchOn  => 0x0000,
35        Cia402State::SwitchOnDisabled    => 0x0040,
36        Cia402State::ReadyToSwitchOn     => 0x0021,
37        Cia402State::SwitchedOn          => 0x0023,
38        Cia402State::OperationEnabled    => 0x0027,
39        Cia402State::QuickStopActive     => 0x0007,
40        Cia402State::FaultReactionActive => 0x000F,
41        Cia402State::Fault               => 0x0008,
42        Cia402State::Unknown             => 0x0000,
43    }
44}
45
46/// A simulated CiA-402 Profile Position drive.
47///
48/// Commands arrive through the [`AxisView`] setters; [`step`](Self::step)
49/// advances the state machine and integrates motion; the [`AxisView`] getters
50/// report the simulated feedback. Raw drive units throughout (encoder counts,
51/// counts/s) — the same units a real PDO carries.
52#[derive(Debug, Clone)]
53pub struct SimDrive {
54    // ── Commanded (written via AxisView) ──
55    control_word: u16,
56    target_position: i32,
57    profile_velocity: u32,
58    profile_acceleration: u32,
59    profile_deceleration: u32,
60    modes_of_operation: i8,
61
62    // ── Simulated feedback ──
63    status_word: u16,
64    position_actual: i32,
65    velocity_actual: i32,
66    modes_display: i8,
67
68    // ── Internal sim state ──
69    state: Cia402State,
70    active_target: i32,
71    moving: bool,
72    setpoint_ack: bool,
73    prev_new_setpoint: bool,
74
75    /// When `true`, the sim refuses to assert set-point-acknowledge while
76    /// Halt (CW bit 8) is asserted — emulating the Kollmorgen-AKD / Inovance
77    /// behavior so the halt cancel handshake can be exercised in tests.
78    /// Default `false` (ideal drive that acks while halted).
79    halt_blocks_setpoint_ack: bool,
80}
81
82impl Default for SimDrive {
83    fn default() -> Self {
84        Self {
85            control_word: 0,
86            target_position: 0,
87            profile_velocity: 0,
88            profile_acceleration: 0,
89            profile_deceleration: 0,
90            modes_of_operation: 0,
91            status_word: status_base(Cia402State::SwitchOnDisabled) | (1 << 9),
92            position_actual: 0,
93            velocity_actual: 0,
94            modes_display: 0,
95            state: Cia402State::SwitchOnDisabled,
96            active_target: 0,
97            moving: false,
98            setpoint_ack: false,
99            prev_new_setpoint: false,
100            halt_blocks_setpoint_ack: false,
101        }
102    }
103}
104
105impl SimDrive {
106    /// New sim drive in `Switch On Disabled`, ready to be enabled.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Enable emulation of a drive that won't acknowledge a set-point while
112    /// halted (AKD / Inovance). Lets tests exercise the halt cancel handshake
113    /// against the awkward case. Default off.
114    pub fn set_halt_blocks_setpoint_ack(&mut self, v: bool) {
115        self.halt_blocks_setpoint_ack = v;
116    }
117
118    /// Advance the simulation by `dt` seconds: run the CiA-402 state machine,
119    /// the PP set-point handshake, and integrate motion. Call once per control
120    /// cycle before the axis reads feedback.
121    pub fn step(&mut self, dt: f64) {
122        self.step_state_machine();
123
124        let cw = self.control_word;
125        let bit = |n: u8| cw & (1 << n) != 0;
126        let new_setpoint = bit(4);
127        let halt = bit(8);
128
129        let enabled = self.state == Cia402State::OperationEnabled;
130        let pp_mode = self.modes_of_operation == 1;
131
132        // ── Set-point acknowledge handshake (PP) ──
133        if enabled && pp_mode {
134            if new_setpoint && !self.prev_new_setpoint {
135                // Rising edge: latch the target and ack — unless we're emulating
136                // a drive that won't ack while halted.
137                if !(self.halt_blocks_setpoint_ack && halt) {
138                    self.active_target = self.target_position;
139                    self.setpoint_ack = true;
140                    self.moving = true;
141                }
142            }
143            if !new_setpoint {
144                // Master dropped the new-setpoint bit → drop the ack.
145                self.setpoint_ack = false;
146            }
147        } else {
148            self.setpoint_ack = false;
149            self.moving = false;
150        }
151        self.prev_new_setpoint = new_setpoint;
152
153        // ── Motion integration ──
154        if enabled && pp_mode && !halt && self.moving {
155            let remaining = self.active_target - self.position_actual;
156            if remaining == 0 {
157                self.velocity_actual = 0;
158                self.moving = false;
159            } else {
160                let step = ((self.profile_velocity as f64 * dt).round() as i32).max(1);
161                if step >= remaining.abs() {
162                    self.position_actual = self.active_target;
163                    self.velocity_actual = 0;
164                    self.moving = false;
165                } else {
166                    let dir = remaining.signum();
167                    self.position_actual += dir * step;
168                    self.velocity_actual = dir * self.profile_velocity as i32;
169                }
170            }
171        } else {
172            // Halted, disabled, or idle → decelerate immediately to rest.
173            self.velocity_actual = 0;
174            if halt {
175                self.moving = false;
176            }
177        }
178
179        // ── Assemble the status word ──
180        let mut sw = status_base(self.state) | (1 << 9); // bit 9: remote
181        if self.setpoint_ack {
182            sw |= 1 << 12; // set-point acknowledge
183        }
184        if !self.moving && self.position_actual == self.active_target {
185            sw |= 1 << 10; // target reached
186        }
187        self.status_word = sw;
188        self.modes_display = self.modes_of_operation;
189    }
190
191    /// Run the CiA-402 control state machine for one step based on the
192    /// control-word command bits (0 Switch On, 1 Enable Voltage, 2 Quick Stop,
193    /// 3 Enable Operation). Fault states aren't simulated — the sim never
194    /// faults on its own.
195    fn step_state_machine(&mut self) {
196        let cw = self.control_word;
197        let switch_on = cw & (1 << 0) != 0;
198        let enable_voltage = cw & (1 << 1) != 0;
199        let quick_stop = cw & (1 << 2) != 0;
200        let enable_op = cw & (1 << 3) != 0;
201
202        use Cia402State::*;
203        // Voltage/quick-stop removed → straight to Switch On Disabled from anywhere.
204        if !enable_voltage || !quick_stop {
205            self.state = SwitchOnDisabled;
206            return;
207        }
208        // enable_voltage && quick_stop held; advance toward the commanded state.
209        self.state = if switch_on {
210            if enable_op { OperationEnabled } else { SwitchedOn }
211        } else {
212            ReadyToSwitchOn
213        };
214    }
215}
216
217impl AxisView for SimDrive {
218    fn control_word(&self) -> u16 {
219        self.control_word
220    }
221    fn set_control_word(&mut self, word: u16) {
222        self.control_word = word;
223    }
224    fn set_target_position(&mut self, pos: i32) {
225        self.target_position = pos;
226    }
227    fn set_profile_velocity(&mut self, vel: u32) {
228        self.profile_velocity = vel;
229    }
230    fn set_profile_acceleration(&mut self, accel: u32) {
231        self.profile_acceleration = accel;
232    }
233    fn set_profile_deceleration(&mut self, decel: u32) {
234        self.profile_deceleration = decel;
235    }
236    fn set_modes_of_operation(&mut self, mode: i8) {
237        self.modes_of_operation = mode;
238    }
239    fn modes_of_operation_display(&self) -> i8 {
240        self.modes_display
241    }
242    fn status_word(&self) -> u16 {
243        self.status_word
244    }
245    fn position_actual(&self) -> i32 {
246        self.position_actual
247    }
248    fn velocity_actual(&self) -> i32 {
249        self.velocity_actual
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::motion::cia402::{Cia402Control, Cia402Status, RawControlWord, RawStatusWord};
257
258    /// Drive the sim's control word the way the CiA-402 enable sequence would,
259    /// stepping a few cycles, and confirm it reaches Operation Enabled.
260    fn enable(sim: &mut SimDrive) {
261        let mut cw = RawControlWord(0);
262        cw.cmd_enable_operation(); // 0x000F
263        sim.set_control_word(cw.raw());
264        sim.set_modes_of_operation(1); // PP
265        for _ in 0..3 {
266            sim.step(0.01);
267        }
268    }
269
270    #[test]
271    fn reaches_operation_enabled() {
272        let mut sim = SimDrive::new();
273        assert_eq!(RawStatusWord(sim.status_word()).state(), Cia402State::SwitchOnDisabled);
274        enable(&mut sim);
275        assert_eq!(RawStatusWord(sim.status_word()).state(), Cia402State::OperationEnabled);
276    }
277
278    #[test]
279    fn disable_voltage_returns_to_switch_on_disabled() {
280        let mut sim = SimDrive::new();
281        enable(&mut sim);
282        sim.set_control_word(0x0000); // drop enable voltage
283        sim.step(0.01);
284        assert_eq!(RawStatusWord(sim.status_word()).state(), Cia402State::SwitchOnDisabled);
285    }
286
287    /// Full PP move: new-setpoint rising edge → setpoint-ack → motion to target
288    /// → target-reached, with the ack dropping when the master clears bit 4.
289    #[test]
290    fn pp_move_runs_handshake_and_reaches_target() {
291        let mut sim = SimDrive::new();
292        enable(&mut sim);
293
294        sim.set_target_position(1000);
295        sim.set_profile_velocity(10_000); // counts/s → 100 counts per 10 ms step
296
297        // Rising edge on new-setpoint (bit 4).
298        let mut cw = RawControlWord(sim.control_word());
299        cw.set_bit(4, true);
300        sim.set_control_word(cw.raw());
301        sim.step(0.01);
302
303        let sw = RawStatusWord(sim.status_word());
304        assert!(sw.raw() & (1 << 12) != 0, "setpoint acknowledge asserted on rising edge");
305        assert!(sim.velocity_actual() > 0, "moving toward target");
306
307        // Master clears bit 4 → ack drops.
308        cw.set_bit(4, false);
309        sim.set_control_word(cw.raw());
310        sim.step(0.01);
311        assert!(RawStatusWord(sim.status_word()).raw() & (1 << 12) == 0, "ack cleared");
312
313        // Run to completion.
314        for _ in 0..20 {
315            sim.step(0.01);
316        }
317        assert_eq!(sim.position_actual(), 1000);
318        assert_eq!(sim.velocity_actual(), 0);
319        assert!(RawStatusWord(sim.status_word()).raw() & (1 << 10) != 0, "target reached");
320    }
321
322    #[test]
323    fn halt_brings_velocity_to_zero() {
324        let mut sim = SimDrive::new();
325        enable(&mut sim);
326        sim.set_target_position(100_000);
327        sim.set_profile_velocity(10_000);
328        let mut cw = RawControlWord(sim.control_word());
329        cw.set_bit(4, true);
330        sim.set_control_word(cw.raw());
331        sim.step(0.01);
332        assert!(sim.velocity_actual() > 0);
333
334        // Assert halt (bit 8) → velocity goes to zero, position holds.
335        cw.set_bit(8, true);
336        sim.set_control_word(cw.raw());
337        sim.step(0.01);
338        assert_eq!(sim.velocity_actual(), 0, "halt stops motion");
339    }
340
341    /// With the quirk emulation on, a new-setpoint rising edge issued while
342    /// halt is asserted does NOT acknowledge — modeling the AKD/Inovance
343    /// behavior the whole halt_blocks_setpoint_ack feature is about.
344    #[test]
345    fn quirk_emulation_blocks_ack_while_halted() {
346        let mut sim = SimDrive::new();
347        sim.set_halt_blocks_setpoint_ack(true);
348        enable(&mut sim);
349
350        // Halt asserted, then a new-setpoint rising edge (the cancel handshake).
351        let mut cw = RawControlWord(sim.control_word());
352        cw.set_bit(8, true); // halt
353        cw.set_bit(4, true); // new setpoint
354        sim.set_control_word(cw.raw());
355        sim.step(0.01);
356        assert!(RawStatusWord(sim.status_word()).raw() & (1 << 12) == 0,
357            "quirk drive must NOT ack a setpoint while halted");
358
359        // Default sim (no quirk) WOULD ack in the same situation.
360        let mut ideal = SimDrive::new();
361        enable(&mut ideal);
362        ideal.set_control_word(cw.raw());
363        ideal.step(0.01);
364        assert!(RawStatusWord(ideal.status_word()).raw() & (1 << 12) != 0,
365            "ideal drive acks even while halted");
366    }
367}