Skip to main content

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}