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}