mecha10_behavior_runtime/control/pid.rs
1//! Generic PID (proportional-integral-derivative) controller.
2//!
3//! Used by flight-stabilization action nodes (`actions::flight::TakeoffNode`/`LandNode`) to turn
4//! a setpoint/measurement error (e.g. target vs. current altitude) into a bounded control output
5//! (e.g. per-rotor thrust), but it's deliberately domain-agnostic - nothing here references
6//! altitude, thrust, or any mecha10-core message type - so it's equally usable for any other
7//! single-axis stabilization loop (attitude, velocity, ...) a future behaviour node needs.
8//!
9//! There was no existing numeric PID *loop* in the repo to reuse: `mecha10_controllers::motor::
10//! PidGains` and `mecha10_drivers_motor::common::PIDGains` are just `{kp, ki, kd}` config structs
11//! handed to hardware/firmware that runs its own closed loop - neither crate implements the
12//! actual error-integration math, and `behavior-runtime` (where this is used) doesn't depend on
13//! either of those driver-layer crates. `PidGains` is kept as the field-naming precedent.
14
15use serde::{Deserialize, Serialize};
16
17/// Tunable gains for a [`PidController`]. Mirrors the field names/defaults of
18/// `mecha10_controllers::motor::PidGains` for consistency, but is defined locally so this crate
19/// doesn't take on a dependency on the driver-layer `controllers` crate just for three floats.
20#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
21pub struct PidGains {
22 /// Proportional gain - reacts to the instantaneous error.
23 pub kp: f64,
24 /// Integral gain - reacts to the accumulated error over time (eliminates steady-state error).
25 pub ki: f64,
26 /// Derivative gain - reacts to the rate of change of the error (damps oscillation).
27 pub kd: f64,
28}
29
30impl Default for PidGains {
31 fn default() -> Self {
32 Self {
33 kp: 1.0,
34 ki: 0.0,
35 kd: 0.0,
36 }
37 }
38}
39
40/// A standard PID controller with output clamping and integral anti-windup.
41///
42/// Call [`PidController::update`] once per control tick with the current setpoint, measurement,
43/// and elapsed time (`dt`, in seconds) since the previous call; it returns the control output.
44#[derive(Debug, Clone)]
45pub struct PidController {
46 gains: PidGains,
47 /// Accumulated error, clamped to `integral_limits` each update (anti-windup).
48 integral: f64,
49 /// Error from the previous `update` call, used for the derivative term. `None` until the
50 /// first call, so the derivative term is skipped (treated as 0) on that first call instead
51 /// of producing a spurious spike from an assumed-zero previous error.
52 previous_error: Option<f64>,
53 /// Clamp applied to the final output, as `(min, max)`.
54 output_limits: (f64, f64),
55 /// Clamp applied to the accumulated integral term, as `(min, max)` - bounds how much
56 /// "wind-up" can accumulate while the output is saturated at `output_limits`.
57 integral_limits: (f64, f64),
58}
59
60impl PidController {
61 /// Create a new controller with the given gains and output clamp. Integral wind-up is
62 /// clamped to the same range as the output by default - see [`Self::with_integral_limits`]
63 /// to widen/narrow that independently.
64 pub fn new(gains: PidGains, output_limits: (f64, f64)) -> Self {
65 Self {
66 gains,
67 integral: 0.0,
68 previous_error: None,
69 output_limits,
70 integral_limits: output_limits,
71 }
72 }
73
74 /// Override the integral anti-windup clamp independently of the output clamp.
75 pub fn with_integral_limits(mut self, integral_limits: (f64, f64)) -> Self {
76 self.integral_limits = integral_limits;
77 self
78 }
79
80 /// Advance the controller by one tick and return the (clamped) control output.
81 ///
82 /// `dt` is the elapsed time in seconds since the previous call. A non-positive `dt` skips
83 /// the integral/derivative terms for this tick (treated as a proportional-only response)
84 /// rather than dividing by zero or integrating backwards.
85 pub fn update(&mut self, setpoint: f64, measurement: f64, dt: f64) -> f64 {
86 let error = setpoint - measurement;
87
88 let derivative = if dt > 0.0 {
89 self.integral = (self.integral + error * dt).clamp(self.integral_limits.0, self.integral_limits.1);
90 let derivative = self
91 .previous_error
92 .map(|previous| (error - previous) / dt)
93 .unwrap_or(0.0);
94 self.previous_error = Some(error);
95 derivative
96 } else {
97 0.0
98 };
99
100 let output = self.gains.kp * error + self.gains.ki * self.integral + self.gains.kd * derivative;
101 output.clamp(self.output_limits.0, self.output_limits.1)
102 }
103
104 /// Clear accumulated integral/derivative state (e.g. when a behaviour restarts), so a
105 /// stale integral from a previous run doesn't bias the next one.
106 pub fn reset(&mut self) {
107 self.integral = 0.0;
108 self.previous_error = None;
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn proportional_only_tracks_error() {
118 let gains = PidGains {
119 kp: 2.0,
120 ki: 0.0,
121 kd: 0.0,
122 };
123 let mut pid = PidController::new(gains, (-100.0, 100.0));
124
125 let output = pid.update(10.0, 4.0, 0.1);
126 assert_eq!(output, 12.0); // 2.0 * (10.0 - 4.0)
127 }
128
129 #[test]
130 fn integral_accumulates_error_over_time() {
131 let gains = PidGains {
132 kp: 0.0,
133 ki: 1.0,
134 kd: 0.0,
135 };
136 let mut pid = PidController::new(gains, (-100.0, 100.0));
137
138 // error = 1.0 each tick, dt = 1.0s -> integral grows by 1.0 each call
139 let first = pid.update(1.0, 0.0, 1.0);
140 let second = pid.update(1.0, 0.0, 1.0);
141
142 assert_eq!(first, 1.0);
143 assert_eq!(second, 2.0);
144 }
145
146 #[test]
147 fn derivative_reacts_to_rate_of_change_and_is_zero_on_first_call() {
148 let gains = PidGains {
149 kp: 0.0,
150 ki: 0.0,
151 kd: 1.0,
152 };
153 let mut pid = PidController::new(gains, (-100.0, 100.0));
154
155 // First call: no previous error, derivative term must not spike.
156 let first = pid.update(10.0, 0.0, 1.0);
157 assert_eq!(first, 0.0);
158
159 // error went from 10.0 -> 5.0 over 1s: derivative = (5.0 - 10.0) / 1.0 = -5.0
160 let second = pid.update(10.0, 5.0, 1.0);
161 assert_eq!(second, -5.0);
162 }
163
164 #[test]
165 fn output_is_clamped_to_configured_limits() {
166 let gains = PidGains {
167 kp: 100.0,
168 ki: 0.0,
169 kd: 0.0,
170 };
171 let mut pid = PidController::new(gains, (-1.0, 1.0));
172
173 assert_eq!(pid.update(10.0, 0.0, 1.0), 1.0);
174 assert_eq!(pid.update(-10.0, 0.0, 1.0), -1.0);
175 }
176
177 #[test]
178 fn integral_is_clamped_to_prevent_windup() {
179 let gains = PidGains {
180 kp: 0.0,
181 ki: 1.0,
182 kd: 0.0,
183 };
184 let mut pid = PidController::new(gains, (-100.0, 100.0)).with_integral_limits((-2.0, 2.0));
185
186 // Each call accumulates error=5.0 * dt=1.0 = +5.0 to the integral, but it's clamped to 2.0.
187 for _ in 0..5 {
188 pid.update(5.0, 0.0, 1.0);
189 }
190
191 assert_eq!(pid.update(5.0, 0.0, 1.0), 2.0);
192 }
193
194 #[test]
195 fn non_positive_dt_skips_integral_and_derivative_terms() {
196 let gains = PidGains {
197 kp: 1.0,
198 ki: 1.0,
199 kd: 1.0,
200 };
201 let mut pid = PidController::new(gains, (-100.0, 100.0));
202
203 pid.update(10.0, 0.0, 1.0); // seeds integral=10.0, previous_error=10.0
204 let output = pid.update(10.0, 0.0, 0.0);
205
206 // The integral/derivative terms aren't updated this tick (dt <= 0), but the previously
207 // accumulated integral still contributes: kp * error + ki * integral + kd * 0
208 // = 1.0 * 10.0 + 1.0 * 10.0 + 0.0 = 20.0
209 assert_eq!(output, 20.0);
210 }
211
212 #[test]
213 fn reset_clears_integral_and_derivative_state() {
214 let gains = PidGains {
215 kp: 0.0,
216 ki: 1.0,
217 kd: 1.0,
218 };
219 let mut pid = PidController::new(gains, (-100.0, 100.0));
220
221 pid.update(5.0, 0.0, 1.0);
222 pid.reset();
223
224 // With state cleared, the derivative term should again be 0.0 on the next call (no
225 // previous error to diff against), matching the very first call's behavior.
226 let output = pid.update(5.0, 0.0, 1.0);
227 assert_eq!(output, 5.0); // ki * integral = 1.0 * 5.0, kd term is 0.0
228 }
229
230 #[test]
231 fn converges_to_setpoint_over_repeated_ticks() {
232 // A damped PI loop against a simple first-order plant (measurement moves toward the
233 // last output each tick) should converge close to the setpoint, not just produce a
234 // single sane-looking output.
235 let gains = PidGains {
236 kp: 0.6,
237 ki: 0.3,
238 kd: 0.05,
239 };
240 let mut pid = PidController::new(gains, (-10.0, 10.0));
241
242 let mut measurement = 0.0_f64;
243 let setpoint = 5.0;
244 let dt = 0.1;
245
246 for _ in 0..200 {
247 let output = pid.update(setpoint, measurement, dt);
248 measurement += output * dt;
249 }
250
251 assert!(
252 (measurement - setpoint).abs() < 0.1,
253 "expected convergence near {setpoint}, got {measurement}"
254 );
255 }
256}