diff --git a/client/brewpi.ui b/client/brewpi.ui index e3e72b2..734adad 100644 --- a/client/brewpi.ui +++ b/client/brewpi.ui @@ -708,8 +708,20 @@ - Seite + Plot + + + + + + 0 + 400 + + + + + diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index d808d2e..bc6f35b 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -1,14 +1,87 @@ #!/usr/bin/env python3 from main_window import Ui_MainWindow -from PyQt5 import QtWidgets, QtGui +from PyQt5 import QtWidgets, QtGui, QtCore import json import sys +import time +import matplotlib +matplotlib.use("Qt5Agg") +import matplotlib.style +matplotlib.style.use("dark_background") +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.figure import Figure from ws.client.ws_client import WsClient from ws.message import MessageDispatcherSync from queue import Queue import asyncio +class RealtimePlot(FigureCanvasQTAgg): + """Live theta/heatrate/heater-power strip charts, mirroring figure 1 of + scripts/demos/sud/demo_sud.py but fed from the websocket connection + instead of an offline simulation run.""" + + def __init__(self): + figure = Figure() + FigureCanvasQTAgg.__init__(self, figure) + + self.ax_temp, self.ax_rate, self.ax_power = figure.subplots(3, 1, sharex=True) + + self.line_temp_ist, = self.ax_temp.plot([], [], '-c', linewidth=1, label="theta_ist") + self.line_temp_soll, = self.ax_temp.plot([], [], '-m', linewidth=1, label="theta_soll") + self.ax_temp.set_ylabel("Temp [°C]") + self.ax_temp.legend(loc='upper left', fontsize='small') + self.ax_temp.grid(True) + + self.line_rate_ist, = self.ax_rate.plot([], [], '-c', linewidth=1, label="heatrate_ist") + self.line_rate_soll, = self.ax_rate.plot([], [], '-m', linewidth=1, label="heatrate_soll") + self.ax_rate.set_ylabel("Rate [K/min]") + self.ax_rate.legend(loc='upper left', fontsize='small') + self.ax_rate.grid(True) + + self.line_power_set, = self.ax_power.plot([], [], '-m', linewidth=1, label="power_set") + self.line_power_eff, = self.ax_power.plot([], [], '-c', linewidth=1, label="power_eff") + self.ax_power.set_ylabel("Power [W]") + self.ax_power.set_xlabel("t [s]") + self.ax_power.legend(loc='upper left', fontsize='small') + self.ax_power.grid(True) + + figure.tight_layout() + self.reset() + + def reset(self): + self.t0 = time.monotonic() + self.t = [] + self.temp_ist = [] + self.temp_soll = [] + self.rate_ist = [] + self.rate_soll = [] + self.power_set = [] + self.power_eff = [] + + def sample(self, temp_ist, temp_soll, rate_ist, rate_soll, power_set, power_eff): + self.t.append(time.monotonic() - self.t0) + self.temp_ist.append(temp_ist) + self.temp_soll.append(temp_soll) + self.rate_ist.append(rate_ist) + self.rate_soll.append(rate_soll) + self.power_set.append(power_set) + self.power_eff.append(power_eff) + + self.line_temp_ist.set_data(self.t, self.temp_ist) + self.line_temp_soll.set_data(self.t, self.temp_soll) + self.line_rate_ist.set_data(self.t, self.rate_ist) + self.line_rate_soll.set_data(self.t, self.rate_soll) + self.line_power_set.set_data(self.t, self.power_set) + self.line_power_eff.set_data(self.t, self.power_eff) + + for ax in (self.ax_temp, self.ax_rate, self.ax_power): + ax.relim() + ax.autoscale_view() + + self.draw_idle() + + class Window(QtWidgets.QMainWindow, Ui_MainWindow): def __init__(self): QtWidgets.QMainWindow.__init__(self) @@ -55,6 +128,25 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.heatrate_soll_initial_update = True self.sud_download_path = None + # Latest values sampled by the plot timer - written from the + # websocket recv thread, read from the GUI thread; plain floats so + # no locking, consistent with how the rest of the GUI is wired. + self.plot_temp_ist = 0.0 + self.plot_temp_soll = 0.0 + self.plot_rate_ist = 0.0 + self.plot_rate_soll = 0.0 + self.plot_power_set = 0.0 + self.plot_power_eff = 0.0 + + self.plot = RealtimePlot() + plot_layout = QtWidgets.QVBoxLayout(self.widget_plot) + plot_layout.setContentsMargins(0, 0, 0, 0) + plot_layout.addWidget(self.plot) + + self.plot_timer = QtCore.QTimer(self) + self.plot_timer.timeout.connect(self.on_plot_timer) + self.plot_timer.start(1000) + def connect(self): self.slider_pwr_initial_update = True self.slider_temp_soll_initial_update = True @@ -62,8 +154,15 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.checkbox_heater_activate_initial_update = True self.checkbox_stirrer_activate_initial_update = True self.heatrate_soll_initial_update = True + self.plot.reset() self.ws_client.connect(uri=self.plainTextUri.toPlainText()) + def on_plot_timer(self): + self.plot.sample( + self.plot_temp_ist, self.plot_temp_soll, + self.plot_rate_ist, self.plot_rate_soll, + self.plot_power_set, self.plot_power_eff) + def disconnect(self): self.ws_client.disconnect() @@ -127,6 +226,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): elif "Soll" in key: submsg = msg['Soll'] if 'Temp' in submsg: + self.plot_temp_soll = submsg['Temp'] self.lcdNumber_temp_soll.display(str(submsg['Temp'])) if self.slider_temp_soll_initial_update: self.Slider_temp_soll.setValue(int(round(submsg['Temp']))) @@ -135,6 +235,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): subsubmsg = submsg['Rate'] for subkey in subsubmsg: if "Current" in subkey: + self.plot_rate_soll = subsubmsg['Current'] self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current'])) if "Set" in subkey: if self.heatrate_soll_initial_update: @@ -143,8 +244,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): if "Ist" in key: submsg = msg['Ist'] if 'Temp' in submsg: + self.plot_temp_ist = submsg['Temp'] self.lcdNumber_temp_ist.display(str(submsg['Temp'])) if 'Rate' in submsg: + self.plot_rate_ist = submsg['Rate'] self.lcdNumber_heatrate_ist.display(str(submsg['Rate'])) def on_heater_changed(self, msg): @@ -154,7 +257,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): if self.checkbox_heater_activate_initial_update: self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0) self.checkbox_heater_activate_initial_update = False + elif "PowerSet" in key: + self.plot_power_set = msg['PowerSet'] elif "Power" in key: + self.plot_power_eff = msg['Power'] self.lcdNumber_power_heater.display(msg['Power']) if self.slider_pwr_initial_update: self.Slider_pwr_soll.setValue(int(round(msg['Power']))) diff --git a/client/main_window.py b/client/main_window.py index 8d002f0..6ec9f92 100644 --- a/client/main_window.py +++ b/client/main_window.py @@ -249,6 +249,12 @@ class Ui_MainWindow(object): self.horizontalTabWidget.addTab(self.tab, "") self.tab_2 = QtWidgets.QWidget() self.tab_2.setObjectName("tab_2") + self.verticalLayout_plot = QtWidgets.QVBoxLayout(self.tab_2) + self.verticalLayout_plot.setObjectName("verticalLayout_plot") + self.widget_plot = QtWidgets.QWidget(self.tab_2) + self.widget_plot.setMinimumSize(QtCore.QSize(0, 400)) + self.widget_plot.setObjectName("widget_plot") + self.verticalLayout_plot.addWidget(self.widget_plot) self.horizontalTabWidget.addTab(self.tab_2, "") self.gridLayoutWidget = QtWidgets.QWidget(self.centralwidget) self.gridLayoutWidget.setGeometry(QtCore.QRect(480, 70, 481, 374)) @@ -1270,7 +1276,7 @@ class Ui_MainWindow(object): self.label_11.setText(_translate("MainWindow", "Speed [%]")) self.horizontalTabWidget.setTabText(self.horizontalTabWidget.indexOf(self.horizontalTabWidgetPage1), _translate("MainWindow", "Manual")) self.horizontalTabWidget.setTabText(self.horizontalTabWidget.indexOf(self.tab), _translate("MainWindow", "Automatic")) - self.horizontalTabWidget.setTabText(self.horizontalTabWidget.indexOf(self.tab_2), _translate("MainWindow", "Seite")) + self.horizontalTabWidget.setTabText(self.horizontalTabWidget.indexOf(self.tab_2), _translate("MainWindow", "Plot")) self.label.setText(_translate("MainWindow", "Ist")) self.label_2.setText(_translate("MainWindow", "Soll")) self.label_3.setText(_translate("MainWindow", "Temperature [°C]")) diff --git a/client/requirements.txt b/client/requirements.txt index 6a00701..98ada63 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -1,2 +1,3 @@ pyqt5 +matplotlib websockets==10.4 \ No newline at end of file diff --git a/tasks/heater.py b/tasks/heater.py index 7a7f58c..d52c301 100755 --- a/tasks/heater.py +++ b/tasks/heater.py @@ -26,6 +26,9 @@ class HeaterTask(ATask): def on_changed_power(self, power): asyncio.create_task(self.send({'Power': power})) + def on_changed_power_set(self, power): + asyncio.create_task(self.send({'PowerSet': power})) + async def recv(self, data): for pair in data.items(): if 'Power' in pair[0]: @@ -53,6 +56,7 @@ class HeaterTask(ATask): await self.send({'Capabilities': {'Power': {'Min': 0, 'Max': self.device.get_power_max()}}}) self.device.set_on_changed('power_eff', ChangedInteger(self.on_changed_power).set) + power_set_changed = ChangedInteger(self.on_changed_power_set).set pulse_period_s = 10 pulse_period_count = pulse_period_s/self.interval @@ -61,6 +65,7 @@ class HeaterTask(ATask): while True: # Combine power set from different sources power_soll = max(self.power_soll, self.power_actor) + power_set_changed(power_soll) # Calculate duty cycle power_low = self.get_next_smaller_power(power_soll)