Skip to main content

oxirs_physics/
control_systems.rs

1//! PID and closed-loop control system simulation.
2//!
3//! Implements a discrete-time PID controller with anti-windup, a simple first-order
4//! system plant, and a closed-loop simulator that combines the two.
5
6/// PID controller gain parameters.
7#[derive(Debug, Clone, PartialEq)]
8pub struct PidGains {
9    /// Proportional gain
10    pub kp: f64,
11    /// Integral gain
12    pub ki: f64,
13    /// Derivative gain
14    pub kd: f64,
15}
16
17impl PidGains {
18    /// Create new gains.
19    pub fn new(kp: f64, ki: f64, kd: f64) -> Self {
20        Self { kp, ki, kd }
21    }
22}
23
24/// Discrete-time PID controller with output clamping and anti-windup.
25pub struct PidController {
26    pub gains: PidGains,
27    /// Accumulated integral term
28    integral: f64,
29    /// Error from the previous update step
30    prev_error: f64,
31    /// Minimum allowable output
32    output_min: f64,
33    /// Maximum allowable output
34    output_max: f64,
35}
36
37impl PidController {
38    /// Create a new PID controller.
39    ///
40    /// # Panics
41    /// Panics (in debug mode) if `output_min > output_max`.
42    pub fn new(gains: PidGains, output_min: f64, output_max: f64) -> Self {
43        debug_assert!(output_min <= output_max, "output_min must be ≤ output_max");
44        Self {
45            gains,
46            integral: 0.0,
47            prev_error: 0.0,
48            output_min,
49            output_max,
50        }
51    }
52
53    /// Compute one discrete-time PID step.
54    ///
55    /// Anti-windup: the integral is only accumulated when the output is not
56    /// saturated, preventing unbounded growth.
57    ///
58    /// Returns the clamped control output.
59    pub fn update(&mut self, setpoint: f64, measurement: f64, dt: f64) -> f64 {
60        let error = setpoint - measurement;
61
62        let p = self.p_term(error);
63        let d = self.d_term(error, self.prev_error, dt);
64
65        // Tentative output before adding integral
66        let unclamped_without_i = p + d + self.gains.ki * self.integral;
67        let clamped_without_i = unclamped_without_i.clamp(self.output_min, self.output_max);
68
69        // Anti-windup: only integrate when output is not saturated
70        let would_saturate =
71            unclamped_without_i >= self.output_max || unclamped_without_i <= self.output_min;
72        if !would_saturate
73            || (error > 0.0 && unclamped_without_i <= self.output_min)
74            || (error < 0.0 && unclamped_without_i >= self.output_max)
75        {
76            self.integral += error * dt;
77        }
78
79        let output =
80            (p + self.gains.ki * self.integral + d).clamp(self.output_min, self.output_max);
81
82        self.prev_error = error;
83        let _ = clamped_without_i; // suppress unused warning
84        output
85    }
86
87    /// Reset integral and previous-error state.
88    pub fn reset(&mut self) {
89        self.integral = 0.0;
90        self.prev_error = 0.0;
91    }
92
93    /// Proportional term.
94    pub fn p_term(&self, error: f64) -> f64 {
95        self.gains.kp * error
96    }
97
98    /// Current integral term (accumulated so far).
99    pub fn i_term(&self) -> f64 {
100        self.gains.ki * self.integral
101    }
102
103    /// Derivative term given current error, previous error, and time step.
104    pub fn d_term(&self, error: f64, prev_error: f64, dt: f64) -> f64 {
105        if dt <= 0.0 {
106            return 0.0;
107        }
108        self.gains.kd * (error - prev_error) / dt
109    }
110}
111
112/// Simple first-order system plant: `dy/dt = (u - y) / tau`.
113///
114/// State is integrated with forward Euler.
115pub struct SystemPlant {
116    /// Time constant (seconds)
117    tau: f64,
118    /// Current plant output
119    output: f64,
120}
121
122impl SystemPlant {
123    /// Create a new first-order plant.
124    pub fn new(tau: f64, initial: f64) -> Self {
125        Self {
126            tau,
127            output: initial,
128        }
129    }
130
131    /// Advance the plant by one time step with input `u`.
132    /// Returns the new output y(t+dt).
133    pub fn step(&mut self, input: f64, dt: f64) -> f64 {
134        // dy = (u - y) / tau * dt  (forward Euler)
135        if self.tau > 0.0 {
136            let dy = (input - self.output) / self.tau * dt;
137            self.output += dy;
138        }
139        self.output
140    }
141
142    /// Current plant output without advancing time.
143    pub fn output(&self) -> f64 {
144        self.output
145    }
146}
147
148/// Closed-loop simulator combining a PID controller with a plant.
149pub struct ClosedLoopSimulator {
150    controller: PidController,
151    plant: SystemPlant,
152    /// Recorded output trace across all simulation steps
153    output_trace: Vec<f64>,
154}
155
156impl ClosedLoopSimulator {
157    /// Create a new closed-loop simulator.
158    pub fn new(controller: PidController, plant: SystemPlant) -> Self {
159        Self {
160            controller,
161            plant,
162            output_trace: Vec::new(),
163        }
164    }
165
166    /// Run the closed-loop simulation for `steps` time steps of size `dt`.
167    ///
168    /// Returns the output trace (one value per step).
169    pub fn simulate(&mut self, setpoint: f64, steps: usize, dt: f64) -> Vec<f64> {
170        self.output_trace.clear();
171        self.output_trace.reserve(steps);
172
173        for _ in 0..steps {
174            let measurement = self.plant.output();
175            let u = self.controller.update(setpoint, measurement, dt);
176            let y = self.plant.step(u, dt);
177            self.output_trace.push(y);
178        }
179
180        self.output_trace.clone()
181    }
182
183    /// Steady-state error: difference between the last recorded output and setpoint.
184    pub fn steady_state_error(&self, setpoint: f64) -> f64 {
185        match self.output_trace.last() {
186            Some(&last) => last - setpoint,
187            None => 0.0,
188        }
189    }
190
191    /// Index of the first step at which |error| < tolerance * |setpoint|.
192    /// Returns None if the system never settles within the trace.
193    pub fn settling_time(&self, setpoint: f64, tolerance: f64) -> Option<usize> {
194        let threshold = tolerance * setpoint.abs();
195        self.output_trace
196            .iter()
197            .position(|&y| (y - setpoint).abs() < threshold)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    // --- PidGains ---
206
207    #[test]
208    fn test_pid_gains_new() {
209        let g = PidGains::new(1.0, 0.5, 0.1);
210        assert_eq!(g.kp, 1.0);
211        assert_eq!(g.ki, 0.5);
212        assert_eq!(g.kd, 0.1);
213    }
214
215    // --- PID p_term / i_term / d_term ---
216
217    #[test]
218    fn test_p_term() {
219        let pid = PidController::new(PidGains::new(2.0, 0.0, 0.0), -10.0, 10.0);
220        assert_eq!(pid.p_term(3.0), 6.0);
221    }
222
223    #[test]
224    fn test_p_term_negative_error() {
225        let pid = PidController::new(PidGains::new(2.0, 0.0, 0.0), -10.0, 10.0);
226        assert_eq!(pid.p_term(-3.0), -6.0);
227    }
228
229    #[test]
230    fn test_i_term_initial_zero() {
231        let pid = PidController::new(PidGains::new(1.0, 2.0, 0.0), -10.0, 10.0);
232        assert_eq!(pid.i_term(), 0.0);
233    }
234
235    #[test]
236    fn test_d_term_zero_dt() {
237        let pid = PidController::new(PidGains::new(1.0, 0.0, 5.0), -100.0, 100.0);
238        assert_eq!(pid.d_term(1.0, 0.0, 0.0), 0.0);
239    }
240
241    #[test]
242    fn test_d_term_positive() {
243        let pid = PidController::new(PidGains::new(1.0, 0.0, 2.0), -100.0, 100.0);
244        // error = 5.0, prev_error = 3.0, dt = 0.1 → (5-3)/0.1 * 2 = 40
245        let d = pid.d_term(5.0, 3.0, 0.1);
246        assert!((d - 40.0).abs() < 1e-9);
247    }
248
249    #[test]
250    fn test_d_term_negative() {
251        let pid = PidController::new(PidGains::new(1.0, 0.0, 2.0), -100.0, 100.0);
252        let d = pid.d_term(3.0, 5.0, 0.1);
253        assert!((d - (-40.0)).abs() < 1e-9);
254    }
255
256    // --- PID update / clamping ---
257
258    #[test]
259    fn test_pid_update_pure_p_no_error() {
260        let mut pid = PidController::new(PidGains::new(1.0, 0.0, 0.0), -100.0, 100.0);
261        let out = pid.update(5.0, 5.0, 0.1);
262        assert_eq!(out, 0.0);
263    }
264
265    #[test]
266    fn test_pid_update_output_clamps_high() {
267        let mut pid = PidController::new(PidGains::new(1000.0, 0.0, 0.0), -5.0, 5.0);
268        let out = pid.update(10.0, 0.0, 0.01);
269        assert_eq!(out, 5.0);
270    }
271
272    #[test]
273    fn test_pid_update_output_clamps_low() {
274        let mut pid = PidController::new(PidGains::new(1000.0, 0.0, 0.0), -5.0, 5.0);
275        let out = pid.update(0.0, 10.0, 0.01);
276        assert_eq!(out, -5.0);
277    }
278
279    #[test]
280    fn test_pid_update_accumulates_integral() {
281        let mut pid = PidController::new(PidGains::new(0.0, 1.0, 0.0), -1000.0, 1000.0);
282        // Each step: error = 1.0, dt = 0.1 → integral grows
283        for _ in 0..5 {
284            pid.update(1.0, 0.0, 0.1);
285        }
286        // After 5 steps: integral = 5 * 1 * 0.1 = 0.5 → i_term = 0.5
287        assert!(pid.i_term() > 0.0);
288    }
289
290    #[test]
291    fn test_pid_reset_clears_state() {
292        let mut pid = PidController::new(PidGains::new(1.0, 1.0, 1.0), -100.0, 100.0);
293        pid.update(10.0, 0.0, 0.1);
294        pid.reset();
295        assert_eq!(pid.integral, 0.0);
296        assert_eq!(pid.prev_error, 0.0);
297    }
298
299    #[test]
300    fn test_pid_reset_then_behaves_fresh() {
301        let mut pid = PidController::new(PidGains::new(1.0, 0.0, 0.0), -100.0, 100.0);
302        pid.update(5.0, 0.0, 0.1);
303        pid.reset();
304        let out = pid.update(3.0, 0.0, 0.1);
305        assert!((out - 3.0).abs() < 1e-9);
306    }
307
308    // --- SystemPlant ---
309
310    #[test]
311    fn test_plant_initial_output() {
312        let p = SystemPlant::new(1.0, 2.5);
313        assert_eq!(p.output(), 2.5);
314    }
315
316    #[test]
317    fn test_plant_step_moves_toward_input() {
318        let mut p = SystemPlant::new(1.0, 0.0);
319        let y = p.step(10.0, 0.1);
320        // dy = (10 - 0) / 1 * 0.1 = 1.0 → y = 1.0
321        assert!((y - 1.0).abs() < 1e-9);
322    }
323
324    #[test]
325    fn test_plant_step_tau_effect() {
326        let mut p_slow = SystemPlant::new(10.0, 0.0); // slow
327        let mut p_fast = SystemPlant::new(0.1, 0.0); // fast
328        let y_slow = p_slow.step(1.0, 0.1);
329        let y_fast = p_fast.step(1.0, 0.1);
330        assert!(y_fast > y_slow, "faster plant should respond more");
331    }
332
333    #[test]
334    fn test_plant_step_zero_dt() {
335        let mut p = SystemPlant::new(1.0, 5.0);
336        let y = p.step(10.0, 0.0);
337        assert_eq!(y, 5.0); // no change with dt=0
338    }
339
340    #[test]
341    fn test_plant_multiple_steps() {
342        let mut p = SystemPlant::new(1.0, 0.0);
343        for _ in 0..100 {
344            p.step(1.0, 0.1);
345        }
346        // After many steps the plant should be close to the input = 1.0
347        assert!(p.output() > 0.9);
348    }
349
350    // --- ClosedLoopSimulator ---
351
352    #[test]
353    fn test_simulate_returns_correct_length() {
354        let pid = PidController::new(PidGains::new(5.0, 0.1, 0.0), 0.0, 100.0);
355        let plant = SystemPlant::new(1.0, 0.0);
356        let mut sim = ClosedLoopSimulator::new(pid, plant);
357        let trace = sim.simulate(1.0, 50, 0.1);
358        assert_eq!(trace.len(), 50);
359    }
360
361    #[test]
362    fn test_simulate_converges_near_setpoint() {
363        // High Kp with some Ki should drive output close to setpoint
364        let pid = PidController::new(PidGains::new(10.0, 1.0, 0.0), 0.0, 200.0);
365        let plant = SystemPlant::new(1.0, 0.0);
366        let mut sim = ClosedLoopSimulator::new(pid, plant);
367        sim.simulate(1.0, 200, 0.05);
368        let err = sim.steady_state_error(1.0).abs();
369        assert!(err < 0.1, "Expected small steady-state error, got {err}");
370    }
371
372    #[test]
373    fn test_simulate_output_increases() {
374        let pid = PidController::new(PidGains::new(5.0, 0.0, 0.0), 0.0, 100.0);
375        let plant = SystemPlant::new(2.0, 0.0);
376        let mut sim = ClosedLoopSimulator::new(pid, plant);
377        let trace = sim.simulate(1.0, 20, 0.1);
378        // Output should be monotonically increasing (P-only, no overshoot expected)
379        assert!(trace[1] > trace[0]);
380    }
381
382    #[test]
383    fn test_simulate_zero_steps() {
384        let pid = PidController::new(PidGains::new(1.0, 0.0, 0.0), -10.0, 10.0);
385        let plant = SystemPlant::new(1.0, 0.0);
386        let mut sim = ClosedLoopSimulator::new(pid, plant);
387        let trace = sim.simulate(1.0, 0, 0.1);
388        assert!(trace.is_empty());
389    }
390
391    #[test]
392    fn test_steady_state_error_no_simulation() {
393        let pid = PidController::new(PidGains::new(1.0, 0.0, 0.0), -10.0, 10.0);
394        let plant = SystemPlant::new(1.0, 0.0);
395        let sim = ClosedLoopSimulator::new(pid, plant);
396        assert_eq!(sim.steady_state_error(1.0), 0.0);
397    }
398
399    #[test]
400    fn test_settling_time_none_when_never_settles() {
401        let pid = PidController::new(PidGains::new(0.0001, 0.0, 0.0), 0.0, 10.0);
402        let plant = SystemPlant::new(100.0, 0.0);
403        let mut sim = ClosedLoopSimulator::new(pid, plant);
404        sim.simulate(1.0, 10, 0.01); // too few steps
405        let st = sim.settling_time(1.0, 0.02); // 2% criterion
406                                               // Either None or some index (depends on gains); just ensure we don't panic
407        let _ = st;
408    }
409
410    #[test]
411    fn test_settling_time_some_when_converges() {
412        let pid = PidController::new(PidGains::new(10.0, 2.0, 0.0), 0.0, 500.0);
413        let plant = SystemPlant::new(0.5, 0.0);
414        let mut sim = ClosedLoopSimulator::new(pid, plant);
415        sim.simulate(1.0, 500, 0.02);
416        let st = sim.settling_time(1.0, 0.05); // 5% criterion
417        assert!(st.is_some(), "Expected system to settle");
418    }
419
420    #[test]
421    fn test_simulate_with_different_setpoints() {
422        let pid = PidController::new(PidGains::new(5.0, 1.0, 0.0), 0.0, 100.0);
423        let plant = SystemPlant::new(1.0, 0.0);
424        let mut sim = ClosedLoopSimulator::new(pid, plant);
425        sim.simulate(5.0, 200, 0.05);
426        let err = sim.steady_state_error(5.0).abs();
427        assert!(
428            err < 0.5,
429            "Expected small steady-state error for setpoint=5, got {err}"
430        );
431    }
432
433    #[test]
434    fn test_simulate_produces_non_negative_output_with_positive_setpoint() {
435        let pid = PidController::new(PidGains::new(5.0, 0.0, 0.0), 0.0, 100.0);
436        let plant = SystemPlant::new(1.0, 0.0);
437        let mut sim = ClosedLoopSimulator::new(pid, plant);
438        let trace = sim.simulate(2.0, 100, 0.05);
439        for &y in &trace {
440            assert!(y >= 0.0, "Output should be non-negative, got {y}");
441        }
442    }
443
444    #[test]
445    fn test_d_term_damping_reduces_overshoot() {
446        // Pure P controller
447        let pid_p = PidController::new(PidGains::new(5.0, 0.0, 0.0), 0.0, 100.0);
448        let plant_p = SystemPlant::new(0.5, 0.0);
449        let mut sim_p = ClosedLoopSimulator::new(pid_p, plant_p);
450        let trace_p = sim_p.simulate(1.0, 100, 0.05);
451
452        // PD controller
453        let pid_pd = PidController::new(PidGains::new(5.0, 0.0, 0.5), 0.0, 100.0);
454        let plant_pd = SystemPlant::new(0.5, 0.0);
455        let mut sim_pd = ClosedLoopSimulator::new(pid_pd, plant_pd);
456        let trace_pd = sim_pd.simulate(1.0, 100, 0.05);
457
458        // Both traces should be non-empty
459        assert!(!trace_p.is_empty());
460        assert!(!trace_pd.is_empty());
461    }
462}