Skip to main content

qualia_core_db/modalities/control_feedback/
advanced.rs

1//! Advanced control algorithms — adaptive PID tuning, Model Predictive Control, and MIMO
2//! state-space — complementing the PID/anti-windup loops in the parent module. Pure `f64` math,
3//! zero-heap (caller-supplied buffers; bounded loops).
4
5// ─── Adaptive PID tuning (gain scheduling + MIT-rule adaptation) ───────────────────
6
7/// Scale a PID gain triple by a scalar `adaptation` factor — the simplest gain-scheduling step
8/// (retune as the operating regime changes). Returns `(kp, ki, kd)`.
9#[inline]
10pub fn adaptive_gains(kp: f64, ki: f64, kd: f64, adaptation: f64) -> (f64, f64, f64) {
11    (kp * adaptation, ki * adaptation, kd * adaptation)
12}
13
14/// **MIT-rule** online gain adaptation (model-reference adaptive control): nudge `gain` to reduce
15/// the tracking `error` — `gain + adaptation_rate · error · signal`. One adaptation step.
16#[inline]
17pub fn mit_rule_adapt(gain: f64, adaptation_rate: f64, error: f64, signal: f64) -> f64 {
18    gain + adaptation_rate * error * signal
19}
20
21/// **Gain scheduling**: linearly interpolate the gain between `low_gain` (at `op_min`) and
22/// `high_gain` (at `op_max`) for the current `op_point` — adaptive tuning across the operating
23/// envelope. Clamps outside `[op_min, op_max]`.
24pub fn scheduled_gain(
25    op_point: f64,
26    op_min: f64,
27    op_max: f64,
28    low_gain: f64,
29    high_gain: f64,
30) -> f64 {
31    if op_max <= op_min {
32        return low_gain;
33    }
34    let t = ((op_point - op_min) / (op_max - op_min)).clamp(0.0, 1.0);
35    low_gain + t * (high_gain - low_gain)
36}
37
38// ─── Model Predictive Control (receding horizon) ──────────────────────────────────
39
40/// One-step **Model Predictive Control** for a scalar LTI plant `x_{k+1} = a·x_k + b·u`: search
41/// candidate controls across `[u_min, u_max]` (a grid of `steps`+1 points), simulate `horizon`
42/// steps holding `u`, and return the `u` minimising `Σ (setpoint − x_k)² + control_penalty·u²`.
43/// The optimal FIRST move of the receding horizon. Zero-heap (bounded loops, no allocation).
44pub fn mpc_control(
45    a: f64,
46    b: f64,
47    state: f64,
48    setpoint: f64,
49    horizon: u32,
50    u_min: f64,
51    u_max: f64,
52    steps: u32,
53    control_penalty: f64,
54) -> f64 {
55    let n = steps.max(1);
56    let mut best_u = u_min;
57    let mut best_cost = f64::INFINITY;
58    for i in 0..=n {
59        let u = u_min + (u_max - u_min) * (i as f64) / (n as f64);
60        let mut x = state;
61        let mut cost = 0.0;
62        for _ in 0..horizon {
63            x = a * x + b * u;
64            let e = setpoint - x;
65            cost += e * e;
66        }
67        cost += control_penalty * u * u;
68        if cost < best_cost {
69            best_cost = cost;
70            best_u = u;
71        }
72    }
73    best_u
74}
75
76// ─── MIMO state-space (multi-input multi-output) ──────────────────────────────────
77
78/// MIMO state transition `x' = A·x + B·u` for a system with `n` states and `m` inputs. `a` is the
79/// `n×n` state matrix (row-major), `b` the `n×m` input matrix (row-major), `x` the state (len n),
80/// `u` the input (len m); the next state is written to `out` (len n). Zero-heap. Returns `false`
81/// on a dimension mismatch.
82pub fn mimo_step(a: &[f64], b: &[f64], x: &[f64], u: &[f64], out: &mut [f64]) -> bool {
83    let n = x.len();
84    let m = u.len();
85    if a.len() < n * n || b.len() < n * m || out.len() < n {
86        return false;
87    }
88    for i in 0..n {
89        let mut s = 0.0;
90        for j in 0..n {
91            s += a[i * n + j] * x[j];
92        }
93        for j in 0..m {
94            s += b[i * m + j] * u[j];
95        }
96        out[i] = s;
97    }
98    true
99}
100
101/// MIMO output equation `y = C·x + D·u` with `p` outputs: `c` is `p×n` (row-major), `d` is `p×m`
102/// (row-major); the output is written to `out` (len p). Zero-heap. Returns `false` on a mismatch.
103pub fn mimo_output(c: &[f64], d: &[f64], x: &[f64], u: &[f64], p: usize, out: &mut [f64]) -> bool {
104    let n = x.len();
105    let m = u.len();
106    if c.len() < p * n || d.len() < p * m || out.len() < p {
107        return false;
108    }
109    for i in 0..p {
110        let mut s = 0.0;
111        for j in 0..n {
112            s += c[i * n + j] * x[j];
113        }
114        for j in 0..m {
115            s += d[i * m + j] * u[j];
116        }
117        out[i] = s;
118    }
119    true
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn close(a: f64, b: f64) -> bool {
127        (a - b).abs() < 1e-6
128    }
129
130    #[test]
131    fn adaptive_tuning_schedules_and_adapts_gains() {
132        // Scalar adaptation scales all three gains.
133        let (kp, ki, kd) = adaptive_gains(1.0, 0.5, 0.1, 2.0);
134        assert!(close(kp, 2.0) && close(ki, 1.0) && close(kd, 0.2));
135        // MIT rule nudges the gain to cut tracking error.
136        assert!(close(mit_rule_adapt(1.0, 0.1, 2.0, 3.0), 1.6)); // 1 + 0.1*2*3
137                                                                 // Gain scheduling interpolates across the envelope and clamps.
138        assert!(close(scheduled_gain(5.0, 0.0, 10.0, 1.0, 3.0), 2.0)); // midpoint
139        assert!(close(scheduled_gain(-5.0, 0.0, 10.0, 1.0, 3.0), 1.0)); // clamp low
140        assert!(close(scheduled_gain(99.0, 0.0, 10.0, 1.0, 3.0), 3.0)); // clamp high
141    }
142
143    #[test]
144    fn mpc_drives_state_toward_setpoint() {
145        // Integrator x' = x + u, from 0 to setpoint 10, no control penalty → u ≈ 10 over horizon 1.
146        let u = mpc_control(1.0, 1.0, 0.0, 10.0, 1, 0.0, 20.0, 200, 0.0);
147        assert!(
148            (u - 10.0).abs() <= 0.1,
149            "MPC picks u≈10 to reach the setpoint, got {u}"
150        );
151        // A control penalty pulls the optimal move below the unpenalised value.
152        let u_pen = mpc_control(1.0, 1.0, 0.0, 10.0, 1, 0.0, 20.0, 200, 1.0);
153        assert!(u_pen < u, "control penalty reduces the aggressive move");
154    }
155
156    #[test]
157    fn mimo_state_space_step_and_output() {
158        // 2-state, 1-input system. A = [[1,1],[0,1]] (a double integrator), B = [[0],[1]].
159        let a = [1.0, 1.0, 0.0, 1.0];
160        let b = [0.0, 1.0];
161        let x = [0.0, 0.0];
162        let u = [2.0];
163        let mut nx = [0.0; 2];
164        assert!(mimo_step(&a, &b, &x, &u, &mut nx));
165        // x' = A·x + B·u = [0, 2].
166        assert!(close(nx[0], 0.0) && close(nx[1], 2.0));
167        // Output y = C·x + D·u with C = [[1,0]] (observe position), D = [[0]].
168        let c = [1.0, 0.0];
169        let d = [0.0];
170        let mut y = [0.0; 1];
171        assert!(mimo_output(&c, &d, &nx, &u, 1, &mut y));
172        assert!(close(y[0], 0.0)); // position still 0 after one step
173                                   // Dimension mismatch refuses.
174        assert!(!mimo_step(&a, &b, &x, &u, &mut [0.0; 1]));
175    }
176}