hopr_transport_session/balancer/
pid.rs1use std::str::FromStr;
2
3use anyhow::anyhow;
4use pid::Pid;
5
6use crate::{
7 balancer::{BalancerControllerBounds, SurbBalancerController},
8 errors,
9 errors::SessionManagerError,
10};
11
12#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct PidControllerGains(f64, f64, f64);
15
16impl PidControllerGains {
17 pub fn new(p: f64, i: f64, d: f64) -> errors::Result<Self> {
19 if p.is_finite() && i.is_finite() && d.is_finite() {
20 Ok(Self(p, i, d))
21 } else {
22 Err(SessionManagerError::other(anyhow!("gains must be finite")).into())
23 }
24 }
25
26 pub fn from_env_or_default() -> Self {
28 let default = Self::default();
29 Self(
30 std::env::var("HOPR_BALANCER_PID_P_GAIN")
31 .ok()
32 .and_then(|v| f64::from_str(&v).ok())
33 .unwrap_or(default.0),
34 std::env::var("HOPR_BALANCER_PID_I_GAIN")
35 .ok()
36 .and_then(|v| f64::from_str(&v).ok())
37 .unwrap_or(default.1),
38 std::env::var("HOPR_BALANCER_PID_D_GAIN")
39 .ok()
40 .and_then(|v| f64::from_str(&v).ok())
41 .unwrap_or(default.2),
42 )
43 }
44
45 #[inline]
47 pub fn p(&self) -> f64 {
48 self.0
49 }
50
51 #[inline]
53 pub fn i(&self) -> f64 {
54 self.1
55 }
56
57 #[inline]
59 pub fn d(&self) -> f64 {
60 self.2
61 }
62}
63
64impl Eq for PidControllerGains {}
66
67const DEFAULT_P_GAIN: f64 = 0.6;
70const DEFAULT_I_GAIN: f64 = 0.7;
71const DEFAULT_D_GAIN: f64 = 0.2;
72
73impl Default for PidControllerGains {
74 fn default() -> Self {
75 Self(DEFAULT_P_GAIN, DEFAULT_I_GAIN, DEFAULT_D_GAIN)
76 }
77}
78
79impl TryFrom<(f64, f64, f64)> for PidControllerGains {
80 type Error = errors::TransportSessionError;
81
82 fn try_from(value: (f64, f64, f64)) -> Result<Self, Self::Error> {
83 Self::new(value.0, value.1, value.2)
84 }
85}
86
87#[derive(Clone, Copy, Debug)]
89pub struct PidBalancerController(Pid<f64>);
90
91impl PidBalancerController {
92 pub fn new(setpoint: u64, output_limit: u64, gains: PidControllerGains) -> Self {
94 let mut pid = Pid::new(setpoint as f64, output_limit as f64);
95 pid.p(gains.p(), output_limit as f64);
96 pid.i(gains.i(), output_limit as f64);
97 pid.d(gains.d(), output_limit as f64);
98 Self(pid)
99 }
100
101 pub fn from_gains(gains: PidControllerGains) -> Self {
106 Self::new(0, 0, gains)
107 }
108}
109
110impl Default for PidBalancerController {
111 fn default() -> Self {
113 Self::new(0, 0, PidControllerGains::default())
114 }
115}
116
117impl SurbBalancerController for PidBalancerController {
118 fn bounds(&self) -> BalancerControllerBounds {
119 BalancerControllerBounds::new(self.0.setpoint as u64, self.0.output_limit as u64)
120 }
121
122 fn set_target_and_limit(&mut self, bounds: BalancerControllerBounds) {
123 let mut pid = Pid::new(bounds.target() as f64, bounds.output_limit() as f64);
124 pid.p(self.0.kp, bounds.output_limit() as f64);
125 pid.i(self.0.ki, bounds.output_limit() as f64);
126 pid.d(self.0.kd, bounds.output_limit() as f64);
127 self.0 = pid;
128 }
129
130 fn next_control_output(&mut self, current_buffer_level: u64) -> u64 {
131 self.0.next_control_output(current_buffer_level as f64).output.max(0.0) as u64
132 }
133
134 fn reset(&mut self) {
135 self.0.reset_integral_term();
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
148 fn pid_should_command_production_when_the_buffer_is_empty() {
149 let mut c = PidBalancerController::from_gains(PidControllerGains::default());
150 c.set_target_and_limit(BalancerControllerBounds::new(7_000, 5_000));
151
152 let out = c.next_control_output(0);
153 assert!(out > 0, "empty buffer against a 7000 target must produce, got {out}");
154 }
155
156 #[test]
160 fn pid_with_a_zero_output_limit_commands_nothing_however_starved() {
161 let mut c = PidBalancerController::from_gains(PidControllerGains::default());
162 c.set_target_and_limit(BalancerControllerBounds::new(7_000, 0));
163
164 assert_eq!(0, c.next_control_output(0), "a zero limit clamps every term to zero");
165 }
166
167 #[test]
171 fn pid_should_produce_at_the_operating_point_seen_in_production() {
172 let mut c = PidBalancerController::from_gains(PidControllerGains::default());
173 c.set_target_and_limit(BalancerControllerBounds::new(
174 9_803,
175 crate::SurbBalancerConfig::default().max_surbs_per_sec,
176 ));
177
178 let out = c.next_control_output(0);
179 assert!(out > 0, "target 9803 with an empty buffer produced {out}");
180 }
181
182 #[test]
185 fn pid_first_output_after_reconfiguration_should_not_be_zero() {
186 let mut c = PidBalancerController::default();
187 c.set_target_and_limit(BalancerControllerBounds::new(7_000, 5_000));
188
189 assert!(
190 c.next_control_output(0) > 0,
191 "first output after reconfiguration was zero"
192 );
193 }
194
195 #[test]
198 fn pid_should_produce_at_the_live_bounds() {
199 let mut c = PidBalancerController::from_gains(PidControllerGains::default());
200 c.set_target_and_limit(BalancerControllerBounds::new(9_803, 1_960));
201
202 let out = c.next_control_output(0);
203 assert!(out > 0, "target 9803 / limit 1960 with an empty buffer produced {out}");
204 }
205
206 #[test]
210 fn pid_should_recover_after_a_healthy_buffer_drains() {
211 let mut c = PidBalancerController::from_gains(PidControllerGains::default());
212 c.set_target_and_limit(BalancerControllerBounds::new(9_803, 1_960));
213
214 for _ in 0..100 {
216 c.next_control_output(9_803);
217 }
218
219 let out = c.next_control_output(0);
221 assert!(out > 0, "an emptied buffer after a healthy period produced {out}");
222 }
223
224 #[test]
227 fn gains_default_values_are_stable() {
228 let gains = PidControllerGains::default();
229 insta::assert_yaml_snapshot!((gains.p(), gains.i(), gains.d()));
230 }
231
232 #[test]
233 fn gains_finite_values_are_accepted() -> anyhow::Result<()> {
234 let gains = PidControllerGains::new(1.0, 2.0, 3.0)?;
235 insta::assert_yaml_snapshot!((gains.p(), gains.i(), gains.d()));
236 Ok(())
237 }
238
239 #[test]
240 fn gains_infinity_is_rejected() {
241 assert!(PidControllerGains::new(f64::INFINITY, 0.0, 0.0).is_err());
242 assert!(PidControllerGains::new(0.0, f64::NEG_INFINITY, 0.0).is_err());
243 assert!(PidControllerGains::new(0.0, 0.0, f64::NAN).is_err());
244 }
245
246 #[test]
247 fn gains_try_from_tuple() -> anyhow::Result<()> {
248 let gains = PidControllerGains::try_from((0.5, 0.3, 0.1))?;
249 insta::assert_yaml_snapshot!((gains.p(), gains.i(), gains.d()));
250 Ok(())
251 }
252
253 #[test]
254 fn gains_try_from_tuple_with_nan_fails() {
255 assert!(PidControllerGains::try_from((f64::NAN, 0.0, 0.0)).is_err());
256 }
257
258 #[test]
259 fn gains_eq_works() -> anyhow::Result<()> {
260 let a = PidControllerGains::new(1.0, 2.0, 3.0)?;
261 let b = PidControllerGains::new(1.0, 2.0, 3.0)?;
262 assert_eq!(a, b);
263 Ok(())
264 }
265
266 #[test]
269 fn controller_default_has_zero_bounds() {
270 let ctrl = PidBalancerController::default();
271 assert_eq!(ctrl.bounds().unzip(), (0, 0));
272 }
273
274 #[test]
275 fn controller_new_stores_bounds() {
276 let gains = PidControllerGains::default();
277 let ctrl = PidBalancerController::new(100, 50, gains);
278 assert_eq!(ctrl.bounds().unzip(), (100, 50));
279 }
280
281 #[test]
282 fn controller_set_target_and_limit_updates_bounds() {
283 let mut ctrl = PidBalancerController::default();
284 ctrl.set_target_and_limit(BalancerControllerBounds::new(200, 100));
285 assert_eq!(ctrl.bounds().unzip(), (200, 100));
286 }
287
288 #[test]
289 fn controller_step_response_snapshot() {
290 let gains = PidControllerGains::default();
292 let mut ctrl = PidBalancerController::new(100, 200, gains);
293
294 let outputs: Vec<u64> = (0..10).map(|_| ctrl.next_control_output(0)).collect();
295 insta::assert_yaml_snapshot!(outputs);
296 }
297
298 #[test]
299 fn controller_at_setpoint_outputs_zero_or_near_zero() {
300 let gains = PidControllerGains::default();
301 let mut ctrl = PidBalancerController::new(100, 200, gains);
302
303 let output = ctrl.next_control_output(100);
305 assert_eq!(output, 0);
307 }
308
309 #[test]
310 fn controller_above_setpoint_clamps_to_zero() {
311 let gains = PidControllerGains::default();
312 let mut ctrl = PidBalancerController::new(100, 200, gains);
313
314 let output = ctrl.next_control_output(200);
316 assert_eq!(output, 0);
317 }
318
319 #[test]
320 fn controller_convergence_from_empty_buffer() {
321 let gains = PidControllerGains::default();
323 let mut ctrl = PidBalancerController::new(100, 200, gains);
324
325 let mut buffer: f64 = 0.0;
326 let mut history = Vec::new();
327
328 for _ in 0..20 {
329 let output = ctrl.next_control_output(buffer as u64);
330 buffer += output as f64;
331 buffer = buffer.min(200.0); history.push(buffer as u64);
333 }
334
335 insta::assert_yaml_snapshot!(history);
336 }
337
338 #[test]
339 fn controller_from_gains_uses_defaults_for_bounds() {
340 let gains = PidControllerGains::default();
341 let ctrl = PidBalancerController::from_gains(gains);
342 assert_eq!(ctrl.bounds().unzip(), (0, 0));
343 }
344}