diff --git a/client/brewpi.ui b/client/brewpi.ui
index 734adad..994f935 100644
--- a/client/brewpi.ui
+++ b/client/brewpi.ui
@@ -705,6 +705,18 @@
Automatic
+
+ -
+
+
+
+ 0
+ 400
+
+
+
+
+
diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py
index 1eb9735..198938c 100755
--- a/client/brewpi_gui.py
+++ b/client/brewpi_gui.py
@@ -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()
diff --git a/client/main_window.py b/client/main_window.py
index 6ec9f92..7bb797c 100644
--- a/client/main_window.py
+++ b/client/main_window.py
@@ -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")
diff --git a/tasks/sud.py b/tasks/sud.py
index 4ff1de1..f92b9a6 100644
--- a/tasks/sud.py
+++ b/tasks/sud.py
@@ -74,6 +74,7 @@ class SudTask(ATask):
elif 'Upload' in pair[0]:
if self.sud.upload(pair[1]):
await self.send({'Name': self.sud.name, 'Description': self.sud.description})
+ await self.send({'Json': pair[1]})
async def send(self, data):
await self.msg_handler.send(data)