pub use axon_frontend::stability::{GainError, Gains, StabilityBand};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Converged,
Refine,
Breach,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Control {
pub effort: f64,
pub error: f64,
pub lyapunov: f64,
pub step: u32,
pub outcome: Outcome,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MandateSpec {
pub gains: Gains,
pub epsilon: f64,
pub max_steps: u32,
}
impl MandateSpec {
pub fn validate(&self, band: &StabilityBand) -> Result<(), GainError> {
if self.epsilon <= 0.0 || self.epsilon > 1.0 {
return Err(GainError::ToleranceOutOfRange {
epsilon: self.epsilon,
});
}
if self.max_steps < 1 {
return Err(GainError::NonPositiveStepBudget {
max_steps: i64::from(self.max_steps),
});
}
band.admits(&self.gains)
}
}
#[derive(Debug, Clone)]
pub struct Controller {
spec: MandateSpec,
integral: f64,
i_max: f64,
last_error: Option<f64>,
step: u32,
trajectory: Vec<f64>,
}
fn integral_clamp(gains: &Gains) -> f64 {
if gains.ki > 0.0 {
gains.kp / gains.ki
} else {
0.0
}
}
impl Controller {
pub fn new(spec: MandateSpec, band: &StabilityBand) -> Result<Self, GainError> {
spec.validate(band)?;
Ok(Controller {
spec,
integral: 0.0,
i_max: integral_clamp(&spec.gains),
last_error: None,
step: 0,
trajectory: Vec::new(),
})
}
pub fn observe(&mut self, error: f64) -> Control {
self.step += 1;
self.trajectory.push(error);
self.integral = (self.integral + error).clamp(-self.i_max, self.i_max);
let derivative = match self.last_error {
Some(prev) => error - prev,
None => 0.0,
};
self.last_error = Some(error);
let effort = self.spec.gains.kp * error
+ self.spec.gains.ki * self.integral
+ self.spec.gains.kd * derivative;
let outcome = if error.abs() < self.spec.epsilon {
Outcome::Converged
} else if self.step >= self.spec.max_steps {
Outcome::Breach
} else {
Outcome::Refine
};
Control {
effort,
error,
lyapunov: 0.5 * error * error,
step: self.step,
outcome,
}
}
pub fn trajectory(&self) -> &[f64] {
&self.trajectory
}
pub fn lyapunov_descends(&self) -> bool {
self.trajectory
.windows(2)
.all(|w| 0.5 * w[1] * w[1] < 0.5 * w[0] * w[0])
}
pub fn steps_taken(&self) -> u32 {
self.step
}
pub fn integral_clamp(&self) -> f64 {
self.i_max
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gains(kp: f64, ki: f64, kd: f64) -> Gains {
Gains { kp, ki, kd }
}
fn spec(kp: f64, ki: f64, kd: f64, epsilon: f64, max_steps: u32) -> MandateSpec {
MandateSpec {
gains: gains(kp, ki, kd),
epsilon,
max_steps,
}
}
fn open_controller(s: MandateSpec) -> Controller {
Controller::new(s, &StabilityBand::uncharacterised()).expect("admissible")
}
#[test]
fn the_first_observation_has_no_derivative_term() {
let mut c = open_controller(spec(1.0, 0.0, 100.0, 0.01, 5));
let out = c.observe(0.4);
assert!(
(out.effort - 0.4).abs() < 1e-12,
"first-step effort {} should be Kp·e alone",
out.effort
);
}
#[test]
fn effort_is_the_published_sum_of_three_terms() {
let mut c = open_controller(spec(2.0, 0.5, 0.25, 0.01, 10));
c.observe(0.8); let out = c.observe(0.6);
let expected = 2.0 * 0.6 + 0.5 * 1.4 + 0.25 * -0.2;
assert!(
(out.effort - expected).abs() < 1e-12,
"got {}, expected {expected}",
out.effort
);
}
#[test]
fn the_integral_accumulator_is_clamped_not_merely_its_term() {
let mut c = open_controller(spec(1.0, 1.0, 0.0, 0.05, 100));
for _ in 0..80 {
c.observe(1.0);
}
let i_max = c.integral_clamp();
assert!(
c.integral <= i_max + 1e-12,
"integral wound up to {} past the clamp {i_max}",
c.integral
);
let out = c.observe(0.0);
assert!(
out.effort <= i_max + 1e-12,
"windup survived the clamp: effort {} after the error hit zero",
out.effort
);
}
#[test]
fn lyapunov_is_half_e_squared() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.01, 5));
assert!((c.observe(0.4).lyapunov - 0.08).abs() < 1e-12);
}
#[test]
fn entering_the_band_converges() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.1, 5));
assert_eq!(c.observe(0.5).outcome, Outcome::Refine);
assert_eq!(c.observe(0.05).outcome, Outcome::Converged);
}
#[test]
fn the_band_is_strict_so_e_equal_to_epsilon_is_not_convergence() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.1, 5));
assert_eq!(c.observe(0.1).outcome, Outcome::Refine);
}
#[test]
fn exhausting_the_budget_breaches_it_never_returns_best_effort() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.01, 3));
assert_eq!(c.observe(0.9).outcome, Outcome::Refine);
assert_eq!(c.observe(0.8).outcome, Outcome::Refine);
let last = c.observe(0.7);
assert_eq!(
last.outcome,
Outcome::Breach,
"a spent budget with e outside the band is a breach; anything else \
ships a response that violates the mandate"
);
}
#[test]
fn converging_exactly_on_the_last_permitted_step_is_convergence_not_breach() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.1, 2));
assert_eq!(c.observe(0.9).outcome, Outcome::Refine);
assert_eq!(c.observe(0.01).outcome, Outcome::Converged);
}
#[test]
fn a_single_step_budget_still_permits_one_attempt() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.1, 1));
assert_eq!(c.observe(0.01).outcome, Outcome::Converged);
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.1, 1));
assert_eq!(c.observe(0.9).outcome, Outcome::Breach);
}
#[test]
fn epsilon_and_step_budget_are_rejected_at_the_declaration() {
let band = StabilityBand::uncharacterised();
assert_eq!(
spec(1.0, 0.0, 0.0, 0.0, 5).validate(&band),
Err(GainError::ToleranceOutOfRange { epsilon: 0.0 })
);
assert_eq!(
spec(1.0, 0.0, 0.0, -0.1, 5).validate(&band),
Err(GainError::ToleranceOutOfRange { epsilon: -0.1 })
);
assert_eq!(
spec(1.0, 0.0, 0.0, 1.5, 5).validate(&band),
Err(GainError::ToleranceOutOfRange { epsilon: 1.5 }),
"ε > 1 is vacuous: e = 1 − CSR cannot exceed 1, so the mandate could never fail"
);
assert_eq!(
spec(1.0, 0.0, 0.0, 0.1, 0).validate(&band),
Err(GainError::NonPositiveStepBudget { max_steps: 0 })
);
}
#[test]
fn a_controller_cannot_be_built_from_a_declaration_the_band_refuses() {
let band = StabilityBand::new(0.8, 0.5).unwrap();
assert!(Controller::new(spec(0.3, 0.1, 0.05, 0.1, 5), &band).is_err());
}
fn simulate(spec: MandateSpec, drift: f64, authority: f64, controlled: bool) -> Vec<f64> {
let mut c = Controller::new(spec, &StabilityBand::uncharacterised()).unwrap();
let mut e: f64 = 1.0;
let mut trace = vec![e];
for _ in 0..spec.max_steps {
let out = c.observe(e);
let u = if controlled { out.effort } else { 0.0 };
e = (e + drift - authority * u).clamp(0.0, 1.0);
trace.push(e);
if controlled && out.outcome == Outcome::Converged {
break;
}
}
trace
}
#[test]
fn via_b_the_controlled_trajectory_settles_where_the_free_one_does_not() {
let s = spec(1.2, 0.15, 0.1, 0.05, 40);
let free = simulate(s, 0.02, 0.35, false);
let controlled = simulate(s, 0.02, 0.35, true);
let free_final = *free.last().unwrap();
let controlled_final = *controlled.last().unwrap();
assert!(
free_final >= 0.99,
"the uncontrolled baseline must NOT settle; got {free_final}. If it \
does, the simulation proves nothing about the controller"
);
assert!(
controlled_final < s.epsilon,
"the controlled trajectory must enter the band; got {controlled_final}"
);
assert!(
controlled.len() < free.len(),
"convergence must terminate the loop early: controlled {} steps vs \
free {}",
controlled.len(),
free.len()
);
}
#[test]
fn via_b_lyapunov_descent_is_checked_on_the_trajectory_not_assumed() {
let s = spec(1.2, 0.15, 0.1, 0.05, 40);
let mut c = Controller::new(s, &StabilityBand::uncharacterised()).unwrap();
for e in [1.0, 0.7, 0.45, 0.2, 0.04] {
c.observe(e);
}
assert!(c.lyapunov_descends(), "V(e) = ½e² decreased at every step");
let mut bumpy = Controller::new(s, &StabilityBand::uncharacterised()).unwrap();
for e in [1.0, 0.7, 0.75, 0.2] {
bumpy.observe(e);
}
assert!(
!bumpy.lyapunov_descends(),
"a non-monotone trajectory must be REPORTED as non-descending; a \
predicate that returns true regardless proves nothing"
);
}
#[test]
fn the_trajectory_is_recorded_in_order() {
let mut c = open_controller(spec(1.0, 0.0, 0.0, 0.01, 10));
for e in [0.9, 0.5, 0.2] {
c.observe(e);
}
assert_eq!(c.trajectory(), &[0.9, 0.5, 0.2]);
assert_eq!(c.steps_taken(), 3);
}
}