Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e756178dc | ||
|
|
96e5919464 | ||
|
|
7c7a1b648e | ||
|
|
f0c97ed220 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,5 +8,4 @@ thumbs.db
|
|||||||
#storage/profiles
|
#storage/profiles
|
||||||
#config.py
|
#config.py
|
||||||
.idea/*
|
.idea/*
|
||||||
state.json
|
.DS_Store
|
||||||
venv/*
|
|
||||||
|
|||||||
89
README.md
89
README.md
@ -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**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**Edit Kiln Schedule**
|
**Edit Kiln Schedule**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|
||||||
@ -39,24 +35,22 @@ Turns a Raspberry Pi into an inexpensive, web-enabled kiln controller.
|
|||||||
|
|
||||||
| Image | Hardware | Description |
|
| Image | Hardware | Description |
|
||||||
| ------| -------- | ----------- |
|
| ------| -------- | ----------- |
|
||||||
|  | [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. |
|
|  | [Raspberry Pi](https://www.adafruit.com/category/105) | Virtually any Raspberry Pi will work since only a few GPIO pins are being used. |
|
||||||
|  | [Adafruit MAX31855](https://www.adafruit.com/product/269) or [Adafruit MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board |
|
|  | [MAX31855](https://www.adafruit.com/product/269) or [MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board |
|
||||||
|  | [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. |
|
|  | [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 |
|
||||||
|  | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires |
|
|  | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires |
|
||||||
|  | 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. |
|
|  | 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. |
|
||||||
|  | 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. |
|
|  | 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.
|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
*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.
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
{"data": [[0, 200], [3600, 200], [4200, 500], [10800, 500], [14400, 2250], [16400, 2000], [19400, 2250]], "type": "profile", "name": "test-fast"}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{"data": [[0, 200], [3600, 200], [10800, 2000], [14400, 2250], [16400, 2250], [19400, 700]], "type": "profile", "name": "test-fast"}
|
|
||||||
@ -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
174
config.py
@ -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
|
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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.
|
|
||||||
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -6,67 +6,84 @@ The method implemented here is taken from ["Ziegler–Nichols 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 .
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
151
gpioreadall.py
151
gpioreadall.py
@ -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()
|
|
||||||
|
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
103
kiln-tuner.py
103
kiln-tuner.py
@ -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
265
lib/max31855.py
Normal 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
36
lib/max31855spi.py
Normal 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
341
lib/max31856.py
Normal 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()
|
||||||
545
lib/oven.py
545
lib/oven.py
@ -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))
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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":
|
||||||
|
|||||||
@ -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");
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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" >°C</span></div>
|
<div class="display ds-num"><span id="act_temp">25</span><span class="ds-unit" id="act_temp_scale" >°C</span></div>
|
||||||
<div class="display ds-num ds-target"><span id="target_temp">---</span><span class="ds-unit" id="target_temp_scale">°C</span></div>
|
<div class="display ds-num ds-target"><span id="target_temp">---</span><span class="ds-unit" id="target_temp_scale">°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">°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">\</span><span class="ds-led" id="cool">l</span><span class="ds-led" id="air">[</span><span class="ds-led" id="hazard">I</span><span class="ds-led" id="door">♨</span></div>
|
<div class="display pull-right ds-state" style="padding-right:0"><span class="ds-led" id="heat">\</span><span class="ds-led" id="cool">l</span><span class="ds-led" id="air">[</span><span class="ds-led" id="hazard">I</span><span class="ds-led" id="door">♨</span></div>
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -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"}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
{"data": [[0, 200], [3600, 200], [10800, 2000], [14400, 2250], [16400, 2250], [19400, 70]], "type": "profile", "name": "test-fast"}
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
2
ziplogs
2
ziplogs
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user