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 NyquistPoint<S: ControlScalar> {
pub re: S,
pub im: S,
pub omega: S,
}
pub struct NyquistData<S: ControlScalar, const N: usize> {
pub points: HVec<NyquistPoint<S>, N>,
}
impl<S: ControlScalar, const N: usize> NyquistData<S, N> {
pub fn len(&self) -> usize {
self.points.len()
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
}
pub fn compute_nyquist<S: ControlScalar, const TF_ORDER: usize, const N: usize>(
tf: &TransferFn<S, TF_ORDER>,
omega_max: S,
) -> Result<NyquistData<S, N>, FreqError> {
if N < 2 {
return Err(FreqError::InsufficientPoints);
}
if omega_max <= S::ZERO {
return Err(FreqError::InvalidFrequencyRange);
}
let mut data: NyquistData<S, N> = NyquistData {
points: HVec::new(),
};
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 = t * omega_max;
let (re, im) = eval_tf_at_freq(tf, omega);
let _ = data.points.push(NyquistPoint { re, im, omega });
}
Ok(data)
}
pub fn encirclement_count<S: ControlScalar, const N: usize>(data: &NyquistData<S, N>) -> i32 {
let pts = &data.points;
if pts.len() < 2 {
return 0;
}
let mut total_angle = S::ZERO;
let critical_re = S::from_f64(-1.0);
let critical_im = S::ZERO;
for i in 0..(pts.len() - 1) {
let v0_re = pts[i].re - critical_re;
let v0_im = pts[i].im - critical_im;
let v1_re = pts[i + 1].re - critical_re;
let v1_im = pts[i + 1].im - critical_im;
let cross = v0_re * v1_im - v0_im * v1_re;
let dot = v0_re * v1_re + v0_im * v1_im;
let angle = cross.atan2(dot);
total_angle += angle;
}
let two_pi = S::from_f64(2.0 * core::f64::consts::PI);
let winding = total_angle / two_pi;
let winding_f64 = winding.to_f64();
if winding_f64 >= 0.0 {
(winding_f64 + 0.5) as i32
} else {
(winding_f64 - 0.5) as i32
}
}
pub fn is_stable_nyquist<S: ControlScalar, const TF_ORDER: usize>(
tf: &TransferFn<S, TF_ORDER>,
n_points: usize,
) -> bool {
const MAX_PTS: usize = 512;
let n_use = if n_points > MAX_PTS {
MAX_PTS
} else {
n_points
};
let omega_max = S::PI; let mut pts: HVec<NyquistPoint<S>, MAX_PTS> = HVec::new();
if n_use < 2 {
return true; }
let n_minus_one = S::from_f64((n_use - 1) as f64);
for i in 0..n_use {
let t = S::from_f64(i as f64) / n_minus_one;
let omega = t * omega_max;
let (re, im) = eval_tf_at_freq(tf, omega);
let _ = pts.push(NyquistPoint { re, im, omega });
}
let mut total_angle = S::ZERO;
let critical_re = S::from_f64(-1.0);
for i in 0..(pts.len() - 1) {
let v0_re = pts[i].re - critical_re;
let v0_im = pts[i].im;
let v1_re = pts[i + 1].re - critical_re;
let v1_im = pts[i + 1].im;
let cross = v0_re * v1_im - v0_im * v1_re;
let dot = v0_re * v1_re + v0_im * v1_im;
total_angle += cross.atan2(dot);
}
let two_pi = S::from_f64(2.0 * core::f64::consts::PI);
let winding_f64 = (total_angle / two_pi).to_f64();
let winding = if winding_f64 >= 0.0 {
(winding_f64 + 0.5) as i32
} else {
(winding_f64 - 0.5) as i32
};
winding >= 0
}
pub fn distance_to_critical<S: ControlScalar, const N: usize>(data: &NyquistData<S, N>) -> S {
let critical_re = S::from_f64(-1.0);
let mut min_dist = S::from_f64(f64::MAX);
for pt in data.points.iter() {
let dr = pt.re - critical_re;
let di = pt.im;
let dist = (dr * dr + di * di).sqrt();
if dist < min_dist {
min_dist = dist;
}
}
if min_dist > S::from_f64(f64::MAX / 2.0) {
S::ZERO
} else {
min_dist
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::transfer_fn::TransferFn;
#[test]
fn unity_gain_nyquist_no_encirclement() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let data = compute_nyquist::<f64, 1, 32>(&tf, core::f64::consts::PI).expect("nyquist ok");
let enc = encirclement_count(&data);
assert_eq!(enc, 0, "Unity gain has no encirclements of -1");
}
#[test]
fn attenuating_nyquist_no_encirclement() {
let tf = TransferFn::<f64, 1>::new([0.5], [0.0]);
let data = compute_nyquist::<f64, 1, 64>(&tf, core::f64::consts::PI).expect("nyquist ok");
let enc = encirclement_count(&data);
assert_eq!(enc, 0, "Attenuating system should have no encirclements");
}
#[test]
fn nyquist_point_count() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let data = compute_nyquist::<f64, 1, 32>(&tf, core::f64::consts::PI).expect("nyquist ok");
assert_eq!(data.len(), 32, "Should have 32 Nyquist points");
}
#[test]
fn nyquist_invalid_omega() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let result = compute_nyquist::<f64, 1, 8>(&tf, -1.0);
assert!(
matches!(result, Err(FreqError::InvalidFrequencyRange)),
"Negative omega_max should error"
);
}
#[test]
fn first_order_stable_nyquist() {
let alpha = 0.5_f64;
let tf = TransferFn::<f64, 1>::new([1.0 - alpha], [-alpha]);
let stable = is_stable_nyquist::<f64, 1>(&tf, 128);
assert!(stable, "First-order LP with α=0.5 should be stable");
}
#[test]
fn unity_distance_to_critical() {
let tf = TransferFn::<f64, 1>::new([1.0], [0.0]);
let data = compute_nyquist::<f64, 1, 64>(&tf, core::f64::consts::PI).expect("nyquist ok");
let dist = distance_to_critical(&data);
assert!(dist > 0.0, "Distance should be positive");
assert!(
(dist - 2.0).abs() < 0.1,
"Unity gain distance to -1 should be ~2, got {}",
dist
);
}
#[test]
fn nyquist_dc_value() {
let tf = TransferFn::<f64, 1>::new([0.5], [0.0]);
let data = compute_nyquist::<f64, 1, 16>(&tf, core::f64::consts::PI).expect("nyquist ok");
let first = &data.points[0];
assert!(
(first.re - 0.5).abs() < 1e-9,
"DC real part should be 0.5, got {}",
first.re
);
assert!(
first.im.abs() < 1e-9,
"DC imag part should be 0, got {}",
first.im
);
}
}