Add a real-time Plot tab to brewpi_gui, mirroring demo_sud figure 1
Embeds a dark-themed matplotlib canvas (3 stacked strip charts: theta ist/soll, heatrate ist/soll, heater power_set/power_eff) in a new Plot tab, sampled once per second from the latest websocket-received values. HeaterTask now also emits a PowerSet message (the pre-discretization target power) alongside the existing Power (effective) message, since the GUI had no way to see the set-point side of figure 1's bottom plot otherwise.
This commit is contained in:
+13
-1
@@ -708,8 +708,20 @@
|
|||||||
</widget>
|
</widget>
|
||||||
<widget class="QWidget" name="tab_2">
|
<widget class="QWidget" name="tab_2">
|
||||||
<attribute name="title">
|
<attribute name="title">
|
||||||
<string>Seite</string>
|
<string>Plot</string>
|
||||||
</attribute>
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_plot">
|
||||||
|
<item>
|
||||||
|
<widget class="QWidget" name="widget_plot" native="true">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>400</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QWidget" name="gridLayoutWidget">
|
<widget class="QWidget" name="gridLayoutWidget">
|
||||||
|
|||||||
+107
-1
@@ -1,14 +1,87 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from main_window import Ui_MainWindow
|
from main_window import Ui_MainWindow
|
||||||
from PyQt5 import QtWidgets, QtGui
|
from PyQt5 import QtWidgets, QtGui, QtCore
|
||||||
import json
|
import json
|
||||||
import sys
|
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.client.ws_client import WsClient
|
||||||
from ws.message import MessageDispatcherSync
|
from ws.message import MessageDispatcherSync
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
import asyncio
|
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):
|
class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
QtWidgets.QMainWindow.__init__(self)
|
QtWidgets.QMainWindow.__init__(self)
|
||||||
@@ -55,6 +128,25 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.heatrate_soll_initial_update = True
|
self.heatrate_soll_initial_update = True
|
||||||
self.sud_download_path = None
|
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):
|
def connect(self):
|
||||||
self.slider_pwr_initial_update = True
|
self.slider_pwr_initial_update = True
|
||||||
self.slider_temp_soll_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_heater_activate_initial_update = True
|
||||||
self.checkbox_stirrer_activate_initial_update = True
|
self.checkbox_stirrer_activate_initial_update = True
|
||||||
self.heatrate_soll_initial_update = True
|
self.heatrate_soll_initial_update = True
|
||||||
|
self.plot.reset()
|
||||||
self.ws_client.connect(uri=self.plainTextUri.toPlainText())
|
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):
|
def disconnect(self):
|
||||||
self.ws_client.disconnect()
|
self.ws_client.disconnect()
|
||||||
|
|
||||||
@@ -127,6 +226,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
elif "Soll" in key:
|
elif "Soll" in key:
|
||||||
submsg = msg['Soll']
|
submsg = msg['Soll']
|
||||||
if 'Temp' in submsg:
|
if 'Temp' in submsg:
|
||||||
|
self.plot_temp_soll = submsg['Temp']
|
||||||
self.lcdNumber_temp_soll.display(str(submsg['Temp']))
|
self.lcdNumber_temp_soll.display(str(submsg['Temp']))
|
||||||
if self.slider_temp_soll_initial_update:
|
if self.slider_temp_soll_initial_update:
|
||||||
self.Slider_temp_soll.setValue(int(round(submsg['Temp'])))
|
self.Slider_temp_soll.setValue(int(round(submsg['Temp'])))
|
||||||
@@ -135,6 +235,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
subsubmsg = submsg['Rate']
|
subsubmsg = submsg['Rate']
|
||||||
for subkey in subsubmsg:
|
for subkey in subsubmsg:
|
||||||
if "Current" in subkey:
|
if "Current" in subkey:
|
||||||
|
self.plot_rate_soll = subsubmsg['Current']
|
||||||
self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current']))
|
self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current']))
|
||||||
if "Set" in subkey:
|
if "Set" in subkey:
|
||||||
if self.heatrate_soll_initial_update:
|
if self.heatrate_soll_initial_update:
|
||||||
@@ -143,8 +244,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
if "Ist" in key:
|
if "Ist" in key:
|
||||||
submsg = msg['Ist']
|
submsg = msg['Ist']
|
||||||
if 'Temp' in submsg:
|
if 'Temp' in submsg:
|
||||||
|
self.plot_temp_ist = submsg['Temp']
|
||||||
self.lcdNumber_temp_ist.display(str(submsg['Temp']))
|
self.lcdNumber_temp_ist.display(str(submsg['Temp']))
|
||||||
if 'Rate' in submsg:
|
if 'Rate' in submsg:
|
||||||
|
self.plot_rate_ist = submsg['Rate']
|
||||||
self.lcdNumber_heatrate_ist.display(str(submsg['Rate']))
|
self.lcdNumber_heatrate_ist.display(str(submsg['Rate']))
|
||||||
|
|
||||||
def on_heater_changed(self, msg):
|
def on_heater_changed(self, msg):
|
||||||
@@ -154,7 +257,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
if self.checkbox_heater_activate_initial_update:
|
if self.checkbox_heater_activate_initial_update:
|
||||||
self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
|
self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
|
||||||
self.checkbox_heater_activate_initial_update = False
|
self.checkbox_heater_activate_initial_update = False
|
||||||
|
elif "PowerSet" in key:
|
||||||
|
self.plot_power_set = msg['PowerSet']
|
||||||
elif "Power" in key:
|
elif "Power" in key:
|
||||||
|
self.plot_power_eff = msg['Power']
|
||||||
self.lcdNumber_power_heater.display(msg['Power'])
|
self.lcdNumber_power_heater.display(msg['Power'])
|
||||||
if self.slider_pwr_initial_update:
|
if self.slider_pwr_initial_update:
|
||||||
self.Slider_pwr_soll.setValue(int(round(msg['Power'])))
|
self.Slider_pwr_soll.setValue(int(round(msg['Power'])))
|
||||||
|
|||||||
@@ -249,6 +249,12 @@ class Ui_MainWindow(object):
|
|||||||
self.horizontalTabWidget.addTab(self.tab, "")
|
self.horizontalTabWidget.addTab(self.tab, "")
|
||||||
self.tab_2 = QtWidgets.QWidget()
|
self.tab_2 = QtWidgets.QWidget()
|
||||||
self.tab_2.setObjectName("tab_2")
|
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.horizontalTabWidget.addTab(self.tab_2, "")
|
||||||
self.gridLayoutWidget = QtWidgets.QWidget(self.centralwidget)
|
self.gridLayoutWidget = QtWidgets.QWidget(self.centralwidget)
|
||||||
self.gridLayoutWidget.setGeometry(QtCore.QRect(480, 70, 481, 374))
|
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.label_11.setText(_translate("MainWindow", "Speed [%]"))
|
||||||
self.horizontalTabWidget.setTabText(self.horizontalTabWidget.indexOf(self.horizontalTabWidgetPage1), _translate("MainWindow", "Manual"))
|
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), _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.setText(_translate("MainWindow", "Ist"))
|
||||||
self.label_2.setText(_translate("MainWindow", "Soll"))
|
self.label_2.setText(_translate("MainWindow", "Soll"))
|
||||||
self.label_3.setText(_translate("MainWindow", "Temperature [°C]"))
|
self.label_3.setText(_translate("MainWindow", "Temperature [°C]"))
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pyqt5
|
pyqt5
|
||||||
|
matplotlib
|
||||||
websockets==10.4
|
websockets==10.4
|
||||||
@@ -26,6 +26,9 @@ class HeaterTask(ATask):
|
|||||||
def on_changed_power(self, power):
|
def on_changed_power(self, power):
|
||||||
asyncio.create_task(self.send({'Power': 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):
|
async def recv(self, data):
|
||||||
for pair in data.items():
|
for pair in data.items():
|
||||||
if 'Power' in pair[0]:
|
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()}}})
|
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)
|
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_s = 10
|
||||||
pulse_period_count = pulse_period_s/self.interval
|
pulse_period_count = pulse_period_s/self.interval
|
||||||
@@ -61,6 +65,7 @@ class HeaterTask(ATask):
|
|||||||
while True:
|
while True:
|
||||||
# Combine power set from different sources
|
# Combine power set from different sources
|
||||||
power_soll = max(self.power_soll, self.power_actor)
|
power_soll = max(self.power_soll, self.power_actor)
|
||||||
|
power_set_changed(power_soll)
|
||||||
|
|
||||||
# Calculate duty cycle
|
# Calculate duty cycle
|
||||||
power_low = self.get_next_smaller_power(power_soll)
|
power_low = self.get_next_smaller_power(power_soll)
|
||||||
|
|||||||
Reference in New Issue
Block a user