Compare commits

..

4 Commits
main ... master

Author SHA1 Message Date
Jason Bruce
2e756178dc
Merge pull request #168 from SamSkjord/master
profile filetype checks
2024-07-02 13:56:42 -04:00
Sam
96e5919464 profile filetype checks
Added my Controller as a samba share so I could edit profile files remotely, as OSX is very aggressive about shoving ._ files where you don't want them,
I added a check for that and another check to make sure to only try and open files ending in .json
2024-01-23 14:23:57 +00:00
jbruce
7c7a1b648e remove the loading message 2022-12-16 14:21:37 -05:00
jbruce
f0c97ed220 make hours the tick size on the live graph always 2022-12-16 09:49:19 -05:00
31 changed files with 1088 additions and 1832 deletions

3
.gitignore vendored
View File

@ -8,5 +8,4 @@ thumbs.db
#storage/profiles #storage/profiles
#config.py #config.py
.idea/* .idea/*
state.json .DS_Store
venv/*

View File

@ -5,33 +5,29 @@ Turns a Raspberry Pi into an inexpensive, web-enabled kiln controller.
## Features ## Features
* supports [many boards](https://github.com/jbruce12000/kiln-controller/blob/main/docs/supported-boards.md) into addition to raspberry pi
* supports Adafruit MAX31856 and MAX31855 thermocouple boards
* support for K, J, N, R, S, T, E, or B type thermocouples
* easy to create new kiln schedules and edit / modify existing schedules * easy to create new kiln schedules and edit / modify existing schedules
* no limit to runtime - fire for days if you want * no limit to runtime - fire for days if you want
* view status from multiple devices at once - computer, tablet etc * view status from multiple devices at once - computer, tablet etc
* real-time firing cost estimate * real-time firing cost estimate
* real-time heating rate displayed in degrees per hour * NIST-linearized conversion for accurate K type thermocouple readings
* supports PID parameters you tune to your kiln * supports PID parameters you tune to your kiln
* monitors temperature in kiln after schedule has ended * monitors temperature in kiln after schedule has ended
* api for starting and stopping at any point in a schedule * api for starting and stopping at any point in a schedule
* supports MAX31856 and MAX31855 thermocouple boards
* support for K, J, N, R, S, T, E, or B type thermocouples
* accurate simulation * accurate simulation
* support for shifting schedule when kiln cannot heat quickly enough * support for shifting schedule when kiln cannot heat quickly enough
* support for skipping first part of profile to match current kiln temperature
* prevents integral wind-up when temperatures not near the set point * prevents integral wind-up when temperatures not near the set point
* automatic restarts if there is a power outage or other event * automatic restarts if there is a power outage or other event
* support for a watcher to page you via slack if you kiln is out of whack
* easy scheduling of future kiln runs
**Run Kiln Schedule** **Run Kiln Schedule**
![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/kiln-running.png) ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/kiln-running.png)
**Edit Kiln Schedule** **Edit Kiln Schedule**
![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/kiln-schedule.png) ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/kiln-schedule.png)
## Hardware ## Hardware
@ -39,24 +35,22 @@ Turns a Raspberry Pi into an inexpensive, web-enabled kiln controller.
| Image | Hardware | Description | | Image | Hardware | Description |
| ------| -------- | ----------- | | ------| -------- | ----------- |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/rpi.png) | [Raspberry Pi](https://www.adafruit.com/category/105) | Virtually any Raspberry Pi will work since only a few GPIO pins are being used. Any board supported by [blinka](https://circuitpython.org/blinka) and has SPI should work. You'll also want to make sure the board has wifi. If you use something other than a Raspberry PI and get it to work, let me know. | | ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/rpi.png) | [Raspberry Pi](https://www.adafruit.com/category/105) | Virtually any Raspberry Pi will work since only a few GPIO pins are being used. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/max31855.png) | [Adafruit MAX31855](https://www.adafruit.com/product/269) or [Adafruit MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board | | ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/max31855.png) | [MAX31855](https://www.adafruit.com/product/269) or [MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/k-type-thermocouple.png) | [Thermocouple](https://www.auberins.com/index.php?main_page=product_info&cPath=20_3&products_id=39) | Invest in a heavy duty, ceramic thermocouple designed for kilns. Make sure the type will work with your thermocouple board. Adafruit-MAX31855 works only with K-type. Adafruit-MAX31856 is flexible and works with many types, but folks usually pick S-type. | | ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/k-type-thermocouple.png) | [K-Type Thermocouple](https://www.auberins.com/index.php?main_page=product_info&cPath=20_3&products_id=39) | Invest in a heavy duty, ceramic, k-type thermocouple designed for kilns |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/breadboard.png) | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires | | ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/breadboard.png) | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/ssr.png) | Solid State Relay | Zero crossing, make sure it can handle the max current of your kiln. Even if the kiln is 220V you can buy a single [3 Phase SSR](https://www.auberins.com/index.php?main_page=product_info&cPath=2_30&products_id=331). It's like having 3 SSRs in one. Relays this big always require a heat sink. | | ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/ssr.png) | Solid State Relay | Zero crossing, make sure it can handle the max current of your kiln. Even if the kiln is 220V you can buy a single [3 Phase SSR](https://www.auberins.com/index.php?main_page=product_info&cPath=2_30&products_id=331). It's like having 3 SSRs in one. Relays this big always require a heat sink. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/ks-1018.png) | Electric Kiln | There are many old electric kilns on the market that don't have digital controls. You can pick one up on the used market cheaply. This controller will work with 110V or 220V (pick a proper SSR). My kiln is a Skutt KS-1018. | | ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/ks-1018.png) | Electric Kiln | There are many old electric kilns on the market that don't have digital controls. You can pick one up on the used market cheaply. This controller will work with 110V or 220V (pick a proper SSR). My kiln is a Skutt KS-1018. |
### Schematic ### Schematic
The pi has three gpio pins connected to the MAX31855 chip. D0 is configured as an input and CS and CLK are outputs. The signal that controls the solid state relay starts as a gpio output which drives a transistor acting as a switch in front of it. This transistor provides 5V and plenty of current to control the ssr. Since only four gpio pins are in use, any pi can be used for this project. See the [config](https://github.com/jbruce12000/kiln-controller/blob/main/config.py) file for gpio pin configuration. The pi has three gpio pins connected to the MAX31855 chip. D0 is configured as an input and CS and CLK are outputs. The signal that controls the solid state relay starts as a gpio output which drives a transistor acting as a switch in front of it. This transistor provides 5V and plenty of current to control the ssr. Since only four gpio pins are in use, any pi can be used for this project. See the [config](https://github.com/jbruce12000/kiln-controller/blob/master/config.py) file for gpio pin configuration.
My controller plugs into the wall, and the kiln plugs into the controller. My controller plugs into the wall, and the kiln plugs into the controller.
**WARNING** This project involves high voltages and high currents. Please make sure that anything you build conforms to local electrical codes and aligns with industry best practices. **WARNING** This project involves high voltages and high currents. Please make sure that anything you build conforms to local electrical codes and aligns with industry best practices.
**Note:** The GPIO configuration in this schematic does not match the defaults, check [config](https://github.com/jbruce12000/kiln-controller/blob/main/config.py) and make sure the gpio pin configuration aligns with your actual connections. ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/schematic.png)
![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/schematic.png)
*Note: I tried to power my ssr directly using a gpio pin, but it did not work. My ssr required 25ma to switch and rpi's gpio could only provide 16ma. YMMV.* *Note: I tried to power my ssr directly using a gpio pin, but it did not work. My ssr required 25ma to switch and rpi's gpio could only provide 16ma. YMMV.*
@ -68,11 +62,14 @@ Download [Raspberry PI OS](https://www.raspberrypi.org/software/). Use Rasberry
$ sudo apt-get update $ sudo apt-get update
$ sudo apt-get dist-upgrade $ sudo apt-get dist-upgrade
$ sudo apt-get install python3-dev python3-virtualenv libevent-dev virtualenv
$ git clone https://github.com/jbruce12000/kiln-controller $ git clone https://github.com/jbruce12000/kiln-controller
$ cd kiln-controller $ cd kiln-controller
$ python3 -m venv venv $ virtualenv -p python3 venv
$ source venv/bin/activate $ source venv/bin/activate
$ pip install -r requirements.txt $ export CFLAGS=-fcommon
$ pip3 install --upgrade setuptools
$ pip3 install greenlet bottle gevent gevent-websocket
*Note: The above steps work on ubuntu if you prefer* *Note: The above steps work on ubuntu if you prefer*
@ -80,45 +77,19 @@ Download [Raspberry PI OS](https://www.raspberrypi.org/software/). Use Rasberry
If you're done playing around with simulations and want to deploy the code on a Raspberry PI to control a kiln, you'll need to do this in addition to the stuff listed above: If you're done playing around with simulations and want to deploy the code on a Raspberry PI to control a kiln, you'll need to do this in addition to the stuff listed above:
$ sudo raspi-config $ cd kiln-controller
interfacing options -> SPI -> Select Yes to enable $ virtualenv -p python3 venv
select reboot $ source venv/bin/activate
$ export CFLAGS=-fcommon
$ pip3 install -r requirements.txt
## Configuration ## Configuration
All parameters are defined in config.py. You need to read through config.py carefully to understand each setting. Here are some of the most important settings: All parameters are defined in config.py, review/change to your mind's content.
| Variable | Default | Description | You should change, test, and verify PID parameters in config.py. Here is a [PID Tuning Guide](https://github.com/jbruce12000/kiln-controller/blob/master/docs/pid_tuning.md). There is also an [autotuner](https://github.com/jbruce12000/kiln-controller/blob/master/docs/ziegler_tuning.md). Be patient with tuning. No tuning is perfect across a wide temperature range.
| -------- | ------- | ----------- |
| sensor_time_wait | 2 seconds | It's the duty cycle for the entire system. It's set to two seconds by default which means that a decision is made every 2s about whether to turn on relay[s] and for how long. If you use mechanical relays, you may want to increase this. At 2s, my SSR switches 11,000 times in 13 hours. |
| temp_scale | f | f for farenheit, c for celcius |
| pid parameters | | Used to tune your kiln. See PID Tuning. |
| simulate | True | Simulate a kiln. Used to test the software by new users so they can check out the features. |
## Testing You may want to change the configuration parameter **sensor_time_wait**. It's the duty cycle for the entire system. It's set to two seconds by default which means that a decision is made every 2s about whether to turn on relay[s] and for how long. If you use mechanical relays, you may want to increase this. At 2s, my SSR switches 11,000 times in 13 hours.
After you've completed connecting all the hardware together, there are scripts to test the thermocouple and to test the output to the solid state relay. Read the scripts below and then start your testing. First, activate the virtual environment like so...
$ source venv/bin/activate
then test the thermocouple with:
$ ./test-thermocouple.py
then test the output with:
$ ./test-output.py
and you can use this script to examine each pin's state including input/output/voltage on your board:
$ ./gpioreadall.py
## PID Tuning
Run the [autotuner](https://github.com/jbruce12000/kiln-controller/blob/main/docs/ziegler_tuning.md). It will heat your kiln to 400F, pass that, and then once it cools back down to 400F, it will calculate PID values which you must copy into config.py. No tuning is perfect across a wide temperature range. Here is a [PID Tuning Guide](https://github.com/jbruce12000/kiln-controller/blob/main/docs/pid_tuning.md) if you end up having to manually tune.
There is a state view that can help with tuning. It shows the P,I, and D parameters over time plus allows for a csv dump of data collected. It also shows lots of other details that might help with troubleshooting issues. Go to /state.
## Usage ## Usage
@ -140,13 +111,9 @@ of your PI and the port defined in config.py (default 8081).
In config.py, set **simulate=True**. Start the server and select a profile and click Start. Simulations run at near real time. In config.py, set **simulate=True**. Start the server and select a profile and click Start. Simulations run at near real time.
### Scheduling a Kiln run
If you want to schedule a kiln run to start in the future. Here are [examples](https://github.com/jbruce12000/kiln-controller/blob/main/docs/scheduling.md).
### Watcher ### Watcher
If you're busy and do not want to sit around watching the web interface for problems, there is a watcher.py script which you can run on any machine in your local network or even on the raspberry pi which will watch the kiln-controller process to make sure it is running a schedule, and staying within a pre-defined temperature range. When things go bad, it sends messages to a slack channel you define. I have alerts set on my android phone for that specific slack channel. Here are detailed [instructions](https://github.com/jbruce12000/kiln-controller/blob/main/docs/watcher.md). If you're busy and do not want to sit around watching the web interface for problems, there is a watcher.py script which you can run on any machine in your local network or even on the raspberry pi which will watch the kiln-controller process to make sure it is running a schedule, and staying within a pre-defined temperature range. When things go bad, it sends messages to a slack channel you define. I have alerts set on my android phone for that specific slack channel. Here are detailed [instructions](https://github.com/jbruce12000/kiln-controller/blob/master/docs/watcher.md).
## License ## License
@ -166,7 +133,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
## Support & Contact ## Support & Contact
Please use the issue tracker for project related issues. Please use the issue tracker for project related issues.
If you're having trouble with hardware, I did too. Here is a [troubleshooting guide](https://github.com/jbruce12000/kiln-controller/blob/main/docs/troubleshooting.md) I created for testing RPi gpio pins. If you're having trouble with hardware, I did too. Here is a [troubleshooting guide](https://github.com/jbruce12000/kiln-controller/blob/master/docs/troubleshooting.md) I created for testing RPi gpio pins.
## Origin ## Origin
This project was originally forked from https://github.com/apollo-ng/picoReflow but has diverged a large amount. This project was originally forked from https://github.com/apollo-ng/picoReflow but has diverged a large amount.

View File

@ -1 +0,0 @@
{"data": [[0, 200], [3600, 200], [4200, 500], [10800, 500], [14400, 2250], [16400, 2000], [19400, 2250]], "type": "profile", "name": "test-fast"}

View File

@ -1 +0,0 @@
{"data": [[0, 200], [3600, 200], [10800, 2000], [14400, 2250], [16400, 2250], [19400, 700]], "type": "profile", "name": "test-fast"}

View File

@ -1,80 +0,0 @@
from lib.oven import Profile
import os
import json
def get_profile(file = "test-fast.json"):
profile_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'Test', file))
print(profile_path)
with open(profile_path) as infile:
profile_json = json.dumps(json.load(infile))
profile = Profile(profile_json)
return profile
def test_get_target_temperature():
profile = get_profile()
temperature = profile.get_target_temperature(3000)
assert int(temperature) == 200
temperature = profile.get_target_temperature(6004)
assert temperature == 801.0
def test_find_time_from_temperature():
profile = get_profile()
time = profile.find_next_time_from_temperature(500)
assert time == 4800
time = profile.find_next_time_from_temperature(2004)
assert time == 10857.6
time = profile.find_next_time_from_temperature(1900)
assert time == 10400.0
def test_find_time_odd_profile():
profile = get_profile("test-cases.json")
time = profile.find_next_time_from_temperature(500)
assert time == 4200
time = profile.find_next_time_from_temperature(2023)
assert time == 16676.0
def test_find_x_given_y_on_line_from_two_points():
profile = get_profile()
y = 500
p1 = [3600, 200]
p2 = [10800, 2000]
time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
assert time == 4800
y = 500
p1 = [3600, 200]
p2 = [10800, 200]
time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
assert time == 0
y = 500
p1 = [3600, 600]
p2 = [10800, 600]
time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
assert time == 0
y = 500
p1 = [3600, 500]
p2 = [10800, 500]
time = profile.find_x_given_y_on_line_from_two_points(y, p1, p2)
assert time == 0

174
config.py
View File

@ -1,7 +1,8 @@
import logging import logging
import os import os
from digitalio import DigitalInOut
import busio # uncomment this if using MAX-31856
#from lib.max31856 import MAX31856
######################################################################## ########################################################################
# #
@ -20,107 +21,37 @@ listening_port = 8081
# This is used to calculate a cost estimate before a run. It's also used # This is used to calculate a cost estimate before a run. It's also used
# to produce the actual cost during a run. My kiln has three # to produce the actual cost during a run. My kiln has three
# elements that when my switches are set to high, consume 9460 watts. # elements that when my switches are set to high, consume 9460 watts.
kwh_rate = 0.1319 # cost per kilowatt hour per currency_type to calculate cost to run job kwh_rate = 0.1319 # cost per kilowatt hour per currency_type to calculate cost to run job
kw_elements = 9.460 # if the kiln elements are on, the wattage in kilowatts kw_elements = 9.460 # if the kiln elements are on, the wattage in kilowatts
currency_type = "$" # Currency Symbol to show when calculating cost to run job currency_type = "$" # Currency Symbol to show when calculating cost to run job
######################################################################## ########################################################################
# #
# Hardware Setup (uses BCM Pin Numbering) # GPIO Setup (BCM SoC Numbering Schema)
# #
# kiln-controller.py uses SPI interface from the blinka library to read # Check the RasPi docs to see where these GPIOs are
# temperature data from the adafruit-31855 or adafruit-31856. # connected on the P1 header for your board type/rev.
# Blinka supports many different boards. I've only tested raspberry pi. # These were tested on a Pi B Rev2 but of course you
# # can use whichever GPIO you prefer/have available.
# First you must decide whether to use hardware spi or software spi.
#
# Hardware SPI
#
# - faster
# - requires 3 specific GPIO pins be used on rpis
# - no pins are listed in this config file
#
# Software SPI
#
# - slower (which will not matter for reading a thermocouple
# - can use any GPIO pins
# - pins must be specified in this config file
####################################### ### Outputs
# SPI pins if you choose Hardware SPI # gpio_heat = 23 # Switches zero-cross solid-state-relay
#######################################
# On the raspberry pi, you MUST use predefined
# pins for HW SPI. In the case of the adafruit-31855, only 3 pins are used:
#
# SPI0_SCLK = BCM pin 11 = CLK on the adafruit-31855
# SPI0_MOSI = BCM pin 10 = not connected
# SPI0_MISO = BCM pin 9 = D0 on the adafruit-31855
#
# plus a GPIO output to connect to CS. You can use any GPIO pin you want.
# I chose gpio pin 5:
#
# GPIO5 = BCM pin 5 = CS on the adafruit-31855
#
# Note that NO pins are configured in this file for hardware spi
####################################### ### Thermocouple Adapter selection:
# SPI pins if you choose software spi # # max31855 - bitbang SPI interface
####################################### # max31856 - bitbang SPI interface. must specify thermocouple_type.
# For software SPI, you can choose any GPIO pins you like.
# You must connect clock, mosi, miso and cs each to a GPIO pin
# and configure them below based on your connections.
#######################################
# SPI is Autoconfigured !!!
#######################################
# whether you choose HW or SW spi, it is autodetected. If you list the PINs
# below, software spi is assumed.
#######################################
# Output to control the relay
#######################################
# A single GPIO pin is used to control a relay which controls the kiln.
# I use GPIO pin 23.
try:
import board
spi_sclk = board.D17 #spi clock
spi_miso = board.D27 #spi Microcomputer In Serial Out
spi_cs = board.D22 #spi Chip Select
spi_mosi = board.D10 #spi Microcomputer Out Serial In (not connected)
gpio_heat = board.D23 #output that controls relay
gpio_heat_invert = False #invert the output state
except (NotImplementedError,AttributeError):
print("not running on blinka recognized board, probably a simulation")
#######################################
### Thermocouple breakout boards
#######################################
# There are only two breakoutboards supported.
# max31855 - only supports type K thermocouples
# max31856 - supports many thermocouples
max31855 = 1 max31855 = 1
max31856 = 0 max31856 = 0
# uncomment these two lines if using MAX-31856 # see lib/max31856.py for other thermocouple_type, only applies to max31856
import adafruit_max31856 # uncomment this if using MAX-31856
thermocouple_type = adafruit_max31856.ThermocoupleType.K #thermocouple_type = MAX31856.MAX31856_S_TYPE
# here are the possible max-31856 thermocouple types ### Thermocouple Connection (using bitbang interfaces)
# ThermocoupleType.B gpio_sensor_cs = 27
# ThermocoupleType.E gpio_sensor_clock = 22
# ThermocoupleType.J gpio_sensor_data = 17
# ThermocoupleType.K gpio_sensor_di = 10 # only used with max31856
# ThermocoupleType.N
# ThermocoupleType.R
# ThermocoupleType.S
# ThermocoupleType.T
########################################################################
#
# If your kiln is above the starting temperature of the schedule when you
# click the Start button... skip ahead and begin at the first point in
# the schedule matching the current kiln temperature.
seek_start = True
######################################################################## ########################################################################
# #
@ -140,9 +71,10 @@ sensor_time_wait = 2
# well with the simulated oven. You must tune them to work well with # well with the simulated oven. You must tune them to work well with
# your specific kiln. Note that the integral pid_ki is # your specific kiln. Note that the integral pid_ki is
# inverted so that a smaller number means more integral action. # inverted so that a smaller number means more integral action.
pid_kp = 10 # Proportional 25,200,200 pid_kp = 25 # Proportional 25,200,200
pid_ki = 80 # Integral pid_ki = 10 # Integral
pid_kd = 220.83497910261562 # Derivative pid_kd = 200 # Derivative
######################################################################## ########################################################################
# #
@ -156,7 +88,7 @@ stop_integral_windup = True
# #
# Simulation parameters # Simulation parameters
simulate = True simulate = True
sim_t_env = 65 # deg sim_t_env = 60.0 # deg C
sim_c_heat = 500.0 # J/K heat capacity of heat element sim_c_heat = 500.0 # J/K heat capacity of heat element
sim_c_oven = 5000.0 # J/K heat capacity of oven sim_c_oven = 5000.0 # J/K heat capacity of oven
sim_p_heat = 5450.0 # W heating power of oven sim_p_heat = 5450.0 # W heating power of oven
@ -165,10 +97,6 @@ sim_R_o_cool = 0.05 # K/W " with cooling
sim_R_ho_noair = 0.1 # K/W thermal resistance heat element -> oven sim_R_ho_noair = 0.1 # K/W thermal resistance heat element -> oven
sim_R_ho_air = 0.05 # K/W " with internal air circulation sim_R_ho_air = 0.05 # K/W " with internal air circulation
# if you want simulations to happen faster than real time, this can be
# set as high as 1000 to speed simulations up by 1000 times.
sim_speedup_factor = 1
######################################################################## ########################################################################
# #
@ -176,6 +104,7 @@ sim_speedup_factor = 1
# #
# If you change the temp_scale, all settings in this file are assumed to # If you change the temp_scale, all settings in this file are assumed to
# be in that scale. # be in that scale.
temp_scale = "f" # c = Celsius | f = Fahrenheit - Unit to display temp_scale = "f" # c = Celsius | f = Fahrenheit - Unit to display
time_scale_slope = "h" # s = Seconds | m = Minutes | h = Hours - Slope displayed in temp_scale per time_scale_slope time_scale_slope = "h" # s = Seconds | m = Minutes | h = Hours - Slope displayed in temp_scale per time_scale_slope
time_scale_profile = "m" # s = Seconds | m = Minutes | h = Hours - Enter and view target time in time_scale_profile time_scale_profile = "m" # s = Seconds | m = Minutes | h = Hours - Enter and view target time in time_scale_profile
@ -199,7 +128,7 @@ kiln_must_catch_up = True
# or 100% off because the kiln is too hot. No integral builds up # or 100% off because the kiln is too hot. No integral builds up
# outside the window. The bigger you make the window, the more # outside the window. The bigger you make the window, the more
# integral you will accumulate. This should be a positive integer. # integral you will accumulate. This should be a positive integer.
pid_control_window = 5 #degrees pid_control_window = 5 #degrees
# thermocouple offset # thermocouple offset
# If you put your thermocouple in ice water and it reads 36F, you can # If you put your thermocouple in ice water and it reads 36F, you can
@ -207,11 +136,11 @@ pid_control_window = 5 #degrees
# cheap thermocouple. Invest in a better thermocouple. # cheap thermocouple. Invest in a better thermocouple.
thermocouple_offset=0 thermocouple_offset=0
# number of samples of temperature to take over each duty cycle. # number of samples of temperature to average.
# The larger the number, the more load on the board. K type # If you suffer from the high temperature kiln issue and have set
# thermocouples have a precision of about 1/2 degree C. # honour_theromocouple_short_errors to False,
# The median of these samples is used for the temperature. # you will likely need to increase this (eg I use 40)
temperature_average_samples = 10 temperature_average_samples = 40
# Thermocouple AC frequency filtering - set to True if in a 50Hz locale, else leave at False for 60Hz locale # 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 = False
@ -225,25 +154,16 @@ ac_freq_50hz = False
# - unknown error with thermocouple # - unknown error with thermocouple
# - too many errors in a short period from thermocouple # - too many errors in a short period from thermocouple
# but in some cases, you might want to ignore a specific error, log it, # but in some cases, you might want to ignore a specific error, log it,
# and continue running your profile instead of having the process die. # and continue running your profile.
#
# You should only set these to True if you experience a problem
# and WANT to ignore it to complete a firing.
ignore_temp_too_high = False ignore_temp_too_high = False
ignore_tc_lost_connection = False ignore_lost_connection_tc = False
ignore_tc_cold_junction_range_error = False ignore_unknown_tc_error = False
ignore_tc_range_error = False ignore_too_many_tc_errors = False
ignore_tc_cold_junction_temp_high = False # some kilns/thermocouples start erroneously reporting "short"
ignore_tc_cold_junction_temp_low = False # errors at higher temperatures due to plasma forming in the kiln.
ignore_tc_temp_high = False # Set this to True to ignore these errors and assume the temperature
ignore_tc_temp_low = False # reading was correct anyway
ignore_tc_voltage_error = False
ignore_tc_short_errors = False ignore_tc_short_errors = False
ignore_tc_unknown_error = False
# This overrides all possible thermocouple errors and prevents the
# process from exiting.
ignore_tc_too_many_errors = False
######################################################################## ########################################################################
# automatic restarts - if you have a power brown-out and the raspberry pi # automatic restarts - if you have a power brown-out and the raspberry pi
@ -267,13 +187,3 @@ automatic_restart_state_file = os.path.abspath(os.path.join(os.path.dirname( __f
kiln_profiles_directory = os.path.abspath(os.path.join(os.path.dirname( __file__ ),"storage", "profiles")) kiln_profiles_directory = os.path.abspath(os.path.join(os.path.dirname( __file__ ),"storage", "profiles"))
#kiln_profiles_directory = os.path.abspath(os.path.join(os.path.dirname( __file__ ),'..','kiln-profiles','pottery')) #kiln_profiles_directory = os.path.abspath(os.path.join(os.path.dirname( __file__ ),'..','kiln-profiles','pottery'))
########################################################################
# low temperature throttling of elements
# kiln elements have lots of power and tend to drastically overshoot
# at low temperatures. When under the set point and outside the PID
# control window and below throttle_below_temp, only throttle_percent
# of the elements are used max.
# To prevent throttling, set throttle_percent to 100.
throttle_below_temp = 300
throttle_percent = 20

View File

@ -18,11 +18,3 @@ post a memo
stats for currently running schedule stats for currently running schedule
curl -X GET http://0.0.0.0:8081/api/stats curl -X GET http://0.0.0.0:8081/api/stats
pause a run (maintain current temperature until resume)
curl -d '{"cmd":"pause"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
resume a paused run
curl -d '{"cmd":"resume"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api

View File

@ -1,77 +0,0 @@
Migrating from Old to New kiln-controller Code
==========
This describes how to migrate from the old version of the code to the new.
## History
In early 2023 I rewrote most of the kiln-controller back-end code. It was a major change with all new classes. Lots of libraries were removed and the Adafruit blinka library was chosen which allows for a hundred or more supported boards in addition to raspberry pis.
## Why Swap?
As of 2023 I stopped supporting and adding features to the old code. It still works, but I no longer use it, update it, test it, or change it.
## Easiest possible migration
The easiest way to convert from the old code to the new is to use software spi, also known as bitbanging, to grab data from the thermocouple board. You will not have to make any wiring changes. You'll only need to change config.py and test it to make sure it works.
1. make a backup of config.py. You'll need it for the next step.
```
cp config.py config.py.bak
```
2. update to the new code
```
git checkout master
git pull (maybe force here???)
```
FIXME - need instructions on branch names to checkout etc.
3. Install all the libraries that the new code uses
```
cd kiln-controller
source venv/bin/activate
pip install -r ./requirements.txt
```
4. find these settings in config.py.bak and change them in config.py:
```
gpio_sensor_cs = 27
gpio_sensor_clock = 22
gpio_sensor_data = 17
gpio_sensor_di = 10
gpio_heat = 23
```
change them in config.py to look like so:
```
spi_cs = board.D27
spi_sclk = board.D22
spi_miso = board.D17
spi_mosi = board.D10 #this one is not actually used, so set it or not
gpio_heat = board.D23
gpio_heat_invert = False
```
5. test the thermocouple board and thermocouple
```
./test-thermocouple.py
```
You should see that **software spi** is configured. You should see the pin configuration printed out. You should see the temperature reported every second.
4. test output
```
./test-output.py
```
Every 5 seconds, verify the output is flipped from on to off or vice versa.

View File

@ -1,74 +0,0 @@
Scheduling a Kiln Run
=====================
Our lives are busy. Sometimes you'll want your kiln to start at a scheduled time. This is really easy to do with the **at** command. Scheduled events persist if the raspberry pi reboots.
## Install the scheduler
This installs and starts the **at** scheduler.
sudo apt-get update
sudo apt-get install at
### Verify Time Settings
Verify the date and time and time zone are right on your system:
date
If yours looks right, proceed to **Examples**. If not, you need to execute commands to set it. On a raspberry-pi, this is easiest by running...
sudo raspi-config
Localisation Options -> Timezone -> Pick one -> Ok
## Examples
Start a biscuit firing at 5am Friday morning:
at 5:00am friday <<END
curl -d '{"cmd":"run", "profile":"cone-05-long-bisque"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
END
Start a glaze firing in 15 minutes and start a kiln watcher. This is really useful because the kiln watcher should page you in slack if something is wrong with the firing:
at now +15 minutes <<END
curl -d '{"cmd":"run", "profile":"cone-6-long-glaze"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
source ~/kiln-controller/venv/bin/activate; ~/kiln-controller/watcher.jbruce.py
END
Start a biscuit fire at 1a tomorrow, but skip the first two hours [120 minutes] of candling because I know my wares are dry. Start a kiln watcher 15 minutes later to give the kiln time to reach temperature so the watcher does not page me.
at 1am tomorrow <<END
curl -d '{"cmd":"run", "profile":"cone-05-long-bisque","startat":120}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
END
at 1:15am tomorrow <<END
source ~/kiln-controller/venv/bin/activate; ~/kiln-controller/watcher.jbruce.py
END
Stop any running firing at 3pm tomorrow:
at 3pm tomorrow <<END
curl -d '{"cmd":"stop"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
END
Start a 15 hour long glaze firing in 5 minutes and schedule for graphs from [kiln-stats](https://github.com/jbruce12000/kiln-stats) to be created on the raspberry-pi afterward and make the graphs available via a web server running on port 8000. You can do all kinds of interesting things with this. You could create a single job for the webserver and a job per hour to update the graphs. This way you can see detailed graphs of PID params and how the system is responding to them.
at now + 5 minutes <<END
curl -d '{"cmd":"run", "profile":"cone-6-long-glaze"}' -H "Content-Type: application/json" -X POST http://0.0.0.0:8081/api
END
at now + 16 hours <<END
source ~/kiln-stats/venv/bin/activate; cd ~/kiln-stats/scripts/; cat /var/log/daemon.log |~/kiln-stats/scripts/log-splitter.pl |grep ^1>~/kiln-stats/input/daemon.log; ~/kiln-stats/scripts/go; cd ~/kiln-stats/output; python3 -m http.server
END
List scheduled jobs...
atq
Remove scheduled jobs...
atrm jobid
where jobid is an integer that came from the atq output

View File

@ -1,72 +0,0 @@
This is a list of all the boards that have SPI support in blinka. Any of
these could be used to control the kiln for this project. I have experience
only with Raspberry PI.
- bananapi/bpim2plus.py
- bananapi/bpim2zero.py
- bananapi/bpim5.py
- beagleboard/beaglebone_ai.py
- beagleboard/beaglebone_black.py
- beagleboard/beaglebone_pocketbeagle.py
- beagleboard/beaglev_starlight.py
- binho_nova.py
- clockworkcpi3.py
- coral_dev_board_mini.py
- coral_dev_board.py
- dragonboard_410c.py
- feather_huzzah.py
- feather_u2if.py
- ftdi_ft2232h.py
- ftdi_ft232h.py
- giantboard.py
- greatfet_one.py
- hardkernel/odroidc2.py
- hardkernel/odroidc4.py
- hardkernel/odroidn2.py
- hardkernel/odroidxu4.py
- hifive_unleashed.py
- itsybitsy_u2if.py
- khadas/khadasvim3.py
- librecomputer/aml_s905x_cc_v1.py
- lubancat/lubancat_imx6ull.py
- macropad_u2if.py
- nanopi/duo2.py
- nanopi/neoair.py
- nanopi/neo.py
- nodemcu.py
- nvidia/clara_agx_xavier.py
- nvidia/jetson_nano.py
- nvidia/jetson_nx.py
- nvidia/jetson_orin.py
- nvidia/jetson_tx1.py
- nvidia/jetson_tx2_nx.py
- nvidia/jetson_tx2.py
- nvidia/jetson_xavier.py
- onion/omega2.py
- orangepi/orangepi3.py
- orangepi/orangepi4.py
- orangepi/orangepipc.py
- orangepi/orangepir1.py
- orangepi/orangepizero2.py
- orangepi/orangepizeroplus2h5.py
- orangepi/orangepizeroplus.py
- orangepi/orangepizero.py
- pico_u2if.py
- pine64.py
- pineH64.py
- qtpy_u2if.py
- radxa/radxazero.py
- radxa/rockpi4.py
- radxa/rockpie.py
- radxa/rockpis.py
- raspberrypi/raspi_1b_rev1.py
- raspberrypi/raspi_1b_rev2.py
- raspberrypi/raspi_40pin.py
- raspberrypi/raspi_4b.py
- raspberrypi/raspi_cm.py
- siemens/siemens_iot2050.py
- soPine.py
- stm32/osd32mp1_brk.py
- stm32/osd32mp1_red.py
- stm32/stm32mp157c_dk2.py
- tritium-h3.py

View File

@ -24,7 +24,7 @@ If you're using a breadboard with a labeled break-out board, verify:
I thought at one point that I had fried my RPi. I needed to verify that it I thought at one point that I had fried my RPi. I needed to verify that it
still worked as expected. Here's what I did to verify GPIO on my pi. still worked as expected. Here's what I did to verify GPIO on my pi.
```source venv/bin/activate; ./gpioreadall.py``` ```gpio readall```
and you'll get output that looks something like this... and you'll get output that looks something like this...
@ -76,7 +76,7 @@ This will show you the output of gpio readall every 2 seconds. This way you can
moving a wire to each gpio pin and then look up to verify **V** has changed as you expect without moving a wire to each gpio pin and then look up to verify **V** has changed as you expect without
having to type. having to type.
```watch ./gpioreadall.py``` ```watch gpio readall```
* connect a 3V3 pin in series to a 1k ohm resistor * connect a 3V3 pin in series to a 1k ohm resistor
* connect the other end of the resistor to each gpio pin one at a time * connect the other end of the resistor to each gpio pin one at a time

View File

@ -6,67 +6,84 @@ The method implemented here is taken from ["ZieglerNichols Tuning Method"](ht
One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further manual adjustment may be necessary. One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further manual adjustment may be necessary.
- make sure the kiln-controller is **stopped** ## Process Overview
- make sure your kiln is in the same state it will be in during a normal firing. For instance, if you use a kiln vent during normal firing, make sure it is on.
- make sure the kiln is completely cool. We need to record the data starting from room temperature to correctly measure the effect of kiln/heating.
## Step 1: Stop the kiln-controller process 1. First of all, you will record a temperature profile for your kiln.
2. Next, we use those figures to estimate Kp/Ki/Kd.
If the kiln controller auto-starts, you'll need to stop it before tuning... ## Step 1: Record Temperature Profie
```sudo service kiln-controller stop``` Ensure `kiln-controller` is **stopped** during profile recording: The profile must be recorded without any interference from the actual PID control loop (you also don't want two things changing the same GPIOs at the same time!)
After, you're done with the tuning process, just reboot and the kiln-controller will automatically restart. Make sure your kiln is completely cool - we need to record the data starting from room temperature to correctly measure the effect of kiln/heating.
## Step 2: Run the Auto-Tuner There needs to be no abnormal source of temperature change to the kiln: eg if you normally run with a kiln plug in place - make sure its in place for the test!
run the auto-tuner: To record the profile, run:
``` ```
source venv/bin/activate; ./kiln-tuner.py python kiln-tuner.py recordprofile zn.csv
``` ```
The kiln-tuner will heat your kiln to 400F. Next it will start cooling. Once the temperature goes back to 400F, the PID values are calculated and the program ends. The output will look like this: The above will drive your kiln to 400 and record the temperature profile to the file `zn.csv`. The file will look something like this:
``` ```
stage = cooling, actual = 401.51, target = 400.00 time,temperature
stage = cooling, actual = 401.26, target = 400.00 4.025461912,45.5407078
stage = cooling, actual = 401.01, target = 400.00 6.035358906,45.5407078
stage = cooling, actual = 400.77, target = 400.00 8.045399904,45.5407078
stage = cooling, actual = 400.52, target = 400.00 10.05544925,45.59087846
stage = cooling, actual = 400.28, target = 400.00 ...
stage = cooling, actual = 400.03, target = 400.00
stage = cooling, actual = 399.78, target = 400.00
pid_kp = 14.231158917317776
pid_ki = 4.745613033146341
pid_kd = 240.27736881914797
``` ```
## Step 3: Replace the PID parameters in config.py ## Step 2: Compute the PID parameters
Copy & paste the pid_kp, pid_ki, and pid_kd values into config.py and restart the kiln-controller. Test out the values by firing your kiln. They may require manual adjustment. Once you have your zn.csv profile, run the following:
## The values didn't work for me. ```
python kiln-tuner.py zn zn.csv
```
The Ziegler Nicols estimate requires that your graph look similar to this: [kiln-tuner-example.png](kiln-tuner-example.png). The smooth linear part of the chart is very important. If it is too short, try increasing the target temperature (see later). The red diagonal line **must** follow the smooth part of your chart closely. The values will be output to stdout, for example:
```
Kp: 3.853985144980333 1/Ki: 87.78173053095107 Kd: 325.9599328488931
```
(Note that the Ki value is already inverted ready for use in config)
------
## Sanity checking the results
If you run
```
python kiln-tuner.py zn zn.csv --showplot
```
It will display a plot of the parameters. It should look simular to this ![kiln-tuner-example.png](kiln-tuner-example.png).
Note: you will need python's `pyplot` installed for this to work.
The smooth linear part of the chart is very important. If it is too short, try increasing the target temperature (see later).
The red diagonal line: this **must** follow the smooth part of your chart closely.
## My diagonal line isn't right ## My diagonal line isn't right
You might need to adjust the line parameters to make it fit your data properly. You'll do this using previously saved data without the need to heat & cool again. You might need to adjust the line parameters to make it fit your data properly. You can do this as follows:
``` ```
source venv/bin/activate;./kiln-tuner.py -c -s -d 4 python kiln-tuner.py zn zn.csv --tangentdivisor 4
``` ```
| Parameter | Description | `tangentdivisor` modifies which parts of the profile is used to calculate the line.
| --------- | ----------- |
| -c | calculate only (don't heat/cool and record) | It is a floating point number >= 2; If necessary, try varying it till you get a better fit.
| -s | show plot (requires pyplot be installed in the virtual env) |
| -d float | tangent divisor which modifies which part of the profile is used to calculate the line. Must be >= 2.0. Vary it to get a better fit. |
## Changing the target temperature ## Changing the target temperature
By default it is 400F. You can change this as follows: By default it is 400. You can change this as follows:
``` ```
python kiln-tuner.py -t 500 python kiln-tuner.py recordprofile zn.csv --targettemp 500
``` ```
(where the target temperature has been changed to 500 in the example above)

View File

@ -1,151 +0,0 @@
#! /usr/bin/env python3
# 2021-04-02
# 2021-04-13 Fix Wrong model for Old Style revision codes
# 2021-12-20 Improve Old Style revision codes; ignore unwanted status bits
# 2022-03-25 Zero 2 W
# 2022-04-07 typo
"""
Read all GPIO
This version for raspi-gpio debug tool
"""
import sys, os, time
import subprocess
MODES=["IN", "OUT", "ALT5", "ALT4", "ALT0", "ALT1", "ALT2", "ALT3"]
HEADER = ('3.3v', '5v', 2, '5v', 3, 'GND', 4, 14, 'GND', 15, 17, 18, 27, 'GND', 22, 23, '3.3v', 24, 10, 'GND', 9, 25, 11, 8, 'GND', 7, 0, 1, 5, 'GND', 6, 12, 13, 'GND', 19, 16, 26, 20, 'GND', 21)
# https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#new-style-revision-codes
PiModel = {
0: 'A',
1: 'B',
2: 'A+',
3: 'B+',
4: '2B',
6: 'CM1',
8: '3B',
9: 'Zero',
0xa: 'CM3',
0xc: 'ZeroW',
0xd: '3B+',
0xe: '3A+',
0x10: 'CM3+',
0x11: '4B',
0x12: 'Zero2W',
0x13: '400',
0x14: 'CM4'
}
RED = '\033[1;31m'
GREEN = '\033[1;32m'
ORANGE = '\033[1;33m'
BLUE = '\033[1;34m'
LRED = '\033[1;91m'
YELLOW = '\033[1;93m'
RESET = '\033[0;0m'
COL = {
'3.3v': LRED,
'5v': RED,
'GND': GREEN
}
TYPE = 0
rev = 0
def pin_state(g):
"""
Return "state" of BCM g
Return is tuple (name, mode, value)
"""
result = subprocess.run(['raspi-gpio', 'get', ascii(g)], stdout=subprocess.PIPE).stdout.decode('utf-8')
D = {} # Convert output of raspi-gpio get to dict for convenience
paras = result.split()
for par in paras[2:] :
p, v = par.split('=')
if (v.isdigit()):
D[p] = int(v)
else:
D[p] = v
if('fsel' in D):
if(D['fsel'] < 2): # i.e. IN or OUT
name = 'GPIO{}'.format(g)
else:
name = D['func']
mode = MODES[D['fsel']]
if(D['fsel'] == 0 and 'pull' in D):
if(D['pull'] == 'UP'):
mode = 'IN ^'
if(D['pull'] == 'DOWN'):
mode = 'IN v'
else:
name = D['func']
mode = ''
return name, mode, D['level']
def print_gpio(pin_state):
"""
Print listing of Raspberry pins, state & value
Layout matching Pi 2 row Header
"""
global TYPE, rev
GPIOPINS = 40
try:
Model = 'Pi ' + PiModel[TYPE]
except:
Model = 'Pi ??'
if rev < 16 : # older models (pre PiB+)
GPIOPINS = 26
print('+-----+------------+------+---+{:^10}+---+------+-----------+-----+'.format(Model) )
print('| BCM | Name | Mode | V | Board | V | Mode | Name | BCM |')
print('+-----+------------+------+---+----++----+---+------+-----------+-----+')
for h in range(1, GPIOPINS, 2):
# odd pin
hh = HEADER[h-1]
if(type(hh)==type(1)):
print('|{0:4} | {1[0]:<10} | {1[1]:<4} | {1[2]} |{2:3} '.format(hh, pin_state(hh), h), end='|| ')
else:
# print('| {:18} | {:2}'.format(hh, h), end=' || ') # non-coloured output
print('| {}{:18} | {:2}{}'.format(COL[hh], hh, h, RESET), end=' || ') # coloured output
# even pin
hh = HEADER[h]
if(type(hh)==type(1)):
print('{0:2} | {1[2]:<2}| {1[1]:<5}| {1[0]:<10}|{2:4} |'.format(h+1, pin_state(hh), hh))
else:
# print('{:2} | {:9} |'.format(h+1, hh)) # non-coloured output
print('{}{:2} | {:9}{} |'.format(COL[hh], h+1, hh, RESET)) # coloured output
print('+-----+------------+------+---+----++----+---+------+-----------+-----+')
print('| BCM | Name | Mode | V | Board | V | Mode | Name | BCM |')
print('+-----+------------+------+---+{:^10}+---+------+-----------+-----+'.format(Model) )
def get_hardware_revision():
"""
Returns the Pi's hardware revision number.
"""
with open('/proc/cpuinfo', 'r') as f:
for line in f.readlines():
if 'Revision' in line:
REV = line.split(':')[1]
REV = REV.strip() # Revision as string
return int(REV, base=16)
def main():
global TYPE, rev
rev = get_hardware_revision()
if(rev & 0x800000): # New Style
TYPE = (rev&0x00000FF0)>>4
else: # Old Style
rev &= 0x1F
MM = [0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 3, 6, 2, 3, 6, 2]
TYPE = MM[rev] # Map Old Style revision to TYPE
print_gpio(pin_state)
if __name__ == '__main__':
main()

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
import time
import os import os
import sys import sys
import logging import logging
@ -9,20 +8,28 @@ import json
import bottle import bottle
import gevent import gevent
import geventwebsocket import geventwebsocket
#from bottle import post, get
# from bottle import post, get
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
from geventwebsocket.handler import WebSocketHandler from geventwebsocket.handler import WebSocketHandler
from geventwebsocket import WebSocketError from geventwebsocket import WebSocketError
# try/except removed here on purpose so folks can see why things break try:
import config sys.dont_write_bytecode = True
import config
sys.dont_write_bytecode = False
except:
print("Could not import config file.")
print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.")
exit(1)
logging.basicConfig(level=config.log_level, format=config.log_format) logging.basicConfig(level=config.log_level, format=config.log_format)
log = logging.getLogger("kiln-controller") log = logging.getLogger("kiln-controller")
log.info("Starting kiln controller") log.info("Starting kiln controller")
script_dir = os.path.dirname(os.path.realpath(__file__)) script_dir = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, script_dir + '/lib/') sys.path.insert(0, script_dir + "/lib/")
profile_path = config.kiln_profiles_directory profile_path = config.kiln_profiles_directory
from oven import SimulatedOven, RealOven, Profile from oven import SimulatedOven, RealOven, Profile
@ -40,110 +47,99 @@ ovenWatcher = OvenWatcher(oven)
# this ovenwatcher is used in the oven class for restarts # this ovenwatcher is used in the oven class for restarts
oven.set_ovenwatcher(ovenWatcher) oven.set_ovenwatcher(ovenWatcher)
@app.route('/')
@app.route("/")
def index(): def index():
return bottle.redirect('/picoreflow/index.html') return bottle.redirect("/picoreflow/index.html")
@app.route('/state')
def state():
return bottle.redirect('/picoreflow/state.html')
@app.get('/api/stats') @app.get("/api/stats")
def handle_api(): def handle_api():
log.info("/api/stats command received") log.info("/api/stats command received")
if hasattr(oven,'pid'): if hasattr(oven, "pid"):
if hasattr(oven.pid,'pidstats'): if hasattr(oven.pid, "pidstats"):
return json.dumps(oven.pid.pidstats) return json.dumps(oven.pid.pidstats)
@app.post('/api') @app.post("/api")
def handle_api(): def handle_api():
log.info("/api is alive") log.info("/api is alive")
# run a kiln schedule # run a kiln schedule
if bottle.request.json['cmd'] == 'run': if bottle.request.json["cmd"] == "run":
wanted = bottle.request.json['profile'] wanted = bottle.request.json["profile"]
log.info('api requested run of profile = %s' % wanted) log.info("api requested run of profile = %s" % wanted)
# start at a specific minute in the schedule # start at a specific minute in the schedule
# for restarting and skipping over early parts of a schedule # for restarting and skipping over early parts of a schedule
startat = 0; startat = 0
if 'startat' in bottle.request.json: if "startat" in bottle.request.json:
startat = bottle.request.json['startat'] startat = bottle.request.json["startat"]
#Shut off seek if start time has been set
allow_seek = True
if startat > 0:
allow_seek = False
# get the wanted profile/kiln schedule # get the wanted profile/kiln schedule
profile = find_profile(wanted) profile = find_profile(wanted)
if profile is None: if profile is None:
return { "success" : False, "error" : "profile %s not found" % wanted } return {"success": False, "error": "profile %s not found" % wanted}
# FIXME juggling of json should happen in the Profile class # FIXME juggling of json should happen in the Profile class
profile_json = json.dumps(profile) profile_json = json.dumps(profile)
profile = Profile(profile_json) profile = Profile(profile_json)
oven.run_profile(profile, startat=startat, allow_seek=allow_seek) oven.run_profile(profile, startat=startat)
ovenWatcher.record(profile) ovenWatcher.record(profile)
if bottle.request.json['cmd'] == 'pause': if bottle.request.json["cmd"] == "stop":
log.info("api pause command received")
oven.state = 'PAUSED'
if bottle.request.json['cmd'] == 'resume':
log.info("api resume command received")
oven.state = 'RUNNING'
if bottle.request.json['cmd'] == 'stop':
log.info("api stop command received") log.info("api stop command received")
oven.abort_run() oven.abort_run()
if bottle.request.json['cmd'] == 'memo': if bottle.request.json["cmd"] == "memo":
log.info("api memo command received") log.info("api memo command received")
memo = bottle.request.json['memo'] memo = bottle.request.json["memo"]
log.info("memo=%s" % (memo)) log.info("memo=%s" % (memo))
# get stats during a run # get stats during a run
if bottle.request.json['cmd'] == 'stats': if bottle.request.json["cmd"] == "stats":
log.info("api stats command received") log.info("api stats command received")
if hasattr(oven,'pid'): if hasattr(oven, "pid"):
if hasattr(oven.pid,'pidstats'): if hasattr(oven.pid, "pidstats"):
return json.dumps(oven.pid.pidstats) return json.dumps(oven.pid.pidstats)
return { "success" : True } return {"success": True}
def find_profile(wanted): def find_profile(wanted):
''' """
given a wanted profile name, find it and return the parsed given a wanted profile name, find it and return the parsed
json profile object or None. json profile object or None.
''' """
#load all profiles from disk # load all profiles from disk
profiles = get_profiles() profiles = get_profiles()
json_profiles = json.loads(profiles) json_profiles = json.loads(profiles)
# find the wanted profile # find the wanted profile
for profile in json_profiles: for profile in json_profiles:
if profile['name'] == wanted: if profile["name"] == wanted:
return profile return profile
return None return None
@app.route('/picoreflow/:filename#.*#')
@app.route("/picoreflow/:filename#.*#")
def send_static(filename): def send_static(filename):
log.debug("serving %s" % filename) log.debug("serving %s" % filename)
return bottle.static_file(filename, root=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "public")) return bottle.static_file(
filename,
root=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "public"),
)
def get_websocket_from_request(): def get_websocket_from_request():
env = bottle.request.environ env = bottle.request.environ
wsock = env.get('wsgi.websocket') wsock = env.get("wsgi.websocket")
if not wsock: if not wsock:
abort(400, 'Expected WebSocket request.') abort(400, "Expected WebSocket request.")
return wsock return wsock
@app.route('/control') @app.route("/control")
def handle_control(): def handle_control():
wsock = get_websocket_from_request() wsock = get_websocket_from_request()
log.info("websocket (control) opened") log.info("websocket (control) opened")
@ -155,7 +151,7 @@ def handle_control():
msgdict = json.loads(message) msgdict = json.loads(message)
if msgdict.get("cmd") == "RUN": if msgdict.get("cmd") == "RUN":
log.info("RUN command received") log.info("RUN command received")
profile_obj = msgdict.get('profile') profile_obj = msgdict.get("profile")
if profile_obj: if profile_obj:
profile_json = json.dumps(profile_obj) profile_json = json.dumps(profile_obj)
profile = Profile(profile_json) profile = Profile(profile_json)
@ -163,26 +159,25 @@ def handle_control():
ovenWatcher.record(profile) ovenWatcher.record(profile)
elif msgdict.get("cmd") == "SIMULATE": elif msgdict.get("cmd") == "SIMULATE":
log.info("SIMULATE command received") log.info("SIMULATE command received")
#profile_obj = msgdict.get('profile') # profile_obj = msgdict.get('profile')
#if profile_obj: # if profile_obj:
# profile_json = json.dumps(profile_obj) # profile_json = json.dumps(profile_obj)
# profile = Profile(profile_json) # profile = Profile(profile_json)
#simulated_oven = Oven(simulate=True, time_step=0.05) # simulated_oven = Oven(simulate=True, time_step=0.05)
#simulation_watcher = OvenWatcher(simulated_oven) # simulation_watcher = OvenWatcher(simulated_oven)
#simulation_watcher.add_observer(wsock) # simulation_watcher.add_observer(wsock)
#simulated_oven.run_profile(profile) # simulated_oven.run_profile(profile)
#simulation_watcher.record(profile) # simulation_watcher.record(profile)
elif msgdict.get("cmd") == "STOP": elif msgdict.get("cmd") == "STOP":
log.info("Stop command received") log.info("Stop command received")
oven.abort_run() oven.abort_run()
time.sleep(1)
except WebSocketError as e: except WebSocketError as e:
log.error(e) log.error(e)
break break
log.info("websocket (control) closed") log.info("websocket (control) closed")
@app.route('/storage') @app.route("/storage")
def handle_storage(): def handle_storage():
wsock = get_websocket_from_request() wsock = get_websocket_from_request()
log.info("websocket (storage) opened") log.info("websocket (storage) opened")
@ -203,18 +198,18 @@ def handle_storage():
wsock.send(get_profiles()) wsock.send(get_profiles())
elif msgdict.get("cmd") == "DELETE": elif msgdict.get("cmd") == "DELETE":
log.info("DELETE command received") log.info("DELETE command received")
profile_obj = msgdict.get('profile') profile_obj = msgdict.get("profile")
if delete_profile(profile_obj): if delete_profile(profile_obj):
msgdict["resp"] = "OK" msgdict["resp"] = "OK"
wsock.send(json.dumps(msgdict)) wsock.send(json.dumps(msgdict))
#wsock.send(get_profiles()) # wsock.send(get_profiles())
elif msgdict.get("cmd") == "PUT": elif msgdict.get("cmd") == "PUT":
log.info("PUT command received") log.info("PUT command received")
profile_obj = msgdict.get('profile') profile_obj = msgdict.get("profile")
#force = msgdict.get('force', False) # force = msgdict.get('force', False)
force = True force = True
if profile_obj: if profile_obj:
#del msgdict["cmd"] # del msgdict["cmd"]
if save_profile(profile_obj, force): if save_profile(profile_obj, force):
msgdict["resp"] = "OK" msgdict["resp"] = "OK"
else: else:
@ -223,13 +218,12 @@ def handle_storage():
wsock.send(json.dumps(msgdict)) wsock.send(json.dumps(msgdict))
wsock.send(get_profiles()) wsock.send(get_profiles())
time.sleep(1)
except WebSocketError: except WebSocketError:
break break
log.info("websocket (storage) closed") log.info("websocket (storage) closed")
@app.route('/config') @app.route("/config")
def handle_config(): def handle_config():
wsock = get_websocket_from_request() wsock = get_websocket_from_request()
log.info("websocket (config) opened") log.info("websocket (config) opened")
@ -239,11 +233,10 @@ def handle_config():
wsock.send(get_config()) wsock.send(get_config())
except WebSocketError: except WebSocketError:
break break
time.sleep(1)
log.info("websocket (config) closed") log.info("websocket (config) closed")
@app.route('/status') @app.route("/status")
def handle_status(): def handle_status():
wsock = get_websocket_from_request() wsock = get_websocket_from_request()
ovenWatcher.add_observer(wsock) ovenWatcher.add_observer(wsock)
@ -254,99 +247,69 @@ def handle_status():
wsock.send("Your message was: %r" % message) wsock.send("Your message was: %r" % message)
except WebSocketError: except WebSocketError:
break break
time.sleep(1)
log.info("websocket (status) closed") log.info("websocket (status) closed")
def get_profiles(): def get_profiles():
try: try:
profile_files = os.listdir(profile_path) profile_files = os.listdir(profile_path)
profile_files.sort()
except: except:
profile_files = [] profile_files = []
profiles = [] profiles = []
for filename in profile_files: for filename in profile_files:
with open(os.path.join(profile_path, filename), 'r') as f: if filename.startswith("._"):
profiles.append(json.load(f)) pass
profiles = normalize_temp_units(profiles) else:
if filename.endswith(".json"):
with open(os.path.join(profile_path, filename), "r") as f:
profiles.append(json.load(f))
else:
pass
return json.dumps(profiles) return json.dumps(profiles)
def save_profile(profile, force=False): def save_profile(profile, force=False):
profile=add_temp_units(profile)
profile_json = json.dumps(profile) profile_json = json.dumps(profile)
filename = profile['name']+".json" filename = profile["name"] + ".json"
filepath = os.path.join(profile_path, filename) filepath = os.path.join(profile_path, filename)
if not force and os.path.exists(filepath): if not force and os.path.exists(filepath):
log.error("Could not write, %s already exists" % filepath) log.error("Could not write, %s already exists" % filepath)
return False return False
with open(filepath, 'w+') as f: with open(filepath, "w+") as f:
f.write(profile_json) f.write(profile_json)
f.close() f.close()
log.info("Wrote %s" % filepath) log.info("Wrote %s" % filepath)
return True return True
def add_temp_units(profile):
"""
always store the temperature in degrees c
this way folks can share profiles
"""
if "temp_units" in profile:
return profile
profile['temp_units']="c"
if config.temp_scale=="c":
return profile
if config.temp_scale=="f":
profile=convert_to_c(profile);
return profile
def convert_to_c(profile):
newdata=[]
for (secs,temp) in profile["data"]:
temp = (5/9)*(temp-32)
newdata.append((secs,temp))
profile["data"]=newdata
return profile
def convert_to_f(profile):
newdata=[]
for (secs,temp) in profile["data"]:
temp = ((9/5)*temp)+32
newdata.append((secs,temp))
profile["data"]=newdata
return profile
def normalize_temp_units(profiles):
normalized = []
for profile in profiles:
if "temp_units" in profile:
if config.temp_scale == "f" and profile["temp_units"] == "c":
profile = convert_to_f(profile)
profile["temp_units"] = "f"
normalized.append(profile)
return normalized
def delete_profile(profile): def delete_profile(profile):
profile_json = json.dumps(profile) profile_json = json.dumps(profile)
filename = profile['name']+".json" filename = profile["name"] + ".json"
filepath = os.path.join(profile_path, filename) filepath = os.path.join(profile_path, filename)
os.remove(filepath) os.remove(filepath)
log.info("Deleted %s" % filepath) log.info("Deleted %s" % filepath)
return True return True
def get_config(): def get_config():
return json.dumps({"temp_scale": config.temp_scale, return json.dumps(
"time_scale_slope": config.time_scale_slope, {
"time_scale_profile": config.time_scale_profile, "temp_scale": config.temp_scale,
"kwh_rate": config.kwh_rate, "time_scale_slope": config.time_scale_slope,
"currency_type": config.currency_type}) "time_scale_profile": config.time_scale_profile,
"kwh_rate": config.kwh_rate,
"currency_type": config.currency_type,
}
)
def main(): def main():
ip = "0.0.0.0" ip = "0.0.0.0"
port = config.listening_port port = config.listening_port
log.info("listening on %s:%d" % (ip, port)) log.info("listening on %s:%d" % (ip, port))
server = WSGIServer((ip, port), app, server = WSGIServer((ip, port), app, handler_class=WebSocketHandler)
handler_class=WebSocketHandler)
server.serve_forever() server.serve_forever()

View File

@ -6,19 +6,19 @@ import csv
import time import time
import argparse import argparse
try:
def recordprofile(csvfile, targettemp):
try:
sys.dont_write_bytecode = True sys.dont_write_bytecode = True
import config import config
sys.dont_write_bytecode = False sys.dont_write_bytecode = False
except ImportError: except ImportError:
print("Could not import config file.") print("Could not import config file.")
print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.") print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.")
exit(1) exit(1)
def recordprofile(csvfile, targettemp):
script_dir = os.path.dirname(os.path.realpath(__file__)) script_dir = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, script_dir + '/lib/') sys.path.insert(0, script_dir + '/lib/')
@ -32,7 +32,6 @@ def recordprofile(csvfile, targettemp):
# construct the oven # construct the oven
if config.simulate: if config.simulate:
oven = SimulatedOven() oven = SimulatedOven()
oven.target = targettemp * 2 # insures max heating for simulation
else: else:
oven = RealOven() oven = RealOven()
@ -43,43 +42,35 @@ def recordprofile(csvfile, targettemp):
# * wait for it to decay back to the target again. # * wait for it to decay back to the target again.
# * quit # * quit
# #
# We record the temperature every config.sensor_time_wait # We record the temperature every second
try: try:
stage = 'heating'
if not config.simulate:
oven.output.heat(0)
# heating to target of 400F while True:
temp = 0 temp = oven.board.temp_sensor.temperature + \
sleepfor = config.sensor_time_wait
stage = "heating"
while(temp <= targettemp):
if config.simulate:
oven.heat_then_cool()
else:
oven.output.heat(sleepfor)
temp = oven.board.temp_sensor.temperature() + \
config.thermocouple_offset config.thermocouple_offset
print("stage = %s, actual = %.2f, target = %.2f" % (stage,temp,targettemp))
csvout.writerow([time.time(), temp]) csvout.writerow([time.time(), temp])
f.flush() f.flush()
# overshoot past target of 400F and then cooling down to 400F if stage == 'heating':
stage = "cooling" if temp >= targettemp:
if config.simulate: if not config.simulate:
oven.target = 0 oven.output.cool(0)
while(temp >= targettemp): stage = 'cooling'
if config.simulate:
oven.heat_then_cool() elif stage == 'cooling':
else: if temp < targettemp:
oven.output.cool(sleepfor) break
temp = oven.board.temp_sensor.temperature() + \
config.thermocouple_offset print("stage = %s, actual = %s, target = %s" % (stage,temp,targettemp))
time.sleep(1)
print("stage = %s, actual = %.2f, target = %.2f" % (stage,temp,targettemp))
csvout.writerow([time.time(), temp]) f.close()
f.flush()
finally: finally:
f.close()
# ensure we always shut the oven down! # ensure we always shut the oven down!
if not config.simulate: if not config.simulate:
oven.output.cool(0) oven.output.cool(0)
@ -184,22 +175,34 @@ def calculate(filename, tangentdivisor, showplot):
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Kiln tuner') parser = argparse.ArgumentParser(description='Kiln tuner')
parser.add_argument('-c', '--calculate_only', action='store_true') subparsers = parser.add_subparsers()
parser.add_argument('-t', '--target_temp', type=float, default=400, help="Target temperature") parser.set_defaults(mode='')
parser.add_argument('-d', '--tangent_divisor', type=float, default=8, help="Adjust the tangent calculation to fit better. Must be >= 2 (default 8).")
parser.add_argument('-s', '--showplot', action='store_true', help="draw plot so you can see tanget line and possibly change") parser_profile = subparsers.add_parser('recordprofile', help='Record kiln temperature profile')
parser_profile.add_argument('csvfile', type=str, help="The CSV file to write to.")
parser_profile.add_argument('--targettemp', type=int, default=400, help="The target temperature to drive the kiln to (default 400).")
parser_profile.set_defaults(mode='recordprofile')
parser_zn = subparsers.add_parser('zn', help='Calculate Ziegler-Nicols parameters')
parser_zn.add_argument('csvfile', type=str, help="The CSV file to read from. Must contain two columns called time (time in seconds) and temperature (observed temperature)")
parser_zn.add_argument('--showplot', action='store_true', help="If set, also plot results (requires pyplot to be pip installed)")
parser_zn.add_argument('--tangentdivisor', type=float, default=8, help="Adjust the tangent calculation to fit better. Must be >= 2 (default 8).")
parser_zn.set_defaults(mode='zn')
args = parser.parse_args() args = parser.parse_args()
csvfile = "tuning.csv" if args.mode == 'recordprofile':
target = args.target_temp recordprofile(args.csvfile, args.targettemp)
if config.temp_scale.lower() == "c":
target = (target - 32)*5/9 elif args.mode == 'zn':
tangentdivisor = args.tangent_divisor if args.tangentdivisor < 2:
raise ValueError("tangentdivisor must be >= 2")
calculate(args.csvfile, args.tangentdivisor, args.showplot)
elif args.mode == '':
parser.print_help()
exit(1)
# default behavior is to record profile to csv file tuning.csv
# and then calculate pid values and print them
if args.calculate_only:
calculate(csvfile, tangentdivisor, args.showplot)
else: else:
recordprofile(csvfile, target) raise NotImplementedError("Unknown mode %s" % args.mode)
calculate(csvfile, tangentdivisor, args.showplot)

265
lib/max31855.py Normal file
View File

@ -0,0 +1,265 @@
#!/usr/bin/python
import RPi.GPIO as GPIO
import math
class MAX31855(object):
'''Python driver for [MAX38155 Cold-Junction Compensated Thermocouple-to-Digital Converter](http://www.maximintegrated.com/datasheet/index.mvp/id/7273)
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
self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
# 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())
return getattr(self, "to_" + self.units)(self.data_to_LinearizedTempC())
def get_rj(self):
'''Reads SPI bus and returns current value of reference junction.'''
self.read()
return getattr(self, "to_" + self.units)(self.data_to_rj_temperature())
def read(self):
'''Reads 32 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 32 bits
for i in range(32):
GPIO.output(self.clock_pin, GPIO.LOW)
bytesin = bytesin << 1
if (GPIO.input(self.data_pin)):
bytesin = bytesin | 1
GPIO.output(self.clock_pin, GPIO.HIGH)
# Unselect the chip
GPIO.output(self.cs_pin, GPIO.HIGH)
# Save data
self.data = bytesin
def checkErrors(self, data_32 = None):
'''Checks error bits to see if there are any SCV, SCG, or OC faults'''
if data_32 is None:
data_32 = self.data
anyErrors = (data_32 & 0x10000) != 0 # Fault bit, D16
if anyErrors:
self.noConnection = (data_32 & 0x00000001) != 0 # OC bit, D0
self.shortToGround = (data_32 & 0x00000002) != 0 # SCG bit, D1
self.shortToVCC = (data_32 & 0x00000004) != 0 # SCV bit, D2
self.unknownError = not (self.noConnection | self.shortToGround | self.shortToVCC) # Errk!
else:
self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
def data_to_tc_temperature(self, data_32 = None):
'''Takes an integer and returns a thermocouple temperature in celsius.'''
if data_32 is None:
data_32 = self.data
tc_data = ((data_32 >> 18) & 0x3FFF)
return self.convert_tc_data(tc_data)
def data_to_rj_temperature(self, data_32 = None):
'''Takes an integer and returns a reference junction temperature in celsius.'''
if data_32 is None:
data_32 = self.data
rj_data = ((data_32 >> 4) & 0xFFF)
return self.convert_rj_data(rj_data)
def convert_tc_data(self, tc_data):
'''Convert thermocouple data to a useful number (celsius).'''
if tc_data & 0x2000:
# two's compliment
without_resolution = ~tc_data & 0x1FFF
without_resolution += 1
without_resolution *= -1
else:
without_resolution = tc_data & 0x1FFF
return without_resolution * 0.25
def convert_rj_data(self, rj_data):
'''Convert reference junction data to a useful number (celsius).'''
if rj_data & 0x800:
without_resolution = ~rj_data & 0x7FF
without_resolution += 1
without_resolution *= -1
else:
without_resolution = rj_data & 0x7FF
return without_resolution * 0.0625
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)
def data_to_LinearizedTempC(self, data_32 = None):
'''Return the NIST-linearized thermocouple temperature value in degrees
celsius. See https://learn.adafruit.com/calibrating-sensors/maxim-31855-linearization for more infoo.
This code came from https://github.com/nightmechanic/FuzzypicoReflow/blob/master/lib/max31855.py
'''
if data_32 is None:
data_32 = self.data
# extract TC temp
# Check if signed bit is set.
if data_32 & 0x80000000:
# Negative value, take 2's compliment. Compute this with subtraction
# because python is a little odd about handling signed/unsigned.
data_32 >>= 18
data_32 -= 16384
else:
# Positive value, just shift the bits to get the value.
data_32 >>= 18
# Scale by 0.25 degrees C per bit and return value.
TC_temp = data_32 * 0.25
# Extract Internal Temp
data_32 = self.data
# Ignore bottom 4 bits of thermocouple data.
data_32 >>= 4
# Grab bottom 11 bits as internal temperature data.
Internal_Temp= data_32 & 0x7FF
if data_32 & 0x800:
# Negative value, take 2's compliment. Compute this with subtraction
# because python is a little odd about handling signed/unsigned.
Internal_Temp -= 4096
# Scale by 0.0625 degrees C per bit and return value.
Internal_Temp = Internal_Temp * 0.0625
# MAX31855 thermocouple voltage reading in mV
thermocoupleVoltage = (TC_temp - Internal_Temp) * 0.041276
# MAX31855 cold junction voltage reading in mV
coldJunctionTemperature = Internal_Temp
coldJunctionVoltage = (-0.176004136860E-01 +
0.389212049750E-01 * coldJunctionTemperature +
0.185587700320E-04 * math.pow(coldJunctionTemperature, 2.0) +
-0.994575928740E-07 * math.pow(coldJunctionTemperature, 3.0) +
0.318409457190E-09 * math.pow(coldJunctionTemperature, 4.0) +
-0.560728448890E-12 * math.pow(coldJunctionTemperature, 5.0) +
0.560750590590E-15 * math.pow(coldJunctionTemperature, 6.0) +
-0.320207200030E-18 * math.pow(coldJunctionTemperature, 7.0) +
0.971511471520E-22 * math.pow(coldJunctionTemperature, 8.0) +
-0.121047212750E-25 * math.pow(coldJunctionTemperature, 9.0) +
0.118597600000E+00 * math.exp(-0.118343200000E-03 * math.pow((coldJunctionTemperature-0.126968600000E+03), 2.0)))
# cold junction voltage + thermocouple voltage
voltageSum = thermocoupleVoltage + coldJunctionVoltage
# calculate corrected temperature reading based on coefficients for 3 different ranges
# float b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10;
if voltageSum < 0:
b0 = 0.0000000E+00
b1 = 2.5173462E+01
b2 = -1.1662878E+00
b3 = -1.0833638E+00
b4 = -8.9773540E-01
b5 = -3.7342377E-01
b6 = -8.6632643E-02
b7 = -1.0450598E-02
b8 = -5.1920577E-04
b9 = 0.0000000E+00
elif voltageSum < 20.644:
b0 = 0.000000E+00
b1 = 2.508355E+01
b2 = 7.860106E-02
b3 = -2.503131E-01
b4 = 8.315270E-02
b5 = -1.228034E-02
b6 = 9.804036E-04
b7 = -4.413030E-05
b8 = 1.057734E-06
b9 = -1.052755E-08
elif voltageSum < 54.886:
b0 = -1.318058E+02
b1 = 4.830222E+01
b2 = -1.646031E+00
b3 = 5.464731E-02
b4 = -9.650715E-04
b5 = 8.802193E-06
b6 = -3.110810E-08
b7 = 0.000000E+00
b8 = 0.000000E+00
b9 = 0.000000E+00
else:
# TODO: handle error - out of range
return 0
return (b0 +
b1 * voltageSum +
b2 * pow(voltageSum, 2.0) +
b3 * pow(voltageSum, 3.0) +
b4 * pow(voltageSum, 4.0) +
b5 * pow(voltageSum, 5.0) +
b6 * pow(voltageSum, 6.0) +
b7 * pow(voltageSum, 7.0) +
b8 * pow(voltageSum, 8.0) +
b9 * pow(voltageSum, 9.0))
class MAX31855Error(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
if __name__ == "__main__":
# Multi-chip example
import time
cs_pins = [4, 17, 18, 24]
clock_pin = 23
data_pin = 22
units = "f"
thermocouples = []
for cs_pin in cs_pins:
thermocouples.append(MAX31855(cs_pin, clock_pin, data_pin, units))
running = True
while(running):
try:
for thermocouple in thermocouples:
rj = thermocouple.get_rj()
try:
tc = thermocouple.get()
except MAX31855Error as e:
tc = "Error: "+ e.value
running = False
print("tc: {} and rj: {}".format(tc, rj))
time.sleep(1)
except KeyboardInterrupt:
running = False
for thermocouple in thermocouples:
thermocouple.cleanup()

36
lib/max31855spi.py Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/python
import logging
from Adafruit_MAX31855 import MAX31855
class MAX31855SPI(object):
'''Python driver for [MAX38155 Cold-Junction Compensated Thermocouple-to-Digital Converter](http://www.maximintegrated.com/datasheet/index.mvp/id/7273)
Requires:
- adafruit's MAX31855 SPI-only device library
'''
def __init__(self, spi_dev):
self.max31855 = MAX31855.MAX31855(spi=spi_dev)
self.log = logging.getLogger(__name__)
def get(self):
'''Reads SPI bus and returns current value of thermocouple.'''
state = self.max31855.readState()
self.log.debug("status %s" % state)
if state['openCircuit']:
raise MAX31855Error('Not Connected')
elif state['shortGND']:
raise MAX31855Error('Short to Ground')
elif state['shortVCC']:
raise MAX31855Error('Short to VCC')
elif state['fault']:
raise MAX31855Error('Unknown Error')
return self.max31855.readLinearizedTempC()
class MAX31855SPIError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)

341
lib/max31856.py Normal file
View File

@ -0,0 +1,341 @@
"""
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, ac_freq_50hz=False, ocdetect=0x1, 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.
ac_freq_50hz: Set to True if your AC frequency is 50Hz, Set to False for 60Hz,
ocdetect: Detect open circuit errors (ie broken thermocouple). Choose from values in CR1 table of datasheet
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
self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
# 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.cr0 = self.MAX31856_CR0_READ_CONT | ((ocdetect & 3) << 4) | (1 if ac_freq_50hz else 0)
self.cr1 = (((self.avgsel & 7) << 4) + (self.tc_type & 0x0f))
# Setup for reading continuously with T-Type thermocouple
self._write_register(self.MAX31856_REG_WRITE_CR0, 0)
self._write_register(self.MAX31856_REG_WRITE_CR1, self.cr1)
self._write_register(self.MAX31856_REG_WRITE_CR0, self.cr0)
@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 checkErrors(self):
data = self.read_fault_register()
self.noConnection = (data & 0x00000001) != 0
self.unknownError = (data & 0xfe) != 0
def get(self):
self.checkErrors()
celcius = self.read_temp_c()
return getattr(self, "to_" + self.units)(celcius)
if __name__ == "__main__":
# Multi-chip example
import time
cs_pins = [6]
clock_pin = 13
data_pin = 5
di_pin = 26
units = "c"
thermocouples = []
for cs_pin in cs_pins:
thermocouples.append(MAX31856(avgsel=0, ac_freq_50hz=True, tc_type=MAX31856.MAX31856_K_TYPE, software_spi={'clk': clock_pin, 'cs': cs_pin, 'do': data_pin, 'di': di_pin}, units=units))
running = True
while(running):
try:
for thermocouple in thermocouples:
rj = thermocouple.read_internal_temp_c()
tc = thermocouple.get()
print("tc: {} and rj: {}, NC:{} ??:{}".format(tc, rj, thermocouple.noConnection, thermocouple.unknownError))
time.sleep(1)
except KeyboardInterrupt:
running = False
for thermocouple in thermocouples:
thermocouple.cleanup()

View File

@ -1,14 +1,11 @@
import threading import threading
import time import time
import random
import datetime import datetime
import logging import logging
import json import json
import config import config
import os import os
import digitalio
import busio
import adafruit_bitbangio as bitbangio
import statistics
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -31,297 +28,170 @@ class Duplogger():
duplog = Duplogger().logref() duplog = Duplogger().logref()
class Output(object): class Output(object):
'''This represents a GPIO output that controls a solid
state relay to turn the kiln elements on and off.
inputs
config.gpio_heat
config.gpio_heat_invert
'''
def __init__(self): def __init__(self):
self.active = False self.active = False
self.heater = digitalio.DigitalInOut(config.gpio_heat) self.load_libs()
self.heater.direction = digitalio.Direction.OUTPUT
self.off = config.gpio_heat_invert def load_libs(self):
self.on = not self.off 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): def heat(self,sleepfor):
self.heater.value = self.on self.GPIO.output(config.gpio_heat, self.GPIO.HIGH)
time.sleep(sleepfor) time.sleep(sleepfor)
def cool(self,sleepfor): def cool(self,sleepfor):
'''no active cooling, so sleep''' '''no active cooling, so sleep'''
self.heater.value = self.off self.GPIO.output(config.gpio_heat, self.GPIO.LOW)
time.sleep(sleepfor) time.sleep(sleepfor)
# wrapper for blinka board # FIX - Board class needs to be completely removed
class Board(object): class Board(object):
'''This represents a blinka board where this code
runs.
'''
def __init__(self):
log.info("board: %s" % (self.name))
self.temp_sensor.start()
class RealBoard(Board):
'''Each board has a thermocouple board attached to it.
Any blinka board that supports SPI can be used. The
board is automatically detected by blinka.
'''
def __init__(self): def __init__(self):
self.name = None self.name = None
self.active = False
self.temp_sensor = None
self.gpio_active = False
self.load_libs() self.load_libs()
self.temp_sensor = self.choose_tempsensor() self.create_temp_sensor()
Board.__init__(self) self.temp_sensor.start()
def load_libs(self): def load_libs(self):
import board
self.name = board.board_id
def choose_tempsensor(self):
if config.max31855: if config.max31855:
return Max31855() try:
if config.max31856: #from max31855 import MAX31855, MAX31855Error
return Max31856() self.name='MAX31855'
self.active = True
log.info("import %s " % (self.name))
except ImportError:
msg = "max31855 config set, but import failed"
log.warning(msg)
class SimulatedBoard(Board): if config.max31856:
'''Simulated board used during simulations. try:
See config.simulate #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): def __init__(self):
self.name = "simulated"
self.temp_sensor = TempSensorSimulated() self.temp_sensor = TempSensorSimulated()
Board.__init__(self)
class TempSensor(threading.Thread): class TempSensor(threading.Thread):
'''Used by the Board class. Each Board must have
a TempSensor.
'''
def __init__(self): def __init__(self):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.daemon = True self.daemon = True
self.temperature = 0
self.bad_percent = 0
self.time_step = config.sensor_time_wait self.time_step = config.sensor_time_wait
self.status = ThermocoupleTracker() self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
class TempSensorSimulated(TempSensor): class TempSensorSimulated(TempSensor):
'''Simulates a temperature sensor ''' '''not much here, just need to be able to set the temperature'''
def __init__(self): def __init__(self):
TempSensor.__init__(self) TempSensor.__init__(self)
self.simulated_temperature = config.sim_t_env
def temperature(self):
return self.simulated_temperature
class TempSensorReal(TempSensor): class TempSensorReal(TempSensor):
'''real temperature sensor that takes many measurements '''real temperature sensor thread that takes N measurements
during the time_step during the time_step'''
inputs
config.temperature_average_samples
'''
def __init__(self): def __init__(self):
TempSensor.__init__(self) TempSensor.__init__(self)
self.sleeptime = self.time_step / float(config.temperature_average_samples) self.sleeptime = self.time_step / float(config.temperature_average_samples)
self.temptracker = TempTracker() self.bad_count = 0
self.spi_setup() self.ok_count = 0
self.cs = digitalio.DigitalInOut(config.spi_cs) self.bad_stamp = 0
def spi_setup(self): if config.max31855:
if(hasattr(config,'spi_sclk') and log.info("init MAX31855")
hasattr(config,'spi_mosi') and from max31855 import MAX31855, MAX31855Error
hasattr(config,'spi_miso')): self.thermocouple = MAX31855(config.gpio_sensor_cs,
self.spi = bitbangio.SPI(config.spi_sclk, config.spi_mosi, config.spi_miso) config.gpio_sensor_clock,
log.info("Software SPI selected for reading thermocouple") config.gpio_sensor_data,
else: config.temp_scale)
import board
self.spi = board.SPI();
log.info("Hardware SPI selected for reading thermocouple")
def get_temperature(self): if config.max31856:
'''read temp from tc and convert if needed''' log.info("init MAX31856")
try: from max31856 import MAX31856
temp = self.raw_temp() # raw_temp provided by subclasses software_spi = { 'cs': config.gpio_sensor_cs,
if config.temp_scale.lower() == "f": 'clk': config.gpio_sensor_clock,
temp = (temp*9/5)+32 'do': config.gpio_sensor_data,
self.status.good() 'di': config.gpio_sensor_di }
return temp self.thermocouple = MAX31856(tc_type=config.thermocouple_type,
except ThermocoupleError as tce: software_spi = software_spi,
if tce.ignore: units = config.temp_scale,
log.error("Problem reading temp (ignored) %s" % (tce.message)) ac_freq_50hz = config.ac_freq_50hz,
self.status.good() )
else:
log.error("Problem reading temp %s" % (tce.message))
self.status.bad()
return None
def temperature(self):
'''average temp over a duty cycle'''
return self.temptracker.get_avg_temp()
def run(self): def run(self):
'''use a moving average of config.temperature_average_samples across the time_step'''
temps = []
while True: while True:
temp = self.get_temperature() # reset error counter if time is up
if temp: if (time.time() - self.bad_stamp) > (self.time_step * 2):
self.temptracker.add(temp) 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 not config.ignore_tc_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 = self.get_avg_temp(temps)
time.sleep(self.sleeptime) time.sleep(self.sleeptime)
class TempTracker(object): def get_avg_temp(self, temps, chop=25):
'''creates a sliding window of N temperatures per
config.sensor_time_wait
'''
def __init__(self):
self.size = config.temperature_average_samples
self.temps = [0 for i in range(self.size)]
def add(self,temp):
self.temps.append(temp)
while(len(self.temps) > self.size):
del self.temps[0]
def get_avg_temp(self, chop=25):
''' '''
take the median of the given values. this used to take an avg strip off chop percent from the beginning and end of the sorted temps
after getting rid of outliers. median works better. then return the average of what is left
''' '''
return statistics.median(self.temps) chop = chop / 100
temps = sorted(temps)
class ThermocoupleTracker(object): total = len(temps)
'''Keeps sliding window to track successful/failed calls to get temp items = int(total*chop)
over the last two duty cycles. temps = temps[items:total-items]
''' return sum(temps) / len(temps)
def __init__(self):
self.size = config.temperature_average_samples * 2
self.status = [True for i in range(self.size)]
self.limit = 30
def good(self):
'''True is good!'''
self.status.append(True)
del self.status[0]
def bad(self):
'''False is bad!'''
self.status.append(False)
del self.status[0]
def error_percent(self):
errors = sum(i == False for i in self.status)
return (errors/self.size)*100
def over_error_limit(self):
if self.error_percent() > self.limit:
return True
return False
class Max31855(TempSensorReal):
'''each subclass expected to handle errors and get temperature'''
def __init__(self):
TempSensorReal.__init__(self)
log.info("thermocouple MAX31855")
import adafruit_max31855
self.thermocouple = adafruit_max31855.MAX31855(self.spi, self.cs)
def raw_temp(self):
try:
return self.thermocouple.temperature_NIST
except RuntimeError as rte:
if rte.args and rte.args[0]:
raise Max31855_Error(rte.args[0])
raise Max31855_Error('unknown')
class ThermocoupleError(Exception):
'''
thermocouple exception parent class to handle mapping of error messages
and make them consistent across adafruit libraries. Also set whether
each exception should be ignored based on settings in config.py.
'''
def __init__(self, message):
self.ignore = False
self.message = message
self.map_message()
self.set_ignore()
super().__init__(self.message)
def set_ignore(self):
if self.message == "not connected" and config.ignore_tc_lost_connection == True:
self.ignore = True
if self.message == "short circuit" and config.ignore_tc_short_errors == True:
self.ignore = True
if self.message == "unknown" and config.ignore_tc_unknown_error == True:
self.ignore = True
if self.message == "cold junction range fault" and config.ignore_tc_cold_junction_range_error == True:
self.ignore = True
if self.message == "thermocouple range fault" and config.ignore_tc_range_error == True:
self.ignore = True
if self.message == "cold junction temp too high" and config.ignore_tc_cold_junction_temp_high == True:
self.ignore = True
if self.message == "cold junction temp too low" and config.ignore_tc_cold_junction_temp_low == True:
self.ignore = True
if self.message == "thermocouple temp too high" and config.ignore_tc_temp_high == True:
self.ignore = True
if self.message == "thermocouple temp too low" and config.ignore_tc_temp_low == True:
self.ignore = True
if self.message == "voltage too high or low" and config.ignore_tc_voltage_error == True:
self.ignore = True
def map_message(self):
try:
self.message = self.map[self.orig_message]
except KeyError:
self.message = "unknown"
class Max31855_Error(ThermocoupleError):
'''
All children must set self.orig_message and self.map
'''
def __init__(self, message):
self.orig_message = message
# this purposefully makes "fault reading" and
# "Total thermoelectric voltage out of range..." unknown errors
self.map = {
"thermocouple not connected" : "not connected",
"short circuit to ground" : "short circuit",
"short circuit to power" : "short circuit",
}
super().__init__(message)
class Max31856_Error(ThermocoupleError):
def __init__(self, message):
self.orig_message = message
self.map = {
"cj_range" : "cold junction range fault",
"tc_range" : "thermocouple range fault",
"cj_high" : "cold junction temp too high",
"cj_low" : "cold junction temp too low",
"tc_high" : "thermocouple temp too high",
"tc_low" : "thermocouple temp too low",
"voltage" : "voltage too high or low",
"open_tc" : "not connected"
}
super().__init__(message)
class Max31856(TempSensorReal):
'''each subclass expected to handle errors and get temperature'''
def __init__(self):
TempSensorReal.__init__(self)
log.info("thermocouple MAX31856")
import adafruit_max31856
self.thermocouple = adafruit_max31856.MAX31856(self.spi,self.cs,
thermocouple_type=config.thermocouple_type)
if (config.ac_freq_50hz == True):
self.thermocouple.noise_rejection = 50
else:
self.thermocouple.noise_rejection = 60
def raw_temp(self):
# The underlying adafruit library does not throw exceptions
# for thermocouple errors. Instead, they are stored in
# dict named self.thermocouple.fault. Here we check that
# dict for errors and raise an exception.
# and raise Max31856_Error(message)
temp = self.thermocouple.temperature
for k,v in self.thermocouple.fault.items():
if v:
raise Max31856_Error(k)
return temp
class Oven(threading.Thread): class Oven(threading.Thread):
'''parent oven class. this has all the common code '''parent oven class. this has all the common code
@ -342,51 +212,26 @@ class Oven(threading.Thread):
self.totaltime = 0 self.totaltime = 0
self.target = 0 self.target = 0
self.heat = 0 self.heat = 0
self.heat_rate = 0
self.heat_rate_temps = []
self.pid = PID(ki=config.pid_ki, kd=config.pid_kd, kp=config.pid_kp) self.pid = PID(ki=config.pid_ki, kd=config.pid_kd, kp=config.pid_kp)
self.catching_up = False
@staticmethod
def get_start_from_temperature(profile, temp):
target_temp = profile.get_target_temperature(0)
if temp > target_temp + 5:
startat = profile.find_next_time_from_temperature(temp)
log.info("seek_start is in effect, starting at: {} s, {} deg".format(round(startat), round(temp)))
else:
startat = 0
return startat
def set_heat_rate(self,runtime,temp):
'''heat rate is the heating rate in degrees/hour
'''
# arbitrary number of samples
# the time this covers changes based on a few things
numtemps = 60
self.heat_rate_temps.append((runtime,temp))
# drop old temps off the list
if len(self.heat_rate_temps) > numtemps:
self.heat_rate_temps = self.heat_rate_temps[-1*numtemps:]
time2 = self.heat_rate_temps[-1][0]
time1 = self.heat_rate_temps[0][0]
temp2 = self.heat_rate_temps[-1][1]
temp1 = self.heat_rate_temps[0][1]
if time2 > time1:
self.heat_rate = ((temp2 - temp1) / (time2 - time1))*3600
def run_profile(self, profile, startat=0, allow_seek=True):
log.debug('run_profile run on thread' + threading.current_thread().name)
runtime = startat * 60
if allow_seek:
if self.state == 'IDLE':
if config.seek_start:
temp = self.board.temp_sensor.temperature() # Defined in a subclass
runtime += self.get_start_from_temperature(profile, temp)
def run_profile(self, profile, startat=0):
self.reset() 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
self.startat = startat * 60 self.startat = startat * 60
self.runtime = runtime self.runtime = self.startat
self.start_time = datetime.datetime.now() - datetime.timedelta(seconds=self.startat) self.start_time = datetime.datetime.now() - datetime.timedelta(seconds=self.startat)
self.profile = profile self.profile = profile
self.totaltime = profile.get_duration() self.totaltime = profile.get_duration()
@ -398,28 +243,20 @@ class Oven(threading.Thread):
self.reset() self.reset()
self.save_automatic_restart_state() self.save_automatic_restart_state()
def get_start_time(self):
return datetime.datetime.now() - datetime.timedelta(milliseconds = self.runtime * 1000)
def kiln_must_catch_up(self): def kiln_must_catch_up(self):
'''shift the whole schedule forward in time by one time_step '''shift the whole schedule forward in time by one time_step
to wait for the kiln to catch up''' to wait for the kiln to catch up'''
if config.kiln_must_catch_up == True: if config.kiln_must_catch_up == True:
temp = self.board.temp_sensor.temperature() + \ temp = self.board.temp_sensor.temperature + \
config.thermocouple_offset config.thermocouple_offset
# kiln too cold, wait for it to heat up # kiln too cold, wait for it to heat up
if self.target - temp > config.pid_control_window: if self.target - temp > config.pid_control_window:
log.info("kiln must catch up, too cold, shifting schedule") log.info("kiln must catch up, too cold, shifting schedule")
self.start_time = self.get_start_time() self.start_time = datetime.datetime.now() - datetime.timedelta(milliseconds = self.runtime * 1000)
self.catching_up = True;
return
# kiln too hot, wait for it to cool down # kiln too hot, wait for it to cool down
if temp - self.target > config.pid_control_window: if temp - self.target > config.pid_control_window:
log.info("kiln must catch up, too hot, shifting schedule") log.info("kiln must catch up, too hot, shifting schedule")
self.start_time = self.get_start_time() self.start_time = datetime.datetime.now() - datetime.timedelta(milliseconds = self.runtime * 1000)
self.catching_up = True;
return
self.catching_up = False;
def update_runtime(self): def update_runtime(self):
@ -434,15 +271,25 @@ class Oven(threading.Thread):
def reset_if_emergency(self): def reset_if_emergency(self):
'''reset if the temperature is way TOO HOT, or other critical errors detected''' '''reset if the temperature is way TOO HOT, or other critical errors detected'''
if (self.board.temp_sensor.temperature() + config.thermocouple_offset >= if (self.board.temp_sensor.temperature + config.thermocouple_offset >=
config.emergency_shutoff_temp): config.emergency_shutoff_temp):
log.info("emergency!!! temperature too high") log.info("emergency!!! temperature too high")
if config.ignore_temp_too_high == False: if config.ignore_temp_too_high == False:
self.abort_run() self.abort_run()
if self.board.temp_sensor.status.over_error_limit(): if self.board.temp_sensor.noConnection:
log.info("emergency!!! lost connection to thermocouple")
if config.ignore_lost_connection_tc == False:
self.abort_run()
if self.board.temp_sensor.unknownError:
log.info("emergency!!! unknown thermocouple error")
if config.ignore_unknown_tc_error == False:
self.abort_run()
if self.board.temp_sensor.bad_percent > 30:
log.info("emergency!!! too many errors in a short period") log.info("emergency!!! too many errors in a short period")
if config.ignore_tc_too_many_errors == False: if config.ignore_too_many_tc_errors == False:
self.abort_run() self.abort_run()
def reset_if_schedule_ended(self): def reset_if_schedule_ended(self):
@ -461,14 +308,12 @@ class Oven(threading.Thread):
def get_state(self): def get_state(self):
temp = 0 temp = 0
try: try:
temp = self.board.temp_sensor.temperature() + config.thermocouple_offset temp = self.board.temp_sensor.temperature + config.thermocouple_offset
except AttributeError as error: except AttributeError as error:
# this happens at start-up with a simulated oven # this happens at start-up with a simulated oven
temp = 0 temp = 0
pass pass
self.set_heat_rate(self.runtime,temp)
state = { state = {
'cost': self.cost, 'cost': self.cost,
'runtime': self.runtime, 'runtime': self.runtime,
@ -476,13 +321,11 @@ class Oven(threading.Thread):
'target': self.target, 'target': self.target,
'state': self.state, 'state': self.state,
'heat': self.heat, 'heat': self.heat,
'heat_rate': self.heat_rate,
'totaltime': self.totaltime, 'totaltime': self.totaltime,
'kwh_rate': config.kwh_rate, 'kwh_rate': config.kwh_rate,
'currency_type': config.currency_type, 'currency_type': config.currency_type,
'profile': self.profile.name if self.profile else None, 'profile': self.profile.name if self.profile else None,
'pidstats': self.pid.pidstats, 'pidstats': self.pid.pidstats,
'catching_up': self.catching_up,
} }
return state return state
@ -534,7 +377,7 @@ class Oven(threading.Thread):
with open(profile_path) as infile: with open(profile_path) as infile:
profile_json = json.dumps(json.load(infile)) profile_json = json.dumps(json.load(infile))
profile = Profile(profile_json) profile = Profile(profile_json)
self.run_profile(profile, startat=startat, allow_seek=False) # We don't want a seek on an auto restart. self.run_profile(profile,startat=startat)
self.cost = d["cost"] self.cost = d["cost"]
time.sleep(1) time.sleep(1)
self.ovenwatcher.record(profile) self.ovenwatcher.record(profile)
@ -545,20 +388,11 @@ class Oven(threading.Thread):
def run(self): def run(self):
while True: while True:
log.debug('Oven running on ' + threading.current_thread().name)
if self.state == "IDLE": if self.state == "IDLE":
if self.should_i_automatic_restart() == True: if self.should_i_automatic_restart() == True:
self.automatic_restart() self.automatic_restart()
time.sleep(1) time.sleep(1)
continue continue
if self.state == "PAUSED":
self.start_time = self.get_start_time()
self.update_runtime()
self.update_target_temp()
self.heat_then_cool()
self.reset_if_emergency()
self.reset_if_schedule_ended()
continue
if self.state == "RUNNING": if self.state == "RUNNING":
self.update_cost() self.update_cost()
self.save_automatic_restart_state() self.save_automatic_restart_state()
@ -572,7 +406,7 @@ class Oven(threading.Thread):
class SimulatedOven(Oven): class SimulatedOven(Oven):
def __init__(self): def __init__(self):
self.board = SimulatedBoard() self.board = BoardSimulated()
self.t_env = config.sim_t_env self.t_env = config.sim_t_env
self.c_heat = config.sim_c_heat self.c_heat = config.sim_c_heat
self.c_oven = config.sim_c_oven self.c_oven = config.sim_c_oven
@ -580,34 +414,17 @@ class SimulatedOven(Oven):
self.R_o_nocool = config.sim_R_o_nocool self.R_o_nocool = config.sim_R_o_nocool
self.R_ho_noair = config.sim_R_ho_noair self.R_ho_noair = config.sim_R_ho_noair
self.R_ho = self.R_ho_noair self.R_ho = self.R_ho_noair
self.speedup_factor = config.sim_speedup_factor
# set temps to the temp of the surrounding environment # set temps to the temp of the surrounding environment
self.t = config.sim_t_env # deg C or F temp of oven self.t = self.t_env # deg C temp of oven
self.t_h = self.t_env #deg C temp of heating element self.t_h = self.t_env #deg C temp of heating element
super().__init__() super().__init__()
self.start_time = self.get_start_time();
# start thread # start thread
self.start() self.start()
log.info("SimulatedOven started") log.info("SimulatedOven started")
# runtime is in sped up time, start_time is actual time of day
def get_start_time(self):
return datetime.datetime.now() - datetime.timedelta(milliseconds = self.runtime * 1000 / self.speedup_factor)
def update_runtime(self):
runtime_delta = datetime.datetime.now() - self.start_time
if runtime_delta.total_seconds() < 0:
runtime_delta = datetime.timedelta(0)
self.runtime = runtime_delta.total_seconds() * self.speedup_factor
def update_target_temp(self):
self.target = self.profile.get_target_temperature(self.runtime)
def heating_energy(self,pid): def heating_energy(self,pid):
# using pid here simulates the element being on for # using pid here simulates the element being on for
# only part of the time_step # only part of the time_step
@ -628,14 +445,12 @@ class SimulatedOven(Oven):
self.p_env = (self.t - self.t_env) / self.R_o_nocool self.p_env = (self.t - self.t_env) / self.R_o_nocool
self.t -= self.p_env * self.time_step / self.c_oven self.t -= self.p_env * self.time_step / self.c_oven
self.temperature = self.t self.temperature = self.t
self.board.temp_sensor.simulated_temperature = self.t self.board.temp_sensor.temperature = self.t
def heat_then_cool(self): def heat_then_cool(self):
now_simulator = self.start_time + datetime.timedelta(milliseconds = self.runtime * 1000)
pid = self.pid.compute(self.target, pid = self.pid.compute(self.target,
self.board.temp_sensor.temperature() + self.board.temp_sensor.temperature +
config.thermocouple_offset, now_simulator) config.thermocouple_offset)
heat_on = float(self.time_step * pid) heat_on = float(self.time_step * pid)
heat_off = float(self.time_step * (1 - pid)) heat_off = float(self.time_step * (1 - pid))
@ -647,7 +462,7 @@ class SimulatedOven(Oven):
if heat_on > 0: if heat_on > 0:
self.heat = heat_on self.heat = heat_on
log.info("simulation: -> %dW heater: %.0f -> %dW oven: %.0f -> %dW env" % (int(self.p_heat * pid), log.info("simulation: -> %dW heater: %.0f -> %dW oven: %.0f -> %dW env" % (int(self.p_heat * pid),
self.t_h, self.t_h,
int(self.p_ho), int(self.p_ho),
self.t, self.t,
@ -674,13 +489,13 @@ class SimulatedOven(Oven):
# we don't actually spend time heating & cooling during # we don't actually spend time heating & cooling during
# a simulation, so sleep. # a simulation, so sleep.
time.sleep(self.time_step / self.speedup_factor) time.sleep(self.time_step)
class RealOven(Oven): class RealOven(Oven):
def __init__(self): def __init__(self):
self.board = RealBoard() self.board = Board()
self.output = Output() self.output = Output()
self.reset() self.reset()
@ -696,9 +511,8 @@ class RealOven(Oven):
def heat_then_cool(self): def heat_then_cool(self):
pid = self.pid.compute(self.target, pid = self.pid.compute(self.target,
self.board.temp_sensor.temperature() + self.board.temp_sensor.temperature +
config.thermocouple_offset, datetime.datetime.now()) config.thermocouple_offset)
heat_on = float(self.time_step * pid) heat_on = float(self.time_step * pid)
heat_off = float(self.time_step * (1 - pid)) heat_off = float(self.time_step * (1 - pid))
@ -738,28 +552,6 @@ class Profile():
def get_duration(self): def get_duration(self):
return max([t for (t, x) in self.data]) return max([t for (t, x) in self.data])
# x = (y-y1)(x2-x1)/(y2-y1) + x1
@staticmethod
def find_x_given_y_on_line_from_two_points(y, point1, point2):
if point1[0] > point2[0]: return 0 # time2 before time1 makes no sense in kiln segment
if point1[1] >= point2[1]: return 0 # Zero will crach. Negative temeporature slope, we don't want to seek a time.
x = (y - point1[1]) * (point2[0] -point1[0] ) / (point2[1] - point1[1]) + point1[0]
return x
def find_next_time_from_temperature(self, temperature):
time = 0 # The seek function will not do anything if this returns zero, no useful intersection was found
for index, point2 in enumerate(self.data):
if point2[1] >= temperature:
if index > 0: # Zero here would be before the first segment
if self.data[index - 1][1] <= temperature: # We have an intersection
time = self.find_x_given_y_on_line_from_two_points(temperature, self.data[index - 1], point2)
if time == 0:
if self.data[index - 1][1] == point2[1]: # It's a flat segment that matches the temperature
time = self.data[index - 1][0]
break
return time
def get_surrounding_points(self, time): def get_surrounding_points(self, time):
if time > self.get_duration(): if time > self.get_duration():
return (None, None) return (None, None)
@ -802,7 +594,8 @@ class PID():
# settled on -50 to 50 and then divide by 50 at the end. This results # 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... # in a larger PID control window and much more accurate control...
# instead of what used to be binary on/off control. # instead of what used to be binary on/off control.
def compute(self, setpoint, ispoint, now): def compute(self, setpoint, ispoint):
now = datetime.datetime.now()
timeDelta = (now - self.lastNow).total_seconds() timeDelta = (now - self.lastNow).total_seconds()
window_size = 100 window_size = 100
@ -825,10 +618,6 @@ class PID():
elif error > (1 * config.pid_control_window): elif error > (1 * config.pid_control_window):
log.info("kiln outside pid control window, max heating") log.info("kiln outside pid control window, max heating")
output = 1 output = 1
if config.throttle_below_temp and config.throttle_percent:
if setpoint <= config.throttle_below_temp:
output = config.throttle_percent/100
log.info("max heating throttled at %d percent below %d degrees to prevent overshoot" % (config.throttle_percent,config.throttle_below_temp))
else: else:
icomp = (error * timeDelta * (1/self.ki)) icomp = (error * timeDelta * (1/self.ki))
self.iterm += (error * timeDelta * (1/self.ki)) self.iterm += (error * timeDelta * (1/self.ki))

View File

@ -33,7 +33,7 @@ class OvenWatcher(threading.Thread):
self.recording = False self.recording = False
self.notify_all(oven_state) self.notify_all(oven_state)
time.sleep(self.oven.time_step) time.sleep(self.oven.time_step)
def lastlog_subset(self,maxpts=50): def lastlog_subset(self,maxpts=50):
'''send about maxpts from lastlog by skipping unwanted data''' '''send about maxpts from lastlog by skipping unwanted data'''
totalpts = len(self.last_log) totalpts = len(self.last_log)
@ -79,7 +79,6 @@ class OvenWatcher(threading.Thread):
def notify_all(self,message): def notify_all(self,message):
message_json = json.dumps(message) message_json = json.dumps(message)
log.debug("sending to %d clients: %s"%(len(self.observers),message_json)) log.debug("sending to %d clients: %s"%(len(self.observers),message_json))
for wsock in self.observers: for wsock in self.observers:
if wsock: if wsock:
try: try:

View File

@ -1,47 +0,0 @@
.container {
border-radius: 5px;
background: #888888;
//background: #CCCCCC;
padding: 2px;
display: flex;
flex-direction: row;
align-items: center;
width: max-content;
font-family: sans-serif;
margin: 4px;
}
.stat {
padding: 2px;
border-radius: 5px;
font-size: 40pt;
color: #FFFFFF;
}
.stattxt {
padding: 7px;
border-radius: 5px;
font-size: 40pt;
background: #BBBBBB;
//color: #FFFFFF;
color: #888888;
margin: 1px 2px 1px 2px;
}
.top {
border-radius: 5px 5px 0px 0px;
background: #0000CC;
padding: 4px;
color: #CCCCCC;
text-align: center;
font-size: 18pt;
}
.bottom {
border-radius: 0px 0px 5px 5px;
background: #BBBBBB;
padding: 4px;
text-align: center;
color: #0000CC;
font-size: 20pt;
}

View File

@ -8,7 +8,7 @@ var selected_profile = 0;
var selected_profile_name = 'cone-05-long-bisque.json'; var selected_profile_name = 'cone-05-long-bisque.json';
var temp_scale = "c"; var temp_scale = "c";
var time_scale_slope = "s"; var time_scale_slope = "s";
var time_scale_profile = "h"; var time_scale_profile = "s";
var time_scale_long = "Seconds"; var time_scale_long = "Seconds";
var temp_scale_display = "C"; var temp_scale_display = "C";
var kwh_rate = 0.26; var kwh_rate = 0.26;
@ -274,7 +274,6 @@ function enterEditMode()
graph.profile.draggable = true; graph.profile.draggable = true;
graph.plot = $.plot("#graph_container", [ graph.profile, graph.live ], getOptions()); graph.plot = $.plot("#graph_container", [ graph.profile, graph.live ], getOptions());
updateProfileTable(); updateProfileTable();
toggleTable();
} }
function leaveEditMode() function leaveEditMode()
@ -503,6 +502,9 @@ $(document).ready(function()
ws_status.onmessage = function(e) ws_status.onmessage = function(e)
{ {
console.log("received status data")
console.log(e.data);
x = JSON.parse(e.data); x = JSON.parse(e.data);
if (x.type == "backlog") if (x.type == "backlog")
{ {
@ -526,11 +528,11 @@ $(document).ready(function()
if(state!="EDIT") if(state!="EDIT")
{ {
state = x.state; state = x.state;
if (state!=state_last) if (state!=state_last)
{ {
if(state_last == "RUNNING" && state != "PAUSED" ) if(state_last == "RUNNING")
{ {
console.log(state);
$('#target_temp').html('---'); $('#target_temp').html('---');
updateProgress(0); updateProgress(0);
$.bootstrapGrowl("<span class=\"glyphicon glyphicon-exclamation-sign\"></span> <b>Run completed</b>", { $.bootstrapGrowl("<span class=\"glyphicon glyphicon-exclamation-sign\"></span> <b>Run completed</b>", {
@ -573,13 +575,7 @@ $(document).ready(function()
} }
$('#act_temp').html(parseInt(x.temperature)); $('#act_temp').html(parseInt(x.temperature));
heat_rate = parseInt(x.heat_rate) $('#heat').html('<div class="bar" style="height:'+x.pidstats.out*70+'%;"></div>')
if (heat_rate > 9999) { heat_rate = 9999; }
if (heat_rate < -9999) { heat_rate = -9999; }
$('#heat_rate').html(heat_rate);
if (typeof x.pidstats !== 'undefined') {
$('#heat').html('<div class="bar" style="height:'+x.pidstats.out*70+'%;"></div>')
}
if (x.cool > 0.5) { $('#cool').addClass("ds-led-cool-active"); } else { $('#cool').removeClass("ds-led-cool-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.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"); } if (x.temperature > hazardTemp()) { $('#hazard').addClass("ds-led-hazard-active"); } else { $('#hazard').removeClass("ds-led-hazard-active"); }
@ -612,7 +608,6 @@ $(document).ready(function()
$('#act_temp_scale').html('º'+temp_scale_display); $('#act_temp_scale').html('º'+temp_scale_display);
$('#target_temp_scale').html('º'+temp_scale_display); $('#target_temp_scale').html('º'+temp_scale_display);
$('#heat_rate_temp_scale').html('º'+temp_scale_display);
switch(time_scale_profile){ switch(time_scale_profile){
case "s": case "s":

View File

@ -1,302 +0,0 @@
config = "";
all = [];
var table = "";
var protocol = 'ws:';
if (window.location.protocol == 'https:') {
protocol = 'wss:';
}
var host = "" + protocol + "//" + window.location.hostname + ":" + window.location.port;
var ws_status = new WebSocket(host+"/status");
var ws_config = new WebSocket(host+"/config");
ws_status.onmessage = function(e) {
x = JSON.parse(e.data);
if (x.pidstats) {
x.pidstats["datetime"]=unix_to_yymmdd_hhmmss(x.pidstats.time);
x.pidstats.err = x.pidstats.err*-1;
x.pidstats.out = x.pidstats.out*100;
x.pidstats.catching_up = x.catching_up
if (x.catching_up == true) {
x.pidstats.catchingup = x.pidstats.ispoint;
}
all.push(x.pidstats);
}
var str = JSON.stringify(x, null, 2);
document.getElementById("state").innerHTML = "<pre>"+str+"</pre>"
table.replaceData(latest(20));
drawall(all);
document.getElementById("error-current").innerHTML = rnd(x.pidstats.err);
document.getElementById("error-1min").innerHTML = rnd(average("err",1,all));
document.getElementById("error-5min").innerHTML = rnd(average("err",5,all));
document.getElementById("error-15min").innerHTML = rnd(average("err",15,all));
document.getElementById("temp").innerHTML = rnd(x.pidstats.ispoint);
document.getElementById("target").innerHTML = rnd(x.pidstats.setpoint);
document.getElementById("heat-pct").innerHTML = rnd(x.pidstats.out);
document.getElementById("catching-up").innerHTML = rnd(percent_catching_up(all));
};
ws_config.onopen = function() {
ws_config.send('GET');
};
ws_config.onmessage = function(e) {
config = JSON.parse(e.data);
//console.log(e);
};
create_table(all);
//---------------------------------------------------------------------------
function rnd(number) {
return Number(number).toFixed(2);
}
//---------------------------------------------------------------------------
function average(field,minutes,data) {
if(data[0]!=null) {
var t = data[data.length - 1].time;
var oldest = t-(60*minutes);
var q = "SELECT AVG("+ field + ") from ? where time>=" + oldest.toString();
var avg = alasql(q,[data]);
return avg[0]["AVG(err)"];
}
return 0;
}
//---------------------------------------------------------------------------
function drawall(data) {
draw_temps(data);
draw_error(data);
draw_heat(data);
draw_p(data);
draw_i(data);
draw_d(data);
}
//---------------------------------------------------------------------------
function draw_heat(data) {
var traces=[];
var rows = alasql('SELECT datetime, out from ?',[data]);
var title = 'Heating Percent';
var trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'out'),
name: 'heat',
mode: 'lines',
line: { color: 'rgb(255,0,0)', width:2 }
};
traces.push(trace);
spot = document.getElementById('heat');
var layout = {
title: title,
showlegend: true,
};
Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
}
//---------------------------------------------------------------------------
function draw_p(data) {
var traces=[];
var rows = alasql('SELECT datetime, p from ?',[data]);
var title = 'Proportional';
var trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'p'),
name: 'p',
mode: 'lines',
line: { color: 'rgb(0,0,255)', width:2 }
};
traces.push(trace);
spot = document.getElementById('p');
var layout = {
title: title,
showlegend: true,
};
Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
}
//---------------------------------------------------------------------------
function draw_i(data) {
var traces=[];
var rows = alasql('SELECT datetime, i from ?',[data]);
var title = 'Integral';
var trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'i'),
name: 'i',
mode: 'lines',
line: { color: 'rgb(0,0,255)', width:2 }
};
traces.push(trace);
spot = document.getElementById('i');
var layout = {
title: title,
showlegend: true,
};
Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
}
//---------------------------------------------------------------------------
function draw_d(data) {
var traces=[];
var rows = alasql('SELECT datetime, d from ?',[data]);
var title = 'Derivative';
var trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'd'),
name: 'd',
mode: 'lines',
line: { color: 'rgb(0,0,255)', width:2 }
};
traces.push(trace);
spot = document.getElementById('d');
var layout = {
title: title,
showlegend: true,
};
Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
}
//---------------------------------------------------------------------------
function draw_error(data) {
var traces=[];
var rows = alasql('SELECT datetime, err from ?',[data]);
var title = 'Error';
var trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'err'),
name: 'error',
mode: 'lines',
line: { color: 'rgb(255,0,0)', width:2 }
};
traces.push(trace);
spot = document.getElementById('error');
var layout = {
title: title,
showlegend: true,
//xaxis : { tickformat:'%b' },
};
Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
}
//---------------------------------------------------------------------------
function draw_temps(data) {
var traces=[];
var rows = alasql('SELECT datetime, ispoint, setpoint, catchingup from ?',[data]);
var title = 'Temperature and Target';
var trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'setpoint'),
name: 'target',
mode: 'lines',
line: { color: 'rgb(0,0,255)', width:2 }
};
traces.push(trace);
trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'ispoint'),
name: 'temp',
mode: 'lines',
line: { color: 'rgb(255,0,0)', width:2 }
};
traces.push(trace);
trace = {
x: unpack(rows, 'datetime'),
y: unpack(rows, 'catchingup'),
name: 'catchup',
mode: 'markers',
marker: { color: 'rgb(0,255,0)', width:3 }
};
traces.push(trace);
spot = document.getElementById('temps');
var layout = {
title: title,
showlegend: true,
//xaxis : { tickformat:'%b' },
};
Plotly.newPlot(spot, traces, layout, {displayModeBar: false});
}
//---------------------------------------------------------------------------
function unpack(rows, key) {
return rows.map(function(row) { return row[key]; });
}
//---------------------------------------------------------------------------
function unix_to_yymmdd_hhmmss(t) {
var date = new Date(t * 1000);
var newd = new Date(date.getTime() - date.getTimezoneOffset()*60000);
//return date.toLocaleString('en-US',{hour12:false}).replace(',','');
return newd.toISOString().replace("T"," ").substring(0, 19);
}
//---------------------------------------------------------------------------
function latest(n) {
//sql = "select * from ? order by time desc limit " + n;
sql = "select * from ? order by time desc";
results = alasql(sql,[all]);
return results;
}
//---------------------------------------------------------------------------
function percent_catching_up(data) {
var sql = "select sum(timeDelta) as slip from ? where catching_up=true";
var a = alasql(sql,[data]);
a = a[0]["slip"];
sql = "select sum(timeDelta) as [all] from ?";
var b = alasql(sql,[data]);
b = b[0]["all"];
return a/b*100;
}
//---------------------------------------------------------------------------
function create_table(data) {
table = new Tabulator("#state-table", {
height:300,
data:data, //assign data to table
//layout:"fitColumns", //fit columns to width of table (optional)
columns:[
{title:"DateTime", field:"datetime"},
{title:"Target", field:"setpoint"},
{title:"Temp", field:"ispoint"},
{title:"Error", field:"err"},
{title:"P", field:"p"},
{title:"I", field:"i"},
{title:"D", field:"d"},
{title:"Heat", field:"out"},
{title:"Catching Up", field:"catching_up"},
{title:"Time Delta", field:"timeDelta"},
]});
}
//---------------------------------------------------------------------------
function csv_string() {
table.download("csv", "kiln-state.csv");
}

View File

@ -29,7 +29,6 @@
<div class="ds-title-panel"> <div class="ds-title-panel">
<div class="ds-title">Sensor Temp</div> <div class="ds-title">Sensor Temp</div>
<div class="ds-title">Target Temp</div> <div class="ds-title">Target Temp</div>
<div class="ds-title">Heat Rate</div>
<div class="ds-title">Cost</div> <div class="ds-title">Cost</div>
<div class="ds-title ds-state pull-right" style="border-left: 1px solid #ccc;">Status</div> <div class="ds-title ds-state pull-right" style="border-left: 1px solid #ccc;">Status</div>
</div> </div>
@ -37,7 +36,6 @@
<div class="ds-panel"> <div class="ds-panel">
<div class="display ds-num"><span id="act_temp">25</span><span class="ds-unit" id="act_temp_scale" >&deg;C</span></div> <div class="display ds-num"><span id="act_temp">25</span><span class="ds-unit" id="act_temp_scale" >&deg;C</span></div>
<div class="display ds-num ds-target"><span id="target_temp">---</span><span class="ds-unit" id="target_temp_scale">&deg;C</span></div> <div class="display ds-num ds-target"><span id="target_temp">---</span><span class="ds-unit" id="target_temp_scale">&deg;C</span></div>
<div class="display ds-num ds-heat-rate"><span id="heat_rate">---</span><span class="ds-unit" id="heat_rate_temp_scale">&deg;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.00</span><span class="ds-unit" id="cost"></span></div>
<div class="display ds-num ds-text" id="state"></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">&#92;</span><span class="ds-led" id="cool">&#108;</span><span class="ds-led" id="air">&#91;</span><span class="ds-led" id="hazard">&#73;</span><span class="ds-led" id="door">&#9832;</span></div> <div class="display pull-right ds-state" style="padding-right:0"><span class="ds-led" id="heat">&#92;</span><span class="ds-led" id="cool">&#108;</span><span class="ds-led" id="air">&#91;</span><span class="ds-led" id="hazard">&#73;</span><span class="ds-led" id="door">&#9832;</span></div>

View File

@ -1,74 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Kiln Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="assets/css/state.css"/>
</head>
<body>
<div class="container" id="temp-stats">
<div class="stattxt">TEMP</div>
<div class="stat">
<div class="top">temp</div>
<div class="bottom" id="temp"></div>
</div>
<div class="stat">
<div class="top">target</div>
<div class="bottom" id="target"></div>
</div>
</div>
<div class="container" id="error-stats">
<div class="stattxt">ERROR</div>
<div class="stat">
<div class="top">now</div>
<div class="bottom" id="error-current"></div>
</div>
<div class="stat">
<div class="top">1 min</div>
<div class="bottom" id="error-1min"></div>
</div>
<div class="stat">
<div class="top">5 min</div>
<div class="bottom" id="error-5min"></div>
</div>
<div class="stat">
<div class="top">15 min</div>
<div class="bottom" id="error-15min"></div>
</div>
</div>
<div class="container" id="heat-stats">
<div class="stattxt">HEAT</div>
<div class="stat">
<div class="top">%</div>
<div class="bottom" id="heat-pct"></div>
</div>
</div>
<div class="container" id="catchingup">
<div class="stattxt">CATCH UP</div>
<div class="stat">
<div class="top">%</div>
<div class="bottom" id="catching-up"></div>
</div>
</div>
<div id="temps"></div>
<div id="error"></div>
<div id="heat"></div>
<div id="p"></div>
<div id="i"></div>
<div id="d"></div>
<div id="state-table"></div>
<div id="state-table-controls"><a href="javascript: void(0)" onclick="csv_string();" >csv</a></div>
<div id="state"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/alasql/4.3.2/alasql.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/plotly.js/2.32.0/plotly.min.js" charset="utf-8"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.6.1/css/tabulator.min.css" rel="stylesheet">
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.6.1/js/tabulator.min.js"></script>
<script src="assets/js/state.js"></script>
</body>
</html>

View File

@ -3,23 +3,7 @@ greenlet
bottle bottle
gevent gevent
gevent-websocket gevent-websocket
websocket-client
requests
# for folks running raspberry pis
# we have no proof of anyone using another board yet, but when that
# happens, you might want to comment this out.
RPi.GPIO RPi.GPIO
Adafruit-MAX31855
# List of all supported adafruit modules for thermocouples Adafruit-GPIO
adafruit-circuitpython-max31855 websocket-client
adafruit-circuitpython-max31856
# for folks using sw spi (bit banging)
adafruit-circuitpython-bitbangio
# untested - for PT100 platinum thermocouples
#adafruit-circuitpython-max31865
# untested - for mcp9600 and mcp9601
#adafruit-circuitpython-mcp9600

View File

@ -1 +0,0 @@
{"type": "profile", "data": [[0, 65], [600, 200], [2088, 250], [5688, 250], [23135, 1733], [28320, 1888], [30900, 1888]], "name": "cone-05-fast-bisque"}

View File

@ -1 +0,0 @@
{"data": [[0, 200], [3600, 200], [10800, 2000], [14400, 2250], [16400, 2250], [19400, 70]], "type": "profile", "name": "test-fast"}

View File

@ -1,47 +0,0 @@
#!/usr/bin/env python
import config
import adafruit_max31855
import digitalio
import time
import datetime
try:
import board
except NotImplementedError:
print("not running a recognized blinka board, exiting...")
import sys
sys.exit()
########################################################################
#
# To test your gpio output to control a relay...
#
# Edit config.py and set the following in that file to match your
# hardware setup: gpio_heat, gpio_heat_invert
#
# then run this script...
#
# ./test-output.py
#
# This will switch the output on for five seconds and then off for five
# seconds. Measure the voltage between the output and any ground pin.
# You can also run ./gpioreadall.py in another window to see the voltage
# on your configured pin change.
########################################################################
heater = digitalio.DigitalInOut(config.gpio_heat)
heater.direction = digitalio.Direction.OUTPUT
off = config.gpio_heat_invert
on = not off
print("\nboard: %s" % (board.board_id))
print("heater configured as config.gpio_heat = %s BCM pin\n" % (config.gpio_heat))
print("heater output pin configured as invert = %r\n" % (config.gpio_heat_invert))
while True:
heater.value = on
print("%s heater on" % datetime.datetime.now())
time.sleep(5)
heater.value = off
print("%s heater off" % datetime.datetime.now())
time.sleep(5)

View File

@ -1,74 +0,0 @@
#!/usr/bin/env python
import config
from digitalio import DigitalInOut
import time
import datetime
import busio
import adafruit_bitbangio as bitbangio
try:
import board
except NotImplementedError:
print("not running a recognized blinka board, exiting...")
import sys
sys.exit()
########################################################################
#
# To test your thermocouple...
#
# Edit config.py and set the following in that file to match your
# hardware setup: SPI_SCLK, SPI_MOSI, SPI_MISO, SPI_CS
#
# then run this script...
#
# ./test-thermocouple.py
#
# It will output a temperature in degrees every second. Touch your
# thermocouple to heat it up and make sure the value changes. Accuracy
# of my thermocouple is .25C.
########################################################################
spi = None
if(hasattr(config,'spi_sclk') and
hasattr(config,'spi_mosi') and
hasattr(config,'spi_miso')):
spi = bitbangio.SPI(config.spi_sclk, config.spi_mosi, config.spi_miso)
print("Software SPI selected for reading thermocouple")
print("SPI configured as:\n")
print(" config.spi_sclk = %s BCM pin" % (config.spi_sclk))
print(" config.spi_mosi = %s BCM pin" % (config.spi_mosi))
print(" config.spi_miso = %s BCM pin" % (config.spi_miso))
print(" config.spi_cs = %s BCM pin\n" % (config.spi_cs))
else:
spi = board.SPI();
print("Hardware SPI selected for reading thermocouple")
cs = DigitalInOut(config.spi_cs)
cs.switch_to_output(value=True)
sensor = None
print("\nboard: %s" % (board.board_id))
if(config.max31855):
import adafruit_max31855
print("thermocouple: adafruit max31855")
sensor = adafruit_max31855.MAX31855(spi, cs)
if(config.max31856):
import adafruit_max31856
print("thermocouple: adafruit max31856")
sensor = adafruit_max31856.MAX31856(spi, cs)
print("Degrees displayed in %s\n" % (config.temp_scale))
temp = 0
while(True):
time.sleep(1)
try:
temp = sensor.temperature
scale = "C"
if config.temp_scale == "f":
temp = temp * (9/5) + 32
scale ="F"
print("%s %0.2f%s" %(datetime.datetime.now(),temp,scale))
except Exception as error:
print("error: " , error)

View File

@ -3,7 +3,7 @@
echo "----------------------------------------------" echo "----------------------------------------------"
echo "| Writing all kiln logs to ./kiln.logs.gz... |" echo "| Writing all kiln logs to ./kiln.logs.gz... |"
echo "----------------------------------------------" echo "----------------------------------------------"
zcat -f /var/log/* 2>/dev/null|strings|grep -E "(INFO|WARN|ERROR) (oven|kiln-controller|gevent)"|sort|uniq|gzip > kiln.logs.gz zgrep --no-filename -E "(INFO|WARN|ERROR) (oven|kiln-controller|gevent)" /var/log/* 2>/dev/null|strings|sort|uniq|gzip > kiln.logs.gz
ls -la kiln.logs.gz ls -la kiln.logs.gz