This adds the possibility to use a datepicker in the modal after clicking the start button to schedule when the kiln should start running by itself automatically. The timer is implemented in the backend and the start is triggered there so closing or refreshing the browser does not stop it. In the state when it's "waiting to start", the frontend state changes so that the glowing timer icon is now shown instead of the previously unused door icon. The state is also displayed as SCHEDULED and above it the info states when it's due to start: "Start at: ..."
565 lines
19 KiB
Python
565 lines
19 KiB
Python
import threading
|
|
import time
|
|
import random
|
|
import datetime
|
|
import logging
|
|
import json
|
|
import config
|
|
|
|
from threading import Timer
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class Output(object):
|
|
def __init__(self):
|
|
self.active = False
|
|
self.load_libs()
|
|
|
|
def load_libs(self):
|
|
try:
|
|
import RPi.GPIO as GPIO
|
|
GPIO.setmode(GPIO.BCM)
|
|
GPIO.setwarnings(False)
|
|
GPIO.setup(config.gpio_heat, GPIO.OUT)
|
|
self.active = True
|
|
self.GPIO = GPIO
|
|
except:
|
|
msg = "Could not initialize GPIOs, oven operation will only be simulated!"
|
|
log.warning(msg)
|
|
self.active = False
|
|
|
|
def heat(self,sleepfor):
|
|
self.GPIO.output(config.gpio_heat, self.GPIO.HIGH)
|
|
time.sleep(sleepfor)
|
|
|
|
def cool(self,sleepfor):
|
|
'''no active cooling, so sleep'''
|
|
self.GPIO.output(config.gpio_heat, self.GPIO.LOW)
|
|
time.sleep(sleepfor)
|
|
|
|
# FIX - Board class needs to be completely removed
|
|
class Board(object):
|
|
def __init__(self):
|
|
self.name = None
|
|
self.active = False
|
|
self.temp_sensor = None
|
|
self.gpio_active = False
|
|
self.load_libs()
|
|
self.create_temp_sensor()
|
|
self.temp_sensor.start()
|
|
|
|
def load_libs(self):
|
|
if config.max31855:
|
|
try:
|
|
#from max31855 import MAX31855, MAX31855Error
|
|
self.name='MAX31855'
|
|
self.active = True
|
|
log.info("import %s " % (self.name))
|
|
except ImportError:
|
|
msg = "max31855 config set, but import failed"
|
|
log.warning(msg)
|
|
|
|
if config.max31856:
|
|
try:
|
|
#from max31856 import MAX31856, MAX31856Error
|
|
self.name='MAX31856'
|
|
self.active = True
|
|
log.info("import %s " % (self.name))
|
|
except ImportError:
|
|
msg = "max31856 config set, but import failed"
|
|
log.warning(msg)
|
|
|
|
def create_temp_sensor(self):
|
|
if config.simulate == True:
|
|
self.temp_sensor = TempSensorSimulate()
|
|
else:
|
|
self.temp_sensor = TempSensorReal()
|
|
|
|
class BoardSimulated(object):
|
|
def __init__(self):
|
|
self.temp_sensor = TempSensorSimulated()
|
|
|
|
class TempSensor(threading.Thread):
|
|
def __init__(self):
|
|
threading.Thread.__init__(self)
|
|
self.daemon = True
|
|
self.temperature = 0
|
|
self.bad_percent = 0
|
|
self.time_step = config.sensor_time_wait
|
|
self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
|
|
|
|
class TempSensorSimulated(TempSensor):
|
|
'''not much here, just need to be able to set the temperature'''
|
|
def __init__(self):
|
|
TempSensor.__init__(self)
|
|
|
|
class TempSensorReal(TempSensor):
|
|
'''real temperature sensor thread that takes N measurements
|
|
during the time_step'''
|
|
def __init__(self):
|
|
TempSensor.__init__(self)
|
|
self.sleeptime = self.time_step / float(config.temperature_average_samples)
|
|
self.bad_count = 0
|
|
self.ok_count = 0
|
|
self.bad_stamp = 0
|
|
|
|
if config.max31855:
|
|
log.info("init MAX31855")
|
|
from max31855 import MAX31855, MAX31855Error
|
|
self.thermocouple = MAX31855(config.gpio_sensor_cs,
|
|
config.gpio_sensor_clock,
|
|
config.gpio_sensor_data,
|
|
config.temp_scale)
|
|
|
|
if config.max31856:
|
|
log.info("init MAX31856")
|
|
from max31856 import MAX31856
|
|
software_spi = { 'cs': config.gpio_sensor_cs,
|
|
'clk': config.gpio_sensor_clock,
|
|
'do': config.gpio_sensor_data,
|
|
'di': config.gpio_sensor_di }
|
|
self.thermocouple = MAX31856(tc_type=config.thermocouple_type,
|
|
software_spi = software_spi,
|
|
units = config.temp_scale,
|
|
ac_freq_50hz = config.ac_freq_50hz,
|
|
)
|
|
|
|
def run(self):
|
|
'''use a moving average of config.temperature_average_samples across the time_step'''
|
|
temps = []
|
|
while True:
|
|
# reset error counter if time is up
|
|
if (time.time() - self.bad_stamp) > (self.time_step * 2):
|
|
if self.bad_count + self.ok_count:
|
|
self.bad_percent = (self.bad_count / (self.bad_count + self.ok_count)) * 100
|
|
else:
|
|
self.bad_percent = 0
|
|
self.bad_count = 0
|
|
self.ok_count = 0
|
|
self.bad_stamp = time.time()
|
|
|
|
temp = self.thermocouple.get()
|
|
self.noConnection = self.thermocouple.noConnection
|
|
self.shortToGround = self.thermocouple.shortToGround
|
|
self.shortToVCC = self.thermocouple.shortToVCC
|
|
self.unknownError = self.thermocouple.unknownError
|
|
|
|
is_bad_value = self.noConnection | self.unknownError
|
|
if config.honour_theromocouple_short_errors:
|
|
is_bad_value |= self.shortToGround | self.shortToVCC
|
|
|
|
if not is_bad_value:
|
|
temps.append(temp)
|
|
if len(temps) > config.temperature_average_samples:
|
|
del temps[0]
|
|
self.ok_count += 1
|
|
|
|
else:
|
|
log.error("Problem reading temp N/C:%s GND:%s VCC:%s ???:%s" % (self.noConnection,self.shortToGround,self.shortToVCC,self.unknownError))
|
|
self.bad_count += 1
|
|
|
|
if len(temps):
|
|
self.temperature = sum(temps) / len(temps)
|
|
time.sleep(self.sleeptime)
|
|
|
|
class Oven(threading.Thread):
|
|
'''parent oven class. this has all the common code
|
|
for either a real or simulated oven'''
|
|
def __init__(self):
|
|
threading.Thread.__init__(self)
|
|
self.daemon = True
|
|
self.temperature = 0
|
|
self.time_step = config.sensor_time_wait
|
|
self.scheduled_run_timer = None
|
|
self.start_datetime = None
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.state = "IDLE"
|
|
if self.scheduled_run_timer and self.scheduled_run_timer.is_alive():
|
|
log.info("Cancelling previously scheduled run")
|
|
self.scheduled_run_timer.cancel()
|
|
self.start_datetime = None
|
|
self.profile = None
|
|
self.start_time = 0
|
|
self.runtime = 0
|
|
self.totaltime = 0
|
|
self.target = 0
|
|
self.heat = 0
|
|
self.pid = PID(ki=config.pid_ki, kd=config.pid_kd, kp=config.pid_kp)
|
|
|
|
def run_profile(self, profile, startat=0):
|
|
self.reset()
|
|
|
|
if self.board.temp_sensor.noConnection:
|
|
log.info("Refusing to start profile - thermocouple not connected")
|
|
return
|
|
if self.board.temp_sensor.shortToGround:
|
|
log.info("Refusing to start profile - thermocouple short to ground")
|
|
return
|
|
if self.board.temp_sensor.shortToVCC:
|
|
log.info("Refusing to start profile - thermocouple short to VCC")
|
|
return
|
|
if self.board.temp_sensor.unknownError:
|
|
log.info("Refusing to start profile - thermocouple unknown error")
|
|
return
|
|
|
|
log.info("Running schedule %s" % profile.name)
|
|
self.profile = profile
|
|
self.totaltime = profile.get_duration()
|
|
self.state = "RUNNING"
|
|
self.start_time = datetime.datetime.now()
|
|
self.startat = startat * 60
|
|
log.info("Starting")
|
|
|
|
def scheduled_run(self, start_datetime, profile, run_trigger, startat=0):
|
|
self.reset()
|
|
seconds_until_start = (
|
|
start_datetime - datetime.datetime.now()
|
|
).total_seconds()
|
|
if seconds_until_start <= 0:
|
|
return
|
|
|
|
self.state = "SCHEDULED"
|
|
self.start_datetime = start_datetime
|
|
self.scheduled_run_timer = Timer(
|
|
seconds_until_start,
|
|
self._timeout,
|
|
args=[profile, run_trigger, startat],
|
|
)
|
|
self.scheduled_run_timer.start()
|
|
log.info(
|
|
"Scheduled to run the kiln at %s",
|
|
self.start_datetime,
|
|
)
|
|
|
|
def _timeout(self, profile, run_trigger, startat):
|
|
self.run_profile(profile, startat)
|
|
if run_trigger:
|
|
run_trigger()
|
|
|
|
def abort_run(self):
|
|
self.reset()
|
|
|
|
def kiln_must_catch_up(self):
|
|
'''shift the whole schedule forward in time by one time_step
|
|
to wait for the kiln to catch up'''
|
|
if config.kiln_must_catch_up == True:
|
|
temp = self.board.temp_sensor.temperature + \
|
|
config.thermocouple_offset
|
|
# kiln too cold, wait for it to heat up
|
|
if self.target - temp > config.kiln_must_catch_up_max_error:
|
|
log.info("kiln must catch up, too cold, shifting schedule")
|
|
self.start_time = self.start_time + \
|
|
datetime.timedelta(seconds=self.time_step)
|
|
# kiln too hot, wait for it to cool down
|
|
if temp - self.target > config.kiln_must_catch_up_max_error:
|
|
log.info("kiln must catch up, too hot, shifting schedule")
|
|
self.start_time = self.start_time + \
|
|
datetime.timedelta(seconds=self.time_step)
|
|
|
|
def update_runtime(self):
|
|
runtime_delta = datetime.datetime.now() - self.start_time
|
|
if runtime_delta.total_seconds() < 0:
|
|
runtime_delta = datetime.timedelta(0)
|
|
|
|
if self.startat > 0:
|
|
self.runtime = self.startat + runtime_delta.total_seconds()
|
|
else:
|
|
self.runtime = runtime_delta.total_seconds()
|
|
|
|
def update_target_temp(self):
|
|
self.target = self.profile.get_target_temperature(self.runtime)
|
|
|
|
def reset_if_emergency(self):
|
|
'''reset if the temperature is way TOO HOT, or other critical errors detected'''
|
|
if (self.board.temp_sensor.temperature + config.thermocouple_offset >=
|
|
config.emergency_shutoff_temp):
|
|
log.info("emergency!!! temperature too high, shutting down")
|
|
self.reset()
|
|
|
|
if self.board.temp_sensor.noConnection:
|
|
log.info("emergency!!! lost connection to thermocouple, shutting down")
|
|
self.reset()
|
|
|
|
if self.board.temp_sensor.unknownError:
|
|
log.info("emergency!!! unknown thermocouple error, shutting down")
|
|
self.reset()
|
|
|
|
if self.board.temp_sensor.bad_percent > 30:
|
|
log.info("emergency!!! too many errors in a short period, shutting down")
|
|
self.reset()
|
|
|
|
def reset_if_schedule_ended(self):
|
|
if self.runtime > self.totaltime:
|
|
log.info("schedule ended, shutting down")
|
|
self.reset()
|
|
|
|
def get_state(self):
|
|
scheduled_start = None
|
|
if self.start_datetime:
|
|
scheduled_start = self.start_datetime.strftime("%Y-%m-%d %H:%M")
|
|
state = {
|
|
'runtime': self.runtime,
|
|
'temperature': self.board.temp_sensor.temperature + config.thermocouple_offset,
|
|
'target': self.target,
|
|
'state': self.state,
|
|
'heat': self.heat,
|
|
'totaltime': self.totaltime,
|
|
'kwh_rate': config.kwh_rate,
|
|
'currency_type': config.currency_type,
|
|
'profile': self.profile.name if self.profile else None,
|
|
'pidstats': self.pid.pidstats,
|
|
'scheduled_start': scheduled_start,
|
|
}
|
|
return state
|
|
|
|
def run(self):
|
|
while True:
|
|
if self.state == "IDLE":
|
|
time.sleep(1)
|
|
continue
|
|
if self.state == "RUNNING":
|
|
self.kiln_must_catch_up()
|
|
self.update_runtime()
|
|
self.update_target_temp()
|
|
self.heat_then_cool()
|
|
self.reset_if_emergency()
|
|
self.reset_if_schedule_ended()
|
|
|
|
|
|
class SimulatedOven(Oven):
|
|
|
|
def __init__(self):
|
|
# call parent init
|
|
Oven.__init__(self)
|
|
self.board = BoardSimulated()
|
|
|
|
self.t_env = config.sim_t_env
|
|
self.c_heat = config.sim_c_heat
|
|
self.c_oven = config.sim_c_oven
|
|
self.p_heat = config.sim_p_heat
|
|
self.R_o_nocool = config.sim_R_o_nocool
|
|
self.R_ho_noair = config.sim_R_ho_noair
|
|
self.R_ho = self.R_ho_noair
|
|
|
|
# set temps to the temp of the surrounding environment
|
|
self.t = self.t_env # deg C temp of oven
|
|
self.t_h = self.t_env #deg C temp of heating element
|
|
|
|
# start thread
|
|
self.start()
|
|
log.info("SimulatedOven started")
|
|
|
|
def heating_energy(self,pid):
|
|
# using pid here simulates the element being on for
|
|
# only part of the time_step
|
|
self.Q_h = self.p_heat * self.time_step * pid
|
|
|
|
def temp_changes(self):
|
|
#temperature change of heat element by heating
|
|
self.t_h += self.Q_h / self.c_heat
|
|
|
|
#energy flux heat_el -> oven
|
|
self.p_ho = (self.t_h - self.t) / self.R_ho
|
|
|
|
#temperature change of oven and heating element
|
|
self.t += self.p_ho * self.time_step / self.c_oven
|
|
self.t_h -= self.p_ho * self.time_step / self.c_heat
|
|
|
|
#temperature change of oven by cooling to environment
|
|
self.p_env = (self.t - self.t_env) / self.R_o_nocool
|
|
self.t -= self.p_env * self.time_step / self.c_oven
|
|
self.temperature = self.t
|
|
self.board.temp_sensor.temperature = self.t
|
|
|
|
def heat_then_cool(self):
|
|
pid = self.pid.compute(self.target,
|
|
self.board.temp_sensor.temperature +
|
|
config.thermocouple_offset)
|
|
heat_on = float(self.time_step * pid)
|
|
heat_off = float(self.time_step * (1 - pid))
|
|
|
|
self.heating_energy(pid)
|
|
self.temp_changes()
|
|
|
|
# self.heat is for the front end to display if the heat is on
|
|
self.heat = 0.0
|
|
if heat_on > 0:
|
|
self.heat = heat_on
|
|
|
|
log.info("simulation: -> %dW heater: %.0f -> %dW oven: %.0f -> %dW env" % (int(self.p_heat * pid),
|
|
self.t_h,
|
|
int(self.p_ho),
|
|
self.t,
|
|
int(self.p_env)))
|
|
|
|
time_left = self.totaltime - self.runtime
|
|
log.info("temp=%.2f, target=%.2f, pid=%.3f, heat_on=%.2f, heat_off=%.2f, run_time=%d, total_time=%d, time_left=%d" %
|
|
(self.board.temp_sensor.temperature + config.thermocouple_offset,
|
|
self.target,
|
|
pid,
|
|
heat_on,
|
|
heat_off,
|
|
self.runtime,
|
|
self.totaltime,
|
|
time_left))
|
|
|
|
# we don't actually spend time heating & cooling during
|
|
# a simulation, so sleep.
|
|
time.sleep(self.time_step)
|
|
|
|
|
|
class RealOven(Oven):
|
|
|
|
def __init__(self):
|
|
self.board = Board()
|
|
self.output = Output()
|
|
# call parent init
|
|
Oven.__init__(self)
|
|
|
|
self.reset()
|
|
|
|
# start thread
|
|
self.start()
|
|
|
|
def reset(self):
|
|
super().reset()
|
|
self.output.cool(0)
|
|
|
|
def heat_then_cool(self):
|
|
pid = self.pid.compute(self.target,
|
|
self.board.temp_sensor.temperature +
|
|
config.thermocouple_offset)
|
|
heat_on = float(self.time_step * pid)
|
|
heat_off = float(self.time_step * (1 - pid))
|
|
|
|
# self.heat is for the front end to display if the heat is on
|
|
self.heat = 0.0
|
|
if heat_on > 0:
|
|
self.heat = 1.0
|
|
|
|
if heat_on:
|
|
self.output.heat(heat_on)
|
|
if heat_off:
|
|
self.output.cool(heat_off)
|
|
time_left = self.totaltime - self.runtime
|
|
log.info("temp=%.2f, target=%.2f, pid=%.3f, heat_on=%.2f, heat_off=%.2f, run_time=%d, total_time=%d, time_left=%d" %
|
|
(self.board.temp_sensor.temperature + config.thermocouple_offset,
|
|
self.target,
|
|
pid,
|
|
heat_on,
|
|
heat_off,
|
|
self.runtime,
|
|
self.totaltime,
|
|
time_left))
|
|
|
|
class Profile():
|
|
def __init__(self, json_data):
|
|
obj = json.loads(json_data)
|
|
self.name = obj["name"]
|
|
self.data = sorted(obj["data"])
|
|
|
|
def get_duration(self):
|
|
return max([t for (t, x) in self.data])
|
|
|
|
def get_surrounding_points(self, time):
|
|
if time > self.get_duration():
|
|
return (None, None)
|
|
|
|
prev_point = None
|
|
next_point = None
|
|
|
|
for i in range(len(self.data)):
|
|
if time < self.data[i][0]:
|
|
prev_point = self.data[i-1]
|
|
next_point = self.data[i]
|
|
break
|
|
|
|
return (prev_point, next_point)
|
|
|
|
def get_target_temperature(self, time):
|
|
if time > self.get_duration():
|
|
return 0
|
|
|
|
(prev_point, next_point) = self.get_surrounding_points(time)
|
|
|
|
incl = float(next_point[1] - prev_point[1]) / float(next_point[0] - prev_point[0])
|
|
temp = prev_point[1] + (time - prev_point[0]) * incl
|
|
return temp
|
|
|
|
|
|
class PID():
|
|
|
|
def __init__(self, ki=1, kp=1, kd=1):
|
|
self.ki = ki
|
|
self.kp = kp
|
|
self.kd = kd
|
|
self.lastNow = datetime.datetime.now()
|
|
self.iterm = 0
|
|
self.lastErr = 0
|
|
self.pidstats = {}
|
|
|
|
# FIX - this was using a really small window where the PID control
|
|
# takes effect from -1 to 1. I changed this to various numbers and
|
|
# settled on -50 to 50 and then divide by 50 at the end. This results
|
|
# in a larger PID control window and much more accurate control...
|
|
# instead of what used to be binary on/off control.
|
|
def compute(self, setpoint, ispoint):
|
|
now = datetime.datetime.now()
|
|
timeDelta = (now - self.lastNow).total_seconds()
|
|
|
|
window_size = 100
|
|
|
|
error = float(setpoint - ispoint)
|
|
|
|
if self.ki > 0:
|
|
if config.stop_integral_windup == True:
|
|
if abs(self.kp * error) < window_size:
|
|
self.iterm += (error * timeDelta * (1/self.ki))
|
|
else:
|
|
self.iterm += (error * timeDelta * (1/self.ki))
|
|
|
|
dErr = (error - self.lastErr) / timeDelta
|
|
output = self.kp * error + self.iterm + self.kd * dErr
|
|
out4logs = output
|
|
output = sorted([-1 * window_size, output, window_size])[1]
|
|
self.lastErr = error
|
|
self.lastNow = now
|
|
|
|
# not actively cooling, so
|
|
if output < 0:
|
|
output = 0
|
|
|
|
output = float(output / window_size)
|
|
|
|
self.pidstats = {
|
|
'time': time.mktime(now.timetuple()),
|
|
'timeDelta': timeDelta,
|
|
'setpoint': setpoint,
|
|
'ispoint': ispoint,
|
|
'err': error,
|
|
'errDelta': dErr,
|
|
'p': self.kp * error,
|
|
'i': self.iterm,
|
|
'd': self.kd * dErr,
|
|
'kp': self.kp,
|
|
'ki': self.ki,
|
|
'kd': self.kd,
|
|
'pid': out4logs,
|
|
'out': output,
|
|
}
|
|
|
|
if out4logs > 0:
|
|
# log.info("pid percents pid=%0.2f p=%0.2f i=%0.2f d=%0.2f" % (out4logs,
|
|
# ((self.kp * error)/out4logs)*100,
|
|
# (self.iterm/out4logs)*100,
|
|
# ((self.kd * dErr)/out4logs)*100))
|
|
log.info("pid actuals pid=%0.2f p=%0.2f i=%0.2f d=%0.2f" % (out4logs,
|
|
self.kp * error,
|
|
self.iterm,
|
|
self.kd * dErr))
|
|
|
|
return output
|