Skip to main content

ferromotion_learn/
capstone.rs

1//! **Capstone — model-based control (learn in imagination).** The whole course in one loop: identify a
2//! model of an unknown plant from data, tune a controller by differentiating through the *learned* model, and
3//! deploy it on the *real* plant. This is the sample-efficient alternative to reinforcement learning on
4//! hardware — all the tuning happens offline, in the model, by gradient descent; the real system is only ever
5//! run by the finished controller.
6//!
7//! Three stages: (1) **system identification** — from `(x, v, u) → ẍ` samples of a hidden damped point mass,
8//! least squares recovers the coefficients of `ẍ = a·x + b·v + c·u + d`; (2) **control in imagination** — a
9//! PID is tuned by rolling the closed loop out through the *learned* model on the autodiff tape and
10//! backpropagating the tracking cost; (3) **deployment** — the tuned controller runs on the *true* plant and
11//! reaches the setpoint, having never been tuned against it. Verified: the identified coefficients match the
12//! plant, and a controller trained entirely in the learned model settles the true plant on its setpoint.
13
14use crate::autodiff::Tape;
15use nalgebra::{DMatrix, DVector};
16
17/// Learn a plant model from data, tune a PID inside it, and deploy on the true plant.
18pub struct ModelBasedControl {
19    // hidden true plant: mass·ẍ = u − damping·v − disturbance
20    true_mass: f64,
21    true_damping: f64,
22    true_disturbance: f64,
23    setpoint: f64,
24    dt: f64,
25    steps: usize,
26    // learned model ẍ = a·x + b·v + c·u + d
27    learned: [f64; 4],
28    identified: bool,
29    // controller
30    gains: [f64; 3],
31    m: [f64; 3],
32    v: [f64; 3],
33    t: u64,
34}
35
36fn splitmix64(state: &mut u64) -> u64 {
37    *state = state.wrapping_add(0x9E3779B97F4A7C15);
38    let mut z = *state;
39    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
40    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
41    z ^ (z >> 31)
42}
43
44impl ModelBasedControl {
45    /// A task on a hidden damped point mass, with the controller starting proportional-only.
46    pub fn new(mass: f64, damping: f64, disturbance: f64, setpoint: f64) -> Self {
47        ModelBasedControl {
48            true_mass: mass,
49            true_damping: damping,
50            true_disturbance: disturbance,
51            setpoint,
52            dt: 0.05,
53            steps: 90,
54            learned: [0.0; 4],
55            identified: false,
56            gains: [1.0, 0.0, 0.0],
57            m: [0.0; 3],
58            v: [0.0; 3],
59            t: 0,
60        }
61    }
62
63    fn true_accel(&self, x: f64, v: f64, u: f64) -> f64 {
64        let _ = x; // no spring term in the true plant
65        (u - self.true_damping * v - self.true_disturbance) / self.true_mass
66    }
67
68    /// Stage 1 — identify `ẍ = a·x + b·v + c·u + d` from data by least squares.
69    pub fn identify(&mut self) {
70        let mut s = 20240101u64;
71        let mut rnd = |lo: f64, hi: f64| lo + (splitmix64(&mut s) as f64 / u64::MAX as f64) * (hi - lo);
72        let n = 200;
73        let mut a = DMatrix::zeros(n, 4);
74        let mut y = DVector::zeros(n);
75        for i in 0..n {
76            let (x, v, u) = (rnd(-2.0, 2.0), rnd(-2.0, 2.0), rnd(-5.0, 5.0));
77            a[(i, 0)] = x;
78            a[(i, 1)] = v;
79            a[(i, 2)] = u;
80            a[(i, 3)] = 1.0;
81            y[i] = self.true_accel(x, v, u);
82        }
83        let sol = a.svd(true, true).solve(&y, 1e-12).unwrap_or_else(|_| DVector::zeros(4));
84        self.learned = [sol[0], sol[1], sol[2], sol[3]];
85        self.identified = true;
86    }
87
88    /// The learned model coefficients `[a, b, c, d]`.
89    pub fn learned_coeffs(&self) -> [f64; 4] {
90        self.learned
91    }
92    /// The true coefficients (for comparison): `a=0, b=−damping/m, c=1/m, d=−disturbance/m`.
93    pub fn true_coeffs(&self) -> [f64; 4] {
94        [0.0, -self.true_damping / self.true_mass, 1.0 / self.true_mass, -self.true_disturbance / self.true_mass]
95    }
96    pub fn gains(&self) -> [f64; 3] {
97        self.gains
98    }
99
100    /// Roll out the closed loop in plain `f64`; `use_true` selects the true plant or the learned model.
101    fn simulate(&self, use_true: bool) -> Vec<f64> {
102        let (mut x, mut v, mut integ) = (0.0, 0.0, 0.0);
103        let mut out = vec![x];
104        for _ in 0..self.steps {
105            let e = self.setpoint - x;
106            integ += e * self.dt;
107            let u = self.gains[0] * e + self.gains[1] * integ + self.gains[2] * (-v);
108            let acc = if use_true {
109                self.true_accel(x, v, u)
110            } else {
111                self.learned[0] * x + self.learned[1] * v + self.learned[2] * u + self.learned[3]
112            };
113            v += acc * self.dt;
114            x += v * self.dt;
115            out.push(x);
116        }
117        out
118    }
119
120    /// The step response on the true plant.
121    pub fn deploy(&self) -> Vec<f64> {
122        self.simulate(true)
123    }
124    /// The step response in the learned model (the "imagination").
125    pub fn imagine(&self) -> Vec<f64> {
126        self.simulate(false)
127    }
128    /// Steady-state error on the true plant.
129    pub fn deploy_error(&self) -> f64 {
130        (self.deploy().last().copied().unwrap_or(0.0) - self.setpoint).abs()
131    }
132
133    /// Stage 2 — one Adam step tuning the PID by differentiating through the LEARNED model. Returns the cost.
134    pub fn train_step(&mut self, lr: f64) -> f64 {
135        let tape = Tape::new();
136        let kp = tape.var(self.gains[0]);
137        let ki = tape.var(self.gains[1]);
138        let kd = tape.var(self.gains[2]);
139        let mut x = tape.constant(0.0);
140        let mut v = tape.constant(0.0);
141        let mut integ = tape.constant(0.0);
142        let mut cost = tape.constant(0.0);
143        let (sp, dt) = (self.setpoint, self.dt);
144        let [a, b, c, d] = self.learned;
145        for step in 0..self.steps {
146            let e = tape.constant(sp) - x;
147            integ = integ + e * dt;
148            let u = kp * e + ki * integ + kd * (v * -1.0);
149            // learned model dynamics ẍ = a x + b v + c u + d
150            let acc = x * a + v * b + u * c + tape.constant(d);
151            v = v + acc * dt;
152            x = x + v * dt;
153            let w = 0.5 + step as f64 / self.steps as f64;
154            cost = cost + e * e * (w * dt) + u * u * (1e-4 * dt);
155        }
156        let g = cost.backward();
157        let grad = [g.wrt(kp), g.wrt(ki), g.wrt(kd)];
158        self.t += 1;
159        let (b1, b2, eps) = (0.9_f64, 0.999_f64, 1e-8);
160        let (bc1, bc2) = (1.0 - b1.powi(self.t as i32), 1.0 - b2.powi(self.t as i32));
161        for (i, &gi) in grad.iter().enumerate() {
162            self.m[i] = b1 * self.m[i] + (1.0 - b1) * gi;
163            self.v[i] = b2 * self.v[i] + (1.0 - b2) * gi * gi;
164            self.gains[i] -= lr * (self.m[i] / bc1) / ((self.v[i] / bc2).sqrt() + eps);
165        }
166        cost.value()
167    }
168
169    /// Train the controller in the learned model for `epochs` steps.
170    pub fn train(&mut self, epochs: usize, lr: f64) -> f64 {
171        let mut c = f64::INFINITY;
172        for _ in 0..epochs {
173            c = self.train_step(lr);
174        }
175        c
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn learn_a_model_then_control_the_real_plant_through_it() {
185        // The full arc: identify the plant, tune a PID inside the learned model, deploy on the true plant.
186        let mut mbc = ModelBasedControl::new(1.0, 0.6, 2.0, 1.0);
187        mbc.identify();
188        // stage 1: identification is accurate
189        let (lc, tc) = (mbc.learned_coeffs(), mbc.true_coeffs());
190        for i in 0..4 {
191            assert!((lc[i] - tc[i]).abs() < 1e-6, "coeff {i}: learned {} vs true {}", lc[i], tc[i]);
192        }
193        // stages 2+3: a controller tuned entirely in the learned model settles the TRUE plant on its setpoint
194        let before = mbc.deploy_error();
195        assert!(before > 0.3, "proportional-only should miss on the true plant: {before}");
196        mbc.train(800, 0.05);
197        let after = mbc.deploy_error();
198        assert!(after < 0.06, "controller trained in imagination should settle the real plant: {after}");
199        assert!(mbc.gains()[1] > 0.1, "it should discover integral action: Ki={}", mbc.gains()[1]);
200    }
201}