Root cause of "forecast vanishes right after loading, before Start": Sud.elapsed resets to 0 on every fresh Load too, not just on an actual run start (components/sud.py's _reset_run_state()), and that reset broadcasts unconditionally. The GUI's Elapsed handler applied it unconditionally too, flipping sud_elapsed_seconds from None to 0.0 purely from loading a schedule - which made on_plot_timer() think a run had started, hijacking the plot into show_dynamic() (empty history, visible progress line, "(est.)" title) instead of the static forecast preview. Fixed by only applying an incoming Elapsed value once the State-transition logic has already put the client into dynamic-view mode, so a mere Load while idle can't masquerade as a run starting. Separately, eliminates the naive abs(delta)/rate forecast entirely, as instructed - both the immediate static placeholder shown the instant a schedule loaded (show_schedule()/_estimate_course()) and the dynamic per-tick fallback used until the server's simulated RemainingForecast arrived after a step change. The static preview now always waits for and shows only the simulation-based forecast (show_computing() while it's pending, then show_precomputed_course()); the dynamic dashed projection simply holds its last value during that brief gap instead of guessing. _remaining_schedule() and SUD_HOLDING_STATE, now unused, removed too. Also fixes the axes-scaling bug found along the way: relim()+ autoscale_view() leaves autoscaling switched on, so a later, unrelated redraw (e.g. set_current_temp()'s continuous updates, which keep that indicator live regardless of whether a forecast is even showing) could silently re-autoscale and stretch the view out to fit it - e.g. leftover heat from a previous, different run sitting well outside a newly loaded schedule's own range. show_precomputed_course() now computes and locks explicit xlim/ylim from the forecast data alone via the new _set_fixed_limits(). Verified live: a fresh load shows only the simulated forecast, correctly scaled, with no premature dynamic-mode hijacking; loading a schedule after a much hotter previous run no longer distorts the axes to fit the leftover actual temperature. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
933 lines
38 KiB
Python
Executable File
933 lines
38 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from main_window import Ui_MainWindow
|
|
from PyQt5 import QtWidgets, QtGui, QtCore
|
|
import json
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
import matplotlib
|
|
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
|
|
from user_config import UserConfig
|
|
|
|
# str(SudState.X) values (as sent in the 'State' message) that count as
|
|
# "running" for enabling/disabling the Start/Pause/Stop toolbar actions.
|
|
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
|
|
SUD_WAIT_USER_STATE = "SudState.WAIT_USER"
|
|
SUD_PAUSED_STATE = "SudState.PAUSED"
|
|
|
|
|
|
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
|
|
instead of an offline simulation run."""
|
|
|
|
def __init__(self):
|
|
figure = Figure(facecolor='white')
|
|
FigureCanvasQTAgg.__init__(self, figure)
|
|
|
|
self.ax_temp, self.ax_rate, self.ax_power = figure.subplots(3, 1, sharex=True)
|
|
|
|
self.line_temp_ist, = self.ax_temp.plot([], [], '-b', linewidth=1, label="theta_ist")
|
|
self.line_temp_soll, = self.ax_temp.plot([], [], '-r', linewidth=1, label="theta_soll")
|
|
self.ax_temp.set_ylabel("Temp [°C]")
|
|
self.ax_temp.legend(loc='upper left', fontsize='small')
|
|
|
|
self.line_rate_ist, = self.ax_rate.plot([], [], '-b', linewidth=1, label="heatrate_ist")
|
|
self.line_rate_soll, = self.ax_rate.plot([], [], '-r', linewidth=1, label="heatrate_soll")
|
|
self.ax_rate.set_ylabel("Rate [K/min]")
|
|
self.ax_rate.legend(loc='upper left', fontsize='small')
|
|
|
|
self.line_power_set, = self.ax_power.plot([], [], '-r', linewidth=1, label="power_set")
|
|
self.line_power_eff, = self.ax_power.plot([], [], '-b', linewidth=1, label="power_eff")
|
|
self.ax_power.set_ylabel("Power [W]")
|
|
self.ax_power.set_xlabel("t [s]")
|
|
self.ax_power.legend(loc='upper left', fontsize='small')
|
|
|
|
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
|
|
_style_axis(ax)
|
|
|
|
# Only the bottom subplot needs x tick labels - sharex keeps the
|
|
# others in sync without repeating them.
|
|
self.ax_temp.label_outer()
|
|
self.ax_rate.label_outer()
|
|
|
|
figure.tight_layout()
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.t0 = time.monotonic()
|
|
self.t = []
|
|
self.temp_ist = []
|
|
self.temp_soll = []
|
|
self.rate_ist = []
|
|
self.rate_soll = []
|
|
self.power_set = []
|
|
self.power_eff = []
|
|
|
|
def sample(self, temp_ist, temp_soll, rate_ist, rate_soll, power_set, power_eff):
|
|
self.t.append(time.monotonic() - self.t0)
|
|
self.temp_ist.append(temp_ist)
|
|
self.temp_soll.append(temp_soll)
|
|
self.rate_ist.append(rate_ist)
|
|
self.rate_soll.append(rate_soll)
|
|
self.power_set.append(power_set)
|
|
self.power_eff.append(power_eff)
|
|
|
|
self.line_temp_ist.set_data(self.t, self.temp_ist)
|
|
self.line_temp_soll.set_data(self.t, self.temp_soll)
|
|
self.line_rate_ist.set_data(self.t, self.rate_ist)
|
|
self.line_rate_soll.set_data(self.t, self.rate_soll)
|
|
self.line_power_set.set_data(self.t, self.power_set)
|
|
self.line_power_eff.set_data(self.t, self.power_eff)
|
|
|
|
for ax in (self.ax_temp, self.ax_rate, self.ax_power):
|
|
ax.relim()
|
|
ax.autoscale_view()
|
|
|
|
self.draw_idle()
|
|
|
|
|
|
class SudForecastPlot(FigureCanvasQTAgg):
|
|
"""A preview of a Sud schedule's temperature course, simulated
|
|
server-side with the same plant/controller model the real run uses
|
|
(components/sud_forecast.py's SudForecastEstimator - see
|
|
show_computing()/show_precomputed_course()). There used to also be an
|
|
immediate, naive abs(delta)/rate placeholder shown the instant a
|
|
schedule loaded, before the simulated one arrived a moment later -
|
|
removed because it was structurally wrong often enough (no model of
|
|
PID settling time, ramp dead time, etc.) to not be worth showing even
|
|
briefly.
|
|
|
|
Once running, nonideal ramp rates/user pauses make any static estimate
|
|
drift from reality - show_dynamic() instead re-anchors the projection
|
|
every tick: the already-elapsed part is the actual measured trace
|
|
(line, solid), and only the remaining steps are projected forward from
|
|
the live current temperature/step/hold_remaining (line_projected,
|
|
dashed)."""
|
|
|
|
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, zorder=2)
|
|
self.line_projected, = self.ax.plot([], [], '--b', linewidth=1.5, alpha=0.5, zorder=2)
|
|
self.progress_line = self.ax.axvline(0, color='g', linewidth=1.5, visible=False, zorder=2)
|
|
# Current temperature - drawn behind (lower zorder than) the
|
|
# forecast/progress lines so it never covers them.
|
|
self.current_temp_line = self.ax.axhline(0, color='r', linewidth=1.5, visible=False, zorder=1)
|
|
self.ax.set_xlabel("t [min]")
|
|
self.ax.set_ylabel("Temp [°C]")
|
|
_style_axis(self.ax)
|
|
|
|
figure.tight_layout()
|
|
|
|
def show_computing(self, name):
|
|
"""Shown right after a schedule loads, while the simulation-based
|
|
forecast is still being computed server-side (see
|
|
Window.on_sud_forecast_received()) - typically resolved within a
|
|
second. Deliberately shows nothing rather than an approximate
|
|
placeholder; leaves the axes' current view alone (there's nothing
|
|
meaningful to fit yet), so whatever was on screen before just
|
|
stays frozen underneath the title change until show_precomputed_
|
|
course() replaces it."""
|
|
self.line.set_data([], [])
|
|
self.line_projected.set_data([], [])
|
|
self.ax.set_title("{} - computing forecast...".format(name), fontsize='small')
|
|
self.figure.tight_layout()
|
|
self.draw_idle()
|
|
|
|
def show_precomputed_course(self, t_min, theta, name):
|
|
"""Static forecast computed by the server simulating the schedule
|
|
with its real plant/controller (components/sud_forecast.py) - see
|
|
Window.on_sud_forecast_received()."""
|
|
self.line.set_data(t_min, theta)
|
|
self.line_projected.set_data([], [])
|
|
self._set_fixed_limits(t_min, theta)
|
|
self.ax.set_title("{} - {:.0f} min total".format(name, t_min[-1] if t_min else 0.0), fontsize='small')
|
|
self.figure.tight_layout()
|
|
self.draw_idle()
|
|
|
|
def _set_fixed_limits(self, t_list, theta_list):
|
|
"""Explicitly sets (and, unlike relim()+autoscale_view(), locks)
|
|
the axes' view to fit exactly the given data, with matplotlib's
|
|
usual ~5% margin. Locking matters here: relim()+autoscale_view()
|
|
leaves autoscaling switched on, so a later, unrelated redraw -
|
|
e.g. set_current_temp()'s continuous updates, which keep that
|
|
indicator live regardless of whether a forecast is even showing -
|
|
can silently re-autoscale and stretch the view back out to fit it
|
|
instead, squashing the actual forecast curve down to nothing."""
|
|
if not t_list or not theta_list:
|
|
return
|
|
t_lo, t_hi = min(t_list), max(t_list)
|
|
th_lo, th_hi = min(theta_list), max(theta_list)
|
|
t_pad = (t_hi - t_lo) * 0.05 or 1.0
|
|
th_pad = (th_hi - th_lo) * 0.05 or 1.0
|
|
self.ax.set_xlim(t_lo - t_pad, t_hi + t_pad)
|
|
self.ax.set_ylim(th_lo - th_pad, th_hi + th_pad)
|
|
|
|
def show_dynamic(self, history, t_rem_min, theta_rem, name):
|
|
"""Re-anchored forecast for a run in progress: history is the
|
|
[(t_min, theta), ...] actually measured so far (drawn solid);
|
|
t_rem_min/theta_rem is the projected remainder (dashed), the
|
|
server's simulation-based SudForecastEstimator result for
|
|
whatever's left of the schedule, already anchored by the caller
|
|
(see Window.on_plot_timer()). Either can be None - meaning the
|
|
server hasn't recomputed it yet for the step just entered - in
|
|
which case the dashed line is simply left showing whatever it
|
|
last did, rather than guessing at a replacement."""
|
|
hist_t = [t for t, _ in history]
|
|
hist_theta = [theta for _, theta in history]
|
|
self.line.set_data(hist_t, hist_theta)
|
|
if t_rem_min is not None:
|
|
self.line_projected.set_data(t_rem_min, theta_rem)
|
|
|
|
self.ax.relim()
|
|
self.ax.autoscale_view()
|
|
total_min = t_rem_min[-1] if t_rem_min else (hist_t[-1] if hist_t else 0.0)
|
|
self.ax.set_title("{} - {:.0f} min total (est.)".format(name, total_min), fontsize='small')
|
|
self.figure.tight_layout()
|
|
self.draw_idle()
|
|
|
|
def show_no_schedule(self):
|
|
self.line.set_data([], [])
|
|
self.line_projected.set_data([], [])
|
|
self.clear_progress()
|
|
self.ax.relim()
|
|
self.ax.autoscale_view()
|
|
self.ax.set_title("No sud loaded", fontsize='small')
|
|
self.figure.tight_layout()
|
|
self.draw_idle()
|
|
|
|
def set_progress(self, t_min):
|
|
self.progress_line.set_xdata([t_min, t_min])
|
|
self.progress_line.set_visible(True)
|
|
self.draw_idle()
|
|
|
|
def clear_progress(self):
|
|
self.progress_line.set_visible(False)
|
|
self.draw_idle()
|
|
|
|
def set_current_temp(self, theta):
|
|
self.current_temp_line.set_ydata([theta, theta])
|
|
self.current_temp_line.set_visible(True)
|
|
self.draw_idle()
|
|
|
|
def clear_current_temp(self):
|
|
self.current_temp_line.set_visible(False)
|
|
self.draw_idle()
|
|
|
|
|
|
class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|
# on_*_changed()/on_ws_connect_changed() are invoked straight from the
|
|
# websocket client's background thread (WsClient.bg_thread), but Qt
|
|
# widgets may only be touched from the GUI thread. A signal/slot crosses
|
|
# that boundary safely (Qt auto-queues the slot call onto the receiver's
|
|
# thread when sender and receiver differ) - recv_signal carries the
|
|
# handler itself plus its one argument, so every channel can share it
|
|
# instead of needing one signal per handler.
|
|
recv_signal = QtCore.pyqtSignal(object, object)
|
|
|
|
def __init__(self):
|
|
QtWidgets.QMainWindow.__init__(self)
|
|
self.setWindowIcon(QtGui.QIcon('res/beer.png'))
|
|
self.recv_signal.connect(self._dispatch_recv)
|
|
self.user_config = UserConfig()
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True)
|
|
self.msg_dispatch.on_connect_changed = lambda connected: self.recv_signal.emit(self.on_ws_connect_changed, connected)
|
|
self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop)
|
|
self.connected = False
|
|
self.sud_loaded = False
|
|
# Whether the loaded schedule has zero steps (e.g. just after "New
|
|
# Sud") - distinct from sud_loaded, which just means a Sud is
|
|
# configured server-side at all. Defaults True so Start/Pause/Stop
|
|
# stay disabled until a non-empty schedule is actually confirmed.
|
|
self.sud_empty = True
|
|
self.sud_state = None
|
|
self.sud_user_message = None
|
|
# Mirrors the server's TempController.enabled - gates the
|
|
# temp/heatrate setpoint controls (see on_tempctrl_changed()).
|
|
self.tc_enabled = False
|
|
# Global, not Sud-specific - from the 'System' channel's one-time
|
|
# startup message. warp_factor defaults to 1.0 (no scaling) until
|
|
# that arrives, or for a server that doesn't send it at all.
|
|
self.ambient_temp = None
|
|
self.warp_factor = 1.0
|
|
# Elapsed simulated seconds of the current run, as reported by the
|
|
# server (Sud's own tick-counted 'elapsed' - see components/sud.py) -
|
|
# None while not running. Used to position the forecast plot's
|
|
# progress line; ground truth, not reconstructed from wall-clock
|
|
# time, since the server's actual achieved speedup runs measurably
|
|
# below its nominal configured warp factor under real scheduling
|
|
# load, which used to make the live trace visibly drift from the
|
|
# forecast.
|
|
self.sud_elapsed_seconds = None
|
|
# Resolved schedule + live progress within it, kept around so the
|
|
# forecast plot can be dynamically re-anchored every tick instead of
|
|
# only estimated once when the schedule was (re)loaded - see
|
|
# refresh_forecast()/SudForecastPlot.show_dynamic().
|
|
self.sud_name = ''
|
|
self.sud_schedule = []
|
|
self.sud_step_index = None
|
|
self.sud_step_descr = None
|
|
self.sud_step_type = None
|
|
self.sud_hold_remaining = 0.0
|
|
# [(t_min, theta), ...] actually measured since the current run
|
|
# started - the "already happened" part of the dynamic forecast.
|
|
self.forecast_history = []
|
|
# (anchor_min, T, Theta) from the server's simulation-based estimate
|
|
# of the remaining steps, recomputed on every step change -
|
|
# anchor_min is elapsed_min as of receipt, so on_plot_timer() can
|
|
# re-anchor the projection without it drifting right every tick.
|
|
# None until it arrives (or after a step change, until the next one
|
|
# does), in which case on_plot_timer() falls back to the naive
|
|
# estimate.
|
|
self.server_remaining_forecast = None
|
|
self.msg_pot = self.msg_dispatch.msgio_get('Pot')
|
|
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
|
|
self.msg_heater = self.msg_dispatch.msgio_get('Heater')
|
|
self.msg_stirrer = self.msg_dispatch.msgio_get('Stirrer')
|
|
self.msg_tempctrl = self.msg_dispatch.msgio_get('TempCtrl')
|
|
self.msg_sud = self.msg_dispatch.msgio_get('Sud')
|
|
self.msg_system = self.msg_dispatch.msgio_get('System')
|
|
|
|
# set_recv_handler's callback runs on WsClient's background thread -
|
|
# route each through recv_signal instead of wiring the real handler
|
|
# directly, so its body always runs on the GUI thread.
|
|
self.msg_pot.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_pot_changed, msg))
|
|
self.msg_sensor.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sensor_changed, msg))
|
|
self.msg_heater.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_heater_changed, msg))
|
|
self.msg_stirrer.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_stirrer_changed, msg))
|
|
self.msg_tempctrl.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_tempctrl_changed, msg))
|
|
self.msg_sud.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sud_changed, msg))
|
|
self.msg_system.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_system_changed, msg))
|
|
|
|
self.setupUi(self)
|
|
# Matches tc_enabled's default (False) until the server says otherwise.
|
|
self.Slider_temp_soll.setEnabled(False)
|
|
self.doubleSpinBox_heatrate_soll.setEnabled(False)
|
|
# Permanent (right-aligned, not cleared by showMessage()/clearMessage())
|
|
# label for static environment info - distinct from the status bar's
|
|
# transient step/schedule messages.
|
|
self.status_label_env = QtWidgets.QLabel()
|
|
self.statusBar().addPermanentWidget(self.status_label_env)
|
|
self.update_status_env_label()
|
|
|
|
# Blinking indicator for the temperature controller's own COOL
|
|
# state (target below the current temperature - the heater is
|
|
# forced off server-side and we just wait for ambient loss to
|
|
# catch up).
|
|
self.tc_cooling = False
|
|
self.status_label_cooling = QtWidgets.QLabel("Cool down")
|
|
self.status_label_cooling.setStyleSheet("color: blue; font-weight: bold;")
|
|
self.status_label_cooling.setVisible(False)
|
|
self.statusBar().addPermanentWidget(self.status_label_cooling)
|
|
self.cooling_blink_timer = QtCore.QTimer(self)
|
|
self.cooling_blink_timer.timeout.connect(self.on_cooling_blink)
|
|
|
|
self.update_sud_actions()
|
|
self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
|
|
self.actionStart.triggered.connect(self.on_action_sud_start)
|
|
self.actionPause.triggered.connect(self.on_action_sud_pause)
|
|
self.actionStop.triggered.connect(self.on_action_sud_stop)
|
|
self.actionSudNew.triggered.connect(self.on_action_sud_new)
|
|
self.actionSudSave.triggered.connect(self.on_action_sud_save)
|
|
self.actionSudLoad.triggered.connect(self.on_action_sud_load)
|
|
self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed)
|
|
self.Slider_temp_soll.valueChanged.connect(self.on_slider_temp_soll_changed)
|
|
self.Slider_speed_soll.valueChanged.connect(self.on_slider_stirrer_speed_soll_changed)
|
|
|
|
self.doubleSpinBox_heatrate_soll.valueChanged.connect(self.on_heatrate_soll_changed)
|
|
|
|
self.checkbox_heater_activate.stateChanged.connect(self.on_checkbox_changed)
|
|
self.checkbox_stirrer_activate.stateChanged.connect(self.on_checkbox_stirrer_activate_changed)
|
|
self.checkbox_tc_enable.stateChanged.connect(self.on_checkbox_tc_enable_changed)
|
|
self.lineEdit_ambient.returnPressed.connect(self.on_ambient_entry_changed)
|
|
self.btn_pot_reset.clicked.connect(self.on_action_pot_reset)
|
|
# Only meaningful (and only shown) once the server confirms its Pot
|
|
# is simulated - see on_system_changed()'s "PlantSim" handling.
|
|
self.btn_pot_reset.setVisible(False)
|
|
|
|
self.send_data = Queue()
|
|
self.slider_pwr_initial_update = True
|
|
self.slider_temp_soll_initial_update = True
|
|
self.slider_speed_initial_update = True
|
|
self.checkbox_heater_activate_initial_update = True
|
|
self.checkbox_stirrer_activate_initial_update = True
|
|
self.heatrate_soll_initial_update = True
|
|
self.sud_save_path = None
|
|
|
|
# Latest values sampled by the plot timer - written from the
|
|
# websocket recv thread, read from the GUI thread; plain floats so
|
|
# no locking, consistent with how the rest of the GUI is wired.
|
|
self.plot_temp_ist = 0.0
|
|
self.plot_temp_soll = 0.0
|
|
self.plot_rate_ist = 0.0
|
|
self.plot_rate_soll = 0.0
|
|
self.plot_power_set = 0.0
|
|
self.plot_power_eff = 0.0
|
|
|
|
self.plot = RealtimePlot()
|
|
plot_layout = QtWidgets.QVBoxLayout(self.widget_plot)
|
|
plot_layout.setContentsMargins(0, 0, 0, 0)
|
|
plot_layout.addWidget(self.plot)
|
|
|
|
self.plot_timer = QtCore.QTimer(self)
|
|
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 _dispatch_recv(self, handler, msg):
|
|
"""recv_signal's slot - runs on the GUI thread regardless of which
|
|
thread emitted the signal, so handler(msg) can touch Qt widgets
|
|
safely."""
|
|
handler(msg)
|
|
|
|
def connect(self):
|
|
self.slider_pwr_initial_update = True
|
|
self.slider_temp_soll_initial_update = True
|
|
self.slider_speed_initial_update = True
|
|
self.checkbox_heater_activate_initial_update = True
|
|
self.checkbox_stirrer_activate_initial_update = True
|
|
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 save, so sud_save_path stays None and
|
|
# on_sud_changed() routes the reply to update_sud_forecast() instead
|
|
# of a save dialog.
|
|
self.msg_sud.send({'Save': True})
|
|
# Re-apply the last ambient temperature we set, in case the server
|
|
# restarted since (and so fell back to its own config.json default).
|
|
remembered_ambient = self.user_config.get('ambient_temperature')
|
|
if remembered_ambient is not None:
|
|
self.msg_system.send({'AmbientTemp': remembered_ambient})
|
|
|
|
def _elapsed_min(self):
|
|
"""Simulated-schedule minutes elapsed since the run started, or
|
|
None if no run is in progress - straight from the server's own
|
|
tick-counted Sud.elapsed (see on_sud_changed()'s 'Elapsed'
|
|
handling), already frozen while PAUSED there."""
|
|
if self.sud_elapsed_seconds is None:
|
|
return None
|
|
return self.sud_elapsed_seconds / 60.0
|
|
|
|
def on_plot_timer(self):
|
|
self.plot.sample(
|
|
self.plot_temp_ist, self.plot_temp_soll,
|
|
self.plot_rate_ist, self.plot_rate_soll,
|
|
self.plot_power_set, self.plot_power_eff)
|
|
|
|
elapsed_min = self._elapsed_min()
|
|
if elapsed_min is not None:
|
|
self.forecast_plot.set_progress(elapsed_min)
|
|
|
|
# Re-anchor the forecast from the live state instead of redrawing
|
|
# the static, increasingly-stale estimate from t=0. Frozen while
|
|
# PAUSED (elapsed_min isn't advancing either, and the paused
|
|
# phase - ramp or hold - isn't visible to the client to resume
|
|
# the projection from correctly) - just leave the last draw up.
|
|
if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist:
|
|
self.forecast_history.append((elapsed_min, self.plot_temp_ist))
|
|
if self.server_remaining_forecast is not None:
|
|
# Anchored to elapsed_min as of when the server computed
|
|
# this forecast, not the current tick's - it's only
|
|
# recomputed on step changes, so re-adding the live,
|
|
# ever-growing elapsed_min here would push the whole
|
|
# projection further right every tick until the next
|
|
# step change, instead of holding it fixed.
|
|
anchor_min, t_rem, theta_rem = self.server_remaining_forecast
|
|
t_rem_min = [anchor_min + seconds / 60.0 for seconds in t_rem]
|
|
self.forecast_plot.show_dynamic(self.forecast_history, t_rem_min, theta_rem, self.sud_name)
|
|
else:
|
|
# Server's simulation-based remainder hasn't arrived yet
|
|
# for the step just entered (recomputed off the event
|
|
# loop on every step change) - leave the dashed
|
|
# projection as whatever it last showed instead of
|
|
# guessing; it catches up within a tick or two.
|
|
self.forecast_plot.show_dynamic(self.forecast_history, None, None, self.sud_name)
|
|
else:
|
|
self.forecast_plot.clear_progress()
|
|
|
|
if self.plot_temp_ist:
|
|
self.forecast_plot.set_current_temp(self.plot_temp_ist)
|
|
else:
|
|
self.forecast_plot.clear_current_temp()
|
|
|
|
def disconnect(self):
|
|
# ws_client.disconnect() blocks until the websocket's close handshake
|
|
# completes (or times out, ~10s if the server doesn't ack promptly),
|
|
# joining the connection's background thread - run it off a throwaway
|
|
# thread so that wait doesn't freeze the GUI. on_ws_connect_changed()
|
|
# still flips the button once the disconnect actually finishes.
|
|
threading.Thread(target=self.ws_client.disconnect, daemon=True).start()
|
|
|
|
def on_btn_connect_clicked(self):
|
|
if self.connected:
|
|
self.disconnect()
|
|
else:
|
|
self.connect()
|
|
|
|
def on_ws_connect_changed(self, connected):
|
|
self.connected = connected
|
|
self.btn_connect.setText("Disconnect" if connected else "Connect")
|
|
if not connected:
|
|
# Nothing reported over a dead connection can be trusted anymore.
|
|
self.sud_loaded = False
|
|
self.sud_empty = True
|
|
self.sud_state = None
|
|
self.sud_user_message = None
|
|
self.tc_enabled = False
|
|
self.checkbox_tc_enable.blockSignals(True)
|
|
self.checkbox_tc_enable.setChecked(False)
|
|
self.checkbox_tc_enable.blockSignals(False)
|
|
self.Slider_temp_soll.setEnabled(False)
|
|
self.doubleSpinBox_heatrate_soll.setEnabled(False)
|
|
self.ambient_temp = None
|
|
self.lineEdit_ambient.clear()
|
|
self.btn_pot_reset.setVisible(False)
|
|
self.warp_factor = 1.0
|
|
self.sud_elapsed_seconds = None
|
|
self.sud_name = ''
|
|
self.sud_schedule = []
|
|
self.sud_step_index = None
|
|
self.sud_step_descr = None
|
|
self.sud_step_type = None
|
|
self.sud_hold_remaining = 0.0
|
|
self.forecast_history = []
|
|
self.server_remaining_forecast = None
|
|
self.set_tc_cooling(False)
|
|
self.update_sud_actions()
|
|
self.update_status_env_label()
|
|
self.setWindowTitle("BrewPi")
|
|
|
|
def update_sud_actions(self):
|
|
can_run = self.sud_loaded and not self.sud_empty
|
|
running = can_run and self.sud_state in SUD_RUNNING_STATES
|
|
paused = can_run and self.sud_state == SUD_PAUSED_STATE
|
|
# Start also doubles as Resume while paused; Stop can still abort a
|
|
# paused run, but there's nothing left to Pause once already paused.
|
|
self.actionStart.setEnabled(can_run and not running)
|
|
self.actionPause.setEnabled(running)
|
|
self.actionStop.setEnabled(running or paused)
|
|
|
|
def on_system_changed(self, msg):
|
|
print("on_system_changed {}".format(msg))
|
|
for key in msg:
|
|
if "AmbientTemp" in key:
|
|
self.ambient_temp = msg['AmbientTemp']
|
|
# Only sync the entry's text while the user isn't actively
|
|
# editing it, so a server echo doesn't clobber in-progress
|
|
# typing.
|
|
if not self.lineEdit_ambient.hasFocus():
|
|
self.lineEdit_ambient.setText("{:.1f}".format(self.ambient_temp))
|
|
elif "WarpFactor" in key:
|
|
self.warp_factor = msg['WarpFactor']
|
|
elif "PlantSim" in key:
|
|
self.btn_pot_reset.setVisible(msg['PlantSim'])
|
|
self.update_status_env_label()
|
|
|
|
def on_action_pot_reset(self):
|
|
self.msg_pot.send({'Reset': True})
|
|
|
|
def on_ambient_entry_changed(self):
|
|
try:
|
|
value = float(self.lineEdit_ambient.text())
|
|
except ValueError:
|
|
return
|
|
self.user_config.set('ambient_temperature', value)
|
|
self.msg_system.send({'AmbientTemp': value})
|
|
|
|
def update_status_env_label(self):
|
|
ambient = "{:.1f}°C".format(self.ambient_temp) if self.ambient_temp is not None else "-"
|
|
self.status_label_env.setText("Ambient: {} Warp: {:.1f}x".format(ambient, self.warp_factor))
|
|
|
|
@staticmethod
|
|
def _make_window_title(name, description):
|
|
title = "BrewPi"
|
|
if name:
|
|
title += " - {}".format(name)
|
|
if description:
|
|
title += " - {}".format(description)
|
|
return title
|
|
|
|
def set_tc_cooling(self, cooling):
|
|
if cooling == self.tc_cooling:
|
|
return
|
|
self.tc_cooling = cooling
|
|
if cooling:
|
|
self.status_label_cooling.setVisible(True)
|
|
self.cooling_blink_timer.start(500)
|
|
else:
|
|
self.cooling_blink_timer.stop()
|
|
self.status_label_cooling.setVisible(False)
|
|
|
|
def on_cooling_blink(self):
|
|
self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible())
|
|
|
|
def on_slider_temp_soll_changed(self, value):
|
|
print("on_slider_temp_soll_changed {}".format(value))
|
|
self.msg_tempctrl.send({'Soll': {'Temp': value}})
|
|
|
|
def on_heatrate_soll_changed(self, value):
|
|
value = round(value, 1)
|
|
print("on_textbox_heatrate_soll_changed {}".format(value))
|
|
self.msg_tempctrl.send({'Soll': {'Rate': value}})
|
|
|
|
def on_slider_pwr_soll_changed(self, value):
|
|
print("on_slider_pwr_soll_changed {}".format(value))
|
|
self.msg_heater.send({'Power': value})
|
|
|
|
def on_checkbox_changed(self, value):
|
|
print("on_checkbox_changed {}".format(value))
|
|
self.msg_heater.send({'Activate': int(value == 2)})
|
|
|
|
def on_slider_stirrer_speed_soll_changed(self, value):
|
|
print("on_slider_stirrer_speed_soll_changed {}".format(value))
|
|
self.msg_stirrer.send({'Speed': value})
|
|
|
|
def on_checkbox_stirrer_activate_changed(self, value):
|
|
print("on_checkbox_stirrer_activate_changed {}".format(value))
|
|
self.msg_stirrer.send({'Activate': int(value == 2)})
|
|
|
|
def on_checkbox_tc_enable_changed(self, value):
|
|
print("on_checkbox_tc_enable_changed {}".format(value))
|
|
self.msg_tempctrl.send({'Enable': value == 2})
|
|
|
|
def on_action_sud_start(self):
|
|
self.msg_sud.send({'Start': True})
|
|
|
|
def on_action_sud_pause(self):
|
|
self.msg_sud.send({'Pause': True})
|
|
|
|
def on_action_sud_stop(self):
|
|
self.msg_sud.send({'Stop': True})
|
|
|
|
def show_user_message(self, message):
|
|
# on_sud_changed() already runs on the GUI thread (via recv_signal),
|
|
# so this can show the dialog directly. The Sud is already blocked
|
|
# in WAIT_USER server-side; closing this dialog is what unblocks it.
|
|
QtWidgets.QMessageBox.information(self, "Sud", message)
|
|
self.msg_sud.send({'Confirm': True})
|
|
|
|
def on_action_sud_new(self):
|
|
# Sends an empty schedule via the same Load path Load Sud uses, with
|
|
# a built-in empty document instead of a file dialog - the server
|
|
# itself refuses this (like any Load) while a run is in progress,
|
|
# surfacing an error (see on_sud_changed()'s 'Error' handling)
|
|
# rather than the GUI trying to guess that here.
|
|
self.msg_sud.send({'Load': {'Name': '', 'Description': '', 'pot_mass': 0, 'pot_material': None, 'steps': []}})
|
|
|
|
def on_action_sud_save(self):
|
|
# Pick the destination up front, on the GUI thread - the actual
|
|
# write happens in on_sud_changed(), which runs off a worker thread
|
|
# and must not touch Qt (e.g. open a file dialog) itself.
|
|
start_dir = self.user_config.get('sud_dir', '')
|
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Sud", start_dir, filter="JSON files (*.json)")
|
|
if not path:
|
|
return
|
|
self.user_config.set('sud_dir', str(Path(path).parent))
|
|
self.sud_save_path = path
|
|
self.msg_sud.send({'Save': True})
|
|
|
|
def on_action_sud_load(self):
|
|
start_dir = self.user_config.get('sud_dir', '')
|
|
path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Sud", start_dir, filter="JSON files (*.json)")
|
|
if not path:
|
|
return
|
|
self.user_config.set('sud_dir', str(Path(path).parent))
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
self.msg_sud.send({'Load': data})
|
|
|
|
def on_pot_changed(self, msg):
|
|
print("on_pot_changed {}".format(msg))
|
|
for key in msg:
|
|
if "Power" in key:
|
|
self.lcdNumber_power_pot.display(msg['Power'])
|
|
|
|
def on_sensor_changed(self, msg):
|
|
print("on_sensor_changed {}".format(msg))
|
|
|
|
def on_tempctrl_changed(self, msg):
|
|
print("on_tempctrl_changed {}".format(msg))
|
|
for key in msg:
|
|
if 'State' in key:
|
|
self.label_state.setText(msg['State'])
|
|
self.set_tc_cooling(msg['State'] == 'States.COOL')
|
|
elif "Soll" in key:
|
|
submsg = msg['Soll']
|
|
if 'Temp' in submsg:
|
|
self.plot_temp_soll = submsg['Temp']
|
|
self.lcdNumber_temp_soll.display(str(submsg['Temp']))
|
|
if self.slider_temp_soll_initial_update:
|
|
# blockSignals: setValue() here is just syncing the
|
|
# slider's display to the server's current value, not
|
|
# a user edit - without this it would also fire
|
|
# valueChanged and echo the value straight back as a
|
|
# command.
|
|
self.Slider_temp_soll.blockSignals(True)
|
|
self.Slider_temp_soll.setValue(int(round(submsg['Temp'])))
|
|
self.Slider_temp_soll.blockSignals(False)
|
|
self.slider_temp_soll_initial_update = False
|
|
if 'Rate' in submsg:
|
|
subsubmsg = submsg['Rate']
|
|
for subkey in subsubmsg:
|
|
if "Current" in subkey:
|
|
self.plot_rate_soll = subsubmsg['Current']
|
|
self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current']))
|
|
if "Set" in subkey:
|
|
if self.heatrate_soll_initial_update:
|
|
self.heatrate_soll_initial_update = False
|
|
self.doubleSpinBox_heatrate_soll.blockSignals(True)
|
|
self.doubleSpinBox_heatrate_soll.setValue(subsubmsg['Set'])
|
|
self.doubleSpinBox_heatrate_soll.blockSignals(False)
|
|
elif "Enabled" in key:
|
|
self.tc_enabled = msg['Enabled']
|
|
self.checkbox_tc_enable.blockSignals(True)
|
|
self.checkbox_tc_enable.setChecked(self.tc_enabled)
|
|
self.checkbox_tc_enable.blockSignals(False)
|
|
self.Slider_temp_soll.setEnabled(self.tc_enabled)
|
|
self.doubleSpinBox_heatrate_soll.setEnabled(self.tc_enabled)
|
|
if "Ist" in key:
|
|
submsg = msg['Ist']
|
|
if 'Temp' in submsg:
|
|
self.plot_temp_ist = submsg['Temp']
|
|
self.lcdNumber_temp_ist.display(str(submsg['Temp']))
|
|
if 'Rate' in submsg:
|
|
self.plot_rate_ist = submsg['Rate']
|
|
self.lcdNumber_heatrate_ist.display(str(submsg['Rate']))
|
|
|
|
def on_heater_changed(self, msg):
|
|
print("on_heater_changed {}".format(msg))
|
|
for key in msg:
|
|
if "Activate" in key:
|
|
if self.checkbox_heater_activate_initial_update:
|
|
self.checkbox_heater_activate.blockSignals(True)
|
|
self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
|
|
self.checkbox_heater_activate.blockSignals(False)
|
|
self.checkbox_heater_activate_initial_update = False
|
|
elif "PowerSet" in key:
|
|
self.plot_power_set = msg['PowerSet']
|
|
elif "Power" in key:
|
|
self.plot_power_eff = msg['Power']
|
|
self.lcdNumber_power_heater.display(msg['Power'])
|
|
if self.slider_pwr_initial_update:
|
|
# blockSignals: this is a display-only sync of the
|
|
# slider to the heater's current power, not a manual
|
|
# override - letting valueChanged fire here would echo
|
|
# it back as {'Power': ...}, which tasks/heater.py's
|
|
# power_soll = max(power_soll, power_actor) latches as a
|
|
# floor that never goes back down on its own. That's the
|
|
# "stale power > 0 after reconnect" bug.
|
|
self.Slider_pwr_soll.blockSignals(True)
|
|
self.Slider_pwr_soll.setValue(int(round(msg['Power'])))
|
|
self.Slider_pwr_soll.blockSignals(False)
|
|
self.slider_pwr_initial_update = False
|
|
elif "Capabilities" in key:
|
|
submsg = msg['Capabilities']
|
|
if "Power" in submsg:
|
|
submsg = submsg['Power']
|
|
self.Slider_pwr_soll.setMinimum(int(submsg['Min']))
|
|
self.Slider_pwr_soll.setMaximum(int(submsg['Max']))
|
|
|
|
def on_stirrer_changed(self, msg):
|
|
print("on_stirrer_changed {}".format(msg))
|
|
for key in msg:
|
|
if "Activate" in key:
|
|
if self.checkbox_stirrer_activate_initial_update:
|
|
self.checkbox_stirrer_activate.blockSignals(True)
|
|
self.checkbox_stirrer_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
|
|
self.checkbox_stirrer_activate.blockSignals(False)
|
|
self.checkbox_stirrer_activate_initial_update = False
|
|
elif "Speed" in key:
|
|
if self.slider_speed_initial_update:
|
|
self.Slider_speed_soll.blockSignals(True)
|
|
self.Slider_speed_soll.setValue(int(round(msg['Speed'])))
|
|
self.Slider_speed_soll.blockSignals(False)
|
|
self.slider_speed_initial_update = False
|
|
elif "Capabilities" in key:
|
|
submsg = msg['Capabilities']
|
|
if "Power" in submsg:
|
|
submsg = submsg['Power']
|
|
self.Slider_speed_soll.setMinimum(int(submsg['Min']))
|
|
self.Slider_speed_soll.setMaximum(int(submsg['Max']))
|
|
|
|
def on_sud_changed(self, msg):
|
|
print("on_sud_changed {}".format(msg))
|
|
# Receiving anything at all on this channel means a Sud is loaded
|
|
# server-side - the schedule it's running may still be coming.
|
|
self.sud_loaded = True
|
|
for key in msg:
|
|
if "Json" in key:
|
|
if self.sud_save_path:
|
|
with open(self.sud_save_path, 'w') as f:
|
|
json.dump(msg['Json'], f, indent='\t')
|
|
self.sud_save_path = None
|
|
else:
|
|
self.update_sud_forecast(msg['Json'])
|
|
elif "Name" in key:
|
|
self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
|
|
self.setWindowTitle(self._make_window_title(msg.get('Name', ''), msg.get('Description', '')))
|
|
elif "State" in key:
|
|
prev_state = self.sud_state
|
|
self.sud_state = msg['State']
|
|
|
|
if self.sud_state in SUD_RUNNING_STATES and self.sud_elapsed_seconds is None:
|
|
self.sud_elapsed_seconds = 0.0
|
|
# Start the dynamic forecast's measured-history trace
|
|
# fresh for this run.
|
|
self.forecast_history = []
|
|
elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE:
|
|
was_running = self.sud_elapsed_seconds is not None
|
|
self.sud_elapsed_seconds = None
|
|
# on_plot_timer() only redraws the dynamic forecast while
|
|
# sud_elapsed_seconds is set.
|
|
if was_running:
|
|
# A run just finished or was stopped - leave the
|
|
# dynamic view's last frame (the actual measured
|
|
# history) up rather than recomputing a "static"
|
|
# plan from here, which would walk the *whole*
|
|
# original schedule again starting from wherever
|
|
# the brew happens to have ended up (e.g. close to
|
|
# the last step's target), producing a nonsensical
|
|
# total.
|
|
pass
|
|
elif self.sud_schedule:
|
|
# Fresh load (or reconnecting to an already-idle
|
|
# server) - no run has happened yet, so the upfront
|
|
# plan is exactly right; on_sud_forecast_received()
|
|
# fills it in once the simulated forecast arrives
|
|
# (a fresh Save request goes out on every connect,
|
|
# so this resolves shortly either way).
|
|
self.forecast_plot.show_computing(self.sud_name)
|
|
|
|
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
|
|
self.show_user_message(self.sud_user_message)
|
|
elif "UserMessage" in key:
|
|
self.sud_user_message = msg['UserMessage']
|
|
elif "Step" in key:
|
|
step = msg['Step']
|
|
self.sud_step_index = step.get('Index')
|
|
self.sud_step_descr = step.get('Descr')
|
|
self.sud_step_type = step.get('Type')
|
|
# Stale for the step that just ended - the server is
|
|
# already recomputing it (see on_plot_timer()'s fallback
|
|
# to the naive estimate in the meantime).
|
|
self.server_remaining_forecast = None
|
|
self.update_status_step_label()
|
|
elif "HoldRemaining" in key:
|
|
self.sud_hold_remaining = msg['HoldRemaining']
|
|
self.update_status_step_label()
|
|
elif "Elapsed" in key:
|
|
# Sud.elapsed resets to 0 on every fresh Load too (not just
|
|
# on an actual run start - see components/sud.py's
|
|
# _reset_run_state()) and broadcasts unconditionally - only
|
|
# apply it once the State handling above has already put
|
|
# this client into dynamic-view mode (sud_elapsed_seconds
|
|
# not None), so a mere Load while idle can't masquerade as
|
|
# a run starting.
|
|
if self.sud_elapsed_seconds is not None:
|
|
self.sud_elapsed_seconds = msg['Elapsed']
|
|
elif "RemainingForecast" in key:
|
|
forecast = msg['RemainingForecast']
|
|
# Anchor to elapsed_min as of receipt - close enough to
|
|
# when the server actually computed it (it's seeded from
|
|
# the live theta_ist at that moment) - rather than the
|
|
# live elapsed_min on_plot_timer() sees on later ticks.
|
|
anchor_min = self._elapsed_min()
|
|
if anchor_min is not None:
|
|
self.server_remaining_forecast = (anchor_min, forecast['T'], forecast['Theta'])
|
|
elif "Forecast" in key:
|
|
self.on_sud_forecast_received(msg['Forecast'])
|
|
elif "Error" in key:
|
|
# The server clears this back to None right after sending
|
|
# it (see tasks/sud.py's recv()) so it doesn't linger in
|
|
# global_state and get replayed as stale to a client
|
|
# connecting later - the clearing message itself must be
|
|
# a no-op here, not an empty dialog.
|
|
if msg['Error']:
|
|
QtWidgets.QMessageBox.warning(self, "Sud", msg['Error'])
|
|
self.update_sud_actions()
|
|
|
|
def on_sud_forecast_received(self, forecast):
|
|
# The server computes this asynchronously (it simulates the whole
|
|
# schedule) and it can arrive slightly after the schedule itself -
|
|
# only apply it if we're still looking at the static "not running
|
|
# yet" preview show_computing() drew; a run already in progress (or
|
|
# one that's since finished) owns the plot via show_dynamic()/its
|
|
# frozen last frame instead.
|
|
if self.sud_empty or self.sud_elapsed_seconds is not None:
|
|
return
|
|
t_min = [seconds / 60.0 for seconds in forecast['T']]
|
|
self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name)
|
|
|
|
def update_status_step_label(self):
|
|
if not self.sud_step_descr:
|
|
self.statusBar().clearMessage()
|
|
return
|
|
text = "Step {}: {}".format(self.sud_step_index, self.sud_step_descr)
|
|
if self.sud_step_type == 'hold':
|
|
remaining = max(self.sud_hold_remaining, 0.0)
|
|
text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
|
|
self.statusBar().showMessage(text)
|
|
|
|
def update_sud_forecast(self, doc):
|
|
try:
|
|
name, _, schedule, _, _ = Sud._parse_data(doc)
|
|
except (KeyError, TypeError):
|
|
return
|
|
self.sud_name = name
|
|
self.sud_schedule = schedule
|
|
self.sud_empty = len(schedule) == 0
|
|
self.update_sud_actions()
|
|
if self.sud_empty:
|
|
self.forecast_plot.show_no_schedule()
|
|
return
|
|
# on_sud_forecast_received() fills this in once the simulated
|
|
# forecast arrives. If a run is already in progress (e.g.
|
|
# reconnecting mid-brew), the next on_plot_timer() tick switches
|
|
# this over to the dynamic view instead.
|
|
self.forecast_plot.show_computing(name)
|
|
|
|
def closeEvent(self, event):
|
|
print("Exitting gracefully!")
|
|
self.ws_client.disconnect()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app = QtWidgets.QApplication(sys.argv)
|
|
win = Window()
|
|
win.show()
|
|
sys.exit(app.exec_())
|
|
|
|
print ("End of program.")
|