khora_core/control/pid.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A discrete-time PID controller for closed-loop budget regulation.
16//!
17//! The DCC uses this to drive GORNA's global budget multiplier so that the
18//! *measured* frame time tracks a *setpoint* (the heuristic-suggested latency),
19//! replacing the old static thermal/battery lookup. The controller is a pure
20//! scalar algorithm — no engine state, no allocation — so it lives in
21//! `khora-core` and is unit-tested in isolation.
22//!
23//! It implements the practical refinements that matter for a noisy,
24//! saturating, discrete-actuator plant:
25//!
26//! - **Derivative on measurement** (not on error) — no derivative kick when the
27//! setpoint jumps (it jumps every time the thermal state or phase changes).
28//! - **Filtered derivative** — a first-order low-pass on the derivative term
29//! tames frame-time noise; the filter time constant is `(Kd/Kp)/N`.
30//! - **Back-calculation anti-windup** — the output saturates to
31//! `[output_min, output_max]`; the integrator is unwound by
32//! `Kb·(saturated − unsaturated)` so it never accumulates against a limit
33//! (the steady ceiling at `output_max` when running with headroom is the most
34//! common windup case here).
35//! - **Setpoint weighting** — the setpoint is weighted by `b` in the
36//! proportional term to curb overshoot without hurting disturbance rejection.
37//! - **Output clamping** — to a configurable `[output_min, output_max]`.
38
39/// Tuning and limits for a [`PidController`].
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct PidConfig {
42 /// Proportional gain.
43 pub kp: f32,
44 /// Integral gain (per second).
45 pub ki: f32,
46 /// Derivative gain (seconds).
47 pub kd: f32,
48 /// Setpoint weight `b` on the proportional term (`b·setpoint − measurement`).
49 /// `1.0` is classic PID; lower values (≈0.7) reduce overshoot on setpoint
50 /// changes while keeping full disturbance rejection.
51 pub setpoint_weight_b: f32,
52 /// Derivative filter divisor `N` (the filter time constant is `(Kd/Kp)/N`).
53 /// Typical range `2..=20`; higher `N` filters less. Must be `> 0`.
54 pub derivative_filter_n: f32,
55 /// Back-calculation anti-windup gain. `0.0` disables anti-windup. A common
56 /// starting point is `Kb ≈ Ki` (anti-windup time constant `1/Kb`).
57 pub kb: f32,
58 /// Lower saturation bound on the output.
59 pub output_min: f32,
60 /// Upper saturation bound on the output.
61 pub output_max: f32,
62}
63
64impl Default for PidConfig {
65 /// Defaults tuned for the DCC frame-time loop: output is the budget
66 /// multiplier in `[0.3, 1.0]`, the plant is the discrete strategy ladder
67 /// sampled at the cold-path rate (~20 Hz). Conservative gains favour
68 /// stability over speed on this coarse, non-linear actuator.
69 fn default() -> Self {
70 Self {
71 // Gains are expressed against an error in *milliseconds* of frame
72 // time mapped onto a unitless multiplier, so they are small.
73 kp: 0.02,
74 ki: 0.04,
75 kd: 0.002,
76 setpoint_weight_b: 0.7,
77 derivative_filter_n: 5.0,
78 // Back-calculation gain ≫ ki so the integrator stays bounded under
79 // sustained saturation and recovers promptly once the error clears.
80 kb: 0.4,
81 output_min: 0.3,
82 output_max: 1.0,
83 }
84 }
85}
86
87/// A discrete-time PID controller with filtered derivative-on-measurement and
88/// back-calculation anti-windup. See the [module docs](self) for the rationale.
89#[derive(Debug, Clone)]
90pub struct PidController {
91 cfg: PidConfig,
92 /// Integral accumulator. Seeded to `output_max` so the controller rests at
93 /// full performance until an error pushes it down.
94 integral: f32,
95 /// Previous measurement, for the derivative-on-measurement difference.
96 prev_measurement: f32,
97 /// Low-pass-filtered derivative term.
98 derivative_state: f32,
99 /// Last saturated output, exposed for the glass-box surface.
100 last_output: f32,
101 /// `false` until the first [`update`](Self::update) seeds `prev_measurement`
102 /// (avoids a spurious derivative spike on the first sample).
103 initialized: bool,
104}
105
106impl PidController {
107 /// Creates a controller resting at `output_max` (full performance).
108 pub fn new(cfg: PidConfig) -> Self {
109 Self {
110 integral: cfg.output_max,
111 prev_measurement: 0.0,
112 derivative_state: 0.0,
113 last_output: cfg.output_max,
114 initialized: false,
115 cfg,
116 }
117 }
118
119 /// Advances the controller one discrete step and returns the saturated
120 /// output. `dt` is the elapsed time in seconds since the previous call; a
121 /// non-positive `dt` is a no-op that returns the last output.
122 pub fn update(&mut self, setpoint: f32, measurement: f32, dt: f32) -> f32 {
123 if dt <= 0.0 {
124 return self.last_output;
125 }
126
127 // First call: seed the measurement history so the derivative starts at
128 // zero instead of jumping from the default 0.0.
129 if !self.initialized {
130 self.prev_measurement = measurement;
131 self.initialized = true;
132 }
133
134 let error = setpoint - measurement;
135
136 // Proportional term with setpoint weighting.
137 let p = self.cfg.kp * (self.cfg.setpoint_weight_b * setpoint - measurement);
138
139 // Derivative on measurement (negated), low-pass filtered. Deriving on
140 // the measurement avoids a kick when the setpoint steps.
141 let d_measurement = (measurement - self.prev_measurement) / dt;
142 let d_raw = -self.cfg.kd * d_measurement;
143 // First-order filter: alpha = dt / (Tf + dt), Tf = (Kd/Kp)/N.
144 let tf = if self.cfg.kp.abs() > f32::EPSILON {
145 (self.cfg.kd / self.cfg.kp) / self.cfg.derivative_filter_n
146 } else {
147 0.0
148 };
149 let alpha = if tf + dt > 0.0 { dt / (tf + dt) } else { 1.0 };
150 self.derivative_state += alpha * (d_raw - self.derivative_state);
151
152 // Unsaturated command, then clamp.
153 let unsat = p + self.integral + self.derivative_state;
154 let out = unsat.clamp(self.cfg.output_min, self.cfg.output_max);
155
156 // Integrate with back-calculation anti-windup: the `kb·(out − unsat)`
157 // term bleeds the integrator back inside the saturation limits.
158 self.integral += (self.cfg.ki * error + self.cfg.kb * (out - unsat)) * dt;
159
160 self.prev_measurement = measurement;
161 self.last_output = out;
162 out
163 }
164
165 /// Resets the controller to its initial resting state (`output_max`).
166 pub fn reset(&mut self) {
167 self.integral = self.cfg.output_max;
168 self.prev_measurement = 0.0;
169 self.derivative_state = 0.0;
170 self.last_output = self.cfg.output_max;
171 self.initialized = false;
172 }
173
174 /// The last saturated output (the current control value). Glass-box only.
175 pub fn output(&self) -> f32 {
176 self.last_output
177 }
178
179 /// The active configuration.
180 pub fn config(&self) -> &PidConfig {
181 &self.cfg
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 /// Drives the controller toward a setpoint with a simple first-order plant:
190 /// `measurement` moves toward `k / output` — i.e. a lower multiplier yields
191 /// a lower (faster) frame time, mirroring the real budget→strategy→time
192 /// relationship. Returns the settled measurement and output.
193 fn simulate(cfg: PidConfig, setpoint: f32, plant_gain: f32, steps: usize) -> (f32, f32) {
194 let mut pid = PidController::new(cfg);
195 let dt = 1.0 / 20.0; // 20 Hz cold path
196 let mut measurement = setpoint; // start on target
197 let mut output = cfg.output_max;
198 for _ in 0..steps {
199 output = pid.update(setpoint, measurement, dt);
200 // Plant: frame time scales with the granted budget multiplier.
201 let target_measurement = plant_gain * output;
202 // First-order lag toward the plant's response (gentle, stable).
203 measurement += 0.3 * (target_measurement - measurement);
204 }
205 (measurement, output)
206 }
207
208 #[test]
209 fn rests_at_output_max_on_construction() {
210 let pid = PidController::new(PidConfig::default());
211 assert_eq!(pid.output(), 1.0);
212 }
213
214 #[test]
215 fn non_positive_dt_is_noop() {
216 let mut pid = PidController::new(PidConfig::default());
217 let before = pid.output();
218 assert_eq!(pid.update(16.0, 20.0, 0.0), before);
219 assert_eq!(pid.update(16.0, 20.0, -1.0), before);
220 }
221
222 #[test]
223 fn output_stays_within_clamp() {
224 let cfg = PidConfig::default();
225 let mut pid = PidController::new(cfg);
226 let dt = 0.05;
227 // Hammer with a huge persistent overrun; output must never leave bounds.
228 for _ in 0..500 {
229 let out = pid.update(16.0, 200.0, dt);
230 assert!(out >= cfg.output_min - 1e-6 && out <= cfg.output_max + 1e-6);
231 }
232 }
233
234 #[test]
235 fn persistent_overrun_drives_output_down() {
236 let mut pid = PidController::new(PidConfig::default());
237 let dt = 0.05;
238 let first = pid.update(16.0, 40.0, dt);
239 let mut last = first;
240 for _ in 0..50 {
241 last = pid.update(16.0, 40.0, dt);
242 }
243 // Measurement well above setpoint → multiplier must shrink.
244 assert!(
245 last < first,
246 "expected output to decrease, {first} -> {last}"
247 );
248 assert!(last < 1.0);
249 }
250
251 #[test]
252 fn converges_toward_setpoint() {
253 // Plant gain 32 means measurement = 32*output; setpoint 16 → output≈0.5.
254 let (measurement, output) = simulate(PidConfig::default(), 16.0, 32.0, 5000);
255 assert!(
256 (measurement - 16.0).abs() < 1.5,
257 "measurement {measurement} should settle near setpoint 16"
258 );
259 assert!(
260 (0.3..=1.0).contains(&output),
261 "output {output} should be within clamp"
262 );
263 }
264
265 #[test]
266 fn anti_windup_keeps_integrator_bounded() {
267 // Under a long, heavy (but realistic) overrun the output pins at the
268 // floor; back-calculation must keep the integrator bounded so the output
269 // recovers once the frame becomes cheap again — not stay stuck at floor.
270 let cfg = PidConfig::default();
271 let mut pid = PidController::new(cfg);
272 let dt = 0.05;
273 for _ in 0..200 {
274 pid.update(16.0, 50.0, dt); // sustained 50ms frames → pinned at floor
275 }
276 assert!((pid.output() - cfg.output_min).abs() < 1e-3);
277 // Frames are now well under budget: the output must climb back off floor.
278 let mut out = pid.output();
279 for _ in 0..400 {
280 out = pid.update(16.0, 6.0, dt);
281 }
282 assert!(
283 out > cfg.output_min + 0.15,
284 "anti-windup should let output recover off the floor, got {out}"
285 );
286 }
287
288 #[test]
289 fn filtered_derivative_tames_noise() {
290 // Alternating noisy measurements around the setpoint should not blow the
291 // output around violently; with a filtered derivative it stays bounded.
292 let mut pid = PidController::new(PidConfig::default());
293 let dt = 0.05;
294 let mut min_out = f32::MAX;
295 let mut max_out = f32::MIN;
296 for i in 0..200 {
297 let noisy = if i % 2 == 0 { 14.0 } else { 18.0 };
298 let out = pid.update(16.0, noisy, dt);
299 min_out = min_out.min(out);
300 max_out = max_out.max(out);
301 }
302 assert!(
303 max_out - min_out < 0.3,
304 "filtered derivative should keep output swing small, got {}",
305 max_out - min_out
306 );
307 }
308
309 #[test]
310 fn reset_restores_resting_state() {
311 let mut pid = PidController::new(PidConfig::default());
312 let dt = 0.05;
313 for _ in 0..50 {
314 pid.update(16.0, 60.0, dt);
315 }
316 assert!(pid.output() < 1.0);
317 pid.reset();
318 assert_eq!(pid.output(), 1.0);
319 }
320}