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    output: f32,
35    min: f32,
36    max: f32,
37}
38
39impl Pid {
40    /// Creates a PID controller with the given gains and no output limit.
41    ///
42    /// # Arguments
43    ///
44    /// * `kp` - proportional gain.
45    /// * `ki` - integral gain.
46    /// * `kd` - derivative gain.
47    ///
48    /// A gain that is not a finite number is taken as zero.
49    ///
50    /// # Returns
51    ///
52    /// A controller with a cleared history and unbounded output.
53    pub fn new(kp: f32, ki: f32, kd: f32) -> Self {
54        Self {
55            kp: gain(kp),
56            ki: gain(ki),
57            kd: gain(kd),
58            integral: 0.0,
59            last_error: None,
60            output: 0.0,
61            min: f32::NEG_INFINITY,
62            max: f32::INFINITY,
63        }
64    }
65
66    /// Limits the output to `[min, max]`, also bounding the integral so it cannot wind up
67    /// beyond the range while the output is saturated.
68    ///
69    /// # Arguments
70    ///
71    /// * `min` - the lowest output. One that is not a number leaves the low side open.
72    /// * `max` - the highest output. One that is not a number leaves the high side open. If
73    ///   `max` is below `min` the two are swapped.
74    ///
75    /// # Returns
76    ///
77    /// The controller, for chaining after [`new`](Pid::new).
78    pub fn with_limits(mut self, min: f32, max: f32) -> Self {
79        let min = if min.is_nan() { f32::NEG_INFINITY } else { min };
80        let max = if max.is_nan() { f32::INFINITY } else { max };
81        if min <= max {
82            self.min = min;
83            self.max = max;
84        } else {
85            self.min = max;
86            self.max = min;
87        }
88        self
89    }
90
91    /// Computes the control output for one time step.
92    ///
93    /// # Arguments
94    ///
95    /// * `setpoint` - the target value.
96    /// * `measurement` - the latest measured value.
97    /// * `dt` - the time since the previous update, in the unit `ki` and `kd` assume. A
98    ///   value at or below zero, or one that is not a finite number, skips the integral and
99    ///   derivative updates.
100    ///
101    /// A setpoint or measurement that is not a finite number, such as the NaN a failed
102    /// sensor reports, is ignored: the controller keeps its history and returns its previous
103    /// output, where one NaN would otherwise stay in the integral for good.
104    ///
105    /// # Returns
106    ///
107    /// The control output, clamped to the configured limits.
108    pub fn update(&mut self, setpoint: f32, measurement: f32, dt: f32) -> f32 {
109        if !setpoint.is_finite() || !measurement.is_finite() {
110            return self.output;
111        }
112        let stepped = dt > 0.0 && dt.is_finite();
113        let error = setpoint - measurement;
114        let derivative = match self.last_error {
115            Some(previous) if stepped => (error - previous) / dt,
116            _ => 0.0,
117        };
118        self.last_error = Some(error);
119
120        if stepped && self.ki != 0.0 {
121            self.integral += error * dt;
122            // Anti-windup: hold the integral term inside the output range.
123            let term = self.ki * self.integral;
124            let bounded = clamp(term, self.min, self.max);
125            self.integral = bounded / self.ki;
126        }
127
128        let output = self.kp * error + self.ki * self.integral + self.kd * derivative;
129        self.output = clamp(output, self.min, self.max);
130        self.output
131    }
132
133    /// Clears the integral and derivative history, and the output it last returned.
134    pub fn reset(&mut self) {
135        self.integral = 0.0;
136        self.last_error = None;
137        self.output = 0.0;
138    }
139}
140
141// A gain that is not a finite number contributes nothing rather than poisoning the output.
142fn gain(value: f32) -> f32 {
143    if value.is_finite() {
144        value
145    } else {
146        0.0
147    }
148}
149
150// `f32::clamp` lives in `std`, so this `no_std` crate clamps by hand.
151#[allow(clippy::manual_clamp)]
152fn clamp(value: f32, min: f32, max: f32) -> f32 {
153    if value < min {
154        min
155    } else if value > max {
156        max
157    } else {
158        value
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn proportional_only_scales_the_error() {
168        let mut pid = Pid::new(2.0, 0.0, 0.0);
169        assert_eq!(pid.update(10.0, 7.0, 1.0), 6.0);
170    }
171
172    #[test]
173    fn the_integral_term_accumulates() {
174        let mut pid = Pid::new(0.0, 0.5, 0.0);
175        assert_eq!(pid.update(2.0, 0.0, 1.0), 1.0); // integral 2 times ki 0.5
176        assert_eq!(pid.update(2.0, 0.0, 1.0), 2.0); // integral 4 times 0.5
177    }
178
179    #[test]
180    fn the_derivative_term_responds_to_change() {
181        let mut pid = Pid::new(0.0, 0.0, 1.0);
182        assert_eq!(pid.update(0.0, 0.0, 1.0), 0.0); // first step: no history
183        assert_eq!(pid.update(0.0, 5.0, 1.0), -5.0); // error fell by 5 over dt 1
184    }
185
186    #[test]
187    fn integral_does_not_wind_up_while_saturated() {
188        let mut pid = Pid::new(0.0, 1.0, 0.0).with_limits(-5.0, 5.0);
189        for _ in 0..10 {
190            pid.update(100.0, 0.0, 1.0);
191        }
192        // Pinned at the limit, but the integral has not wound up past it.
193        assert_eq!(pid.update(100.0, 0.0, 1.0), 5.0);
194        // So reversing the error leaves saturation immediately, with no wound-up
195        // integral holding the output high.
196        assert_eq!(pid.update(-100.0, 0.0, 1.0), -5.0);
197    }
198
199    #[test]
200    fn a_reading_that_is_not_a_number_does_not_poison_the_controller() {
201        let mut pid = Pid::new(2.0, 0.5, 0.1);
202        let before = pid.update(10.0, 7.0, 1.0);
203        assert_eq!(pid.update(10.0, f32::NAN, 1.0), before);
204        assert_eq!(pid.update(10.0, f32::INFINITY, 1.0), before);
205        assert_eq!(pid.update(f32::NAN, 7.0, 1.0), before);
206        assert!(pid.update(10.0, 7.0, 1.0).is_finite());
207        assert!(pid.update(10.0, 7.0, f32::NAN).is_finite());
208        assert!(pid.update(10.0, 7.0, f32::INFINITY).is_finite());
209        assert!(pid.update(10.0, 7.0, 1.0).is_finite());
210    }
211
212    #[test]
213    fn a_limit_that_is_not_a_number_leaves_that_side_open() {
214        let mut pid = Pid::new(1.0, 0.0, 0.0).with_limits(f32::NAN, 5.0);
215        assert_eq!(pid.update(100.0, 0.0, 1.0), 5.0);
216        assert_eq!(pid.update(-100.0, 0.0, 1.0), -100.0);
217    }
218
219    #[test]
220    fn a_gain_that_is_not_a_number_is_taken_as_zero() {
221        let mut pid = Pid::new(2.0, f32::NAN, f32::INFINITY);
222        assert_eq!(pid.update(10.0, 7.0, 1.0), 6.0);
223    }
224
225    #[test]
226    fn reset_clears_the_history() {
227        let mut pid = Pid::new(1.0, 1.0, 0.0);
228        pid.update(10.0, 0.0, 1.0);
229        pid.reset();
230        assert_eq!(pid.update(10.0, 0.0, 0.0), 10.0); // dt 0: proportional only
231    }
232}