Skip to main content

pamoja_kit/
pid.rs

1//! Holding a value at a target with a PID controller.
2
3/// Drives a measured value to a target by blending proportional, integral, and derivative
4/// terms.
5///
6/// This is the workhorse continuous controller behind "keep it here": hold a heater at a
7/// temperature, a pump at a pressure, a motor at a speed. It sums three responses to the
8/// error (target minus measurement): the proportional term reacts to the error now, the
9/// integral term removes the steady offset the proportional term leaves behind, and the
10/// derivative term damps overshoot by reacting to how fast the error is changing. The gains
11/// `kp`, `ki`, and `kd` weight them. The output is clamped to a configurable range, and the
12/// integral is held back from winding up past that range while the output is saturated, the
13/// standard clamping anti-windup.
14///
15/// For the simplest on/off case (a fridge, a tank pump) reach for
16/// [`Thermostat`](crate::Thermostat) instead; a PID is for a smooth, proportional actuator.
17///
18/// # Examples
19///
20/// ```
21/// use pamoja_kit::Pid;
22///
23/// // Proportional-only: the command is the gain times the error.
24/// let mut pid = Pid::new(2.0, 0.0, 0.0);
25/// assert_eq!(pid.update(10.0, 7.0, 1.0), 6.0); // error 3 times kp 2
26/// ```
27#[derive(Clone, Copy, Debug)]
28pub struct Pid {
29    kp: f32,
30    ki: f32,
31    kd: f32,
32    integral: f32,
33    last_error: Option<f32>,
34    min: f32,
35    max: f32,
36}
37
38impl Pid {
39    /// Creates a PID controller with the given gains and no output limit.
40    ///
41    /// # Arguments
42    ///
43    /// * `kp` - proportional gain.
44    /// * `ki` - integral gain.
45    /// * `kd` - derivative gain.
46    ///
47    /// # Returns
48    ///
49    /// A controller with a cleared history and unbounded output.
50    pub fn new(kp: f32, ki: f32, kd: f32) -> Self {
51        Self {
52            kp,
53            ki,
54            kd,
55            integral: 0.0,
56            last_error: None,
57            min: f32::NEG_INFINITY,
58            max: f32::INFINITY,
59        }
60    }
61
62    /// Limits the output to `[min, max]`, also bounding the integral so it cannot wind up
63    /// beyond the range while the output is saturated.
64    ///
65    /// # Arguments
66    ///
67    /// * `min` - the lowest output.
68    /// * `max` - the highest output. If `max` is below `min` the two are swapped.
69    ///
70    /// # Returns
71    ///
72    /// The controller, for chaining after [`new`](Pid::new).
73    pub fn with_limits(mut self, min: f32, max: f32) -> Self {
74        if min <= max {
75            self.min = min;
76            self.max = max;
77        } else {
78            self.min = max;
79            self.max = min;
80        }
81        self
82    }
83
84    /// Computes the control output for one time step.
85    ///
86    /// # Arguments
87    ///
88    /// * `setpoint` - the target value.
89    /// * `measurement` - the latest measured value.
90    /// * `dt` - the time since the previous update, in the unit `ki` and `kd` assume. A
91    ///   value at or below zero skips the integral and derivative updates.
92    ///
93    /// # Returns
94    ///
95    /// The control output, clamped to the configured limits.
96    pub fn update(&mut self, setpoint: f32, measurement: f32, dt: f32) -> f32 {
97        let error = setpoint - measurement;
98        let derivative = match self.last_error {
99            Some(previous) if dt > 0.0 => (error - previous) / dt,
100            _ => 0.0,
101        };
102        self.last_error = Some(error);
103
104        if dt > 0.0 {
105            self.integral += error * dt;
106            // Anti-windup: hold the integral term inside the output range.
107            if self.ki != 0.0 {
108                let term = self.ki * self.integral;
109                let bounded = clamp(term, self.min, self.max);
110                self.integral = bounded / self.ki;
111            }
112        }
113
114        let output = self.kp * error + self.ki * self.integral + self.kd * derivative;
115        clamp(output, self.min, self.max)
116    }
117
118    /// Clears the integral and derivative history.
119    pub fn reset(&mut self) {
120        self.integral = 0.0;
121        self.last_error = None;
122    }
123}
124
125// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
126#[allow(clippy::manual_clamp)]
127fn clamp(value: f32, min: f32, max: f32) -> f32 {
128    if value < min {
129        min
130    } else if value > max {
131        max
132    } else {
133        value
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn proportional_only_scales_the_error() {
143        let mut pid = Pid::new(2.0, 0.0, 0.0);
144        assert_eq!(pid.update(10.0, 7.0, 1.0), 6.0);
145    }
146
147    #[test]
148    fn the_integral_term_accumulates() {
149        let mut pid = Pid::new(0.0, 0.5, 0.0);
150        assert_eq!(pid.update(2.0, 0.0, 1.0), 1.0); // integral 2 times ki 0.5
151        assert_eq!(pid.update(2.0, 0.0, 1.0), 2.0); // integral 4 times 0.5
152    }
153
154    #[test]
155    fn the_derivative_term_responds_to_change() {
156        let mut pid = Pid::new(0.0, 0.0, 1.0);
157        assert_eq!(pid.update(0.0, 0.0, 1.0), 0.0); // first step: no history
158        assert_eq!(pid.update(0.0, 5.0, 1.0), -5.0); // error fell by 5 over dt 1
159    }
160
161    #[test]
162    fn integral_does_not_wind_up_while_saturated() {
163        let mut pid = Pid::new(0.0, 1.0, 0.0).with_limits(-5.0, 5.0);
164        for _ in 0..10 {
165            pid.update(100.0, 0.0, 1.0);
166        }
167        // Pinned at the limit, but the integral has not wound up past it.
168        assert_eq!(pid.update(100.0, 0.0, 1.0), 5.0);
169        // So reversing the error leaves saturation immediately, with no wound-up
170        // integral holding the output high.
171        assert_eq!(pid.update(-100.0, 0.0, 1.0), -5.0);
172    }
173
174    #[test]
175    fn reset_clears_the_history() {
176        let mut pid = Pid::new(1.0, 1.0, 0.0);
177        pid.update(10.0, 0.0, 1.0);
178        pid.reset();
179        assert_eq!(pid.update(10.0, 0.0, 0.0), 10.0); // dt 0: proportional only
180    }
181}