Re-enabling the temp controller landed unconditionally in HOLD and relied on the next tick to correct it, but SudTask.on_process() reads is_holding() synchronously in the same call chain that pushes a fresh step's setpoint - so a restart could finish a hold-less step instantly instead of actually ramping. Resolve the FSM against the real gap right away instead. Also fix the status bar showing one step number below what the Progress tab's plates show for the same step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
1385 lines
60 KiB
Python
Executable File
1385 lines
60 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"
|
|
|
|
# Below this (simulated seconds), an 'Elapsed' push is treated as "this run
|
|
# just started" rather than "reconnecting mid-brew" - see on_sud_changed()'s
|
|
# Elapsed handling. Generous relative to typical dt (0.1-1s/tick): a fresh
|
|
# start's first push always lands well under it, while reconnecting to an
|
|
# established run (anything past its first handful of ticks) always lands
|
|
# well over it - and seeding the history a few seconds "late" within a
|
|
# genuinely fresh run is harmless anyway, since the temperature there is
|
|
# still essentially the t=0 anchor.
|
|
FRESH_RUN_ELAPSED_THRESHOLD_S = 5.0
|
|
|
|
|
|
def _format_duration(seconds):
|
|
"""mm:ss, or h:mm:ss past an hour - used by StepPlate's remaining-time
|
|
label, where a brew step can run from a few minutes to a few hours."""
|
|
seconds = max(0, int(seconds))
|
|
hours, rem = divmod(seconds, 3600)
|
|
minutes, secs = divmod(rem, 60)
|
|
if hours:
|
|
return "{}:{:02d}:{:02d}".format(hours, minutes, secs)
|
|
return "{}:{:02d}".format(minutes, secs)
|
|
|
|
|
|
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):
|
|
"""Compares a Sud schedule's actual control behavior (line, solid)
|
|
against a simulation-based forecast (line_projected, also solid but
|
|
faded via alpha - see __init__()) - the same plant/controller model
|
|
the real run uses
|
|
(components/sud_forecast.py's SudForecastEstimator - see
|
|
show_computing()/show_forecast()). The forecast covers the whole
|
|
schedule from the very first Load, including through any step
|
|
requiring user confirmation - a human's response time genuinely
|
|
can't be forecast, so it's modeled as a zero-delay auto-confirm
|
|
there instead of stopping. That assumption gets corrected once the
|
|
schedule actually reaches the next real step boundary (anchored at
|
|
the real elapsed time and temperature) - see tasks/sud.py's
|
|
SudTask._reanchor_forecast() - at which point show_forecast() is
|
|
called again with the corrected curve, replacing the optimistic
|
|
guess outright rather than the GUI patching it up itself."""
|
|
|
|
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 first
|
|
simulation-based forecast segment 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_forecast() 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_forecast(self, t_min, theta, name, finished):
|
|
"""The forecast curve (faded, via alpha) - computed by the server simulating
|
|
the schedule with its real plant/controller
|
|
(components/sud_forecast.py) - see Window.on_sud_forecast_
|
|
received(). Called again, with a corrected t_min/theta, each time
|
|
a real user confirmation replaces a zero-delay guess with one
|
|
anchored at the real elapsed time and temperature; finished is
|
|
False only in the pathological case of a step whose target can
|
|
never be reached (components/sud_forecast.py's MAX_TICKS). Locks
|
|
the axes to fit the forecast alone, regardless of whatever the
|
|
actual trace (line) is currently doing."""
|
|
self.line_projected.set_data(t_min, theta)
|
|
self._set_fixed_limits(t_min, theta)
|
|
total = t_min[-1] if t_min else 0.0
|
|
suffix = "" if finished else " so far"
|
|
self.ax.set_title("{} - {:.0f} min{} total".format(name, total, suffix), 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):
|
|
"""Updates the actual measured trace (solid) only - the forecast
|
|
(also solid, but faded via alpha) is left exactly as
|
|
show_forecast() last drew it (that's the whole point: an honest,
|
|
unmoving baseline - see this class's docstring), and the axes
|
|
stay locked to whatever it set, so divergence between the two is
|
|
visible rather than auto-hidden by the view rescaling to chase
|
|
whichever line is currently drawing."""
|
|
hist_t = [t for t, _ in history]
|
|
hist_theta = [theta for _, theta in history]
|
|
self.line.set_data(hist_t, hist_theta)
|
|
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 StepPlate(QtWidgets.QFrame):
|
|
"""One rectangle per schedule step, stacked top-to-bottom on the
|
|
Progress tab to represent the whole Sud - see Window.
|
|
_rebuild_step_plates()/_update_step_plates(). The LED at a glance
|
|
communicates this step's status (not yet entered/finished/active);
|
|
the row below it mirrors what's actually driving the brew at that
|
|
step: target temp (resolved by the caller - a step without its own
|
|
'temperature' inherits whatever the previous one set, see
|
|
components/sud.py's _build_step()), masses and ramp rate as
|
|
configured in the schedule, plus, for whichever step is currently
|
|
active, the same live actual-temp/stirrer readings the Manual tab
|
|
shows (frozen at their last value once that step finishes, blank
|
|
before it's ever been reached)."""
|
|
|
|
LED_OFF = "#555555"
|
|
LED_GREEN = "#2ecc71"
|
|
LED_GREEN_DIM = "#1b5e36"
|
|
LED_ORANGE = "#e67e22"
|
|
LED_ORANGE_DIM = "#7a4111"
|
|
|
|
def __init__(self, index, step, resolved_temp):
|
|
QtWidgets.QFrame.__init__(self)
|
|
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
|
self.setFrameShadow(QtWidgets.QFrame.Raised)
|
|
# 'off' (not yet entered) | 'done' (finished, solid green) |
|
|
# 'ramp'/'hold' (active, flashing orange/green - see apply_blink()).
|
|
self.led_mode = 'off'
|
|
|
|
self.led = QtWidgets.QLabel()
|
|
self.led.setFixedSize(16, 16)
|
|
|
|
self.label_title = QtWidgets.QLabel("Step {}: {}".format(index + 1, step.get('descr') or ''))
|
|
self.label_title.setStyleSheet("font-weight: bold; color: black;")
|
|
self.label_title.setWordWrap(True)
|
|
|
|
self.label_remaining = QtWidgets.QLabel("--:--")
|
|
self.label_remaining.setStyleSheet("font-weight: bold; color: black;")
|
|
self.label_remaining.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
|
|
|
|
self.label_target = QtWidgets.QLabel()
|
|
self.label_actual = QtWidgets.QLabel()
|
|
self.label_masses = QtWidgets.QLabel()
|
|
self.label_rate = QtWidgets.QLabel()
|
|
self.label_stirrer = QtWidgets.QLabel()
|
|
self.label_energy = QtWidgets.QLabel()
|
|
for label in (self.label_target, self.label_actual, self.label_masses, self.label_rate,
|
|
self.label_stirrer, self.label_energy):
|
|
label.setStyleSheet("color: #555;")
|
|
|
|
# A grid, not a single row - the Progress tab shares its pane's
|
|
# width with the LCD panel on the Manual tab's side of the same
|
|
# splitter (see Window.__init__), which only leaves room for two
|
|
# short columns before needing a horizontal scrollbar.
|
|
layout = QtWidgets.QGridLayout(self)
|
|
layout.setColumnStretch(1, 1)
|
|
layout.addWidget(self.led, 0, 0)
|
|
layout.addWidget(self.label_title, 0, 1)
|
|
layout.addWidget(self.label_remaining, 0, 2)
|
|
layout.addWidget(self.label_target, 1, 0, 1, 2)
|
|
layout.addWidget(self.label_actual, 1, 2)
|
|
layout.addWidget(self.label_masses, 2, 0, 1, 2)
|
|
layout.addWidget(self.label_energy, 2, 2)
|
|
layout.addWidget(self.label_rate, 3, 0, 1, 2)
|
|
layout.addWidget(self.label_stirrer, 3, 2)
|
|
|
|
self.set_step(step, resolved_temp)
|
|
self.set_actual_temp(None)
|
|
self.set_live_stirrer(None)
|
|
self.set_energy(None)
|
|
|
|
def set_step(self, step, resolved_temp):
|
|
"""Applies this step's static, schedule-config fields - called once,
|
|
right after construction (see __init__)."""
|
|
grain = step.get('grain_mass', 0)
|
|
water = step.get('water_mass', 0)
|
|
self.label_masses.setText("Grain {:.2f} kg / Water {:.2f} kg".format(grain, water))
|
|
ramp = step.get('ramp') or {}
|
|
self.label_rate.setText("Rate {:.2f} °/min".format(ramp.get('rate', 0)))
|
|
hold = step.get('hold')
|
|
# The hold phase's speed, not the ramp phase's, as the "configured"
|
|
# fallback shown for an inactive step - hold is the long-running
|
|
# phase a glance at the plate cares about; ramp's own speed still
|
|
# shows live (see set_live_stirrer()) while this step is actually
|
|
# ramping.
|
|
hold_stirrer = hold.get('stirrer', {}) if hold else {}
|
|
self._configured_speed = hold_stirrer.get('speed', ramp.get('stirrer', {}).get('speed', 0))
|
|
self.label_target.setText(
|
|
"Target {}".format("{:.1f} °C".format(resolved_temp) if resolved_temp is not None else "—"))
|
|
|
|
def set_actual_temp(self, temp):
|
|
self.label_actual.setText("Actual {}".format("{:.1f} °C".format(temp) if temp is not None else "—"))
|
|
|
|
def set_energy(self, wh):
|
|
"""wh is this step's energy consumption so far (Wh) - live and
|
|
still growing while this is the active step, frozen at its final
|
|
total once finished, or None before this step has ever been
|
|
reached (see Window._update_step_plates())."""
|
|
self.label_energy.setText("Energy {}".format("{:.0f} Wh".format(wh) if wh is not None else "—"))
|
|
|
|
def set_live_stirrer(self, speed):
|
|
"""speed is the live rpm while this step is the active one, or None
|
|
to fall back to its configured (hold-phase) speed for a step
|
|
that's inactive (not yet reached, or already finished)."""
|
|
if speed is not None:
|
|
self.label_stirrer.setText("Stirrer {:.0f} rpm".format(speed))
|
|
else:
|
|
self.label_stirrer.setText("Stirrer {:.0f} rpm (cfg)".format(self._configured_speed))
|
|
|
|
def set_remaining(self, seconds):
|
|
self.label_remaining.setText(_format_duration(seconds) if seconds is not None else "--:--")
|
|
|
|
def set_led(self, mode):
|
|
self.led_mode = mode
|
|
if mode == 'done':
|
|
self._led_color(self.LED_GREEN)
|
|
elif mode == 'ramp':
|
|
self._led_color(self.LED_ORANGE)
|
|
elif mode == 'hold':
|
|
self._led_color(self.LED_GREEN)
|
|
else:
|
|
self._led_color(self.LED_OFF)
|
|
|
|
def apply_blink(self, blink_on):
|
|
"""Ticked by Window's shared blink timer - only an active step's LED
|
|
(mode 'ramp'/'hold') actually alternates; 'off'/'done' ignore it."""
|
|
if self.led_mode == 'ramp':
|
|
self._led_color(self.LED_ORANGE if blink_on else self.LED_ORANGE_DIM)
|
|
elif self.led_mode == 'hold':
|
|
self._led_color(self.LED_GREEN if blink_on else self.LED_GREEN_DIM)
|
|
|
|
def _led_color(self, color):
|
|
self.led.setStyleSheet("background-color: {}; border-radius: 8px; border: 1px solid #222;".format(color))
|
|
|
|
|
|
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
|
|
# The last real 'Elapsed' value seen, full stop - unlike
|
|
# sud_elapsed_seconds above, never reset back to None once the run
|
|
# leaves a running state (that's specifically what the forecast
|
|
# plot's dynamic-vs-static view switch needs - see _elapsed_min()),
|
|
# so the status bar/Progress tab can still show where the run
|
|
# actually ended up rather than "--:--" the moment it finishes.
|
|
# Reset on a fresh Start/Load (see on_sud_changed()/
|
|
# update_sud_forecast()) - a new run's total shouldn't open by
|
|
# showing the previous one's.
|
|
self.sud_elapsed_last = None
|
|
self.sud_name = ''
|
|
self.sud_schedule = []
|
|
self.sud_pot_mass = 0
|
|
self.sud_grain_mass = 0
|
|
self.sud_water_mass = 0
|
|
self.sud_step_index = None
|
|
self.sud_step_descr = None
|
|
self.sud_step_type = None
|
|
self.sud_hold_remaining = 0.0
|
|
# The last doc update_sud_forecast() actually processed - lets it
|
|
# tell a genuinely different schedule (a real Load/New Sud) apart
|
|
# from a standalone re-fetch of the *same*, already-running one
|
|
# (e.g. connect()'s unconditional Save request) - see its own
|
|
# comment for why that distinction matters.
|
|
self.sud_last_loaded_doc = None
|
|
# [(t_min, theta), ...] actually measured since the current run
|
|
# started - the "actual" half of the forecast-vs-actual
|
|
# comparison (SudForecastPlot.show_dynamic()).
|
|
self.forecast_history = []
|
|
# Per-step boundary times (absolute schedule index -> simulated
|
|
# second), straight from the latest 'Forecast' message's
|
|
# 'StepStarts' (see tasks/sud.py's SudTask.forecast_step_starts) -
|
|
# drives the Progress tab's remaining-time labels (see
|
|
# _update_step_plates()). sud_forecast_final_t is the forecast's
|
|
# own last t, used as the implicit "end" of the last step once
|
|
# sud_forecast_finished says the simulation actually reached it.
|
|
self.sud_forecast_step_starts = {}
|
|
self.sud_forecast_finished = False
|
|
self.sud_forecast_final_t = None
|
|
# One StepPlate per self.sud_schedule entry, same order/index - see
|
|
# _rebuild_step_plates()/_update_step_plates().
|
|
self.step_plates = []
|
|
# Index -> the live actual temperature last seen while that step
|
|
# was active, captured the moment a later 'Step' message reports
|
|
# the schedule has moved past it (see on_sud_changed()) - lets a
|
|
# finished step's plate keep showing where it actually ended up
|
|
# rather than going blank.
|
|
self.step_actual_frozen = {}
|
|
# Live stirrer speed (rpm), tracked unconditionally (unlike
|
|
# Slider_speed_soll, which only syncs once - see
|
|
# on_stirrer_changed()) so the Progress tab's active-step plate can
|
|
# show it.
|
|
self.stirrer_speed_ist = 0.0
|
|
# Energy consumption (Wh), straight from the latest 'Energy'
|
|
# message (see tasks/sud.py's SudTask._on_energy_changed()) -
|
|
# sud_energy_by_step covers every *finished* step (absolute index
|
|
# -> Wh), sud_energy_current is the running total for whichever
|
|
# step is presently active. Unlike the timing/temperature fields
|
|
# above, there's no forecast equivalent to preview before a run
|
|
# starts - energy actually used can only be measured, not
|
|
# predicted - so both stay empty/zero until one actually does.
|
|
self.sud_energy_by_step = {}
|
|
self.sud_energy_current = 0.0
|
|
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)
|
|
# Show the last value we had before the GUI last closed (see
|
|
# closeEvent()) right away, rather than the spinbox's bare design-time
|
|
# default - blockSignals so this doesn't queue a spurious 'AmbientTemp'
|
|
# send before the user has touched anything.
|
|
remembered_ambient = self.user_config.get('ambient_temperature')
|
|
if remembered_ambient is not None:
|
|
self.doubleSpinBox_ambient.blockSignals(True)
|
|
self.doubleSpinBox_ambient.setValue(remembered_ambient)
|
|
self.doubleSpinBox_ambient.blockSignals(False)
|
|
self.doubleSpinBox_ambient.valueChanged.connect(self.on_ambient_entry_changed)
|
|
# Pre-dial the Pot reset target at the same starting point btn_pot_
|
|
# reset used to hardcode (ambient) - purely a one-off starting value
|
|
# for the user to optionally dial away from before clicking the
|
|
# button, so unlike the ambient spinbox it's never persisted/synced
|
|
# again afterwards.
|
|
if remembered_ambient is not None:
|
|
self.doubleSpinBox_pot_temp.setValue(remembered_ambient)
|
|
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.label_pot_temp.setVisible(False)
|
|
self.doubleSpinBox_pot_temp.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)
|
|
|
|
# Progress tab - one StepPlate per schedule step, stacked inside a
|
|
# scroll area (a long mash schedule can easily run past the visible
|
|
# height). Built entirely in code rather than via brewpi.ui/
|
|
# main_window.py, since its content (the plates themselves) is
|
|
# already fully dynamic - there's nothing for Designer to usefully
|
|
# own here, unlike widget_plot/widget_plot_forecast above, which at
|
|
# least have a fixed placeholder to hand a single child widget to.
|
|
progress_tab = QtWidgets.QWidget()
|
|
progress_layout = QtWidgets.QVBoxLayout(progress_tab)
|
|
progress_layout.setContentsMargins(0, 0, 0, 0)
|
|
progress_scroll = QtWidgets.QScrollArea()
|
|
progress_scroll.setWidgetResizable(True)
|
|
progress_container = QtWidgets.QWidget()
|
|
self.progress_container_layout = QtWidgets.QVBoxLayout(progress_container)
|
|
self.progress_container_layout.setSpacing(6)
|
|
# Kept as the layout's last item by _rebuild_step_plates() (which
|
|
# only ever inserts plates before it) - without it, fewer plates
|
|
# than the scroll area's height would stretch to fill the gap
|
|
# instead of stacking at the top.
|
|
self.progress_container_layout.addStretch(1)
|
|
progress_scroll.setWidget(progress_container)
|
|
progress_layout.addWidget(progress_scroll)
|
|
self.horizontalTabWidget.addTab(progress_tab, "Progress")
|
|
|
|
# Single shared timer for every active plate's flashing LED -
|
|
# mirrors status_label_cooling/cooling_blink_timer's pattern above.
|
|
self.progress_blink_on = True
|
|
self.progress_blink_timer = QtCore.QTimer(self)
|
|
self.progress_blink_timer.timeout.connect(self.on_progress_blink)
|
|
self.progress_blink_timer.start(500)
|
|
|
|
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 our own ambient temperature, in case the server restarted
|
|
# since (and so fell back to its own config.json default) - the
|
|
# spinbox's current value, not the on-disk user config (which is
|
|
# only refreshed on shutdown - see closeEvent()), so this also picks
|
|
# up a change made earlier in the same session, before a reconnect.
|
|
self.msg_system.send({'AmbientTemp': self.doubleSpinBox_ambient.value()})
|
|
|
|
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)
|
|
|
|
# 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 trace 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))
|
|
self.forecast_plot.show_dynamic(self.forecast_history)
|
|
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)
|
|
# ambient_temp (the server's last echoed reading, shown in the
|
|
# status bar) is no longer trustworthy, but the spinbox itself is
|
|
# a client-owned setting (see closeEvent()/connect()) - leave it
|
|
# showing whatever the user last set rather than blanking it.
|
|
self.ambient_temp = None
|
|
self.btn_pot_reset.setVisible(False)
|
|
self.label_pot_temp.setVisible(False)
|
|
self.doubleSpinBox_pot_temp.setVisible(False)
|
|
self.warp_factor = 1.0
|
|
self.sud_elapsed_seconds = None
|
|
self.sud_name = ''
|
|
self.sud_schedule = []
|
|
self.sud_pot_mass = 0
|
|
self.sud_grain_mass = 0
|
|
self.sud_water_mass = 0
|
|
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.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 spinbox while the user isn't actively
|
|
# interacting with it, so a server echo doesn't clobber an
|
|
# in-progress edit - blockSignals avoids this setValue()
|
|
# itself re-triggering on_ambient_entry_changed() and
|
|
# echoing the same value straight back.
|
|
if not self.doubleSpinBox_ambient.hasFocus():
|
|
self.doubleSpinBox_ambient.blockSignals(True)
|
|
self.doubleSpinBox_ambient.setValue(self.ambient_temp)
|
|
self.doubleSpinBox_ambient.blockSignals(False)
|
|
elif "WarpFactor" in key:
|
|
self.warp_factor = msg['WarpFactor']
|
|
elif "PlantSim" in key:
|
|
self.btn_pot_reset.setVisible(msg['PlantSim'])
|
|
self.label_pot_temp.setVisible(msg['PlantSim'])
|
|
self.doubleSpinBox_pot_temp.setVisible(msg['PlantSim'])
|
|
self.update_status_env_label()
|
|
|
|
def on_action_pot_reset(self):
|
|
self.msg_pot.send({'Reset': self.doubleSpinBox_pot_temp.value()})
|
|
|
|
def on_ambient_entry_changed(self, value):
|
|
# Persisted on shutdown instead (see closeEvent()), not on every
|
|
# spinbox tick - this fires on every keystroke/arrow click while
|
|
# editing.
|
|
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_progress_blink(self):
|
|
self.progress_blink_on = not self.progress_blink_on
|
|
for plate in self.step_plates:
|
|
plate.apply_blink(self.progress_blink_on)
|
|
|
|
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']))
|
|
self._update_step_plates()
|
|
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:
|
|
self.stirrer_speed_ist = msg['Speed']
|
|
self._update_step_plates()
|
|
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
|
|
self.sud_elapsed_last = 0.0
|
|
# A genuine fresh start (excludes Pause->resume, which
|
|
# never hits this branch - sud_elapsed_seconds is
|
|
# already non-None throughout a pause) means whatever
|
|
# energy the previous run banked belongs to that run,
|
|
# not this one - mirrors tasks/sud.py's SudTask.recv()
|
|
# resetting its own server-side bookkeeping on the same
|
|
# event. The server will overwrite these with the
|
|
# now-reset real figures within a tick or two regardless,
|
|
# this just avoids a stale flash of the old run's totals
|
|
# in between.
|
|
self.sud_energy_by_step = {}
|
|
self.sud_energy_current = 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)
|
|
self._update_step_plates()
|
|
elif "UserMessage" in key:
|
|
self.sud_user_message = msg['UserMessage']
|
|
elif "Step" in key:
|
|
step = msg['Step']
|
|
prev_index = self.sud_step_index
|
|
new_index = step.get('Index')
|
|
if prev_index is not None and new_index is not None and new_index > prev_index:
|
|
# The step(s) just left (usually exactly one, but a hold
|
|
# whose duration is already 0 can advance straight
|
|
# through without this client ever seeing it as active -
|
|
# see tasks/sud.py's _reanchor_forecast() comment on the
|
|
# same race) freeze their last live actual temperature
|
|
# for the Progress tab - see StepPlate.set_actual_temp().
|
|
for passed_index in range(prev_index, new_index):
|
|
self.step_actual_frozen[passed_index] = self.plot_temp_ist
|
|
self.sud_step_index = new_index
|
|
self.sud_step_descr = step.get('Descr')
|
|
self.sud_step_type = step.get('Type')
|
|
self.update_status_step_label()
|
|
self._update_step_plates()
|
|
elif "HoldRemaining" in key:
|
|
self.sud_hold_remaining = msg['HoldRemaining']
|
|
self.update_status_step_label()
|
|
elif "Elapsed" in key:
|
|
# Unlike sud_elapsed_seconds below, kept regardless of
|
|
# dynamic-view mode - see its own comment for why - so the
|
|
# status bar/Progress tab still have the real final total
|
|
# once a run finishes rather than "--:--".
|
|
self.sud_elapsed_last = msg['Elapsed']
|
|
self._update_step_plates()
|
|
self.update_status_step_label()
|
|
# 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']
|
|
# Seed the actual-trace history with this run's real
|
|
# t=0 point - without it, the first sample only lands
|
|
# at on_plot_timer()'s next 1-second tick, which under
|
|
# a real sim_warp_factor can already be a noticeable
|
|
# way into the run, leaving a small visible gap between
|
|
# the forecast's exact t=0 anchor and where the actual
|
|
# trace first appears. Gated on elapsed still being
|
|
# small so reconnecting mid-brew (large elapsed) - where
|
|
# this temperature reading has nothing to do with that
|
|
# run's actual t=0 - doesn't get a bogus point plotted
|
|
# at x=0.
|
|
if not self.forecast_history and self.plot_temp_ist and msg['Elapsed'] < FRESH_RUN_ELAPSED_THRESHOLD_S:
|
|
self.forecast_history.append((msg['Elapsed'] / 60.0, self.plot_temp_ist))
|
|
elif "Forecast" in key:
|
|
self.on_sud_forecast_received(msg['Forecast'])
|
|
elif "Energy" in key:
|
|
self.sud_energy_by_step = dict(msg['Energy']['StepEnergy'])
|
|
self.sud_energy_current = msg['Energy']['Current']
|
|
self._update_step_plates()
|
|
self.update_status_step_label()
|
|
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):
|
|
# Computed asynchronously server-side and can arrive slightly
|
|
# after the schedule itself - applies regardless of whether a
|
|
# run is in progress, since this can also be a later piecewise
|
|
# segment appended after a real step transition (see tasks/
|
|
# sud.py's SudTask._reanchor_forecast()), not just the initial
|
|
# one computed at Load.
|
|
if self.sud_empty:
|
|
return
|
|
t_min = [seconds / 60.0 for seconds in forecast['T']]
|
|
self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished'])
|
|
|
|
self.sud_forecast_step_starts = dict(forecast['StepStarts'])
|
|
self.sud_forecast_finished = forecast['Finished']
|
|
self.sud_forecast_final_t = forecast['T'][-1] if forecast['T'] else None
|
|
self._update_step_plates()
|
|
|
|
def update_status_step_label(self):
|
|
if self.sud_step_descr:
|
|
# +1: sud_step_index is the 0-based index Sud.index uses, but
|
|
# StepPlate (the Progress tab) labels steps 1-based - match it
|
|
# here so the status bar doesn't announce a step one number
|
|
# below what the Progress tab shows for the same step.
|
|
text = "Step {}: {}".format(self.sud_step_index + 1, 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)
|
|
# sud_step_index is the same 0-based index Sud.index uses into
|
|
# its own schedule list (see components/sud.py) - self.
|
|
# sud_schedule is built from the very same doc, so it lines up
|
|
# directly; grain_mass/water_mass are already resolved against
|
|
# default.step there (see components/sud.py's _build_step()).
|
|
if 0 <= self.sud_step_index < len(self.sud_schedule):
|
|
step = self.sud_schedule[self.sud_step_index]
|
|
grain, water = step.get('grain_mass', 0), step.get('water_mass', 0)
|
|
else:
|
|
grain, water = self.sud_grain_mass, self.sud_water_mass
|
|
elif self.sud_schedule:
|
|
# Loaded but not started yet (no real Step message has
|
|
# arrived) - preview the doc's own initial grain_mass/
|
|
# water_mass right away instead of leaving the status line
|
|
# blank until Start, mirroring tasks/sud.py's SudTask.
|
|
# apply_plant_params() applying it to the real plant/
|
|
# controller immediately on Load too.
|
|
text = ""
|
|
grain, water = self.sud_grain_mass, self.sud_water_mass
|
|
else:
|
|
self.statusBar().clearMessage()
|
|
return
|
|
|
|
pot = self.sud_pot_mass
|
|
mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format(
|
|
pot, water, grain, pot + water + grain)
|
|
text = text + " " + mass_text if text else mass_text
|
|
# Sums, not just the active step's own figures: total process time
|
|
# is just self.sud_elapsed_last (Sud's own tick-counted total -
|
|
# every step's time, including any WAIT_USER dwell, already adds
|
|
# up into it sequentially with no gaps) - sud_elapsed_seconds
|
|
# itself would go back to "--:--" the moment a run finishes, see
|
|
# its own comment, which is exactly when this total matters most.
|
|
# Total energy has no such single running counter server-side
|
|
# (see tasks/sud.py's SudTask - energy is banked per step, not
|
|
# accumulated as one grand total), so it's summed here from every
|
|
# finished step's own Wh total plus the currently active one.
|
|
elapsed_text = "Elapsed {}".format(
|
|
_format_duration(self.sud_elapsed_last) if self.sud_elapsed_last is not None else "--:--")
|
|
total_energy_kwh = (sum(self.sud_energy_by_step.values()) + self.sud_energy_current) / 1000.0
|
|
energy_text = "Energy {:.2f} kWh".format(total_energy_kwh)
|
|
text = "{} {} {}".format(text, elapsed_text, energy_text)
|
|
self.statusBar().showMessage(text)
|
|
|
|
def update_sud_forecast(self, doc):
|
|
try:
|
|
name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc)
|
|
except (KeyError, TypeError):
|
|
return
|
|
# A genuinely different schedule (an actual Load, or "New Sud")
|
|
# means no step has actually started yet, regardless of whatever
|
|
# the previously loaded Sud last reported - don't wait for the
|
|
# server's own 'Step' (None) reset (asyncio.create_task() there
|
|
# means its arrival order relative to this doc isn't guaranteed)
|
|
# to clear a stale step from the *previous* schedule before
|
|
# showing this one's preview below.
|
|
#
|
|
# But this same 'Json' also arrives for the *same*, already-
|
|
# running schedule - e.g. connect()'s unconditional Save request,
|
|
# whose server-side reply is two standalone pushes (this Json,
|
|
# then a Forecast - see tasks/sud.py's recv()), unlike the bundled
|
|
# replay a subscribe triggers, which carries Step/State alongside
|
|
# it to immediately fix up any such reset. Without this doc
|
|
# equality check, that standalone Json would wipe sud_step_index
|
|
# back to None with nothing in the same message to restore it -
|
|
# exactly what made a freshly-connected client briefly highlight
|
|
# (and then, once the next real Step/Elapsed push finally
|
|
# arrived, fail to correct) the wrong step.
|
|
is_new_schedule = (doc != self.sud_last_loaded_doc)
|
|
self.sud_last_loaded_doc = doc
|
|
self.sud_name = name
|
|
self.sud_schedule = schedule
|
|
self.sud_pot_mass = pot_mass
|
|
self.sud_grain_mass = grain_mass
|
|
self.sud_water_mass = water_mass
|
|
self.sud_empty = len(schedule) == 0
|
|
if is_new_schedule:
|
|
self.sud_step_descr = None
|
|
self.sud_step_index = None
|
|
self.sud_step_type = None
|
|
self.sud_hold_remaining = 0.0
|
|
# Belongs to whatever schedule was loaded before - on_sud_
|
|
# forecast_received() re-fills these once a forecast for
|
|
# *this* one arrives.
|
|
self.sud_forecast_step_starts = {}
|
|
self.sud_forecast_finished = False
|
|
self.sud_forecast_final_t = None
|
|
self.step_actual_frozen = {}
|
|
self.sud_energy_by_step = {}
|
|
self.sud_energy_current = 0.0
|
|
self.sud_elapsed_last = None
|
|
self._rebuild_step_plates()
|
|
self.update_sud_actions()
|
|
self.update_status_step_label()
|
|
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 _rebuild_step_plates(self):
|
|
"""(Re)builds the Progress tab's stack of StepPlates from
|
|
self.sud_schedule - called whenever a (possibly different) Sud is
|
|
loaded (see update_sud_forecast()). Keeps the trailing stretch
|
|
progress_container_layout was seeded with at construction (see
|
|
Window.__init__) as its last item, so plates always stack at the
|
|
top regardless of how few there are."""
|
|
while self.progress_container_layout.count() > 1:
|
|
item = self.progress_container_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
self.step_plates = []
|
|
resolved_temp = None
|
|
for step in self.sud_schedule:
|
|
if step.get('temperature') is not None:
|
|
resolved_temp = step['temperature']
|
|
plate = StepPlate(len(self.step_plates), step, resolved_temp)
|
|
self.step_plates.append(plate)
|
|
self.progress_container_layout.insertWidget(self.progress_container_layout.count() - 1, plate)
|
|
self._update_step_plates()
|
|
|
|
def _update_step_plates(self):
|
|
"""Refreshes every StepPlate's LED/remaining-time/live fields from
|
|
current state - called on every relevant push from the server
|
|
(Step/State/Elapsed/Forecast on the Sud channel, Ist on TempCtrl,
|
|
Speed on Stirrer). Cheap enough to just redo in full each time: a
|
|
mash schedule is at most a handful of steps."""
|
|
if not self.step_plates:
|
|
return
|
|
# sud_elapsed_last, not sud_elapsed_seconds - the latter resets to
|
|
# None the moment a run finishes (see its own comment), which
|
|
# would otherwise make every finished step's remaining time jump
|
|
# back to its full duration right when the run ends instead of
|
|
# staying at 0:00.
|
|
current_elapsed = self.sud_elapsed_last if self.sud_elapsed_last is not None else 0.0
|
|
starts = self.sud_forecast_step_starts
|
|
n = len(self.step_plates)
|
|
for i, plate in enumerate(self.step_plates):
|
|
start_t = starts.get(i)
|
|
end_t = starts.get(i + 1)
|
|
if end_t is None and i == n - 1 and self.sud_forecast_finished:
|
|
end_t = self.sud_forecast_final_t
|
|
if start_t is not None and end_t is not None:
|
|
total = max(0.0, end_t - start_t)
|
|
plate.set_remaining(max(0.0, min(total, end_t - current_elapsed)))
|
|
else:
|
|
plate.set_remaining(None)
|
|
|
|
if self.sud_step_index is None or i > self.sud_step_index:
|
|
plate.set_led('off')
|
|
elif i < self.sud_step_index:
|
|
plate.set_led('done')
|
|
else:
|
|
# WAIT_USER/PAUSED don't change sud_step_type (see
|
|
# components/sud.py's Sud._finish_step()/pause()) - it
|
|
# stays whatever phase was actually last active, which is
|
|
# exactly the color this step's LED should keep flashing.
|
|
plate.set_led(self.sud_step_type if self.sud_step_type in ('ramp', 'hold') else 'hold')
|
|
|
|
if self.sud_step_index is not None and i == self.sud_step_index:
|
|
plate.set_actual_temp(self.plot_temp_ist)
|
|
plate.set_live_stirrer(self.stirrer_speed_ist)
|
|
elif i in self.step_actual_frozen:
|
|
plate.set_actual_temp(self.step_actual_frozen[i])
|
|
plate.set_live_stirrer(None)
|
|
else:
|
|
plate.set_actual_temp(None)
|
|
plate.set_live_stirrer(None)
|
|
|
|
if self.sud_step_index is not None and i == self.sud_step_index:
|
|
plate.set_energy(self.sud_energy_current)
|
|
elif i in self.sud_energy_by_step:
|
|
plate.set_energy(self.sud_energy_by_step[i])
|
|
else:
|
|
plate.set_energy(None)
|
|
|
|
def closeEvent(self, event):
|
|
print("Exitting gracefully!")
|
|
self.user_config.set('ambient_temperature', self.doubleSpinBox_ambient.value())
|
|
self.ws_client.disconnect()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app = QtWidgets.QApplication(sys.argv)
|
|
win = Window()
|
|
win.show()
|
|
sys.exit(app.exec_())
|
|
|
|
print ("End of program.")
|