Compare commits

...

122 Commits
master ... main

Author SHA1 Message Date
Jason Bruce
a2b3071e4e
Merge pull request #186 from solnus/main
Add output pin inversion, update docs.
2024-08-24 17:03:06 -04:00
Steve
d3b10d29b0 Add output pin inversion, update docs. 2024-08-21 21:00:54 -06:00
jbruce12000
42d391ce4e toggle detail edit table at start of edit 2024-08-21 18:21:52 -04:00
jbruce12000
7299056506 adding detail about /state to readme 2024-08-03 11:17:55 -04:00
jbruce12000
6ad786bab2 change all links from blinka to main 2024-07-25 14:28:15 -04:00
jbruce12000
2906e3b3ed change all links from master to main branch 2024-07-25 14:26:56 -04:00
jbruce12000
b0ebd2a01f fix for simulated oven start_time so it works with kiln-tuner.py 2024-07-24 19:58:02 -04:00
jbruce12000
f2cf1675cb adding state page 2024-07-23 15:23:28 -04:00
jbruce12000
168204bdb3 always save temp profiles in degrees celcius. convert as needed based on settings 2024-07-03 15:40:10 -04:00
jbruce12000
df7ee9e234 add pause and resume to api 2024-06-30 11:44:45 -04:00
jason bruce
8c9d3881dc change autodection so only SW SPI needs pins listed in config.py 2023-12-11 10:21:41 -05:00
jbruce12000
9fb5ec940f adding section 2023-12-07 10:29:29 -05:00
jbruce12000
d66ce7b616 another fixme 2023-12-05 21:59:13 -05:00
jbruce12000
267c999152 change subject 2023-12-05 15:27:27 -05:00
jbruce12000
97879168de fix code block 2023-12-05 15:00:00 -05:00
jbruce12000
2db6207d7d code block addition 2023-12-05 14:58:23 -05:00
jbruce12000
7c18599d4d fixme for branch checkout 2023-12-05 13:12:50 -05:00
jbruce12000
4ccc516672 bold 2023-12-05 13:08:56 -05:00
jbruce12000
5559e48cd6 add code blocks 2023-12-05 13:07:31 -05:00
jbruce12000
0b67e0c69e adding docs describing migration from old to new code 2023-12-05 13:05:22 -05:00
jbruce12000
e207c54181 reduce cpu and network usage with websockets 2023-12-04 11:38:18 -05:00
jason bruce
d58025fa9e print errors, dont exit while testing thermocouple 2023-12-02 13:59:41 -05:00
jason bruce
e0d2aacfe3 add RPi.GPIO to requirements 2023-12-01 15:14:56 -05:00
jason bruce
07ea69cdb2 fixes to sw spi. adafruit 31855 now works with sw spi and autodetect of it. also fixed ziplogs to use cat. 2023-12-01 15:00:04 -05:00
jason bruce
bf6c72bba4 set sane defaults for config.py 2023-12-01 08:13:50 -05:00
jason bruce
e1490187c7 adding software spi and spi hw/sw autodetection 2023-11-30 15:05:53 -05:00
jbruce12000
100d5ccb6d removing try except for error visibility 2023-11-21 13:56:15 -05:00
jbruce12000
9a29cd95de checking for attribute in board too 2023-11-21 13:39:32 -05:00
jbruce12000
4fddeeb4a8 add throttling of elements below a specific temp if outside the pid control window 2023-11-21 09:53:09 -05:00
jbruce12000
e4f954f55b change from 20 temp samples/sec to 5 2023-01-21 11:26:30 -05:00
jbruce
8b39f96d39 sane defaults for config 2023-01-10 14:07:19 -05:00
Jason Bruce
78f88ea418
Merge pull request #128 from chipgarner/blinka
Blinka
2023-01-10 14:01:02 -05:00
Chip Garner
0485ed961a Merge branch 'blinka' of https://github.com/jbruce12000/kiln-controller into blinka 2023-01-02 20:33:22 -08:00
Chip Garner
5b1c516260 Works with MAX31856 2023-01-02 20:33:15 -08:00
jbruce12000
27c64b3209 add temp seek to features 2023-01-02 15:41:52 -05:00
jbruce12000
45739cf3d0 heating rate added to features 2023-01-02 13:15:24 -05:00
Jason Bruce
32ea1bab32
Merge pull request #127 from jbruce12000/heating-rate
Heating rate
2023-01-02 13:09:10 -05:00
jbruce12000
d0749ac7d9 add rate to web interface 2023-01-02 11:47:45 -05:00
jbruce12000
e3eaa6f44a stats now contain heat_rate 2023-01-01 16:25:27 -05:00
jbruce12000
2df86242f3 turn off debug logging 2022-12-31 11:51:31 -05:00
Jason Bruce
a503944e2d
Merge pull request #124 from chipgarner/fastsim
Fastsim
2022-12-31 11:45:16 -05:00
Jason Bruce
418dfd900e
Merge pull request #126 from chipgarner/seek
Seek
2022-12-31 11:29:27 -05:00
James Kirikland Garner
37f2a53aec Set start temperature for simulation using sim_t_env in config. 2022-12-30 12:53:06 -08:00
James Kirikland Garner
3c515761e8 skip sink on API start with start time set 2022-12-26 20:32:51 -08:00
James Kirikland Garner
b960bb4710 Fixed running seek on auto restart bug. 2022-12-24 13:56:07 -08:00
jbruce12000
3b19282ae8 fast cone 05 now hot enough 2022-12-24 10:45:01 -05:00
James Kirikland Garner
ee70ba1667 Seek is working with log, pytests added. 2022-12-23 15:01:35 -08:00
James Kirikland Garner
4c03cfa8a6 git ignore 2022-12-23 11:56:20 -08:00
jason bruce
0d5f3ee897 add fast bisque profile 2022-12-21 22:45:08 -05:00
jason bruce
abaf155e5a setting named incorrectly in code. made it to 1700F and ran into this bug. needed 1888F. damnit. 2022-12-21 22:34:49 -05:00
jbruce12000
90d10eb125 fix bug missing self for a few vars. nice find chip! 2022-12-18 18:23:48 -05:00
James Kirikland Garner
21b2655867 Really put back ovenWatcher I hope; 2022-12-18 10:54:35 -08:00
James Kirikland Garner
d5af5bcf7d Put back as it was 2022-12-18 10:50:46 -08:00
James Kirikland Garner
c43770ace5 Removed changes not needed for PR 2022-12-18 10:30:07 -08:00
James Kirikland Garner
3a097e5098 revert to 5df0bc5 as in pull request 2022-12-17 19:56:01 -08:00
James Kirikland Garner
d8c1f7cb00 fussing 2022-12-16 19:24:03 -08:00
jbruce
dba6cde65d remove the loading message 2022-12-16 14:19:57 -05:00
James Kirikland Garner
5df0bc503a Merge branch 'fastsim' into blinka 2022-12-16 09:25:11 -08:00
James Kirikland Garner
ab0b0e11c0 Merge branch 'blinka' of https://github.com/jbruce12000/kiln-controller into blinka 2022-12-16 09:24:18 -08:00
jbruce
024fb4f232 make minutes the defualt for editing graphs 2022-12-16 09:50:19 -05:00
jbruce
71e25b450d time scale on graph should always show hours 2022-12-16 09:44:45 -05:00
James Kirikland Garner
9900bb4421 Oven watcher time hard coded, added test json 2022-12-15 14:49:32 -08:00
James Kirikland Garner
8d770b3086 Blinka merge, playing with config settings. 2022-12-15 10:23:08 -08:00
James Kirikland Garner
a6674c5ddc now is fixed 2022-12-15 09:59:22 -08:00
James Kirikland Garner
c9ee92c93d now bug 2022-12-15 09:38:14 -08:00
James Kirikland Garner
83ce8fb5f5 fix now bug 2022-12-15 09:22:58 -08:00
James Kirikland Garner
656e808f59 merge 2022-12-15 08:52:46 -08:00
James Kirikland Garner
945fcf4187 Works at 1000 times speed, a little messy. 2022-12-14 19:22:42 -08:00
James Kirikland Garner
f5336ec2a1 Speeding up simulator. 2022-12-14 16:32:10 -08:00
jason bruce
a4a09873d7 change wording about average of samples to reflect code 2022-12-02 14:49:37 -05:00
jbruce
cc3ef3f6ca changing from avg to median for temp smoothing 2022-12-01 17:39:20 -05:00
jbruce12000
dc212f516a fix inheritance of tv error 2022-11-26 17:15:56 -05:00
jbruce
4141018044 reorder warnings 2022-11-21 10:11:57 -05:00
jbruce
7ed5b55881 shutdown process before tuning 2022-11-21 09:57:52 -05:00
jbruce12000
37fcdc4d89 change install instructions 2022-11-16 18:51:50 -05:00
jbruce12000
13b47ee6ce make default time scale hours 2022-11-15 17:50:01 -05:00
jbruce
748253244f keep up with master...tick graph display changes 2022-11-15 11:03:16 -05:00
jbruce
22b663f648 make config work on linux 2022-11-09 13:09:13 -05:00
jbruce12000
a76f9e19be fix spacing issue 2022-11-07 17:41:35 -05:00
jbruce12000
0ae153f38c update for example to start watcher later 2022-11-07 17:39:26 -05:00
jbruce
1348cc0d9b remove fix statements 2022-11-05 14:02:23 -04:00
jbruce
b2ea29f4e3 adding requests 2022-11-05 12:54:24 -04:00
jbruce
64ec25f7b2 small fix to docs 2022-11-05 11:31:26 -04:00
jbruce
4d69718c41 fix job bug 2022-11-04 17:31:42 -04:00
jbruce
9ac6b17aa8 typo for code block 2022-11-04 16:52:45 -04:00
jbruce
01abf01431 schedule some graphs 2022-11-04 16:50:51 -04:00
jbruce
fc8cfb9c40 so many options 2022-11-04 13:57:55 -04:00
jbruce
656f24637a fix typo 2022-11-04 13:48:50 -04:00
jbruce
391a65695d add stuff to feature list 2022-11-04 13:46:33 -04:00
jbruce
899e4401ec add scheduling info 2022-11-04 13:39:58 -04:00
jbruce
1649e54e31 note about reboots 2022-11-04 13:30:03 -04:00
jbruce
a446d05e30 final tweaks 2022-11-04 13:27:00 -04:00
jbruce
7495cd5245 more details on scheduling 2022-11-04 13:20:16 -04:00
jbruce
043f438ca3 add scheduling docs 2022-11-04 13:11:12 -04:00
jbruce
83696a64ab rework params 2022-11-04 05:44:41 -09:00
jbruce
925ddead08 final doc tweaks 2022-11-04 05:43:14 -09:00
jbruce
9c3cf48e5c smooth the bumps 2022-11-04 05:40:03 -09:00
jbruce
e070b47dca match docs with code changes 2022-11-04 05:36:39 -09:00
jbruce
0d057abcaf wording improvements 2022-11-03 17:51:15 -09:00
jbruce
c201b7d1d3 make a table for common config params 2022-11-03 17:48:53 -09:00
jbruce
6981c29986 change some params, calculate by default after run 2022-11-03 17:33:39 -09:00
jbruce
a2d2823cba changing statement if board not found 2022-11-03 12:41:32 -09:00
jbruce
80ec0ea5a3 be specific about autotuner 2022-11-03 09:05:04 -09:00
jbruce
1abeac4f4e simplify kiln-tuner, fix docs 2022-11-03 08:57:58 -09:00
jbruce
62efd5cde5 changed to work with simulation for easier maintenance 2022-11-03 07:53:02 -09:00
jbruce
f608a7199b check blinka board or exit 2022-11-03 06:07:10 -09:00
jbruce
d0de972b32 add max31856 support 2022-11-03 06:05:41 -09:00
jbruce
afc6092d4e info about board support 2022-11-03 10:37:59 -04:00
jbruce
3c10f63242 more info on thermocouple 2022-11-03 10:31:54 -04:00
jbruce
699c420c1c makeit clear the MAX chips are from adafruit 2022-11-03 10:28:26 -04:00
jbruce
d963b6a0f1 bold fix 2022-11-03 10:25:58 -04:00
jbruce
70d1e81a9b docs update 2022-11-03 10:22:52 -04:00
jbruce
2fa18589a5 exception handling completed for 31855 and 31856 2022-11-02 09:31:48 -09:00
jbruce
d6163a4c6e ok, now just need to deal with exceptions 2022-11-01 10:50:51 -09:00
jbruce
5172d8c294 basics working 2022-11-01 09:09:10 -09:00
jbruce
bb7f9f9353 part way through oven changes 2022-10-31 17:17:28 -09:00
jbruce
d9fe0570c4 starting to change README 2022-10-31 10:27:42 -09:00
jbruce
2896b87b97 adding board id to output 2022-10-31 10:00:03 -09:00
jbruce
375a725480 blinka supported boards 2022-10-31 09:21:36 -09:00
jbruce
048db827e8 change config and requirements for blinka testing 2022-10-31 09:21:06 -09:00
jbruce
69f14cbd73 adding tools to test initial setup of blinka 2022-10-31 07:10:36 -09:00
jbruce12000
74a949d384 starting to play 2022-10-30 13:48:50 -04:00
31 changed files with 1797 additions and 1033 deletions

