diff --git a/config.py b/config.py index c21bf3a..c605bfe 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,5 @@ import logging +from lib.max31856 import MAX31856 ######################################################################## # @@ -13,7 +14,7 @@ listening_ip = "0.0.0.0" listening_port = 8081 ### Cost Estimate -kwh_rate = 0.18 # Rate in currency_type to calculate cost to run job +kwh_rate = 0.1319 # Rate in currency_type to calculate cost to run job currency_type = "$" # Currency Symbol to show when calculating cost to run job ######################################################################## @@ -27,35 +28,30 @@ currency_type = "$" # Currency Symbol to show when calculating cost to run j ### Outputs gpio_heat = 23 # Switches zero-cross solid-state-relay -heater_invert = 0 # switches the polarity of the heater control ### Thermocouple Adapter selection: # max31855 - bitbang SPI interface -# max31855spi - kernel SPI interface -# max6675 - bitbang SPI interface +# max31856 - bitbang SPI interface. must specify thermocouple_type. max31855 = 1 -max6675 = 0 -max31855spi = 0 # if you use this one, you MUST reassign the default GPIO pins +max31856 = 0 +# see lib/max31856.py for other thermocouple_type, only applies to max31856 +thermocouple_type = MAX31856.MAX31856_S_TYPE ### Thermocouple Connection (using bitbang interfaces) gpio_sensor_cs = 27 gpio_sensor_clock = 22 gpio_sensor_data = 17 -### Thermocouple SPI Connection (using adafrut drivers + kernel SPI interface) -spi_sensor_chip_id = 0 - ### duty cycle of the entire system in seconds. Every N seconds a decision ### is made about switching the relay[s] on & off and for how long. ### The thermocouple is read five times during this period and the highest ### value is used. -sensor_time_wait = 2 +sensor_time_wait = 1 ######################################################################## # # PID parameters - pid_kp = 25 # Proportional pid_ki = 1088 # Integration pid_kd = 217 # Derivative was 217 @@ -64,7 +60,7 @@ pid_kd = 217 # Derivative was 217 ######################################################################## # # Simulation parameters - +simulate = True sim_t_env = 25.0 # deg C sim_c_heat = 100.0 # J/K heat capacity of heat element sim_c_oven = 5000.0 # J/K heat capacity of oven @@ -87,15 +83,14 @@ time_scale_profile = "m" # s = Seconds | m = Minutes | h = Hours - Enter and vi # when solid state relays fail, they usually fail closed. this means your # kiln receives full power until your house burns down. # this should not replace you watching your kiln or use of a kiln-sitter -emergency_shutoff_temp = 2250 +emergency_shutoff_temp = 2264 #cone 7 -# not used yet -# if measured value is N degrees below set point -warning_temp_low = 5 - -# not used yet -# if measured value is N degrees above set point -warning_temp_high = 5 +# If the kiln cannot heat fast enough and is off by more than +# kiln_must_catch_up_max_error the entire schedule is shifted until +# the desired temperature is reached. If your kiln cannot attain the +# wanted temperature, the schedule will run forever. +kiln_must_catch_up = True +kiln_must_catch_up_max_error = 10 #degrees # thermocouple offset # If you put your thermocouple in ice water and it reads 36F, you can diff --git a/kiln-controller.py b/kiln-controller.py index 9e7795e..4482ba8 100755 --- a/kiln-controller.py +++ b/kiln-controller.py @@ -25,17 +25,23 @@ except: logging.basicConfig(level=config.log_level, format=config.log_format) log = logging.getLogger("kiln-controller") -log.info("Starting kill controller") +log.info("Starting kiln controller") script_dir = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, script_dir + '/lib/') profile_path = os.path.join(script_dir, "storage", "profiles") -from oven import Oven, Profile +from oven import SimulatedOven, RealOven, Profile from ovenWatcher import OvenWatcher app = bottle.Bottle() -oven = Oven() + +if config.simulate == True: + log.info("this is a simulation") + oven = SimulatedOven() +else: + log.info("this is a real kiln") + oven = RealOven() ovenWatcher = OvenWatcher(oven) @app.route('/') diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/max31856.py b/lib/max31856.py new file mode 100644 index 0000000..f0fd4ef --- /dev/null +++ b/lib/max31856.py @@ -0,0 +1,304 @@ +""" +max31856.py + +Class which defines interaction with the MAX31856 sensor. + +Copyright (c) 2019 John Robinson +Author: John Robinson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" +import logging +import warnings + +import Adafruit_GPIO as Adafruit_GPIO +import Adafruit_GPIO.SPI as SPI + + +class MAX31856(object): + """Class to represent an Adafruit MAX31856 thermocouple temperature + measurement board. + """ + + # Board Specific Constants + MAX31856_CONST_THERM_LSB = 2**-7 + MAX31856_CONST_THERM_BITS = 19 + MAX31856_CONST_CJ_LSB = 2**-6 + MAX31856_CONST_CJ_BITS = 14 + + ### Register constants, see data sheet Table 6 (in Rev. 0) for info. + # Read Addresses + MAX31856_REG_READ_CR0 = 0x00 + MAX31856_REG_READ_CR1 = 0x01 + MAX31856_REG_READ_MASK = 0x02 + MAX31856_REG_READ_CJHF = 0x03 + MAX31856_REG_READ_CJLF = 0x04 + MAX31856_REG_READ_LTHFTH = 0x05 + MAX31856_REG_READ_LTHFTL = 0x06 + MAX31856_REG_READ_LTLFTH = 0x07 + MAX31856_REG_READ_LTLFTL = 0x08 + MAX31856_REG_READ_CJTO = 0x09 + MAX31856_REG_READ_CJTH = 0x0A # Cold-Junction Temperature Register, MSB + MAX31856_REG_READ_CJTL = 0x0B # Cold-Junction Temperature Register, LSB + MAX31856_REG_READ_LTCBH = 0x0C # Linearized TC Temperature, Byte 2 + MAX31856_REG_READ_LTCBM = 0x0D # Linearized TC Temperature, Byte 1 + MAX31856_REG_READ_LTCBL = 0x0E # Linearized TC Temperature, Byte 0 + MAX31856_REG_READ_FAULT = 0x0F # Fault status register + + # Write Addresses + MAX31856_REG_WRITE_CR0 = 0x80 + MAX31856_REG_WRITE_CR1 = 0x81 + MAX31856_REG_WRITE_MASK = 0x82 + MAX31856_REG_WRITE_CJHF = 0x83 + MAX31856_REG_WRITE_CJLF = 0x84 + MAX31856_REG_WRITE_LTHFTH = 0x85 + MAX31856_REG_WRITE_LTHFTL = 0x86 + MAX31856_REG_WRITE_LTLFTH = 0x87 + MAX31856_REG_WRITE_LTLFTL = 0x88 + MAX31856_REG_WRITE_CJTO = 0x89 + MAX31856_REG_WRITE_CJTH = 0x8A # Cold-Junction Temperature Register, MSB + MAX31856_REG_WRITE_CJTL = 0x8B # Cold-Junction Temperature Register, LSB + + # Pre-config Register Options + MAX31856_CR0_READ_ONE = 0x40 # One shot reading, delay approx. 200ms then read temp registers + MAX31856_CR0_READ_CONT = 0x80 # Continuous reading, delay approx. 100ms between readings + + # Thermocouple Types + MAX31856_B_TYPE = 0x0 # Read B Type Thermocouple + MAX31856_E_TYPE = 0x1 # Read E Type Thermocouple + MAX31856_J_TYPE = 0x2 # Read J Type Thermocouple + MAX31856_K_TYPE = 0x3 # Read K Type Thermocouple + MAX31856_N_TYPE = 0x4 # Read N Type Thermocouple + MAX31856_R_TYPE = 0x5 # Read R Type Thermocouple + MAX31856_S_TYPE = 0x6 # Read S Type Thermocouple + MAX31856_T_TYPE = 0x7 # Read T Type Thermocouple + + def __init__(self, tc_type=MAX31856_S_TYPE, units="c", avgsel=0x0, software_spi=None, hardware_spi=None, gpio=None): + """ + Initialize MAX31856 device with software SPI on the specified CLK, + CS, and DO pins. Alternatively can specify hardware SPI by sending an + SPI.SpiDev device in the spi parameter. + + Args: + tc_type (1-byte Hex): Type of Thermocouple. Choose from class variables of the form + MAX31856.MAX31856_X_TYPE. + avgsel (1-byte Hex): Type of Averaging. Choose from values in CR0 table of datasheet. + Default is single sample. + software_spi (dict): Contains the pin assignments for software SPI, as defined below: + clk (integer): Pin number for software SPI clk + cs (integer): Pin number for software SPI cs + do (integer): Pin number for software SPI MISO + di (integer): Pin number for software SPI MOSI + hardware_spi (SPI.SpiDev): If using hardware SPI, define the connection + """ + self._logger = logging.getLogger('Adafruit_MAX31856.MAX31856') + self._spi = None + self.tc_type = tc_type + self.avgsel = avgsel + self.units = units + # Handle hardware SPI + if hardware_spi is not None: + self._logger.debug('Using hardware SPI') + self._spi = hardware_spi + elif software_spi is not None: + self._logger.debug('Using software SPI') + # Default to platform GPIO if not provided. + if gpio is None: + gpio = Adafruit_GPIO.get_platform_gpio() + self._spi = SPI.BitBang(gpio, software_spi['clk'], software_spi['di'], + software_spi['do'], software_spi['cs']) + else: + raise ValueError( + 'Must specify either spi for for hardware SPI or clk, cs, and do for softwrare SPI!') + self._spi.set_clock_hz(5000000) + # According to Wikipedia (on SPI) and MAX31856 Datasheet: + # SPI mode 1 corresponds with correct timing, CPOL = 0, CPHA = 1 + self._spi.set_mode(1) + self._spi.set_bit_order(SPI.MSBFIRST) + + self.cr1 = ((self.avgsel << 4) + self.tc_type) + + # Setup for reading continuously with T-Type thermocouple + self._write_register(self.MAX31856_REG_WRITE_CR0, self.MAX31856_CR0_READ_CONT) + self._write_register(self.MAX31856_REG_WRITE_CR1, self.cr1) + + @staticmethod + def _cj_temp_from_bytes(msb, lsb): + """ + Takes in the msb and lsb from a Cold Junction (CJ) temperature reading and converts it + into a decimal value. + + This function was removed from readInternalTempC() and moved to its own method to allow for + easier testing with standard values. + + Args: + msb (hex): Most significant byte of CJ temperature + lsb (hex): Least significant byte of a CJ temperature + + """ + # (((msb w/o +/-) shifted by number of 1 byte above lsb) + # + val_low_byte) + # >> shifted back by # of dead bits + temp_bytes = (((msb & 0x7F) << 8) + lsb) >> 2 + + if msb & 0x80: + # Negative Value. Scale back by number of bits + temp_bytes -= 2**(MAX31856.MAX31856_CONST_CJ_BITS -1) + + # temp_bytes*value of lsb + temp_c = temp_bytes*MAX31856.MAX31856_CONST_CJ_LSB + + return temp_c + + @staticmethod + def _thermocouple_temp_from_bytes(byte0, byte1, byte2): + """ + Converts the thermocouple byte values to a decimal value. + + This function was removed from readInternalTempC() and moved to its own method to allow for + easier testing with standard values. + + Args: + byte2 (hex): Most significant byte of thermocouple temperature + byte1 (hex): Middle byte of thermocouple temperature + byte0 (hex): Least significant byte of a thermocouple temperature + + Returns: + temp_c (float): Temperature in degrees celsius + """ + # (((val_high_byte w/o +/-) shifted by 2 bytes above LSB) + # + (val_mid_byte shifted by number 1 byte above LSB) + # + val_low_byte ) + # >> back shift by number of dead bits + temp_bytes = (((byte2 & 0x7F) << 16) + (byte1 << 8) + byte0) + temp_bytes = temp_bytes >> 5 + + if byte2 & 0x80: + temp_bytes -= 2**(MAX31856.MAX31856_CONST_THERM_BITS -1) + + # temp_bytes*value of LSB + temp_c = temp_bytes*MAX31856.MAX31856_CONST_THERM_LSB + + return temp_c + + def read_internal_temp_c(self): + """ + Return internal temperature value in degrees celsius. + """ + val_low_byte = self._read_register(self.MAX31856_REG_READ_CJTL) + val_high_byte = self._read_register(self.MAX31856_REG_READ_CJTH) + + temp_c = MAX31856._cj_temp_from_bytes(val_high_byte, val_low_byte) + self._logger.debug("Cold Junction Temperature {0} deg. C".format(temp_c)) + + return temp_c + + def read_temp_c(self): + """ + Return the thermocouple temperature value in degrees celsius. + """ + val_low_byte = self._read_register(self.MAX31856_REG_READ_LTCBL) + val_mid_byte = self._read_register(self.MAX31856_REG_READ_LTCBM) + val_high_byte = self._read_register(self.MAX31856_REG_READ_LTCBH) + + temp_c = MAX31856._thermocouple_temp_from_bytes(val_low_byte, val_mid_byte, val_high_byte) + + self._logger.debug("Thermocouple Temperature {0} deg. C".format(temp_c)) + + return temp_c + + def read_fault_register(self): + """Return bytes containing fault codes and hardware problems. + + TODO: Could update in the future to return human readable values + """ + reg = self._read_register(self.MAX31856_REG_READ_FAULT) + return reg + + def _read_register(self, address): + """ + Reads a register at address from the MAX31856 + + Args: + address (8-bit Hex): Address for read register. Format 0Xh. Constants listed in class + as MAX31856_REG_READ_* + + Note: + SPI transfer method is used. The address is written in as the first byte, and then a + dummy value as the second byte. The data from the sensor is contained in the second + byte, the dummy byte is only used to keep the SPI clock ticking as we read in the + value. The first returned byte is discarded because no data is transmitted while + specifying the register address. + """ + raw = self._spi.transfer([address, 0x00]) + if raw is None or len(raw) != 2: + raise RuntimeError('Did not read expected number of bytes from device!') + + value = raw[1] + self._logger.debug('Read Register: 0x{0:02X}, Raw Value: 0x{1:02X}'.format( + (address & 0xFFFF), (value & 0xFFFF))) + return value + + def _write_register(self, address, write_value): + """ + Writes to a register at address from the MAX31856 + + Args: + address (8-bit Hex): Address for read register. Format 0Xh. Constants listed in class + as MAX31856_REG_WRITE_* + write_value (8-bit Hex): Value to write to the register + """ + self._spi.transfer([address, write_value]) + self._logger.debug('Wrote Register: 0x{0:02X}, Value 0x{1:02X}'.format((address & 0xFF), + (write_value & 0xFF))) + + # If we've gotten this far without an exception, the transmission must've gone through + return True + + # Deprecated Methods + def readTempC(self): #pylint: disable-msg=invalid-name + """Depreciated due to Python naming convention, use read_temp_c instead + """ + warnings.warn("Depreciated due to Python naming convention, use read_temp_c() instead", DeprecationWarning) + return read_temp_c(self) + + def readInternalTempC(self): #pylint: disable-msg=invalid-name + """Depreciated due to Python naming convention, use read_internal_temp_c instead + """ + warnings.warn("Depreciated due to Python naming convention, use read_internal_temp_c() instead", DeprecationWarning) + return read_internal_temp_c(self) + + # added by jbruce to mimic MAX31855 lib + def to_c(self, celsius): + '''Celsius passthrough for generic to_* method.''' + return celsius + + def to_k(self, celsius): + '''Convert celsius to kelvin.''' + return celsius + 273.15 + + def to_f(self, celsius): + '''Convert celsius to fahrenheit.''' + return celsius * 9.0/5.0 + 32 + + def get(self): + celcius = self.read_temp_c() + return getattr(self, "to_" + self.units)(celcius) + + diff --git a/lib/max6675.py b/lib/max6675.py deleted file mode 100644 index bfe3fa9..0000000 --- a/lib/max6675.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/python -import RPi.GPIO as GPIO -import time - -class MAX6675(object): - '''Python driver for [MAX6675 Cold-Junction Compensated Thermocouple-to-Digital Converter](http://www.adafruit.com/datasheets/MAX6675.pdf) - Requires: - - The [GPIO Library](https://code.google.com/p/raspberry-gpio-python/) (Already on most Raspberry Pi OS builds) - - A [Raspberry Pi](http://www.raspberrypi.org/) - - ''' - def __init__(self, cs_pin, clock_pin, data_pin, units = "c", board = GPIO.BCM): - '''Initialize Soft (Bitbang) SPI bus - - Parameters: - - cs_pin: Chip Select (CS) / Slave Select (SS) pin (Any GPIO) - - clock_pin: Clock (SCLK / SCK) pin (Any GPIO) - - data_pin: Data input (SO / MOSI) pin (Any GPIO) - - units: (optional) unit of measurement to return. ("c" (default) | "k" | "f") - - board: (optional) pin numbering method as per RPi.GPIO library (GPIO.BCM (default) | GPIO.BOARD) - - ''' - self.cs_pin = cs_pin - self.clock_pin = clock_pin - self.data_pin = data_pin - self.units = units - self.data = None - self.board = board - - # Initialize needed GPIO - GPIO.setmode(self.board) - GPIO.setup(self.cs_pin, GPIO.OUT) - GPIO.setup(self.clock_pin, GPIO.OUT) - GPIO.setup(self.data_pin, GPIO.IN) - - # Pull chip select high to make chip inactive - GPIO.output(self.cs_pin, GPIO.HIGH) - - def get(self): - '''Reads SPI bus and returns current value of thermocouple.''' - self.read() - self.checkErrors() - return getattr(self, "to_" + self.units)(self.data_to_tc_temperature()) - - def read(self): - '''Reads 16 bits of the SPI bus & stores as an integer in self.data.''' - bytesin = 0 - # Select the chip - GPIO.output(self.cs_pin, GPIO.LOW) - # Read in 16 bits - for i in range(16): - GPIO.output(self.clock_pin, GPIO.LOW) - time.sleep(0.001) - bytesin = bytesin << 1 - if (GPIO.input(self.data_pin)): - bytesin = bytesin | 1 - GPIO.output(self.clock_pin, GPIO.HIGH) - time.sleep(0.001) - # Unselect the chip - GPIO.output(self.cs_pin, GPIO.HIGH) - # Save data - self.data = bytesin - - def checkErrors(self, data_16 = None): - '''Checks errors on bit D2''' - if data_16 is None: - data_16 = self.data - noConnection = (data_16 & 0x4) != 0 # tc input bit, D2 - - if noConnection: - raise MAX6675Error("No Connection") # open thermocouple - - def data_to_tc_temperature(self, data_16 = None): - '''Takes an integer and returns a thermocouple temperature in celsius.''' - if data_16 is None: - data_16 = self.data - # Remove bits D0-3 - tc_data = ((data_16 >> 3) & 0xFFF) - # 12-bit resolution - return (tc_data * 0.25) - - def to_c(self, celsius): - '''Celsius passthrough for generic to_* method.''' - return celsius - - def to_k(self, celsius): - '''Convert celsius to kelvin.''' - return celsius + 273.15 - - def to_f(self, celsius): - '''Convert celsius to fahrenheit.''' - return celsius * 9.0/5.0 + 32 - - def cleanup(self): - '''Selective GPIO cleanup''' - GPIO.setup(self.cs_pin, GPIO.IN) - GPIO.setup(self.clock_pin, GPIO.IN) - -class MAX6675Error(Exception): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) - -if __name__ == "__main__": - - # default example - cs_pin = 24 - clock_pin = 23 - data_pin = 22 - units = "c" - thermocouple = MAX6675(cs_pin, clock_pin, data_pin, units) - running = True - while(running): - try: - try: - tc = thermocouple.get() - except MAX6675Error as e: - tc = "Error: "+ e.value - running = False - print("tc: {}".format(tc)) - time.sleep(1) - except KeyboardInterrupt: - running = False - thermocouple.cleanup() diff --git a/lib/oven.py b/lib/oven.py index 3ff9dc3..e3a39b9 100644 --- a/lib/oven.py +++ b/lib/oven.py @@ -4,213 +4,95 @@ import random import datetime import logging import json - import config log = logging.getLogger(__name__) -try: - if config.max31855 + config.max6675 + config.max31855spi > 1: - log.error("choose (only) one converter IC") - exit() - if config.max31855: - from max31855 import MAX31855, MAX31855Error - log.info("import MAX31855") - if config.max31855spi: - import Adafruit_GPIO.SPI as SPI - from max31855spi import MAX31855SPI, MAX31855SPIError - log.info("import MAX31855SPI") - spi_reserved_gpio = [7, 8, 9, 10, 11] - if config.gpio_heat in spi_reserved_gpio: - raise Exception("gpio_heat pin %s collides with SPI pins %s" % (config.gpio_heat, spi_reserved_gpio)) - if config.max6675: - from max6675 import MAX6675, MAX6675Error - log.info("import MAX6675") - sensor_available = True -except ImportError: - log.exception("Could not initialize temperature sensor, using dummy values!") - sensor_available = False +class Output(object): + def __init__(self): + self.active = False + self.load_libs() -try: - import RPi.GPIO as GPIO - GPIO.setmode(GPIO.BCM) - GPIO.setwarnings(False) - GPIO.setup(config.gpio_heat, GPIO.OUT) -# GPIO.setup(config.gpio_cool, GPIO.OUT) -# GPIO.setup(config.gpio_air, GPIO.OUT) -# GPIO.setup(config.gpio_door, GPIO.IN, pull_up_down=GPIO.PUD_UP) + 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 + except: + msg = "Could not initialize GPIOs, oven operation will only be simulated!" + log.warning(msg) + self.active = False - gpio_available = True -except ImportError: - msg = "Could not initialize GPIOs, oven operation will only be simulated!" - log.warning(msg) - gpio_available = False + def heat(self,time): + GPIO.output(config.gpio_heat, GPIO.HIGH) + time.sleep(time) + GPIO.output(config.gpio_heat, GPIO.LOW) + def cool(self,time): + '''no active cooling, so sleep''' + time.sleep(time) -class Oven (threading.Thread): - STATE_IDLE = "IDLE" - STATE_RUNNING = "RUNNING" - - def __init__(self, simulate=False, time_step=config.sensor_time_wait): - threading.Thread.__init__(self) - self.daemon = True - self.simulate = simulate - self.time_step = time_step - self.reset() - if simulate: - self.temp_sensor = TempSensorSimulate(self, - self.time_step, - self.time_step) - if sensor_available: - self.temp_sensor = TempSensorReal(self.time_step) - else: - self.temp_sensor = TempSensorSimulate(self, - self.time_step, - self.time_step) +class Board(object): + def __init__(self): + self.name = None + self.active = False + self.temp_sensor = None + self.gpio_active = False + self.load_gpio_libs() + self.load_libs() + self.create_temp_sensor() self.temp_sensor.start() - self.start() - def reset(self): - self.profile = None - self.start_time = 0 - self.runtime = 0 - self.totaltime = 0 - self.target = 0 - self.state = Oven.STATE_IDLE - self.set_heat(False) - self.pid = PID(ki=config.pid_ki, kd=config.pid_kd, kp=config.pid_kp) + 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) - def run_profile(self, profile, startat=0): - log.info("Running schedule %s" % profile.name) - self.profile = profile - self.totaltime = profile.get_duration() - self.state = Oven.STATE_RUNNING - self.start_time = datetime.datetime.now() - self.startat = startat * 60 - log.info("Starting") + 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 abort_run(self): - self.reset() - - def run(self): - temperature_count = 0 - last_temp = 0 - pid = 0 - while True: - - if self.state == Oven.STATE_IDLE: - time.sleep(1) - elif self.state == Oven.STATE_RUNNING: - if self.simulate: - self.runtime += 0.5 - else: - runtime_delta = datetime.datetime.now() - self.start_time - if self.startat > 0: - self.runtime = self.startat + runtime_delta.total_seconds(); - else: - self.runtime = runtime_delta.total_seconds() - - self.target = self.profile.get_target_temperature(self.runtime) - pid = self.pid.compute(self.target, self.temp_sensor.temperature + config.thermocouple_offset) - - heat_on = float(0) - heat_off = float(self.time_step) - if pid > 0: - heat_on = float(self.time_step * pid) - heat_off = float(self.time_step * (1 - pid)) - time_left = self.totaltime - self.runtime - - log.info("temp=%.1f, target=%.1f, pid=%.3f, heat_on=%.2f, heat_off=%.2f, run_time=%d, total_time=%d, time_left=%d" % - (self.temp_sensor.temperature + config.thermocouple_offset, - self.target, - pid, - heat_on, - heat_off, - self.runtime, - self.totaltime, - time_left)) - - # FIX - this whole thing should be replaced with - # a warning low and warning high below and above - # set value. If either of these are exceeded, - # warn in the interface. DO NOT RESET. - - # if we are WAY TOO HOT, shut down - if(self.temp_sensor.temperature + config.thermocouple_offset >= config.emergency_shutoff_temp): - log.info("emergency!!! temperature too high, shutting down") - self.reset() - - # Capture the last temperature value. This must be done before set_heat, - # since there is a sleep in there now. - last_temp = self.temp_sensor.temperature + config.thermocouple_offset - - self.set_heat(pid) - - if self.runtime > self.totaltime: - log.info("schedule ended, shutting down") - self.reset() - - # amount of time to sleep with the heater off - # for example if pid = .6 and time step is 1, sleep for .4s - if pid > 0: - time.sleep(self.time_step * (1 - pid)) - else: - time.sleep(self.time_step) - - def set_heat(self, value): - if value > 0: - self.heat = 1.0 - if gpio_available: - if config.heater_invert: - GPIO.output(config.gpio_heat, GPIO.LOW) - time.sleep(self.time_step * value) - GPIO.output(config.gpio_heat, GPIO.HIGH) - else: - GPIO.output(config.gpio_heat, GPIO.HIGH) - time.sleep(self.time_step * value) - GPIO.output(config.gpio_heat, GPIO.LOW) - else: - # for runs that are simulations - time.sleep(self.time_step * value) + def create_temp_sensor(self): + if config.simulate == True: + self.temp_sensor = TempSensorSimulate() else: - self.heat = 0.0 - if gpio_available: - if config.heater_invert: - GPIO.output(config.gpio_heat, GPIO.HIGH) - else: - GPIO.output(config.gpio_heat, GPIO.LOW) - - - def get_state(self): - state = { - 'runtime': self.runtime, - 'temperature': self.temp_sensor.temperature + config.thermocouple_offset, - 'target': self.target, - 'state': self.state, - 'heat': self.heat, - 'totaltime': self.totaltime, - } - return state - + self.temp_sensor = TempSensorReal() +class BoardSimulated(object): + def __init__(self): + self.temp_sensor = TempSensorSimulated() + class TempSensor(threading.Thread): - def __init__(self, time_step): + def __init__(self): threading.Thread.__init__(self) self.daemon = True self.temperature = 0 - self.time_step = time_step + self.time_step = config.sensor_time_wait +class TempSensorSimulated(TempSensor): + '''not much here, just need to be able to set the temperature''' + def __init__(self): + TempSensor.__init__(self) class TempSensorReal(TempSensor): - def __init__(self, time_step): - TempSensor.__init__(self, time_step) - if config.max6675: - log.info("init MAX6675") - self.thermocouple = MAX6675(config.gpio_sensor_cs, - config.gpio_sensor_clock, - config.gpio_sensor_data, - config.temp_scale) - + '''real temperature sensor thread that takes N measurements + during the time_step''' + def __init__(self): + TempSensor.__init__(self) if config.max31855: log.info("init MAX31855") self.thermocouple = MAX31855(config.gpio_sensor_cs, @@ -218,14 +100,19 @@ class TempSensorReal(TempSensor): config.gpio_sensor_data, config.temp_scale) - if config.max31855spi: - log.info("init MAX31855-spi") - self.thermocouple = MAX31855SPI(spi_dev=SPI.SpiDev(port=0, device=config.spi_sensor_chip_id)) + if config.max31856: + log.info("init MAX31856") + software_spi = { 'cs': config.gpio_sensor_cs, + 'clk': config.gpio_sensor_clock, + 'do': config.gpio_sensor_data } + self.thermocouple = MAX31856(tc_type=config.thermocouple_type, + software_spi = sofware_spi, + units = config.temp_scale + ) def run(self): while True: - - maxtries = 5 + maxtries = 5 sleeptime = self.time_step / float(maxtries) maxtemp = 0 for x in range(0,maxtries): @@ -237,48 +124,217 @@ class TempSensorReal(TempSensor): maxtemp = temp time.sleep(sleeptime) self.temperature = maxtemp - #time.sleep(self.time_step) +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.reset() -class TempSensorSimulate(TempSensor): - def __init__(self, oven, time_step, sleep_time): - TempSensor.__init__(self, time_step) - self.oven = oven - self.sleep_time = sleep_time + def reset(self): + self.state = "IDLE" + 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): + 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 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 + if self.target - temp > config.kiln_must_catch_up_max_error: + log.info("kiln must catch up, 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 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''' + if (self.board.temp_sensor.temperature + config.thermocouple_offset >= + config.emergency_shutoff_temp): + log.info("emergency!!! temperature too high, 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): + 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, + } + return state def run(self): - t_env = config.sim_t_env - c_heat = config.sim_c_heat - c_oven = config.sim_c_oven - p_heat = config.sim_p_heat - R_o_nocool = config.sim_R_o_nocool - R_ho_noair = config.sim_R_ho_noair - R_ho = R_ho_noair - - t = t_env # deg C temp in oven - t_h = t # deg C temp of heat element while True: - #heating energy - Q_h = p_heat * self.time_step * self.oven.heat + 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() - #temperature change of heat element by heating - t_h += Q_h / c_heat - #energy flux heat_el -> oven - p_ho = (t_h - t) / R_ho +class SimulatedOven(Oven): - #temperature change of oven and heat el - t += p_ho * self.time_step / c_oven - t_h -= p_ho * self.time_step / c_heat + def __init__(self): + self.reset() + self.board = BoardSimulated() - #temperature change of oven by cooling to env - p_env = (t - t_env) / R_o_nocool - t -= p_env * self.time_step / c_oven - log.debug("energy sim: -> %dW heater: %.0f -> %dW oven: %.0f -> %dW env" % (int(p_heat * self.oven.heat), t_h, int(p_ho), t, int(p_env))) - self.temperature = t + 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 - time.sleep(self.sleep_time) + # 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 + + # call parent init + Oven.__init__(self) + + # 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 = 1.0 + + 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=%.1f, target=%.1f, 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.reset() + + # call parent init + Oven.__init__(self) + + # start thread + self.start() + + 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 + + self.output.heat(heat_on) + self.output.cool(heat_off) + time_left = self.totaltime - self.runtime + log.info("temp=%.1f, target=%.1f, 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): @@ -304,13 +360,6 @@ class Profile(): return (prev_point, next_point) - def is_rising(self, time): - (prev_point, next_point) = self.get_surrounding_points(time) - if prev_point and next_point: - return prev_point[1] < next_point[1] - else: - return False - def get_target_temperature(self, time): if time > self.get_duration(): return 0 @@ -323,6 +372,7 @@ class Profile(): class PID(): + def __init__(self, ki=1, kp=1, kd=1): self.ki = ki self.kp = kp @@ -345,4 +395,8 @@ class PID(): self.lastErr = error self.lastNow = now + # not actively cooling, so + if output < 0: + output = 0 + return output diff --git a/lib/ovenWatcher.py b/lib/ovenWatcher.py index d9774fe..3e47e4f 100644 --- a/lib/ovenWatcher.py +++ b/lib/ovenWatcher.py @@ -27,7 +27,7 @@ class OvenWatcher(threading.Thread): oven_state = self.oven.get_state() # record state for any new clients that join - if oven_state.get("state") == Oven.STATE_RUNNING: + if oven_state.get("state") == "RUNNING": self.last_log.append(oven_state) else: self.recording = False diff --git a/requirements.txt b/requirements.txt index 51559d0..5ccec19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ gevent gevent-websocket #RPi.GPIO #Adafruit-MAX31855 +#Adafruit-GPIO