use super::FreqError;
use crate::core::scalar::ControlScalar;
use crate::core::transfer_fn::TransferFn;
use heapless::Vec as HVec;
fn eval_tf_at_freq<S: ControlScalar, const N: usize>(tf: &TransferFn<S, N>, omega: S) -> (S, S) {
let b = tf.b();
let a = tf.a();
let mut num_re = S::ZERO;
let mut num_im = S::ZERO;
for (k, &b_k) in b.iter().enumerate().take(N) {
let angle = -(omega * S::from_f64(k as f64));
let (sin_a, cos_a) = angle.sin_cos();
num_re += b_k * cos_a;
num_im += b_k * sin_a;
}
let mut den_re = S::ONE;
let mut den_im = S::ZERO;
for (k, &a_k) in a.iter().enumerate().take(N) {
let angle = -(omega * S::from_f64((k + 1) as f64));
let (sin_a, cos_a) = angle.sin_cos();
den_re += a_k * cos_a;
den_im += a_k * sin_a;
}
let den_mag_sq = den_re * den_re + den_im * den_im;
if den_mag_sq < S::EPSILON {
return (S::ZERO, S::ZERO);
}
let re = (num_re * den_re + num_im * den_im) / den_mag_sq;
let im = (num_im * den_re - num_re * den_im) / den_mag_sq;
(re, im)
}
#[derive(Debug, Clone, Copy)]
pub struct BodePoint<S: ControlScalar> {
pub omega: S,
pub magnitude_db: S,
pub phase_deg: S,
}
pub struct BodeData<S: ControlScalar, const N: usize> {
pub points: HVec<BodePoint<S>, N>,
}
impl<S: ControlScalar, const N: usize> BodeData<S, N> {
pub fn len(&self) -> usize {
self.points.len()
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
}
pub fn compute_bode<S: ControlScalar, const TF_ORDER: usize, const N: usize>(
tf: &TransferFn<S, TF_ORDER>,
omega_min: S,
omega_max: S,
) -> Result<BodeData<S, N>, FreqError> {
if N < 2 {
return Err(FreqError::InsufficientPoints);
}
if omega_min <= S::ZERO || omega_min >= omega_max {
return Err(FreqError::InvalidFrequencyRange);
}
let mut data: BodeData<S, N> = BodeData {
points: HVec::new(),
};
let ln_min = omega_min.ln();
let ln_max = omega_max.ln();
let ln_range = ln_max - ln_min;
let n_minus_one = S::from_f64((N - 1) as f64);
for i in 0..N {
let t = S::from_f64(i as f64) / n_minus_one;
let omega = (ln_min + t * ln_range).exp();
let (re, im) = eval_tf_at_freq(tf, omega);
let mag_sq = re * re + im * im;
let magnitude_db = if mag_sq > S::ZERO {
S::from_f64(20.0) * mag_sq.sqrt().log10()
} else {
S::from_f64(-120.0) };
let phase_deg = im.atan2(re) * S::from_f64(180.0 / core::f64::consts::PI);
let point = BodePoint {
omega,
magnitude_db,
phase_deg,
};
let _ = data.points.push(point);
}
Ok(data)
}
pub fn gain_crossover_frequency<S: ControlScalar, const N: usize>(
data: &BodeData<S, N>,
) -> Option<S> {
let pts = &data.points;
if pts.len() < 2 {
return None;
}
for i in 0..(pts.len() - 1) {
let m0 = pts[i].magnitude_db;
let m1 = pts[i + 1].magnitude_db;
if (m0 >= S::ZERO && m1 <= S::ZERO) || (m0 <= S::ZERO && m1 >= S::ZERO) {
let dm = m1 - m0;
if dm.abs() < S::EPSILON {
return Some(pts[i].omega);
}
let t = -m0 / dm;
let omega = pts[i].omega + t * (pts[i + 1].omega - pts[i].omega);
return Some(omega);
}
}
None
}
pub fn phase_crossover_frequency<S: ControlScalar, const N: usize>(
data: &BodeData<S, N>,
) -> Option<S> {
let pts = &data.points;
if pts.len() < 2 {
return None;
}
let neg180 = S::from_f64(-180.0);
for i in 0..(pts.len() - 1) {
let p0 = pts[i].phase_deg;
let p1 = pts[i + 1].phase_deg;
let above0 = p0 > neg180;
let above1 = p1 > neg180;
if above0 != above1 {
let dp = p1 - p0;
if dp.abs() < S::EPSILON {
return Some(pts[i].omega);
}
let t = (neg180 - p0) / dp;
let omega = pts[i].omega + t * (pts[i + 1].omega - pts[i].omega);
return Some(omega);
}
}
None
}
pub fn gain_margin<S: ControlScalar, const N: usize>(data: &BodeData<S, N>) -> Option<S> {
let phase_xover = phase_crossover_frequency(data)?;
let pts = &data.points;
for i in 0..(pts.len() - 1) {
if pts[i].omega <= phase_xover && phase_xover <= pts[i + 1].omega {
let t = if (pts[i + 1].omega - pts[i].omega).abs() < S::EPSILON {
S::ZERO
} else {
(phase_xover - pts[i].omega) / (pts[i + 1].omega - pts[i].omega)
};
let mag_db = pts[i].magnitude_db + t * (pts[i + 1].magnitude_db - pts[i].magnitude_db);
return Some(-mag_db);
}
}
None
}
pub fn phase_margin<S: ControlScalar, const N: usize>(data: &BodeData<S, N>) -> Option<S> {
let gain_xover = gain_crossover_frequency(data)?;
let pts = &data.points;
for i in 0..(pts.len() - 1) {
if pts[i].omega <= gain_xover && gain_xover <= pts[i + 1].omega {
let t = if (pts[i + 1].omega - pts[i].omega).abs() < S::EPSILON {
S::ZERO
} else {
(gain_xover - pts[i].omega) / (pts[i + 1].omega - pts[i].omega)
};
let phase_deg = pts[i].phase_deg + t * (pts[i + 1].phase_deg - pts[i].phase_deg);
return Some(S::from_f64(180.0) + phase_deg);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::transfer_fn::TransferFn;
#[test]
fn unity_gain_dc_bode() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let data = compute_bode::<f64, 1, 16>(&tf, 1e-3, 1.0).expect("bode ok");
let first = &data.points[0];
assert!(
first.magnitude_db.abs() < 0.5,
"Unity gain DC should be ~0 dB, got {} dB",
first.magnitude_db
);
}
#[test]
fn bode_point_count() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let data = compute_bode::<f64, 1, 32>(&tf, 1e-3, 1.0).expect("bode ok");
assert_eq!(data.len(), 32, "Should have exactly 32 Bode points");
}
#[test]
fn bode_invalid_range() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let result = compute_bode::<f64, 1, 8>(&tf, 10.0, 1.0);
assert!(
matches!(result, Err(FreqError::InvalidFrequencyRange)),
"Should return InvalidFrequencyRange"
);
}
#[test]
fn bode_insufficient_points() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let result = compute_bode::<f64, 1, 1>(&tf, 1e-3, 1.0);
assert!(
matches!(result, Err(FreqError::InsufficientPoints)),
"Should return InsufficientPoints"
);
}
#[test]
fn first_order_lowpass_phase_margin_positive() {
let alpha = 0.9_f64;
let b = [1.0 - alpha];
let a = [-alpha];
let tf = TransferFn::<f64, 1>::new(b, a);
let data = compute_bode::<f64, 1, 64>(&tf, 1e-3, core::f64::consts::PI).expect("bode ok");
if let Some(pm) = phase_margin(&data) {
assert!(
pm > 0.0,
"First-order LP should have positive phase margin, got {}°",
pm
);
}
}
#[test]
fn first_order_lowpass_gain_crossover() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.5]);
let data = compute_bode::<f64, 1, 64>(&tf, 1e-3, core::f64::consts::PI).expect("bode ok");
let _gcf = gain_crossover_frequency(&data);
let max_mag = data
.points
.iter()
.map(|p| p.magnitude_db)
.fold(f64::NEG_INFINITY, f64::max);
let min_mag = data
.points
.iter()
.map(|p| p.magnitude_db)
.fold(f64::INFINITY, f64::min);
assert!(max_mag > min_mag, "Magnitude should vary across frequency");
}
#[test]
fn unity_gain_zero_db() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let data = compute_bode::<f64, 1, 8>(&tf, 1e-4, 0.1).expect("bode ok");
for pt in data.points.iter() {
assert!(
pt.magnitude_db.abs() < 1e-6,
"Unity TF should be 0 dB everywhere, got {} at omega={}",
pt.magnitude_db,
pt.omega
);
}
}
}