use crate::domains::matrix::Matrix;
use crate::prelude::*;
fn poly_coeffs_symbolic(expr: &Ex, var: &Ex) -> Option<Vec<Ex>> {
let var_id = expr.checked_id(var);
let mut inner = expr.inner.write();
let ids =
crate::transforms::solve::symbolic_poly_coeffs(&mut inner.arena, expr.raw_id(), var_id)?;
drop(inner);
Some(ids.into_iter().map(|id| expr.wrap(id)).collect())
}
fn roots_with_multiplicity(p: &Ex, var: &Ex) -> Vec<Ex> {
if let Some(mut c) = poly_coeffs_symbolic(p, var) {
while c.len() > 1 && c.last().is_some_and(is_exact_zero) {
c.pop();
}
let n = c.len() - 1;
if n == 0 {
if !c.first().is_some_and(is_exact_zero) {
return Vec::new();
}
} else {
let ctx = p.context();
let lead = &c[n];
let companion = Matrix::from_fn(n, n, |i, j| {
if i + 1 < n {
if j == i + 1 { ctx.one() } else { ctx.zero() }
} else {
(-(&c[j] / lead)).eval()
}
});
if let Ok(ev) = companion.and_then(|m| m.eigenvals())
&& ev.len() == n
{
return ev;
}
}
}
p.solve_or_empty(var)
}
#[derive(Clone, Debug)]
pub struct StateSpace {
a: Matrix,
b: Matrix,
c: Matrix,
d: Matrix,
}
impl StateSpace {
fn ctx(&self) -> crate::api::context::Context {
self.a.get(0, 0).context()
}
pub fn new(a: Matrix, b: Matrix, c: Matrix, d: Matrix) -> Result<Self, SymplexError> {
let bad = |reason: String| Err(SymplexError::invalid_argument("StateSpace::new", reason));
let n = a.nrows();
if a.ncols() != n {
return bad(format!("A must be square, got {}×{}", n, a.ncols()));
}
if b.nrows() != n {
return bad(format!(
"B must have {n} rows (same as A), got {}",
b.nrows()
));
}
if c.ncols() != n {
return bad(format!(
"C must have {n} columns (same as A), got {}",
c.ncols()
));
}
let (m, p) = (b.ncols(), c.nrows());
if d.shape() != (p, m) {
return bad(format!(
"D must be {p}×{m} (rows of C × columns of B), got {}×{}",
d.nrows(),
d.ncols()
));
}
Ok(StateSpace { a, b, c, d })
}
pub fn a(&self) -> &Matrix {
&self.a
}
pub fn b(&self) -> &Matrix {
&self.b
}
pub fn c(&self) -> &Matrix {
&self.c
}
pub fn d(&self) -> &Matrix {
&self.d
}
pub fn num_states(&self) -> usize {
self.a.nrows()
}
pub fn num_inputs(&self) -> usize {
self.b.ncols()
}
pub fn num_outputs(&self) -> usize {
self.c.nrows()
}
pub fn poles(&self) -> Vec<Ex> {
self.a.eigenvals().unwrap_or_default()
}
pub fn char_poly(&self, s: &Ex) -> Ex {
self.try_char_poly(s).unwrap_or_else(|_| self.ctx().nan())
}
pub fn try_char_poly(&self, s: &Ex) -> Result<Ex, SymplexError> {
let n = self.num_states();
let si = Matrix::identity(&self.ctx(), n)?.scale(s);
si.sub(&self.a)?.det()
}
pub fn to_transfer_function(&self, s: &Ex) -> Result<TransferFunction, SymplexError> {
if self.num_inputs() != 1 || self.num_outputs() != 1 {
return Err(SymplexError::InvalidArgument {
operation: "StateSpace::to_transfer_function",
reason: format!(
"requires a SISO system, got {} input(s) and {} output(s)",
self.num_inputs(),
self.num_outputs()
),
});
}
let n = self.num_states();
let si_minus_a = Matrix::identity(&self.ctx(), n)?.scale(s).sub(&self.a)?;
let den = si_minus_a.det()?.expand();
let adj = si_minus_a.adjugate()?;
let c_adj_b = self.c.matmul(&adj)?.matmul(&self.b)?;
let num = (c_adj_b.get(0, 0) + &(self.d.get(0, 0) * &den)).expand();
Ok(TransferFunction::new(num, den, s.clone()))
}
pub fn controllability_matrix(&self) -> Result<Matrix, SymplexError> {
let n = self.num_states();
let mut cols: Vec<Matrix> = vec![self.b.clone()];
let mut ab = self.a.matmul(&self.b)?;
for _ in 1..n {
cols.push(ab.clone());
ab = self.a.matmul(&ab)?;
}
let refs: Vec<&Matrix> = cols.iter().collect();
Matrix::hstack(&refs)
}
pub fn observability_matrix(&self) -> Result<Matrix, SymplexError> {
let n = self.num_states();
let mut rows: Vec<Matrix> = vec![self.c.clone()];
let mut ca = self.c.matmul(&self.a)?;
for _ in 1..n {
rows.push(ca.clone());
ca = ca.matmul(&self.a)?;
}
let refs: Vec<&Matrix> = rows.iter().collect();
Matrix::vstack(&refs)
}
pub fn is_controllable(&self) -> bool {
self.controllability_matrix()
.is_ok_and(|m| m.rank() == self.num_states())
}
pub fn is_observable(&self) -> bool {
self.observability_matrix()
.is_ok_and(|m| m.rank() == self.num_states())
}
pub fn is_stable(&self) -> Option<bool> {
let poles = self.poles();
let mut undecided = poles.len() < self.num_states();
for pole in &poles {
if let Ok(val) = pole.eval_f64() {
if val >= 0.0 {
return Some(false);
}
} else if let Ok(z) = pole.eval_complex64() {
if z.re >= 0.0 {
return Some(false);
}
} else {
undecided = true;
}
}
if !undecided {
return Some(true);
}
let s = self.a.fresh_symbol("s");
let cp = self.try_char_poly(&s).ok()?.expand();
let mut coeffs = poly_coeffs_symbolic(&cp, &s)?;
coeffs.reverse();
is_routh_stable(&coeffs)
}
pub fn discretize_zoh(&self, dt: &Ex, order: usize) -> Result<StateSpace, SymplexError> {
let n = self.num_states();
let a_dt = self.a.scale(dt);
let exp_a_dt = a_dt.exp_series(order)?;
let ctx = self.ctx();
let mut term = Matrix::identity(&ctx, n)?.scale(dt);
let mut b_sum = term.clone();
for k in 2..=order {
let step = dt * &ctx.rational(1, i64::try_from(k).unwrap_or(i64::MAX));
term = term.matmul(&self.a)?.scale(&step);
b_sum = b_sum.add(&term)?;
}
let b_d = b_sum.matmul(&self.b)?;
Ok(StateSpace {
a: exp_a_dt,
b: b_d,
c: self.c.clone(),
d: self.d.clone(),
})
}
pub fn riccati_residual(
&self,
p: &Matrix,
q: &Matrix,
r: &Matrix,
) -> Result<Matrix, SymplexError> {
let at = self.a.transpose();
let r_inv = r.inv()?;
let bt = self.b.transpose();
let term1 = at.matmul(p)?; let term2 = p.matmul(&self.a)?; let term3 = p.matmul(&self.b)?.matmul(&r_inv)?.matmul(&bt)?.matmul(p)?;
term1.add(&term2)?.sub(&term3)?.add(q)
}
pub fn ackermann(&self, desired_poles: &[Ex]) -> Result<Matrix, SymplexError> {
if self.num_inputs() != 1 {
return Err(SymplexError::InvalidArgument {
operation: "StateSpace::ackermann",
reason: format!(
"requires a single-input system, got {} inputs",
self.num_inputs()
),
});
}
let n = self.num_states();
if desired_poles.len() != n {
return Err(SymplexError::InvalidArgument {
operation: "StateSpace::ackermann",
reason: format!("need {n} desired poles, got {}", desired_poles.len()),
});
}
let ctrb = self.controllability_matrix()?;
if ctrb.rank() != n {
return Err(SymplexError::ComputationFailed {
operation: "StateSpace::ackermann",
reason: "system is not controllable".into(),
});
}
let ctrb_inv = ctrb.inv()?;
let mut poly_coeffs: Vec<Ex> = vec![self.ctx().int(1)];
for pole in desired_poles {
let neg_pole = -(pole.clone());
let prev = poly_coeffs;
poly_coeffs = vec![self.ctx().int(0); prev.len() + 1];
for (i, c) in prev.into_iter().enumerate() {
let c_neg_pole = &c * &neg_pole;
poly_coeffs[i] = poly_coeffs[i].clone() + c;
poly_coeffs[i + 1] = poly_coeffs[i + 1].clone() + c_neg_pole;
}
}
let mut p_a = Matrix::zeros(&self.ctx(), n, n)?;
for (i, coeff) in poly_coeffs.iter().enumerate() {
let power = (poly_coeffs.len() - 1 - i) as u32;
let a_power = self.a.powi(power)?;
p_a = p_a.add(&a_power.scale(coeff))?;
}
let last_row: Vec<Ex> = (0..n).map(|j| ctrb_inv.get(n - 1, j).clone()).collect();
let last_row_mat = Matrix::new(vec![last_row])?;
last_row_mat.matmul(&p_a) }
}
impl std::fmt::Display for StateSpace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"StateSpace(n={}, m={}, p={})\nA = {}\nB = {}\nC = {}\nD = {}",
self.num_states(),
self.num_inputs(),
self.num_outputs(),
self.a,
self.b,
self.c,
self.d
)
}
}
#[derive(Clone, Debug)]
pub struct TransferFunction {
pub num: Ex,
pub den: Ex,
pub var: Ex,
}
impl TransferFunction {
fn ctx(&self) -> crate::api::context::Context {
self.num.context()
}
pub fn new(num: Ex, den: Ex, var: Ex) -> Self {
TransferFunction { num, den, var }
}
pub fn from_coeffs(num_coeffs: &[i64], den_coeffs: &[i64], var: &Ex) -> Self {
let ctx = var.context();
let build_poly = |coeffs: &[i64]| -> Ex {
let mut result = ctx.int(0);
for (i, &c) in coeffs.iter().enumerate() {
if c != 0 {
let term = if i == 0 {
ctx.int(c)
} else {
&ctx.int(c) * &var.powi(i as i64)
};
result = &result + &term;
}
}
result
};
Self::new(build_poly(num_coeffs), build_poly(den_coeffs), var.clone())
}
pub fn poles(&self) -> Vec<Ex> {
roots_with_multiplicity(&self.den, &self.var)
}
pub fn zeros(&self) -> Vec<Ex> {
roots_with_multiplicity(&self.num, &self.var)
}
pub fn dc_gain(&self) -> Ex {
let ctx = self.ctx();
let zero = ctx.int(0);
let num_0 = self.num.subs(&self.var, &zero).eval();
let den_0 = self.den.subs(&self.var, &zero).eval();
if !(is_exact_zero(&num_0) && is_exact_zero(&den_0)) {
return &num_0 / &den_0;
}
if let (Some(n), Some(d)) = (
poly_coeffs_symbolic(&self.num, &self.var),
poly_coeffs_symbolic(&self.den, &self.var),
) {
let order = |c: &[Ex]| c.iter().position(|e| !is_exact_zero(e));
return match (order(&n), order(&d)) {
(None, Some(_)) => zero, (Some(i), Some(j)) if i > j => zero,
(Some(i), Some(j)) if i < j => ctx.complex_infinity(),
(Some(i), Some(j)) => (&n[i] / &d[j]).eval(),
(_, None) => ctx.nan(), };
}
(&self.num / &self.den).limit(&self.var, &zero)
}
fn other_in_var(&self, other: &TransferFunction) -> (Ex, Ex) {
if other.var == self.var {
(other.num.clone(), other.den.clone())
} else {
(
other.num.subs(&other.var, &self.var),
other.den.subs(&other.var, &self.var),
)
}
}
pub fn series(&self, other: &TransferFunction) -> TransferFunction {
let (num2, den2) = self.other_in_var(other);
TransferFunction {
num: &self.num * &num2,
den: &self.den * &den2,
var: self.var.clone(),
}
}
pub fn parallel(&self, other: &TransferFunction) -> TransferFunction {
let (num2, den2) = self.other_in_var(other);
TransferFunction {
num: &(&self.num * &den2) + &(&num2 * &self.den),
den: &self.den * &den2,
var: self.var.clone(),
}
}
pub fn feedback(&self) -> TransferFunction {
TransferFunction {
num: self.num.clone(),
den: &self.den + &self.num,
var: self.var.clone(),
}
}
pub fn feedback_with(&self, h: &TransferFunction) -> TransferFunction {
let (h_num, h_den) = self.other_in_var(h);
TransferFunction {
num: &self.num * &h_den,
den: &(&self.den * &h_den) + &(&self.num * &h_num),
var: self.var.clone(),
}
}
pub fn eval_at(&self, s_val: &Ex) -> Ex {
let num = self.num.subs(&self.var, s_val).eval();
let den = self.den.subs(&self.var, s_val).eval();
&num / &den
}
pub fn to_state_space(&self) -> Result<StateSpace, SymplexError> {
let ctx = self.ctx();
let inv = |reason: String| SymplexError::InvalidArgument {
operation: "TransferFunction::to_state_space",
reason,
};
let den = poly_coeffs_symbolic(&self.den, &self.var)
.ok_or_else(|| inv("denominator is not a polynomial in the Laplace variable".into()))?;
let num = poly_coeffs_symbolic(&self.num, &self.var)
.ok_or_else(|| inv("numerator is not a polynomial in the Laplace variable".into()))?;
let n = den.len().saturating_sub(1);
if n == 0 {
return Err(inv("denominator must have degree ≥ 1".into()));
}
if num.len() > den.len() {
return Err(inv(format!(
"improper transfer function: numerator degree {} > denominator degree {}",
num.len() - 1,
n
)));
}
let lead = &den[n];
let a_coef: Vec<Ex> = den.iter().take(n).map(|c| (c / lead).eval()).collect();
let mut b_coef: Vec<Ex> = num.iter().map(|c| (c / lead).eval()).collect();
b_coef.resize(n + 1, ctx.zero());
let bn = b_coef[n].clone();
let a = Matrix::from_fn(n, n, |i, j| {
if i + 1 < n {
if j == i + 1 { ctx.one() } else { ctx.zero() }
} else {
-&a_coef[j]
}
})?;
let b = Matrix::from_fn(n, 1, |i, _| if i + 1 == n { ctx.one() } else { ctx.zero() })?;
let c = Matrix::from_fn(1, n, |_, j| (&b_coef[j] - &(&a_coef[j] * &bn)).eval())?;
let d = Matrix::new(vec![vec![bn]])?;
StateSpace::new(a, b, c, d)
}
}
impl std::fmt::Display for TransferFunction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({}) / ({})", self.num, self.den)
}
}
pub fn routh_array(coeffs: &[Ex]) -> Result<Vec<Vec<Ex>>, SymplexError> {
routh_table(coeffs).map(|t| t.rows)
}
struct RouthTable {
rows: Vec<Vec<Ex>>,
degenerate: bool,
}
fn is_exact_zero(e: &Ex) -> bool {
crate::domains::matrix::ex_is_zero(e) == Some(true)
}
fn routh_table(coeffs: &[Ex]) -> Result<RouthTable, SymplexError> {
if coeffs.is_empty() {
return Err(SymplexError::invalid_argument(
"routh_array",
"coefficients must not be empty",
));
}
let first_nonzero = coeffs.iter().position(|c| !is_exact_zero(c));
let Some(start) = first_nonzero else {
return Err(SymplexError::invalid_argument(
"routh_array",
"the zero polynomial has no Routh array",
));
};
let coeffs = &coeffs[start..];
let lead = &coeffs[0];
let ctx = lead.context();
let n = coeffs.len();
if n == 1 {
return Ok(RouthTable {
rows: vec![vec![lead.clone()]],
degenerate: false,
});
}
let num_cols = n.div_ceil(2);
let mut row0: Vec<Ex> = Vec::with_capacity(num_cols);
for i in (0..n).step_by(2) {
row0.push(coeffs[i].clone());
}
while row0.len() < num_cols {
row0.push(coeffs[0].context().int(0));
}
let mut row1: Vec<Ex> = Vec::with_capacity(num_cols);
for i in (1..n).step_by(2) {
row1.push(coeffs[i].clone());
}
while row1.len() < num_cols {
row1.push(coeffs[0].context().int(0));
}
let mut table: Vec<Vec<Ex>> = vec![row0, row1];
let mut degenerate = false;
let epsilon = {
let used: Vec<String> = coeffs
.iter()
.flat_map(|c| c.free_symbols())
.map(|s| s.to_string())
.collect();
let mut name = String::from("ε");
while used.contains(&name) {
name.push('\'');
}
ctx.symbol(&name)
};
let total_rows = n;
for i in 2..=total_rows {
let last = i - 1;
if table[last].iter().all(is_exact_zero) {
degenerate = true;
let d = (n - 1 - (last - 1)) as i64;
let above = table[last - 1].clone();
let deriv: Vec<Ex> = above
.iter()
.enumerate()
.map(|(k, c)| (c * (d - 2 * k as i64)).eval())
.collect();
table[last] = deriv;
}
if is_exact_zero(&table[last][0]) {
degenerate = true;
table[last][0] = epsilon.clone();
}
if i == total_rows {
break;
}
let prev = &table[i - 1];
let prev2 = &table[i - 2];
let pivot = prev[0].clone();
let mut new_row: Vec<Ex> = Vec::with_capacity(num_cols);
for j in 0..(num_cols - 1) {
let prev2_j1 = if j + 1 < prev2.len() {
prev2[j + 1].clone()
} else {
coeffs[0].context().int(0)
};
let prev_j1 = if j + 1 < prev.len() {
prev[j + 1].clone()
} else {
coeffs[0].context().int(0)
};
let numerator = &(&pivot * &prev2_j1) - &(&prev2[0] * &prev_j1);
let mut entry = (&numerator / &pivot).eval();
if entry.contains(&epsilon) {
entry = entry.ratsimp();
}
new_row.push(entry);
}
if new_row.is_empty() {
new_row.push(coeffs[0].context().int(0));
}
table.push(new_row);
}
Ok(RouthTable {
rows: table,
degenerate,
})
}
pub fn is_routh_stable(coeffs: &[Ex]) -> Option<bool> {
let Ok(table) = routh_table(coeffs) else {
return Some(false);
};
let mut nonzero = coeffs.iter().skip_while(|c| is_exact_zero(c));
if let Some(lead) = nonzero.next()
&& crate::domains::matrix::ex_is_zero(lead) == Some(false)
&& let Some(lead_positive) = crate::domains::matrix::ex_is_positive(lead)
{
for c in nonzero {
let wrong_sign = crate::domains::matrix::ex_is_positive(c) == Some(!lead_positive)
&& crate::domains::matrix::ex_is_zero(c) == Some(false);
if is_exact_zero(c) || wrong_sign {
return Some(false);
}
}
}
if table.degenerate {
return Some(false);
}
let mut first_sign: Option<bool> = None;
for row in &table.rows {
let entry = &row[0];
let positive = crate::domains::matrix::ex_is_positive(entry)?;
if !positive && crate::domains::matrix::ex_is_zero(entry) != Some(false) {
return None;
}
match first_sign {
None => first_sign = Some(positive),
Some(s) if s != positive => return Some(false), Some(_) => {}
}
}
Some(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_space_basic_construction() {
let ctx = crate::api::context::Context::new();
let a = Matrix::new(vec![
vec![ctx.int(0), ctx.int(1)],
vec![ctx.int(-2), ctx.int(-3)],
])
.unwrap();
let b = Matrix::new(vec![vec![ctx.int(0)], vec![ctx.int(1)]]).unwrap();
let c = Matrix::new(vec![vec![ctx.int(1), ctx.int(0)]]).unwrap();
let d = Matrix::new(vec![vec![ctx.int(0)]]).unwrap();
let ss = StateSpace::new(a, b, c, d).unwrap();
assert_eq!(ss.num_states(), 2);
assert_eq!(ss.num_inputs(), 1);
assert_eq!(ss.num_outputs(), 1);
}
#[test]
fn transfer_function_basic_display() {
let ctx = crate::api::context::Context::new();
let s = ctx.symbol("s");
let tf = TransferFunction::new(ctx.int(1), &s * &s + &s * 3 + 2, s);
let display = format!("{tf}");
assert!(
display.contains("/"),
"Display should show fraction: {display}"
);
}
#[test]
fn routh_array_row_count() {
let ctx = crate::api::context::Context::new();
let coeffs = vec![ctx.int(1), ctx.int(3), ctx.int(2)];
let table = routh_array(&coeffs).unwrap();
assert_eq!(table.len(), 3);
}
}