Skip to main content

autocore_std/motion/
pressure_control.rs

1//! Closed-loop pressure / force controller for Profile Position axes.
2//!
3//! Drives a single axis toward a target load (e.g. from a load cell) and
4//! holds it there by issuing a small incremental `move_absolute` every
5//! tick. Uses an exponential moving-average filter on the load input, a
6//! PID with anti-windup, a per-tick step clamp, and optional position
7//! limits to keep the axis inside a safety envelope.
8//!
9//! # Lifecycle
10//!
11//! Same shape as every other domain FB in autocore-std:
12//!
13//! ```text
14//!   [Idle]  --start(target, max_load)-->  [Controlling]
15//!   [Controlling]  --stop()----------->   [Halted] -- axis idle --> [Idle]
16//!   [Controlling]  --fault or limit -->   [Error]
17//! ```
18//!
19//! `is_busy()` is `true` while the loop is running or stopping. `is_error()`
20//! goes true on axis fault or `max_load` exceedance; call `error_message()`
21//! for a short description, or inspect the underlying state machine.
22//!
23//! # Engage only when the axis has settled
24//!
25//! `start()` lazily seeds `commanded_position = axis.position()` on the
26//! first `tick()` after engagement. If you engage while the axis is still
27//! decelerating from a prior move, the seed lands in the middle of that
28//! deceleration profile and the loop's initial output will fight the
29//! drive's trajectory. The typical pattern is: run `MoveToLoad` to target,
30//! wait for it to go idle, THEN call `start()`.
31
32use crate::fb::StateMachine;
33use super::axis_view::AxisHandle;
34
35/// Configuration for the Pressure Control function block.
36#[derive(Debug, Clone)]
37pub struct PressureControlConfig {
38    /// Proportional gain (Kp).
39    pub kp: f64,
40    /// Integral gain (Ki).
41    pub ki: f64,
42    /// Derivative gain (Kd).
43    pub kd: f64,
44    /// Feed forward value added directly to the output.
45    pub feed_forward: f64,
46    /// Maximum allowed position delta (in user units) per tick.
47    /// Critical for safety to prevent crushing the load cell.
48    /// E.g., 0.001 inches per ms tick.
49    pub max_step: f64,
50    /// Sub-threshold deadband. When the computed step is smaller than
51    /// `min_step` we skip the `move_absolute` call entirely. Keeps the
52    /// EtherCAT bus quiet near steady state. Set to 0 to issue a move
53    /// every tick regardless of step size.
54    pub min_step: f64,
55    /// Maximum accumulated integral windup.
56    pub max_integral: f64,
57    /// Exponential Moving Average filter coefficient for the load cell (0.0 to 1.0).
58    /// 1.0 = No filtering (raw data).
59    /// 0.1 = Heavy filtering.
60    pub filter_alpha: f64,
61    /// If true, a positive error (need more pressure) results in a *negative* move.
62    /// Set to true if moving the axis down (negative) increases compression.
63    pub invert_direction: bool,
64    /// The acceptable load error window to be considered "in tolerance" (e.g., +/- 2.0 lbs).
65    pub tolerance: f64,
66    /// How long the load must remain within `tolerance` before reporting `in_tolerance = true`.
67    pub settling_time: f64,
68    /// Optional hard ceiling on `commanded_position` in user units. If the
69    /// loop's integrator drives the commanded position past this, the
70    /// commanded position is clamped and the integral is frozen that tick
71    /// (extra anti-windup). `None` disables the clamp.
72    pub position_limit_pos: Option<f64>,
73    /// Optional hard floor on `commanded_position`. Mirror of
74    /// `position_limit_pos`.
75    pub position_limit_neg: Option<f64>,
76}
77
78impl Default for PressureControlConfig {
79    fn default() -> Self {
80        Self {
81            kp: 0.0,
82            ki: 0.0,
83            kd: 0.0,
84            feed_forward: 0.0,
85            max_step: 0.005,      // Conservative 5 thousandths max step
86            min_step: 0.0,        // Opt-in deadband; default fires every tick
87            max_integral: 100.0,
88            filter_alpha: 0.5,
89            invert_direction: false,
90            tolerance: 1.0,
91            settling_time: 0.1,
92            position_limit_pos: None,
93            position_limit_neg: None,
94        }
95    }
96}
97
98#[repr(i32)]
99#[derive(Copy, Clone, PartialEq, Debug)]
100enum PcState {
101    Idle        = 0,
102    Controlling = 10,
103    /// `stop()` issued; axis has been halted, waiting on the drive to
104    /// flip its busy bit back to false before returning to Idle.
105    Halted      = 20,
106    Error       = 30,
107}
108
109impl PcState {
110    fn from_index(idx: i32) -> Option<Self> {
111        match idx {
112            x if x == Self::Idle as i32        => Some(Self::Idle),
113            x if x == Self::Controlling as i32 => Some(Self::Controlling),
114            x if x == Self::Halted as i32      => Some(Self::Halted),
115            x if x == Self::Error as i32       => Some(Self::Error),
116            _ => None,
117        }
118    }
119}
120
121/// A closed-loop PID pressure/force controller for Profile Position (PP) axes.
122#[derive(Debug, Clone)]
123pub struct PressureControl {
124    /// Internal state machine.
125    state: StateMachine,
126
127    /// True when the current load has been within `config.tolerance` of the
128    /// target for at least `config.settling_time` seconds. Cleared by
129    /// `start()`, `stop()`, and any error transition.
130    in_tolerance: bool,
131
132    // Latched on start().
133    target_load: f64,
134    max_load: f64,
135
136    // Internal PID state.
137    integral: f64,
138    prev_error: f64,
139    filtered_load: f64,
140
141    // Internal setpoint state. Accumulated to avoid the positive-feedback
142    // lag of re-reading axis.position() each tick. Reseeded from the axis
143    // on the first tick after start().
144    commanded_position: f64,
145
146    /// PID error from the most recent tick, exposed via `pid_error()`.
147    last_pid_error: f64,
148
149    // Tolerance timer.
150    settling_timer: f64,
151
152    /// True between `start()` and the first tick that consumes it — tells
153    /// the loop to seed `filtered_load` and `commanded_position` from the
154    /// axis instead of using stale values from a prior run.
155    is_first_run: bool,
156}
157
158impl Default for PressureControl {
159    fn default() -> Self {
160        Self {
161            state: StateMachine::new(),
162            in_tolerance: false,
163            target_load: 0.0,
164            max_load: f64::INFINITY,
165            integral: 0.0,
166            prev_error: 0.0,
167            filtered_load: 0.0,
168            commanded_position: 0.0,
169            last_pid_error: 0.0,
170            settling_timer: 0.0,
171            is_first_run: false,
172        }
173    }
174}
175
176impl PressureControl {
177    /// Creates a new, idle PressureControl block.
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Engage the loop. Latches `target_load` and the safety ceiling
183    /// `max_load`; clears PID state. The next `tick()` seeds
184    /// `commanded_position` from the axis's current position.
185    ///
186    /// `max_load` is checked against `|current_load|` every tick; exceeding
187    /// it transitions to `Error` and halts the axis. Pass `f64::INFINITY`
188    /// if you genuinely want no limit (not recommended).
189    pub fn start(&mut self, target_load: f64, max_load: f64) {
190        self.state.clear_error();
191        self.target_load = target_load;
192        self.max_load = max_load;
193        self.integral = 0.0;
194        self.prev_error = 0.0;
195        self.last_pid_error = 0.0;
196        self.settling_timer = 0.0;
197        self.in_tolerance = false;
198        self.is_first_run = true;
199        self.state.index = PcState::Controlling as i32;
200    }
201
202    /// Halt the axis and transition toward Idle. Safe to call any time;
203    /// a no-op when already Idle or Halted.
204    pub fn stop(&mut self, axis: &mut impl AxisHandle) {
205        let idx = self.state.index;
206        if idx == PcState::Idle as i32 || idx == PcState::Halted as i32 {
207            return;
208        }
209        axis.halt();
210        self.in_tolerance = false;
211        self.state.index = PcState::Halted as i32;
212    }
213
214    /// Change the target load without resetting the loop. Useful for
215    /// setpoint ramps during a test.
216    pub fn set_target(&mut self, target_load: f64) {
217        self.target_load = target_load;
218    }
219
220    /// Change the max-load safety limit without resetting the loop.
221    pub fn set_max_load(&mut self, max_load: f64) {
222        self.max_load = max_load;
223    }
224
225    /// Current loop state: running or stopping.
226    pub fn is_busy(&self) -> bool {
227        let idx = self.state.index;
228        idx == PcState::Controlling as i32 || idx == PcState::Halted as i32
229    }
230
231    /// Fault state. Check `error_message()` for the reason.
232    pub fn is_error(&self) -> bool {
233        self.state.index == PcState::Error as i32 || self.state.is_error()
234    }
235
236    /// Description of the most recent error.
237    pub fn error_message(&self) -> String {
238        self.state.error_message.clone()
239    }
240
241    /// True once the load has been within `config.tolerance` of
242    /// `target_load` for at least `config.settling_time` seconds.
243    pub fn is_in_tolerance(&self) -> bool {
244        self.in_tolerance
245    }
246
247    /// Most recent filtered load reading. Updated every `tick()`.
248    pub fn filtered_load(&self) -> f64 {
249        self.filtered_load
250    }
251
252    /// Current internal commanded position (user units). Updated every
253    /// `tick()` by adding the clamped PID step.
254    pub fn commanded_position(&self) -> f64 {
255        self.commanded_position
256    }
257
258    /// Most recent PID error: `target_load − filtered_load`. Signed; sign
259    /// is NOT inverted by `config.invert_direction`.
260    pub fn pid_error(&self) -> f64 {
261        self.last_pid_error
262    }
263
264    /// Advance the loop by one scan. No-op when Idle.
265    pub fn tick(
266        &mut self,
267        axis: &mut impl AxisHandle,
268        current_load: f64,
269        config: &PressureControlConfig,
270        dt: f64,
271    ) {
272        // Drive-side fault takes priority over anything the loop is doing.
273        if axis.is_error() && self.state.index != PcState::Idle as i32 {
274            self.state.set_error(100, "Axis is in error state");
275            self.state.index = PcState::Error as i32;
276            return;
277        }
278
279        match PcState::from_index(self.state.index) {
280            Some(PcState::Idle) | Some(PcState::Error) | None => {
281                // Idle or errored out — nothing to do. `start()` takes us
282                // back to Controlling.
283            }
284
285            Some(PcState::Halted) => {
286                // `stop()` issued the halt; wait for the drive to finish
287                // decelerating before declaring Idle.
288                if !axis.is_busy() {
289                    self.state.index = PcState::Idle as i32;
290                }
291            }
292
293            Some(PcState::Controlling) => {
294                // 1. Safety first: abort if the raw load exceeded its
295                //    ceiling. Must check the raw reading, not the filter,
296                //    because a sensor fault can spike faster than the EMA
297                //    responds.
298                if current_load.abs() > self.max_load {
299                    axis.halt();
300                    self.state.set_error(
301                        110,
302                        format!(
303                            "load {} exceeded max_load {}",
304                            current_load, self.max_load,
305                        ),
306                    );
307                    self.state.index = PcState::Error as i32;
308                    return;
309                }
310
311                // 2. First-tick seed. Align the filter and commanded
312                //    position to current reality so the loop doesn't start
313                //    chasing stale values.
314                if self.is_first_run {
315                    self.filtered_load      = current_load;
316                    self.commanded_position = axis.position();
317                    self.is_first_run       = false;
318                }
319
320                // 3. EMA filter on the load.
321                let alpha = config.filter_alpha.clamp(0.0, 1.0);
322                self.filtered_load = alpha * current_load
323                                   + (1.0 - alpha) * self.filtered_load;
324
325                // 4. PID. Integral + derivative use `dt`; caller owns it.
326                let error = self.target_load - self.filtered_load;
327                self.last_pid_error = error;
328
329                let derivative = if dt > 0.0 {
330                    (error - self.prev_error) / dt
331                } else {
332                    0.0
333                };
334                self.prev_error = error;
335
336                // Raw (pre-clamp) output — integral contribution included
337                // for now; we'll roll back the integral step if we end up
338                // saturating.
339                let integral_candidate = (self.integral + error * dt)
340                    .clamp(-config.max_integral, config.max_integral);
341                let raw_output = config.kp * error
342                               + config.ki * integral_candidate
343                               + config.kd * derivative
344                               + config.feed_forward;
345
346                // 5. Direction + step clamp. Track whether we saturated so
347                //    we can apply conditional integration.
348                let signed_output = if config.invert_direction {
349                    -raw_output
350                } else {
351                    raw_output
352                };
353                let saturated = signed_output.abs() > config.max_step;
354                let step = signed_output.clamp(-config.max_step, config.max_step);
355
356                // Conditional integration: only commit the integral step
357                // when we're NOT bumping against the saturation clamp.
358                // Keeps the integrator from winding up during long
359                // approaches where max_step is the binding constraint.
360                if !saturated {
361                    self.integral = integral_candidate;
362                }
363
364                // 6. Accumulate commanded position, clamp to optional
365                //    envelope. Clamping also freezes the integral this
366                //    tick (a second anti-windup belt-and-suspenders).
367                let next_cmd = self.commanded_position + step;
368                let clamped = match (config.position_limit_neg, config.position_limit_pos) {
369                    (Some(lo), Some(hi)) => next_cmd.clamp(lo, hi),
370                    (Some(lo), None)     => next_cmd.max(lo),
371                    (None, Some(hi))     => next_cmd.min(hi),
372                    (None, None)         => next_cmd,
373                };
374                if clamped != next_cmd {
375                    // Clamped — pull the integral back to the pre-accumulate
376                    // value so it doesn't keep winding into the wall.
377                    self.integral = self.integral - error * dt;
378                    self.integral = self.integral.clamp(-config.max_integral, config.max_integral);
379                }
380                self.commanded_position = clamped;
381
382                // 7. Issue the move, subject to the min-step deadband.
383                let issued_step = self.commanded_position - axis.position();
384                if issued_step.abs() > config.min_step {
385                    let vel = axis.config().jog_speed;
386                    let acc = axis.config().jog_accel;
387                    let dec = axis.config().jog_decel;
388                    axis.move_absolute(self.commanded_position, vel, acc, dec);
389                }
390
391                // 8. Tolerance / settling.
392                if error.abs() <= config.tolerance {
393                    self.settling_timer += dt;
394                    if self.settling_timer >= config.settling_time {
395                        self.in_tolerance = true;
396                    }
397                } else {
398                    self.settling_timer = 0.0;
399                    self.in_tolerance = false;
400                }
401            }
402        }
403
404        self.state.call();
405    }
406}
407
408// -------------------------------------------------------------------------
409// Tests
410// -------------------------------------------------------------------------
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::motion::axis_config::AxisConfig;
416
417    struct MockAxis {
418        position:        f64,
419        busy:            bool,
420        error:           bool,
421        config:          AxisConfig,
422        halt_calls:      u32,
423        last_move_target: f64,
424        move_calls:      u32,
425        /// Simple spring model: position increases force linearly.
426        /// current_load = spring_k * (position - rest_position)
427        spring_k:        f64,
428        rest_position:   f64,
429    }
430
431    impl MockAxis {
432        fn new() -> Self {
433            let mut cfg = AxisConfig::new(10_000);
434            cfg.jog_speed = 5.0;
435            cfg.jog_accel = 50.0;
436            cfg.jog_decel = 50.0;
437            Self {
438                position: 0.0, busy: false, error: false,
439                config: cfg,
440                halt_calls: 0, last_move_target: 0.0, move_calls: 0,
441                spring_k: 0.0, rest_position: 0.0,
442            }
443        }
444        fn with_spring(mut self, k: f64, rest: f64) -> Self {
445            self.spring_k = k;
446            self.rest_position = rest;
447            self
448        }
449        fn simulated_load(&self) -> f64 {
450            self.spring_k * (self.position - self.rest_position)
451        }
452        /// Pretend the drive processed the last move_absolute: move the
453        /// axis instantly to the commanded position. Tests call this
454        /// between ticks to advance the simulation.
455        fn advance(&mut self) {
456            self.position = self.last_move_target;
457        }
458    }
459
460    impl AxisHandle for MockAxis {
461        fn position(&self) -> f64 { self.position }
462        fn config(&self) -> &AxisConfig { &self.config }
463        fn move_absolute(&mut self, p: f64, _v: f64, _a: f64, _d: f64) {
464            self.last_move_target = p;
465            self.move_calls += 1;
466            self.busy = true;
467        }
468        fn move_relative(&mut self, _: f64, _: f64, _: f64, _: f64) {}
469        fn halt(&mut self) {
470            self.halt_calls += 1;
471            self.busy = false;
472        }
473        fn is_busy(&self) -> bool { self.busy }
474        fn is_error(&self) -> bool { self.error }
475        fn motor_on(&self) -> bool { true }
476    }
477
478    fn zero_pid_config() -> PressureControlConfig {
479        PressureControlConfig { ..Default::default() }
480    }
481
482    #[test]
483    fn tick_is_noop_when_idle() {
484        let mut pc = PressureControl::new();
485        let mut axis = MockAxis::new();
486        let cfg = zero_pid_config();
487        pc.tick(&mut axis, 0.0, &cfg, 0.01);
488        assert!(!pc.is_busy());
489        assert_eq!(axis.move_calls, 0);
490        assert_eq!(axis.halt_calls, 0);
491    }
492
493    #[test]
494    fn start_then_tick_seeds_from_axis() {
495        let mut pc = PressureControl::new();
496        let mut axis = MockAxis::new();
497        axis.position = 3.25;
498        pc.start(10.0, 500.0);
499        assert!(pc.is_busy());
500        // is_first_run catches in this tick.
501        pc.tick(&mut axis, 0.0, &zero_pid_config(), 0.01);
502        assert_eq!(pc.commanded_position(), 3.25);
503        assert_eq!(pc.filtered_load(), 0.0);
504    }
505
506    #[test]
507    fn stop_halts_and_transitions_to_idle() {
508        let mut pc = PressureControl::new();
509        let mut axis = MockAxis::new();
510        pc.start(10.0, 500.0);
511        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
512        assert!(pc.is_busy());
513        pc.stop(&mut axis);
514        assert_eq!(axis.halt_calls, 1);
515        assert!(pc.is_busy(), "still busy until axis finishes halt");
516        axis.busy = false;  // drive finished deceleration
517        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
518        assert!(!pc.is_busy());
519    }
520
521    #[test]
522    fn max_load_triggers_error_and_halt() {
523        let mut pc = PressureControl::new();
524        let mut axis = MockAxis::new();
525        pc.start(10.0, 100.0);
526        pc.tick(&mut axis, 150.0, &zero_pid_config(), 0.01);
527        assert!(pc.is_error());
528        assert_eq!(axis.halt_calls, 1);
529        assert!(pc.error_message().contains("exceeded max_load"));
530    }
531
532    #[test]
533    fn loop_converges_toward_target() {
534        // Spring model: 100 N per mm starting at rest=0. Target 50 N → 0.5 mm.
535        let mut pc = PressureControl::new();
536        let mut axis = MockAxis::new().with_spring(100.0, 0.0);
537        let cfg = PressureControlConfig {
538            kp: 0.01,       // 0.01 mm per N of error
539            ki: 0.0,
540            kd: 0.0,
541            max_step: 0.5,
542            min_step: 0.0,
543            filter_alpha: 1.0,
544            tolerance: 1.0,
545            settling_time: 0.0,
546            ..Default::default()
547        };
548        pc.start(50.0, 500.0);
549        for _ in 0..200 {
550            let load = axis.simulated_load();
551            pc.tick(&mut axis, load, &cfg, 0.01);
552            axis.advance();
553        }
554        let final_load = axis.simulated_load();
555        assert!(
556            (final_load - 50.0).abs() < 1.0,
557            "expected load near 50 N, got {}", final_load,
558        );
559        assert!(pc.is_in_tolerance());
560    }
561
562    #[test]
563    fn invert_direction_flips_step_sign() {
564        // Axis where moving NEGATIVE increases compression.
565        // spring_k = -100 N/mm, rest_position = 0 → load = -100 * position.
566        // Target 50 N requires position = -0.5 mm. Without inversion the
567        // raw PID output is positive (target − current > 0 → +output); with
568        // inversion that becomes a negative step.
569        let mut pc = PressureControl::new();
570        let mut axis = MockAxis::new().with_spring(-100.0, 0.0);
571        let cfg = PressureControlConfig {
572            kp: 0.01,
573            invert_direction: true,
574            max_step: 0.5,
575            min_step: 0.0,
576            filter_alpha: 1.0,
577            tolerance: 1.0,
578            settling_time: 0.0,
579            ..Default::default()
580        };
581        pc.start(50.0, 500.0);
582        for _ in 0..200 {
583            let load = axis.simulated_load();
584            pc.tick(&mut axis, load, &cfg, 0.01);
585            axis.advance();
586        }
587        assert!(axis.position < 0.0, "expected negative position, got {}", axis.position);
588    }
589
590    #[test]
591    fn min_step_deadband_suppresses_move() {
592        let mut pc = PressureControl::new();
593        let mut axis = MockAxis::new();
594        let cfg = PressureControlConfig {
595            kp: 0.0001,       // tiny gain → tiny step
596            max_step: 0.5,
597            min_step: 0.01,   // step 0.0001 * 1 = 0.0001 → well below 0.01
598            filter_alpha: 1.0,
599            ..Default::default()
600        };
601        pc.start(1.0, 500.0);
602        pc.tick(&mut axis, 0.0, &cfg, 0.01);  // seeds
603        let first_move_calls = axis.move_calls;
604        // Steady state: step is tiny every tick → no moves.
605        for _ in 0..10 {
606            pc.tick(&mut axis, 0.0, &cfg, 0.01);
607        }
608        assert_eq!(axis.move_calls, first_move_calls,
609            "min_step should have suppressed all these moves");
610    }
611
612    #[test]
613    fn position_limit_clamps_commanded() {
614        let mut pc = PressureControl::new();
615        let mut axis = MockAxis::new();
616        let cfg = PressureControlConfig {
617            kp: 1.0,
618            max_step: 0.5,
619            min_step: 0.0,
620            filter_alpha: 1.0,
621            position_limit_pos: Some(1.0),  // Cap at 1 mm
622            tolerance: 1.0,
623            settling_time: 0.0,
624            ..Default::default()
625        };
626        pc.start(100.0, 500.0);  // huge error → will try to run away
627        for _ in 0..20 {
628            pc.tick(&mut axis, 0.0, &cfg, 0.01);
629        }
630        assert!(pc.commanded_position() <= 1.0 + 1e-6,
631            "commanded position should be clamped, got {}", pc.commanded_position());
632    }
633
634    #[test]
635    fn axis_error_transitions_to_error_state() {
636        let mut pc = PressureControl::new();
637        let mut axis = MockAxis::new();
638        pc.start(10.0, 500.0);
639        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
640        assert!(pc.is_busy());
641        axis.error = true;
642        pc.tick(&mut axis, 5.0, &zero_pid_config(), 0.01);
643        assert!(pc.is_error());
644    }
645
646    #[test]
647    fn set_target_changes_setpoint_without_reset() {
648        let mut pc = PressureControl::new();
649        let mut axis = MockAxis::new().with_spring(100.0, 0.0);
650        let cfg = PressureControlConfig {
651            kp: 0.01, max_step: 0.5, min_step: 0.0, filter_alpha: 1.0,
652            tolerance: 0.5, settling_time: 0.0, ..Default::default()
653        };
654        pc.start(50.0, 500.0);
655        for _ in 0..200 {
656            let load = axis.simulated_load();
657            pc.tick(&mut axis, load, &cfg, 0.01);
658            axis.advance();
659        }
660        assert!((axis.simulated_load() - 50.0).abs() < 1.0);
661        pc.set_target(30.0);
662        for _ in 0..200 {
663            let load = axis.simulated_load();
664            pc.tick(&mut axis, load, &cfg, 0.01);
665            axis.advance();
666        }
667        assert!((axis.simulated_load() - 30.0).abs() < 1.0);
668    }
669}