From 78ee80f96da9fea8c44270bfc3714e1689e27dc3 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 20:50:01 +0200 Subject: [PATCH 01/11] Deduplicate TempController/TempController_smith into a shared base Both controllers had nearly identical process_fsm(), process_pid(), and all getters/setters; only Kalman/model setup and process() genuinely differed, and the duplication had already drifted (the Smith variant resets model state on entering HEAT, the plain one didn't). Add TempControllerBase with the shared logic and three small hooks (init_kalman, on_state_entered, post_pid) subclasses use to plug in their own Kalman/model behavior. Each subclass now contains only what makes it different. Also fixes a latent crash: TempController's constructor only accepted (dt, params), but PidFactory/brewpi.py always call it with a third model_params arg, so pid_type "Normal" would have raised TypeError. The shared base's model_params=None default fixes this. Also initializes the Smith controller's trace attributes in __init__ instead of leaving them undefined until the first process() call. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/temp_controller.py | 103 ++------------------ components/pid/temp_controller_base.py | 112 +++++++++++++++++++++ components/pid/temp_controller_smith.py | 124 +++++------------------- 3 files changed, 141 insertions(+), 198 deletions(-) create mode 100644 components/pid/temp_controller_base.py diff --git a/components/pid/temp_controller.py b/components/pid/temp_controller.py index 0b34212..ac769cb 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -1,64 +1,18 @@ from components.plant.pot import Pot from matplotlib.pyplot import plot, figure, subplot, grid, show, legend -from components import APid -from components.pid import Pid, Kalman +from components.pid import Kalman +from components.pid.temp_controller_base import TempControllerBase from components.pid.tc_constants import * import numpy as np -class TempController(APid): - def __init__(self, dt, params): - APid.__init__(self) - self.pid_hold = Pid(dt) - self.pid_rate = Pid(dt) - self.theta_ist_set = 0 - self.theta_soll_set = 0 - self.heatrate_ist_set = 0 - self.heatrate_soll_set = 1.0 - self.heatrate_soll = 1.0 - self.theta_ist = 0 - self.heatrate_ist = 0 - self.params = params +class TempController(TempControllerBase): + def __init__(self, dt, params, model_params=None): + TempControllerBase.__init__(self, dt, params, model_params) self.kalman = Kalman(dt, params['Kalman']) - self.y = -1 - self.state = States.INIT - self.use_kalman = True - self.pid_hold.set_params(params['Hold']) - self.pid_rate.set_params(params['Heat']) - self.is_startup = True - def set_theta_ist(self, value): - self.theta_ist_set = value - if self.is_startup: - self.is_startup = False - self.kalman.initial((value, 0)) - - def get_theta_ist(self): - return self.theta_ist - - def set_heatrate_ist(self, value): - self.heatrate_ist_set = value - - def get_heatrate_ist(self): - return self.heatrate_ist - - def set_theta_soll(self, value): - self.theta_soll_set = value - - def get_theta_soll(self): - return self.theta_soll - - def get_theta_soll_set(self): - return self.theta_soll_set - - def set_heatrate_soll(self, value): - self.heatrate_soll_set = value - - def get_heatrate_soll(self): - return self.heatrate_soll - - def get_heatrate_soll_set(self): - return self.heatrate_soll_set + def init_kalman(self, value): + self.kalman.initial((value, 0)) def process(self): # Process Kalman @@ -83,48 +37,6 @@ class TempController(APid): self.process_fsm(diff) self.process_pid(theta_err, heatrate_err) - def process_fsm(self, diff): - # Process state - state_next = self.state - if self.state == States.INIT: - if not self.is_startup: - state_next = States.IDLE - elif self.state == States.IDLE: - if diff >= THRESH_IDLE_HEAT: - state_next = States.HEAT - self.pid_rate.reset() - elif diff >= -THRESH_IDLE_HOLD: - state_next = States.HOLD - self.pid_rate.reset() - elif self.state == States.HOLD: - if diff >= THRESH_HOLD_HEAT: - state_next = States.HEAT - self.pid_rate.reset() - elif diff <= -THRESH_HOLD_IDLE: - state_next = States.IDLE - elif self.state == States.HEAT: - if diff <= -THRESH_HEAT_IDLE: - state_next = States.IDLE - elif diff <= THRESH_HEAT_HOLD: - state_next = States.HOLD - self.pid_hold.reset() - - if state_next != self.state: - self.state = state_next - print("New state = {}".format(state_next)) - - def process_pid(self, theta_err, heatrate_err): - self.pid_hold.process(theta_err, -self.theta_ist) - self.pid_rate.process(heatrate_err, -self.heatrate_ist) - - if self.state == States.IDLE: - self.y = 0 - else: - self.y = self.pid_rate.get_y() - - def get_power(self): - return self.y - if __name__ == '__main__': dt = 1.0 @@ -196,4 +108,3 @@ if __name__ == '__main__': show() print("End of program") - diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py new file mode 100644 index 0000000..828c6e8 --- /dev/null +++ b/components/pid/temp_controller_base.py @@ -0,0 +1,112 @@ +from components import APid +from components.pid.pid import Pid +from components.pid.tc_constants import States, THRESH_HOLD_IDLE, THRESH_HOLD_HEAT, \ + THRESH_IDLE_HEAT, THRESH_IDLE_HOLD, THRESH_HEAT_HOLD, THRESH_HEAT_IDLE + + +class TempControllerBase(APid): + def __init__(self, dt, params, model_params=None): + APid.__init__(self) + self.pid_hold = Pid(dt) + self.pid_rate = Pid(dt) + self.theta_ist_set = 0 + self.theta_soll_set = 0 + self.heatrate_ist_set = 0 + self.heatrate_soll_set = 1.0 + self.heatrate_soll = 1.0 + self.theta_ist = 0 + self.heatrate_ist = 0 + self.params = params + self.model_params = model_params + self.y = -1 + self.state = States.INIT + self.use_kalman = True + self.pid_hold.set_params(params['Hold']) + self.pid_rate.set_params(params['Heat']) + self.is_startup = True + + def init_kalman(self, value): + raise NotImplementedError + + def on_state_entered(self, state): + pass + + def post_pid(self): + pass + + def set_theta_ist(self, value): + self.theta_ist_set = value + if self.is_startup: + self.is_startup = False + self.init_kalman(value) + + def get_theta_ist(self): + return self.theta_ist + + def set_heatrate_ist(self, value): + self.heatrate_ist_set = value + + def get_heatrate_ist(self): + return self.heatrate_ist + + def set_theta_soll(self, value): + self.theta_soll_set = value + + def get_theta_soll(self): + return self.theta_soll + + def get_theta_soll_set(self): + return self.theta_soll_set + + def set_heatrate_soll(self, value): + self.heatrate_soll_set = value + + def get_heatrate_soll(self): + return self.heatrate_soll + + def get_heatrate_soll_set(self): + return self.heatrate_soll_set + + def process_fsm(self, diff): + state_next = self.state + if self.state == States.INIT: + if not self.is_startup: + state_next = States.IDLE + elif self.state == States.IDLE: + if diff >= THRESH_IDLE_HEAT: + state_next = States.HEAT + self.pid_rate.reset() + elif diff >= -THRESH_IDLE_HOLD: + state_next = States.HOLD + self.pid_rate.reset() + elif self.state == States.HOLD: + if diff >= THRESH_HOLD_HEAT: + state_next = States.HEAT + self.pid_rate.reset() + elif diff <= -THRESH_HOLD_IDLE: + state_next = States.IDLE + elif self.state == States.HEAT: + if diff <= -THRESH_HEAT_IDLE: + state_next = States.IDLE + elif diff <= THRESH_HEAT_HOLD: + state_next = States.HOLD + self.pid_hold.reset() + + if state_next != self.state: + self.state = state_next + print("New state = {}".format(state_next)) + self.on_state_entered(state_next) + + def process_pid(self, theta_err, heatrate_err): + self.pid_hold.process(theta_err, -self.theta_ist) + self.pid_rate.process(heatrate_err, -self.heatrate_ist) + + if self.state == States.IDLE: + self.y = 0 + else: + self.y = self.pid_rate.get_y() + + self.post_pid() + + def get_power(self): + return self.y diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index 6d33516..b2943fd 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -1,73 +1,42 @@ from components.plant.pot import Pot from matplotlib.pyplot import plot, figure, subplot, grid, show, legend -from components import APid -from components.pid import Pid, Kalman +from components.pid import Kalman +from components.pid.temp_controller_base import TempControllerBase from components.pid.tc_constants import * import numpy as np -class TempController(APid): +class TempController(TempControllerBase): def __init__(self, dt, params, model_params): - APid.__init__(self) - self.pid_hold = Pid(dt) - self.pid_rate = Pid(dt) - self.theta_ist_set = 0 - self.theta_soll_set = 0 - self.heatrate_ist_set = 0 - self.heatrate_soll_set = 1.0 - self.heatrate_soll = 1.0 - self.theta_ist = 0 - self.heatrate_ist = 0 - self.params = params - self.model_params = model_params + TempControllerBase.__init__(self, dt, params, model_params) self.kalman_model = Kalman(dt, params['Kalman']) self.kalman_model_delay = Kalman(dt, params['Kalman']) self.kalman_plant = Kalman(dt, params['Kalman']) - self.y = -1 - self.state = States.INIT - self.use_kalman = True - self.pid_hold.set_params(params['Hold']) - self.pid_rate.set_params(params['Heat']) self.model = Pot(dt, model_params) - self.is_startup = True + + self.theta_ist_plant = 0 + self.dtheta_ist_plant = 0 + self.theta_ist_model = 0 + self.dtheta_ist_model = 0 + self.theta_ist_model_delay = 0 + self.dtheta_ist_model_delay = 0 def set_model_power(self, power): self.model.set_power(power) - def set_theta_ist(self, value): - self.theta_ist_set = value - if self.is_startup: - self.is_startup = False - self.kalman_model.initial((value, 0)) - self.kalman_model_delay.initial((value, 0)) - self.kalman_plant.initial((value, 0)) + def init_kalman(self, value): + self.kalman_model.initial((value, 0)) + self.kalman_model_delay.initial((value, 0)) + self.kalman_plant.initial((value, 0)) - def get_theta_ist(self): - return self.theta_ist + def on_state_entered(self, state): + if state == States.HEAT: + self.model.initial(self.theta_ist) + self.kalman_model.initial((self.theta_ist, 0)) + self.kalman_model_delay.initial((self.theta_ist, 0)) - def set_heatrate_ist(self, value): - self.heatrate_ist_set = value - - def get_heatrate_ist(self): - return self.heatrate_ist - - def set_theta_soll(self, value): - self.theta_soll_set = value - - def get_theta_soll(self): - return self.theta_soll - - def get_theta_soll_set(self): - return self.theta_soll_set - - def set_heatrate_soll(self, value): - self.heatrate_soll_set = value - - def get_heatrate_soll(self): - return self.heatrate_soll - - def get_heatrate_soll_set(self): - return self.heatrate_soll_set + def post_pid(self): + self.model.process() def process(self): # Process Kalman of Plant @@ -114,54 +83,6 @@ class TempController(APid): self.process_fsm(diff) self.process_pid(theta_err, heatrate_err) - def process_fsm(self, diff): - # Process state - state_next = self.state - if self.state == States.INIT: - if not self.is_startup: - state_next = States.IDLE - elif self.state == States.IDLE: - if diff >= THRESH_IDLE_HEAT: - state_next = States.HEAT - self.pid_rate.reset() - elif diff >= -THRESH_IDLE_HOLD: - state_next = States.HOLD - self.pid_rate.reset() - elif self.state == States.HOLD: - if diff >= THRESH_HOLD_HEAT: - state_next = States.HEAT - self.pid_rate.reset() - elif diff <= -THRESH_HOLD_IDLE: - state_next = States.IDLE - elif self.state == States.HEAT: - if diff <= -THRESH_HEAT_IDLE: - state_next = States.IDLE - elif diff <= THRESH_HEAT_HOLD: - state_next = States.HOLD - self.pid_hold.reset() - - if state_next != self.state: - self.state = state_next - print("New state = {}".format(state_next)) - if state_next == States.HEAT: - self.model.initial(self.theta_ist) - self.kalman_model.initial((self.theta_ist, 0)) - self.kalman_model_delay.initial((self.theta_ist, 0)) - - def process_pid(self, theta_err, heatrate_err): - self.pid_hold.process(theta_err, -self.theta_ist) - self.pid_rate.process(heatrate_err, -self.heatrate_ist) - - if self.state == States.IDLE: - self.y = 0 - else: - self.y = self.pid_rate.get_y() - - self.model.process() - - def get_power(self): - return self.y - if __name__ == '__main__': dt = 1.0 @@ -246,4 +167,3 @@ if __name__ == '__main__': show() print("End of program") - From 1bf3929b6020a2c5f62dbab7477bda7932252467 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 21:06:35 +0200 Subject: [PATCH 02/11] Drop model_params from base, enable Smith predictor delay correction TempControllerBase no longer threads model_params through (only the Smith subclass needs it, for its own Pot model and Kalman filters). Enable the Smith predictor's actual delay-compensated error term (theta_err now uses theta_ist_plant - theta_ist_model_delay + theta_ist_model instead of the plain plant reading), which is the correction this controller is named for. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/temp_controller.py | 4 ++-- components/pid/temp_controller_base.py | 3 +-- components/pid/temp_controller_smith.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/components/pid/temp_controller.py b/components/pid/temp_controller.py index ac769cb..f7146e4 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -7,8 +7,8 @@ import numpy as np class TempController(TempControllerBase): - def __init__(self, dt, params, model_params=None): - TempControllerBase.__init__(self, dt, params, model_params) + def __init__(self, dt, params): + TempControllerBase.__init__(self, dt, params) self.kalman = Kalman(dt, params['Kalman']) def init_kalman(self, value): diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index 828c6e8..c81cae4 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -5,7 +5,7 @@ from components.pid.tc_constants import States, THRESH_HOLD_IDLE, THRESH_HOLD_HE class TempControllerBase(APid): - def __init__(self, dt, params, model_params=None): + def __init__(self, dt, params): APid.__init__(self) self.pid_hold = Pid(dt) self.pid_rate = Pid(dt) @@ -17,7 +17,6 @@ class TempControllerBase(APid): self.theta_ist = 0 self.heatrate_ist = 0 self.params = params - self.model_params = model_params self.y = -1 self.state = States.INIT self.use_kalman = True diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index b2943fd..e1dbe19 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -8,7 +8,7 @@ import numpy as np class TempController(TempControllerBase): def __init__(self, dt, params, model_params): - TempControllerBase.__init__(self, dt, params, model_params) + TempControllerBase.__init__(self, dt, params) self.kalman_model = Kalman(dt, params['Kalman']) self.kalman_model_delay = Kalman(dt, params['Kalman']) self.kalman_plant = Kalman(dt, params['Kalman']) @@ -72,7 +72,7 @@ class TempController(TempControllerBase): self.heatrate_soll = self.heatrate_soll_set * self.pid_hold.get_y() - if 0: + if 1: theta_err = self.theta_soll_set - (theta_ist_plant - theta_ist_model_delay + theta_ist_model) else: theta_err = self.theta_soll_set - theta_ist_plant From 88357b31f63867e3c4e598ee15092367eb2e11f5 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 21:24:09 +0200 Subject: [PATCH 03/11] Remove PyQt5.Qwt dependency from the GUI client PyQt5.Qwt isn't pip-installable; it only existed via the apt package python3-pyqt5.qwt, so pip-installing client/requirements.txt's plain PyPI PyQt5 always crashed with "No module named 'PyQt5.Qwt'". Qwt was only used for 3 QwtSlider widgets (Slider_temp_soll, Slider_pwr_soll, Slider_speed_soll). Swap them for stock QSlider in brewpi.ui, regenerate brewpi_win.py via pyuic5, and update brewpi_gui.py's setLowerBound/setUpperBound calls to QSlider's integer-only setMinimum/setMaximum. The GUI now runs from a plain `pip install -r client/requirements.txt` venv with no system PyQt5 packages required. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- client/brewpi.ui | 67 ++++++++++++++------------------------------ client/brewpi_gui.py | 14 ++++----- client/brewpi_win.py | 37 +++++++++++------------- 4 files changed, 45 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index ee8b75d..a9b8e61 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ commands back on (e.g. `{"TempCtrl": {"Soll": {"Temp": 65}}}`). - Python 3.8+ - Server (`brewpi/requirements.txt`): `numpy`, `scipy`, `websockets`, `dpath`, and `pyserial`/`spidev` if using real hardware backends. -- Client (`client/requirements.txt`): `PyQt5` (and `PyQt5.Qwt` for plotting). +- Client (`client/requirements.txt`): `PyQt5`. Install with: diff --git a/client/brewpi.ui b/client/brewpi.ui index 90641b6..5462c6c 100644 --- a/client/brewpi.ui +++ b/client/brewpi.ui @@ -517,7 +517,7 @@ Controller - + 20 @@ -526,20 +526,14 @@ 281 - - 0.000000000000000 + + 0 - - 100.000000000000000 + + 100 - - 7 - - - 3 - - - 0.000000000000000 + + Qt::Vertical @@ -600,7 +594,7 @@ Heater - + 10 @@ -609,20 +603,14 @@ 281 - - 0.000000000000000 + + 0 - - 100.000000000000000 + + 100 - - 7 - - - 3 - - - 0.000000000000000 + + Qt::Vertical @@ -674,7 +662,7 @@ Activate - + 40 @@ -683,20 +671,14 @@ 281 - - 0.000000000000000 + + 0 - - 100.000000000000000 + + 100 - - 7 - - - 3 - - - 0.000000000000000 + + Qt::Vertical @@ -3745,13 +3727,6 @@ - - - QwtSlider - QWidget -
qwt_slider.h
-
-
diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 56dbb27..79333f9 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -105,7 +105,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): if 'Temp' in submsg: self.lcdNumber_temp_soll.display(str(submsg['Temp'])) if self.slider_temp_soll_initial_update: - self.Slider_temp_soll.setValue(submsg['Temp']) + self.Slider_temp_soll.setValue(int(round(submsg['Temp']))) self.slider_temp_soll_initial_update = False if 'Rate' in submsg: subsubmsg = submsg['Rate'] @@ -133,14 +133,14 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): elif "Power" in key: self.lcdNumber_power_heater.display(msg['Power']) if self.slider_pwr_initial_update: - self.Slider_pwr_soll.setValue(msg['Power']) + self.Slider_pwr_soll.setValue(int(round(msg['Power']))) self.slider_pwr_initial_update = False elif "Capabilities" in key: submsg = msg['Capabilities'] if "Power" in submsg: submsg = submsg['Power'] - self.Slider_pwr_soll.setLowerBound(submsg['Min']) - self.Slider_pwr_soll.setUpperBound(submsg['Max']) + self.Slider_pwr_soll.setMinimum(int(submsg['Min'])) + self.Slider_pwr_soll.setMaximum(int(submsg['Max'])) def on_stirrer_changed(self, msg): print("on_stirrer_changed {}".format(msg)) @@ -151,14 +151,14 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.checkbox_stirrer_activate_initial_update = False elif "Speed" in key: if self.slider_speed_initial_update: - self.Slider_speed_soll.setValue(msg['Speed']) + self.Slider_speed_soll.setValue(int(round(msg['Speed']))) self.slider_speed_initial_update = False elif "Capabilities" in key: submsg = msg['Capabilities'] if "Power" in submsg: submsg = submsg['Power'] - self.Slider_speed_soll.setLowerBound(submsg['Min']) - self.Slider_speed_soll.setUpperBound(submsg['Max']) + self.Slider_speed_soll.setMinimum(int(submsg['Min'])) + self.Slider_speed_soll.setMaximum(int(submsg['Max'])) def closeEvent(self, event): print("Exitting gracefully!") diff --git a/client/brewpi_win.py b/client/brewpi_win.py index 93f3f51..0c811d6 100644 --- a/client/brewpi_win.py +++ b/client/brewpi_win.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'brewpi.ui' # -# Created by: PyQt5 UI code generator 5.14.1 +# Created by: PyQt5 UI code generator 5.15.10 # -from PyQt5.Qwt import * +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -189,13 +190,11 @@ class Ui_MainWindow(object): self.groupBox_2.setSizePolicy(sizePolicy) self.groupBox_2.setMinimumSize(QtCore.QSize(0, 0)) self.groupBox_2.setObjectName("groupBox_2") - self.Slider_temp_soll = QwtSlider(self.groupBox_2) + self.Slider_temp_soll = QtWidgets.QSlider(self.groupBox_2) self.Slider_temp_soll.setGeometry(QtCore.QRect(20, 70, 63, 281)) - self.Slider_temp_soll.setLowerBound(0.0) - self.Slider_temp_soll.setUpperBound(100.0) - self.Slider_temp_soll.setScaleMaxMajor(7) - self.Slider_temp_soll.setScaleMaxMinor(3) - self.Slider_temp_soll.setScaleStepSize(0.0) + self.Slider_temp_soll.setMinimum(0) + self.Slider_temp_soll.setMaximum(100) + self.Slider_temp_soll.setOrientation(QtCore.Qt.Vertical) self.Slider_temp_soll.setObjectName("Slider_temp_soll") self.label_6 = QtWidgets.QLabel(self.groupBox_2) self.label_6.setGeometry(QtCore.QRect(10, 370, 67, 31)) @@ -214,13 +213,11 @@ class Ui_MainWindow(object): self.horizontalLayout.addWidget(self.groupBox_2) self.groupBox = QtWidgets.QGroupBox(self.horizontalTabWidgetPage1) self.groupBox.setObjectName("groupBox") - self.Slider_pwr_soll = QwtSlider(self.groupBox) + self.Slider_pwr_soll = QtWidgets.QSlider(self.groupBox) self.Slider_pwr_soll.setGeometry(QtCore.QRect(10, 70, 81, 281)) - self.Slider_pwr_soll.setLowerBound(0.0) - self.Slider_pwr_soll.setUpperBound(100.0) - self.Slider_pwr_soll.setScaleMaxMajor(7) - self.Slider_pwr_soll.setScaleMaxMinor(3) - self.Slider_pwr_soll.setScaleStepSize(0.0) + self.Slider_pwr_soll.setMinimum(0) + self.Slider_pwr_soll.setMaximum(100) + self.Slider_pwr_soll.setOrientation(QtCore.Qt.Vertical) self.Slider_pwr_soll.setObjectName("Slider_pwr_soll") self.checkbox_heater_activate = QtWidgets.QCheckBox(self.groupBox) self.checkbox_heater_activate.setGeometry(QtCore.QRect(30, 370, 81, 31)) @@ -235,13 +232,11 @@ class Ui_MainWindow(object): self.checkbox_stirrer_activate = QtWidgets.QCheckBox(self.groupBox_3) self.checkbox_stirrer_activate.setGeometry(QtCore.QRect(40, 370, 81, 23)) self.checkbox_stirrer_activate.setObjectName("checkbox_stirrer_activate") - self.Slider_speed_soll = QwtSlider(self.groupBox_3) + self.Slider_speed_soll = QtWidgets.QSlider(self.groupBox_3) self.Slider_speed_soll.setGeometry(QtCore.QRect(40, 70, 63, 281)) - self.Slider_speed_soll.setLowerBound(0.0) - self.Slider_speed_soll.setUpperBound(100.0) - self.Slider_speed_soll.setScaleMaxMajor(7) - self.Slider_speed_soll.setScaleMaxMinor(3) - self.Slider_speed_soll.setScaleStepSize(0.0) + self.Slider_speed_soll.setMinimum(0) + self.Slider_speed_soll.setMaximum(100) + self.Slider_speed_soll.setOrientation(QtCore.Qt.Vertical) self.Slider_speed_soll.setObjectName("Slider_speed_soll") self.label_11 = QtWidgets.QLabel(self.groupBox_3) self.label_11.setGeometry(QtCore.QRect(0, 30, 141, 31)) @@ -1277,7 +1272,7 @@ class Ui_MainWindow(object): self.label_5.setText(_translate("MainWindow", "Power [W]")) self.label_7.setText(_translate("MainWindow", "State")) self.label_state.setText(_translate("MainWindow", "State")) - self.plainTextUri.setPlainText(_translate("MainWindow", "ws://localhost:8765")) + self.plainTextUri.setPlainText(_translate("MainWindow", "ws://brewpi:8765")) self.label_8.setText(_translate("MainWindow", "Host")) self.menu_File.setTitle(_translate("MainWindow", "&File")) self.menu_Help.setTitle(_translate("MainWindow", "&Help")) From 44372364c4f72a463006eac5ef781dfd32731fdd Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 21:28:22 +0200 Subject: [PATCH 04/11] Rename brewpi_win.py to main_window.py Pure rename of the pyuic5-generated UI module plus its one import site in brewpi_gui.py; regenerated from brewpi.ui, no content changes. Co-Authored-By: Claude Sonnet 4.6 --- client/brewpi_gui.py | 2 +- client/{brewpi_win.py => main_window.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename client/{brewpi_win.py => main_window.py} (100%) diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 79333f9..c0b16ad 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from brewpi_win import Ui_MainWindow +from main_window import Ui_MainWindow from PyQt5 import QtWidgets, QtGui import sys from ws.client.ws_client import WsClient diff --git a/client/brewpi_win.py b/client/main_window.py similarity index 100% rename from client/brewpi_win.py rename to client/main_window.py From b2a56522909758a5ea24b3e47195696a804bc37c Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 21:31:23 +0200 Subject: [PATCH 05/11] Add design-flaw backlog for components/pid Tracks open issues from a review that weren't fixed as part of this branch's Smith-predictor and dedup work: non-configurable FSM thresholds, Pid.scale()'s gain-scheduling hack, shared Kalman tuning across the Smith controller's 3 filters, matplotlib imported at module level in production code, no automated tests, and unchecked config access. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/TODO.md | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 components/pid/TODO.md diff --git a/components/pid/TODO.md b/components/pid/TODO.md new file mode 100644 index 0000000..f4ac028 --- /dev/null +++ b/components/pid/TODO.md @@ -0,0 +1,48 @@ +# components/pid design backlog + +Findings from a design review, not yet acted on. See git history on +`claude_refactor` for items already fixed (Smith predictor's delay +correction enabled, the two TempController classes deduplicated into +`temp_controller_base.py`). + +- [ ] **FSM thresholds aren't configurable.** `tc_constants.py`'s + `THRESH_HOLD_IDLE`, `THRESH_HOLD_HEAT`, `THRESH_IDLE_HEAT`, + `THRESH_IDLE_HOLD`, `THRESH_HEAT_HOLD`, `THRESH_HEAT_IDLE` are + hardcoded module-level constants shared by every controller + instance. Move them into `config.json`'s `TempCtrl` section like the + PID gains, so mash schedules can tune hysteresis without code edits. + +- [ ] **`Pid.scale()` is a gain-scheduling hack, not anti-windup.** + `pid.py`'s `scale(k)` multiplies every gain by `1/heatrate_soll_set` + (called from `temp_controller*.py`'s `process()`), but it's framed + as overshoot compensation, conflated with the real anti-windup logic + a few lines below. `self.k` also never resets in `reset()`, so a + stale scale factor can survive a state-transition reset. Consider + moving this scheduling logic out of the generic `Pid` class and into + the temp controller, and resetting `k` in `reset()`. + +- [ ] **Three Kalman filters share one tuning.** In + `temp_controller_smith.py`, `kalman_model`, `kalman_model_delay`, + and `kalman_plant` are all constructed with the same + `params['Kalman']` despite tracking signals with different noise + characteristics. Allow independent `var_P`/`var_Q`/`var_R` per + filter in config. + +- [ ] **matplotlib imported at module level in production code.** + `temp_controller.py`, `temp_controller_smith.py`, and `kalman.py` + all `from matplotlib.pyplot import ...` just to support their + `__main__` self-test plots, which couples the live server's import + graph (and its dependency list) to a GUI plotting library it never + uses at runtime. Move the plotting demos into separate scripts. + +- [ ] **No automated tests.** The `__main__` blocks (and + `kalman_eval.py`) are manual, eyeballed-plot harnesses. Add + regression tests for FSM transitions, anti-windup clamping, and + Kalman convergence — this code drives a physical heater. + +- [ ] **Config access is unchecked `dict[key]` everywhere.** + `params['Kalman']`, `model_params['gain']`, etc., throughout, with + no schema validation at load time. We already hit this bug class + once (`config.json.sim`'s `gain`/`Model` nesting mismatch). A small + schema/dataclass validation layer at config-load would surface + errors immediately instead of mid-`__init__`. From 4256622e9da9d00731d1343e6f8dde66e2803c10 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 22:09:39 +0200 Subject: [PATCH 06/11] Make FSM hysteresis thresholds configurable THRESH_HOLD_IDLE/HOLD_HEAT/IDLE_HEAT/IDLE_HOLD/HEAT_HOLD/HEAT_IDLE in tc_constants.py were hardcoded module-level constants shared by every controller instance, so tuning the IDLE/HOLD/HEAT hysteresis required a code change. Replace them with DEFAULT_THRESHOLDS plus an optional TempCtrl.Thresholds config section, merged at construction time in TempControllerBase.__init__ so configs that omit the section keep behaving exactly as before. Documented the new section in config.json.templ; left config.json.sim without it to exercise the default-fallback path. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/TODO.md | 11 +++++------ components/pid/tc_constants.py | 14 ++++++++------ components/pid/temp_controller_base.py | 16 ++++++++-------- config.json.templ | 8 ++++++++ 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/components/pid/TODO.md b/components/pid/TODO.md index f4ac028..460adc1 100644 --- a/components/pid/TODO.md +++ b/components/pid/TODO.md @@ -5,12 +5,11 @@ Findings from a design review, not yet acted on. See git history on correction enabled, the two TempController classes deduplicated into `temp_controller_base.py`). -- [ ] **FSM thresholds aren't configurable.** `tc_constants.py`'s - `THRESH_HOLD_IDLE`, `THRESH_HOLD_HEAT`, `THRESH_IDLE_HEAT`, - `THRESH_IDLE_HOLD`, `THRESH_HEAT_HOLD`, `THRESH_HEAT_IDLE` are - hardcoded module-level constants shared by every controller - instance. Move them into `config.json`'s `TempCtrl` section like the - PID gains, so mash schedules can tune hysteresis without code edits. +- [x] **FSM thresholds aren't configurable.** Moved into an optional + `TempCtrl.Thresholds` config section (`HoldIdle`, `HoldHeat`, + `IdleHeat`, `IdleHold`, `HeatHold`, `HeatIdle`), merged over + `DEFAULT_THRESHOLDS` in `tc_constants.py` so existing configs without + the section keep working unchanged. - [ ] **`Pid.scale()` is a gain-scheduling hack, not anti-windup.** `pid.py`'s `scale(k)` multiplies every gain by `1/heatrate_soll_set` diff --git a/components/pid/tc_constants.py b/components/pid/tc_constants.py index 9703336..0809cd5 100644 --- a/components/pid/tc_constants.py +++ b/components/pid/tc_constants.py @@ -8,12 +8,14 @@ class States(Enum): HOLD = 2 -THRESH_HOLD_IDLE = 0.1 -THRESH_HOLD_HEAT = 1.0 -THRESH_IDLE_HEAT = 1.0 -THRESH_IDLE_HOLD = 0.1 -THRESH_HEAT_HOLD = 1.0 -THRESH_HEAT_IDLE = 1.0 +DEFAULT_THRESHOLDS = { + "HoldIdle": 0.1, + "HoldHeat": 1.0, + "IdleHeat": 1.0, + "IdleHold": 0.1, + "HeatHold": 1.0, + "HeatIdle": 1.0 +} class Test: diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index c81cae4..fa03fdf 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -1,7 +1,6 @@ from components import APid from components.pid.pid import Pid -from components.pid.tc_constants import States, THRESH_HOLD_IDLE, THRESH_HOLD_HEAT, \ - THRESH_IDLE_HEAT, THRESH_IDLE_HOLD, THRESH_HEAT_HOLD, THRESH_HEAT_IDLE +from components.pid.tc_constants import States, DEFAULT_THRESHOLDS class TempControllerBase(APid): @@ -17,6 +16,7 @@ class TempControllerBase(APid): self.theta_ist = 0 self.heatrate_ist = 0 self.params = params + self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})} self.y = -1 self.state = States.INIT self.use_kalman = True @@ -72,22 +72,22 @@ class TempControllerBase(APid): if not self.is_startup: state_next = States.IDLE elif self.state == States.IDLE: - if diff >= THRESH_IDLE_HEAT: + if diff >= self.thresholds['IdleHeat']: state_next = States.HEAT self.pid_rate.reset() - elif diff >= -THRESH_IDLE_HOLD: + elif diff >= -self.thresholds['IdleHold']: state_next = States.HOLD self.pid_rate.reset() elif self.state == States.HOLD: - if diff >= THRESH_HOLD_HEAT: + if diff >= self.thresholds['HoldHeat']: state_next = States.HEAT self.pid_rate.reset() - elif diff <= -THRESH_HOLD_IDLE: + elif diff <= -self.thresholds['HoldIdle']: state_next = States.IDLE elif self.state == States.HEAT: - if diff <= -THRESH_HEAT_IDLE: + if diff <= -self.thresholds['HeatIdle']: state_next = States.IDLE - elif diff <= THRESH_HEAT_HOLD: + elif diff <= self.thresholds['HeatHold']: state_next = States.HOLD self.pid_hold.reset() diff --git a/config.json.templ b/config.json.templ index 889b62c..c0a323c 100644 --- a/config.json.templ +++ b/config.json.templ @@ -25,6 +25,14 @@ "ki": 0.01, "kd": 0.0, "kt": 1.5 + }, + "Thresholds": { + "HoldIdle": 0.1, + "HoldHeat": 1.0, + "IdleHeat": 1.0, + "IdleHold": 0.1, + "HeatHold": 1.0, + "HeatIdle": 1.0 } }, "Model": { From 9713d66e18d5298219f4b01f589cf33316290fcf Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Thu, 18 Jun 2026 23:15:32 +0200 Subject: [PATCH 07/11] Add design-flaw backlog for components/plant Review of pot.py found a likely root cause for sim-vs-real control mismatches: the heat-loss term carries the wrong units, masking ambient cooling in simulation. Also notes the dropped sensor-noise term, hardcoded ambient temp, and lag-vs-dead-time modeling gap. Co-Authored-By: Claude Sonnet 4.6 --- components/plant/TODO.md | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 components/plant/TODO.md diff --git a/components/plant/TODO.md b/components/plant/TODO.md new file mode 100644 index 0000000..19a8d4e --- /dev/null +++ b/components/plant/TODO.md @@ -0,0 +1,51 @@ +# components/plant design backlog + +Findings from reviewing `pot.py`'s water-bath model against real-plant +behavior (sim/real control mismatch reported by user). Not yet acted on. + +- [ ] **Heat-loss term has wrong units.** `pot.py:37`: + `leak = 1/self.M * self.L * (self.theta_amb - self.temp)/self.theta_amb`. + The power term right above it is `power/(self.M * self.C)` (W / + (J/K) -> K/s). `leak` should follow the same pattern — + `self.L * (self.theta_amb - self.temp) / (self.M * self.C)` — but + instead it's missing `/C` and divides by `theta_amb` (~20) instead, + which isn't a physically meaningful normalization. With + `C=4190` this suppresses ambient heat loss by ~3-4 orders of + magnitude versus what the power term implies, so the simulated pot + barely cools at all. The Smith predictor's internal model is also a + `Pot` instance with the identical bug, so this never showed up in + sim-vs-sim testing — only against the real plant. Likely the + primary cause of the sim/real control mismatch. + +- [ ] **`kn` is loaded but never applied.** `pot.py:18` reads + `params['kn']` but nothing in `process()`/`get_temperature()` uses + it. Git history shows it used to add Gaussian sensor noise + (`self.kn * np.random.normal(...)`) to the output, since commented + out and then dropped during the heat-diffusion refactor. Sim is + currently fully deterministic/noise-free, making the Kalman filter + and PID derivative term look better-behaved in sim than they will + against a real noisy sensor. Re-enable as measurement noise (and + decide if process noise is also wanted). + +- [ ] **`theta_amb` is hardcoded, not configured.** `brewpi.py:40` + passes a literal `20` instead of reading it from config or a live + ambient sensor. Real ambient temperature drifts and is never + modeled as a disturbance, so the controller's robustness to it is + untested. + +- [ ] **Thermal lag modeled as single-pole lag, not transport delay.** + `HeatDiffusion` (`heat_diffusion.py`) implements `alpha=dt/Td` + exponential smoothing, not true dead time. There's already a + `Delay` class (`plant/delay.py`) for pure transport delay, dropped + in favor of `HeatDiffusion` in commit `16cb3d3`. If the real pot's + heater-to-sensor coupling behaves more like dead time (heat must + propagate through the water before reaching the sensor) than a + single lag, `Td` won't represent the same dynamic in sim vs. reality, + and the Smith predictor's delay compensation (which assumes `Td` + matches the real plant) will be off. + +- [ ] **No actuator/process realism.** No heater power saturation + enforced inside `Pot` itself (handled ad hoc in test harnesses), no + evaporation/lid heat loss, no varying thermal mass `M` as volume + changes (e.g. boil-off, adding water/grain) — `M` is a fixed + constant for the whole simulation. \ No newline at end of file From 81e66dbcd17d12259065a15f7d488c46fb77b272 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 19 Jun 2026 08:21:47 +0200 Subject: [PATCH 08/11] Move PID matplotlib demo harnesses out of production modules temp_controller.py, temp_controller_smith.py, and kalman.py imported matplotlib at module level just to support eyeballed-plot __main__ blocks, coupling the live server's import graph to a GUI plotting lib it never uses at runtime. Relocate those demos (and kalman_eval.py) to scripts/demos/pid/ and strip the now-unused imports/__main__ blocks from the production files. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/kalman.py | 55 ------------ components/pid/temp_controller.py | 76 ---------------- components/pid/temp_controller_smith.py | 87 ------------------ scripts/__init__.py | 0 scripts/demos/__init__.py | 0 scripts/demos/pid/__init__.py | 0 scripts/demos/pid/demo_kalman.py | 56 ++++++++++++ .../demos/pid/demo_kalman_eval.py | 0 scripts/demos/pid/demo_temp_controller.py | 77 ++++++++++++++++ .../demos/pid/demo_temp_controller_smith.py | 90 +++++++++++++++++++ 10 files changed, 223 insertions(+), 218 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 scripts/demos/__init__.py create mode 100644 scripts/demos/pid/__init__.py create mode 100644 scripts/demos/pid/demo_kalman.py rename components/pid/kalman_eval.py => scripts/demos/pid/demo_kalman_eval.py (100%) create mode 100644 scripts/demos/pid/demo_temp_controller.py create mode 100644 scripts/demos/pid/demo_temp_controller_smith.py diff --git a/components/pid/kalman.py b/components/pid/kalman.py index cb90568..c31358f 100644 --- a/components/pid/kalman.py +++ b/components/pid/kalman.py @@ -1,6 +1,5 @@ import numpy as np from numpy.linalg import inv -from matplotlib.pyplot import plot, figure, subplot, grid, show class Kalman: @@ -76,57 +75,3 @@ class Kalman: I = np.eye(self.N) self.P = (I - K * self.H) * P return self.X - - -# Main -if __name__ == '__main__': - dt = 1.0 - - params = { - 'var_P' : 1, - 'var_Q' : 0.0001, - 'var_R' : 0.5 - } - k = Kalman(dt, params) - - _x1 = np.empty(0) - _y1 = np.empty(0) - _x2 = np.empty(0) - _y2 = np.empty(0) - - N = int(1000/dt) - seqn = range(0, N) - - _seqn = range(0, 2*N) - X = np.array([dt, 0]) - for n in seqn: - Z = k.process_measurement(X, 0.5) - # Kalman.print("Z:", Z) - Xp = k.process(Z) - - _x1 = np.append(_x1, Z[0]) - _x2 = np.append(_x2, Z[1]) - _y1 = np.append(_y1, Xp[0]) - _y2 = np.append(_y2, Xp[1]) - - for n in seqn: - X[0] = X[0] + dt - Z = k.process_measurement(X, 0.5) - # Kalman.print("Z:", Z) - Xp = k.process(Z) - - _x1 = np.append(_x1, Z[0]) - _x2 = np.append(_x2, Z[1]) - _y1 = np.append(_y1, Xp[0]) - _y2 = np.append(_y2, Xp[1]) - - figure(1) - subplot(2, 1, 1) - plot(_seqn, _x1, 'bx', _seqn, _y1, '-r', linewidth=1) - grid(True) - subplot(2, 1, 2) - plot(_seqn, _x2, 'bx', _seqn, _y2, '-r', linewidth=1) - grid(True) - show() - - print("End of program") diff --git a/components/pid/temp_controller.py b/components/pid/temp_controller.py index f7146e4..becc777 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -1,9 +1,5 @@ -from components.plant.pot import Pot -from matplotlib.pyplot import plot, figure, subplot, grid, show, legend from components.pid import Kalman from components.pid.temp_controller_base import TempControllerBase -from components.pid.tc_constants import * -import numpy as np class TempController(TempControllerBase): @@ -36,75 +32,3 @@ class TempController(TempControllerBase): diff = self.theta_soll_set - self.theta_ist self.process_fsm(diff) self.process_pid(theta_err, heatrate_err) - - -if __name__ == '__main__': - dt = 1.0 - - temp_ist = 0 - temp_soll = 20 - ctrl = TempController(dt, Test.tc_ctrl_params) - plant = Pot(dt, Test.tc_pot_params) - _temp_ist = np.empty(0) - _temp_soll = np.empty(0) - _y = np.empty(0) - _fb = np.empty(0) - _t = np.empty(0) - _heatrate_ist_kalman = np.empty(0) - _temp_ist_kalman = np.empty(0) - a = 0.5 - fb = 0 - rho = 0.02 - temps = [{'Temp': 20, 'Duration': 1000}, {'Temp': 40, 'Duration': 1000}, {'Temp': 50, 'Duration': 1000}, {'Temp': 60, 'Duration': 1000}, {'Temp': 70, 'Duration': 1000}, {'Temp': 80, 'Duration': 1000}, {'Temp': 78, 'Duration': 1000}] - - t = 0 - for temp in temps: - temp_soll = temp['Temp'] - hold_counter = temp['Duration'] - hold = False - ctrl.set_theta_soll(temp_soll) - ctrl.set_heatrate_soll(1.0) - - while True: - if hold: - if hold_counter == 0: - break - hold_counter -= 1 - plant.process() - temp_ist = plant.get_temperature() + 0.0 * np.random.randn() - ctrl.set_theta_ist(temp_ist) - ctrl.process() - - y = 3500*ctrl.get_power() - power = max(0, y) - plant.set_power(power) - fb = plant.get_power() - if abs(temp_ist - temp_soll) < 0.1: - hold = True - - temp_ist -= rho - _temp_ist = np.append(_temp_ist, temp_ist) - _temp_soll = np.append(_temp_soll, temp_soll) - _y = np.append(_y, y) - _fb = np.append(_fb, fb) - _t = np.append(_t, t) - _heatrate_ist_kalman = np.append(_heatrate_ist_kalman, max(-1, min(3, ctrl.heatrate_ist))) - _temp_ist_kalman = np.append(_temp_ist, ctrl.theta_ist) - t += 1 - - figure(1) - subplot(3, 1, 1) - plot(_t, _temp_ist, _t, _temp_soll, 'r-', linewidth=1) - legend(["ist", "soll"]) - grid(True) - subplot(3, 1, 2) - plot(_t, _y, '-b', _t, _fb, '-r', linewidth=1) - legend(["y", "pot"]) - grid(True) - subplot(3, 1, 3) - plot(_t, _heatrate_ist_kalman, '-b', linewidth=1) - legend(["heatrate"]) - grid(True) - show() - - print("End of program") diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index e1dbe19..0fdd579 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -1,9 +1,7 @@ from components.plant.pot import Pot -from matplotlib.pyplot import plot, figure, subplot, grid, show, legend from components.pid import Kalman from components.pid.temp_controller_base import TempControllerBase from components.pid.tc_constants import * -import numpy as np class TempController(TempControllerBase): @@ -82,88 +80,3 @@ class TempController(TempControllerBase): self.process_fsm(diff) self.process_pid(theta_err, heatrate_err) - - -if __name__ == '__main__': - dt = 1.0 - - temp_soll = 20 - ctrl = TempController(dt, Test.tc_ctrl_params, Test.tc_model_params) - plant = Pot(dt, Test.tc_pot_params) - _y = np.empty(0) - _fb = np.empty(0) - _t = np.empty(0) - _temp_soll = np.empty(0) - _temp_ist_kalman = np.empty(0) - _heatrate_ist_kalman = np.empty(0) - _temp_ist_kalman_plant = np.empty(0) - _heatrate_ist_kalman_plant = np.empty(0) - _temp_ist_kalman_model = np.empty(0) - _heatrate_ist_kalman_model = np.empty(0) - a = 0.5 - fb = 0 - rho = 0.02 - temps = [{'Temp': 20, 'Duration': 1000}, {'Temp': 40, 'Duration': 1000}, {'Temp': 50, 'Duration': 1000}, {'Temp': 60, 'Duration': 1000}, {'Temp': 70, 'Duration': 1000}, {'Temp': 80, 'Duration': 1000}, {'Temp': 78, 'Duration': 1000}] - - t = 0 - - for temp in temps: - temp_soll = temp['Temp'] - hold_counter = temp['Duration'] - hold = False - ctrl.set_theta_soll(temp_soll) - ctrl.set_heatrate_soll(1.0) - - while True: - if hold: - if hold_counter == 0: - break - hold_counter -= 1 - plant.process() - temp_ist = plant.get_temperature() - ctrl.set_theta_ist(temp_ist) - ctrl.process() - - y = 3500*ctrl.get_power() - power = max(0, y) - plant.set_power(power) - fb = plant.get_power() - if abs(temp_ist - temp_soll) < 0.1: - hold = True - - _y = np.append(_y, y) - _fb = np.append(_fb, fb) - _t = np.append(_t, t) - _temp_soll = np.append(_temp_soll, temp_soll) - _temp_ist_kalman_plant = np.append(_temp_ist_kalman_plant, ctrl.theta_ist_plant) - _heatrate_ist_kalman_plant = np.append(_heatrate_ist_kalman_plant, max(-1, min(3, ctrl.dtheta_ist_plant))) - _temp_ist_kalman_model = np.append(_temp_ist_kalman_model, ctrl.theta_ist_model_delay) - _heatrate_ist_kalman_model = np.append(_heatrate_ist_kalman_model, max(-1, min(3, ctrl.dtheta_ist_model_delay))) - t += 1 - - figure(1) - subplot(3, 1, 1) - plot(_t, _temp_ist_kalman_plant, _t, _temp_soll, 'r-', linewidth=1) - legend(["ist", "soll"]) - grid(True) - subplot(3, 1, 2) - plot(_t, _y, '-b', _t, _fb, '-r', linewidth=1) - legend(["y", "pot"]) - grid(True) - subplot(3, 1, 3) - plot(_t, _heatrate_ist_kalman_plant, '-b', linewidth=1) - legend(["heatrate"]) - grid(True) - - figure(2) - subplot(2, 1, 1) - plot(_t, _temp_ist_kalman_plant, '-b', _t, _temp_ist_kalman_model, 'r-', linewidth=1) - legend(["plant", "model"]) - grid(True) - subplot(2, 1, 2) - plot(_t, _heatrate_ist_kalman_plant, '-b', _t, _heatrate_ist_kalman_model, '-r', linewidth=1) - legend(["plant", "model"]) - grid(True) - show() - - print("End of program") diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/demos/__init__.py b/scripts/demos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/demos/pid/__init__.py b/scripts/demos/pid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/demos/pid/demo_kalman.py b/scripts/demos/pid/demo_kalman.py new file mode 100644 index 0000000..da11e36 --- /dev/null +++ b/scripts/demos/pid/demo_kalman.py @@ -0,0 +1,56 @@ +import numpy as np +from matplotlib.pyplot import plot, figure, subplot, grid, show +from components.pid.kalman import Kalman + + +if __name__ == '__main__': + dt = 1.0 + + params = { + 'var_P' : 1, + 'var_Q' : 0.0001, + 'var_R' : 0.5 + } + k = Kalman(dt, params) + + _x1 = np.empty(0) + _y1 = np.empty(0) + _x2 = np.empty(0) + _y2 = np.empty(0) + + N = int(1000/dt) + seqn = range(0, N) + + _seqn = range(0, 2*N) + X = np.array([dt, 0]) + for n in seqn: + Z = k.process_measurement(X, 0.5) + # Kalman.print("Z:", Z) + Xp = k.process(Z) + + _x1 = np.append(_x1, Z[0]) + _x2 = np.append(_x2, Z[1]) + _y1 = np.append(_y1, Xp[0]) + _y2 = np.append(_y2, Xp[1]) + + for n in seqn: + X[0] = X[0] + dt + Z = k.process_measurement(X, 0.5) + # Kalman.print("Z:", Z) + Xp = k.process(Z) + + _x1 = np.append(_x1, Z[0]) + _x2 = np.append(_x2, Z[1]) + _y1 = np.append(_y1, Xp[0]) + _y2 = np.append(_y2, Xp[1]) + + figure(1) + subplot(2, 1, 1) + plot(_seqn, _x1, 'bx', _seqn, _y1, '-r', linewidth=1) + grid(True) + subplot(2, 1, 2) + plot(_seqn, _x2, 'bx', _seqn, _y2, '-r', linewidth=1) + grid(True) + show() + + print("End of program") diff --git a/components/pid/kalman_eval.py b/scripts/demos/pid/demo_kalman_eval.py similarity index 100% rename from components/pid/kalman_eval.py rename to scripts/demos/pid/demo_kalman_eval.py diff --git a/scripts/demos/pid/demo_temp_controller.py b/scripts/demos/pid/demo_temp_controller.py new file mode 100644 index 0000000..60e3b42 --- /dev/null +++ b/scripts/demos/pid/demo_temp_controller.py @@ -0,0 +1,77 @@ +import numpy as np +from matplotlib.pyplot import plot, figure, subplot, grid, show, legend +from components.plant.pot import Pot +from components.pid.temp_controller import TempController +from components.pid.tc_constants import Test + + +if __name__ == '__main__': + dt = 1.0 + + temp_ist = 0 + temp_soll = 20 + ctrl = TempController(dt, Test.tc_ctrl_params) + plant = Pot(dt, Test.tc_pot_params) + _temp_ist = np.empty(0) + _temp_soll = np.empty(0) + _y = np.empty(0) + _fb = np.empty(0) + _t = np.empty(0) + _heatrate_ist_kalman = np.empty(0) + _temp_ist_kalman = np.empty(0) + a = 0.5 + fb = 0 + rho = 0.02 + temps = [{'Temp': 20, 'Duration': 1000}, {'Temp': 40, 'Duration': 1000}, {'Temp': 50, 'Duration': 1000}, {'Temp': 60, 'Duration': 1000}, {'Temp': 70, 'Duration': 1000}, {'Temp': 80, 'Duration': 1000}, {'Temp': 78, 'Duration': 1000}] + + t = 0 + for temp in temps: + temp_soll = temp['Temp'] + hold_counter = temp['Duration'] + hold = False + ctrl.set_theta_soll(temp_soll) + ctrl.set_heatrate_soll(1.0) + + while True: + if hold: + if hold_counter == 0: + break + hold_counter -= 1 + plant.process() + temp_ist = plant.get_temperature() + 0.0 * np.random.randn() + ctrl.set_theta_ist(temp_ist) + ctrl.process() + + y = 3500*ctrl.get_power() + power = max(0, y) + plant.set_power(power) + fb = plant.get_power() + if abs(temp_ist - temp_soll) < 0.1: + hold = True + + temp_ist -= rho + _temp_ist = np.append(_temp_ist, temp_ist) + _temp_soll = np.append(_temp_soll, temp_soll) + _y = np.append(_y, y) + _fb = np.append(_fb, fb) + _t = np.append(_t, t) + _heatrate_ist_kalman = np.append(_heatrate_ist_kalman, max(-1, min(3, ctrl.heatrate_ist))) + _temp_ist_kalman = np.append(_temp_ist, ctrl.theta_ist) + t += 1 + + figure(1) + subplot(3, 1, 1) + plot(_t, _temp_ist, _t, _temp_soll, 'r-', linewidth=1) + legend(["ist", "soll"]) + grid(True) + subplot(3, 1, 2) + plot(_t, _y, '-b', _t, _fb, '-r', linewidth=1) + legend(["y", "pot"]) + grid(True) + subplot(3, 1, 3) + plot(_t, _heatrate_ist_kalman, '-b', linewidth=1) + legend(["heatrate"]) + grid(True) + show() + + print("End of program") diff --git a/scripts/demos/pid/demo_temp_controller_smith.py b/scripts/demos/pid/demo_temp_controller_smith.py new file mode 100644 index 0000000..5dd70dc --- /dev/null +++ b/scripts/demos/pid/demo_temp_controller_smith.py @@ -0,0 +1,90 @@ +import numpy as np +from matplotlib.pyplot import plot, figure, subplot, grid, show, legend +from components.plant.pot import Pot +from components.pid.temp_controller_smith import TempController +from components.pid.tc_constants import Test + + +if __name__ == '__main__': + dt = 1.0 + + temp_soll = 20 + ctrl = TempController(dt, Test.tc_ctrl_params, Test.tc_model_params) + plant = Pot(dt, Test.tc_pot_params) + _y = np.empty(0) + _fb = np.empty(0) + _t = np.empty(0) + _temp_soll = np.empty(0) + _temp_ist_kalman = np.empty(0) + _heatrate_ist_kalman = np.empty(0) + _temp_ist_kalman_plant = np.empty(0) + _heatrate_ist_kalman_plant = np.empty(0) + _temp_ist_kalman_model = np.empty(0) + _heatrate_ist_kalman_model = np.empty(0) + a = 0.5 + fb = 0 + rho = 0.02 + temps = [{'Temp': 20, 'Duration': 1000}, {'Temp': 40, 'Duration': 1000}, {'Temp': 50, 'Duration': 1000}, {'Temp': 60, 'Duration': 1000}, {'Temp': 70, 'Duration': 1000}, {'Temp': 80, 'Duration': 1000}, {'Temp': 78, 'Duration': 1000}] + + t = 0 + + for temp in temps: + temp_soll = temp['Temp'] + hold_counter = temp['Duration'] + hold = False + ctrl.set_theta_soll(temp_soll) + ctrl.set_heatrate_soll(1.0) + + while True: + if hold: + if hold_counter == 0: + break + hold_counter -= 1 + plant.process() + temp_ist = plant.get_temperature() + ctrl.set_theta_ist(temp_ist) + ctrl.process() + + y = 3500*ctrl.get_power() + power = max(0, y) + plant.set_power(power) + fb = plant.get_power() + if abs(temp_ist - temp_soll) < 0.1: + hold = True + + _y = np.append(_y, y) + _fb = np.append(_fb, fb) + _t = np.append(_t, t) + _temp_soll = np.append(_temp_soll, temp_soll) + _temp_ist_kalman_plant = np.append(_temp_ist_kalman_plant, ctrl.theta_ist_plant) + _heatrate_ist_kalman_plant = np.append(_heatrate_ist_kalman_plant, max(-1, min(3, ctrl.dtheta_ist_plant))) + _temp_ist_kalman_model = np.append(_temp_ist_kalman_model, ctrl.theta_ist_model_delay) + _heatrate_ist_kalman_model = np.append(_heatrate_ist_kalman_model, max(-1, min(3, ctrl.dtheta_ist_model_delay))) + t += 1 + + figure(1) + subplot(3, 1, 1) + plot(_t, _temp_ist_kalman_plant, _t, _temp_soll, 'r-', linewidth=1) + legend(["ist", "soll"]) + grid(True) + subplot(3, 1, 2) + plot(_t, _y, '-b', _t, _fb, '-r', linewidth=1) + legend(["y", "pot"]) + grid(True) + subplot(3, 1, 3) + plot(_t, _heatrate_ist_kalman_plant, '-b', linewidth=1) + legend(["heatrate"]) + grid(True) + + figure(2) + subplot(2, 1, 1) + plot(_t, _temp_ist_kalman_plant, '-b', _t, _temp_ist_kalman_model, 'r-', linewidth=1) + legend(["plant", "model"]) + grid(True) + subplot(2, 1, 2) + plot(_t, _heatrate_ist_kalman_plant, '-b', _t, _heatrate_ist_kalman_model, '-r', linewidth=1) + legend(["plant", "model"]) + grid(True) + show() + + print("End of program") From 5c7cffa3a720dba11c03785ee8c2281c390a24fa Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 19 Jun 2026 14:21:23 +0200 Subject: [PATCH 09/11] [Pot] - remove HeatDiffusion - use delay line for heat propagation modeling [Temp Controller] - remove Kalman --- components/pid/temp_controller.py | 21 +++----- components/pid/temp_controller_base.py | 5 -- components/plant/pot.py | 59 ++++++++++++++++------- scripts/demos/pid/demo_temp_controller.py | 48 +++++++++++++----- 4 files changed, 84 insertions(+), 49 deletions(-) diff --git a/components/pid/temp_controller.py b/components/pid/temp_controller.py index becc777..3f50315 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -1,25 +1,18 @@ -from components.pid import Kalman from components.pid.temp_controller_base import TempControllerBase class TempController(TempControllerBase): def __init__(self, dt, params): TempControllerBase.__init__(self, dt, params) - self.kalman = Kalman(dt, params['Kalman']) - - def init_kalman(self, value): - self.kalman.initial((value, 0)) - + self.dt = dt + self.last_theta_ist = 20 + self.heatrate_ist = 0 def process(self): # Process Kalman - if self.use_kalman: - Z = self.kalman.process_measurement((self.theta_ist_set, 0), 0.0) - xp = self.kalman.process(Z) - self.theta_ist = xp[0, 0] - self.heatrate_ist = xp[1, 0] * 60 - else: - self.theta_ist = self.theta_ist_set - self.heatrate_ist = self.heatrate_ist_set + self.theta_ist = self.theta_ist_set + heatrate = (self.theta_ist - self.last_theta_ist)/self.dt*60 + self.heatrate_ist = heatrate + self.last_theta_ist = self.theta_ist # Compensate for max heat rate to reduce overshoot if self.heatrate_soll_set > 0: diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index fa03fdf..36c6069 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -19,14 +19,10 @@ class TempControllerBase(APid): self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})} self.y = -1 self.state = States.INIT - self.use_kalman = True self.pid_hold.set_params(params['Hold']) self.pid_rate.set_params(params['Heat']) self.is_startup = True - def init_kalman(self, value): - raise NotImplementedError - def on_state_entered(self, state): pass @@ -37,7 +33,6 @@ class TempControllerBase(APid): self.theta_ist_set = value if self.is_startup: self.is_startup = False - self.init_kalman(value) def get_theta_ist(self): return self.theta_ist diff --git a/components/plant/pot.py b/components/plant/pot.py index 26d6c17..9aa9341 100644 --- a/components/plant/pot.py +++ b/components/plant/pot.py @@ -1,6 +1,6 @@ from components.aplant import APlant from components.plant.heat_diffusion import HeatDiffusion - +from components.plant import delay class Pot(APlant): def __init__(self, dt, params, theta_amb=20): @@ -10,35 +10,57 @@ class Pot(APlant): self.e = 0 self.x = 0 + # Plant power gain (efficiency) self.gain = params['gain'] - self.C = params['C'] - self.M = params['M'] - self.L = params['L'] - self.Td = params['Td'] - self.kn = params['kn'] - self.temp = params['theta'] - self.temp_intermediate = params['theta'] + # Plant specific thermal capacity [W*s/(kg*K)] + self.C = params['C'] + + # Plant mass [kg] + self.M = params['M'] + + # Plant energy loss coefficient [W/(kg*K)] + # Negative input power as a function of plant mass and temperature difference T_plant and T_ambient + # P_loss = L * Mass * (T_plant - T_ambient) + self.L = params['L'] + + # Energy transport propagation delay + self.Td = params['Td'] + + # TBD + self.kn = params['kn'] + + # Plant temperature [°C] + self.temp = params['theta'] + + # Ambient temperature [°C] self.theta_amb = theta_amb + # Set power [W] self.power_set = 0 - self.delay = HeatDiffusion(self.dt, self.Td, params['theta']) + + # Plant delay [s] + self.delay = delay.Delay(dt, self.Td, 0) + self.p_in = 0 + self.p_pot = 0 def initial(self, temp): - self.temp_intermediate = temp self.temp = temp - self.delay.initial(temp) def activate(self, enable): pass def process(self): - power = self.gain * self.power_set - leak = 1/self.M * self.L * (self.theta_amb - self.temp)/self.theta_amb - self.temp_intermediate += (power / (self.M * self.C) + leak) * self.dt + self.p_in = self.gain * self.power_set + self.delay.put(self.p_in) - # Delay - self.temp = self.delay.process(self.temp_intermediate) + p_loss = self.L * self.M * (self.temp - self.theta_amb) + self.p_pot = self.delay.get() - p_loss + + print(f"p_loss: {p_loss}") + print(f"theta_amb: {self.theta_amb}") + print(f"temp: {self.temp}") + self.temp = min(100, self.temp + self.p_pot/(self.M * self.C) * self.dt) def is_activated(self): return True @@ -52,5 +74,6 @@ class Pot(APlant): def get_temperature(self): return self.temp - def get_temperature_intermediate(self): - return self.temp_intermediate + def get_p_pot(self): + return self.p_pot + diff --git a/scripts/demos/pid/demo_temp_controller.py b/scripts/demos/pid/demo_temp_controller.py index 60e3b42..9e0b8a1 100644 --- a/scripts/demos/pid/demo_temp_controller.py +++ b/scripts/demos/pid/demo_temp_controller.py @@ -2,23 +2,48 @@ import numpy as np from matplotlib.pyplot import plot, figure, subplot, grid, show, legend from components.plant.pot import Pot from components.pid.temp_controller import TempController -from components.pid.tc_constants import Test if __name__ == '__main__': + ctrl_params = { + "Hold": { + "kp": 0.4, + "ki": 0.0, + "kd": 0.0, + "kt": 0.0 + }, + "Heat": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5 + } + } + + plant_params = { + "theta" : 20, + "C" : 4190, + "M" : 20, + "L" : 0.2, + "Td" : 30, + "kn" : 0.2, + "gain" : 1.0 + } + dt = 1.0 + k_noise = 0.001 temp_ist = 0 temp_soll = 20 - ctrl = TempController(dt, Test.tc_ctrl_params) - plant = Pot(dt, Test.tc_pot_params) - _temp_ist = np.empty(0) - _temp_soll = np.empty(0) + heatrate_soll = 1.25 + ctrl = TempController(dt, ctrl_params) + plant = Pot(dt, plant_params) _y = np.empty(0) _fb = np.empty(0) _t = np.empty(0) - _heatrate_ist_kalman = np.empty(0) - _temp_ist_kalman = np.empty(0) + _temp_soll = np.empty(0) + _heatrate_ist = np.empty(0) + _temp_ist = np.empty(0) a = 0.5 fb = 0 rho = 0.02 @@ -30,7 +55,7 @@ if __name__ == '__main__': hold_counter = temp['Duration'] hold = False ctrl.set_theta_soll(temp_soll) - ctrl.set_heatrate_soll(1.0) + ctrl.set_heatrate_soll(heatrate_soll) while True: if hold: @@ -38,7 +63,7 @@ if __name__ == '__main__': break hold_counter -= 1 plant.process() - temp_ist = plant.get_temperature() + 0.0 * np.random.randn() + temp_ist = plant.get_temperature() + k_noise * np.random.randn() ctrl.set_theta_ist(temp_ist) ctrl.process() @@ -55,8 +80,7 @@ if __name__ == '__main__': _y = np.append(_y, y) _fb = np.append(_fb, fb) _t = np.append(_t, t) - _heatrate_ist_kalman = np.append(_heatrate_ist_kalman, max(-1, min(3, ctrl.heatrate_ist))) - _temp_ist_kalman = np.append(_temp_ist, ctrl.theta_ist) + _heatrate_ist = np.append(_heatrate_ist, max(-1, min(3, ctrl.heatrate_ist))) t += 1 figure(1) @@ -69,7 +93,7 @@ if __name__ == '__main__': legend(["y", "pot"]) grid(True) subplot(3, 1, 3) - plot(_t, _heatrate_ist_kalman, '-b', linewidth=1) + plot(_t, _heatrate_ist, '-b', linewidth=1) legend(["heatrate"]) grid(True) show() From 29e85dbc9c8e5388cd3ccc9c280e1fdeab31ae73 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 19 Jun 2026 14:21:55 +0200 Subject: [PATCH 10/11] [Pot] - added demo --- scripts/demos/plant/__init__.py | 0 scripts/demos/plant/demo_pot.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 scripts/demos/plant/__init__.py create mode 100644 scripts/demos/plant/demo_pot.py diff --git a/scripts/demos/plant/__init__.py b/scripts/demos/plant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/demos/plant/demo_pot.py b/scripts/demos/plant/demo_pot.py new file mode 100644 index 0000000..21ee5a3 --- /dev/null +++ b/scripts/demos/plant/demo_pot.py @@ -0,0 +1,59 @@ +import numpy as np +from matplotlib.pyplot import plot, figure, subplot, grid, show, legend +from components.plant.pot import Pot + + +if __name__ == '__main__': + dt = 1.0 + power_on = 3500 + steps_heat = 4000 + steps_cool = 40000 + + pot_params = { + "theta" : 20, + "C" : 4190, + "M" : 20, + "L" : 0.4, + "Td" : 30, + "kn" : 0.2, + "gain" : 1.0 + } + + pot = Pot(dt, pot_params) + + _t = np.empty(0) + _temp = np.empty(0) + _p_pot = np.empty(0) + _power = np.empty(0) + + t = 0 + pot.set_power(power_on) + for _ in range(steps_heat): + pot.process() + _t = np.append(_t, t) + _temp = np.append(_temp, pot.get_temperature()) + _p_pot = np.append(_p_pot, pot.get_p_pot()) + _power = np.append(_power, pot.get_power()) + t += 1 + + pot.set_power(0) + for _ in range(steps_cool): + pot.process() + _t = np.append(_t, t) + _temp = np.append(_temp, pot.get_temperature()) + _p_pot = np.append(_p_pot, pot.get_p_pot()) + _power = np.append(_power, pot.get_power()) + t += 1 + + figure(1) + subplot(2, 1, 1) + plot(_t, _temp, '-b', linewidth=1) + legend(["temp"]) + grid(True) + subplot(2, 1, 2) + plot(_t, _power, '-g', _t, _p_pot, '-r', linewidth=1) + legend(["power", "p_pot"]) + grid(True) + show() + + print("End of program") From 95eacabe2830f1a3e17118c22184abfe95a532c7 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 19 Jun 2026 14:42:10 +0200 Subject: [PATCH 11/11] Rewrite Smith predictor controller and demo for the new Pot/no-Kalman design temp_controller_smith.py still relied on Kalman filtering and Pot's old get_temperature_intermediate(), both removed by the recent Pot/Kalman simplification. Rebuild it using two Pot model copies (zero-delay and delayed) and the same backward-difference heat rate as temp_controller.py, combined into the classic Smith correction: theta_ist = theta_model_fast + (theta_plant - theta_model_delay). Also fix set_model_power() never being called, so the internal model actually receives the controller's power output, and fill in demo_temp_controller_smith.py to exercise it end-to-end with an extra plot of plant vs. fast-model vs. delayed-model temperature. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/temp_controller_smith.py | 73 +++++++----------- .../demos/pid/demo_temp_controller_smith.py | 75 ++++++++++++------- 2 files changed, 74 insertions(+), 74 deletions(-) diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index 0fdd579..4576c35 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -1,82 +1,61 @@ from components.plant.pot import Pot -from components.pid import Kalman from components.pid.temp_controller_base import TempControllerBase -from components.pid.tc_constants import * +from components.pid.tc_constants import States class TempController(TempControllerBase): def __init__(self, dt, params, model_params): TempControllerBase.__init__(self, dt, params) - self.kalman_model = Kalman(dt, params['Kalman']) - self.kalman_model_delay = Kalman(dt, params['Kalman']) - self.kalman_plant = Kalman(dt, params['Kalman']) - self.model = Pot(dt, model_params) + self.dt = dt + self.last_theta_ist = 20 + self.heatrate_ist = 0 + + # Fast model: same plant model but with zero transport delay, used + # to predict the current temperature without the dead time. + self.model = Pot(dt, {**model_params, 'Td': 0}) + # Delayed model: keeps the plant's assumed transport delay, so it + # can be compared like-for-like against the real (delayed) measurement. + self.model_delay = Pot(dt, model_params) self.theta_ist_plant = 0 - self.dtheta_ist_plant = 0 self.theta_ist_model = 0 - self.dtheta_ist_model = 0 self.theta_ist_model_delay = 0 - self.dtheta_ist_model_delay = 0 def set_model_power(self, power): self.model.set_power(power) - - def init_kalman(self, value): - self.kalman_model.initial((value, 0)) - self.kalman_model_delay.initial((value, 0)) - self.kalman_plant.initial((value, 0)) + self.model_delay.set_power(power) def on_state_entered(self, state): if state == States.HEAT: self.model.initial(self.theta_ist) - self.kalman_model.initial((self.theta_ist, 0)) - self.kalman_model_delay.initial((self.theta_ist, 0)) + self.model_delay.initial(self.theta_ist) def post_pid(self): self.model.process() + self.model_delay.process() def process(self): - # Process Kalman of Plant - Z_plant = self.kalman_plant.process_measurement((self.theta_ist_set, 0), 0.0) - xp_plant = self.kalman_plant.process(Z_plant) - theta_ist_plant = xp_plant[0, 0] - heatrate_ist_plant = xp_plant[1, 0] * 60 + self.theta_ist_plant = self.theta_ist_set + self.theta_ist_model = self.model.get_temperature() + self.theta_ist_model_delay = self.model_delay.get_temperature() - # Process Kalman of Model - k_model = self.kalman_model.process_measurement((self.model.get_temperature_intermediate(), 0), 0.0) - xp_model = self.kalman_model.process(k_model) - theta_ist_model = xp_model[0, 0] - heatrate_ist_model = xp_model[1, 0] * 60 + # Smith predictor: use the fast model's prediction, corrected by the + # mismatch between the real (delayed) plant and the delayed model, + # so the dead time drops out of the feedback path. + self.theta_ist = self.theta_ist_model + (self.theta_ist_plant - self.theta_ist_model_delay) - # Process Kalman of delayed Model - k_model_delay = self.kalman_model_delay.process_measurement((self.model.get_temperature(), 0), 0.0) - xp_model_delay = self.kalman_model_delay.process(k_model_delay) - theta_ist_model_delay = xp_model_delay[0, 0] - dtheta_ist_model_delay = xp_model_delay[1, 0] * 60 - - self.theta_ist_plant = theta_ist_plant - self.dtheta_ist_plant = heatrate_ist_plant - self.theta_ist_model = theta_ist_model - self.dtheta_ist_model = heatrate_ist_model - self.theta_ist_model_delay = theta_ist_model_delay - self.dtheta_ist_model_delay = dtheta_ist_model_delay - self.theta_ist = theta_ist_plant - self.heatrate_ist = heatrate_ist_plant + heatrate = (self.theta_ist - self.last_theta_ist)/self.dt*60 + self.heatrate_ist = heatrate + self.last_theta_ist = self.theta_ist # Compensate for max heat rate to reduce overshoot if self.heatrate_soll_set > 0: self.pid_hold.scale(1.0/self.heatrate_soll_set) self.heatrate_soll = self.heatrate_soll_set * self.pid_hold.get_y() + theta_err = self.theta_soll_set - self.theta_ist + heatrate_err = self.heatrate_soll - self.heatrate_ist - if 1: - theta_err = self.theta_soll_set - (theta_ist_plant - theta_ist_model_delay + theta_ist_model) - else: - theta_err = self.theta_soll_set - theta_ist_plant - - heatrate_err = self.heatrate_soll - (heatrate_ist_plant - dtheta_ist_model_delay + heatrate_ist_model) diff = self.theta_soll_set - self.theta_ist - self.process_fsm(diff) self.process_pid(theta_err, heatrate_err) diff --git a/scripts/demos/pid/demo_temp_controller_smith.py b/scripts/demos/pid/demo_temp_controller_smith.py index 5dd70dc..31e7027 100644 --- a/scripts/demos/pid/demo_temp_controller_smith.py +++ b/scripts/demos/pid/demo_temp_controller_smith.py @@ -2,38 +2,62 @@ import numpy as np from matplotlib.pyplot import plot, figure, subplot, grid, show, legend from components.plant.pot import Pot from components.pid.temp_controller_smith import TempController -from components.pid.tc_constants import Test if __name__ == '__main__': - dt = 1.0 + ctrl_params = { + "Hold": { + "kp": 0.4, + "ki": 0.0, + "kd": 0.0, + "kt": 0.0 + }, + "Heat": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5 + } + } + plant_params = { + "theta" : 20, + "C" : 4190, + "M" : 20, + "L" : 0.2, + "Td" : 30, + "kn" : 0.2, + "gain" : 1.0 + } + + dt = 1.0 + k_noise = 0.001 + + temp_ist = 0 temp_soll = 20 - ctrl = TempController(dt, Test.tc_ctrl_params, Test.tc_model_params) - plant = Pot(dt, Test.tc_pot_params) + heatrate_soll = 1.25 + ctrl = TempController(dt, ctrl_params, plant_params) + plant = Pot(dt, plant_params) _y = np.empty(0) _fb = np.empty(0) _t = np.empty(0) _temp_soll = np.empty(0) - _temp_ist_kalman = np.empty(0) - _heatrate_ist_kalman = np.empty(0) - _temp_ist_kalman_plant = np.empty(0) - _heatrate_ist_kalman_plant = np.empty(0) - _temp_ist_kalman_model = np.empty(0) - _heatrate_ist_kalman_model = np.empty(0) + _heatrate_ist = np.empty(0) + _temp_ist = np.empty(0) + _temp_ist_model = np.empty(0) + _temp_ist_model_delay = np.empty(0) a = 0.5 fb = 0 rho = 0.02 temps = [{'Temp': 20, 'Duration': 1000}, {'Temp': 40, 'Duration': 1000}, {'Temp': 50, 'Duration': 1000}, {'Temp': 60, 'Duration': 1000}, {'Temp': 70, 'Duration': 1000}, {'Temp': 80, 'Duration': 1000}, {'Temp': 78, 'Duration': 1000}] t = 0 - for temp in temps: temp_soll = temp['Temp'] hold_counter = temp['Duration'] hold = False ctrl.set_theta_soll(temp_soll) - ctrl.set_heatrate_soll(1.0) + ctrl.set_heatrate_soll(heatrate_soll) while True: if hold: @@ -41,30 +65,32 @@ if __name__ == '__main__': break hold_counter -= 1 plant.process() - temp_ist = plant.get_temperature() + temp_ist = plant.get_temperature() + k_noise * np.random.randn() ctrl.set_theta_ist(temp_ist) ctrl.process() y = 3500*ctrl.get_power() power = max(0, y) plant.set_power(power) + ctrl.set_model_power(power) fb = plant.get_power() if abs(temp_ist - temp_soll) < 0.1: hold = True + temp_ist -= rho + _temp_ist = np.append(_temp_ist, temp_ist) + _temp_soll = np.append(_temp_soll, temp_soll) _y = np.append(_y, y) _fb = np.append(_fb, fb) _t = np.append(_t, t) - _temp_soll = np.append(_temp_soll, temp_soll) - _temp_ist_kalman_plant = np.append(_temp_ist_kalman_plant, ctrl.theta_ist_plant) - _heatrate_ist_kalman_plant = np.append(_heatrate_ist_kalman_plant, max(-1, min(3, ctrl.dtheta_ist_plant))) - _temp_ist_kalman_model = np.append(_temp_ist_kalman_model, ctrl.theta_ist_model_delay) - _heatrate_ist_kalman_model = np.append(_heatrate_ist_kalman_model, max(-1, min(3, ctrl.dtheta_ist_model_delay))) + _heatrate_ist = np.append(_heatrate_ist, max(-1, min(3, ctrl.heatrate_ist))) + _temp_ist_model = np.append(_temp_ist_model, ctrl.theta_ist_model) + _temp_ist_model_delay = np.append(_temp_ist_model_delay, ctrl.theta_ist_model_delay) t += 1 figure(1) subplot(3, 1, 1) - plot(_t, _temp_ist_kalman_plant, _t, _temp_soll, 'r-', linewidth=1) + plot(_t, _temp_ist, _t, _temp_soll, 'r-', linewidth=1) legend(["ist", "soll"]) grid(True) subplot(3, 1, 2) @@ -72,18 +98,13 @@ if __name__ == '__main__': legend(["y", "pot"]) grid(True) subplot(3, 1, 3) - plot(_t, _heatrate_ist_kalman_plant, '-b', linewidth=1) + plot(_t, _heatrate_ist, '-b', linewidth=1) legend(["heatrate"]) grid(True) figure(2) - subplot(2, 1, 1) - plot(_t, _temp_ist_kalman_plant, '-b', _t, _temp_ist_kalman_model, 'r-', linewidth=1) - legend(["plant", "model"]) - grid(True) - subplot(2, 1, 2) - plot(_t, _heatrate_ist_kalman_plant, '-b', _t, _heatrate_ist_kalman_model, '-r', linewidth=1) - legend(["plant", "model"]) + plot(_t, _temp_ist, '-b', _t, _temp_ist_model, '-g', _t, _temp_ist_model_delay, '-r', linewidth=1) + legend(["plant", "model (fast)", "model (delayed)"]) grid(True) show()