2
.gitignore vendored
View File

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

View File

@ -5,29 +5,33 @@ Turns a Raspberry Pi into an inexpensive, web-enabled kiln controller.
## 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
* no limit to runtime - fire for days if you want
* view status from multiple devices at once - computer, tablet etc
* real-time firing cost estimate
* NIST-linearized conversion for accurate K type thermocouple readings
* real-time heating rate displayed in degrees per hour
* supports PID parameters you tune to your kiln
* monitors temperature in kiln after schedule has ended
* 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
* 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
* 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**
![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/kiln-running.png)
![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/kiln-running.png)
**Edit Kiln Schedule**
![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/kiln-schedule.png)
![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/kiln-schedule.png)
## Hardware
@ -35,22 +39,24 @@ Turns a Raspberry Pi into an inexpensive, web-enabled kiln controller.
| Image | Hardware | Description |
| ------| -------- | ----------- |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/rpi.png) | [Raspberry Pi](https://www.adafruit.com/category/105) | Virtually any Raspberry Pi will work since only a few GPIO pins are being used. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/max31855.png) | [MAX31855](https://www.adafruit.com/product/269) or [MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/k-type-thermocouple.png) | [K-Type Thermocouple](https://www.auberins.com/index.php?main_page=product_info&cPath=20_3&products_id=39) | Invest in a heavy duty, ceramic, k-type thermocouple designed for kilns |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/breadboard.png) | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/ssr.png) | Solid State Relay | Zero crossing, make sure it can handle the max current of your kiln. Even if the kiln is 220V you can buy a single [3 Phase SSR](https://www.auberins.com/index.php?main_page=product_info&cPath=2_30&products_id=331). It's like having 3 SSRs in one. Relays this big always require a heat sink. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/ks-1018.png) | Electric Kiln | There are many old electric kilns on the market that don't have digital controls. You can pick one up on the used market cheaply. This controller will work with 110V or 220V (pick a proper SSR). My kiln is a Skutt KS-1018. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/rpi.png) | [Raspberry Pi](https://www.adafruit.com/category/105) | Virtually any Raspberry Pi will work since only a few GPIO pins are being used. Any board supported by [blinka](https://circuitpython.org/blinka) and has SPI should work. You'll also want to make sure the board has wifi. If you use something other than a Raspberry PI and get it to work, let me know. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/max31855.png) | [Adafruit MAX31855](https://www.adafruit.com/product/269) or [Adafruit MAX31856](https://www.adafruit.com/product/3263) | Thermocouple breakout board |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/k-type-thermocouple.png) | [Thermocouple](https://www.auberins.com/index.php?main_page=product_info&cPath=20_3&products_id=39) | Invest in a heavy duty, ceramic thermocouple designed for kilns. Make sure the type will work with your thermocouple board. Adafruit-MAX31855 works only with K-type. Adafruit-MAX31856 is flexible and works with many types, but folks usually pick S-type. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/breadboard.png) | Breadboard | breadboard, ribbon cable, connector for pi's gpio pins & connecting wires |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/ssr.png) | Solid State Relay | Zero crossing, make sure it can handle the max current of your kiln. Even if the kiln is 220V you can buy a single [3 Phase SSR](https://www.auberins.com/index.php?main_page=product_info&cPath=2_30&products_id=331). It's like having 3 SSRs in one. Relays this big always require a heat sink. |
| ![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/ks-1018.png) | Electric Kiln | There are many old electric kilns on the market that don't have digital controls. You can pick one up on the used market cheaply. This controller will work with 110V or 220V (pick a proper SSR). My kiln is a Skutt KS-1018. |
### 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/master/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/main/config.py) file for gpio pin configuration.
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.
![Image](https://github.com/jbruce12000/kiln-controller/blob/master/public/assets/images/schematic.png)
**Note:** The GPIO configuration in this schematic does not match the defaults, check [config](https://github.com/jbruce12000/kiln-controller/blob/main/config.py) and make sure the gpio pin configuration aligns with your actual connections.
![Image](https://github.com/jbruce12000/kiln-controller/blob/main/public/assets/images/schematic.png)
*Note: I tried to power my ssr directly using a gpio pin, but it did not work. My ssr required 25ma to switch and rpi's gpio could only provide 16ma. YMMV.*
@ -62,14 +68,11 @@ Download [Raspberry PI OS](https://www.raspberrypi.org/software/). Use Rasberry
$ sudo apt-get update
$ sudo apt-get dist-upgrade
$ sudo apt-get install python3-dev python3-virtualenv libevent-dev virtualenv
$ git clone https://github.com/jbruce12000/kiln-controller
$ cd kiln-controller
$ virtualenv -p python3 venv
$ python3 -m venv venv
$ source venv/bin/activate
$ export CFLAGS=-fcommon
$ pip3 install --upgrade setuptools
$ pip3 install greenlet bottle gevent gevent-websocket
$ pip install -r requirements.txt
*Note: The above steps work on ubuntu if you prefer*
@ -77,19 +80,45 @@ 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:
$ cd kiln-controller
$ virtualenv -p python3 venv
$ source venv/bin/activate
$ export CFLAGS=-fcommon
$ pip3 install -r requirements.txt
$ sudo raspi-config
interfacing options -> SPI -> Select Yes to enable
select reboot
## Configuration
All parameters are defined in config.py, review/change to your mind's content.
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:
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.
| Variable | Default | Description |
| -------- | ------- | ----------- |
| 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. |
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.
## Testing
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
@ -111,9 +140,13 @@ 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.
### 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
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).
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).
## License
@ -133,7 +166,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
## Support & Contact
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/master/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/main/docs/troubleshooting.md) I created for testing RPi gpio pins.
## Origin
This project was originally forked from https://github.com/apollo-ng/picoReflow but has diverged a large amount.

1
Test/test-cases.json Normal file
View File

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

1
Test/test-fast.json Normal file
View File

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

80
Test/test_Profile.py Normal file
View File

@ -0,0 +1,80 @@
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

176
config.py
View File

@ -1,8 +1,7 @@
import logging
import os
# uncomment this if using MAX-31856
#from lib.max31856 import MAX31856
from digitalio import DigitalInOut
import busio
########################################################################
#
@ -21,37 +20,107 @@ listening_port = 8081
# 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
# 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
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
########################################################################
#
# GPIO Setup (BCM SoC Numbering Schema)
# Hardware Setup (uses BCM Pin Numbering)
#
# Check the RasPi docs to see where these GPIOs are
# connected on the P1 header for your board type/rev.
# These were tested on a Pi B Rev2 but of course you
# can use whichever GPIO you prefer/have available.
# kiln-controller.py uses SPI interface from the blinka library to read
# temperature data from the adafruit-31855 or adafruit-31856.
# Blinka supports many different boards. I've only tested raspberry pi.
#
# 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
gpio_heat = 23 # Switches zero-cross solid-state-relay
#######################################
# SPI pins if you choose Hardware SPI #
#######################################
# 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:
# max31855 - bitbang SPI interface
# max31856 - bitbang SPI interface. must specify thermocouple_type.
#######################################
# SPI pins if you choose software spi #
#######################################
# 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
max31856 = 0
# see lib/max31856.py for other thermocouple_type, only applies to max31856
# uncomment this if using MAX-31856
#thermocouple_type = MAX31856.MAX31856_S_TYPE
# uncomment these two lines if using MAX-31856
import adafruit_max31856
thermocouple_type = adafruit_max31856.ThermocoupleType.K
### Thermocouple Connection (using bitbang interfaces)
gpio_sensor_cs = 27
gpio_sensor_clock = 22
gpio_sensor_data = 17
gpio_sensor_di = 10 # only used with max31856
# here are the possible max-31856 thermocouple types
# ThermocoupleType.B
# ThermocoupleType.E
# ThermocoupleType.J
# ThermocoupleType.K
# 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
########################################################################
#
@ -71,10 +140,9 @@ sensor_time_wait = 2
# well with the simulated oven. You must tune them to work well with
# your specific kiln. Note that the integral pid_ki is
# inverted so that a smaller number means more integral action.
pid_kp = 25 # Proportional 25,200,200
pid_ki = 10 # Integral
pid_kd = 200 # Derivative
pid_kp = 10 # Proportional 25,200,200
pid_ki = 80 # Integral
pid_kd = 220.83497910261562 # Derivative
########################################################################
#
@ -88,7 +156,7 @@ stop_integral_windup = True
#
# Simulation parameters
simulate = True
sim_t_env = 60.0 # deg C
sim_t_env = 65 # deg
sim_c_heat = 500.0 # J/K heat capacity of heat element
sim_c_oven = 5000.0 # J/K heat capacity of oven
sim_p_heat = 5450.0 # W heating power of oven
@ -97,6 +165,10 @@ 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_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
########################################################################
#
@ -104,10 +176,9 @@ sim_R_ho_air = 0.05 # K/W " with internal air circulation
#
# If you change the temp_scale, all settings in this file are assumed to
# be in that scale.
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_profile = "h" # 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
# emergency shutoff the profile if this temp is reached or exceeded.
# This just shuts off the profile. If your SSR is working, your kiln will
@ -128,7 +199,7 @@ kiln_must_catch_up = True
# or 100% off because the kiln is too hot. No integral builds up
# outside the window. The bigger you make the window, the more
# integral you will accumulate. This should be a positive integer.
pid_control_window = 5 #degrees
pid_control_window = 5 #degrees
# thermocouple offset
# If you put your thermocouple in ice water and it reads 36F, you can
@ -136,11 +207,11 @@ pid_control_window = 5 #degrees
# cheap thermocouple. Invest in a better thermocouple.
thermocouple_offset=0
# number of samples of temperature to average.
# If you suffer from the high temperature kiln issue and have set
# honour_theromocouple_short_errors to False,
# you will likely need to increase this (eg I use 40)
temperature_average_samples = 40
# number of samples of temperature to take over each duty cycle.
# The larger the number, the more load on the board. K type
# thermocouples have a precision of about 1/2 degree C.
# The median of these samples is used for the temperature.
temperature_average_samples = 10
# Thermocouple AC frequency filtering - set to True if in a 50Hz locale, else leave at False for 60Hz locale
ac_freq_50hz = False
@ -154,16 +225,25 @@ ac_freq_50hz = False
# - unknown error with thermocouple
# - too many errors in a short period from thermocouple
# but in some cases, you might want to ignore a specific error, log it,
# and continue running your profile.
# and continue running your profile instead of having the process die.
#
# 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_lost_connection_tc = False
ignore_unknown_tc_error = False
ignore_too_many_tc_errors = False
# some kilns/thermocouples start erroneously reporting "short"
# errors at higher temperatures due to plasma forming in the kiln.
# Set this to True to ignore these errors and assume the temperature
# reading was correct anyway
ignore_tc_lost_connection = False
ignore_tc_cold_junction_range_error = False
ignore_tc_range_error = False
ignore_tc_cold_junction_temp_high = False
ignore_tc_cold_junction_temp_low = False
ignore_tc_temp_high = False
ignore_tc_temp_low = False
ignore_tc_voltage_error = 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
@ -187,3 +267,13 @@ 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__ ),'..','kiln-profiles','pottery'))
########################################################################
# low temperature throttling of elements
# kiln elements have lots of power and tend to drastically overshoot
# at low temperatures. When under the set point and outside the PID
# control window and below throttle_below_temp, only throttle_percent
# of the elements are used max.
# To prevent throttling, set throttle_percent to 100.
throttle_below_temp = 300
throttle_percent = 20

