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:
2026-06-21 11:14:00 +02:00
parent d6b6548d88
commit b767053c18
+106 -6
View File
@@ -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)