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)