View File

@ -18,3 +18,11 @@ post a memo
stats for currently running schedule
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

77
docs/old-to-new.md Normal file
View File

@ -0,0 +1,77 @@
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.

74
docs/schedule.md Normal file
View File

@ -0,0 +1,74 @@
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

72
docs/supported-boards.md Normal file
View File

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

View File

@ -24,7 +24,7 @@ If you're using a breadboard with a labeled break-out board, verify:
I thought at one point that I had fried my RPi. I needed to verify that it
still worked as expected. Here's what I did to verify GPIO on my pi.
```gpio readall```
```source venv/bin/activate; ./gpioreadall.py```
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
having to type.
```watch gpio readall```
```watch ./gpioreadall.py```
* 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

View File

@ -6,84 +6,67 @@ The method implemented here is taken from ["ZieglerNichols Tuning Method"](ht
One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further manual adjustment may be necessary.
## Process Overview
- make sure the kiln-controller is **stopped**
- 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.
1. First of all, you will record a temperature profile for your kiln.
2. Next, we use those figures to estimate Kp/Ki/Kd.
## Step 1: Stop the kiln-controller process
## Step 1: Record Temperature Profie
If the kiln controller auto-starts, you'll need to stop it before tuning...
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!)
```sudo service kiln-controller stop```
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.
After, you're done with the tuning process, just reboot and the kiln-controller will automatically restart.
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!
## Step 2: Run the Auto-Tuner
To record the profile, run:
run the auto-tuner:
```
python kiln-tuner.py recordprofile zn.csv
source venv/bin/activate; ./kiln-tuner.py
```
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:
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:
```
time,temperature
4.025461912,45.5407078
6.035358906,45.5407078
8.045399904,45.5407078
10.05544925,45.59087846
...
stage = cooling, actual = 401.51, target = 400.00
stage = cooling, actual = 401.26, target = 400.00
stage = cooling, actual = 401.01, target = 400.00
stage = cooling, actual = 400.77, target = 400.00
stage = cooling, actual = 400.52, target = 400.00
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 2: Compute the PID parameters
## Step 3: Replace the PID parameters in config.py
Once you have your zn.csv profile, run the following:
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.
```
python kiln-tuner.py zn zn.csv
```
## The values didn't work for me.
The values will be output to stdout, for example:
```
Kp: 3.853985144980333 1/Ki: 87.78173053095107 Kd: 325.9599328488931
```
(Note that the Ki value is already inverted ready for use in config)
------
## Sanity checking the results
If you run
```
python kiln-tuner.py zn zn.csv --showplot
```
It will display a plot of the parameters. It should look simular to this ![kiln-tuner-example.png](kiln-tuner-example.png).
Note: you will need python's `pyplot` installed for this to work.
The smooth linear part of the chart is very important. If it is too short, try increasing the target temperature (see later).
The red diagonal line: this **must** follow the smooth part of your chart closely.
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.
## My diagonal line isn't right
You might need to adjust the line parameters to make it fit your data properly. You can do this as follows:
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.
```
python kiln-tuner.py zn zn.csv --tangentdivisor 4
source venv/bin/activate;./kiln-tuner.py -c -s -d 4
```
`tangentdivisor` modifies which parts of the profile is used to calculate the line.
It is a floating point number >= 2; If necessary, try varying it till you get a better fit.
| Parameter | Description |
| --------- | ----------- |
| -c | calculate only (don't heat/cool and record) |
| -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
By default it is 400. You can change this as follows:
By default it is 400F. You can change this as follows:
```
python kiln-tuner.py recordprofile zn.csv --targettemp 500
python kiln-tuner.py -t 500
```
(where the target temperature has been changed to 500 in the example above)

151
gpioreadall.py Executable file
View File

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

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python
import time
import os
import sys
import logging
@ -13,14 +14,8 @@ from gevent.pywsgi import WSGIServer
from geventwebsocket.handler import WebSocketHandler
from geventwebsocket import WebSocketError
try:
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)
# try/except removed here on purpose so folks can see why things break
import config
logging.basicConfig(level=config.log_level, format=config.log_format)
log = logging.getLogger("kiln-controller")
@ -49,6 +44,10 @@ oven.set_ovenwatcher(ovenWatcher)
def index():
return bottle.redirect('/picoreflow/index.html')
@app.route('/state')
def state():
return bottle.redirect('/picoreflow/state.html')
@app.get('/api/stats')
def handle_api():
log.info("/api/stats command received")
@ -73,6 +72,11 @@ def handle_api():
if 'startat' in bottle.request.json:
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
profile = find_profile(wanted)
if profile is None:
@ -81,9 +85,17 @@ def handle_api():
# FIXME juggling of json should happen in the Profile class
profile_json = json.dumps(profile)
profile = Profile(profile_json)
oven.run_profile(profile,startat=startat)
oven.run_profile(profile, startat=startat, allow_seek=allow_seek)
ovenWatcher.record(profile)
if bottle.request.json['cmd'] == 'pause':
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")
oven.abort_run()
@ -163,6 +175,7 @@ def handle_control():
elif msgdict.get("cmd") == "STOP":
log.info("Stop command received")
oven.abort_run()
time.sleep(1)
except WebSocketError as e:
log.error(e)
break
@ -210,6 +223,7 @@ def handle_storage():
wsock.send(json.dumps(msgdict))
wsock.send(get_profiles())
time.sleep(1)
except WebSocketError:
break
log.info("websocket (storage) closed")
@ -225,6 +239,7 @@ def handle_config():
wsock.send(get_config())
except WebSocketError:
break
time.sleep(1)
log.info("websocket (config) closed")
@ -239,6 +254,7 @@ def handle_status():
wsock.send("Your message was: %r" % message)
except WebSocketError:
break
time.sleep(1)
log.info("websocket (status) closed")
@ -251,10 +267,12 @@ def get_profiles():
for filename in profile_files:
with open(os.path.join(profile_path, filename), 'r') as f:
profiles.append(json.load(f))
profiles = normalize_temp_units(profiles)
return json.dumps(profiles)
def save_profile(profile, force=False):
profile=add_temp_units(profile)
profile_json = json.dumps(profile)
filename = profile['name']+".json"
filepath = os.path.join(profile_path, filename)
@ -267,6 +285,46 @@ def save_profile(profile, force=False):
log.info("Wrote %s" % filepath)
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):
profile_json = json.dumps(profile)
filename = profile['name']+".json"
@ -275,7 +333,6 @@ def delete_profile(profile):
log.info("Deleted %s" % filepath)
return True
def get_config():
return json.dumps({"temp_scale": config.temp_scale,
"time_scale_slope": config.time_scale_slope,
@ -283,7 +340,6 @@ def get_config():
"kwh_rate": config.kwh_rate,
"currency_type": config.currency_type})
def main():
ip = "0.0.0.0"
port = config.listening_port

View File

@ -6,19 +6,19 @@ import csv
import time
import argparse
def recordprofile(csvfile, targettemp):
try:
try:
sys.dont_write_bytecode = True
import config
sys.dont_write_bytecode = False
except ImportError:
except ImportError:
print("Could not import config file.")
print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.")
exit(1)
def recordprofile(csvfile, targettemp):
script_dir = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, script_dir + '/lib/')
@ -32,6 +32,7 @@ def recordprofile(csvfile, targettemp):
# construct the oven
if config.simulate:
oven = SimulatedOven()
oven.target = targettemp * 2 # insures max heating for simulation
else:
oven = RealOven()
@ -42,35 +43,43 @@ def recordprofile(csvfile, targettemp):
# * wait for it to decay back to the target again.
# * quit
#
# We record the temperature every second
# We record the temperature every config.sensor_time_wait
try:
stage = 'heating'
if not config.simulate:
oven.output.heat(0)
while True:
temp = oven.board.temp_sensor.temperature + \
# heating to target of 400F
temp = 0
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
print("stage = %s, actual = %.2f, target = %.2f" % (stage,temp,targettemp))
csvout.writerow([time.time(), temp])
f.flush()
if stage == 'heating':
if temp >= targettemp:
if not config.simulate:
oven.output.cool(0)
stage = 'cooling'
elif stage == 'cooling':
if temp < targettemp:
break
print("stage = %s, actual = %s, target = %s" % (stage,temp,targettemp))
time.sleep(1)
f.close()
# overshoot past target of 400F and then cooling down to 400F
stage = "cooling"
if config.simulate:
oven.target = 0
while(temp >= targettemp):
if config.simulate:
oven.heat_then_cool()
else:
oven.output.cool(sleepfor)
temp = oven.board.temp_sensor.temperature() + \
config.thermocouple_offset
print("stage = %s, actual = %.2f, target = %.2f" % (stage,temp,targettemp))
csvout.writerow([time.time(), temp])
f.flush()
finally:
f.close()
# ensure we always shut the oven down!
if not config.simulate:
oven.output.cool(0)
@ -175,34 +184,22 @@ def calculate(filename, tangentdivisor, showplot):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Kiln tuner')
subparsers = parser.add_subparsers()
parser.set_defaults(mode='')
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')
parser.add_argument('-c', '--calculate_only', action='store_true')
parser.add_argument('-t', '--target_temp', type=float, default=400, help="Target temperature")
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")
args = parser.parse_args()
if args.mode == 'recordprofile':
recordprofile(args.csvfile, args.targettemp)
elif args.mode == 'zn':
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)
csvfile = "tuning.csv"
target = args.target_temp
if config.temp_scale.lower() == "c":
target = (target - 32)*5/9
tangentdivisor = args.tangent_divisor
# 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:
raise NotImplementedError("Unknown mode %s" % args.mode)
recordprofile(csvfile, target)
calculate(csvfile, tangentdivisor, args.showplot)

View File

@ -1,265 +0,0 @@
#!/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()

View File

@ -1,36 +0,0 @@
#!/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)

View File

@ -1,341 +0,0 @@
"""
max31856.py
Class which defines interaction with the MAX31856 sensor.
Copyright (c) 2019 John Robinson
Author: John Robinson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import logging
import warnings
import Adafruit_GPIO as Adafruit_GPIO
import Adafruit_GPIO.SPI as SPI
class MAX31856(object):
"""Class to represent an Adafruit MAX31856 thermocouple temperature
measurement board.
"""
# Board Specific Constants
MAX31856_CONST_THERM_LSB = 2**-7
MAX31856_CONST_THERM_BITS = 19
MAX31856_CONST_CJ_LSB = 2**-6
MAX31856_CONST_CJ_BITS = 14
### Register constants, see data sheet Table 6 (in Rev. 0) for info.
# Read Addresses
MAX31856_REG_READ_CR0 = 0x00
MAX31856_REG_READ_CR1 = 0x01
MAX31856_REG_READ_MASK = 0x02
MAX31856_REG_READ_CJHF = 0x03
MAX31856_REG_READ_CJLF = 0x04
MAX31856_REG_READ_LTHFTH = 0x05
MAX31856_REG_READ_LTHFTL = 0x06
MAX31856_REG_READ_LTLFTH = 0x07
MAX31856_REG_READ_LTLFTL = 0x08
MAX31856_REG_READ_CJTO = 0x09
MAX31856_REG_READ_CJTH = 0x0A # Cold-Junction Temperature Register, MSB
MAX31856_REG_READ_CJTL = 0x0B # Cold-Junction Temperature Register, LSB
MAX31856_REG_READ_LTCBH = 0x0C # Linearized TC Temperature, Byte 2
MAX31856_REG_READ_LTCBM = 0x0D # Linearized TC Temperature, Byte 1
MAX31856_REG_READ_LTCBL = 0x0E # Linearized TC Temperature, Byte 0
MAX31856_REG_READ_FAULT = 0x0F # Fault status register
# Write Addresses
MAX31856_REG_WRITE_CR0 = 0x80
MAX31856_REG_WRITE_CR1 = 0x81
MAX31856_REG_WRITE_MASK = 0x82
MAX31856_REG_WRITE_CJHF = 0x83
MAX31856_REG_WRITE_CJLF = 0x84
MAX31856_REG_WRITE_LTHFTH = 0x85
MAX31856_REG_WRITE_LTHFTL = 0x86
MAX31856_REG_WRITE_LTLFTH = 0x87
MAX31856_REG_WRITE_LTLFTL = 0x88
MAX31856_REG_WRITE_CJTO = 0x89
MAX31856_REG_WRITE_CJTH = 0x8A # Cold-Junction Temperature Register, MSB
MAX31856_REG_WRITE_CJTL = 0x8B # Cold-Junction Temperature Register, LSB
# Pre-config Register Options
MAX31856_CR0_READ_ONE = 0x40 # One shot reading, delay approx. 200ms then read temp registers
MAX31856_CR0_READ_CONT = 0x80 # Continuous reading, delay approx. 100ms between readings
# Thermocouple Types
MAX31856_B_TYPE = 0x0 # Read B Type Thermocouple
MAX31856_E_TYPE = 0x1 # Read E Type Thermocouple
MAX31856_J_TYPE = 0x2 # Read J Type Thermocouple
MAX31856_K_TYPE = 0x3 # Read K Type Thermocouple
MAX31856_N_TYPE = 0x4 # Read N Type Thermocouple
MAX31856_R_TYPE = 0x5 # Read R Type Thermocouple
MAX31856_S_TYPE = 0x6 # Read S Type Thermocouple
MAX31856_T_TYPE = 0x7 # Read T Type Thermocouple
def __init__(self, tc_type=MAX31856_S_TYPE, units="c", avgsel=0x0, ac_freq_50hz=False, ocdetect=0x1, software_spi=None, hardware_spi=None, gpio=None):
"""
Initialize MAX31856 device with software SPI on the specified CLK,
CS, and DO pins. Alternatively can specify hardware SPI by sending an
SPI.SpiDev device in the spi parameter.
Args:
tc_type (1-byte Hex): Type of Thermocouple. Choose from class variables of the form
MAX31856.MAX31856_X_TYPE.
avgsel (1-byte Hex): Type of Averaging. Choose from values in CR0 table of datasheet.
Default is single sample.
ac_freq_50hz: Set to True if your AC frequency is 50Hz, Set to False for 60Hz,
ocdetect: Detect open circuit errors (ie broken thermocouple). Choose from values in CR1 table of datasheet
software_spi (dict): Contains the pin assignments for software SPI, as defined below:
clk (integer): Pin number for software SPI clk
cs (integer): Pin number for software SPI cs
do (integer): Pin number for software SPI MISO
di (integer): Pin number for software SPI MOSI
hardware_spi (SPI.SpiDev): If using hardware SPI, define the connection
"""
self._logger = logging.getLogger('Adafruit_MAX31856.MAX31856')
self._spi = None
self.tc_type = tc_type
self.avgsel = avgsel
self.units = units
self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
# Handle hardware SPI
if hardware_spi is not None:
self._logger.debug('Using hardware SPI')
self._spi = hardware_spi
elif software_spi is not None:
self._logger.debug('Using software SPI')
# Default to platform GPIO if not provided.
if gpio is None:
gpio = Adafruit_GPIO.get_platform_gpio()
self._spi = SPI.BitBang(gpio, software_spi['clk'], software_spi['di'],
software_spi['do'], software_spi['cs'])
else:
raise ValueError(
'Must specify either spi for for hardware SPI or clk, cs, and do for softwrare SPI!')
self._spi.set_clock_hz(5000000)
# According to Wikipedia (on SPI) and MAX31856 Datasheet:
# SPI mode 1 corresponds with correct timing, CPOL = 0, CPHA = 1
self._spi.set_mode(1)
self._spi.set_bit_order(SPI.MSBFIRST)
self.cr0 = self.MAX31856_CR0_READ_CONT | ((ocdetect & 3) << 4) | (1 if ac_freq_50hz else 0)
self.cr1 = (((self.avgsel & 7) << 4) + (self.tc_type & 0x0f))
# Setup for reading continuously with T-Type thermocouple
self._write_register(self.MAX31856_REG_WRITE_CR0, 0)
self._write_register(self.MAX31856_REG_WRITE_CR1, self.cr1)
self._write_register(self.MAX31856_REG_WRITE_CR0, self.cr0)
@staticmethod
def _cj_temp_from_bytes(msb, lsb):
"""
Takes in the msb and lsb from a Cold Junction (CJ) temperature reading and converts it
into a decimal value.
This function was removed from readInternalTempC() and moved to its own method to allow for
easier testing with standard values.
Args:
msb (hex): Most significant byte of CJ temperature
lsb (hex): Least significant byte of a CJ temperature
"""
# (((msb w/o +/-) shifted by number of 1 byte above lsb)
# + val_low_byte)
# >> shifted back by # of dead bits
temp_bytes = (((msb & 0x7F) << 8) + lsb) >> 2
if msb & 0x80:
# Negative Value. Scale back by number of bits
temp_bytes -= 2**(MAX31856.MAX31856_CONST_CJ_BITS -1)
# temp_bytes*value of lsb
temp_c = temp_bytes*MAX31856.MAX31856_CONST_CJ_LSB
return temp_c
@staticmethod
def _thermocouple_temp_from_bytes(byte0, byte1, byte2):
"""
Converts the thermocouple byte values to a decimal value.
This function was removed from readInternalTempC() and moved to its own method to allow for
easier testing with standard values.
Args:
byte2 (hex): Most significant byte of thermocouple temperature
byte1 (hex): Middle byte of thermocouple temperature
byte0 (hex): Least significant byte of a thermocouple temperature
Returns:
temp_c (float): Temperature in degrees celsius
"""
# (((val_high_byte w/o +/-) shifted by 2 bytes above LSB)
# + (val_mid_byte shifted by number 1 byte above LSB)
# + val_low_byte )
# >> back shift by number of dead bits
temp_bytes = (((byte2 & 0x7F) << 16) + (byte1 << 8) + byte0)
temp_bytes = temp_bytes >> 5
if byte2 & 0x80:
temp_bytes -= 2**(MAX31856.MAX31856_CONST_THERM_BITS -1)
# temp_bytes*value of LSB
temp_c = temp_bytes*MAX31856.MAX31856_CONST_THERM_LSB
return temp_c
def read_internal_temp_c(self):
"""
Return internal temperature value in degrees celsius.
"""
val_low_byte = self._read_register(self.MAX31856_REG_READ_CJTL)
val_high_byte = self._read_register(self.MAX31856_REG_READ_CJTH)
temp_c = MAX31856._cj_temp_from_bytes(val_high_byte, val_low_byte)
self._logger.debug("Cold Junction Temperature {0} deg. C".format(temp_c))
return temp_c
def read_temp_c(self):
"""
Return the thermocouple temperature value in degrees celsius.
"""
val_low_byte = self._read_register(self.MAX31856_REG_READ_LTCBL)
val_mid_byte = self._read_register(self.MAX31856_REG_READ_LTCBM)
val_high_byte = self._read_register(self.MAX31856_REG_READ_LTCBH)
temp_c = MAX31856._thermocouple_temp_from_bytes(val_low_byte, val_mid_byte, val_high_byte)
self._logger.debug("Thermocouple Temperature {0} deg. C".format(temp_c))
return temp_c
def read_fault_register(self):
"""Return bytes containing fault codes and hardware problems.
TODO: Could update in the future to return human readable values
"""
reg = self._read_register(self.MAX31856_REG_READ_FAULT)
return reg
def _read_register(self, address):
"""
Reads a register at address from the MAX31856
Args:
address (8-bit Hex): Address for read register. Format 0Xh. Constants listed in class
as MAX31856_REG_READ_*
Note:
SPI transfer method is used. The address is written in as the first byte, and then a
dummy value as the second byte. The data from the sensor is contained in the second
byte, the dummy byte is only used to keep the SPI clock ticking as we read in the
value. The first returned byte is discarded because no data is transmitted while
specifying the register address.
"""
raw = self._spi.transfer([address, 0x00])
if raw is None or len(raw) != 2:
raise RuntimeError('Did not read expected number of bytes from device!')
value = raw[1]
self._logger.debug('Read Register: 0x{0:02X}, Raw Value: 0x{1:02X}'.format(
(address & 0xFFFF), (value & 0xFFFF)))
return value
def _write_register(self, address, write_value):
"""
Writes to a register at address from the MAX31856
Args:
address (8-bit Hex): Address for read register. Format 0Xh. Constants listed in class
as MAX31856_REG_WRITE_*
write_value (8-bit Hex): Value to write to the register
"""
self._spi.transfer([address, write_value])
self._logger.debug('Wrote Register: 0x{0:02X}, Value 0x{1:02X}'.format((address & 0xFF),
(write_value & 0xFF)))
# If we've gotten this far without an exception, the transmission must've gone through
return True
# Deprecated Methods
def readTempC(self): #pylint: disable-msg=invalid-name
"""Depreciated due to Python naming convention, use read_temp_c instead
"""
warnings.warn("Depreciated due to Python naming convention, use read_temp_c() instead", DeprecationWarning)
return read_temp_c(self)
def readInternalTempC(self): #pylint: disable-msg=invalid-name
"""Depreciated due to Python naming convention, use read_internal_temp_c instead
"""
warnings.warn("Depreciated due to Python naming convention, use read_internal_temp_c() instead", DeprecationWarning)
return read_internal_temp_c(self)
# added by jbruce to mimic MAX31855 lib
def to_c(self, celsius):
'''Celsius passthrough for generic to_* method.'''
return celsius
def to_k(self, celsius):
'''Convert celsius to kelvin.'''
return celsius + 273.15
def to_f(self, celsius):
'''Convert celsius to fahrenheit.'''
return celsius * 9.0/5.0 + 32
def checkErrors(self):
data = self.read_fault_register()
self.noConnection = (data & 0x00000001) != 0
self.unknownError = (data & 0xfe) != 0
def get(self):
self.checkErrors()
celcius = self.read_temp_c()
return getattr(self, "to_" + self.units)(celcius)
if __name__ == "__main__":
# Multi-chip example
import time
cs_pins = [6]
clock_pin = 13
data_pin = 5
di_pin = 26
units = "c"
thermocouples = []
for cs_pin in cs_pins:
thermocouples.append(MAX31856(avgsel=0, ac_freq_50hz=True, tc_type=MAX31856.MAX31856_K_TYPE, software_spi={'clk': clock_pin, 'cs': cs_pin, 'do': data_pin, 'di': di_pin}, units=units))
running = True
while(running):
try:
for thermocouple in thermocouples:
rj = thermocouple.read_internal_temp_c()
tc = thermocouple.get()
print("tc: {} and rj: {}, NC:{} ??:{}".format(tc, rj, thermocouple.noConnection, thermocouple.unknownError))
time.sleep(1)
except KeyboardInterrupt:
running = False
for thermocouple in thermocouples:
thermocouple.cleanup()

View File

@ -1,11 +1,14 @@
import threading
import time
import random
import datetime
import logging
import json
import config
import os
import digitalio
import busio
import adafruit_bitbangio as bitbangio
import statistics
log = logging.getLogger(__name__)
@ -28,170 +31,297 @@ class Duplogger():
duplog = Duplogger().logref()
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):
self.active = False
self.load_libs()
def load_libs(self):
try:
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(config.gpio_heat, GPIO.OUT)
self.active = True
self.GPIO = GPIO
except:
msg = "Could not initialize GPIOs, oven operation will only be simulated!"
log.warning(msg)
self.active = False
self.heater = digitalio.DigitalInOut(config.gpio_heat)
self.heater.direction = digitalio.Direction.OUTPUT
self.off = config.gpio_heat_invert
self.on = not self.off
def heat(self,sleepfor):
self.GPIO.output(config.gpio_heat, self.GPIO.HIGH)
self.heater.value = self.on
time.sleep(sleepfor)
def cool(self,sleepfor):
'''no active cooling, so sleep'''
self.GPIO.output(config.gpio_heat, self.GPIO.LOW)
self.heater.value = self.off
time.sleep(sleepfor)
# FIX - Board class needs to be completely removed
# wrapper for blinka board
class Board(object):
'''This represents a blinka board where this code
runs.
'''
def __init__(self):
self.name = None
self.active = False
self.temp_sensor = None
self.gpio_active = False
self.load_libs()
self.create_temp_sensor()
log.info("board: %s" % (self.name))
self.temp_sensor.start()
def load_libs(self):
if config.max31855:
try:
#from max31855 import MAX31855, MAX31855Error
self.name='MAX31855'
self.active = True
log.info("import %s " % (self.name))
except ImportError:
msg = "max31855 config set, but import failed"
log.warning(msg)
if config.max31856:
try:
#from max31856 import MAX31856, MAX31856Error
self.name='MAX31856'
self.active = True
log.info("import %s " % (self.name))
except ImportError:
msg = "max31856 config set, but import failed"
log.warning(msg)
def create_temp_sensor(self):
if config.simulate == True:
self.temp_sensor = TempSensorSimulate()
else:
self.temp_sensor = TempSensorReal()
class BoardSimulated(object):
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):
self.name = None
self.load_libs()
self.temp_sensor = self.choose_tempsensor()
Board.__init__(self)
def load_libs(self):
import board
self.name = board.board_id
def choose_tempsensor(self):
if config.max31855:
return Max31855()
if config.max31856:
return Max31856()
class SimulatedBoard(Board):
'''Simulated board used during simulations.
See config.simulate
'''
def __init__(self):
self.name = "simulated"
self.temp_sensor = TempSensorSimulated()
Board.__init__(self)
class TempSensor(threading.Thread):
'''Used by the Board class. Each Board must have
a TempSensor.
'''
def __init__(self):
threading.Thread.__init__(self)
self.daemon = True
self.temperature = 0
self.bad_percent = 0
self.time_step = config.sensor_time_wait
self.noConnection = self.shortToGround = self.shortToVCC = self.unknownError = False
self.status = ThermocoupleTracker()
class TempSensorSimulated(TempSensor):
'''not much here, just need to be able to set the temperature'''
'''Simulates a temperature sensor '''
def __init__(self):
TempSensor.__init__(self)
self.simulated_temperature = config.sim_t_env
def temperature(self):
return self.simulated_temperature
class TempSensorReal(TempSensor):
'''real temperature sensor thread that takes N measurements
during the time_step'''
'''real temperature sensor that takes many measurements
during the time_step
inputs
config.temperature_average_samples
'''
def __init__(self):
TempSensor.__init__(self)
self.sleeptime = self.time_step / float(config.temperature_average_samples)
self.bad_count = 0
self.ok_count = 0
self.bad_stamp = 0
self.temptracker = TempTracker()
self.spi_setup()
self.cs = digitalio.DigitalInOut(config.spi_cs)
if config.max31855:
log.info("init MAX31855")
from max31855 import MAX31855, MAX31855Error
self.thermocouple = MAX31855(config.gpio_sensor_cs,
config.gpio_sensor_clock,
config.gpio_sensor_data,
config.temp_scale)
def spi_setup(self):
if(hasattr(config,'spi_sclk') and
hasattr(config,'spi_mosi') and
hasattr(config,'spi_miso')):
self.spi = bitbangio.SPI(config.spi_sclk, config.spi_mosi, config.spi_miso)
log.info("Software SPI selected for reading thermocouple")
else:
import board
self.spi = board.SPI();
log.info("Hardware SPI selected for reading thermocouple")
if config.max31856:
log.info("init MAX31856")
from max31856 import MAX31856
software_spi = { 'cs': config.gpio_sensor_cs,
'clk': config.gpio_sensor_clock,
'do': config.gpio_sensor_data,
'di': config.gpio_sensor_di }
self.thermocouple = MAX31856(tc_type=config.thermocouple_type,
software_spi = software_spi,
units = config.temp_scale,
ac_freq_50hz = config.ac_freq_50hz,
)
def get_temperature(self):
'''read temp from tc and convert if needed'''
try:
temp = self.raw_temp() # raw_temp provided by subclasses
if config.temp_scale.lower() == "f":
temp = (temp*9/5)+32
self.status.good()
return temp
except ThermocoupleError as tce:
if tce.ignore:
log.error("Problem reading temp (ignored) %s" % (tce.message))
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):
'''use a moving average of config.temperature_average_samples across the time_step'''
temps = []
while True:
# reset error counter if time is up
if (time.time() - self.bad_stamp) > (self.time_step * 2):
if self.bad_count + self.ok_count:
self.bad_percent = (self.bad_count / (self.bad_count + self.ok_count)) * 100
else:
self.bad_percent = 0
self.bad_count = 0
self.ok_count = 0
self.bad_stamp = time.time()
temp = self.thermocouple.get()
self.noConnection = self.thermocouple.noConnection
self.shortToGround = self.thermocouple.shortToGround
self.shortToVCC = self.thermocouple.shortToVCC
self.unknownError = self.thermocouple.unknownError
is_bad_value = self.noConnection | self.unknownError
if 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)
temp = self.get_temperature()
if temp:
self.temptracker.add(temp)
time.sleep(self.sleeptime)
def get_avg_temp(self, temps, chop=25):
class TempTracker(object):
'''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):
'''
strip off chop percent from the beginning and end of the sorted temps
then return the average of what is left
take the median of the given values. this used to take an avg
after getting rid of outliers. median works better.
'''
chop = chop / 100
temps = sorted(temps)
total = len(temps)
items = int(total*chop)
temps = temps[items:total-items]
return sum(temps) / len(temps)
return statistics.median(self.temps)
class ThermocoupleTracker(object):
'''Keeps sliding window to track successful/failed calls to get temp
over the last two duty cycles.
'''
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):
'''parent oven class. this has all the common code
@ -212,26 +342,51 @@ class Oven(threading.Thread):
self.totaltime = 0
self.target = 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.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()
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.runtime = self.startat
self.runtime = runtime
self.start_time = datetime.datetime.now() - datetime.timedelta(seconds=self.startat)
self.profile = profile
self.totaltime = profile.get_duration()
@ -243,20 +398,28 @@ class Oven(threading.Thread):
self.reset()
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):
'''shift the whole schedule forward in time by one time_step
to wait for the kiln to catch up'''
if config.kiln_must_catch_up == True:
temp = self.board.temp_sensor.temperature + \
temp = self.board.temp_sensor.temperature() + \
config.thermocouple_offset
# kiln too cold, wait for it to heat up
if self.target - temp > config.pid_control_window:
log.info("kiln must catch up, too cold, shifting schedule")
self.start_time = datetime.datetime.now() - datetime.timedelta(milliseconds = self.runtime * 1000)
self.start_time = self.get_start_time()
self.catching_up = True;
return
# kiln too hot, wait for it to cool down
if temp - self.target > config.pid_control_window:
log.info("kiln must catch up, too hot, shifting schedule")
self.start_time = datetime.datetime.now() - datetime.timedelta(milliseconds = self.runtime * 1000)
self.start_time = self.get_start_time()
self.catching_up = True;
return
self.catching_up = False;
def update_runtime(self):
@ -271,25 +434,15 @@ class Oven(threading.Thread):
def reset_if_emergency(self):
'''reset if the temperature is way TOO HOT, or other critical errors detected'''
if (self.board.temp_sensor.temperature + config.thermocouple_offset >=
if (self.board.temp_sensor.temperature() + config.thermocouple_offset >=
config.emergency_shutoff_temp):
log.info("emergency!!! temperature too high")
if config.ignore_temp_too_high == False:
self.abort_run()
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:
if self.board.temp_sensor.status.over_error_limit():
log.info("emergency!!! too many errors in a short period")
if config.ignore_too_many_tc_errors == False:
if config.ignore_tc_too_many_errors == False:
self.abort_run()
def reset_if_schedule_ended(self):
@ -308,12 +461,14 @@ class Oven(threading.Thread):
def get_state(self):
temp = 0
try:
temp = self.board.temp_sensor.temperature + config.thermocouple_offset
temp = self.board.temp_sensor.temperature() + config.thermocouple_offset
except AttributeError as error:
# this happens at start-up with a simulated oven
temp = 0
pass
self.set_heat_rate(self.runtime,temp)
state = {
'cost': self.cost,
'runtime': self.runtime,
@ -321,11 +476,13 @@ class Oven(threading.Thread):
'target': self.target,
'state': self.state,
'heat': self.heat,
'heat_rate': self.heat_rate,
'totaltime': self.totaltime,
'kwh_rate': config.kwh_rate,
'currency_type': config.currency_type,
'profile': self.profile.name if self.profile else None,
'pidstats': self.pid.pidstats,
'catching_up': self.catching_up,
}
return state
@ -377,7 +534,7 @@ class Oven(threading.Thread):
with open(profile_path) as infile:
profile_json = json.dumps(json.load(infile))
profile = Profile(profile_json)
self.run_profile(profile,startat=startat)
self.run_profile(profile, startat=startat, allow_seek=False) # We don't want a seek on an auto restart.
self.cost = d["cost"]
time.sleep(1)
self.ovenwatcher.record(profile)
@ -388,11 +545,20 @@ class Oven(threading.Thread):
def run(self):
while True:
log.debug('Oven running on ' + threading.current_thread().name)
if self.state == "IDLE":
if self.should_i_automatic_restart() == True:
self.automatic_restart()
time.sleep(1)
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":
self.update_cost()
self.save_automatic_restart_state()
@ -406,7 +572,7 @@ class Oven(threading.Thread):
class SimulatedOven(Oven):
def __init__(self):
self.board = BoardSimulated()
self.board = SimulatedBoard()
self.t_env = config.sim_t_env
self.c_heat = config.sim_c_heat
self.c_oven = config.sim_c_oven
@ -414,17 +580,34 @@ class SimulatedOven(Oven):
self.R_o_nocool = config.sim_R_o_nocool
self.R_ho_noair = config.sim_R_ho_noair
self.R_ho = self.R_ho_noair
self.speedup_factor = config.sim_speedup_factor
# set temps to the temp of the surrounding environment
self.t = self.t_env # deg C temp of oven
self.t = config.sim_t_env # deg C or F temp of oven
self.t_h = self.t_env #deg C temp of heating element
super().__init__()
self.start_time = self.get_start_time();
# start thread
self.start()
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):
# using pid here simulates the element being on for
# only part of the time_step
@ -445,12 +628,14 @@ class SimulatedOven(Oven):
self.p_env = (self.t - self.t_env) / self.R_o_nocool
self.t -= self.p_env * self.time_step / self.c_oven
self.temperature = self.t
self.board.temp_sensor.temperature = self.t
self.board.temp_sensor.simulated_temperature = self.t
def heat_then_cool(self):
now_simulator = self.start_time + datetime.timedelta(milliseconds = self.runtime * 1000)
pid = self.pid.compute(self.target,
self.board.temp_sensor.temperature +
config.thermocouple_offset)
self.board.temp_sensor.temperature() +
config.thermocouple_offset, now_simulator)
heat_on = float(self.time_step * pid)
heat_off = float(self.time_step * (1 - pid))
@ -462,7 +647,7 @@ class SimulatedOven(Oven):
if heat_on > 0:
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,
int(self.p_ho),
self.t,
@ -489,13 +674,13 @@ class SimulatedOven(Oven):
# we don't actually spend time heating & cooling during
# a simulation, so sleep.
time.sleep(self.time_step)
time.sleep(self.time_step / self.speedup_factor)
class RealOven(Oven):
def __init__(self):
self.board = Board()
self.board = RealBoard()
self.output = Output()
self.reset()
@ -511,8 +696,9 @@ class RealOven(Oven):
def heat_then_cool(self):
pid = self.pid.compute(self.target,
self.board.temp_sensor.temperature +
config.thermocouple_offset)
self.board.temp_sensor.temperature() +
config.thermocouple_offset, datetime.datetime.now())
heat_on = float(self.time_step * pid)
heat_off = float(self.time_step * (1 - pid))
@ -552,6 +738,28 @@ class Profile():
def get_duration(self):
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):
if time > self.get_duration():
return (None, None)
@ -594,8 +802,7 @@ class PID():
# settled on -50 to 50 and then divide by 50 at the end. This results
# in a larger PID control window and much more accurate control...
# instead of what used to be binary on/off control.
def compute(self, setpoint, ispoint):
now = datetime.datetime.now()
def compute(self, setpoint, ispoint, now):
timeDelta = (now - self.lastNow).total_seconds()
window_size = 100
@ -618,6 +825,10 @@ class PID():
elif error > (1 * config.pid_control_window):
log.info("kiln outside pid control window, max heating")
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:
icomp = (error * timeDelta * (1/self.ki))
self.iterm += (error * timeDelta * (1/self.ki))

View File

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

View File

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

View File

@ -8,7 +8,7 @@ var selected_profile = 0;
var selected_profile_name = 'cone-05-long-bisque.json';
var temp_scale = "c";
var time_scale_slope = "s";
var time_scale_profile = "s";
var time_scale_profile = "h";
var time_scale_long = "Seconds";
var temp_scale_display = "C";
var kwh_rate = 0.26;
@ -274,6 +274,7 @@ function enterEditMode()
graph.profile.draggable = true;
graph.plot = $.plot("#graph_container", [ graph.profile, graph.live ], getOptions());
updateProfileTable();
toggleTable();
}
function leaveEditMode()
@ -368,14 +369,14 @@ function saveProfile()
}
function get_tick_size() {
switch(time_scale_profile){
case "s":
return 1;
case "m":
return 60;
case "h":
return 3600;
}
//switch(time_scale_profile){
// case "s":
// return 1;
// case "m":
// return 60;
// case "h":
// return 3600;
// }
return 3600;
}
@ -473,17 +474,17 @@ $(document).ready(function()
{
console.log("Status Socket has been opened");
$.bootstrapGrowl("<span class=\"glyphicon glyphicon-exclamation-sign\"></span>Getting data from server",
{
ele: 'body', // which element to append to
type: 'success', // (null, 'info', 'error', 'success')
offset: {from: 'top', amount: 250}, // 'top', or 'bottom'
align: 'center', // ('left', 'right', or 'center')
width: 385, // (integer, or 'auto')
delay: 2500,
allow_dismiss: true,
stackup_spacing: 10 // spacing between consecutively stacked growls.
});
// $.bootstrapGrowl("<span class=\"glyphicon glyphicon-exclamation-sign\"></span>Getting data from server",
// {
// ele: 'body', // which element to append to
// type: 'success', // (null, 'info', 'error', 'success')
// offset: {from: 'top', amount: 250}, // 'top', or 'bottom'
// align: 'center', // ('left', 'right', or 'center')
// width: 385, // (integer, or 'auto')
// delay: 2500,
// allow_dismiss: true,
// stackup_spacing: 10 // spacing between consecutively stacked growls.
// });
};
ws_status.onclose = function()
@ -502,9 +503,6 @@ $(document).ready(function()
ws_status.onmessage = function(e)
{
console.log("received status data")
console.log(e.data);
x = JSON.parse(e.data);
if (x.type == "backlog")
{
@ -528,11 +526,11 @@ $(document).ready(function()
if(state!="EDIT")
{
state = x.state;
if (state!=state_last)
{
if(state_last == "RUNNING")
if(state_last == "RUNNING" && state != "PAUSED" )
{
console.log(state);
$('#target_temp').html('---');
updateProgress(0);
$.bootstrapGrowl("<span class=\"glyphicon glyphicon-exclamation-sign\"></span> <b>Run completed</b>", {
@ -575,7 +573,13 @@ $(document).ready(function()
}
$('#act_temp').html(parseInt(x.temperature));
$('#heat').html('<div class="bar" style="height:'+x.pidstats.out*70+'%;"></div>')
heat_rate = parseInt(x.heat_rate)
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.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"); }
@ -608,6 +612,7 @@ $(document).ready(function()
$('#act_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){
case "s":

302
public/assets/js/state.js Normal file
View File

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

View File

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

74
public/state.html Normal file
View File

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

View File

@ -3,7 +3,23 @@ greenlet
bottle
gevent
gevent-websocket
RPi.GPIO
Adafruit-MAX31855
Adafruit-GPIO
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
# List of all supported adafruit modules for thermocouples
adafruit-circuitpython-max31855
adafruit-circuitpython-max31856
# for folks using sw spi (bit banging)
adafruit-circuitpython-bitbangio
# untested - for PT100 platinum thermocouples
#adafruit-circuitpython-max31865
# untested - for mcp9600 and mcp9601
#adafruit-circuitpython-mcp9600

View File

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

View File

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

47
test-output.py Executable file
View File

@ -0,0 +1,47 @@
#!/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)

74
test-thermocouple.py Executable file
View File

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

View File

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