use crate::core::scalar::ControlScalar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtremumError {
InvalidParameter(&'static str),
}
impl core::fmt::Display for ExtremumError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ExtremumError::InvalidParameter(msg) => {
write!(f, "ExtremumError::InvalidParameter: {msg}")
}
}
}
}
#[derive(Debug, Clone)]
pub struct GradientEsc<S> {
u_hat: S,
eta: S,
xi: S,
phase: S,
amplitude: S,
omega: S,
h_y: S,
h: S,
k_int: S,
dt: S,
minimize: bool,
}
impl<S: ControlScalar> GradientEsc<S> {
#[allow(clippy::too_many_arguments)]
pub fn new(
u_init: S,
amplitude: S,
omega: S,
hpf_bandwidth: S,
lpf_bandwidth: S,
integrator_gain: S,
dt: S,
minimize: bool,
) -> Result<Self, ExtremumError> {
if amplitude <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"amplitude must be positive",
));
}
if omega <= S::ZERO {
return Err(ExtremumError::InvalidParameter("omega must be positive"));
}
if hpf_bandwidth <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"hpf_bandwidth must be positive",
));
}
if lpf_bandwidth <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"lpf_bandwidth must be positive",
));
}
if integrator_gain <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"integrator_gain must be positive",
));
}
if dt <= S::ZERO {
return Err(ExtremumError::InvalidParameter("dt must be positive"));
}
Ok(Self {
u_hat: u_init,
eta: S::ZERO,
xi: S::ZERO,
phase: S::ZERO,
amplitude,
omega,
h_y: hpf_bandwidth,
h: lpf_bandwidth,
k_int: integrator_gain,
dt,
minimize,
})
}
#[inline]
pub fn probing_input(&self) -> S {
let s = S::from_f64(libm::sin(self.phase.to_f64()));
self.u_hat + self.amplitude * s
}
pub fn update(&mut self, y: S) -> Result<S, ExtremumError> {
let sin_phase = S::from_f64(libm::sin(self.phase.to_f64()));
let sign = if self.minimize { -S::ONE } else { S::ONE };
self.eta += self.dt * self.h_y * (y - self.eta);
let y_hp = y - self.eta;
let d = sign * y_hp * sin_phase;
self.xi += self.dt * self.h * (d - self.xi);
self.u_hat += self.dt * self.k_int * self.xi;
self.phase += self.omega * self.dt;
let two_pi = S::TWO * S::PI;
while self.phase >= two_pi {
self.phase -= two_pi;
}
Ok(self.probing_input())
}
#[inline]
pub fn estimate(&self) -> S {
self.u_hat
}
pub fn reset(&mut self, u_init: S) {
self.u_hat = u_init;
self.eta = S::ZERO;
self.xi = S::ZERO;
self.phase = S::ZERO;
}
}
#[derive(Debug, Clone)]
pub struct GradientEsc2D<S> {
u_hat: [S; 2],
eta: [S; 2],
xi: [S; 2],
phase: [S; 2],
amplitude: [S; 2],
omega: [S; 2],
h_y: S,
h: S,
k_int: S,
dt: S,
minimize: bool,
}
impl<S: ControlScalar> GradientEsc2D<S> {
#[allow(clippy::too_many_arguments)]
pub fn new(
u_init: [S; 2],
amplitude: [S; 2],
omega: [S; 2],
hpf_bandwidth: S,
lpf_bandwidth: S,
k_int: S,
dt: S,
minimize: bool,
) -> Result<Self, ExtremumError> {
for &a in &litude {
if a <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"all amplitudes must be positive",
));
}
}
for &w in &omega {
if w <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"all omega values must be positive",
));
}
}
if hpf_bandwidth <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"hpf_bandwidth must be positive",
));
}
if lpf_bandwidth <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"lpf_bandwidth must be positive",
));
}
if k_int <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"integrator_gain must be positive",
));
}
if dt <= S::ZERO {
return Err(ExtremumError::InvalidParameter("dt must be positive"));
}
let diff = S::from_f64(libm::fabs((omega[0] - omega[1]).to_f64()));
if diff < S::EPSILON {
return Err(ExtremumError::InvalidParameter(
"omega[0] and omega[1] must differ to avoid channel cross-talk",
));
}
Ok(Self {
u_hat: u_init,
eta: [S::ZERO; 2],
xi: [S::ZERO; 2],
phase: [S::ZERO; 2],
amplitude,
omega,
h_y: hpf_bandwidth,
h: lpf_bandwidth,
k_int,
dt,
minimize,
})
}
#[inline]
pub fn probing_input(&self) -> [S; 2] {
[
self.u_hat[0] + self.amplitude[0] * S::from_f64(libm::sin(self.phase[0].to_f64())),
self.u_hat[1] + self.amplitude[1] * S::from_f64(libm::sin(self.phase[1].to_f64())),
]
}
pub fn update(&mut self, y: S) -> Result<[S; 2], ExtremumError> {
let sign = if self.minimize { -S::ONE } else { S::ONE };
let two_pi = S::TWO * S::PI;
for i in 0..2 {
let sin_phi = S::from_f64(libm::sin(self.phase[i].to_f64()));
self.eta[i] += self.dt * self.h_y * (y - self.eta[i]);
let y_hp = y - self.eta[i];
let d = sign * y_hp * sin_phi;
self.xi[i] += self.dt * self.h * (d - self.xi[i]);
self.u_hat[i] += self.dt * self.k_int * self.xi[i];
self.phase[i] += self.omega[i] * self.dt;
while self.phase[i] >= two_pi {
self.phase[i] -= two_pi;
}
}
Ok(self.probing_input())
}
#[inline]
pub fn estimate(&self) -> [S; 2] {
self.u_hat
}
pub fn reset(&mut self, u_init: [S; 2]) {
self.u_hat = u_init;
self.eta = [S::ZERO; 2];
self.xi = [S::ZERO; 2];
self.phase = [S::ZERO; 2];
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quadratic_max(u: f64, u_star: f64, peak: f64) -> f64 {
-(u - u_star) * (u - u_star) + peak
}
fn quadratic_min(u: f64, u_star: f64) -> f64 {
(u - u_star) * (u - u_star)
}
#[test]
fn quadratic_1d_maximize() {
let mut esc = GradientEsc::<f64>::new(
0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.001, false, )
.expect("valid params");
for _ in 0..30_000 {
let u = esc.probing_input();
let y = quadratic_max(u, 3.0, 10.0);
esc.update(y).expect("update ok");
}
let est = esc.estimate();
assert!(
(est - 3.0).abs() < 0.3,
"Expected u_hat ≈ 3.0, got {est:.4}"
);
}
#[test]
fn quadratic_1d_minimize() {
let mut esc = GradientEsc::<f64>::new(
0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.001, true, )
.expect("valid params");
for _ in 0..30_000 {
let u = esc.probing_input();
let y = quadratic_min(u, 3.0);
esc.update(y).expect("update ok");
}
let est = esc.estimate();
assert!(
(est - 3.0).abs() < 0.3,
"Expected u_hat ≈ 3.0 (min), got {est:.4}"
);
}
#[test]
fn quadratic_2d_maximize() {
let mut esc = GradientEsc2D::<f64>::new(
[1.0, 4.0], [0.2, 0.2], [20.0, 30.0], 1.0, 5.0, 5.0, 0.001, false, )
.expect("valid params");
for _ in 0..30_000 {
let [u1, u2] = esc.probing_input();
let y = -(u1 - 2.0) * (u1 - 2.0) - (u2 - 5.0) * (u2 - 5.0);
esc.update(y).expect("update ok");
}
let [e1, e2] = esc.estimate();
assert!((e1 - 2.0).abs() < 0.5, "Expected û₁ ≈ 2.0, got {e1:.4}");
assert!((e2 - 5.0).abs() < 0.5, "Expected û₂ ≈ 5.0, got {e2:.4}");
}
#[test]
fn invalid_amplitude_zero() {
let result = GradientEsc::<f64>::new(0.0, 0.0, 20.0, 1.0, 5.0, 5.0, 0.001, false);
assert!(result.is_err(), "zero amplitude should be rejected");
assert_eq!(
result.unwrap_err(),
ExtremumError::InvalidParameter("amplitude must be positive")
);
}
#[test]
fn invalid_omega_negative() {
let result = GradientEsc::<f64>::new(0.0, 0.2, -1.0, 1.0, 5.0, 5.0, 0.001, false);
assert!(result.is_err(), "negative omega should be rejected");
assert_eq!(
result.unwrap_err(),
ExtremumError::InvalidParameter("omega must be positive")
);
}
#[test]
fn invalid_hpf_zero() {
let result = GradientEsc::<f64>::new(0.0, 0.2, 20.0, 0.0, 5.0, 5.0, 0.001, false);
assert!(result.is_err(), "zero hpf_bandwidth should be rejected");
assert_eq!(
result.unwrap_err(),
ExtremumError::InvalidParameter("hpf_bandwidth must be positive")
);
}
#[test]
fn probing_amplitude_at_zero_phase() {
let amp = 0.2_f64;
let esc = GradientEsc::<f64>::new(5.0, amp, 20.0, 1.0, 5.0, 5.0, 0.001, false)
.expect("valid params");
let probe = esc.probing_input();
let est = esc.estimate();
assert!(
(probe - est).abs() < 1e-12,
"At phase=0 probe should equal u_hat: probe={probe}, est={est}"
);
}
#[test]
fn phase_accumulation() {
let omega = 20.0_f64;
let dt = 0.001_f64;
let mut esc = GradientEsc::<f64>::new(0.0, 0.2, omega, 1.0, 5.0, 5.0, dt, false)
.expect("valid params");
let n_steps = 50_usize;
for _ in 0..n_steps {
let u = esc.probing_input();
let y = quadratic_max(u, 3.0, 10.0);
esc.update(y).expect("update ok");
}
let expected_phase = (omega * dt * n_steps as f64).rem_euclid(2.0 * core::f64::consts::PI);
let probe = esc.probing_input();
let u_hat = esc.estimate();
let sin_phase_actual = (probe - u_hat) / 0.2;
let sin_phase_expected = libm::sin(expected_phase);
assert!(
(sin_phase_actual - sin_phase_expected).abs() < 0.02,
"Phase mismatch: sin(actual)={sin_phase_actual:.6}, \
sin(expected)={sin_phase_expected:.6}"
);
}
#[test]
fn reset_restores_state() {
let mut esc = GradientEsc::<f64>::new(0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.001, false)
.expect("valid params");
for _ in 0..500 {
let u = esc.probing_input();
let y = quadratic_max(u, 3.0, 10.0);
esc.update(y).expect("update ok");
}
esc.reset(10.0);
assert!(
(esc.estimate() - 10.0).abs() < 1e-12,
"After reset u_hat should be 10.0"
);
assert!(
(esc.probing_input() - 10.0).abs() < 1e-12,
"After reset probing_input should equal new u_init"
);
}
#[test]
fn invalid_2d_equal_omega() {
let result = GradientEsc2D::<f64>::new(
[0.0, 0.0],
[0.2, 0.2],
[20.0, 20.0], 1.0,
5.0,
5.0,
0.001,
false,
);
assert!(result.is_err(), "Equal omega should be rejected");
}
}