#![allow(
clippy::needless_range_loop,
clippy::manual_memcpy,
clippy::type_complexity
)]
use crate::core::scalar::ControlScalar;
use crate::mpc::stochastic_mpc::Lcg;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MppiError {
InvalidTemperature,
ZeroSamples,
DimensionMismatch,
}
impl core::fmt::Display for MppiError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
MppiError::InvalidTemperature => {
write!(f, "MPPI: temperature λ must be strictly positive")
}
MppiError::ZeroSamples => write!(f, "MPPI: K must be ≥ 1"),
MppiError::DimensionMismatch => {
write!(f, "MPPI: warm-start trajectory dimension mismatch")
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct MppiConfig<S: ControlScalar, const N: usize, const I: usize> {
pub temperature: S,
pub sigma: [S; I],
pub u_min: [S; I],
pub u_max: [S; I],
pub gamma: S,
pub lcg_seed: u64,
_phantom: core::marker::PhantomData<[S; N]>,
}
impl<S: ControlScalar, const N: usize, const I: usize> MppiConfig<S, N, I> {
pub fn new(
temperature: S,
sigma: [S; I],
u_min: [S; I],
u_max: [S; I],
gamma: S,
lcg_seed: u64,
) -> Result<Self, MppiError> {
if temperature <= S::ZERO {
return Err(MppiError::InvalidTemperature);
}
Ok(Self {
temperature,
sigma,
u_min,
u_max,
gamma,
lcg_seed,
_phantom: core::marker::PhantomData,
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct MppiStats<S: ControlScalar> {
pub n_samples: usize,
pub min_cost: S,
pub max_cost: S,
pub mean_cost: S,
pub effective_sample_size: S,
}
pub struct Mppi<S: ControlScalar, const N: usize, const I: usize, const H: usize, const K: usize> {
config: MppiConfig<S, N, I>,
u_nominal: [[S; I]; H],
lcg_state: u64,
v_samples: [[[S; I]; H]; K],
sample_costs: [S; K],
}
impl<S: ControlScalar, const N: usize, const I: usize, const H: usize, const K: usize>
Mppi<S, N, I, H, K>
{
pub fn new(config: MppiConfig<S, N, I>) -> Result<Self, MppiError> {
if K == 0 {
return Err(MppiError::ZeroSamples);
}
let lcg_seed = config.lcg_seed;
Ok(Self {
config,
u_nominal: [[S::ZERO; I]; H],
lcg_state: lcg_seed,
v_samples: [[[S::ZERO; I]; H]; K],
sample_costs: [S::ZERO; K],
})
}
pub fn reset(&mut self) {
self.u_nominal = [[S::ZERO; I]; H];
self.lcg_state = self.config.lcg_seed;
}
pub fn set_nominal(&mut self, u: &[[S; I]; H]) {
self.u_nominal = *u;
}
pub fn nominal(&self) -> &[[S; I]; H] {
&self.u_nominal
}
fn next_uniform(&mut self) -> f64 {
const A: u64 = 1_664_525;
const C: u64 = 1_013_904_223;
const M: u64 = 1u64 << 32;
self.lcg_state = A.wrapping_mul(self.lcg_state).wrapping_add(C) & (M - 1);
self.lcg_state as f64 / M as f64
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_uniform().max(1e-15_f64); let u2 = self.next_uniform();
let two_pi = 2.0_f64 * core::f64::consts::PI;
libm::sqrt(-2.0_f64 * libm::log(u1)) * libm::cos(two_pi * u2)
}
fn sample_perturbation(&mut self, eps: &mut [S; I]) {
for j in 0..I {
let z = self.next_normal();
let sigma_j = self.config.sigma[j];
eps[j] = sigma_j * S::from_f64(z);
}
}
fn control_cost_term(&self, u_h: &[S; I], eps_h: &[S; I]) -> S {
let lambda = self.config.temperature;
let mut acc = S::ZERO;
for j in 0..I {
let sigma_j = self.config.sigma[j];
if sigma_j > S::ZERO {
acc += u_h[j] * eps_h[j] / (sigma_j * sigma_j);
}
}
lambda * acc
}
pub fn update<F, G>(&mut self, x0: [S; N], dynamics: F, cost: G) -> Result<[S; I], MppiError>
where
F: Fn(&[S; N], &[S; I]) -> [S; N],
G: Fn(&[S; N], &[S; I], bool) -> S,
{
for k in 0..K {
let mut x = x0;
let mut total_cost = S::ZERO;
let mut discount = S::ONE;
for h in 0..H {
let mut eps = [S::ZERO; I];
self.sample_perturbation(&mut eps);
let mut v_h = [S::ZERO; I];
for j in 0..I {
let raw = self.u_nominal[h][j] + eps[j];
v_h[j] = raw.clamp_val(self.config.u_min[j], self.config.u_max[j]);
}
self.v_samples[k][h] = v_h;
let is_terminal = h == H - 1;
let stage = discount * cost(&x, &v_h, is_terminal);
let ctrl_cost = discount * self.control_cost_term(&self.u_nominal[h], &eps);
total_cost += stage + ctrl_cost;
discount *= self.config.gamma;
if !is_terminal {
x = dynamics(&x, &v_h);
}
}
self.sample_costs[k] = total_cost;
}
let lambda = self.config.temperature;
let mut s_min = self.sample_costs[0];
for k in 1..K {
if self.sample_costs[k] < s_min {
s_min = self.sample_costs[k];
}
}
let mut weights = [S::ZERO; K];
let mut weight_sum = S::ZERO;
for k in 0..K {
let shifted = self.sample_costs[k] - s_min;
let log_w = -(shifted / lambda);
let w = log_w.exp();
weights[k] = w;
weight_sum += w;
}
let inv_weight_sum = if weight_sum > S::ZERO {
S::ONE / weight_sum
} else {
S::ONE / S::from_f64(K as f64)
};
let mut u_star = [[S::ZERO; I]; H];
for k in 0..K {
let w_norm = weights[k] * inv_weight_sum;
for h in 0..H {
for j in 0..I {
u_star[h][j] += w_norm * self.v_samples[k][h][j];
}
}
}
for h in 0..H {
for j in 0..I {
u_star[h][j] = u_star[h][j].clamp_val(self.config.u_min[j], self.config.u_max[j]);
}
}
for h in 0..H - 1 {
self.u_nominal[h] = u_star[h + 1];
}
self.u_nominal[H - 1] = u_star[H - 1];
Ok(u_star[0])
}
pub fn baseline_cost<F, G>(&self, x0: [S; N], dynamics: F, cost: G) -> S
where
F: Fn(&[S; N], &[S; I]) -> [S; N],
G: Fn(&[S; N], &[S; I], bool) -> S,
{
let mut x = x0;
let mut total = S::ZERO;
let mut discount = S::ONE;
for h in 0..H {
let is_terminal = h == H - 1;
total += discount * cost(&x, &self.u_nominal[h], is_terminal);
discount *= self.config.gamma;
if !is_terminal {
x = dynamics(&x, &self.u_nominal[h]);
}
}
total
}
pub fn compute_stats(&self) -> MppiStats<S> {
let lambda = self.config.temperature;
let mut s_min = self.sample_costs[0];
let mut s_max = self.sample_costs[0];
let mut s_sum = S::ZERO;
for k in 0..K {
let s = self.sample_costs[k];
if s < s_min {
s_min = s;
}
if s > s_max {
s_max = s;
}
s_sum += s;
}
let mean_cost = s_sum / S::from_f64(K as f64);
let mut w_sum = S::ZERO;
let mut w_sq_sum = S::ZERO;
for k in 0..K {
let shifted = self.sample_costs[k] - s_min;
let w = (-(shifted / lambda)).exp();
w_sum += w;
w_sq_sum += w * w;
}
let ess = if w_sq_sum > S::ZERO {
(w_sum * w_sum) / w_sq_sum
} else {
S::from_f64(K as f64)
};
MppiStats {
n_samples: K,
min_cost: s_min,
max_cost: s_max,
mean_cost,
effective_sample_size: ess,
}
}
pub fn sample_costs(&self) -> &[S; K] {
&self.sample_costs
}
pub fn config(&self) -> &MppiConfig<S, N, I> {
&self.config
}
}
pub struct MppiConfigBuilder<S: ControlScalar, const N: usize, const I: usize> {
temperature: S,
sigma: [S; I],
u_min: [S; I],
u_max: [S; I],
gamma: S,
lcg_seed: u64,
}
impl<S: ControlScalar, const N: usize, const I: usize> MppiConfigBuilder<S, N, I> {
pub fn new() -> Self {
let sigma_val = S::from_f64(0.1);
let u_min_val = S::from_f64(-1.0);
let u_max_val = S::ONE;
Self {
temperature: S::ONE,
sigma: [sigma_val; I],
u_min: [u_min_val; I],
u_max: [u_max_val; I],
gamma: S::ONE,
lcg_seed: 42,
}
}
pub fn temperature(mut self, t: S) -> Self {
self.temperature = t;
self
}
pub fn sigma_uniform(mut self, s: S) -> Self {
self.sigma = [s; I];
self
}
pub fn sigma(mut self, s: [S; I]) -> Self {
self.sigma = s;
self
}
pub fn bounds_symmetric(mut self, bound: S) -> Self {
self.u_min = [-bound; I];
self.u_max = [bound; I];
self
}
pub fn bounds(mut self, u_min: [S; I], u_max: [S; I]) -> Self {
self.u_min = u_min;
self.u_max = u_max;
self
}
pub fn gamma(mut self, g: S) -> Self {
self.gamma = g;
self
}
pub fn lcg_seed(mut self, seed: u64) -> Self {
self.lcg_seed = seed;
self
}
pub fn build(self) -> Result<MppiConfig<S, N, I>, MppiError> {
MppiConfig::new(
self.temperature,
self.sigma,
self.u_min,
self.u_max,
self.gamma,
self.lcg_seed,
)
}
}
impl<S: ControlScalar, const N: usize, const I: usize> Default for MppiConfigBuilder<S, N, I> {
fn default() -> Self {
Self::new()
}
}
pub fn box_muller_normal(lcg: &mut Lcg) -> f64 {
lcg.next_normal_f64()
}
#[cfg(test)]
mod tests {
use super::*;
fn integrator_dynamics(x: &[f64; 2], u: &[f64; 1]) -> [f64; 2] {
let dt = 0.1_f64;
[x[0] + dt * x[1] + 0.5 * dt * dt * u[0], x[1] + dt * u[0]]
}
fn quadratic_cost(x: &[f64; 2], u: &[f64; 1], _terminal: bool) -> f64 {
x[0] * x[0] + x[1] * x[1] + 0.01 * u[0] * u[0]
}
fn zero_cost(_x: &[f64; 2], _u: &[f64; 1], _terminal: bool) -> f64 {
0.0_f64
}
type Mppi2_1_10_50 = Mppi<f64, 2, 1, 10, 50>;
fn make_mppi() -> Mppi2_1_10_50 {
let config = MppiConfigBuilder::<f64, 2, 1>::new()
.temperature(1.0)
.sigma_uniform(0.5)
.bounds_symmetric(5.0)
.gamma(1.0)
.lcg_seed(12345)
.build()
.expect("valid config");
Mppi::new(config).expect("valid mppi")
}
#[test]
fn invalid_temperature_returns_error() {
let result = MppiConfig::<f64, 2, 1>::new(-1.0, [0.1], [-5.0], [5.0], 1.0, 42);
assert!(
matches!(result, Err(MppiError::InvalidTemperature)),
"expected InvalidTemperature"
);
}
#[test]
fn zero_temperature_returns_error() {
let result = MppiConfig::<f64, 2, 1>::new(0.0, [0.1], [-5.0], [5.0], 1.0, 42);
assert!(matches!(result, Err(MppiError::InvalidTemperature)));
}
#[test]
fn valid_config_constructs_ok() {
let result = MppiConfig::<f64, 2, 1>::new(1.0, [0.5], [-5.0], [5.0], 1.0, 42);
assert!(result.is_ok());
}
#[test]
fn zero_cost_uniform_weights_u_star_near_nominal() {
let mut ctrl = make_mppi();
let x0 = [0.0_f64; 2];
let u0 = ctrl
.update(x0, integrator_dynamics, zero_cost)
.expect("update ok");
assert!(
u0[0].abs() < 1.5,
"with zero cost u* should be near 0; got {}",
u0[0]
);
}
#[test]
fn quadratic_cost_drives_state_toward_origin() {
let mut ctrl = make_mppi();
let x0 = [2.0_f64, 0.0_f64];
let u0 = ctrl
.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
assert!(
u0[0] < 0.5,
"expected control to reduce positive position, got u={}",
u0[0]
);
}
#[test]
fn quadratic_cost_negative_position_positive_control() {
let mut ctrl = make_mppi();
let x0 = [-2.0_f64, 0.0_f64];
let u0 = ctrl
.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
assert!(
u0[0] > -0.5,
"expected control to reduce negative position, got u={}",
u0[0]
);
}
#[test]
fn ess_le_k_with_zero_cost() {
let mut ctrl = make_mppi();
let x0 = [0.0_f64; 2];
ctrl.update(x0, integrator_dynamics, zero_cost)
.expect("update ok");
let stats = ctrl.compute_stats();
let k_f64 = 50.0_f64;
assert!(
(stats.effective_sample_size - k_f64).abs() < 1e-6,
"ESS should equal K={} with uniform weights, got {}",
k_f64,
stats.effective_sample_size
);
}
#[test]
fn ess_lt_k_with_high_cost_variance() {
let config = MppiConfigBuilder::<f64, 2, 1>::new()
.temperature(0.01) .sigma_uniform(2.0)
.bounds_symmetric(10.0)
.gamma(1.0)
.lcg_seed(999)
.build()
.expect("config");
let mut ctrl: Mppi<f64, 2, 1, 10, 50> = Mppi::new(config).expect("mppi");
let x0 = [3.0_f64, 0.0_f64];
ctrl.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
let stats = ctrl.compute_stats();
assert!(
stats.effective_sample_size <= 50.0,
"ESS must be ≤ K, got {}",
stats.effective_sample_size
);
}
#[test]
fn reset_clears_nominal_trajectory() {
let mut ctrl = make_mppi();
let x0 = [1.0_f64, 0.0_f64];
ctrl.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
ctrl.reset();
let nom = ctrl.nominal();
for h in 0..10 {
for j in 0..1 {
assert_eq!(
nom[h][j], 0.0,
"after reset nominal[{}][{}] should be 0, got {}",
h, j, nom[h][j]
);
}
}
}
#[test]
fn stats_min_le_mean_le_max() {
let mut ctrl = make_mppi();
let x0 = [1.5_f64, -0.5_f64];
ctrl.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
let stats = ctrl.compute_stats();
assert!(
stats.min_cost <= stats.mean_cost,
"min {} > mean {}",
stats.min_cost,
stats.mean_cost
);
assert!(
stats.mean_cost <= stats.max_cost,
"mean {} > max {}",
stats.mean_cost,
stats.max_cost
);
assert_eq!(stats.n_samples, 50);
}
#[test]
fn stats_ess_positive_and_finite() {
let mut ctrl = make_mppi();
let x0 = [0.5_f64, 0.1_f64];
ctrl.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
let stats = ctrl.compute_stats();
assert!(stats.effective_sample_size > 0.0, "ESS must be positive");
assert!(
stats.effective_sample_size.is_finite(),
"ESS must be finite"
);
}
#[test]
fn warm_start_reduces_baseline_cost() {
let ctrl_cold = make_mppi();
let mut ctrl_warm = make_mppi();
let x0 = [2.0_f64, 0.0_f64];
for _ in 0..5 {
ctrl_warm
.update(x0, integrator_dynamics, quadratic_cost)
.expect("warm update ok");
}
let warm_cost = ctrl_warm.baseline_cost(x0, integrator_dynamics, quadratic_cost);
let cold_cost = ctrl_cold.baseline_cost(x0, integrator_dynamics, quadratic_cost);
assert!(
warm_cost <= cold_cost,
"warm_cost={} should be ≤ cold_cost={}",
warm_cost,
cold_cost
);
}
#[test]
fn set_nominal_takes_effect() {
let mut ctrl = make_mppi();
let u_good: [[f64; 1]; 10] = [[-0.5]; 10];
ctrl.set_nominal(&u_good);
let nom = ctrl.nominal();
for h in 0..10 {
assert!(
(nom[h][0] - (-0.5_f64)).abs() < 1e-12,
"set_nominal not reflected at h={}",
h
);
}
}
#[test]
fn deterministic_with_same_seed() {
let x0 = [1.0_f64, 0.5_f64];
let mut ctrl1 = make_mppi();
let mut ctrl2 = make_mppi();
let u1 = ctrl1
.update(x0, integrator_dynamics, quadratic_cost)
.expect("ctrl1 update");
let u2 = ctrl2
.update(x0, integrator_dynamics, quadratic_cost)
.expect("ctrl2 update");
assert_eq!(u1, u2, "same seed must produce same output");
}
#[test]
fn baseline_cost_finite_and_nonneg() {
let ctrl = make_mppi();
let x0 = [1.0_f64, 0.5_f64];
let bc = ctrl.baseline_cost(x0, integrator_dynamics, quadratic_cost);
assert!(bc.is_finite(), "baseline cost must be finite, got {}", bc);
assert!(bc >= 0.0, "quadratic cost is non-negative, got {}", bc);
}
#[test]
fn output_respects_control_bounds() {
let config = MppiConfigBuilder::<f64, 2, 1>::new()
.temperature(1.0)
.sigma_uniform(2.0) .bounds([-0.3], [0.3])
.gamma(1.0)
.lcg_seed(77)
.build()
.expect("config");
let mut ctrl: Mppi<f64, 2, 1, 10, 30> = Mppi::new(config).expect("mppi");
let x0 = [5.0_f64, 0.0_f64];
let u0 = ctrl
.update(x0, integrator_dynamics, quadratic_cost)
.expect("update ok");
assert!(
u0[0] >= -0.3 - 1e-9 && u0[0] <= 0.3 + 1e-9,
"u0={} out of [-0.3, 0.3]",
u0[0]
);
}
#[test]
fn builder_default_produces_valid_config() {
let result = MppiConfigBuilder::<f64, 2, 1>::default().build();
assert!(
result.is_ok(),
"default builder should produce valid config"
);
}
#[test]
fn receding_horizon_reduces_state_norm() {
let mut ctrl = make_mppi();
let mut x = [3.0_f64, 0.0_f64];
let initial_norm = x[0] * x[0] + x[1] * x[1];
for _step in 0..15 {
let u = ctrl
.update(x, integrator_dynamics, quadratic_cost)
.expect("update ok");
x = integrator_dynamics(&x, &u);
}
let final_norm = x[0] * x[0] + x[1] * x[1];
assert!(
final_norm < initial_norm,
"state norm should decrease: initial={}, final={}",
initial_norm,
final_norm
);
}
}