autocore_std/motion/axis_config.rs
1//! Axis configuration: unit conversion, jog defaults, limits.
2//!
3//! [`AxisConfig`] stores the encoder resolution and user-unit scaling,
4//! plus jog parameters, home position, and software position limits.
5//! It is used by [`Axis`](super::Axis) for all unit conversions.
6
7/// Configuration for a single motion axis.
8///
9/// Stores encoder resolution, user-unit scaling, jog defaults,
10/// and position limits. Used by [`Axis`](super::Axis) internally
11/// and also available for direct use (e.g. logging, HMI display).
12///
13/// # Examples
14///
15/// ```
16/// use autocore_std::motion::AxisConfig;
17///
18/// // ClearPath with 12,800 counts/rev, user units = degrees
19/// let cfg = AxisConfig::new(12_800).with_user_scale(360.0);
20/// assert!((cfg.to_counts(45.0) - 1600.0).abs() < 0.01);
21/// assert!((cfg.to_user(1600.0) - 45.0).abs() < 0.01);
22///
23/// // mm on a 5 mm/rev ballscrew
24/// let mm = AxisConfig::new(12_800).with_user_scale(5.0);
25/// assert!((mm.to_counts(5.0) - 12800.0).abs() < 0.01);
26/// ```
27#[derive(Debug, Clone)]
28pub struct AxisConfig {
29 // ── Unit conversion (private — use to_counts/to_user) ──
30 counts_per_rev: f64,
31 user_per_rev: f64,
32 /// When true, invert the sign of all position/velocity conversions.
33 /// Use when the motor counts in the opposite direction to your user-unit convention.
34 pub invert_direction: bool,
35
36 // ── Jog defaults ──
37
38 /// Jog speed in user units/s.
39 pub jog_speed: f64,
40 /// Jog acceleration in user units/s².
41 pub jog_accel: f64,
42 /// Jog deceleration in user units/s².
43 pub jog_decel: f64,
44
45 // ── Home ──
46
47 /// User-unit position assigned at the home reference point.
48 pub home_position: f64,
49 /// Homing search speed in user units/s.
50 pub homing_speed: f64,
51 /// Homing acceleration in user units/s².
52 pub homing_accel: f64,
53 /// Homing deceleration in user units/s².
54 pub homing_decel: f64,
55 /// CiA 402 homing method used when the axis needs to declare "current position
56 /// = home offset" — i.e. the terminal step of software homing (after a sensor
57 /// search) and the integrated [`HomingMethod::CurrentPosition`] variant.
58 ///
59 /// Methods 37 and 35 are functionally equivalent; 37 is the newer CiA 402
60 /// addition. Default: 37 (works on Teknic ClearPath and other modern drives).
61 /// Set to 35 for drives whose 6098h range stops at 35 (Inovance SV660N).
62 pub soft_home_method: i8,
63
64 /// Drive trait: does this drive refuse to assert set-point-acknowledge
65 /// (status word bit 12) while Halt (control word bit 8) is asserted?
66 ///
67 /// In CiA-402 Profile Position mode the cancel handshake that closes out a
68 /// halt issues a new set-point (current position, zero velocity) and waits
69 /// for the drive to acknowledge it. Drives disagree on whether that
70 /// acknowledge is produced while Halt is still active — and this is the
71 /// one behavioral split between the drives we support:
72 ///
73 /// - **`true`** — the drive will NOT acknowledge a set-point while halted,
74 /// so [`command_cancel_move`](super::axis::Axis::command_cancel_move)
75 /// must clear the halt bit while issuing the cancel set-point. The
76 /// Kollmorgen AKD and Inovance SV660N both behave this way. Two
77 /// independent, reference-grade CiA-402 implementations agreeing makes
78 /// this the *standard* behavior.
79 /// - **`false` (default)** — halt is held through the whole cancel
80 /// handshake. The Teknic ClearPath needs this: it acknowledges the
81 /// set-point while halted, and clearing halt early makes it resume the
82 /// prior move. Teknic is the oddball here.
83 ///
84 /// The default is `false` deliberately as the **safe fallback**, not
85 /// because it is the common case: if an unknown drive actually needs halt
86 /// cleared, the halt merely times out with the motor already stopped
87 /// (loud and safe), whereas defaulting to `true` on a Teknic-like drive
88 /// could provoke unexpected motion when halt drops mid-handshake.
89 ///
90 /// Typically not hand-set: the drive's extended-info default is seeded into
91 /// the axis's `options` in `project.json` (overridable there) and baked into
92 /// the generated drive handle by codegen, the same path as `invert_direction`.
93 pub halt_blocks_setpoint_ack: bool,
94
95 /// Drive trait: this drive will NOT assert set-point-acknowledge (status
96 /// word bit 12) for a set-point issued with Change-Set-Immediately (control
97 /// word bit 5) asserted — so the halt cancel handshake, which normally sets
98 /// bit 5, stalls at `WaitCancelAck` even with halt cleared.
99 ///
100 /// When `true`, the halt/cancel sequence keeps bit 5 **clear**, so the
101 /// cancel set-point uses the same plain bit-4 handshake as a normal move
102 /// (which the drive does acknowledge). The Kollmorgen AKD needs this; the
103 /// Teknic ClearPath and Inovance SV660N acknowledge a change-immediately
104 /// set-point and rely on bit 5, so they leave it `false`.
105 ///
106 /// Distinct from [`halt_blocks_setpoint_ack`](Self::halt_blocks_setpoint_ack):
107 /// a drive can need one, both, or neither. Default `false` (preserve bit 5).
108 pub change_set_immediately_blocks_ack: bool,
109
110 // ── Timeouts ──
111
112 /// Timeout for general operations (enable, disable, halt, fault recovery) in seconds.
113 /// Default: 7s.
114 pub operation_timeout_secs: f64,
115 /// Timeout for homing operations in seconds. Default: 30s.
116 pub homing_timeout_secs: f64,
117
118 // ── Software position limits ──
119
120 /// Enable the maximum (positive) software position limit.
121 pub enable_max_position_limit: bool,
122 /// Enable the minimum (negative) software position limit.
123 pub enable_min_position_limit: bool,
124 /// Maximum position limit in user units.
125 pub max_position_limit: f64,
126 /// Minimum position limit in user units.
127 pub min_position_limit: f64,
128}
129
130impl AxisConfig {
131 /// Create a new configuration from encoder resolution.
132 ///
133 /// Default user units are revolutions (1.0 user unit = 1 revolution).
134 /// Call [`with_user_scale`](Self::with_user_scale) to change.
135 pub fn new(counts_per_rev: u32) -> Self {
136 Self {
137 counts_per_rev: counts_per_rev as f64,
138 user_per_rev: 1.0,
139 invert_direction: false,
140 jog_speed: 0.0,
141 jog_accel: 0.0,
142 jog_decel: 0.0,
143 home_position: 0.0,
144 homing_speed: 0.0,
145 homing_accel: 0.0,
146 homing_decel: 0.0,
147 soft_home_method: 37,
148 halt_blocks_setpoint_ack: false,
149 change_set_immediately_blocks_ack: false,
150 operation_timeout_secs: 7.0,
151 homing_timeout_secs: 30.0,
152 enable_max_position_limit: false,
153 enable_min_position_limit: false,
154 max_position_limit: 0.0,
155 min_position_limit: 0.0,
156 }
157 }
158
159 /// Set user-units-per-revolution.
160 ///
161 /// - Degrees: `.with_user_scale(360.0)`
162 /// - mm on 5 mm/rev ballscrew: `.with_user_scale(5.0)`
163 pub fn with_user_scale(mut self, user_per_rev: f64) -> Self {
164 self.user_per_rev = user_per_rev;
165 self
166 }
167
168 // ── Conversion methods ──
169
170 /// Convert user units to encoder counts (f64).
171 ///
172 /// `to_counts(45.0)` on a 12800 cpr / 360° config → 1600.0
173 pub fn to_counts(&self, user_units: f64) -> f64 {
174 let sign = if self.invert_direction { -1.0 } else { 1.0 };
175 user_units * self.counts_per_user() * sign
176 }
177
178 /// Convert encoder counts to user units (f64).
179 ///
180 /// `to_user(1600.0)` on a 12800 cpr / 360° config → 45.0
181 pub fn to_user(&self, counts: f64) -> f64 {
182 let sign = if self.invert_direction { -1.0 } else { 1.0 };
183 counts * sign / self.counts_per_user()
184 }
185
186 /// Encoder counts per user unit (scale factor).
187 ///
188 /// For 12800 cpr / 360°: `counts_per_user() ≈ 35.556`
189 pub fn counts_per_user(&self) -> f64 {
190 self.counts_per_rev / self.user_per_rev
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn halt_blocks_setpoint_ack_defaults_to_safe_false() {
200 // The safe fallback: an unknown drive holds halt through the cancel
201 // handshake (Teknic-style) and fails loud rather than risking motion.
202 let cfg = AxisConfig::new(1_048_576);
203 assert!(!cfg.halt_blocks_setpoint_ack);
204 }
205
206 #[test]
207 fn basic_conversion_degrees() {
208 let cfg = AxisConfig::new(12_800).with_user_scale(360.0);
209 assert!((cfg.to_counts(45.0) - 1600.0).abs() < 0.01);
210 assert!((cfg.to_counts(360.0) - 12800.0).abs() < 0.01);
211 assert!((cfg.to_user(1600.0) - 45.0).abs() < 0.01);
212 assert!((cfg.to_user(12800.0) - 360.0).abs() < 0.01);
213 }
214
215 #[test]
216 fn basic_conversion_mm() {
217 let cfg = AxisConfig::new(12_800).with_user_scale(5.0);
218 assert!((cfg.to_counts(5.0) - 12800.0).abs() < 0.01);
219 assert!((cfg.to_counts(2.5) - 6400.0).abs() < 0.01);
220 }
221
222 #[test]
223 fn counts_per_user() {
224 let cfg = AxisConfig::new(12_800).with_user_scale(360.0);
225 let cpu = cfg.counts_per_user();
226 assert!((cpu - 12800.0 / 360.0).abs() < 1e-9);
227 }
228
229 #[test]
230 fn default_revolutions() {
231 let cfg = AxisConfig::new(12_800);
232 // 1 rev = 12800 counts
233 assert!((cfg.to_counts(1.0) - 12800.0).abs() < 0.01);
234 assert!((cfg.to_counts(0.125) - 1600.0).abs() < 0.01);
235 }
236
237 #[test]
238 fn round_trip() {
239 let cfg = AxisConfig::new(12_800).with_user_scale(360.0);
240 let original = 45.0;
241 let counts = cfg.to_counts(original);
242 let back = cfg.to_user(counts);
243 assert!((back - original).abs() < 1e-9);
244 }
245
246 #[test]
247 fn defaults() {
248 let cfg = AxisConfig::new(12_800);
249 assert_eq!(cfg.jog_speed, 0.0);
250 assert_eq!(cfg.home_position, 0.0);
251 assert!(!cfg.enable_max_position_limit);
252 assert!(!cfg.enable_min_position_limit);
253 }
254}