use crate::core::matrix::{matmul, Matrix};
use crate::core::scalar::ControlScalar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinearizationError {
InvalidEpsilon,
InvalidTimestep,
ControllabilityCheckFailed,
}
impl core::fmt::Display for LinearizationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidEpsilon => write!(f, "eps must be strictly positive"),
Self::InvalidTimestep => write!(f, "dt must be strictly positive"),
Self::ControllabilityCheckFailed => {
write!(f, "Controllability check failed (zero column encountered)")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinearizedSystem<S: ControlScalar, const N: usize, const I: usize> {
pub a: Matrix<S, N, N>,
pub b: Matrix<S, N, I>,
pub c: Matrix<S, N, N>,
}
impl<S: ControlScalar, const N: usize, const I: usize> LinearizedSystem<S, N, I> {
pub fn new(a: Matrix<S, N, N>, b: Matrix<S, N, I>) -> Self {
Self {
a,
b,
c: Matrix::identity(),
}
}
pub fn discretize(
&self,
dt: S,
) -> Result<DiscreteLinearizedSystem<S, N, I>, LinearizationError> {
if dt <= S::ZERO {
return Err(LinearizationError::InvalidTimestep);
}
let eye = Matrix::<S, N, N>::identity();
let ad = eye.add_mat(&self.a.scale(dt));
let bd = self.b.scale(dt);
Ok(DiscreteLinearizedSystem { ad, bd, c: self.c })
}
}
#[derive(Debug, Clone, Copy)]
pub struct DiscreteLinearizedSystem<S: ControlScalar, const N: usize, const I: usize> {
pub ad: Matrix<S, N, N>,
pub bd: Matrix<S, N, I>,
pub c: Matrix<S, N, N>,
}
pub fn linearize<S: ControlScalar, const N: usize, const I: usize>(
f: impl Fn(&[S; N], &[S; I]) -> [S; N],
x0: &[S; N],
u0: &[S; I],
eps: S,
) -> Result<LinearizedSystem<S, N, I>, LinearizationError> {
if eps <= S::ZERO {
return Err(LinearizationError::InvalidEpsilon);
}
let two_eps = S::TWO * eps;
let mut a_data = [[S::ZERO; N]; N];
for j in 0..N {
let mut xp = *x0;
xp[j] += eps;
let fp = f(&xp, u0);
let mut xm = *x0;
xm[j] -= eps;
let fm = f(&xm, u0);
for i in 0..N {
a_data[i][j] = (fp[i] - fm[i]) / two_eps;
}
}
let mut b_data = [[S::ZERO; I]; N];
for j in 0..I {
let mut up = *u0;
up[j] += eps;
let fp = f(x0, &up);
let mut um = *u0;
um[j] -= eps;
let fm = f(x0, &um);
for i in 0..N {
b_data[i][j] = (fp[i] - fm[i]) / two_eps;
}
}
let a = Matrix { data: a_data };
let b = Matrix { data: b_data };
Ok(LinearizedSystem::new(a, b))
}
pub fn linearize_discrete<S: ControlScalar, const N: usize, const I: usize>(
f: impl Fn(&[S; N], &[S; I]) -> [S; N],
x0: &[S; N],
u0: &[S; I],
eps: S,
dt: S,
) -> Result<DiscreteLinearizedSystem<S, N, I>, LinearizationError> {
let sys = linearize(f, x0, u0, eps)?;
sys.discretize(dt)
}
pub fn controllability_rank<S: ControlScalar, const N: usize, const I: usize>(
sys: &LinearizedSystem<S, N, I>,
tol: S,
) -> Result<usize, LinearizationError> {
let max_cols = N * I; let power_count = N;
let mut basis = [[S::ZERO; N]; N]; let mut rank = 0usize;
let mut a_power = Matrix::<S, N, N>::identity();
'outer: for _k in 0..power_count {
let ab = matmul(&a_power, &sys.b);
for col in 0..I {
if rank >= N {
break 'outer; }
if rank * I + col >= max_cols {
break 'outer;
}
let mut v: [S; N] = core::array::from_fn(|row| ab.data[row][col]);
for basis_vec in basis.iter().take(rank) {
let dot = dot_n::<S, N>(&v, basis_vec);
for i in 0..N {
v[i] -= dot * basis_vec[i];
}
}
let norm = dot_n::<S, N>(&v, &v).sqrt();
if !norm.is_finite() {
return Err(LinearizationError::ControllabilityCheckFailed);
}
if norm > tol {
let inv_norm = S::ONE / norm;
for i in 0..N {
basis[rank][i] = v[i] * inv_norm;
}
rank += 1;
}
}
a_power = matmul(&a_power, &sys.a);
}
Ok(rank)
}
pub fn is_controllable<S: ControlScalar, const N: usize, const I: usize>(
sys: &LinearizedSystem<S, N, I>,
tol: S,
) -> Result<bool, LinearizationError> {
let r = controllability_rank(sys, tol)?;
Ok(r == N)
}
#[inline]
fn dot_n<S: ControlScalar, const N: usize>(a: &[S; N], b: &[S; N]) -> S {
let mut s = S::ZERO;
for i in 0..N {
s += a[i] * b[i];
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linearize_matches_analytic_jacobian_linear_system() {
let f = |x: &[f64; 2], u: &[f64; 1]| -> [f64; 2] { [-x[0] + u[0], -2.0 * x[1] + u[0]] };
let x0 = [0.5_f64, -0.3];
let u0 = [1.0_f64];
let eps = 1e-5_f64;
let sys = linearize(f, &x0, &u0, eps).expect("linearize should succeed");
assert!((sys.a.data[0][0] - (-1.0)).abs() < 1e-6, "A[0][0]");
assert!((sys.a.data[0][1]).abs() < 1e-6, "A[0][1]");
assert!((sys.a.data[1][0]).abs() < 1e-6, "A[1][0]");
assert!((sys.a.data[1][1] - (-2.0)).abs() < 1e-6, "A[1][1]");
assert!((sys.b.data[0][0] - 1.0).abs() < 1e-6, "B[0][0]");
assert!((sys.b.data[1][0] - 1.0).abs() < 1e-6, "B[1][0]");
}
#[test]
fn linearize_pendulum_at_upright() {
let g = 9.81_f64;
let l = 1.0_f64;
let m = 1.0_f64;
let f = move |x: &[f64; 2], u: &[f64; 1]| -> [f64; 2] {
let theta = x[0];
let theta_dot = x[1];
let tau = u[0];
let theta_ddot = -(g / l) * theta.sin() + tau / (m * l * l);
[theta_dot, theta_ddot]
};
let x0 = [0.0_f64, 0.0];
let u0 = [0.0_f64];
let eps = 1e-5_f64;
let sys = linearize(f, &x0, &u0, eps).expect("linearize ok");
assert!((sys.a.data[0][0]).abs() < 1e-5, "A[0][0] should be 0");
assert!((sys.a.data[0][1] - 1.0).abs() < 1e-5, "A[0][1] should be 1");
assert!(
(sys.a.data[1][0] - (-g / l)).abs() < 1e-4,
"A[1][0] should be -g/l={:.4}, got {:.4}",
-g / l,
sys.a.data[1][0]
);
assert!((sys.a.data[1][1]).abs() < 1e-5, "A[1][1] should be 0");
assert!((sys.b.data[0][0]).abs() < 1e-5, "B[0][0] should be 0");
assert!(
(sys.b.data[1][0] - 1.0 / (m * l * l)).abs() < 1e-5,
"B[1][0] should be 1/(m·l²)={:.4}, got {:.4}",
1.0 / (m * l * l),
sys.b.data[1][0]
);
}
#[test]
fn double_integrator_is_controllable() {
let f = |x: &[f64; 2], u: &[f64; 1]| -> [f64; 2] { [x[1], u[0]] };
let x0 = [0.0_f64, 0.0];
let u0 = [0.0_f64];
let sys = linearize(f, &x0, &u0, 1e-5).expect("ok");
let ctrl = is_controllable(&sys, 1e-8).expect("no error");
assert!(ctrl, "double integrator should be controllable");
}
#[test]
fn uncontrollable_system_detected() {
let f = |x: &[f64; 2], u: &[f64; 1]| -> [f64; 2] { [-x[0] + u[0], -x[1]] };
let x0 = [0.0_f64, 0.0];
let u0 = [0.0_f64];
let sys = linearize(f, &x0, &u0, 1e-5).expect("ok");
let ctrl = is_controllable(&sys, 1e-8).expect("no error");
assert!(!ctrl, "system with undriven mode should be uncontrollable");
}
#[test]
fn discrete_linearization_step_response() {
let f = |x: &[f64; 2], u: &[f64; 1]| -> [f64; 2] { [x[1], u[0]] };
let x0 = [0.0_f64, 0.0];
let u0 = [1.0_f64];
let dt = 0.01_f64;
let dsys = linearize_discrete(f, &x0, &u0, 1e-5, dt).expect("ok");
let mut x = [0.0_f64, 0.0];
let u = [1.0_f64];
for _ in 0..10 {
let x_new = [
dsys.ad.data[0][0] * x[0] + dsys.ad.data[0][1] * x[1] + dsys.bd.data[0][0] * u[0],
dsys.ad.data[1][0] * x[0] + dsys.ad.data[1][1] * x[1] + dsys.bd.data[1][0] * u[0],
];
x = x_new;
}
assert!(
(x[1] - 0.1).abs() < 0.01,
"velocity should be ≈0.1: got {}",
x[1]
);
assert!(x[0] > 0.0, "position should be positive: got {}", x[0]);
}
#[test]
fn negative_eps_returns_error() {
let f = |x: &[f64; 1], _u: &[f64; 1]| -> [f64; 1] { [-x[0]] };
let result = linearize(f, &[0.0], &[0.0], -1e-5);
assert!(
result == Err(LinearizationError::InvalidEpsilon),
"negative eps should return InvalidEpsilon"
);
let result2 = linearize(f, &[0.0], &[0.0], 0.0_f64);
assert!(
result2 == Err(LinearizationError::InvalidEpsilon),
"zero eps should return InvalidEpsilon"
);
}
#[test]
fn single_integrator_rank_one() {
let f = |_x: &[f64; 1], u: &[f64; 1]| -> [f64; 1] { [u[0]] };
let sys = linearize(f, &[0.0], &[0.0], 1e-5).expect("ok");
let rank = controllability_rank(&sys, 1e-8).expect("no error");
assert_eq!(rank, 1, "single integrator has rank 1");
}
}