This repository has been archived by the owner on Mar 27, 2023. It is now read-only.
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial (untested) attempt at Cascade PID in CBP
- Loading branch information
0 parents
commit bac0f18
Showing
4 changed files
with
256 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
# Byte-compiled / optimized / DLL files | ||
__pycache__/ | ||
*.py[cod] | ||
*$py.class | ||
|
||
# C extensions | ||
*.so | ||
|
||
# Distribution / packaging | ||
.Python | ||
env/ | ||
build/ | ||
develop-eggs/ | ||
dist/ | ||
downloads/ | ||
eggs/ | ||
.eggs/ | ||
lib/ | ||
lib64/ | ||
parts/ | ||
sdist/ | ||
var/ | ||
wheels/ | ||
*.egg-info/ | ||
.installed.cfg | ||
*.egg | ||
|
||
# PyInstaller | ||
# Usually these files are written by a python script from a template | ||
# before PyInstaller builds the exe, so as to inject date/other infos into it. | ||
*.manifest | ||
*.spec | ||
|
||
# Installer logs | ||
pip-log.txt | ||
pip-delete-this-directory.txt | ||
|
||
# Unit test / coverage reports | ||
htmlcov/ | ||
.tox/ | ||
.coverage | ||
.coverage.* | ||
.cache | ||
nosetests.xml | ||
coverage.xml | ||
*.cover | ||
.hypothesis/ | ||
|
||
# Translations | ||
*.mo | ||
*.pot | ||
|
||
# Django stuff: | ||
*.log | ||
local_settings.py | ||
|
||
# Flask stuff: | ||
instance/ | ||
.webassets-cache | ||
|
||
# Scrapy stuff: | ||
.scrapy | ||
|
||
# Sphinx documentation | ||
docs/_build/ | ||
|
||
# PyBuilder | ||
target/ | ||
|
||
# Jupyter Notebook | ||
.ipynb_checkpoints | ||
|
||
# pyenv | ||
.python-version | ||
|
||
# celery beat schedule file | ||
celerybeat-schedule | ||
|
||
# SageMath parsed files | ||
*.sage.py | ||
|
||
# dotenv | ||
.env | ||
|
||
# virtualenv | ||
.venv | ||
venv/ | ||
ENV/ | ||
|
||
# Spyder project settings | ||
.spyderproject | ||
.spyproject | ||
|
||
# Rope project settings | ||
.ropeproject | ||
|
||
# mkdocs documentation | ||
/site | ||
|
||
# mypy | ||
.mypy_cache/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
MIT License | ||
|
||
Copyright (c) 2018 Justin Angevaare | ||
|
||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# cbpi-CascadePID | ||
## Introduction | ||
This CraftBeerPi 3.0 plugin provides a new KettleController type called CascadePID. The main purpose of this plugin is for sophisticated mash temperature control within popular RIMS and HERMS-based breweries, however it may have additional purposes. | ||
|
||
Use of this plugin is technical, and it is not recommended for beginners. | ||
|
||
## License | ||
This plugin is open source, and has an MIT License. Please read the included license if you are not already familiar with it. | ||
|
||
## Safety and Disclaimers | ||
* The most effective way of improving temperature control in your RIMS or HERMS brewery is not by using this plugin, but rather by reducing lag time within the system. This is accomplished by proper mixing and recirculation conditions. | ||
* This plugin is intended only for those knowledgable and comfortable with control systems. | ||
* Improper tuning could lead to unpredictable results with this plugin. The user must closely monitor their brewery at all times of operation, especially during the tuning process. | ||
* This plugin should never be used in the absence of proper safety features, especially those related to element dry firing, properly rated hardware components, and GFCI protection. | ||
|
||
## Control Loops and Cascade Control | ||
With this plugin, we use only two control loops, which we will refer to as the inner loop and the outer loop. | ||
|
||
With Cascade PID, two things happen: | ||
* The outer loop controls the set point of the inner loop, and, | ||
* The action of the inner loop ultimately controls the process variable in the outer loop. | ||
|
||
The inner loop in a traditional HERMS brewery is the control loop for the temperature of the hot liquor tank. In a traditional RIMS brewery, this control loop is for the temperature of the RIMS tube. | ||
|
||
The outer loop is the actual process variable you are interested in controlling. In the case of both RIMS and HERMS breweries, this is the mash temperature. The process variable in the outer loop is dependent upon the inner loop. | ||
|
||
## Tuning | ||
Tuning of these systems is non-trivial, but not impossible. You should approach tuning a cascade control starting with the innermost loop. | ||
|
||
For many homebreweries, it is entirely sufficient to set the integral and derivative action parameters to 0 in the inner loop, as there should be minimal lag in this loop. As such, 0 is the default values of these parameters. | ||
|
||
Beyond this suggestion, tuning is responsibility of the user, and requires some knowledge of control systems. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
import time | ||
from modules import cbpi | ||
from modules.core.controller import KettleController | ||
from modules.core.props import Property | ||
|
||
@cbpi.controller | ||
class CascadePID(KettleController): | ||
inner_sensor = Property.Sensor(label="Inner loop sensor") | ||
inner_kp = Property.Number("Inner loop proportional term", True, 10.0) | ||
inner_ki = Property.Number("Inner loop integral term", True, 0.0) | ||
inner_kd = Property.Number("Inner loop derivative term", True, 0.0) | ||
outer_kp = Property.Number("Outer loop proportional term", True, 30.0) | ||
outer_ki = Property.Number("Outer loop integral term", True, 0.0) | ||
outer_kd = Property.Number("Outer loop derivative term", True, 0.0) | ||
update_interval = Property.Number("Update interval", True, 2.0) | ||
|
||
def __init__(self): | ||
if self.update_interval <= 0: | ||
raise ValueError("Period, must be positive") | ||
|
||
def stop(self): | ||
super(KettleController, self).stop() | ||
self.heater_off() | ||
|
||
def run(self): | ||
self.heater_on(0.0) | ||
# Get the target temperature | ||
outer_target_value = self.get_target_temp() | ||
|
||
# Ensure all terms are floats | ||
inner_kp = float(self.inner_kp) | ||
inner_ki = float(self.inner_ki) | ||
inner_kd = float(self.inner_kd) | ||
outer_kp = float(self.outer_kp) | ||
outer_ki = float(self.outer_ki) | ||
outer_kd = float(self.outer_kd) | ||
|
||
# Get inner sensor as integer | ||
inner_sensor = int(self.inner_sensor) | ||
|
||
# Set a max for the the integrators | ||
integrator_max = 15.0 | ||
|
||
# Initialize PID cascade | ||
if self.get_config_parameter("unit", "C") == "C": | ||
outer_pid = PID(outer_kp, outer_ki, outer_kd, 0.0, 100.0, integrator_max) | ||
else: | ||
outer_pid = PID(outer_kp, outer_ki, outer_kd, 32, 212, integrator_max * 1.8) | ||
|
||
inner_pid = PID(inner_kp, inner_ki, inner_kd, 0.0, 100.0, 15) | ||
|
||
while self.is_running(): | ||
waketime = time.time() + float(self.update_interval) | ||
# Calculate target value of inner loop, as the output of the outer loop | ||
outer_current_value = self.get_temp() | ||
# Calculate output of inner loop actor based on current and target values | ||
inner_current_value = float(cbpi.cache.get("sensors")[inner_sensor].instance.last_value) | ||
inner_output = update(inner_pid, inner_current_value, inner_target_value) | ||
# Update the heater power | ||
self.actor_power(inner_output) | ||
# Sleep until update required again | ||
if waketime <= time.time(): | ||
self.notify("cascadePID Error", "Update interval is too short complete calculations", timeout=None, type="danger") | ||
raise ValueError("Update interval is too short to complete cascadePID calculations") | ||
else: | ||
self.sleep(waketime - time.time()) | ||
|
||
class PID(object): | ||
def __init__(self, kp, ki, kd, output_min, output_max, integrator_max): | ||
self.last_time = 0.0 | ||
self.last_error = 0.0 | ||
self.integrator = 0.0 | ||
|
||
def update(self, current, target): | ||
# Initialization interation | ||
if self.last_time == 0.0: | ||
self.last_time = time.time() | ||
current_error = target - current | ||
# Update last_error | ||
self.last_error = current_error | ||
# Return output | ||
return max(min(self.kp * current_error, output_max), output_min) | ||
# Regular interation | ||
else: | ||
# Calculate duration of interation | ||
current_time = time.time() | ||
interation_time = current_time - last_time | ||
self.last_time = current_time | ||
# Calculate error | ||
current_error = target - current | ||
# Integrate error (respecting bounds to reduce integrator windup) | ||
self.integrator = max(min(self.integrator + (current_error * interation_time), self.integrator_max), -self.integrator_max) | ||
# Calculate error derivative | ||
derivative = (current_error - self.last_error)/interation_time | ||
# Calculate output | ||
p_action = self.kp * current_error | ||
i_action = self.ki * self.integral | ||
d_action = self.kd * derivative | ||
# Update last_error | ||
self.last_error = current_error | ||
# Return output | ||
return max(min(p_action + i_action + d_action, output_max), output_min) |