autocore_std/motion/pressure_control.rs
1use crate::fb::StateMachine;
2use super::axis_view::AxisHandle;
3
4/// Configuration for the Pressure Control function block.
5#[derive(Debug, Clone)]
6pub struct PressureControlConfig {
7 /// Proportional gain (Kp).
8 pub kp: f64,
9 /// Integral gain (Ki).
10 pub ki: f64,
11 /// Derivative gain (Kd).
12 pub kd: f64,
13 /// Feed forward value added directly to the output.
14 pub feed_forward: f64,
15 /// Maximum allowed position delta (in user units) per call/tick.
16 /// Critical for safety to prevent crushing the load cell.
17 /// E.g., 0.001 inches per ms tick.
18 pub max_step: f64,
19 /// Maximum accumulated integral windup.
20 pub max_integral: f64,
21 /// Exponential Moving Average filter coefficient for the load cell (0.0 to 1.0).
22 /// 1.0 = No filtering (raw data).
23 /// 0.1 = Heavy filtering.
24 pub filter_alpha: f64,
25 /// If true, a positive error (need more pressure) results in a *negative* move.
26 /// Set to true if moving the axis down (negative) increases compression.
27 pub invert_direction: bool,
28 /// The acceptable load error window to be considered "in tolerance" (e.g., +/- 2.0 lbs).
29 pub tolerance: f64,
30 /// How long the load must remain within `tolerance` before reporting `in_tolerance = true`.
31 pub settling_time: f64,
32}
33
34impl Default for PressureControlConfig {
35 fn default() -> Self {
36 Self {
37 kp: 0.0,
38 ki: 0.0,
39 kd: 0.0,
40 feed_forward: 0.0,
41 max_step: 0.005, // Conservative 5 thousandths max step
42 max_integral: 100.0,
43 filter_alpha: 0.5,
44 invert_direction: false,
45 tolerance: 1.0,
46 settling_time: 0.1,
47 }
48 }
49}
50
51/// A closed-loop PID pressure/force controller for Profile Position (PP) axes.
52///
53/// This function block uses an Exponential Moving Average (EMA) filter to smooth
54/// incoming load cell data. It calculates a PID output which is clamped to a safe
55/// maximum step size and issued as a small, incremental `move_absolute` target
56/// to the drive every cycle.
57#[derive(Debug, Clone)]
58pub struct PressureControl {
59 /// True when the block is actively executing and controlling the axis.
60 pub active: bool,
61 /// True when the current load has been within `config.tolerance` of the target
62 /// for at least `config.settling_time` seconds.
63 pub in_tolerance: bool,
64 /// True if a fault occurred (e.g., axis error). Check `state.error_code`.
65 pub error: bool,
66 /// Internal state machine for operation sequencing and error reporting.
67 pub state: StateMachine,
68
69 // Internal PID state
70 integral: f64,
71 prev_error: f64,
72 filtered_load: f64,
73
74 // Internal Setpoint state (to prevent read-modify-write jitter on actual position)
75 commanded_position: f64,
76
77 // Tolerance timer
78 settling_timer: f64,
79
80 // First execution flag
81 is_first_run: bool,
82}
83
84impl Default for PressureControl {
85 fn default() -> Self {
86 Self {
87 active: false,
88 in_tolerance: false,
89 error: false,
90 state: StateMachine::new(),
91 integral: 0.0,
92 prev_error: 0.0,
93 filtered_load: 0.0,
94 commanded_position: 0.0,
95 settling_timer: 0.0,
96 is_first_run: true,
97 }
98 }
99}
100
101impl PressureControl {
102 /// Creates a new, uninitialized PressureControl block.
103 pub fn new() -> Self {
104 Self::default()
105 }
106
107 /// Execute the pressure control loop.
108 ///
109 /// - `axis`: The handle to the PP axis.
110 /// - `execute`: Set to true to engage the controller. On a falling edge, the axis halts.
111 /// - `target_load`: The desired pressure/force (e.g., lbs or Newtons).
112 /// - `current_load`: The raw, instantaneous reading from the load cell.
113 /// - `config`: Tuning and safety parameters.
114 /// - `dt`: The cycle time delta in seconds (e.g., 0.001 for 1ms).
115 pub fn call(
116 &mut self,
117 axis: &mut impl AxisHandle,
118 execute: bool,
119 target_load: f64,
120 current_load: f64,
121 config: &PressureControlConfig,
122 dt: f64,
123 ) {
124 // Handle abort/halt on falling edge of execute
125 if !execute {
126 if self.active {
127 axis.halt();
128 self.active = false;
129 self.in_tolerance = false;
130 self.state.index = 0; // Idle
131 }
132 self.is_first_run = true;
133 self.integral = 0.0;
134 self.prev_error = 0.0;
135 self.settling_timer = 0.0;
136 self.error = false;
137 return;
138 }
139
140 // Safety: Check for axis faults
141 if axis.is_error() {
142 self.error = true;
143 self.active = false;
144 self.in_tolerance = false;
145 self.state.set_error(100, "Axis is in error state");
146 return;
147 }
148
149 // 1. Initialize on rising edge
150 if self.is_first_run {
151 self.filtered_load = current_load; // Seed filter
152 self.commanded_position = axis.position(); // Seed trajectory from current reality
153 self.is_first_run = false;
154 self.active = true;
155 self.error = false;
156 self.state.index = 10; // Controlling
157 }
158
159 // 2. Exponential Moving Average (EMA) Filter
160 let alpha = config.filter_alpha.clamp(0.0, 1.0);
161 self.filtered_load = (alpha * current_load) + ((1.0 - alpha) * self.filtered_load);
162
163 // 3. PID Calculation
164 let error = target_load - self.filtered_load;
165
166 // Anti-windup Integral
167 self.integral += error * dt;
168 self.integral = self.integral.clamp(-config.max_integral, config.max_integral);
169
170 // Derivative (change in error over time)
171 let derivative = if dt > 0.0 { (error - self.prev_error) / dt } else { 0.0 };
172 self.prev_error = error;
173
174 // Calculate raw output
175 let mut output = (config.kp * error) + (config.ki * self.integral) + (config.kd * derivative) + config.feed_forward;
176
177 // 4. Direction & Clamping
178 if config.invert_direction {
179 output = -output;
180 }
181
182 // Clamp output to the maximum safe step size
183 let step = output.clamp(-config.max_step, config.max_step);
184
185 // 5. Issue PP Move
186 // We accumulate the internal commanded_position rather than reading axis.position()
187 // every tick. Reading actual position every tick to calculate the next target
188 // creates a positive feedback loop of lag that destabilizes the PID.
189 self.commanded_position += step;
190
191 // We use move_absolute to our internal tracking position rather than move_relative
192 // to prevent compounding loss of precision over thousands of micro-moves.
193 let vel = axis.config().jog_speed;
194 let acc = axis.config().jog_accel;
195 let dec = axis.config().jog_decel;
196
197 // Only command motion if we actually need to move a non-zero distance.
198 if step.abs() > f64::EPSILON {
199 // Note: If the axis options include dynamic max/min position limits,
200 // the DriveHandle's internal move_absolute implementation will automatically
201 // reject this move if it violates the safety envelope.
202 axis.move_absolute(self.commanded_position, vel, acc, dec);
203 }
204
205 // 6. Tolerance Window Evaluation
206 if error.abs() <= config.tolerance {
207 self.settling_timer += dt;
208 if self.settling_timer >= config.settling_time {
209 self.in_tolerance = true;
210 }
211 } else {
212 self.settling_timer = 0.0;
213 self.in_tolerance = false;
214 }
215 }
216}