kopia lustrzana https://github.com/jbruce12000/kiln-controller
Add screen with simple menu for offline use, plus some hacks
rodzic
c39c235c4f
commit
ab5b8ab514
18
config.py
18
config.py
|
|
@ -41,8 +41,8 @@ gpio_heat = 23 # Switches zero-cross solid-state-relay
|
|||
### Thermocouple Adapter selection:
|
||||
# max31855 - bitbang SPI interface
|
||||
# max31856 - bitbang SPI interface. must specify thermocouple_type.
|
||||
max31855 = 1
|
||||
max31856 = 0
|
||||
max31855 = 0
|
||||
max31856 = 1
|
||||
# see lib/max31856.py for other thermocouple_type, only applies to max31856
|
||||
# uncomment this if using MAX-31856
|
||||
thermocouple_type = MAX31856.MAX31856_S_TYPE
|
||||
|
|
@ -60,7 +60,7 @@ gpio_sensor_di = 10 # only used with max31856
|
|||
# Every N seconds a decision is made about switching the relay[s]
|
||||
# on & off and for how long. The thermocouple is read
|
||||
# temperature_average_samples times during and the average value is used.
|
||||
sensor_time_wait = 3
|
||||
sensor_time_wait = 5
|
||||
|
||||
|
||||
########################################################################
|
||||
|
|
@ -71,9 +71,9 @@ sensor_time_wait = 3
|
|||
# well with the simulated oven. You must tune them to work well with
|
||||
# your specific kiln. Note that the integral pid_ki is
|
||||
# inverted so that a smaller number means more integral action.
|
||||
pid_kp = 25 # Proportional 25,200,200
|
||||
pid_ki = 10 # Integral
|
||||
pid_kd = 200 # Derivative
|
||||
pid_kp = 20 # Proportional 25,200,200
|
||||
pid_ki = 100 # Integral
|
||||
pid_kd = 1000 # Derivative
|
||||
|
||||
|
||||
########################################################################
|
||||
|
|
@ -87,7 +87,7 @@ stop_integral_windup = True
|
|||
########################################################################
|
||||
#
|
||||
# Simulation parameters
|
||||
simulate = True
|
||||
simulate = False
|
||||
sim_t_env = 20.0 # deg C
|
||||
sim_c_heat = 500.0 # J/K heat capacity of heat element
|
||||
sim_c_oven = 500.0 # J/K heat capacity of oven
|
||||
|
|
@ -128,7 +128,7 @@ kiln_must_catch_up = True
|
|||
# or 100% off because the kiln is too hot. No integral builds up
|
||||
# outside the window. The bigger you make the window, the more
|
||||
# integral you will accumulate. This should be a positive integer.
|
||||
pid_control_window = 10 #degrees
|
||||
pid_control_window = 15 #degrees
|
||||
|
||||
# thermocouple offset
|
||||
# If you put your thermocouple in ice water and it reads 36F, you can
|
||||
|
|
@ -143,7 +143,7 @@ thermocouple_offset=0
|
|||
temperature_average_samples = 40
|
||||
|
||||
# Thermocouple AC frequency filtering - set to True if in a 50Hz locale, else leave at False for 60Hz locale
|
||||
ac_freq_50hz = False
|
||||
ac_freq_50hz = True
|
||||
|
||||
########################################################################
|
||||
# Emergencies - or maybe not
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import logging
|
||||
import threading
|
||||
from RPLCD.i2c import CharLCD
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
lock = threading.Lock()
|
||||
|
||||
class Display(object):
|
||||
def __init__(self):
|
||||
self.width = 16
|
||||
self.height = 2
|
||||
self.lcd = CharLCD(i2c_expander='PCF8574', address=0x27, port=1,
|
||||
cols=self.width, rows=self.height,
|
||||
auto_linebreaks=False,
|
||||
backlight_enabled=True)
|
||||
self.lcd.cursor_mode = 'hide'
|
||||
self.clear()
|
||||
self.last_text = [''.ljust(self.width, ' ')] * self.height
|
||||
|
||||
def set_text(self, lines, force_redraw=True):
|
||||
lines = list(map(lambda l: l.ljust(self.width, ' '), lines))
|
||||
while len(lines) < self.height:
|
||||
lines.append(''.ljust(self.width, ' '))
|
||||
diffs = []
|
||||
for j in range(0, len(lines)):
|
||||
if (len(lines[j]) is not len(self.last_text[j])):
|
||||
log.info('########### ' + lines[j] + ' - ' + self.last_text[j])
|
||||
for i in range(0, len(lines[j])):
|
||||
if lines[j][i] != self.last_text[j][i]:
|
||||
diffs.append((j, i))
|
||||
if len(diffs) > 0:
|
||||
log.info('setting text to ' + str(lines))
|
||||
with lock:
|
||||
if len(diffs) < self.width*self.height/4 and not force_redraw:
|
||||
log.info('updating only diffs')
|
||||
for diff in diffs:
|
||||
c = str(lines[diff[0]][diff[1]])
|
||||
#log.info('updating pos ' + str(diff) + ' to "' + c + '"')
|
||||
self.lcd.cursor_pos = diff
|
||||
self.lcd.write_string(c)
|
||||
self.lcd.cursor_pos = (0, 0)
|
||||
else:
|
||||
log.info('updating full display')
|
||||
#self.clear()
|
||||
self.lcd.cursor_pos = (0, 0)
|
||||
for line in lines:
|
||||
self.lcd.write_string(line)
|
||||
self.lcd.crlf()
|
||||
self.lcd.cursor_pos = (0, 0)
|
||||
log.info('updating last_text from\n' + str(self.last_text) + ' to\n' + str(lines))
|
||||
self.last_text = lines
|
||||
|
||||
def clear(self):
|
||||
self.lcd.clear()
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import threading,logging,time,json
|
||||
import os
|
||||
import config
|
||||
from datetime import datetime, timedelta
|
||||
from lib.display import Display
|
||||
from lib.rotaryinput import RotaryInput
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_profiles():
|
||||
try:
|
||||
profile_files = os.listdir(config.kiln_profiles_directory)
|
||||
except:
|
||||
profile_files = []
|
||||
profiles = []
|
||||
for filename in profile_files:
|
||||
with open(os.path.join(config.kiln_profiles_directory, filename), 'r') as f:
|
||||
profiles.append(json.load(f))
|
||||
return profiles
|
||||
|
||||
class LCD():
|
||||
def __init__(self):
|
||||
self.clear()
|
||||
|
||||
def write_string(self, string):
|
||||
s = self.text[self.cursor[0]]
|
||||
self.text[self.cursor[0]] = s[:self.cursor[1]] + string + s[self.cursor[1]+len(string):]
|
||||
|
||||
def clear(self):
|
||||
self.text = [' ' * 16, ' ' * 16]
|
||||
self.cursor = (0, 0)
|
||||
|
||||
def cursor_pos(self, pos):
|
||||
self.cursor = pos
|
||||
|
||||
def show(self):
|
||||
#os.system('clear')
|
||||
print('-'*18)
|
||||
for t in self.text:
|
||||
print('|'+t+'|')
|
||||
print('-'*18)
|
||||
|
||||
#lcd = LCD()
|
||||
lcd = Display()
|
||||
|
||||
class MenuNode():
|
||||
def __init__(self, text=None, type='menu', children=[], action=None, profile=None):
|
||||
self.parent = None
|
||||
self.type = type
|
||||
self.text = text
|
||||
self.action = action
|
||||
self.children = children
|
||||
self.profile = profile
|
||||
for child in children:
|
||||
child.set_parent(self)
|
||||
self.reset()
|
||||
|
||||
def add_child(self, child):
|
||||
if self.children == None:
|
||||
self.children = []
|
||||
child.set_parent(self)
|
||||
self.children.append(child)
|
||||
|
||||
def set_parent(self, parent):
|
||||
self.parent = parent
|
||||
|
||||
def update_index(self, direction):
|
||||
self.index = max(0, min(self.index+direction, len(self.children)-1))
|
||||
|
||||
def display_as_child(self, selected):
|
||||
return ('>' if selected else ' ') + self.text.ljust(14, ' ')[:14:] + ('>' if self.type == 'menu' else ' ')
|
||||
|
||||
def display_confirm_options(self):
|
||||
no = ('>' if self.index == 0 else ' ') + self.children[0].text
|
||||
yes = ('>' if self.index == 1 else ' ') + self.children[1].text
|
||||
return no.ljust(16-len(yes), ' ') + yes
|
||||
|
||||
def reset(self):
|
||||
self.index = 0
|
||||
|
||||
|
||||
|
||||
class Menu():
|
||||
def __init__(self, profiles, oven):
|
||||
self.build_menu(profiles)
|
||||
self.node = None
|
||||
self.oven = oven
|
||||
#self.node = self.off.children[1].children[1]
|
||||
#self.node.index = 1
|
||||
|
||||
def build_menu(self, profiles):
|
||||
self.off = MenuNode(type='menu', children=[
|
||||
MenuNode(text='Back', action=self.go_up),
|
||||
MenuNode(text='Profiles', children=self.build_profiles_list(profiles))
|
||||
])
|
||||
|
||||
self.running = MenuNode(type='menu', children=[
|
||||
MenuNode(text='Back', action=self.go_up),
|
||||
MenuNode(text='Abort profile', children=[self.create_confirm_node(self.abort_profile)])
|
||||
])
|
||||
|
||||
def build_profiles_list(self, profiles):
|
||||
nodes = [MenuNode(text='Back', action=self.go_up)]
|
||||
for profile in profiles:
|
||||
node = MenuNode(text=profile['name'], profile=profile, children=[
|
||||
MenuNode(text='Back', action=self.go_up),
|
||||
MenuNode(text='Start', children=[self.create_confirm_node(self.start_profile, profile)])
|
||||
])
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def create_confirm_node(self, action, profile=None):
|
||||
return MenuNode(text=' Are you sure?', type='confirm', profile=profile, children=[
|
||||
MenuNode(text='No', action=self.go_up),
|
||||
MenuNode(text='Yes', action=action)
|
||||
])
|
||||
|
||||
def click(self):
|
||||
if self.node is None:
|
||||
# select menu based on oven state
|
||||
self.node = self.off if self.oven.profile is None else self.running
|
||||
return
|
||||
|
||||
child = self.node.children[self.node.index]
|
||||
if child.action != None:
|
||||
child.action()
|
||||
elif len(child.children) == 0:
|
||||
return
|
||||
else:
|
||||
self.node = child
|
||||
while len(self.node.children) == 1:
|
||||
self.node = self.node.children[0]
|
||||
|
||||
|
||||
def next(self):
|
||||
if self.node is not None:
|
||||
self.node.update_index(1)
|
||||
|
||||
def previous(self):
|
||||
if self.node is not None:
|
||||
self.node.update_index(-1)
|
||||
|
||||
def go_up(self):
|
||||
self.node = self.node.parent
|
||||
while self.node is not None and len(self.node.children) == 1:
|
||||
self.node = self.node.parent
|
||||
|
||||
def start_profile(self):
|
||||
self.oven.run_json_profile(json.dumps(self.node.profile))
|
||||
#self.oven.profile = self.node.profile
|
||||
self.node.reset()
|
||||
self.node = None
|
||||
|
||||
def abort_profile(self):
|
||||
#self.oven.profile = None
|
||||
self.oven.abort_run()
|
||||
self.node.reset()
|
||||
self.node = None
|
||||
|
||||
|
||||
fake_profiles = [{'profile': 'Bisque'}, {'profile': 'Glaze'}, {'profile': 'GlazeHigh'}, {'profile': 'Earthen'}]
|
||||
real_profiles = get_profiles()
|
||||
class OvenDisplay(threading.Thread):
|
||||
def __init__(self,oven):
|
||||
log.info('Starting oven display')
|
||||
threading.Thread.__init__(self)
|
||||
self.sleep_time = 1
|
||||
#self.daemon = True
|
||||
self.oven = oven
|
||||
log.info(real_profiles)
|
||||
self.menu = Menu(real_profiles, oven)
|
||||
self.input = RotaryInput()
|
||||
self.input.on_next(self.next)
|
||||
self.input.on_previous(self.previous)
|
||||
self.input.on_click(self.click)
|
||||
self.start()
|
||||
|
||||
def next(self):
|
||||
self.menu.next()
|
||||
self.update_display()
|
||||
|
||||
def previous(self):
|
||||
self.menu.previous()
|
||||
self.update_display()
|
||||
|
||||
def click(self):
|
||||
self.menu.click()
|
||||
self.update_display()
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self.update_display()
|
||||
#inp = input()
|
||||
#if inp == 'a':
|
||||
# self.menu.previous()
|
||||
#if inp == 'd':
|
||||
# self.menu.next()
|
||||
#if inp == 'w':
|
||||
# self.menu.click()
|
||||
time.sleep(self.sleep_time)
|
||||
|
||||
def update_display(self):
|
||||
node = self.menu.node
|
||||
if node is None:
|
||||
self.show_status()
|
||||
elif node.type == 'menu':
|
||||
first_index = max(0, min(node.index, len(node.children)-2))
|
||||
new_text = [node.children[first_index].display_as_child(node.index == first_index)]
|
||||
if first_index+1 < len(node.children):
|
||||
new_text.append(node.children[first_index+1].display_as_child(node.index == first_index+1))
|
||||
lcd.set_text(new_text)
|
||||
elif node.type == 'confirm':
|
||||
lcd.set_text([node.text, node.display_confirm_options()])
|
||||
|
||||
def show_status(self):
|
||||
new_text = [(str(round(self.oven.board.temp_sensor.temperature + config.thermocouple_offset)) +
|
||||
#('/' + str(round(self.oven.target)) if self.oven.target > 0 else '')).ljust(12, ' ') +
|
||||
#(str(self.oven.load) + '%' if self.oven.load > 0 else ' OFF') +
|
||||
('/' + str(round(self.oven.target)) if self.oven.target > 0 else '')).ljust(12, ' ') +
|
||||
(' H' if (self.oven.heat > 0 and self.oven.profile is not None) else ' OFF')]
|
||||
if self.oven.profile is None:
|
||||
new_text.append('Kiln on standby')
|
||||
else:
|
||||
time_left = timedelta(seconds=self.oven.totaltime - self.oven.runtime)
|
||||
new_text.append(self.oven.profile.name.ljust(10, ' ')[:10:] + ' ' + (datetime.now() + time_left).strftime("%H:%M"))
|
||||
lcd.set_text(new_text, True)
|
||||
|
||||
class Oven():
|
||||
def __init__(self):
|
||||
self.profile = None
|
||||
self.temp = 1030
|
||||
self.target_temp = 1035
|
||||
self.load = 75
|
||||
self.heating = True
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from lib.menu import Menu
|
||||
|
||||
fake_profiles = [{'profile': 'Bisque'}, {'profile': 'Glaze'}, {'profile': 'GlazeHigh'}, {'profile': 'Earthen'}]
|
||||
|
||||
|
||||
def setup(profile=None):
|
||||
oven = Mock()
|
||||
oven.profile = profile
|
||||
return Menu(fake_profiles, oven)
|
||||
|
||||
def test_init():
|
||||
m = setup()
|
||||
assert m.node == None
|
||||
|
||||
def test_click_none_oven_off():
|
||||
m = setup()
|
||||
m.click()
|
||||
assert m.node == m.off
|
||||
|
||||
def test_click_none_oven_running():
|
||||
m = setup(True)
|
||||
m.click()
|
||||
assert m.node == m.running
|
||||
|
||||
def test_click_menu_back():
|
||||
m = setup()
|
||||
m.click()
|
||||
m.click()
|
||||
assert m.node == None
|
||||
|
||||
def test_previous_next():
|
||||
m = setup()
|
||||
m.click()
|
||||
m.next()
|
||||
assert m.node.index == 1
|
||||
m.previous()
|
||||
m.click()
|
||||
assert m.node == None
|
||||
|
||||
def test_start_program():
|
||||
m = setup()
|
||||
m.click() #menu
|
||||
m.next()
|
||||
m.click() #profiles
|
||||
m.next()
|
||||
m.click() #Bisque
|
||||
profile = m.node.profile
|
||||
m.next()
|
||||
m.click() #Start
|
||||
assert "Are you sure" in m.node.text
|
||||
m.click() #No
|
||||
m.click() #Start
|
||||
m.next() #Yes
|
||||
m.click()
|
||||
assert m.node == None #Menu reset
|
||||
assert m.oven.profile == profile #Profile started
|
||||
|
||||
def test_abort_program():
|
||||
m = setup(fake_profiles[0])
|
||||
m.click() #Menu
|
||||
m.click() #Back
|
||||
assert m.node == None
|
||||
assert m.oven.profile == fake_profiles[0]
|
||||
m.click() #Menu
|
||||
m.next()
|
||||
m.click() #Abort
|
||||
assert "Are you sure" in m.node.text
|
||||
m.next() #Yes
|
||||
m.click()
|
||||
assert m.node == None #Menu reset
|
||||
assert m.oven.profile == None #Profile aborted
|
||||
206
lib/oven.py
206
lib/oven.py
|
|
@ -7,6 +7,8 @@ import json
|
|||
import config
|
||||
import os
|
||||
|
||||
from lib.menu import OvenDisplay
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class DupFilter(object):
|
||||
|
|
@ -49,11 +51,23 @@ class Output(object):
|
|||
|
||||
def heat(self,sleepfor):
|
||||
self.GPIO.output(config.gpio_heat, self.GPIO.HIGH)
|
||||
log.info('Starting heat')
|
||||
time.sleep(sleepfor)
|
||||
|
||||
def cool(self,sleepfor):
|
||||
'''no active cooling, so sleep'''
|
||||
self.GPIO.output(config.gpio_heat, self.GPIO.LOW)
|
||||
log.info('Ending heat')
|
||||
time.sleep(sleepfor)
|
||||
|
||||
class OutputSimulated(object):
|
||||
def __init__(self):
|
||||
self.active = True
|
||||
|
||||
def heat(self,sleepfor):
|
||||
time.sleep(sleepfor)
|
||||
|
||||
def cool(self,sleepfor):
|
||||
time.sleep(sleepfor)
|
||||
|
||||
# FIX - Board class needs to be completely removed
|
||||
|
|
@ -199,9 +213,11 @@ class Oven(threading.Thread):
|
|||
def __init__(self):
|
||||
threading.Thread.__init__(self)
|
||||
self.daemon = True
|
||||
self.temperature = 0
|
||||
self.time_step = config.sensor_time_wait
|
||||
self.board = Board()
|
||||
self.output = Output()
|
||||
self.reset()
|
||||
self.display = OvenDisplay(self)
|
||||
|
||||
def reset(self):
|
||||
self.cost = 0
|
||||
|
|
@ -213,10 +229,11 @@ class Oven(threading.Thread):
|
|||
self.target = 0
|
||||
self.heat = 0
|
||||
self.pid = PID(ki=config.pid_ki, kd=config.pid_kd, kp=config.pid_kp)
|
||||
self.on_first = True
|
||||
self.switch_count = 0
|
||||
self.load = 0
|
||||
|
||||
def run_profile(self, profile, startat=0):
|
||||
log.info(profile)
|
||||
self.reset()
|
||||
|
||||
if self.board.temp_sensor.noConnection:
|
||||
|
|
@ -240,6 +257,9 @@ class Oven(threading.Thread):
|
|||
self.state = "RUNNING"
|
||||
log.info("Running schedule %s starting at %d minutes" % (profile.name,startat))
|
||||
log.info("Starting")
|
||||
|
||||
def run_json_profile(self, profile_json):
|
||||
self.run_profile(Profile(profile_json))
|
||||
|
||||
def abort_run(self):
|
||||
self.reset()
|
||||
|
|
@ -309,12 +329,12 @@ class Oven(threading.Thread):
|
|||
|
||||
def get_state(self):
|
||||
temp = 0
|
||||
try:
|
||||
temp = self.board.temp_sensor.temperature + config.thermocouple_offset
|
||||
except AttributeError as error:
|
||||
#try:
|
||||
temp = self.board.temp_sensor.temperature + config.thermocouple_offset
|
||||
#except AttributeError as error:
|
||||
# this happens at start-up with a simulated oven
|
||||
temp = 0
|
||||
pass
|
||||
# temp = 0
|
||||
# pass
|
||||
|
||||
state = {
|
||||
'cost': self.cost,
|
||||
|
|
@ -327,7 +347,8 @@ class Oven(threading.Thread):
|
|||
'kwh_rate': config.kwh_rate,
|
||||
'currency_type': config.currency_type,
|
||||
'profile': self.profile.name if self.profile else None,
|
||||
'pidstats': self.pid.pidstats,
|
||||
'switch_count': self.switch_count,
|
||||
'pidstats': self.pid.pidstats
|
||||
}
|
||||
return state
|
||||
|
||||
|
|
@ -388,6 +409,41 @@ class Oven(threading.Thread):
|
|||
log.info("ovenwatcher set in oven class")
|
||||
self.ovenwatcher = watcher
|
||||
|
||||
def set_heat(self):
|
||||
pid = self.pid.compute(self.target,
|
||||
self.board.temp_sensor.temperature +
|
||||
config.thermocouple_offset)
|
||||
|
||||
if pid > 0:
|
||||
self.output.heat(self.time_step)
|
||||
if (self.heat != self.time_step):
|
||||
self.switch_count += 1
|
||||
self.heat = self.time_step
|
||||
else:
|
||||
self.output.cool(self.time_step)
|
||||
if (self.heat != 0):
|
||||
self.switch_count += 1
|
||||
self.heat = 0
|
||||
|
||||
time_left = self.totaltime - self.runtime
|
||||
|
||||
try:
|
||||
log.info("temp=%.2f, target=%.2f, error=%.2f, pid=%.2f, p=%.2f, i=%.2f, d=%.2f, heat=%d, run_time=%d, total_time=%d, time_left=%d, switch_count=%d" %
|
||||
(self.pid.pidstats['ispoint'],
|
||||
self.pid.pidstats['setpoint'],
|
||||
self.pid.pidstats['err'],
|
||||
self.pid.pidstats['pid'],
|
||||
self.pid.pidstats['p'],
|
||||
self.pid.pidstats['i'],
|
||||
self.pid.pidstats['d'],
|
||||
self.heat,
|
||||
self.runtime,
|
||||
self.totaltime,
|
||||
time_left,
|
||||
self.switch_count))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
if self.state == "IDLE":
|
||||
|
|
@ -401,14 +457,19 @@ class Oven(threading.Thread):
|
|||
self.kiln_must_catch_up()
|
||||
self.update_runtime()
|
||||
self.update_target_temp()
|
||||
self.heat_then_cool()
|
||||
self.set_heat()
|
||||
self.reset_if_emergency()
|
||||
self.reset_if_schedule_ended()
|
||||
#self.display.update_display()
|
||||
|
||||
class SimulatedOven(Oven):
|
||||
|
||||
def __init__(self):
|
||||
# call parent init
|
||||
Oven.__init__(self)
|
||||
|
||||
self.board = BoardSimulated()
|
||||
#self.output = OutputSimulated()
|
||||
self.t_env = config.sim_t_env
|
||||
self.c_heat = config.sim_c_heat
|
||||
self.c_oven = config.sim_c_oven
|
||||
|
|
@ -421,8 +482,6 @@ class SimulatedOven(Oven):
|
|||
self.t = self.t_env # deg C temp of oven
|
||||
self.t_h = self.t_env #deg C temp of heating element
|
||||
|
||||
super().__init__()
|
||||
|
||||
# start thread
|
||||
self.start()
|
||||
log.info("SimulatedOven started")
|
||||
|
|
@ -446,69 +505,18 @@ class SimulatedOven(Oven):
|
|||
#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)
|
||||
if not self.on_first and heat_on < 0.1 * self.time_step:
|
||||
heat_on = 0
|
||||
if self.on_first and heat_on > 0.9 * self.time_step:
|
||||
heat_on = self.time_step
|
||||
heat_off = self.time_step - heat_on
|
||||
if (heat_off > 0 and heat_off < self.time_step) or (heat_on > 0 and heat_on < self.time_step):
|
||||
self.switch_count += 1
|
||||
def set_heat(self):
|
||||
Oven.set_heat(self)
|
||||
|
||||
self.heating_energy(heat_on)
|
||||
self.heating_energy(self.heat)
|
||||
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
|
||||
self.on_first = not self.on_first
|
||||
|
||||
try:
|
||||
log.info("temp=%.2f, target=%.2f, error=%.2f, pid=%.2f, p=%.2f, i=%.2f, d=%.2f, heat_on=%.2f, heat_off=%.2f, run_time=%d, total_time=%d, time_left=%d, switch_count=%d" %
|
||||
(self.pid.pidstats['ispoint'],
|
||||
self.pid.pidstats['setpoint'],
|
||||
self.pid.pidstats['err'],
|
||||
self.pid.pidstats['pid'],
|
||||
self.pid.pidstats['p'],
|
||||
self.pid.pidstats['i'],
|
||||
self.pid.pidstats['d'],
|
||||
heat_on,
|
||||
heat_off,
|
||||
self.runtime,
|
||||
self.totaltime,
|
||||
time_left,
|
||||
self.switch_count))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# 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()
|
||||
self.reset()
|
||||
|
||||
# call parent init
|
||||
Oven.__init__(self)
|
||||
|
||||
|
|
@ -518,56 +526,7 @@ class RealOven(Oven):
|
|||
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)
|
||||
if not self.on_first and heat_on < 0.1 * self.time_step:
|
||||
heat_on = 0
|
||||
if self.on_first and heat_on > 0.9 * self.time_step:
|
||||
heat_on = self.time_step
|
||||
heat_off = self.time_step - heat_on
|
||||
if (heat_off > 0 and heat_off < self.time_step) or (heat_on > 0 and heat_on < self.time_step):
|
||||
self.switch_count += 1
|
||||
|
||||
# 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
|
||||
|
||||
if self.on_first:
|
||||
if heat_on:
|
||||
self.output.heat(heat_on)
|
||||
if heat_off:
|
||||
self.output.cool(heat_off)
|
||||
else:
|
||||
if heat_off:
|
||||
self.output.cool(heat_off)
|
||||
if heat_on:
|
||||
self.output.heat(heat_on)
|
||||
|
||||
time_left = self.totaltime - self.runtime
|
||||
self.on_first = not self.on_first
|
||||
|
||||
try:
|
||||
log.info("temp=%.2f, target=%.2f, error=%.2f, pid=%.2f, p=%.2f, i=%.2f, d=%.2f, heat_on=%.2f, heat_off=%.2f, run_time=%d, total_time=%d, time_left=%d, switch_count=%d" %
|
||||
(self.pid.pidstats['ispoint'],
|
||||
self.pid.pidstats['setpoint'],
|
||||
self.pid.pidstats['err'],
|
||||
self.pid.pidstats['pid'],
|
||||
self.pid.pidstats['p'],
|
||||
self.pid.pidstats['i'],
|
||||
self.pid.pidstats['d'],
|
||||
heat_on,
|
||||
heat_off,
|
||||
self.runtime,
|
||||
self.totaltime,
|
||||
time_left,
|
||||
self.switch_count))
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
class Profile():
|
||||
def __init__(self, json_data):
|
||||
|
|
@ -615,24 +574,15 @@ class PID():
|
|||
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)
|
||||
|
||||
# this removes the need for config.stop_integral_windup
|
||||
# it turns the controller into a binary on/off switch
|
||||
# any time it's outside the window defined by
|
||||
# config.pid_control_window
|
||||
icomp = 0
|
||||
output = 0
|
||||
out4logs = 0
|
||||
dErr = 0
|
||||
|
|
@ -645,21 +595,15 @@ class PID():
|
|||
log.info("kiln outside pid control window, max heating")
|
||||
output = 1
|
||||
else:
|
||||
icomp = (error * timeDelta * (1/self.ki))
|
||||
self.iterm += (error * timeDelta * (1/self.ki))
|
||||
dErr = (error - self.lastErr) / timeDelta
|
||||
output = self.kp * error + self.iterm + self.kd * dErr
|
||||
output = sorted([-1 * window_size, output, window_size])[1]
|
||||
out4logs = output
|
||||
output = float(output / window_size)
|
||||
output = round(sorted([0, output, 1])[1])
|
||||
|
||||
self.lastErr = error
|
||||
self.lastNow = now
|
||||
|
||||
# no active cooling
|
||||
if output < 0:
|
||||
output = 0
|
||||
|
||||
self.pidstats = {
|
||||
'time': time.mktime(now.timetuple()),
|
||||
'timeDelta': timeDelta,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
# Rotary encoder class based on pigpio library
|
||||
# version: 0.2.5
|
||||
|
||||
try:
|
||||
import pigpio
|
||||
except ModuleNotFoundError:
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
sys.modules['pigpio'] = MagicMock()
|
||||
|
||||
|
||||
import time
|
||||
|
||||
# States
|
||||
dt_gpio1 = 'D' # dt_gpio is high
|
||||
dt_gpio0 = 'd' # dt_gpio is low
|
||||
clk_gpio1 = 'C' # clk_gpio is high
|
||||
clk_gpio0 = 'c' # clk_gpio is low
|
||||
|
||||
# State sequences
|
||||
SEQUENCE_UP = dt_gpio1 + clk_gpio1 + dt_gpio0 + clk_gpio0
|
||||
SEQUENCE_DOWN = clk_gpio1 + dt_gpio1 + clk_gpio0 + dt_gpio0
|
||||
|
||||
|
||||
class Rotary:
|
||||
|
||||
sequence = ''
|
||||
|
||||
# Default values for the rotary encoder
|
||||
min = 0
|
||||
max = 100
|
||||
scale = 1
|
||||
debounce = 300
|
||||
_counter = 0
|
||||
last_counter = 0
|
||||
rotary_callback = None
|
||||
|
||||
# Default values for the sw_gpioitch
|
||||
sw_gpio_debounce = 300
|
||||
long_press_opt = False
|
||||
sw_gpio_short_callback = None
|
||||
sw_gpio_long_callback = None
|
||||
up_callback = None
|
||||
down_callback = None
|
||||
sw_short_callback = None
|
||||
sw_long_callback = None
|
||||
sw_debounce = None
|
||||
|
||||
wait_time = time.time()
|
||||
long = False
|
||||
|
||||
def __init__(self, clk_gpio=None, dt_gpio=None, sw_gpio=None, debug=False):
|
||||
if not (clk_gpio and dt_gpio):
|
||||
raise BaseException("clk_gpio and dt_gpio pin must be specified!")
|
||||
|
||||
self.DEBUG = debug
|
||||
self.pi = pigpio.pi()
|
||||
self.clk_gpio = clk_gpio
|
||||
self.dt_gpio = dt_gpio
|
||||
self.pi.set_glitch_filter(self.clk_gpio, self.debounce)
|
||||
self.pi.set_glitch_filter(self.dt_gpio, self.debounce)
|
||||
if sw_gpio is not None:
|
||||
self.sw_gpio = sw_gpio
|
||||
self.pi.set_pull_up_down(self.sw_gpio, pigpio.PUD_UP)
|
||||
self.pi.set_glitch_filter(self.sw_gpio, self.sw_gpio_debounce)
|
||||
self.setup_pigpio_callbacks()
|
||||
|
||||
def setup_pigpio_callbacks(self):
|
||||
self.pi.callback(self.clk_gpio, pigpio.FALLING_EDGE, self.clk_gpio_fall)
|
||||
self.pi.callback(self.clk_gpio, pigpio.RISING_EDGE, self.clk_gpio_rise)
|
||||
self.pi.callback(self.dt_gpio, pigpio.FALLING_EDGE, self.dt_gpio_fall)
|
||||
self.pi.callback(self.dt_gpio, pigpio.RISING_EDGE, self.dt_gpio_rise)
|
||||
if self.sw_gpio is not None:
|
||||
self.pi.callback(self.sw_gpio, pigpio.FALLING_EDGE, self.sw_gpio_fall)
|
||||
self.pi.callback(self.sw_gpio, pigpio.RISING_EDGE, self.sw_gpio_rise)
|
||||
|
||||
@property
|
||||
def counter(self):
|
||||
return self._counter
|
||||
|
||||
@counter.setter
|
||||
def counter(self, value):
|
||||
if self._counter != value:
|
||||
self._counter = value
|
||||
if self.rotary_callback:
|
||||
self.rotary_callback(self._counter)
|
||||
|
||||
def clk_gpio_fall(self, _gpio, _level, _tick):
|
||||
if self.DEBUG:
|
||||
print(self.sequence + ':{}'.format(clk_gpio1))
|
||||
if len(self.sequence) > 2:
|
||||
self.sequence = ''
|
||||
self.sequence += clk_gpio1
|
||||
|
||||
def clk_gpio_rise(self, _gpio, _level, _tick):
|
||||
if self.DEBUG:
|
||||
print(self.sequence + ':{}'.format(clk_gpio0))
|
||||
self.sequence += clk_gpio0
|
||||
if self.sequence == SEQUENCE_UP:
|
||||
if self.counter < self.max:
|
||||
self.counter += self.scale
|
||||
if self.up_callback:
|
||||
self.up_callback(self._counter)
|
||||
self.sequence = ''
|
||||
|
||||
def dt_gpio_fall(self, _gpio, _level, _tick):
|
||||
if self.DEBUG:
|
||||
print(self.sequence + ':{}'.format(dt_gpio1))
|
||||
if len(self.sequence) > 2:
|
||||
self.sequence = ''
|
||||
self.sequence += dt_gpio1
|
||||
|
||||
def dt_gpio_rise(self, _gpio, _level, _tick):
|
||||
if self.DEBUG:
|
||||
print(self.sequence + ':{}'.format(dt_gpio0))
|
||||
self.sequence += dt_gpio0
|
||||
if self.sequence == SEQUENCE_DOWN:
|
||||
if self.counter > self.min:
|
||||
self.counter -= self.scale
|
||||
if self.down_callback:
|
||||
self.down_callback(self._counter)
|
||||
self.sequence = ''
|
||||
|
||||
def sw_gpio_rise(self, _gpio, _level, _tick):
|
||||
if self.long_press_opt:
|
||||
if not self.long:
|
||||
self.short_press()
|
||||
|
||||
def sw_gpio_fall(self, _gpio, _level, _tick):
|
||||
if self.long_press_opt:
|
||||
self.long = False
|
||||
press_time = time.time()
|
||||
while self.pi.read(self.sw_gpio) == 0:
|
||||
self.wait_time = time.time()
|
||||
time.sleep(0.1)
|
||||
if self.wait_time - press_time > 1.5:
|
||||
self.long_press()
|
||||
self.long = True
|
||||
break
|
||||
else:
|
||||
self.short_press()
|
||||
|
||||
def setup_rotary(
|
||||
self,
|
||||
rotary_callback=None,
|
||||
up_callback=None,
|
||||
down_callback=None,
|
||||
min=None,
|
||||
max=None,
|
||||
scale=None,
|
||||
debounce=None,
|
||||
):
|
||||
if not (rotary_callback or up_callback or down_callback):
|
||||
print('At least one callback should be given')
|
||||
# rotary callback has to be set first since the self.counter property depends on it
|
||||
self.rotary_callback = rotary_callback
|
||||
self.up_callback = up_callback
|
||||
self.down_callback = down_callback
|
||||
if min is not None:
|
||||
self.min = min
|
||||
self.counter = self.min
|
||||
self.last_counter = self.min
|
||||
if max is not None:
|
||||
self.max = max
|
||||
if scale is not None:
|
||||
self.scale = scale
|
||||
if debounce is not None:
|
||||
self.debounce = debounce
|
||||
self.pi.set_glitch_filter(self.clk_gpio, self.debounce)
|
||||
self.pi.set_glitch_filter(self.dt_gpio, self.debounce)
|
||||
|
||||
def setup_switch(self,
|
||||
sw_short_callback=None,
|
||||
sw_long_callback=None,
|
||||
debounce=None,
|
||||
long_press=None
|
||||
):
|
||||
assert sw_short_callback is not None or sw_long_callback is not None
|
||||
if sw_short_callback is not None:
|
||||
self.sw_short_callback = sw_short_callback
|
||||
if sw_long_callback is not None:
|
||||
self.sw_long_callback = sw_long_callback
|
||||
if debounce is not None:
|
||||
self.sw_debounce = debounce
|
||||
self.pi.set_glitch_filter(self.sw_gpio, self.sw_debounce)
|
||||
if long_press is not None:
|
||||
self.long_press_opt = long_press
|
||||
|
||||
@staticmethod
|
||||
def watch():
|
||||
"""
|
||||
A simple convenience function to have a waiting loop
|
||||
"""
|
||||
while True:
|
||||
time.sleep(10)
|
||||
|
||||
def short_press(self):
|
||||
self.sw_short_callback()
|
||||
|
||||
def long_press(self):
|
||||
self.sw_long_callback()
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
from RPi_GPIO_Rotary import rotary
|
||||
|
||||
class RotaryInput():
|
||||
def __init__(self):
|
||||
## Initialise (clk, dt, sw, ticks)
|
||||
self.input = rotary.Rotary(18,15,14,2)
|
||||
self.input.start()
|
||||
|
||||
def on_next(self, action):
|
||||
self.input.register(increment=action)
|
||||
|
||||
def on_previous(self, action):
|
||||
self.input.register(decrement=action)
|
||||
|
||||
def on_click(self, action):
|
||||
self.input.register(pressed=action)
|
||||
|
||||
def stop(self):
|
||||
self.input.stop()
|
||||
|
|
@ -126,15 +126,11 @@ body {
|
|||
|
||||
.bar {
|
||||
width:70%;
|
||||
//padding: 2px 2px 0px 2px;
|
||||
//display:block;
|
||||
display: inline-block;
|
||||
font-family:arial;
|
||||
font-size:12px;
|
||||
background-color:#ca3c38;
|
||||
color:#000;
|
||||
//position:absolute;
|
||||
//bottom:0;
|
||||
}
|
||||
|
||||
.ds-led-hazard-active {
|
||||
|
|
|
|||
|
|
@ -502,10 +502,10 @@ $(document).ready(function()
|
|||
|
||||
ws_status.onmessage = function(e)
|
||||
{
|
||||
console.log("received status data")
|
||||
console.log(e.data);
|
||||
|
||||
x = JSON.parse(e.data);
|
||||
document.getElementById('json').innerHTML = JSON.stringify(x, undefined, 4);
|
||||
if (x.type == "backlog")
|
||||
{
|
||||
if (x.profile)
|
||||
|
|
@ -562,7 +562,7 @@ $(document).ready(function()
|
|||
updateProgress(parseFloat(x.runtime)/parseFloat(x.totaltime)*100);
|
||||
$('#state').html('<span class="glyphicon glyphicon-time" style="font-size: 22px; font-weight: normal"></span><span style="font-family: Digi; font-size: 40px;">' + eta + '</span>');
|
||||
$('#target_temp').html(parseInt(x.target));
|
||||
$('#cost').html(x.currency_type + parseFloat(x.cost).toFixed(2));
|
||||
$('#cost').html(x.currency_type + parseFloat(x.cost).toFixed(0));
|
||||
|
||||
|
||||
|
||||
|
|
@ -575,7 +575,8 @@ $(document).ready(function()
|
|||
}
|
||||
|
||||
$('#act_temp').html(parseInt(x.temperature));
|
||||
$('#heat').html('<div class="bar" style="height:'+x.pidstats.out*70+'%;"></div>')
|
||||
//$('#heat').html('<div class="bar" style="height:'+x.pidstats.out*70+'%;"></div>')
|
||||
if (x.heat > 0.5) { $('#heat').addClass("ds-led-heat-active"); } else { $('#heat').removeClass("ds-led-heat-active"); }
|
||||
if (x.cool > 0.5) { $('#cool').addClass("ds-led-cool-active"); } else { $('#cool').removeClass("ds-led-cool-active"); }
|
||||
if (x.air > 0.5) { $('#air').addClass("ds-led-air-active"); } else { $('#air').removeClass("ds-led-air-active"); }
|
||||
if (x.temperature > hazardTemp()) { $('#hazard').addClass("ds-led-hazard-active"); } else { $('#hazard').removeClass("ds-led-hazard-active"); }
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
<div class="ds-panel">
|
||||
<div class="display ds-num"><span id="act_temp">25</span><span class="ds-unit" id="act_temp_scale" >°C</span></div>
|
||||
<div class="display ds-num ds-target"><span id="target_temp">---</span><span class="ds-unit" id="target_temp_scale">°C</span></div>
|
||||
<div class="display ds-num ds-cost"><span id="cost">0.00</span><span class="ds-unit" id="cost"></span></div>
|
||||
<div class="display ds-num ds-cost"><span id="cost">0</span><span class="ds-unit" id="cost"></span></div>
|
||||
<div class="display ds-num ds-text" id="state"></div>
|
||||
<div class="display pull-right ds-state" style="padding-right:0"><span class="ds-led" id="heat">\</span><span class="ds-led" id="cool">l</span><span class="ds-led" id="air">[</span><span class="ds-led" id="hazard">I</span><span class="ds-led" id="door">♨</span></div>
|
||||
</div>
|
||||
|
|
@ -92,6 +92,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<pre style="white-space: pre-wrap;" id="json"></pre>
|
||||
|
||||
<div id="jobSummaryModal" class="modal fade" tabindex="-1" aria-hidden="true" style="display: none;">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
|
|
|||
Ładowanie…
Reference in New Issue