stepper_motion/motion/
profile.rs

1//! Motion profile calculation.
2//!
3//! Provides asymmetric trapezoidal motion profiles with independent
4//! acceleration and deceleration rates.
5
6use libm::sqrtf;
7
8/// Direction of motor motion.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Direction {
11    /// Clockwise (positive step count).
12    Clockwise,
13    /// Counter-clockwise (negative step count).
14    CounterClockwise,
15}
16
17impl Direction {
18    /// Get direction from signed step count.
19    #[inline]
20    pub fn from_steps(steps: i64) -> Self {
21        if steps >= 0 {
22            Direction::Clockwise
23        } else {
24            Direction::CounterClockwise
25        }
26    }
27
28    /// Get the sign multiplier.
29    #[inline]
30    pub fn sign(self) -> i64 {
31        match self {
32            Direction::Clockwise => 1,
33            Direction::CounterClockwise => -1,
34        }
35    }
36}
37
38/// Current phase of motion execution.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum MotionPhase {
41    /// Accelerating from rest toward cruise velocity.
42    Accelerating,
43    /// Moving at constant cruise velocity.
44    Cruising,
45    /// Decelerating from cruise velocity to rest.
46    Decelerating,
47    /// Motion complete.
48    Complete,
49}
50
51/// Computed motion profile for a move (asymmetric trapezoidal).
52#[derive(Debug, Clone)]
53pub struct MotionProfile {
54    /// Total steps to move (absolute value).
55    pub total_steps: u32,
56
57    /// Direction of motion.
58    pub direction: Direction,
59
60    /// Steps in acceleration phase.
61    pub accel_steps: u32,
62
63    /// Steps in cruise phase (constant velocity).
64    pub cruise_steps: u32,
65
66    /// Steps in deceleration phase.
67    pub decel_steps: u32,
68
69    /// Initial step interval (nanoseconds) - at start of acceleration.
70    pub initial_interval_ns: u32,
71
72    /// Cruise step interval (nanoseconds) - at max velocity.
73    pub cruise_interval_ns: u32,
74
75    /// Acceleration rate in steps/sec².
76    pub accel_rate: f32,
77
78    /// Deceleration rate in steps/sec².
79    pub decel_rate: f32,
80}
81
82impl MotionProfile {
83    /// Create an asymmetric trapezoidal motion profile.
84    ///
85    /// # Arguments
86    ///
87    /// * `total_steps` - Signed step count (positive = CW, negative = CCW)
88    /// * `max_velocity` - Maximum velocity in steps/sec
89    /// * `acceleration` - Acceleration rate in steps/sec²
90    /// * `deceleration` - Deceleration rate in steps/sec²
91    pub fn asymmetric_trapezoidal(
92        total_steps: i64,
93        max_velocity: f32,
94        acceleration: f32,
95        deceleration: f32,
96    ) -> Self {
97        let direction = Direction::from_steps(total_steps);
98        let steps = total_steps.unsigned_abs() as u32;
99
100        if steps == 0 || max_velocity <= 0.0 || acceleration <= 0.0 || deceleration <= 0.0 {
101            return Self::zero();
102        }
103
104        // Calculate phase lengths for asymmetric profile
105        // Time to reach max velocity: t = v_max / a
106        // Distance during acceleration: d = 0.5 * a * t²
107        let t_accel = max_velocity / acceleration;
108        let t_decel = max_velocity / deceleration;
109
110        let accel_distance = 0.5 * acceleration * t_accel * t_accel;
111        let decel_distance = 0.5 * deceleration * t_decel * t_decel;
112
113        let (accel_steps, cruise_steps, decel_steps) =
114            if accel_distance + decel_distance >= steps as f32 {
115                // Triangle profile: can't reach max velocity
116                // Scale down proportionally based on acceleration rates
117                let ratio = acceleration / (acceleration + deceleration);
118                let accel_steps = (steps as f32 * ratio) as u32;
119                let decel_steps = steps.saturating_sub(accel_steps);
120                (accel_steps, 0u32, decel_steps)
121            } else {
122                // Full trapezoidal profile
123                let accel_steps = accel_distance as u32;
124                let decel_steps = decel_distance as u32;
125                let cruise_steps = steps.saturating_sub(accel_steps + decel_steps);
126                (accel_steps, cruise_steps, decel_steps)
127            };
128
129        // Calculate step intervals
130        // Initial interval is very long (starting from rest)
131        // We use a practical minimum initial velocity
132        let initial_velocity = sqrtf(2.0 * acceleration);
133        let initial_interval_ns = (1_000_000_000.0 / initial_velocity) as u32;
134        let cruise_interval_ns = (1_000_000_000.0 / max_velocity) as u32;
135
136        Self {
137            total_steps: steps,
138            direction,
139            accel_steps,
140            cruise_steps,
141            decel_steps,
142            initial_interval_ns,
143            cruise_interval_ns,
144            accel_rate: acceleration,
145            decel_rate: deceleration,
146        }
147    }
148
149    /// Create a symmetric trapezoidal profile (same accel and decel).
150    pub fn symmetric_trapezoidal(
151        total_steps: i64,
152        max_velocity: f32,
153        acceleration: f32,
154    ) -> Self {
155        Self::asymmetric_trapezoidal(total_steps, max_velocity, acceleration, acceleration)
156    }
157
158    /// Create a zero-length profile (no motion).
159    pub fn zero() -> Self {
160        Self {
161            total_steps: 0,
162            direction: Direction::Clockwise,
163            accel_steps: 0,
164            cruise_steps: 0,
165            decel_steps: 0,
166            initial_interval_ns: u32::MAX,
167            cruise_interval_ns: u32::MAX,
168            accel_rate: 0.0,
169            decel_rate: 0.0,
170        }
171    }
172
173    /// Check if this is a zero-length profile.
174    #[inline]
175    pub fn is_zero(&self) -> bool {
176        self.total_steps == 0
177    }
178
179    /// Get the phase at a given step number.
180    pub fn phase_at(&self, step: u32) -> MotionPhase {
181        if step >= self.total_steps {
182            MotionPhase::Complete
183        } else if step < self.accel_steps {
184            MotionPhase::Accelerating
185        } else if step < self.accel_steps + self.cruise_steps {
186            MotionPhase::Cruising
187        } else {
188            MotionPhase::Decelerating
189        }
190    }
191
192    /// Calculate step interval for a given step number.
193    ///
194    /// Uses the step timing formula for trapezoidal acceleration.
195    pub fn interval_at(&self, step: u32) -> u32 {
196        let phase = self.phase_at(step);
197
198        match phase {
199            MotionPhase::Complete => u32::MAX,
200            MotionPhase::Cruising => self.cruise_interval_ns,
201            MotionPhase::Accelerating => {
202                // During acceleration: interval decreases
203                // t_n = t_0 * sqrt(n / (n + 1)) approximately
204                // We use a simplified linear interpolation for now
205                let progress = step as f32 / self.accel_steps.max(1) as f32;
206                let interval = self.initial_interval_ns as f32
207                    - (self.initial_interval_ns as f32 - self.cruise_interval_ns as f32) * progress;
208                interval as u32
209            }
210            MotionPhase::Decelerating => {
211                // During deceleration: interval increases
212                let decel_step = step - self.accel_steps - self.cruise_steps;
213                let progress = decel_step as f32 / self.decel_steps.max(1) as f32;
214                let interval = self.cruise_interval_ns as f32
215                    + (self.initial_interval_ns as f32 - self.cruise_interval_ns as f32) * progress;
216                interval as u32
217            }
218        }
219    }
220
221    /// Estimate total duration of the motion profile in seconds.
222    ///
223    /// This is an approximation based on the trapezoidal profile phases.
224    pub fn estimated_duration_secs(&self) -> f32 {
225        if self.total_steps == 0 {
226            return 0.0;
227        }
228
229        let cruise_velocity = 1_000_000_000.0 / self.cruise_interval_ns as f32;
230
231        // Time for each phase
232        // Acceleration: v = a*t, so t = v/a
233        let accel_time = if self.accel_rate > 0.0 {
234            cruise_velocity / self.accel_rate
235        } else {
236            0.0
237        };
238
239        // Cruise: t = distance / velocity
240        let cruise_time = self.cruise_steps as f32 / cruise_velocity;
241
242        // Deceleration: t = v/d
243        let decel_time = if self.decel_rate > 0.0 {
244            cruise_velocity / self.decel_rate
245        } else {
246            0.0
247        };
248
249        accel_time + cruise_time + decel_time
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_symmetric_profile() {
259        let profile = MotionProfile::symmetric_trapezoidal(
260            1000,   // steps
261            1000.0, // steps/sec
262            2000.0, // steps/sec²
263        );
264
265        assert_eq!(profile.total_steps, 1000);
266        assert_eq!(profile.direction, Direction::Clockwise);
267        assert!(profile.accel_steps > 0);
268        assert!(profile.cruise_steps > 0);
269        assert_eq!(profile.accel_steps, profile.decel_steps);
270    }
271
272    #[test]
273    fn test_asymmetric_profile() {
274        let profile = MotionProfile::asymmetric_trapezoidal(
275            1000,   // steps
276            1000.0, // steps/sec
277            2000.0, // accel steps/sec²
278            1000.0, // decel steps/sec² (slower)
279        );
280
281        assert!(profile.decel_steps > profile.accel_steps);
282    }
283
284    #[test]
285    fn test_triangle_profile() {
286        // Very short move that can't reach max velocity
287        let profile = MotionProfile::symmetric_trapezoidal(
288            100,     // only 100 steps
289            10000.0, // very high max velocity
290            1000.0,  // moderate acceleration
291        );
292
293        // Should be a triangle (no cruise phase)
294        assert_eq!(profile.cruise_steps, 0);
295    }
296
297    #[test]
298    fn test_direction() {
299        let cw = MotionProfile::symmetric_trapezoidal(100, 1000.0, 2000.0);
300        let ccw = MotionProfile::symmetric_trapezoidal(-100, 1000.0, 2000.0);
301
302        assert_eq!(cw.direction, Direction::Clockwise);
303        assert_eq!(ccw.direction, Direction::CounterClockwise);
304        assert_eq!(cw.total_steps, ccw.total_steps);
305    }
306}