use crate::rng::Pcg;
#[derive(Clone, Copy, Debug)]
pub struct System {
pub a: f64,
pub b: f64,
pub q: f64,
pub r: f64,
}
impl System {
pub fn step(&self, x: f64, u: f64) -> f64 {
self.a * x + self.b * u
}
pub fn cost(&self, x: f64, u: f64) -> f64 {
self.q * x * x + self.r * u * u
}
pub fn rollout<F: FnMut(f64, usize) -> f64>(&self, x0: f64, steps: usize, mut policy: F) -> f64 {
let mut x = x0;
let mut total = 0.0;
for k in 0..steps {
let u = policy(x, k);
total += self.cost(x, u);
x = self.step(x, u);
}
total
}
}
#[derive(Clone, Copy, Debug)]
pub struct Lqr {
pub p: f64,
pub k: f64,
}
impl Lqr {
pub fn solve(s: &System) -> Lqr {
let mut p = s.q;
for _ in 0..10_000 {
let next = s.q + s.a * s.a * p - (s.a * s.b * p).powi(2) / (s.r + s.b * s.b * p);
if (next - p).abs() < 1e-15 * next.abs().max(1.0) {
p = next;
break;
}
p = next;
}
let k = (s.a * s.b * p) / (s.r + s.b * s.b * p);
Lqr { p, k }
}
pub fn action(&self, x: f64) -> f64 {
-self.k * x
}
pub fn cost_to_go(&self, x0: f64) -> f64 {
self.p * x0 * x0
}
}
#[derive(Clone, Copy, Debug)]
pub struct Mppi {
pub horizon: usize,
pub rollouts: usize,
pub sigma: f64,
pub lambda: f64,
pub iters: usize,
}
impl Mppi {
pub fn action(&self, s: &System, x: f64, nominal: &mut [f64], rng: &mut Pcg) -> f64 {
assert_eq!(nominal.len(), self.horizon, "nominal must cover the horizon");
for _ in 0..self.iters.max(1) {
self.refine(s, x, nominal, rng);
}
let u0 = nominal[0];
for k in 0..self.horizon - 1 {
nominal[k] = nominal[k + 1];
}
nominal[self.horizon - 1] = 0.0;
u0
}
fn refine(&self, s: &System, x: f64, nominal: &mut [f64], rng: &mut Pcg) {
let mut noise = vec![0.0f64; self.horizon * self.rollouts];
let mut costs = vec![0.0f64; self.rollouts];
for r in 0..self.rollouts {
let mut xk = x;
let mut c = 0.0;
for k in 0..self.horizon {
let e = gauss(rng) * self.sigma;
noise[r * self.horizon + k] = e;
let u = nominal[k] + e;
c += s.cost(xk, u);
xk = s.step(xk, u);
}
costs[r] = c;
}
let min = costs.iter().cloned().fold(f64::INFINITY, f64::min);
let mut wsum = 0.0;
let mut w = vec![0.0f64; self.rollouts];
for r in 0..self.rollouts {
w[r] = (-(costs[r] - min) / self.lambda).exp();
wsum += w[r];
}
for k in 0..self.horizon {
let mut d = 0.0;
for r in 0..self.rollouts {
d += w[r] * noise[r * self.horizon + k];
}
nominal[k] += d / wsum;
}
}
pub fn run(&self, s: &System, x0: f64, steps: usize, seed: u64) -> f64 {
let mut rng = Pcg::new(seed, 0);
let mut nominal = vec![0.0f64; self.horizon];
let mut x = x0;
let mut total = 0.0;
for _ in 0..steps {
let u = self.action(s, x, &mut nominal, &mut rng);
total += s.cost(x, u);
x = s.step(x, u);
}
total
}
}
fn gauss(rng: &mut Pcg) -> f64 {
let u = rng.f64().max(1e-15);
let v = rng.f64();
(-2.0 * u.ln()).sqrt() * (core::f64::consts::TAU * v).cos()
}
#[cfg(test)]
mod tests {
use super::*;
const SYS: System = System { a: 0.9, b: 1.0, q: 1.0, r: 0.5 };
const UNSTABLE: System = System { a: 1.1, b: 1.0, q: 1.0, r: 0.5 };
const TUNED: Mppi = Mppi { horizon: 5, rollouts: 300, sigma: 0.2, lambda: 0.3, iters: 10 };
#[test]
fn the_riccati_solution_actually_solves_the_equation() {
for s in [
System { a: 1.1, b: 1.0, q: 1.0, r: 0.5 },
System { a: 0.9, b: 0.5, q: 2.0, r: 1.0 },
System { a: 1.5, b: 1.0, q: 1.0, r: 0.1 },
] {
let l = Lqr::solve(&s);
let residual =
s.q + s.a * s.a * l.p - (s.a * s.b * l.p).powi(2) / (s.r + s.b * s.b * l.p) - l.p;
assert!(residual.abs() < 1e-9, "Riccati residual {residual} for {s:?}");
assert!(l.p > 0.0, "the cost-to-go coefficient must be positive");
}
}
#[test]
fn the_optimal_controller_is_optimal() {
let l = Lqr::solve(&SYS);
let base = SYS.rollout(1.0, 400, |x, _| -l.k * x);
for d in [-0.2, -0.05, 0.05, 0.2] {
let worse = SYS.rollout(1.0, 400, |x, _| -(l.k + d) * x);
assert!(worse > base, "gain {} beat the optimum {}", l.k + d, l.k);
}
assert!((base - l.cost_to_go(1.0)).abs() / l.cost_to_go(1.0) < 1e-6);
}
#[test]
fn sampling_control_lands_near_the_provable_optimum() {
let l = Lqr::solve(&SYS);
let optimal = l.cost_to_go(1.0);
let cost = TUNED.run(&SYS, 1.0, 200, 7);
let excess = (cost - optimal) / optimal;
assert!(excess > -1e-9, "nothing can beat the optimum: {cost} vs {optimal}");
assert!(excess < 0.10, "sampling control was {excess:.3} above the optimum");
}
#[test]
fn refinement_passes_are_what_buy_the_accuracy() {
let opt = Lqr::solve(&SYS).cost_to_go(1.0);
let ex = |iters: usize| {
let m = Mppi { iters, ..TUNED };
(m.run(&SYS, 1.0, 200, 7) - opt) / opt
};
let (one, ten) = (ex(1), ex(10));
assert!(ten < one / 2.0, "10 passes ({ten:.3}) should halve 1 pass ({one:.3})");
}
#[test]
fn a_longer_horizon_makes_it_worse_and_that_is_expected() {
let opt = Lqr::solve(&SYS).cost_to_go(1.0);
let ex = |h: usize| {
let m = Mppi { horizon: h, ..TUNED };
(m.run(&SYS, 1.0, 200, 7) - opt) / opt
};
assert!(ex(15) > ex(5), "the open-loop horizon penalty should be visible");
}
#[test]
fn an_unstable_plant_is_much_harder() {
let opt = Lqr::solve(&UNSTABLE).cost_to_go(1.0);
let ex = |h: usize| {
let m = Mppi { horizon: h, iters: 30, ..TUNED };
(m.run(&UNSTABLE, 1.0, 200, 7) - opt) / opt
};
assert!(ex(10) < ex(30), "a long horizon on an unstable plant should be far worse");
assert!(ex(30) > 1.0, "and it should fail outright, which it does");
}
#[test]
fn the_weighting_overflow_is_handled() {
let l = Lqr::solve(&SYS);
let m = Mppi { horizon: 200, rollouts: 100, sigma: 0.2, lambda: 0.3, iters: 1 };
let cost = m.run(&SYS, 1.0, 100, 5);
assert!(cost.is_finite(), "the weights must not all underflow to zero");
assert!(cost > 0.0);
let _ = l;
}
#[test]
fn it_is_deterministic_by_seed() {
assert_eq!(TUNED.run(&SYS, 1.0, 50, 11), TUNED.run(&SYS, 1.0, 50, 11));
assert_ne!(TUNED.run(&SYS, 1.0, 50, 11), TUNED.run(&SYS, 1.0, 50, 12));
}
#[test]
fn it_is_actually_controlling_something() {
let uncontrolled = UNSTABLE.rollout(1.0, 40, |_, _| 0.0);
let m = Mppi { horizon: 8, iters: 20, ..TUNED };
let controlled = m.run(&UNSTABLE, 1.0, 200, 9);
assert!(controlled < uncontrolled / 100.0,
"controlled {controlled} vs uncontrolled {uncontrolled}");
}
}