diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index ba173cd..1a48e74 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -19,6 +19,7 @@ import asyncio # 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_HOLDING_STATE = "SudState.HOLDING" SUD_WAIT_USER_STATE = "SudState.WAIT_USER" SUD_PAUSED_STATE = "SudState.PAUSED" @@ -107,11 +108,18 @@ class RealtimePlot(FigureCanvasQTAgg): class SudForecastPlot(FigureCanvasQTAgg): - """A static preview of a Sud schedule's temperature course, computed - from its nominal ramp rates/hold durations alone (no plant/controller - model - just how long the declared schedule says each step should - take), so it can be shown as soon as a schedule is loaded/edited, - before a brew is actually started.""" + """A preview of a Sud schedule's temperature course, computed from its + nominal ramp rates/hold durations alone (no plant/controller model - + just how long the declared schedule says each step should take), so it + can be shown as soon as a schedule is loaded/edited, before a brew is + actually started (show_schedule()). + + Once running, nonideal ramp rates/user pauses make that 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') @@ -119,6 +127,7 @@ class SudForecastPlot(FigureCanvasQTAgg): 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. @@ -156,14 +165,38 @@ class SudForecastPlot(FigureCanvasQTAgg): t_min = [seconds / 60.0 for seconds in t] self.line.set_data(t_min, theta) + self.line_projected.set_data([], []) self.ax.relim() self.ax.autoscale_view() 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 show_dynamic(self, history, remaining_schedule, theta_now, elapsed_min, name): + """Re-anchored forecast for a run in progress: history is the + [(t_min, theta), ...] actually measured so far (drawn solid), + remaining_schedule the not-yet-done steps, projected forward + (dashed) from (elapsed_min, theta_now) using the same nominal-rate + estimate as show_schedule(), just re-seeded each call instead of + computed once at t=0.""" + hist_t = [t for t, _ in history] + hist_theta = [theta for _, theta in history] + self.line.set_data(hist_t, hist_theta) + + t_rem, theta_rem = self._estimate_course(remaining_schedule, theta_now) + t_rem_min = [elapsed_min + seconds / 60.0 for seconds in t_rem] + 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 elapsed_min + 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() @@ -229,6 +262,17 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = 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_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 = [] 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') @@ -344,6 +388,17 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): # elapsed time by the server's warp factor to match it. elapsed_min = elapsed_real_s * self.warp_factor / 60.0 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)) + remaining = self._remaining_schedule() + self.forecast_plot.show_dynamic( + self.forecast_history, remaining, self.plot_temp_ist, elapsed_min, self.sud_name) else: self.forecast_plot.clear_progress() @@ -352,6 +407,30 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): else: self.forecast_plot.clear_current_temp() + def _remaining_schedule(self): + """The not-yet-done part of sud_schedule, for SudForecastPlot's + dynamic re-anchoring: the current step's already-finished phase is + dropped, and its still-to-run phase is left for _estimate_course() + to size from the live theta_now/hold_remaining it's seeded with, + rather than the step's nominal start.""" + schedule = self.sud_schedule + index = self.sud_step_index + if not schedule or index is None or not (0 <= index < len(schedule)): + return [] + current = schedule[index] + rest = schedule[index + 1:] + if self.sud_state == SUD_WAIT_USER_STATE: + # The current step (both its ramp and hold, if any) is already + # done - only waiting on the user to advance. + return rest + if self.sud_state == SUD_HOLDING_STATE: + remaining_minutes = max(self.sud_hold_remaining, 0.0) / 60.0 + return [{'hold': {'duration': remaining_minutes}}] + rest + # RAMPING: _estimate_course() recomputes the ramp's remaining + # distance from whatever start_theta it's given, so the step is + # used as-is; its hold phase (if any) hasn't started yet either. + return [current] + rest + 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), @@ -380,6 +459,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = None + self.sud_name = '' + self.sud_schedule = [] + self.sud_step_index = None + self.sud_hold_remaining = 0.0 + self.forecast_history = [] self.set_sud_cooling(False) self.update_sud_actions() self.update_status_env_label() @@ -628,10 +712,19 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): if self.sud_state in SUD_RUNNING_STATES and self.sud_start_time is None: self.sud_start_time = time.monotonic() + # 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: self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = None + # on_plot_timer() only redraws the dynamic forecast while + # sud_start_time is set - revert to the static view now, + # rather than leaving the last dynamic draw frozen. + if self.sud_schedule: + start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0 + self.forecast_plot.show_schedule(self.sud_schedule, start_theta, self.sud_name) if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message: self.user_message_signal.emit(self.sud_user_message) @@ -644,7 +737,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.statusBar().showMessage("Step {}: {}".format(step.get('Index'), descr)) else: self.statusBar().clearMessage() + self.sud_step_index = step.get('Index') self.set_sud_cooling(step.get('Cooling', False)) + elif "HoldRemaining" in key: + self.sud_hold_remaining = msg['HoldRemaining'] self.update_sud_actions() def update_sud_forecast(self, doc): @@ -652,13 +748,17 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): 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 # No measured starting temperature is available before a brew has - # started; fall back to a plausible ambient guess. + # started; fall back to a plausible ambient guess. 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. start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0 self.forecast_plot.show_schedule(schedule, start_theta, name)