Skip to main content

mecha10_behavior_runtime/control/
pid.rs

1//! Generic PID (proportional-integral-derivative) controller.
2//!
3//! Domain-agnostic: usable for any single-axis stabilization loop (altitude, attitude, velocity).
4
5use serde::{Deserialize, Serialize};
6
7/// Tunable gains for a [`PidController`]. Mirrors the field names/defaults of
8/// `mecha10_controllers::motor::PidGains` for consistency, but is defined locally so this crate
9/// doesn't take on a dependency on the driver-layer `controllers` crate just for three floats.
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
11pub struct PidGains {
12    /// Proportional gain - reacts to the instantaneous error.
13    pub kp: f64,
14    /// Integral gain - reacts to the accumulated error over time (eliminates steady-state error).
15    pub ki: f64,
16    /// Derivative gain - reacts to the rate of change of the error (damps oscillation).
17    pub kd: f64,
18}
19
20impl Default for PidGains {
21    fn default() -> Self {
22        Self {
23            kp: 1.0,
24            ki: 0.0,
25            kd: 0.0,
26        }
27    }
28}
29
30/// A standard PID controller with output clamping and integral anti-windup.
31///
32/// Call [`PidController::update`] once per control tick with setpoint, measurement, and `dt`.
33#[derive(Debug, Clone)]
34pub struct PidController {
35    gains: PidGains,
36    /// Accumulated error, clamped to `integral_limits` each update (anti-windup).
37    integral: f64,
38    /// Error from the previous `update` call, used for the derivative term. `None` until the
39    /// first call, so the derivative term is skipped (treated as 0) on that first call instead
40    /// of producing a spurious spike from an assumed-zero previous error.
41    previous_error: Option<f64>,
42    /// Clamp applied to the final output, as `(min, max)`.
43    output_limits: (f64, f64),
44    /// Clamp applied to the accumulated integral term, as `(min, max)` - bounds how much
45    /// "wind-up" can accumulate while the output is saturated at `output_limits`.
46    integral_limits: (f64, f64),
47}
48
49impl PidController {
50    /// Create a new controller with the given gains and output clamp. Integral wind-up is
51    /// clamped to the same range as the output by default - see [`Self::with_integral_limits`]
52    /// to widen/narrow that independently.
53    pub fn new(gains: PidGains, output_limits: (f64, f64)) -> Self {
54        Self {
55            gains,
56            integral: 0.0,
57            previous_error: None,
58            output_limits,
59            integral_limits: output_limits,
60        }
61    }
62
63    /// Override the integral anti-windup clamp independently of the output clamp.
64    pub fn with_integral_limits(mut self, integral_limits: (f64, f64)) -> Self {
65        self.integral_limits = integral_limits;
66        self
67    }
68
69    /// Advance the controller by one tick and return the (clamped) control output.
70    ///
71    /// `dt` is the elapsed time in seconds since the previous call; a non-positive `dt` skips
72    pub fn update(&mut self, setpoint: f64, measurement: f64, dt: f64) -> f64 {
73        let error = setpoint - measurement;
74
75        let derivative = if dt > 0.0 {
76            self.integral = (self.integral + error * dt).clamp(self.integral_limits.0, self.integral_limits.1);
77            let derivative = self
78                .previous_error
79                .map(|previous| (error - previous) / dt)
80                .unwrap_or(0.0);
81            self.previous_error = Some(error);
82            derivative
83        } else {
84            0.0
85        };
86
87        let output = self.gains.kp * error + self.gains.ki * self.integral + self.gains.kd * derivative;
88        output.clamp(self.output_limits.0, self.output_limits.1)
89    }
90
91    /// Clear accumulated integral/derivative state (e.g. when a behaviour restarts), so a
92    /// stale integral from a previous run doesn't bias the next one.
93    pub fn reset(&mut self) {
94        self.integral = 0.0;
95        self.previous_error = None;
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn proportional_only_tracks_error() {
105        let gains = PidGains {
106            kp: 2.0,
107            ki: 0.0,
108            kd: 0.0,
109        };
110        let mut pid = PidController::new(gains, (-100.0, 100.0));
111
112        let output = pid.update(10.0, 4.0, 0.1);
113        assert_eq!(output, 12.0);
114    }
115
116    #[test]
117    fn integral_accumulates_error_over_time() {
118        let gains = PidGains {
119            kp: 0.0,
120            ki: 1.0,
121            kd: 0.0,
122        };
123        let mut pid = PidController::new(gains, (-100.0, 100.0));
124
125        let first = pid.update(1.0, 0.0, 1.0);
126        let second = pid.update(1.0, 0.0, 1.0);
127
128        assert_eq!(first, 1.0);
129        assert_eq!(second, 2.0);
130    }
131
132    #[test]
133    fn derivative_reacts_to_rate_of_change_and_is_zero_on_first_call() {
134        let gains = PidGains {
135            kp: 0.0,
136            ki: 0.0,
137            kd: 1.0,
138        };
139        let mut pid = PidController::new(gains, (-100.0, 100.0));
140
141        let first = pid.update(10.0, 0.0, 1.0);
142        assert_eq!(first, 0.0);
143
144        let second = pid.update(10.0, 5.0, 1.0);
145        assert_eq!(second, -5.0);
146    }
147
148    #[test]
149    fn output_is_clamped_to_configured_limits() {
150        let gains = PidGains {
151            kp: 100.0,
152            ki: 0.0,
153            kd: 0.0,
154        };
155        let mut pid = PidController::new(gains, (-1.0, 1.0));
156
157        assert_eq!(pid.update(10.0, 0.0, 1.0), 1.0);
158        assert_eq!(pid.update(-10.0, 0.0, 1.0), -1.0);
159    }
160
161    #[test]
162    fn integral_is_clamped_to_prevent_windup() {
163        let gains = PidGains {
164            kp: 0.0,
165            ki: 1.0,
166            kd: 0.0,
167        };
168        let mut pid = PidController::new(gains, (-100.0, 100.0)).with_integral_limits((-2.0, 2.0));
169
170        for _ in 0..5 {
171            pid.update(5.0, 0.0, 1.0);
172        }
173
174        assert_eq!(pid.update(5.0, 0.0, 1.0), 2.0);
175    }
176
177    #[test]
178    fn non_positive_dt_skips_integral_and_derivative_terms() {
179        let gains = PidGains {
180            kp: 1.0,
181            ki: 1.0,
182            kd: 1.0,
183        };
184        let mut pid = PidController::new(gains, (-100.0, 100.0));
185
186        pid.update(10.0, 0.0, 1.0);
187        let output = pid.update(10.0, 0.0, 0.0);
188
189        assert_eq!(output, 20.0);
190    }
191
192    #[test]
193    fn reset_clears_integral_and_derivative_state() {
194        let gains = PidGains {
195            kp: 0.0,
196            ki: 1.0,
197            kd: 1.0,
198        };
199        let mut pid = PidController::new(gains, (-100.0, 100.0));
200
201        pid.update(5.0, 0.0, 1.0);
202        pid.reset();
203
204        let output = pid.update(5.0, 0.0, 1.0);
205        assert_eq!(output, 5.0);
206    }
207
208    #[test]
209    fn converges_to_setpoint_over_repeated_ticks() {
210        let gains = PidGains {
211            kp: 0.6,
212            ki: 0.3,
213            kd: 0.05,
214        };
215        let mut pid = PidController::new(gains, (-10.0, 10.0));
216
217        let mut measurement = 0.0_f64;
218        let setpoint = 5.0;
219        let dt = 0.1;
220
221        for _ in 0..200 {
222            let output = pid.update(setpoint, measurement, dt);
223            measurement += output * dt;
224        }
225
226        assert!(
227            (measurement - setpoint).abs() < 0.1,
228            "expected convergence near {setpoint}, got {measurement}"
229        );
230    }
231}