kirdy/pykirdy/pid_autotune.py

361 lines
13 KiB
Python

import math
import logging
from collections import deque, namedtuple
from enum import Enum
import socket
import json
import time
import signal
from driver.kirdy_async import Kirdy
import asyncio
# Based on hirshmann pid-autotune libiary
# See https://github.com/hirschmann/pid-autotune
# Which is in turn based on a fork of Arduino PID AutoTune Library
# See https://github.com/t0mpr1c3/Arduino-PID-AutoTune-Library
class PIDAutotuneState(Enum):
STATE_OFF = 'off'
STATE_RELAY_STEP_UP = 'relay step up'
STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed'
STATE_READY = 'ready'
class PIDAutotune:
PIDParams = namedtuple('PIDParams', ['Kp', 'Ki', 'Kd'])
PEAK_AMPLITUDE_TOLERANCE = 0.05
_tuning_rules = {
"ziegler-nichols": [0.6, 1.2, 0.075],
"tyreus-luyben": [0.4545, 0.2066, 0.07214],
"ciancone-marlin": [0.303, 0.1364, 0.0481],
"pessen-integral": [0.7, 1.75, 0.105],
"some-overshoot": [0.333, 0.667, 0.111],
"no-overshoot": [0.2, 0.4, 0.0667]
}
def __init__(self, setpoint, out_step=10, lookback=60,
noiseband=0.5, sampletime=1.2):
if setpoint is None:
raise ValueError('setpoint must be specified')
self._inputs = deque(maxlen=round(lookback / sampletime))
self._setpoint = setpoint
self._outputstep = out_step
self._noiseband = noiseband
self._out_min = -out_step
self._out_max = out_step
self._state = PIDAutotuneState.STATE_OFF
self._peak_timestamps = deque(maxlen=5)
self._peaks = deque(maxlen=5)
self._output = 0
self._last_run_timestamp = 0
self._peak_type = 0
self._peak_count = 0
self._initial_output = 0
self._induced_amplitude = 0
self._Ku = 0
self._Pu = 0
def setParam(self, target, step, noiseband, sampletime, lookback):
self._setpoint = target
self._outputstep = step
self._out_max = step
self._out_min = -step
self._noiseband = noiseband
self._inputs = deque(maxlen=round(lookback / sampletime))
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
self._peak_count = 0
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
def state(self):
"""Get the current state."""
return self._state
def output(self):
"""Get the last output value."""
return self._output
def tuning_rules(self):
"""Get a list of all available tuning rules."""
return self._tuning_rules.keys()
def get_tec_pid (self):
divisors = self._tuning_rules["tyreus-luyben"]
kp = self._Ku * divisors[0]
ki = divisors[1] * self._Ku / self._Pu
kd = divisors[2] * self._Ku * self._Pu
return kp, ki, kd
def get_pid_parameters(self, tuning_rule='ziegler-nichols'):
"""Get PID parameters.
Args:
tuning_rule (str): Sets the rule which should be used to calculate
the parameters.
"""
divisors = self._tuning_rules[tuning_rule]
kp = self._Ku * divisors[0]
ki = divisors[1] * self._Ku / self._Pu
kd = divisors[2] * self._Ku * self._Pu
return PIDAutotune.PIDParams(kp, ki, kd)
def run(self, input_val, time_input):
"""To autotune a system, this method must be called periodically.
Args:
input_val (float): The temperature input value.
time_input (float): Current time in seconds.
Returns:
`true` if tuning is finished, otherwise `false`.
"""
now = time_input * 1000
if (self._state == PIDAutotuneState.STATE_OFF
or self._state == PIDAutotuneState.STATE_SUCCEEDED
or self._state == PIDAutotuneState.STATE_FAILED
or self._state == PIDAutotuneState.STATE_READY):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
self._last_run_timestamp = now
# check input and change relay state if necessary
if (self._state == PIDAutotuneState.STATE_RELAY_STEP_UP
and input_val > self._setpoint + self._noiseband):
self._state = PIDAutotuneState.STATE_RELAY_STEP_DOWN
logging.debug('switched state: {0}'.format(self._state))
logging.debug('input: {0}'.format(input_val))
elif (self._state == PIDAutotuneState.STATE_RELAY_STEP_DOWN
and input_val < self._setpoint - self._noiseband):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
logging.debug('switched state: {0}'.format(self._state))
logging.debug('input: {0}'.format(input_val))
# set output
if (self._state == PIDAutotuneState.STATE_RELAY_STEP_UP):
self._output = self._initial_output - self._outputstep
elif self._state == PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self._output = self._initial_output + self._outputstep
# respect output limits
self._output = min(self._output, self._out_max)
self._output = max(self._output, self._out_min)
# identify peaks
is_max = True
is_min = True
for val in self._inputs:
is_max = is_max and (input_val >= val)
is_min = is_min and (input_val <= val)
self._inputs.append(input_val)
# we don't trust the maxes or mins until the input array is full
if len(self._inputs) < self._inputs.maxlen:
return False
# increment peak count and record peak time for maxima and minima
inflection = False
# peak types:
# -1: minimum
# +1: maximum
if is_max:
if self._peak_type == -1:
inflection = True
self._peak_type = 1
elif is_min:
if self._peak_type == 1:
inflection = True
self._peak_type = -1
# update peak times and values
if inflection:
self._peak_count += 1
self._peaks.append(input_val)
self._peak_timestamps.append(now)
logging.debug('found peak: {0}'.format(input_val))
logging.debug('peak count: {0}'.format(self._peak_count))
# check for convergence of induced oscillation
# convergence of amplitude assessed on last 4 peaks (1.5 cycles)
self._induced_amplitude = 0
if inflection and (self._peak_count > 4):
abs_max = self._peaks[-2]
abs_min = self._peaks[-2]
for i in range(0, len(self._peaks) - 2):
self._induced_amplitude += abs(self._peaks[i]
- self._peaks[i+1])
abs_max = max(self._peaks[i], abs_max)
abs_min = min(self._peaks[i], abs_min)
self._induced_amplitude /= 6.0
# check convergence criterion for amplitude of induced oscillation
amplitude_dev = ((0.5 * (abs_max - abs_min)
- self._induced_amplitude)
/ self._induced_amplitude)
logging.debug('amplitude: {0}'.format(self._induced_amplitude))
logging.debug('amplitude deviation: {0}'.format(amplitude_dev))
if amplitude_dev < PIDAutotune.PEAK_AMPLITUDE_TOLERANCE:
self._state = PIDAutotuneState.STATE_SUCCEEDED
# if the autotune has not already converged
# terminate after 10 cycles
if self._peak_count >= 20:
self._output = 0
self._state = PIDAutotuneState.STATE_FAILED
return True
if self._state == PIDAutotuneState.STATE_SUCCEEDED:
self._output = 0
logging.debug('peak finding successful')
# calculate ultimate gain
self._Ku = 4.0 * self._outputstep / \
(self._induced_amplitude * math.pi)
print('Ku: {0}'.format(self._Ku))
# calculate ultimate period in seconds
period1 = self._peak_timestamps[3] - self._peak_timestamps[1]
period2 = self._peak_timestamps[4] - self._peak_timestamps[2]
self._Pu = 0.5 * (period1 + period2) / 1000.0
print('Pu: {0}'.format(self._Pu))
for rule in self._tuning_rules:
params = self.get_pid_parameters(rule)
print('rule: {0}'.format(rule))
print('Kp: {0}'.format(params.Kp))
print('Ki: {0}'.format(params.Ki))
print('Kd: {0}'.format(params.Kd))
return True
return False
async def main():
"""
PID Autotune Tool for Kirdy
This tool can tune the PID to be critically damped by adjusting a few parameters.
Please be advised that this tool can generate undesired PID response.
Make sure you have set the over-temperature protection range properly to protect the laser diode.
PID Autotune Tool Parameters:
For the operation theory and implication of each parameters, please refer to this blog post.
http://brettbeauregard.com/blog/2012/01/arduino-pid-autotune-library/
1. lookback: Reference period for local minima/maxima, seconds.
2. noiseband: Determines by how much the input value must overshoot/undershoot the setpoint(celsius).
3. output_step: Output current step size(amps).
4. target_temperature: Target Output Temperature Setpoint.
5. apply_params: Apply PID Parameters after pid autotune is successful.
Kirdy-Related Parameters:
1. Thermistor parameters.
2. Temperature monitor upper and lower limits.
3. Temperature ADC filter type and sampling rate.
Instructions:
Before running the PID Autotune Tool, please:
1. Secure the laser diode onto the LD adapter and copper heat sink with thermal paste.
2. Ensure Kirdy has warmed up and reached thermal equilibrium.
After running the PID Autotune Tool:
Test the PID parameters and evaluate the PID output behavior.
"""
target_temperature = 20
output_step = 1
lookback = 5.0
noiseband = 0.0
# Apply parameter
apply_params = True
kirdy = Kirdy()
kirdy_ctrl = Kirdy()
await kirdy.start_session(host='192.168.1.131', port=1337, timeout=0.25)
await kirdy_ctrl.start_session(host='192.168.1.131', port=1337, timeout=0.25)
await kirdy_ctrl.laser.set_power_on(False)
await kirdy_ctrl.laser.set_i(0)
await kirdy_ctrl.thermostat.set_power_on(False)
await kirdy_ctrl.thermostat.set_constant_current_control_mode()
await kirdy_ctrl.thermostat.set_tec_i_out(0)
await kirdy_ctrl.thermostat.clear_alarm()
class SignalHandler:
KEEP_PROCESSING = True
def __init__(self):
signal.signal(signal.SIGINT, self.exit_gracefully)
signal.signal(signal.SIGTERM, self.exit_gracefully)
def exit_gracefully(self, signum, frame):
self.KEEP_PROCESSING = False
signal_handler = SignalHandler()
# Configure the Thermistor Parameters
await kirdy_ctrl.thermostat.set_sh_beta(3950)
await kirdy_ctrl.thermostat.set_sh_r0(10.0 * 1000)
await kirdy_ctrl.thermostat.set_sh_t0(25)
# Set a large enough temperature range so that it won't trigger over-temperature protection
await kirdy_ctrl.thermostat.set_temp_mon_upper_limit(target_temperature + 20)
await kirdy_ctrl.thermostat.set_temp_mon_lower_limit(target_temperature - 20)
await kirdy_ctrl.thermostat.set_tec_max_cooling_i(output_step)
await kirdy_ctrl.thermostat.set_tec_max_heating_i(output_step)
# The Polling Rate of Temperature Adc is equal to the PID Update Interval
settings = await kirdy_ctrl.device.get_settings_summary()
sampling_rate = settings["thermostat"]["temp_adc_settings"]["rate"]
await kirdy_ctrl.thermostat.set_power_on(True)
tuner = PIDAutotune(target_temperature, output_step,
lookback, noiseband, 1/sampling_rate)
async for status_report in kirdy.report_mode():
if signal_handler.KEEP_PROCESSING:
temperature = status_report["thermostat"]["temperature"]
ts = status_report['ts']
print("Ts: {0} Temperature: {1} degree".format(ts, temperature))
if (tuner.run(temperature, ts / 1000.0)):
print(tuner._state)
kirdy.stop_report_mode()
tuner_out = tuner.output()
print("PID Autotuner Output: {0}A".format(tuner_out))
await kirdy_ctrl.thermostat.set_tec_i_out(tuner_out)
else:
kirdy.stop_report_mode()
await kirdy_ctrl.thermostat.set_tec_i_out(0)
await kirdy_ctrl.thermostat.set_power_on(False)
if apply_params:
kp, ki, kd = tuner.get_tec_pid()
await kirdy_ctrl.thermostat.set_pid_kp(kp)
await kirdy_ctrl.thermostat.set_pid_ki(ki)
await kirdy_ctrl.thermostat.set_pid_kd(kd)
await kirdy.end_session()
await kirdy_ctrl.end_session()
if __name__ == "__main__":
asyncio.run(main())