Skip to main content

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}