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}