Pre-visualize the estimated Sud course in the Automatic tab
Adds a static forecast plot to the (previously empty) Automatic tab, showing the planned temperature course derived from a schedule's declared ramp rates and hold durations alone (no plant/controller model - just how long the schedule itself says each step takes). The client now requests the schedule via Download right after connecting, routing the reply to the forecast instead of a save dialog since sud_download_path is only set for the user-initiated download. SudTask also echoes the just-applied document back after a successful Upload, so editing the schedule refreshes the forecast without an extra round trip.
This commit is contained in:
@@ -705,6 +705,18 @@
|
|||||||
<attribute name="title">
|
<attribute name="title">
|
||||||
<string>Automatic</string>
|
<string>Automatic</string>
|
||||||
</attribute>
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_forecast">
|
||||||
|
<item>
|
||||||
|
<widget class="QWidget" name="widget_plot_forecast" native="true">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>400</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QWidget" name="tab_2">
|
<widget class="QWidget" name="tab_2">
|
||||||
<attribute name="title">
|
<attribute name="title">
|
||||||
|
|||||||
+94
-13
@@ -9,12 +9,25 @@ matplotlib.use("Qt5Agg")
|
|||||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
|
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
from matplotlib.ticker import AutoMinorLocator
|
from matplotlib.ticker import AutoMinorLocator
|
||||||
|
from components.sud import Sud
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
def _style_axis(ax):
|
||||||
|
ax.set_facecolor('white')
|
||||||
|
ax.spines['top'].set_visible(False)
|
||||||
|
ax.spines['right'].set_visible(False)
|
||||||
|
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
||||||
|
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
||||||
|
ax.tick_params(which='major', direction='out', labelsize='small')
|
||||||
|
ax.tick_params(which='minor', direction='out', length=2)
|
||||||
|
ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3)
|
||||||
|
ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15)
|
||||||
|
|
||||||
|
|
||||||
class RealtimePlot(FigureCanvasQTAgg):
|
class RealtimePlot(FigureCanvasQTAgg):
|
||||||
"""Live theta/heatrate/heater-power strip charts, mirroring figure 1 of
|
"""Live theta/heatrate/heater-power strip charts, mirroring figure 1 of
|
||||||
scripts/demos/sud/demo_sud.py but fed from the websocket connection
|
scripts/demos/sud/demo_sud.py but fed from the websocket connection
|
||||||
@@ -43,15 +56,7 @@ class RealtimePlot(FigureCanvasQTAgg):
|
|||||||
self.ax_power.legend(loc='upper left', fontsize='small')
|
self.ax_power.legend(loc='upper left', fontsize='small')
|
||||||
|
|
||||||
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
|
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
|
||||||
ax.set_facecolor('white')
|
_style_axis(ax)
|
||||||
ax.spines['top'].set_visible(False)
|
|
||||||
ax.spines['right'].set_visible(False)
|
|
||||||
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
||||||
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
||||||
ax.tick_params(which='major', direction='out', labelsize='small')
|
|
||||||
ax.tick_params(which='minor', direction='out', length=2)
|
|
||||||
ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3)
|
|
||||||
ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15)
|
|
||||||
|
|
||||||
# Only the bottom subplot needs x tick labels - sharex keeps the
|
# Only the bottom subplot needs x tick labels - sharex keeps the
|
||||||
# others in sync without repeating them.
|
# others in sync without repeating them.
|
||||||
@@ -94,6 +99,59 @@ class RealtimePlot(FigureCanvasQTAgg):
|
|||||||
self.draw_idle()
|
self.draw_idle()
|
||||||
|
|
||||||
|
|
||||||
|
class SudForecastPlot(FigureCanvasQTAgg):
|
||||||
|
"""A static preview of a Sud schedule's temperature course, computed
|
||||||
|
from its nominal ramp rates/hold durations alone (no plant/controller
|
||||||
|
model - just how long the declared schedule says each step should
|
||||||
|
take), so it can be shown as soon as a schedule is loaded/edited,
|
||||||
|
before a brew is actually started."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
figure = Figure(facecolor='white')
|
||||||
|
FigureCanvasQTAgg.__init__(self, figure)
|
||||||
|
|
||||||
|
self.ax = figure.subplots(1, 1)
|
||||||
|
self.line, = self.ax.plot([], [], '-b', linewidth=1.5)
|
||||||
|
self.ax.set_xlabel("t [min]")
|
||||||
|
self.ax.set_ylabel("Temp [°C]")
|
||||||
|
_style_axis(self.ax)
|
||||||
|
|
||||||
|
figure.tight_layout()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_course(schedule, start_theta):
|
||||||
|
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
|
||||||
|
abs(delta)/rate minutes at the declared rate, holds last for their
|
||||||
|
duration at the temperature the preceding ramp reached."""
|
||||||
|
t = [0.0]
|
||||||
|
theta = [start_theta]
|
||||||
|
for step in schedule:
|
||||||
|
ramp = step.get('ramp')
|
||||||
|
hold = step.get('hold')
|
||||||
|
if ramp is not None:
|
||||||
|
rate = ramp.get('rate', 0)
|
||||||
|
target = ramp['temp']
|
||||||
|
if rate > 0:
|
||||||
|
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
|
||||||
|
theta.append(target)
|
||||||
|
elif hold is not None:
|
||||||
|
t.append(t[-1] + hold.get('duration', 0))
|
||||||
|
theta.append(theta[-1])
|
||||||
|
return t, theta
|
||||||
|
|
||||||
|
def show_schedule(self, schedule, start_theta):
|
||||||
|
t, theta = self._estimate_course(schedule, start_theta)
|
||||||
|
t_min = [seconds / 60.0 for seconds in t]
|
||||||
|
|
||||||
|
self.line.set_data(t_min, theta)
|
||||||
|
self.ax.relim()
|
||||||
|
self.ax.autoscale_view()
|
||||||
|
self.ax.set_title("Estimated course (nominal rates, no plant model) "
|
||||||
|
"- {:.0f} min total".format(t_min[-1] if t_min else 0.0), fontsize='small')
|
||||||
|
self.figure.tight_layout()
|
||||||
|
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)
|
||||||
@@ -159,6 +217,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.plot_timer.timeout.connect(self.on_plot_timer)
|
self.plot_timer.timeout.connect(self.on_plot_timer)
|
||||||
self.plot_timer.start(1000)
|
self.plot_timer.start(1000)
|
||||||
|
|
||||||
|
self.forecast_plot = SudForecastPlot()
|
||||||
|
forecast_layout = QtWidgets.QVBoxLayout(self.widget_plot_forecast)
|
||||||
|
forecast_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
forecast_layout.addWidget(self.forecast_plot)
|
||||||
|
|
||||||
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
|
||||||
@@ -168,6 +231,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.heatrate_soll_initial_update = True
|
self.heatrate_soll_initial_update = True
|
||||||
self.plot.reset()
|
self.plot.reset()
|
||||||
self.ws_client.connect(uri=self.plainTextUri.toPlainText())
|
self.ws_client.connect(uri=self.plainTextUri.toPlainText())
|
||||||
|
# Fetch the schedule for the Automatic tab's forecast preview - not
|
||||||
|
# a user-initiated download, so sud_download_path stays None and
|
||||||
|
# on_sud_changed() routes the reply to update_sud_forecast() instead
|
||||||
|
# of a save dialog.
|
||||||
|
self.msg_sud.send({'Download': True})
|
||||||
|
|
||||||
def on_plot_timer(self):
|
def on_plot_timer(self):
|
||||||
self.plot.sample(
|
self.plot.sample(
|
||||||
@@ -305,13 +373,26 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
def on_sud_changed(self, msg):
|
def on_sud_changed(self, msg):
|
||||||
print("on_sud_changed {}".format(msg))
|
print("on_sud_changed {}".format(msg))
|
||||||
for key in msg:
|
for key in msg:
|
||||||
if "Json" in key and self.sud_download_path:
|
if "Json" in key:
|
||||||
with open(self.sud_download_path, 'w') as f:
|
if self.sud_download_path:
|
||||||
json.dump(msg['Json'], f, indent='\t')
|
with open(self.sud_download_path, 'w') as f:
|
||||||
self.sud_download_path = None
|
json.dump(msg['Json'], f, indent='\t')
|
||||||
|
self.sud_download_path = None
|
||||||
|
else:
|
||||||
|
self.update_sud_forecast(msg['Json'])
|
||||||
elif "Name" in key:
|
elif "Name" in key:
|
||||||
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
|
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
|
||||||
|
|
||||||
|
def update_sud_forecast(self, doc):
|
||||||
|
try:
|
||||||
|
_, _, schedule, _, _ = Sud._parse_data(doc)
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return
|
||||||
|
# No measured starting temperature is available before a brew has
|
||||||
|
# started; fall back to a plausible ambient guess.
|
||||||
|
start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0
|
||||||
|
self.forecast_plot.show_schedule(schedule, start_theta)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
print("Exitting gracefully!")
|
print("Exitting gracefully!")
|
||||||
self.ws_client.disconnect()
|
self.ws_client.disconnect()
|
||||||
|
|||||||
@@ -246,6 +246,12 @@ class Ui_MainWindow(object):
|
|||||||
self.horizontalTabWidget.addTab(self.horizontalTabWidgetPage1, "")
|
self.horizontalTabWidget.addTab(self.horizontalTabWidgetPage1, "")
|
||||||
self.tab = QtWidgets.QWidget()
|
self.tab = QtWidgets.QWidget()
|
||||||
self.tab.setObjectName("tab")
|
self.tab.setObjectName("tab")
|
||||||
|
self.verticalLayout_forecast = QtWidgets.QVBoxLayout(self.tab)
|
||||||
|
self.verticalLayout_forecast.setObjectName("verticalLayout_forecast")
|
||||||
|
self.widget_plot_forecast = QtWidgets.QWidget(self.tab)
|
||||||
|
self.widget_plot_forecast.setMinimumSize(QtCore.QSize(0, 400))
|
||||||
|
self.widget_plot_forecast.setObjectName("widget_plot_forecast")
|
||||||
|
self.verticalLayout_forecast.addWidget(self.widget_plot_forecast)
|
||||||
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")
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class SudTask(ATask):
|
|||||||
elif 'Upload' in pair[0]:
|
elif 'Upload' in pair[0]:
|
||||||
if self.sud.upload(pair[1]):
|
if self.sud.upload(pair[1]):
|
||||||
await self.send({'Name': self.sud.name, 'Description': self.sud.description})
|
await self.send({'Name': self.sud.name, 'Description': self.sud.description})
|
||||||
|
await self.send({'Json': pair[1]})
|
||||||
|
|
||||||
async def send(self, data):
|
async def send(self, data):
|
||||||
await self.msg_handler.send(data)
|
await self.msg_handler.send(data)
|
||||||
|
|||||||
Reference in New Issue
Block a user