forked from M-Labs/thermostat
atse
a61b2f2c40
Current limit pins are driven by PWM inputs to the MAX1968 driver, but this is an implementation detail, and should not be exposed in the form of the command interface. Rename "pwm" commands to "output". See M-Labs/thermostat#62 (comment).
263 lines
8.7 KiB
Python
263 lines
8.7 KiB
Python
import math
|
|
import logging
|
|
from collections import deque, namedtuple
|
|
from enum import Enum
|
|
|
|
from pytec.client import Client
|
|
|
|
# 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'
|
|
|
|
|
|
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 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_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):
|
|
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
|
|
|
|
|
|
def main():
|
|
# Auto tune parameters
|
|
# Thermostat channel
|
|
channel = 0
|
|
# Target temperature of the autotune routine, celcius
|
|
target_temperature = 20
|
|
# Value by which output will be increased/decreased from zero, amps
|
|
output_step = 1
|
|
# Reference period for local minima/maxima, seconds
|
|
lookback = 3
|
|
# Determines by how much the input value must
|
|
# overshoot/undershoot the setpoint, celcius
|
|
noiseband = 1.5
|
|
|
|
# logging.basicConfig(level=logging.DEBUG)
|
|
|
|
tec = Client()
|
|
|
|
data = next(tec.report_mode())
|
|
ch = data[channel]
|
|
|
|
tuner = PIDAutotune(target_temperature, output_step,
|
|
lookback, noiseband, ch['interval'])
|
|
|
|
for data in tec.report_mode():
|
|
|
|
ch = data[channel]
|
|
|
|
temperature = ch['temperature']
|
|
|
|
if (tuner.run(temperature, ch['time'])):
|
|
break
|
|
|
|
tuner_out = tuner.output()
|
|
|
|
tec.set_param("output", channel, "i_set", tuner_out)
|
|
|
|
tec.set_param("output", channel, "i_set", 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|