Skip to content
This repository has been archived by the owner on Mar 27, 2023. It is now read-only.

Commit

Permalink
Initial (untested) attempt at Cascade PID in CBP
Browse files Browse the repository at this point in the history
  • Loading branch information
jangevaare committed Mar 6, 2018
0 parents commit bac0f18
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 0 deletions.
101 changes: 101 additions & 0 deletions .gitignore
@@ -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/
21 changes: 21 additions & 0 deletions LICENSE
@@ -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.
32 changes: 32 additions & 0 deletions README.md
@@ -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.
102 changes: 102 additions & 0 deletions __init__.py
@@ -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)

0 comments on commit bac0f18

Please sign in to comment.