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:
2026-06-20 22:54:34 +02:00
parent 0e27c66fc7
commit 3289288488
4 changed files with 113 additions and 13 deletions
+12
View File
@@ -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
View File
@@ -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()
+6
View File
@@ -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")