use crate::math;
#[inline]
pub fn zoh_discretize(a: f64, delta: f64) -> (f64, f64) {
let a_bar = math::exp(delta * a);
let b_bar_factor = if math::abs(a) < 1e-12 {
delta
} else {
(a_bar - 1.0) / a
};
(a_bar, b_bar_factor)
}
#[inline]
pub fn bilinear_discretize(a: f64, delta: f64) -> (f64, f64) {
let half_da = 0.5 * delta * a;
let denom = 1.0 - half_da;
let a_bar = (1.0 + half_da) / denom;
let b_bar_factor = delta / denom;
(a_bar, b_bar_factor)
}
#[inline]
pub fn trapezoidal_complex(a_re: f64, a_im: f64, delta: f64) -> (f64, f64, f64, f64) {
let num_re = 1.0 + 0.5 * delta * a_re;
let num_im = 0.5 * delta * a_im;
let den_re = 1.0 - 0.5 * delta * a_re;
let den_im = -0.5 * delta * a_im;
let denom_sq = den_re * den_re + den_im * den_im;
let a_bar_re = (num_re * den_re + num_im * den_im) / denom_sq;
let a_bar_im = (num_im * den_re - num_re * den_im) / denom_sq;
let b_factor_re = delta * den_re / denom_sq;
let b_factor_im = delta * (-den_im) / denom_sq;
(a_bar_re, a_bar_im, b_factor_re, b_factor_im)
}
#[inline]
pub fn exp_trapezoidal_complex(
a_re: f64,
a_im: f64,
delta: f64,
lambda: f64,
) -> (f64, f64, f64, f64, f64, f64) {
let exp_re_part = math::exp(delta * a_re);
let angle = delta * a_im;
let alpha_re = exp_re_part * math::cos(angle);
let alpha_im = exp_re_part * math::sin(angle);
let beta_scalar = (1.0 - lambda) * delta;
let beta_re = beta_scalar * alpha_re;
let beta_im = beta_scalar * alpha_im;
let gamma_re = lambda * delta;
let gamma_im = 0.0;
(alpha_re, alpha_im, beta_re, beta_im, gamma_re, gamma_im)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoh_negative_a_produces_decaying_state() {
let a = -1.0;
let delta = 0.1;
let (a_bar, b_bar_factor) = zoh_discretize(a, delta);
assert!(
a_bar > 0.0 && a_bar < 1.0,
"a_bar should be in (0,1) for negative a"
);
assert!(b_bar_factor > 0.0, "b_bar_factor should be positive");
let expected_a_bar = math::exp(-0.1);
assert!(
math::abs(a_bar - expected_a_bar) < 1e-12,
"expected a_bar={}, got {}",
expected_a_bar,
a_bar
);
}
#[test]
fn zoh_a_near_zero_uses_lhopital() {
let a = 1e-15;
let delta = 0.5;
let (a_bar, b_bar_factor) = zoh_discretize(a, delta);
assert!(
math::abs(a_bar - 1.0) < 1e-10,
"a_bar should be ~1.0 when a~0"
);
assert!(
math::abs(b_bar_factor - delta) < 1e-10,
"b_bar_factor should be ~delta when a~0, got {}",
b_bar_factor
);
}
#[test]
fn zoh_large_negative_a_decays_quickly() {
let a = -100.0;
let delta = 1.0;
let (a_bar, _b_bar_factor) = zoh_discretize(a, delta);
assert!(
a_bar < 1e-40,
"a_bar should be ~0 for very negative a*delta"
);
}
#[test]
fn bilinear_negative_a_stable() {
let a = -2.0;
let delta = 0.1;
let (a_bar, b_bar_factor) = bilinear_discretize(a, delta);
assert!(
math::abs(a_bar) < 1.0,
"bilinear should preserve stability, got a_bar={}",
a_bar
);
assert!(b_bar_factor > 0.0, "b_bar_factor should be positive");
let expected_a_bar = (1.0 + 0.5 * 0.1 * (-2.0)) / (1.0 - 0.5 * 0.1 * (-2.0));
assert!(
math::abs(a_bar - expected_a_bar) < 1e-12,
"expected {}, got {}",
expected_a_bar,
a_bar
);
}
#[test]
fn bilinear_a_zero_identity() {
let a = 0.0;
let delta = 0.5;
let (a_bar, b_bar_factor) = bilinear_discretize(a, delta);
assert!(
math::abs(a_bar - 1.0) < 1e-12,
"a_bar should be 1.0 when a=0"
);
assert!(
math::abs(b_bar_factor - delta) < 1e-12,
"b_bar_factor should be delta when a=0"
);
}
#[test]
fn zoh_and_bilinear_agree_for_small_delta() {
let a = -1.0;
let delta = 0.001;
let (zoh_a, zoh_b) = zoh_discretize(a, delta);
let (bil_a, bil_b) = bilinear_discretize(a, delta);
assert!(
math::abs(zoh_a - bil_a) < 1e-5,
"ZOH and bilinear should agree for small delta: zoh={}, bil={}",
zoh_a,
bil_a
);
assert!(
math::abs(zoh_b - bil_b) < 1e-5,
"ZOH and bilinear b_bar should agree: zoh={}, bil={}",
zoh_b,
bil_b
);
}
#[test]
fn zoh_delta_zero_gives_identity() {
let a = -5.0;
let delta = 0.0;
let (a_bar, b_bar_factor) = zoh_discretize(a, delta);
assert!(
math::abs(a_bar - 1.0) < 1e-12,
"a_bar should be 1.0 when delta=0"
);
assert!(
math::abs(b_bar_factor) < 1e-12,
"b_bar_factor should be ~0 when delta=0"
);
}
#[test]
fn trapezoidal_complex_real_matches_bilinear() {
let a = -2.0;
let delta = 0.1;
let (bil_a, bil_b) = bilinear_discretize(a, delta);
let (trap_a_re, trap_a_im, trap_b_re, trap_b_im) = trapezoidal_complex(a, 0.0, delta);
assert!(
math::abs(trap_a_re - bil_a) < 1e-12,
"trap a_bar_re should match bilinear a_bar: trap={}, bil={}",
trap_a_re,
bil_a
);
assert!(
math::abs(trap_a_im) < 1e-12,
"trap a_bar_im should be 0 for real A, got {}",
trap_a_im
);
assert!(
math::abs(trap_b_re - bil_b) < 1e-12,
"trap b_factor_re should match bilinear b_factor: trap={}, bil={}",
trap_b_re,
bil_b
);
assert!(
math::abs(trap_b_im) < 1e-12,
"trap b_factor_im should be 0 for real A, got {}",
trap_b_im
);
}
#[test]
fn trapezoidal_complex_stable_eigenvalue() {
let a_re = -1.0;
let a_im = 2.0;
let delta = 0.1;
let (a_bar_re, a_bar_im, _, _) = trapezoidal_complex(a_re, a_im, delta);
let mag_sq = a_bar_re * a_bar_re + a_bar_im * a_bar_im;
assert!(
mag_sq < 1.0,
"|A_bar|^2 should be < 1 for stable A, got {}",
mag_sq
);
}
#[test]
fn exp_trapezoidal_matches_paper_spec() {
let a_re = -1.0;
let a_im = 0.5;
let delta = 0.1;
let (alpha_re, alpha_im, beta_re, beta_im, gamma_re, gamma_im) =
exp_trapezoidal_complex(a_re, a_im, delta, 1.0);
let exp_factor = math::exp(delta * a_re);
let angle = delta * a_im;
let expected_alpha_re = exp_factor * math::cos(angle);
let expected_alpha_im = exp_factor * math::sin(angle);
assert!(
math::abs(alpha_re - expected_alpha_re) < 1e-12,
"alpha_re should match exp-Euler: expected={}, got={}",
expected_alpha_re,
alpha_re
);
assert!(
math::abs(alpha_im - expected_alpha_im) < 1e-12,
"alpha_im should match exp-Euler: expected={}, got={}",
expected_alpha_im,
alpha_im
);
assert!(
math::abs(beta_re) < 1e-12,
"beta_re must be 0 at lambda=1, got {}",
beta_re
);
assert!(
math::abs(beta_im) < 1e-12,
"beta_im must be 0 at lambda=1, got {}",
beta_im
);
assert!(
math::abs(gamma_re - delta) < 1e-12,
"gamma_re must be delta at lambda=1: expected={}, got={}",
delta,
gamma_re
);
assert!(
math::abs(gamma_im) < 1e-12,
"gamma_im must be 0, got {}",
gamma_im
);
}
#[test]
fn exp_trapezoidal_lambda_zero_backward_only() {
let a_re = -1.0;
let a_im = 0.5;
let delta = 0.2;
let (alpha_re, alpha_im, beta_re, beta_im, gamma_re, gamma_im) =
exp_trapezoidal_complex(a_re, a_im, delta, 0.0);
assert!(
math::abs(gamma_re) < 1e-12,
"gamma_re must be 0 at lambda=0, got {}",
gamma_re
);
assert!(
math::abs(gamma_im) < 1e-12,
"gamma_im must be 0 at lambda=0, got {}",
gamma_im
);
assert!(
math::abs(beta_re - delta * alpha_re) < 1e-12,
"beta_re must be delta*alpha_re at lambda=0: expected={}, got={}",
delta * alpha_re,
beta_re
);
assert!(
math::abs(beta_im - delta * alpha_im) < 1e-12,
"beta_im must be delta*alpha_im at lambda=0: expected={}, got={}",
delta * alpha_im,
beta_im
);
}
#[test]
fn exp_trapezoidal_stable_for_negative_real_a() {
let test_cases = [
(-0.5, 1.0, 0.1, 0.5),
(-2.0, 3.15, 1.0, 0.3), (-0.1, 0.0, 5.0, 0.7), (-10.0, 2.0, 0.5, 1.0), ];
for (a_re, a_im, delta, lambda) in test_cases {
let (alpha_re, alpha_im, _, _, _, _) =
exp_trapezoidal_complex(a_re, a_im, delta, lambda);
let mag_sq = alpha_re * alpha_re + alpha_im * alpha_im;
assert!(
mag_sq < 1.0,
"|alpha|² must be < 1 for Re(A)<0: a_re={}, a_im={}, delta={}, lambda={}, got mag_sq={}",
a_re, a_im, delta, lambda, mag_sq
);
}
}
#[test]
fn exp_trapezoidal_and_tustin_agree_at_small_delta() {
let a_re = -1.0;
let a_im = 0.5;
let delta = 0.001; let lambda = 0.5;
let (et_a_re, et_a_im, _, _, _, _) = exp_trapezoidal_complex(a_re, a_im, delta, lambda);
let (tu_a_re, tu_a_im, _, _) = trapezoidal_complex(a_re, a_im, delta);
assert!(
math::abs(et_a_re - tu_a_re) < 1e-5,
"exp-trap and Tustin alpha_re should agree at small delta: et={}, tu={}",
et_a_re,
tu_a_re
);
assert!(
math::abs(et_a_im - tu_a_im) < 1e-5,
"exp-trap and Tustin alpha_im should agree at small delta: et={}, tu={}",
et_a_im,
tu_a_im
);
}
#[test]
fn exp_trapezoidal_distinct_from_tustin_at_moderate_delta() {
let a_re = -1.0;
let a_im = 1.0;
let delta = 0.5;
let lambda = 0.5;
let (et_a_re, et_a_im, _, _, _, _) = exp_trapezoidal_complex(a_re, a_im, delta, lambda);
let (tu_a_re, tu_a_im, _, _) = trapezoidal_complex(a_re, a_im, delta);
let diff = (et_a_re - tu_a_re).powi(2) + (et_a_im - tu_a_im).powi(2);
assert!(
diff > 1e-8,
"exp-trap and Tustin should differ at delta=0.5: et=({},{}) tu=({},{})",
et_a_re,
et_a_im,
tu_a_re,
tu_a_im
);
}
#[test]
fn exp_trapezoidal_input_weights_sum_to_delta_at_zero_a() {
let a_re = 0.0;
let a_im = 0.0;
let delta = 0.3;
let lambda = 0.5;
let (_, _, beta_re, _, gamma_re, _) = exp_trapezoidal_complex(a_re, a_im, delta, lambda);
assert!(
math::abs(beta_re + gamma_re - delta) < 1e-12,
"beta_re + gamma_re should equal delta when A=0: expected={}, got={}",
delta,
beta_re + gamma_re
);
}
}