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..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 @@ -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/main_window.py similarity index 98% rename from client/brewpi_win.py rename to client/main_window.py index 93f3f51..0c811d6 100644 --- a/client/brewpi_win.py +++ b/client/main_window.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")) diff --git a/components/pid/TODO.md b/components/pid/TODO.md new file mode 100644 index 0000000..460adc1 --- /dev/null +++ b/components/pid/TODO.md @@ -0,0 +1,47 @@ +# 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`). + +- [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` + (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__`. 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/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.py b/components/pid/temp_controller.py index 0b34212..3f50315 100644 --- a/components/pid/temp_controller.py +++ b/components/pid/temp_controller.py @@ -1,75 +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.tc_constants import * -import numpy as np +from components.pid.temp_controller_base import TempControllerBase -class TempController(APid): +class TempController(TempControllerBase): 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 + TempControllerBase.__init__(self, dt, params) + self.dt = dt + self.last_theta_ist = 20 self.heatrate_ist = 0 - self.params = 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 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: @@ -82,118 +25,3 @@ class TempController(APid): diff = self.theta_soll_set - self.theta_ist 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 - - 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_base.py b/components/pid/temp_controller_base.py new file mode 100644 index 0000000..36c6069 --- /dev/null +++ b/components/pid/temp_controller_base.py @@ -0,0 +1,106 @@ +from components import APid +from components.pid.pid import Pid +from components.pid.tc_constants import States, DEFAULT_THRESHOLDS + + +class TempControllerBase(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 + self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})} + self.y = -1 + self.state = States.INIT + self.pid_hold.set_params(params['Hold']) + self.pid_rate.set_params(params['Heat']) + self.is_startup = True + + 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 + + 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 >= self.thresholds['IdleHeat']: + state_next = States.HEAT + self.pid_rate.reset() + elif diff >= -self.thresholds['IdleHold']: + state_next = States.HOLD + self.pid_rate.reset() + elif self.state == States.HOLD: + if diff >= self.thresholds['HoldHeat']: + state_next = States.HEAT + self.pid_rate.reset() + elif diff <= -self.thresholds['HoldIdle']: + state_next = States.IDLE + elif self.state == States.HEAT: + if diff <= -self.thresholds['HeatIdle']: + state_next = States.IDLE + elif diff <= self.thresholds['HeatHold']: + 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..4576c35 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -1,249 +1,61 @@ 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.tc_constants import * -import numpy as np +from components.pid.temp_controller_base import TempControllerBase +from components.pid.tc_constants import States -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 + TempControllerBase.__init__(self, dt, params) + self.dt = dt + self.last_theta_ist = 20 self.heatrate_ist = 0 - self.params = params - self.model_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 + + # 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.theta_ist_model = 0 + self.theta_ist_model_delay = 0 def set_model_power(self, power): self.model.set_power(power) + self.model_delay.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 on_state_entered(self, state): + if state == States.HEAT: + self.model.initial(self.theta_ist) + self.model_delay.initial(self.theta_ist) - 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 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 0: - 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) - - 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 - - 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/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 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/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": { 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..9e0b8a1 --- /dev/null +++ b/scripts/demos/pid/demo_temp_controller.py @@ -0,0 +1,101 @@ +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 + + +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 + 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) + _temp_soll = np.empty(0) + _heatrate_ist = np.empty(0) + _temp_ist = 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(heatrate_soll) + + while True: + if hold: + if hold_counter == 0: + break + hold_counter -= 1 + plant.process() + 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) + 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 = np.append(_heatrate_ist, max(-1, min(3, ctrl.heatrate_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, '-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..31e7027 --- /dev/null +++ b/scripts/demos/pid/demo_temp_controller_smith.py @@ -0,0 +1,111 @@ +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 + + +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 + 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) + _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(heatrate_soll) + + while True: + if hold: + if hold_counter == 0: + break + hold_counter -= 1 + plant.process() + 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) + _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, _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, '-b', linewidth=1) + legend(["heatrate"]) + grid(True) + + figure(2) + 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() + + print("End of program") 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")