use crate::core::scalar::ControlScalar;
use crate::extremum::gradient_esc::ExtremumError;
#[derive(Debug, Clone)]
pub struct NewtonEsc<S> {
u_hat: S,
eta: S,
xi_grad: S,
xi_hess: S,
hess_hat: S,
phase: S,
amplitude: S,
omega: S,
h_y: S,
h: S,
k: S,
eps: S,
dt: S,
}
impl<S: ControlScalar> NewtonEsc<S> {
#[allow(clippy::too_many_arguments)]
pub fn new(
u_init: S,
amplitude: S,
omega: S,
hpf_bandwidth: S,
lpf_bandwidth: S,
k: S,
eps: S,
dt: S,
) -> 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 k <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"k (Newton step size) must be positive",
));
}
if eps <= S::ZERO {
return Err(ExtremumError::InvalidParameter(
"eps (Hessian regularisation) 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_grad: S::ZERO,
xi_hess: S::ZERO,
hess_hat: -S::ONE, phase: S::ZERO,
amplitude,
omega,
h_y: hpf_bandwidth,
h: lpf_bandwidth,
k,
eps,
dt,
})
}
#[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 phase_f64 = self.phase.to_f64();
let sin_phi = S::from_f64(libm::sin(phase_f64));
let cos_2phi = S::from_f64(libm::cos(2.0 * phase_f64));
let two = S::TWO;
let a_sq = self.amplitude * self.amplitude;
let hess_weight = -(two / a_sq);
self.eta += self.dt * self.h_y * (y - self.eta);
let y_hp = y - self.eta;
self.xi_grad += self.dt * self.h * (y_hp * sin_phi - self.xi_grad);
let hess_signal = y_hp * hess_weight * cos_2phi;
self.xi_hess += self.dt * self.h * (hess_signal - self.xi_hess);
self.hess_hat = self.xi_hess;
let hess_abs = S::from_f64(libm::fabs(self.hess_hat.to_f64()));
self.u_hat += self.dt * self.k * self.xi_grad / (hess_abs + self.eps);
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
}
#[inline]
pub fn hessian_estimate(&self) -> S {
self.hess_hat
}
pub fn reset(&mut self, u_init: S) {
self.u_hat = u_init;
self.eta = S::ZERO;
self.xi_grad = S::ZERO;
self.xi_hess = S::ZERO;
self.hess_hat = -S::ONE;
self.phase = S::ZERO;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extremum::gradient_esc::GradientEsc;
fn quadratic_max(u: f64, u_star: f64, peak: f64) -> f64 {
-(u - u_star) * (u - u_star) + peak
}
#[test]
fn newton_esc_converges_quadratic() {
let mut esc = NewtonEsc::<f64>::new(
0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.01, 0.001, )
.expect("valid params");
for _ in 0..20_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,
"Newton ESC: expected û ≈ 3.0, got {est:.4}"
);
}
#[test]
fn newton_esc_faster_than_gradient() {
const U_STAR: f64 = 3.0;
const THRESHOLD: f64 = 0.5;
const MAX_STEPS: usize = 30_000;
let dt = 0.001_f64;
let amp = 0.2_f64;
let omega = 20.0_f64;
let mut n_esc =
NewtonEsc::<f64>::new(0.0, amp, omega, 1.0, 5.0, 5.0, 0.01, dt).expect("valid params");
let mut newton_steps = MAX_STEPS;
for i in 0..MAX_STEPS {
let u = n_esc.probing_input();
let y = quadratic_max(u, U_STAR, 10.0);
n_esc.update(y).expect("update ok");
if (n_esc.estimate() - U_STAR).abs() < THRESHOLD {
newton_steps = i + 1;
break;
}
}
let mut g_esc = GradientEsc::<f64>::new(0.0, amp, omega, 1.0, 5.0, 5.0, dt, false)
.expect("valid params");
let mut grad_steps = MAX_STEPS;
for i in 0..MAX_STEPS {
let u = g_esc.probing_input();
let y = quadratic_max(u, U_STAR, 10.0);
g_esc.update(y).expect("update ok");
if (g_esc.estimate() - U_STAR).abs() < THRESHOLD {
grad_steps = i + 1;
break;
}
}
assert!(
newton_steps < MAX_STEPS,
"Newton ESC did not converge within {MAX_STEPS} steps"
);
assert!(
grad_steps < MAX_STEPS,
"Gradient ESC did not converge within {MAX_STEPS} steps (comparator broken)"
);
let slack = grad_steps + (grad_steps / 2);
assert!(
newton_steps <= slack,
"Newton ({newton_steps} steps) was not faster than gradient \
({grad_steps} steps) even with 50% slack"
);
}
#[test]
fn hessian_estimate_negative_after_convergence() {
let mut esc = NewtonEsc::<f64>::new(0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.01, 0.001)
.expect("valid params");
for _ in 0..20_000 {
let u = esc.probing_input();
let y = quadratic_max(u, 3.0, 10.0);
esc.update(y).expect("update ok");
}
let h = esc.hessian_estimate();
assert!(
h < 0.0,
"Hessian estimate should be negative at maximum, got {h:.6}"
);
}
#[test]
fn zero_gradient_stable() {
let mut esc = NewtonEsc::<f64>::new(
3.0, 0.05, 20.0, 1.0, 5.0, 5.0, 0.01, 0.001,
)
.expect("valid params");
for _ in 0..10_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,
"Starting at optimum, û should remain near 3.0, got {est:.4}"
);
}
#[test]
fn invalid_params_rejected() {
assert!(
NewtonEsc::<f64>::new(0.0, 0.0, 20.0, 1.0, 5.0, 5.0, 0.01, 0.001).is_err(),
"zero amplitude should be rejected"
);
assert!(
NewtonEsc::<f64>::new(0.0, 0.2, 20.0, 1.0, 5.0, 0.0, 0.01, 0.001).is_err(),
"zero k should be rejected"
);
assert!(
NewtonEsc::<f64>::new(0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.0, 0.001).is_err(),
"zero eps should be rejected"
);
assert!(
NewtonEsc::<f64>::new(0.0, 0.2, -1.0, 1.0, 5.0, 5.0, 0.01, 0.001).is_err(),
"negative omega should be rejected"
);
assert!(
NewtonEsc::<f64>::new(0.0, 0.2, 20.0, 0.0, 5.0, 5.0, 0.01, 0.001).is_err(),
"zero hpf_bandwidth should be rejected"
);
assert!(
NewtonEsc::<f64>::new(0.0, 0.2, 20.0, 1.0, 0.0, 5.0, 0.01, 0.001).is_err(),
"zero lpf_bandwidth should be rejected"
);
}
#[test]
fn probing_input_at_zero_phase() {
let u_init = 7.5_f64;
let esc = NewtonEsc::<f64>::new(u_init, 0.2, 20.0, 1.0, 5.0, 5.0, 0.01, 0.001)
.expect("valid params");
let probe = esc.probing_input();
assert!(
(probe - u_init).abs() < 1e-12,
"At phase=0: probing_input should equal u_init={u_init}, got {probe}"
);
}
#[test]
fn reset_clears_state() {
let mut esc = NewtonEsc::<f64>::new(0.0, 0.2, 20.0, 1.0, 5.0, 5.0, 0.01, 0.001)
.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(5.0);
assert!(
(esc.estimate() - 5.0).abs() < 1e-12,
"After reset, estimate should be 5.0"
);
assert!(
(esc.probing_input() - 5.0).abs() < 1e-12,
"After reset, probing_input should equal new u_init"
);
}
}