#![allow(
clippy::needless_range_loop,
clippy::doc_overindented_list_items,
clippy::doc_lazy_continuation,
clippy::manual_memcpy
)]
use crate::core::scalar::ControlScalar;
use crate::sysid::arx::cholesky_solve_n;
use crate::sysid::SysIdError;
#[derive(Debug, Clone, Copy)]
pub struct ArmaxModel<S: ControlScalar, const NA: usize, const NB: usize, const NC: usize> {
pub a: [S; NA],
pub b: [S; NB],
pub c: [S; NC],
}
impl<S: ControlScalar, const NA: usize, const NB: usize, const NC: usize>
ArmaxModel<S, NA, NB, NC>
{
pub fn zeros() -> Self {
Self {
a: [S::ZERO; NA],
b: [S::ZERO; NB],
c: [S::ZERO; NC],
}
}
pub fn predict_one_step(&self, y_hist: &[S; NA], u_hist: &[S; NB], e_hist: &[S; NC]) -> S {
let mut y_hat = S::ZERO;
for i in 0..NA {
y_hat -= self.a[i] * y_hist[i];
}
for i in 0..NB {
y_hat += self.b[i] * u_hist[i];
}
for i in 0..NC {
y_hat += self.c[i] * e_hist[i];
}
y_hat
}
}
pub struct ELSIdentifier<
S: ControlScalar,
const NA: usize,
const NB: usize,
const NC: usize,
const P: usize,
> {
_phantom: core::marker::PhantomData<S>,
}
impl<S: ControlScalar, const NA: usize, const NB: usize, const NC: usize, const P: usize>
ELSIdentifier<S, NA, NB, NC, P>
{
const BUF_CAP: usize = 4096;
pub fn fit(
y: &[S],
u: &[S],
max_iter: usize,
tol: S,
) -> Result<ArmaxModel<S, NA, NB, NC>, SysIdError> {
let n = y.len();
if u.len() != n {
return Err(SysIdError::InvalidData);
}
for &v in y.iter().chain(u.iter()) {
if !v.is_finite() {
return Err(SysIdError::InvalidData);
}
}
let start = NA.max(NB).max(NC);
if n <= start {
return Err(SysIdError::InsufficientData);
}
if P == 0 {
return Ok(ArmaxModel::zeros());
}
let n_capped = n.min(Self::BUF_CAP);
let mut e_buf = [S::ZERO; 4096];
let mut theta_prev = [S::ZERO; P];
let mut theta_cur = [S::ZERO; P];
let mut converged = false;
for _iter in 0..max_iter {
let reg = S::from_f64(1e-10);
let mut ata = [[S::ZERO; P]; P];
for i in 0..P {
ata[i][i] = reg;
}
let mut aty = [S::ZERO; P];
for t in start..n_capped {
let mut phi = [S::ZERO; P];
for i in 0..NA {
phi[i] = -y[t - 1 - i];
}
for i in 0..NB {
if t > i {
phi[NA + i] = u[t - 1 - i];
}
}
for i in 0..NC {
let tidx = t as isize - 1 - i as isize;
if tidx >= 0 {
phi[NA + NB + i] = e_buf[tidx as usize];
}
}
let yt = y[t];
for i in 0..P {
for j in 0..P {
ata[i][j] += phi[i] * phi[j];
}
aty[i] += phi[i] * yt;
}
}
cholesky_solve_n::<S, P>(&mut ata, &mut aty)?;
theta_cur = aty;
for t in start..n_capped {
let mut phi = [S::ZERO; P];
for i in 0..NA {
phi[i] = -y[t - 1 - i];
}
for i in 0..NB {
if t > i {
phi[NA + i] = u[t - 1 - i];
}
}
for i in 0..NC {
let tidx = t as isize - 1 - i as isize;
if tidx >= 0 {
phi[NA + NB + i] = e_buf[tidx as usize];
}
}
let mut y_hat = S::ZERO;
for i in 0..P {
y_hat += phi[i] * theta_cur[i];
}
e_buf[t] = y[t] - y_hat;
}
let mut norm_sq = S::ZERO;
for i in 0..P {
let d = theta_cur[i] - theta_prev[i];
norm_sq += d * d;
}
if norm_sq.sqrt() < tol {
converged = true;
break;
}
theta_prev = theta_cur;
}
if !converged && max_iter > 1 {
return Err(SysIdError::NotConverged);
}
let mut model = ArmaxModel::zeros();
for i in 0..NA {
model.a[i] = theta_cur[i];
}
for i in 0..NB {
model.b[i] = theta_cur[NA + i];
}
for i in 0..NC {
model.c[i] = theta_cur[NA + NB + i];
}
Ok(model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sysid::arx::{fit_percent, ArxIdentifier};
fn generate_arx1_noiseless(n: usize) -> (heapless::Vec<f64, 4096>, heapless::Vec<f64, 4096>) {
let a_true = 0.7_f64;
let b_true = 0.3_f64;
let mut y: heapless::Vec<f64, 4096> = heapless::Vec::new();
let mut u: heapless::Vec<f64, 4096> = heapless::Vec::new();
let _ = y.push(0.0);
let _ = u.push(0.0);
for t in 1..n {
let ut = libm::sin(0.1 * t as f64) + libm::cos(0.07 * t as f64) * 0.5;
let yt = a_true * y[t - 1] + b_true * u[t - 1];
let _ = y.push(yt);
let _ = u.push(ut);
}
(y, u)
}
#[test]
fn els_nc0_matches_arx() {
let (y, u) = generate_arx1_noiseless(500);
let arx_model =
ArxIdentifier::<f64, 1, 1, 2, 1>::fit(y.as_slice(), u.as_slice()).expect("ARX fit");
let armax_model =
ELSIdentifier::<f64, 1, 1, 0, 2>::fit(y.as_slice(), u.as_slice(), 1, 1e-12)
.expect("ELS fit with NC=0");
let da = (armax_model.a[0] - arx_model.a[0]).abs();
let db = (armax_model.b[0] - arx_model.b[0]).abs();
assert!(da < 1e-8, "A coefficients differ by {da:.2e}");
assert!(db < 1e-8, "B coefficients differ by {db:.2e}");
}
#[test]
fn els_armax_fits_noiseless_arx_data() {
let (y, u) = generate_arx1_noiseless(500);
let model = ELSIdentifier::<f64, 1, 1, 1, 3>::fit(y.as_slice(), u.as_slice(), 20, 1e-10)
.expect("ELS fit");
let mut predicted: heapless::Vec<f64, 4096> = heapless::Vec::new();
let mut e_hist = [0.0_f64; 1];
let mut y_hist = [0.0_f64; 1];
let mut u_hist_buf = [0.0_f64; 1];
for t in 1..y.len() {
u_hist_buf[0] = u[t - 1];
let y_hat = model.predict_one_step(&y_hist, &u_hist_buf, &e_hist);
let e = y[t] - y_hat;
e_hist[0] = e;
y_hist[0] = y[t];
let _ = predicted.push(y_hat);
}
let fp = fit_percent(predicted.as_slice(), &y.as_slice()[1..]);
assert!(
fp > 99.0,
"ELS ARMAX FIT% should exceed 99% for noiseless data, got {fp:.2}%"
);
}
#[test]
fn armax_model_zeros_predict_returns_zero() {
let model = ArmaxModel::<f64, 2, 2, 1>::zeros();
let y_hist = [1.0_f64, 2.0];
let u_hist = [0.5_f64, 0.3];
let e_hist = [0.1_f64];
let pred = model.predict_one_step(&y_hist, &u_hist, &e_hist);
assert_eq!(pred, 0.0);
}
}