use super::{
ContinuousSos, ContinuousStateSpace, ContinuousTransferFunction, ContinuousZpk, DiscreteSos,
DiscreteStateSpace, DiscreteTransferFunction, DiscreteZpk, LtiError,
util::{unwrap_phase_deg, validate_nonnegative_monotone_grid},
};
use alloc::vec::Vec;
use faer::complex::Complex;
use faer_traits::RealField;
use faer_traits::ext::ComplexFieldExt;
use num_traits::Float;
#[derive(Clone, Debug, PartialEq)]
pub struct BodeData<R> {
pub angular_frequencies: Vec<R>,
pub magnitude_db: Vec<R>,
pub phase_deg: Vec<R>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PoleZeroData<R> {
pub poles: Vec<Complex<R>>,
pub zeros: Vec<Complex<R>>,
}
impl<R> ContinuousTransferFunction<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
bode_from_evaluator(angular_frequencies, |omega| {
Ok(self.evaluate(Complex::new(R::zero(), omega)))
})
}
pub fn pole_zero_data(&self) -> Result<PoleZeroData<R>, LtiError> {
let zpk = self.to_zpk()?;
Ok(PoleZeroData {
poles: zpk.poles().to_vec(),
zeros: zpk.zeros().to_vec(),
})
}
}
impl<R> DiscreteTransferFunction<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
let dt = self.sample_time();
bode_from_evaluator(angular_frequencies, |omega| {
let phase = omega * dt;
Ok(self.evaluate(Complex::new(phase.cos(), phase.sin())))
})
}
pub fn pole_zero_data(&self) -> Result<PoleZeroData<R>, LtiError> {
let zpk = self.to_zpk()?;
Ok(PoleZeroData {
poles: zpk.poles().to_vec(),
zeros: zpk.zeros().to_vec(),
})
}
}
impl<R> ContinuousZpk<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
bode_from_evaluator(angular_frequencies, |omega| {
Ok(self.evaluate(Complex::new(R::zero(), omega)))
})
}
#[must_use]
pub fn pole_zero_data(&self) -> PoleZeroData<R> {
PoleZeroData {
poles: self.poles().to_vec(),
zeros: self.zeros().to_vec(),
}
}
}
impl<R> DiscreteZpk<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
let dt = self.sample_time();
bode_from_evaluator(angular_frequencies, |omega| {
let phase = omega * dt;
Ok(self.evaluate(Complex::new(phase.cos(), phase.sin())))
})
}
#[must_use]
pub fn pole_zero_data(&self) -> PoleZeroData<R> {
PoleZeroData {
poles: self.poles().to_vec(),
zeros: self.zeros().to_vec(),
}
}
}
impl<R> ContinuousSos<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
bode_from_evaluator(angular_frequencies, |omega| {
Ok(self.evaluate(Complex::new(R::zero(), omega)))
})
}
pub fn pole_zero_data(&self) -> Result<PoleZeroData<R>, LtiError> {
Ok(self.to_zpk()?.pole_zero_data())
}
}
impl<R> DiscreteSos<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
let dt = self.domain().sample_time();
bode_from_evaluator(angular_frequencies, |omega| {
let phase = omega * dt;
Ok(self.evaluate(Complex::new(phase.cos(), phase.sin())))
})
}
pub fn pole_zero_data(&self) -> Result<PoleZeroData<R>, LtiError> {
Ok(self.to_zpk()?.pole_zero_data())
}
}
impl<R> ContinuousStateSpace<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
if !self.is_siso() {
return Err(LtiError::NonSisoStateSpace {
ninputs: self.ninputs(),
noutputs: self.noutputs(),
});
}
bode_from_evaluator(angular_frequencies, |omega| {
self.transfer_at(Complex::new(R::zero(), omega))
.map(|value| value[(0, 0)])
})
}
pub fn pole_zero_data(&self) -> Result<PoleZeroData<R>, LtiError> {
let zpk = self.to_zpk()?;
Ok(PoleZeroData {
poles: zpk.poles().to_vec(),
zeros: zpk.zeros().to_vec(),
})
}
}
impl<R> DiscreteStateSpace<R>
where
R: Float + RealField,
{
pub fn bode_data(&self, angular_frequencies: &[R]) -> Result<BodeData<R>, LtiError> {
if !self.is_siso() {
return Err(LtiError::NonSisoStateSpace {
ninputs: self.ninputs(),
noutputs: self.noutputs(),
});
}
let dt = self.sample_time();
bode_from_evaluator(angular_frequencies, |omega| {
let phase = omega * dt;
self.transfer_at(Complex::new(phase.cos(), phase.sin()))
.map(|value| value[(0, 0)])
})
}
pub fn pole_zero_data(&self) -> Result<PoleZeroData<R>, LtiError> {
let zpk = self.to_zpk()?;
Ok(PoleZeroData {
poles: zpk.poles().to_vec(),
zeros: zpk.zeros().to_vec(),
})
}
}
fn bode_from_evaluator<R, F>(
angular_frequencies: &[R],
mut evaluate: F,
) -> Result<BodeData<R>, LtiError>
where
R: Float + RealField,
F: FnMut(R) -> Result<Complex<R>, LtiError>,
{
validate_nonnegative_monotone_grid(angular_frequencies, "bode_data")?;
let mut magnitude_db = Vec::with_capacity(angular_frequencies.len());
let mut phase_deg_wrapped = Vec::with_capacity(angular_frequencies.len());
for &omega in angular_frequencies {
let value = evaluate(omega)?;
magnitude_db.push(R::from(20.0).unwrap() * value.abs().log10());
phase_deg_wrapped.push(value.im.atan2(value.re).to_degrees());
}
Ok(BodeData {
angular_frequencies: angular_frequencies.to_vec(),
magnitude_db,
phase_deg: unwrap_phase_deg(&phase_deg_wrapped),
})
}
#[cfg(test)]
mod tests {
use super::{ContinuousTransferFunction, DiscreteTransferFunction};
use crate::control::lti::LtiError;
use faer::complex::Complex;
use nalgebra::ComplexField;
fn assert_close(lhs: f64, rhs: f64, tol: f64) {
let err = (lhs - rhs).abs();
assert!(err <= tol, "lhs={lhs}, rhs={rhs}, err={err}, tol={tol}");
}
#[test]
fn bode_data_matches_direct_transfer_function_evaluation() {
let tf = ContinuousTransferFunction::continuous(vec![1.0], vec![1.0, 1.0]).unwrap();
let omega = 2.0;
let bode = tf.bode_data(&[omega]).unwrap();
let value = tf.evaluate(Complex::new(0.0, omega));
assert_close(bode.magnitude_db[0], 20.0 * value.abs().log10(), 1.0e-12);
assert_close(
bode.phase_deg[0],
value.im.atan2(value.re).to_degrees(),
1.0e-12,
);
}
#[test]
fn bode_phase_is_unwrapped_across_frequency_grid() {
let tf =
ContinuousTransferFunction::continuous(vec![1.0], vec![1.0, 3.0, 3.0, 1.0]).unwrap();
let grid = vec![1.0e-2, 1.0e-1, 1.0, 10.0, 100.0];
let bode = tf.bode_data(&grid).unwrap();
assert!(bode.phase_deg.last().copied().unwrap() < -200.0);
assert!(
bode.phase_deg
.windows(2)
.all(|window| window[1] <= window[0])
);
}
#[test]
fn bode_data_rejects_unsorted_frequency_grid() {
let tf =
ContinuousTransferFunction::continuous(vec![1.0], vec![1.0, 3.0, 3.0, 1.0]).unwrap();
assert!(matches!(
tf.bode_data(&[100.0, 1.0]),
Err(LtiError::InvalidSampleGrid { which: "bode_data" })
));
}
#[test]
fn pole_zero_data_matches_zpk_conversion() {
let tf =
ContinuousTransferFunction::continuous(vec![1.0, 2.0], vec![1.0, 3.0, 2.0]).unwrap();
let direct = tf.pole_zero_data().unwrap();
let zpk = tf.to_zpk().unwrap();
assert_eq!(direct.poles, zpk.poles().to_vec());
assert_eq!(direct.zeros, zpk.zeros().to_vec());
}
#[test]
fn discrete_bode_uses_unit_circle_mapping() {
let tf = DiscreteTransferFunction::discrete(vec![1.0], vec![1.0, -0.5], 0.1).unwrap();
let omega = 3.0;
let bode = tf.bode_data(&[omega]).unwrap();
let phase: f64 = omega * 0.1;
let value = tf.evaluate(Complex::new(phase.cos(), phase.sin()));
assert_close(bode.magnitude_db[0], 20.0 * value.abs().log10(), 1.0e-12);
}
}