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">
|
||||
<string>Automatic</string>
|
||||
</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 class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
|
||||
+94
-13
@@ -9,12 +9,25 @@ matplotlib.use("Qt5Agg")
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.ticker import AutoMinorLocator
|
||||
from components.sud import Sud
|
||||
from ws.client.ws_client import WsClient
|
||||
from ws.message import MessageDispatcherSync
|
||||
from queue import Queue
|
||||
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):
|
||||
"""Live theta/heatrate/heater-power strip charts, mirroring figure 1 of
|
||||
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')
|
||||
|
||||
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
|
||||
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)
|
||||
_style_axis(ax)
|
||||
|
||||
# Only the bottom subplot needs x tick labels - sharex keeps the
|
||||
# others in sync without repeating them.
|
||||
@@ -94,6 +99,59 @@ class RealtimePlot(FigureCanvasQTAgg):
|
||||
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):
|
||||
def __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.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):
|
||||
self.slider_pwr_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.plot.reset()
|
||||
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):
|
||||
self.plot.sample(
|
||||
@@ -305,13 +373,26 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
def on_sud_changed(self, msg):
|
||||
print("on_sud_changed {}".format(msg))
|
||||
for key in msg:
|
||||
if "Json" in key and self.sud_download_path:
|
||||
with open(self.sud_download_path, 'w') as f:
|
||||
json.dump(msg['Json'], f, indent='\t')
|
||||
self.sud_download_path = None
|
||||
if "Json" in key:
|
||||
if self.sud_download_path:
|
||||
with open(self.sud_download_path, 'w') as f:
|
||||
json.dump(msg['Json'], f, indent='\t')
|
||||
self.sud_download_path = None
|
||||
else:
|
||||
self.update_sud_forecast(msg['Json'])
|
||||
elif "Name" in key:
|
||||
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):
|
||||
print("Exitting gracefully!")
|
||||
self.ws_client.disconnect()
|
||||
|
||||
@@ -246,6 +246,12 @@ class Ui_MainWindow(object):
|
||||
self.horizontalTabWidget.addTab(self.horizontalTabWidgetPage1, "")
|
||||
self.tab = QtWidgets.QWidget()
|
||||
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.tab_2 = QtWidgets.QWidget()
|
||||
self.tab_2.setObjectName("tab_2")
|
||||
|
||||
Reference in New Issue
Block a user