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}