use super::analysis::sparse_transfer_at_points;
use super::error::LtiError;
use super::state_space::convert::matrix_exponential;
use super::state_space::{
ContinuousStateSpace, DiscreteStateSpace, SparseContinuousStateSpace, SparseDiscreteStateSpace,
};
use super::util::validate_nonnegative_monotone_grid;
use crate::control::dense_ops::dense_mul;
use crate::sparse::compensated::CompensatedField;
use crate::sparse::matvec::SparseMatVec;
use alloc::vec::Vec;
use faer::complex::Complex;
use faer::{Mat, MatRef};
use faer_traits::RealField;
use faer_traits::ext::ComplexFieldExt;
use num_traits::{Float, Zero};
#[derive(Clone, Debug, PartialEq)]
pub struct SampledResponse<X, T> {
pub sample_points: Vec<X>,
pub values: Vec<Mat<T>>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ContinuousImpulseResponse<R, T> {
pub sample_times: Vec<R>,
pub direct_feedthrough: Mat<T>,
pub values: Vec<Mat<T>>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ContinuousSimulation<R, T> {
pub sample_times: Vec<R>,
pub inputs: Mat<T>,
pub states: Mat<T>,
pub outputs: Mat<T>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DiscreteSimulation<T> {
pub inputs: Mat<T>,
pub states: Mat<T>,
pub outputs: Mat<T>,
}
impl<T> ContinuousStateSpace<T>
where
T: CompensatedField,
T::Real: Float + RealField,
{
pub fn impulse_response(
&self,
sample_times: &[T::Real],
) -> Result<ContinuousImpulseResponse<T::Real, T>, LtiError> {
validate_nonnegative_grid(sample_times, "continuous impulse response")?;
let mut values = Vec::with_capacity(sample_times.len());
for &time in sample_times {
let scaled_a = Mat::from_fn(self.nstates(), self.nstates(), |row, col| {
self.a()[(row, col)].mul_real(time)
});
let exp_at = matrix_exponential(scaled_a.as_ref())?;
let value = dense_mul(self.c(), dense_mul(exp_at.as_ref(), self.b()).as_ref());
values.push(value);
}
Ok(ContinuousImpulseResponse {
sample_times: sample_times.to_vec(),
direct_feedthrough: self.d().to_owned(),
values,
})
}
pub fn step_response(
&self,
sample_times: &[T::Real],
) -> Result<SampledResponse<T::Real, T>, LtiError> {
validate_nonnegative_monotone_grid(sample_times, "continuous step response")?;
Ok(SampledResponse {
sample_points: sample_times.to_vec(),
values: dense_continuous_step_blocks(self, sample_times)?,
})
}
pub fn frequency_response(
&self,
angular_frequencies: &[T::Real],
) -> Result<SampledResponse<T::Real, Complex<T::Real>>, LtiError> {
validate_finite_grid(angular_frequencies, "continuous frequency response")?;
let mut values = Vec::with_capacity(angular_frequencies.len());
for &omega in angular_frequencies {
values.push(self.transfer_at(Complex::new(<T::Real as Zero>::zero(), omega))?);
}
Ok(SampledResponse {
sample_points: angular_frequencies.to_vec(),
values,
})
}
pub fn simulate_zoh(
&self,
x0: &[T],
sample_times: &[T::Real],
inputs: MatRef<'_, T>,
) -> Result<ContinuousSimulation<T::Real, T>, LtiError> {
validate_state_vector(self.nstates(), x0, "continuous_simulation.x0")?;
validate_continuous_grid(sample_times, "continuous simulation")?;
if inputs.nrows() != self.ninputs() || inputs.ncols() != sample_times.len() {
return Err(LtiError::DimensionMismatch {
which: "continuous_simulation.inputs",
expected_nrows: self.ninputs(),
expected_ncols: sample_times.len(),
actual_nrows: inputs.nrows(),
actual_ncols: inputs.ncols(),
});
}
let inputs_owned = inputs.to_owned();
let mut states = Mat::<T>::zeros(self.nstates(), sample_times.len());
let mut outputs = Mat::<T>::zeros(self.noutputs(), sample_times.len());
let mut state = column_from_slice(x0);
write_column(states.as_mut(), 0, state.as_ref());
for k in 0..sample_times.len() {
let input = inputs_owned.as_ref().subcols(k, 1);
let output =
dense_mul(self.c(), state.as_ref()) + dense_mul(self.d(), input.as_ref()).as_ref();
write_column(outputs.as_mut(), k, output.as_ref());
if k + 1 < sample_times.len() {
let dt = sample_times[k + 1] - sample_times[k];
let (ad, bd) = continuous_interval_maps(self, dt)?;
state = dense_mul(ad.as_ref(), state.as_ref())
+ dense_mul(bd.as_ref(), input.as_ref()).as_ref();
write_column(states.as_mut(), k + 1, state.as_ref());
}
}
Ok(ContinuousSimulation {
sample_times: sample_times.to_vec(),
inputs: inputs_owned,
states,
outputs,
})
}
}
impl<T> SparseContinuousStateSpace<T>
where
T: CompensatedField,
T::Real: Float + RealField,
{
pub fn frequency_response(
&self,
angular_frequencies: &[T::Real],
) -> Result<SampledResponse<T::Real, Complex<T::Real>>, LtiError> {
validate_finite_grid(angular_frequencies, "sparse continuous frequency response")?;
let points = angular_frequencies
.iter()
.map(|&omega| Complex::new(<T::Real as Zero>::zero(), omega))
.collect::<Vec<_>>();
let values = sparse_transfer_at_points(self.a(), self.b(), self.c(), self.d(), &points)?;
Ok(SampledResponse {
sample_points: angular_frequencies.to_vec(),
values,
})
}
}
impl<T> DiscreteStateSpace<T>
where
T: CompensatedField,
T::Real: Float + RealField,
{
pub fn impulse_response(&self, n_steps: usize) -> SampledResponse<usize, T> {
SampledResponse {
sample_points: (0..n_steps).collect(),
values: dense_discrete_markov_blocks(self, n_steps),
}
}
pub fn step_response(&self, n_steps: usize) -> SampledResponse<usize, T> {
SampledResponse {
sample_points: (0..n_steps).collect(),
values: dense_discrete_step_blocks(self, n_steps),
}
}
pub fn frequency_response(
&self,
angular_frequencies: &[T::Real],
) -> Result<SampledResponse<T::Real, Complex<T::Real>>, LtiError> {
validate_finite_grid(angular_frequencies, "discrete frequency response")?;
let dt = self.sample_time();
let mut values = Vec::with_capacity(angular_frequencies.len());
for &omega in angular_frequencies {
let phase = omega * dt;
let point = Complex::new(phase.cos(), phase.sin());
values.push(self.transfer_at(point)?);
}
Ok(SampledResponse {
sample_points: angular_frequencies.to_vec(),
values,
})
}
pub fn simulate(
&self,
x0: &[T],
inputs: MatRef<'_, T>,
) -> Result<DiscreteSimulation<T>, LtiError> {
validate_state_vector(self.nstates(), x0, "discrete_simulation.x0")?;
if inputs.nrows() != self.ninputs() {
return Err(LtiError::DimensionMismatch {
which: "discrete_simulation.inputs",
expected_nrows: self.ninputs(),
expected_ncols: inputs.ncols(),
actual_nrows: inputs.nrows(),
actual_ncols: inputs.ncols(),
});
}
let inputs_owned = inputs.to_owned();
let mut states = Mat::<T>::zeros(self.nstates(), inputs.ncols() + 1);
let mut outputs = Mat::<T>::zeros(self.noutputs(), inputs.ncols());
let mut state = column_from_slice(x0);
write_column(states.as_mut(), 0, state.as_ref());
for k in 0..inputs_owned.ncols() {
let input = inputs_owned.as_ref().subcols(k, 1);
let output =
dense_mul(self.c(), state.as_ref()) + dense_mul(self.d(), input.as_ref()).as_ref();
write_column(outputs.as_mut(), k, output.as_ref());
state =
dense_mul(self.a(), state.as_ref()) + dense_mul(self.b(), input.as_ref()).as_ref();
write_column(states.as_mut(), k + 1, state.as_ref());
}
Ok(DiscreteSimulation {
inputs: inputs_owned,
states,
outputs,
})
}
}
impl<T> SparseDiscreteStateSpace<T>
where
T: CompensatedField,
T::Real: Float + RealField,
{
pub fn impulse_response(&self, n_steps: usize) -> SampledResponse<usize, T> {
SampledResponse {
sample_points: (0..n_steps).collect(),
values: sparse_discrete_markov_blocks(self, n_steps),
}
}
pub fn step_response(&self, n_steps: usize) -> SampledResponse<usize, T> {
SampledResponse {
sample_points: (0..n_steps).collect(),
values: sparse_discrete_step_blocks(self, n_steps),
}
}
pub fn frequency_response(
&self,
angular_frequencies: &[T::Real],
) -> Result<SampledResponse<T::Real, Complex<T::Real>>, LtiError> {
validate_finite_grid(angular_frequencies, "sparse discrete frequency response")?;
let dt = self.sample_time();
let points = angular_frequencies
.iter()
.map(|&omega| {
let phase = omega * dt;
Complex::new(phase.cos(), phase.sin())
})
.collect::<Vec<_>>();
let values = sparse_transfer_at_points(self.a(), self.b(), self.c(), self.d(), &points)?;
Ok(SampledResponse {
sample_points: angular_frequencies.to_vec(),
values,
})
}
pub fn simulate(
&self,
x0: &[T],
inputs: MatRef<'_, T>,
) -> Result<DiscreteSimulation<T>, LtiError> {
validate_state_vector(self.nstates(), x0, "sparse_discrete_simulation.x0")?;
if inputs.nrows() != self.ninputs() {
return Err(LtiError::DimensionMismatch {
which: "sparse_discrete_simulation.inputs",
expected_nrows: self.ninputs(),
expected_ncols: inputs.ncols(),
actual_nrows: inputs.nrows(),
actual_ncols: inputs.ncols(),
});
}
let inputs_owned = inputs.to_owned();
let mut states = Mat::<T>::zeros(self.nstates(), inputs.ncols() + 1);
let mut outputs = Mat::<T>::zeros(self.noutputs(), inputs.ncols());
let mut state = x0.to_vec();
let mut ax = vec![T::zero(); self.nstates()];
write_column_from_slice(states.as_mut(), 0, &state);
for k in 0..inputs_owned.ncols() {
let input = inputs_owned.as_ref().subcols(k, 1);
let state_col = column_from_slice(&state);
let output = dense_mul(self.c(), state_col.as_ref())
+ dense_mul(self.d(), input.as_ref()).as_ref();
write_column(outputs.as_mut(), k, output.as_ref());
self.a().apply_compensated(&mut ax, &state);
let bu = dense_mul(self.b(), input.as_ref());
for row in 0..self.nstates() {
state[row] = ax[row] + bu[(row, 0)];
}
write_column_from_slice(states.as_mut(), k + 1, &state);
}
Ok(DiscreteSimulation {
inputs: inputs_owned,
states,
outputs,
})
}
}
fn write_column<T: Copy>(mut dst: faer::MatMut<'_, T>, col: usize, src: MatRef<'_, T>) {
assert_eq!(src.ncols(), 1);
assert_eq!(dst.nrows(), src.nrows());
for row in 0..src.nrows() {
dst[(row, col)] = src[(row, 0)];
}
}
fn write_column_from_slice<T: Copy>(mut dst: faer::MatMut<'_, T>, col: usize, src: &[T]) {
assert_eq!(dst.nrows(), src.len());
for (row, &value) in src.iter().enumerate() {
dst[(row, col)] = value;
}
}
fn column_from_slice<T: Copy>(values: &[T]) -> Mat<T> {
Mat::from_fn(values.len(), 1, |row, _| values[row])
}
fn dense_discrete_markov_blocks<T>(system: &DiscreteStateSpace<T>, n_steps: usize) -> Vec<Mat<T>>
where
T: CompensatedField,
T::Real: Float,
{
if n_steps == 0 {
return Vec::new();
}
let mut blocks = Vec::with_capacity(n_steps);
blocks.push(system.d().to_owned());
if n_steps == 1 {
return blocks;
}
let mut state = system.b().to_owned();
for _ in 1..n_steps {
blocks.push(dense_mul(system.c(), state.as_ref()));
state = dense_mul(system.a(), state.as_ref());
}
blocks
}
fn dense_discrete_step_blocks<T>(system: &DiscreteStateSpace<T>, n_steps: usize) -> Vec<Mat<T>>
where
T: CompensatedField,
T::Real: Float,
{
let mut blocks = Vec::with_capacity(n_steps);
let mut state = Mat::<T>::zeros(system.nstates(), system.ninputs());
for _ in 0..n_steps {
blocks.push(dense_mul(system.c(), state.as_ref()) + system.d());
state = dense_mul(system.a(), state.as_ref()) + system.b();
}
blocks
}
fn sparse_discrete_markov_blocks<T>(
system: &SparseDiscreteStateSpace<T>,
n_steps: usize,
) -> Vec<Mat<T>>
where
T: CompensatedField,
T::Real: Float,
{
if n_steps == 0 {
return Vec::new();
}
let mut blocks = Vec::with_capacity(n_steps);
blocks.push(system.d().to_owned());
if n_steps == 1 {
return blocks;
}
let mut state = system.b().to_owned();
for _ in 1..n_steps {
blocks.push(dense_mul(system.c(), state.as_ref()));
state = sparse_apply_columns(system.a(), state.as_ref());
}
blocks
}
fn sparse_discrete_step_blocks<T>(
system: &SparseDiscreteStateSpace<T>,
n_steps: usize,
) -> Vec<Mat<T>>
where
T: CompensatedField,
T::Real: Float,
{
let mut blocks = Vec::with_capacity(n_steps);
let mut state = Mat::<T>::zeros(system.nstates(), system.ninputs());
for _ in 0..n_steps {
blocks.push(dense_mul(system.c(), state.as_ref()) + system.d());
state = sparse_apply_columns(system.a(), state.as_ref()) + system.b();
}
blocks
}
fn dense_continuous_step_blocks<T>(
system: &ContinuousStateSpace<T>,
sample_times: &[T::Real],
) -> Result<Vec<Mat<T>>, LtiError>
where
T: CompensatedField,
T::Real: Float + RealField,
{
let mut blocks = Vec::with_capacity(sample_times.len());
for &time in sample_times {
let (_, bd) = continuous_interval_maps(system, time)?;
blocks.push(dense_mul(system.c(), bd.as_ref()) + system.d());
}
Ok(blocks)
}
fn sparse_apply_columns<T>(a: impl SparseMatVec<T>, rhs: MatRef<'_, T>) -> Mat<T>
where
T: CompensatedField,
T::Real: Float,
{
assert_eq!(a.ncols(), rhs.nrows());
let mut out = Mat::<T>::zeros(a.nrows(), rhs.ncols());
let mut input = vec![T::zero(); a.ncols()];
let mut output = vec![T::zero(); a.nrows()];
for col in 0..rhs.ncols() {
for row in 0..a.ncols() {
input[row] = rhs[(row, col)];
}
a.apply_compensated(&mut output, &input);
for row in 0..a.nrows() {
out[(row, col)] = output[row];
}
}
out
}
fn validate_state_vector<T>(nstates: usize, x0: &[T], which: &'static str) -> Result<(), LtiError> {
if x0.len() == nstates {
Ok(())
} else {
Err(LtiError::DimensionMismatch {
which,
expected_nrows: nstates,
expected_ncols: 1,
actual_nrows: x0.len(),
actual_ncols: 1,
})
}
}
fn validate_nonnegative_grid<R: Float>(
sample_points: &[R],
which: &'static str,
) -> Result<(), LtiError> {
for &sample in sample_points {
if !sample.is_finite() || sample < R::zero() {
return Err(LtiError::InvalidSamplePoint { which });
}
}
Ok(())
}
fn validate_continuous_grid<R: Float>(
sample_points: &[R],
which: &'static str,
) -> Result<(), LtiError> {
if sample_points.is_empty() {
return Err(LtiError::InvalidSampleGrid { which });
}
validate_nonnegative_grid(sample_points, which)?;
for window in sample_points.windows(2) {
if window[1] < window[0] {
return Err(LtiError::InvalidSampleGrid { which });
}
}
Ok(())
}
fn validate_finite_grid<R: Float>(
sample_points: &[R],
which: &'static str,
) -> Result<(), LtiError> {
for &sample in sample_points {
if !sample.is_finite() {
return Err(LtiError::InvalidSamplePoint { which });
}
}
Ok(())
}
fn continuous_interval_maps<T>(
system: &ContinuousStateSpace<T>,
dt: T::Real,
) -> Result<(Mat<T>, Mat<T>), LtiError>
where
T: CompensatedField,
T::Real: Float + RealField,
{
let n = system.nstates();
let m = system.ninputs();
let size = n + m;
let mut lifted = Mat::<T>::zeros(size, size);
for row in 0..n {
for col in 0..n {
lifted[(row, col)] = system.a()[(row, col)].mul_real(dt);
}
}
for row in 0..n {
for col in 0..m {
lifted[(row, n + col)] = system.b()[(row, col)].mul_real(dt);
}
}
let exp_lifted = matrix_exponential(lifted.as_ref())?;
let ad = Mat::from_fn(n, n, |row, col| exp_lifted[(row, col)]);
let bd = Mat::from_fn(n, m, |row, col| exp_lifted[(row, n + col)]);
Ok((ad, bd))
}
#[cfg(test)]
mod tests {
use super::{ContinuousImpulseResponse, SampledResponse};
use crate::control::lti::LtiError;
use crate::control::lti::state_space::{
ContinuousStateSpace, DiscreteStateSpace, SparseContinuousStateSpace,
SparseDiscreteStateSpace,
};
use faer::complex::Complex;
use faer::sparse::{SparseColMat, Triplet};
use faer::{Mat, MatRef};
use nalgebra::Normed;
fn assert_close_real(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
assert_eq!(lhs.nrows(), rhs.nrows());
assert_eq!(lhs.ncols(), rhs.ncols());
for col in 0..lhs.ncols() {
for row in 0..lhs.nrows() {
let err = (lhs[(row, col)] - rhs[(row, col)]).abs();
assert!(
err <= tol,
"entry ({row}, {col}) differs: lhs={}, rhs={}, err={err}, tol={tol}",
lhs[(row, col)],
rhs[(row, col)],
);
}
}
}
fn assert_close_complex(
lhs: MatRef<'_, Complex<f64>>,
rhs: MatRef<'_, Complex<f64>>,
tol: f64,
) {
assert_eq!(lhs.nrows(), rhs.nrows());
assert_eq!(lhs.ncols(), rhs.ncols());
for col in 0..lhs.ncols() {
for row in 0..lhs.nrows() {
let err = (lhs[(row, col)] - rhs[(row, col)]).norm();
assert!(
err <= tol,
"entry ({row}, {col}) differs: lhs={:?}, rhs={:?}, err={err}, tol={tol}",
lhs[(row, col)],
rhs[(row, col)],
);
}
}
}
#[test]
fn continuous_impulse_response_matches_first_order_closed_form() {
let sys = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
)
.unwrap();
let response: ContinuousImpulseResponse<f64, f64> =
sys.impulse_response(&[0.0, 1.0]).unwrap();
assert_close_real(
response.direct_feedthrough.as_ref(),
Mat::from_fn(1, 1, |_, _| 5.0).as_ref(),
1.0e-12,
);
assert_close_real(
response.values[0].as_ref(),
Mat::from_fn(1, 1, |_, _| 12.0).as_ref(),
1.0e-12,
);
assert_close_real(
response.values[1].as_ref(),
Mat::from_fn(1, 1, |_, _| 12.0 * (-2.0f64).exp()).as_ref(),
1.0e-12,
);
}
#[test]
fn continuous_step_response_matches_first_order_closed_form() {
let sys = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
)
.unwrap();
let response: SampledResponse<f64, f64> = sys.step_response(&[0.0, 1.0]).unwrap();
assert_close_real(
response.values[0].as_ref(),
Mat::from_fn(1, 1, |_, _| 5.0).as_ref(),
1.0e-12,
);
assert_close_real(
response.values[1].as_ref(),
Mat::from_fn(1, 1, |_, _| 5.0 + 6.0 * (1.0 - (-2.0f64).exp())).as_ref(),
1.0e-12,
);
}
#[test]
fn continuous_step_response_uses_absolute_sample_times() {
let sys = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -1.0),
Mat::from_fn(1, 1, |_, _| 1.0),
Mat::from_fn(1, 1, |_, _| 1.0),
Mat::from_fn(1, 1, |_, _| 0.0),
)
.unwrap();
let response: SampledResponse<f64, f64> = sys.step_response(&[1.0, 2.0]).unwrap();
assert_close_real(
response.values[0].as_ref(),
Mat::from_fn(1, 1, |_, _| 1.0 - (-1.0f64).exp()).as_ref(),
1.0e-12,
);
assert_close_real(
response.values[1].as_ref(),
Mat::from_fn(1, 1, |_, _| 1.0 - (-2.0f64).exp()).as_ref(),
1.0e-12,
);
}
#[test]
fn continuous_step_response_rejects_decreasing_time_grid() {
let sys = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -1.0),
Mat::from_fn(1, 1, |_, _| 1.0),
Mat::from_fn(1, 1, |_, _| 1.0),
Mat::from_fn(1, 1, |_, _| 0.0),
)
.unwrap();
assert!(matches!(
sys.step_response(&[1.0, 0.5]),
Err(LtiError::InvalidSampleGrid {
which: "continuous step response"
})
));
}
#[test]
fn discrete_impulse_and_step_responses_match_recurrence() {
let sys = DiscreteStateSpace::new(
Mat::from_fn(1, 1, |_, _| 0.5),
Mat::from_fn(1, 1, |_, _| 2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
0.1,
)
.unwrap();
let impulse = sys.impulse_response(4);
let expected_impulse = [4.0, 6.0, 3.0, 1.5];
for (matrix, expected) in impulse.values.iter().zip(expected_impulse) {
assert_close_real(
matrix.as_ref(),
Mat::from_fn(1, 1, |_, _| expected).as_ref(),
1.0e-12,
);
}
let step = sys.step_response(4);
let expected_step = [4.0, 10.0, 13.0, 14.5];
for (matrix, expected) in step.values.iter().zip(expected_step) {
assert_close_real(
matrix.as_ref(),
Mat::from_fn(1, 1, |_, _| expected).as_ref(),
1.0e-12,
);
}
}
#[test]
fn discrete_mimo_impulse_response_returns_output_by_input_blocks() {
let sys = DiscreteStateSpace::new(
Mat::from_fn(2, 2, |row, col| match (row, col) {
(0, 0) => 2.0,
(1, 1) => 3.0,
_ => 0.0,
}),
Mat::from_fn(2, 2, |row, col| if row == col { 1.0 } else { 0.0 }),
Mat::from_fn(2, 2, |row, col| match (row, col) {
(0, 0) => 4.0,
(0, 1) => 5.0,
(1, 0) => 6.0,
(1, 1) => 7.0,
_ => unreachable!(),
}),
Mat::from_fn(2, 2, |row, col| match (row, col) {
(0, 0) => 8.0,
(0, 1) => 9.0,
(1, 0) => 10.0,
(1, 1) => 11.0,
_ => unreachable!(),
}),
0.5,
)
.unwrap();
let impulse = sys.impulse_response(3);
assert_eq!(impulse.values[0].nrows(), 2);
assert_eq!(impulse.values[0].ncols(), 2);
assert_close_real(impulse.values[0].as_ref(), sys.d(), 1.0e-12);
assert_close_real(impulse.values[1].as_ref(), sys.c(), 1.0e-12);
assert_close_real(
impulse.values[2].as_ref(),
Mat::from_fn(2, 2, |row, col| match (row, col) {
(0, 0) => 8.0,
(0, 1) => 15.0,
(1, 0) => 12.0,
(1, 1) => 21.0,
_ => unreachable!(),
})
.as_ref(),
1.0e-12,
);
}
#[test]
fn continuous_mimo_step_response_returns_output_by_input_blocks() {
let sys = ContinuousStateSpace::new(
Mat::zeros(2, 2),
Mat::from_fn(2, 2, |row, col| if row == col { 1.0 } else { 0.0 }),
Mat::from_fn(2, 2, |row, col| if row == col { 1.0 } else { 0.0 }),
Mat::zeros(2, 2),
)
.unwrap();
let step = sys.step_response(&[0.0, 1.0]).unwrap();
assert_eq!(step.values[0].nrows(), 2);
assert_eq!(step.values[0].ncols(), 2);
assert_close_real(step.values[0].as_ref(), Mat::zeros(2, 2).as_ref(), 1.0e-12);
assert_close_real(
step.values[1].as_ref(),
Mat::from_fn(2, 2, |row, col| if row == col { 1.0 } else { 0.0 }).as_ref(),
1.0e-12,
);
}
#[test]
fn discrete_simulation_matches_manual_recurrence() {
let sys = DiscreteStateSpace::new(
Mat::from_fn(1, 1, |_, _| 0.5),
Mat::from_fn(1, 1, |_, _| 2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
0.1,
)
.unwrap();
let inputs = Mat::from_fn(1, 2, |_, col| if col == 0 { 7.0 } else { 11.0 });
let sim = sys.simulate(&[5.0], inputs.as_ref()).unwrap();
assert_close_real(
sim.states.as_ref(),
Mat::from_fn(1, 3, |_, col| match col {
0 => 5.0,
1 => 16.5,
2 => 30.25,
_ => unreachable!(),
})
.as_ref(),
1.0e-12,
);
assert_close_real(
sim.outputs.as_ref(),
Mat::from_fn(1, 2, |_, col| if col == 0 { 43.0 } else { 93.5 }).as_ref(),
1.0e-12,
);
}
#[test]
fn continuous_zoh_simulation_matches_closed_form_scalar_case() {
let sys = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -1.0),
Mat::from_fn(1, 1, |_, _| 2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
)
.unwrap();
let sample_times = [0.0, 1.0, 2.0];
let inputs = Mat::from_fn(1, 3, |_, col| match col {
0 => 7.0,
1 => 11.0,
_ => 13.0,
});
let sim = sys
.simulate_zoh(&[5.0], &sample_times, inputs.as_ref())
.unwrap();
let x1 = (-1.0f64).exp() * 5.0 + 2.0 * (1.0 - (-1.0f64).exp()) * 7.0;
let x2 = (-1.0f64).exp() * x1 + 2.0 * (1.0 - (-1.0f64).exp()) * 11.0;
assert_close_real(
sim.states.as_ref(),
Mat::from_fn(1, 3, |_, col| match col {
0 => 5.0,
1 => x1,
2 => x2,
_ => unreachable!(),
})
.as_ref(),
1.0e-12,
);
assert_close_real(
sim.outputs.as_ref(),
Mat::from_fn(1, 3, |_, col| match col {
0 => 3.0 * 5.0 + 4.0 * 7.0,
1 => 3.0 * x1 + 4.0 * 11.0,
2 => 3.0 * x2 + 4.0 * 13.0,
_ => unreachable!(),
})
.as_ref(),
1.0e-12,
);
}
#[test]
fn frequency_response_matches_dc_gain_at_zero_frequency() {
let cont = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
)
.unwrap();
let disc = DiscreteStateSpace::new(
Mat::from_fn(1, 1, |_, _| 0.25),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
0.1,
)
.unwrap();
let cont_resp = cont.frequency_response(&[0.0]).unwrap();
let disc_resp = disc.frequency_response(&[0.0]).unwrap();
assert_close_complex(
cont_resp.values[0].as_ref(),
cont.dc_gain().unwrap().as_ref(),
1.0e-12,
);
assert_close_complex(
disc_resp.values[0].as_ref(),
disc.dc_gain().unwrap().as_ref(),
1.0e-12,
);
}
#[test]
fn sparse_discrete_simulation_matches_dense_reference() {
let dense = DiscreteStateSpace::new(
Mat::from_fn(2, 2, |row, col| match (row, col) {
(0, 0) => 0.5,
(0, 1) => 0.25,
(1, 1) => 0.75,
_ => 0.0,
}),
Mat::from_fn(2, 1, |row, _| if row == 0 { 2.0 } else { -1.0 }),
Mat::from_fn(1, 2, |_, col| if col == 0 { 3.0 } else { -2.0 }),
Mat::from_fn(1, 1, |_, _| 4.0),
0.1,
)
.unwrap();
let sparse_a = SparseColMat::<usize, f64>::try_new_from_triplets(
2,
2,
&[
Triplet::new(0, 0, 0.5),
Triplet::new(0, 1, 0.25),
Triplet::new(1, 1, 0.75),
],
)
.unwrap();
let sparse = SparseDiscreteStateSpace::new(
sparse_a,
Mat::from_fn(2, 1, |row, _| if row == 0 { 2.0 } else { -1.0 }),
Mat::from_fn(1, 2, |_, col| if col == 0 { 3.0 } else { -2.0 }),
Mat::from_fn(1, 1, |_, _| 4.0),
0.1,
)
.unwrap();
let inputs = Mat::from_fn(1, 3, |_, col| match col {
0 => 1.0,
1 => -2.0,
_ => 0.5,
});
let x0 = [0.25, -0.5];
let dense_sim = dense.simulate(&x0, inputs.as_ref()).unwrap();
let sparse_sim = sparse.simulate(&x0, inputs.as_ref()).unwrap();
assert_close_real(
sparse_sim.states.as_ref(),
dense_sim.states.as_ref(),
1.0e-12,
);
assert_close_real(
sparse_sim.outputs.as_ref(),
dense_sim.outputs.as_ref(),
1.0e-12,
);
}
#[test]
fn sparse_discrete_step_response_matches_dense_reference() {
let dense = DiscreteStateSpace::new(
Mat::from_fn(1, 1, |_, _| 0.5),
Mat::from_fn(1, 1, |_, _| 2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
0.1,
)
.unwrap();
let sparse_a =
SparseColMat::<usize, f64>::try_new_from_triplets(1, 1, &[Triplet::new(0, 0, 0.5)])
.unwrap();
let sparse = SparseDiscreteStateSpace::new(
sparse_a,
Mat::from_fn(1, 1, |_, _| 2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
0.1,
)
.unwrap();
let dense_resp = dense.step_response(4);
let sparse_resp = sparse.step_response(4);
for (sparse_value, dense_value) in sparse_resp.values.iter().zip(dense_resp.values.iter()) {
assert_close_real(sparse_value.as_ref(), dense_value.as_ref(), 1.0e-12);
}
}
#[test]
fn sparse_frequency_response_matches_dense_reference_at_zero_frequency() {
let dense_cont = ContinuousStateSpace::new(
Mat::from_fn(1, 1, |_, _| -2.0),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
)
.unwrap();
let sparse_cont = SparseContinuousStateSpace::new(
SparseColMat::<usize, f64>::try_new_from_triplets(1, 1, &[Triplet::new(0, 0, -2.0)])
.unwrap(),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
)
.unwrap();
let dense_disc = DiscreteStateSpace::new(
Mat::from_fn(1, 1, |_, _| 0.25),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
0.1,
)
.unwrap();
let sparse_disc = SparseDiscreteStateSpace::new(
SparseColMat::<usize, f64>::try_new_from_triplets(1, 1, &[Triplet::new(0, 0, 0.25)])
.unwrap(),
Mat::from_fn(1, 1, |_, _| 3.0),
Mat::from_fn(1, 1, |_, _| 4.0),
Mat::from_fn(1, 1, |_, _| 5.0),
0.1,
)
.unwrap();
let dense_cont_resp = dense_cont.frequency_response(&[0.0]).unwrap();
let sparse_cont_resp = sparse_cont.frequency_response(&[0.0]).unwrap();
assert_close_complex(
sparse_cont_resp.values[0].as_ref(),
dense_cont_resp.values[0].as_ref(),
1.0e-12,
);
let dense_disc_resp = dense_disc.frequency_response(&[0.0]).unwrap();
let sparse_disc_resp = sparse_disc.frequency_response(&[0.0]).unwrap();
assert_close_complex(
sparse_disc_resp.values[0].as_ref(),
dense_disc_resp.values[0].as_ref(),
1.0e-12,
);
}
}