use crate::core::scalar::ControlScalar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoTuneState {
Idle,
RelayRunning,
Done,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZnRule {
P,
Pi,
Pid,
PidNoOvershoot,
}
#[derive(Debug, Clone)]
pub struct RelayAutoTuner<S: ControlScalar> {
relay_amplitude: S,
setpoint: S,
hysteresis: S,
state: AutoTuneState,
relay_output: S,
peak_high: S,
peak_low: S,
last_crossing_time: S,
half_period_sum: S,
half_period_count: usize,
min_cycles: usize,
elapsed: S,
ku: S,
tu: S,
tuning_done: bool,
}
impl<S: ControlScalar> RelayAutoTuner<S> {
pub fn new(relay_amplitude: S, setpoint: S, hysteresis: S) -> Self {
Self {
relay_amplitude,
setpoint,
hysteresis,
state: AutoTuneState::Idle,
relay_output: relay_amplitude,
peak_high: S::ZERO,
peak_low: S::ZERO,
last_crossing_time: S::ZERO,
half_period_sum: S::ZERO,
half_period_count: 0,
min_cycles: 4,
elapsed: S::ZERO,
ku: S::ZERO,
tu: S::ZERO,
tuning_done: false,
}
}
pub fn with_min_cycles(mut self, n: usize) -> Self {
self.min_cycles = n;
self
}
pub fn start(&mut self) {
self.state = AutoTuneState::RelayRunning;
self.relay_output = self.relay_amplitude;
self.peak_high = S::ZERO;
self.peak_low = S::ZERO;
self.last_crossing_time = S::ZERO;
self.half_period_sum = S::ZERO;
self.half_period_count = 0;
self.elapsed = S::ZERO;
self.tuning_done = false;
}
pub fn update(&mut self, measurement: S, dt: S) -> S {
if self.state != AutoTuneState::RelayRunning {
return S::ZERO;
}
self.elapsed += dt;
let error = self.setpoint - measurement;
if measurement > self.peak_high {
self.peak_high = measurement;
}
if measurement < self.peak_low || self.peak_low == S::ZERO {
self.peak_low = measurement;
}
let _prev_output = self.relay_output;
if error > self.hysteresis && self.relay_output < S::ZERO {
self.relay_output = self.relay_amplitude;
self.record_crossing();
} else if error < -self.hysteresis && self.relay_output > S::ZERO {
self.relay_output = -self.relay_amplitude;
self.record_crossing();
}
if self.half_period_count >= self.min_cycles {
self.finish();
}
self.relay_output
}
fn record_crossing(&mut self) {
if self.last_crossing_time > S::ZERO {
let half_period = self.elapsed - self.last_crossing_time;
if half_period > S::ZERO {
self.half_period_sum += half_period;
self.half_period_count += 1;
}
}
self.last_crossing_time = self.elapsed;
}
fn finish(&mut self) {
if self.half_period_count == 0 {
return;
}
let avg_half = self.half_period_sum / S::from_f64(self.half_period_count as f64);
self.tu = avg_half * S::TWO;
let amplitude = (self.peak_high - self.peak_low) * S::HALF;
if amplitude > S::ZERO {
self.ku = S::from_f64(4.0) * self.relay_amplitude / (S::PI * amplitude);
}
self.state = AutoTuneState::Done;
self.tuning_done = true;
}
pub fn gains(&self, rule: ZnRule) -> Option<(S, S, S)> {
if !self.tuning_done || self.tu <= S::ZERO || self.ku <= S::ZERO {
return None;
}
let ku = self.ku;
let tu = self.tu;
match rule {
ZnRule::P => Some((ku * S::from_f64(0.5), S::ZERO, S::ZERO)),
ZnRule::Pi => {
let kp = ku * S::from_f64(0.45);
let ti = tu * S::from_f64(0.83);
let ki = if ti > S::ZERO { kp / ti } else { S::ZERO };
Some((kp, ki, S::ZERO))
}
ZnRule::Pid => {
let kp = ku * S::from_f64(0.6);
let ti = tu * S::from_f64(0.5);
let td = tu * S::from_f64(0.125);
let ki = if ti > S::ZERO { kp / ti } else { S::ZERO };
let kd = kp * td;
Some((kp, ki, kd))
}
ZnRule::PidNoOvershoot => {
let kp = ku * S::from_f64(0.2);
let ti = tu * S::from_f64(0.5);
let td = tu * S::from_f64(0.333);
let ki = if ti > S::ZERO { kp / ti } else { S::ZERO };
let kd = kp * td;
Some((kp, ki, kd))
}
}
}
pub fn state(&self) -> AutoTuneState {
self.state
}
pub fn is_done(&self) -> bool {
self.tuning_done
}
pub fn ultimate_gain(&self) -> S {
self.ku
}
pub fn ultimate_period(&self) -> S {
self.tu
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run_relay_on_first_order(
kp: f64,
tau: f64,
relay_amp: f64,
setpoint: f64,
) -> RelayAutoTuner<f64> {
let mut tuner = RelayAutoTuner::new(relay_amp, setpoint, 0.001).with_min_cycles(6);
tuner.start();
let dt = 0.001;
let mut plant = 0.0_f64;
for _ in 0..100_000 {
if tuner.is_done() {
break;
}
let u = tuner.update(plant, dt);
let dy = (kp * u - plant) / tau;
plant += dy * dt;
}
tuner
}
#[test]
fn tuner_completes() {
let tuner = run_relay_on_first_order(2.0, 0.1, 1.0, 0.0);
assert!(tuner.is_done(), "Tuner should complete");
assert_eq!(tuner.state(), AutoTuneState::Done);
}
#[test]
fn ultimate_gain_and_period_positive() {
let tuner = run_relay_on_first_order(2.0, 0.1, 1.0, 0.0);
if tuner.is_done() {
assert!(tuner.ultimate_gain() > 0.0);
assert!(tuner.ultimate_period() > 0.0);
}
}
#[test]
fn zn_gains_valid() {
let tuner = run_relay_on_first_order(2.0, 0.1, 1.0, 0.0);
if tuner.is_done() {
for rule in &[ZnRule::P, ZnRule::Pi, ZnRule::Pid, ZnRule::PidNoOvershoot] {
let gains = tuner.gains(*rule);
assert!(gains.is_some(), "Gains should be available for {:?}", rule);
let (kp, ki, kd) = gains.unwrap();
assert!(kp > 0.0, "kp should be positive");
assert!(ki >= 0.0);
assert!(kd >= 0.0);
}
}
}
#[test]
fn gains_none_when_not_done() {
let tuner = RelayAutoTuner::<f64>::new(1.0, 0.0, 0.001);
assert!(tuner.gains(ZnRule::Pid).is_none());
}
#[test]
fn idle_returns_zero() {
let mut tuner = RelayAutoTuner::<f64>::new(1.0, 0.0, 0.001);
let u = tuner.update(0.0, 0.01);
assert_eq!(u, 0.0);
}
}