gui: dynamically re-anchor the Sud forecast instead of a static estimate
Nonideal ramp rates and user pauses make the schedule's nominal-rate estimate drift from reality the longer a brew runs. While a run is in progress, the forecast plot now redraws every tick: the already- elapsed part is the actual measured trace (solid line), and only the remaining steps are projected forward (dashed line) from the live current temperature, step index, and hold_remaining - rather than recomputing the whole curve once from t=0 at load time. components/sud.py's Step/HoldRemaining messages already carried everything needed; the GUI just wasn't tracking step index or hold_remaining at all before this. Reverts to the static full-schedule view once the run stops/finishes.
This commit is contained in:
+106
-6
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
# str(SudState.X) values (as sent in the 'State' message) that count as
|
# str(SudState.X) values (as sent in the 'State' message) that count as
|
||||||
# "running" for enabling/disabling the Start/Pause/Stop toolbar actions.
|
# "running" for enabling/disabling the Start/Pause/Stop toolbar actions.
|
||||||
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
|
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
|
||||||
|
SUD_HOLDING_STATE = "SudState.HOLDING"
|
||||||
SUD_WAIT_USER_STATE = "SudState.WAIT_USER"
|
SUD_WAIT_USER_STATE = "SudState.WAIT_USER"
|
||||||
SUD_PAUSED_STATE = "SudState.PAUSED"
|
SUD_PAUSED_STATE = "SudState.PAUSED"
|
||||||
|
|
||||||
@@ -107,11 +108,18 @@ class RealtimePlot(FigureCanvasQTAgg):
|
|||||||
|
|
||||||
|
|
||||||
class SudForecastPlot(FigureCanvasQTAgg):
|
class SudForecastPlot(FigureCanvasQTAgg):
|
||||||
"""A static preview of a Sud schedule's temperature course, computed
|
"""A preview of a Sud schedule's temperature course, computed from its
|
||||||
from its nominal ramp rates/hold durations alone (no plant/controller
|
nominal ramp rates/hold durations alone (no plant/controller model -
|
||||||
model - just how long the declared schedule says each step should
|
just how long the declared schedule says each step should take), so it
|
||||||
take), so it can be shown as soon as a schedule is loaded/edited,
|
can be shown as soon as a schedule is loaded/edited, before a brew is
|
||||||
before a brew is actually started."""
|
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):
|
def __init__(self):
|
||||||
figure = Figure(facecolor='white')
|
figure = Figure(facecolor='white')
|
||||||
@@ -119,6 +127,7 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
|||||||
|
|
||||||
self.ax = figure.subplots(1, 1)
|
self.ax = figure.subplots(1, 1)
|
||||||
self.line, = self.ax.plot([], [], '-b', linewidth=1.5, zorder=2)
|
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)
|
self.progress_line = self.ax.axvline(0, color='g', linewidth=1.5, visible=False, zorder=2)
|
||||||
# Current temperature - drawn behind (lower zorder than) the
|
# Current temperature - drawn behind (lower zorder than) the
|
||||||
# forecast/progress lines so it never covers them.
|
# forecast/progress lines so it never covers them.
|
||||||
@@ -156,14 +165,38 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
|||||||
t_min = [seconds / 60.0 for seconds in t]
|
t_min = [seconds / 60.0 for seconds in t]
|
||||||
|
|
||||||
self.line.set_data(t_min, theta)
|
self.line.set_data(t_min, theta)
|
||||||
|
self.line_projected.set_data([], [])
|
||||||
self.ax.relim()
|
self.ax.relim()
|
||||||
self.ax.autoscale_view()
|
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.ax.set_title("{} - {:.0f} min total".format(name, t_min[-1] if t_min else 0.0), fontsize='small')
|
||||||
self.figure.tight_layout()
|
self.figure.tight_layout()
|
||||||
self.draw_idle()
|
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):
|
def show_no_schedule(self):
|
||||||
self.line.set_data([], [])
|
self.line.set_data([], [])
|
||||||
|
self.line_projected.set_data([], [])
|
||||||
self.clear_progress()
|
self.clear_progress()
|
||||||
self.ax.relim()
|
self.ax.relim()
|
||||||
self.ax.autoscale_view()
|
self.ax.autoscale_view()
|
||||||
@@ -229,6 +262,17 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.sud_start_time = None
|
self.sud_start_time = None
|
||||||
self.sud_paused_total = 0.0
|
self.sud_paused_total = 0.0
|
||||||
self.sud_pause_started_at = None
|
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_pot = self.msg_dispatch.msgio_get('Pot')
|
||||||
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
|
self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
|
||||||
self.msg_heater = self.msg_dispatch.msgio_get('Heater')
|
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 time by the server's warp factor to match it.
|
||||||
elapsed_min = elapsed_real_s * self.warp_factor / 60.0
|
elapsed_min = elapsed_real_s * self.warp_factor / 60.0
|
||||||
self.forecast_plot.set_progress(elapsed_min)
|
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:
|
else:
|
||||||
self.forecast_plot.clear_progress()
|
self.forecast_plot.clear_progress()
|
||||||
|
|
||||||
@@ -352,6 +407,30 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
else:
|
else:
|
||||||
self.forecast_plot.clear_current_temp()
|
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):
|
def disconnect(self):
|
||||||
# ws_client.disconnect() blocks until the websocket's close handshake
|
# ws_client.disconnect() blocks until the websocket's close handshake
|
||||||
# completes (or times out, ~10s if the server doesn't ack promptly),
|
# 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_start_time = None
|
||||||
self.sud_paused_total = 0.0
|
self.sud_paused_total = 0.0
|
||||||
self.sud_pause_started_at = None
|
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.set_sud_cooling(False)
|
||||||
self.update_sud_actions()
|
self.update_sud_actions()
|
||||||
self.update_status_env_label()
|
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:
|
if self.sud_state in SUD_RUNNING_STATES and self.sud_start_time is None:
|
||||||
self.sud_start_time = time.monotonic()
|
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:
|
elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE:
|
||||||
self.sud_start_time = None
|
self.sud_start_time = None
|
||||||
self.sud_paused_total = 0.0
|
self.sud_paused_total = 0.0
|
||||||
self.sud_pause_started_at = None
|
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:
|
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)
|
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))
|
self.statusBar().showMessage("Step {}: {}".format(step.get('Index'), descr))
|
||||||
else:
|
else:
|
||||||
self.statusBar().clearMessage()
|
self.statusBar().clearMessage()
|
||||||
|
self.sud_step_index = step.get('Index')
|
||||||
self.set_sud_cooling(step.get('Cooling', False))
|
self.set_sud_cooling(step.get('Cooling', False))
|
||||||
|
elif "HoldRemaining" in key:
|
||||||
|
self.sud_hold_remaining = msg['HoldRemaining']
|
||||||
self.update_sud_actions()
|
self.update_sud_actions()
|
||||||
|
|
||||||
def update_sud_forecast(self, doc):
|
def update_sud_forecast(self, doc):
|
||||||
@@ -652,13 +748,17 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
name, _, schedule, _, _ = Sud._parse_data(doc)
|
name, _, schedule, _, _ = Sud._parse_data(doc)
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
return
|
return
|
||||||
|
self.sud_name = name
|
||||||
|
self.sud_schedule = schedule
|
||||||
self.sud_empty = len(schedule) == 0
|
self.sud_empty = len(schedule) == 0
|
||||||
self.update_sud_actions()
|
self.update_sud_actions()
|
||||||
if self.sud_empty:
|
if self.sud_empty:
|
||||||
self.forecast_plot.show_no_schedule()
|
self.forecast_plot.show_no_schedule()
|
||||||
return
|
return
|
||||||
# No measured starting temperature is available before a brew has
|
# 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
|
start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0
|
||||||
self.forecast_plot.show_schedule(schedule, start_theta, name)
|
self.forecast_plot.show_schedule(schedule, start_theta, name)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user