Eliminate the naive forecast; fix two bugs that hijacked the plot

Root cause of "forecast vanishes right after loading, before Start":
Sud.elapsed resets to 0 on every fresh Load too, not just on an actual
run start (components/sud.py's _reset_run_state()), and that reset
broadcasts unconditionally. The GUI's Elapsed handler applied it
unconditionally too, flipping sud_elapsed_seconds from None to 0.0
purely from loading a schedule - which made on_plot_timer() think a
run had started, hijacking the plot into show_dynamic() (empty
history, visible progress line, "(est.)" title) instead of the static
forecast preview. Fixed by only applying an incoming Elapsed value
once the State-transition logic has already put the client into
dynamic-view mode, so a mere Load while idle can't masquerade as a
run starting.

Separately, eliminates the naive abs(delta)/rate forecast entirely, as
instructed - both the immediate static placeholder shown the instant a
schedule loaded (show_schedule()/_estimate_course()) and the dynamic
per-tick fallback used until the server's simulated RemainingForecast
arrived after a step change. The static preview now always waits for
and shows only the simulation-based forecast (show_computing() while
it's pending, then show_precomputed_course()); the dynamic dashed
projection simply holds its last value during that brief gap instead
of guessing. _remaining_schedule() and SUD_HOLDING_STATE, now unused,
removed too.

Also fixes the axes-scaling bug found along the way: relim()+
autoscale_view() leaves autoscaling switched on, so a later, unrelated
redraw (e.g. set_current_temp()'s continuous updates, which keep that
indicator live regardless of whether a forecast is even showing) could
silently re-autoscale and stretch the view out to fit it - e.g.
leftover heat from a previous, different run sitting well outside a
newly loaded schedule's own range. show_precomputed_course() now
computes and locks explicit xlim/ylim from the forecast data alone via
the new _set_fixed_limits().

Verified live: a fresh load shows only the simulated forecast,
correctly scaled, with no premature dynamic-mode hijacking; loading a
schedule after a much hotter previous run no longer distorts the axes
to fit the leftover actual temperature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
This commit is contained in:
2026-06-22 14:04:02 +02:00
co-authored by Claude Sonnet 4.6
parent 8c23a2c6ac
commit 41b06d8961
+80 -92
View File
@@ -21,7 +21,6 @@ from user_config import UserConfig
# 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"
@@ -110,13 +109,17 @@ class RealtimePlot(FigureCanvasQTAgg):
class SudForecastPlot(FigureCanvasQTAgg): class SudForecastPlot(FigureCanvasQTAgg):
"""A preview of a Sud schedule's temperature course, computed from its """A preview of a Sud schedule's temperature course, simulated
nominal ramp rates/hold durations alone (no plant/controller model - server-side with the same plant/controller model the real run uses
just how long the declared schedule says each step should take), so it (components/sud_forecast.py's SudForecastEstimator - see
can be shown as soon as a schedule is loaded/edited, before a brew is show_computing()/show_precomputed_course()). There used to also be an
actually started (show_schedule()). immediate, naive abs(delta)/rate placeholder shown the instant a
schedule loaded, before the simulated one arrived a moment later -
removed because it was structurally wrong often enough (no model of
PID settling time, ramp dead time, etc.) to not be worth showing even
briefly.
Once running, nonideal ramp rates/user pauses make that static estimate Once running, nonideal ramp rates/user pauses make any static estimate
drift from reality - show_dynamic() instead re-anchors the projection drift from reality - show_dynamic() instead re-anchors the projection
every tick: the already-elapsed part is the actual measured trace every tick: the already-elapsed part is the actual measured trace
(line, solid), and only the remaining steps are projected forward from (line, solid), and only the remaining steps are projected forward from
@@ -140,63 +143,64 @@ class SudForecastPlot(FigureCanvasQTAgg):
figure.tight_layout() figure.tight_layout()
@staticmethod def show_computing(self, name):
def _estimate_course(schedule, start_theta): """Shown right after a schedule loads, while the simulation-based
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take forecast is still being computed server-side (see
abs(delta)/rate minutes at the declared rate, holds last for their Window.on_sud_forecast_received()) - typically resolved within a
duration at the temperature the preceding ramp reached. A step may second. Deliberately shows nothing rather than an approximate
carry both - ramp then hold - contributing both segments in turn.""" placeholder; leaves the axes' current view alone (there's nothing
t = [0.0] meaningful to fit yet), so whatever was on screen before just
theta = [start_theta] stays frozen underneath the title change until show_precomputed_
for step in schedule: course() replaces it."""
ramp = step.get('ramp') self.line.set_data([], [])
hold = step.get('hold') self.line_projected.set_data([], [])
target = step.get('temperature') self.ax.set_title("{} - computing forecast...".format(name), fontsize='small')
if ramp is not None and target is not None: self.figure.tight_layout()
rate = ramp.get('rate', 0) self.draw_idle()
if rate > 0:
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
theta.append(target)
if hold is not None:
t.append(t[-1] + hold.get('duration', 0) * 60.0)
theta.append(theta[-1])
return t, theta
def show_schedule(self, schedule, start_theta, name):
"""Quick, approximate preview shown immediately when a schedule is
loaded - assumes every ramp instantly achieves/sustains its
declared rate, which real plants/controllers don't. Superseded by
show_precomputed_course() shortly after, once the server's
simulation-based forecast (components/sud_forecast.py) arrives."""
t, theta = self._estimate_course(schedule, start_theta)
t_min = [seconds / 60.0 for seconds in t]
self._show_static_course(t_min, theta, name)
def show_precomputed_course(self, t_min, theta, name): def show_precomputed_course(self, t_min, theta, name):
"""Like show_schedule(), but (t_min, theta) were already computed """Static forecast computed by the server simulating the schedule
by the server simulating the schedule with its real plant/ with its real plant/controller (components/sud_forecast.py) - see
controller - see Window.on_sud_forecast_received().""" Window.on_sud_forecast_received()."""
self._show_static_course(t_min, theta, name)
def _show_static_course(self, t_min, theta, name):
self.line.set_data(t_min, theta) self.line.set_data(t_min, theta)
self.line_projected.set_data([], []) self.line_projected.set_data([], [])
self.ax.relim() self._set_fixed_limits(t_min, theta)
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 _set_fixed_limits(self, t_list, theta_list):
"""Explicitly sets (and, unlike relim()+autoscale_view(), locks)
the axes' view to fit exactly the given data, with matplotlib's
usual ~5% margin. Locking matters here: relim()+autoscale_view()
leaves autoscaling switched on, so a later, unrelated redraw -
e.g. set_current_temp()'s continuous updates, which keep that
indicator live regardless of whether a forecast is even showing -
can silently re-autoscale and stretch the view back out to fit it
instead, squashing the actual forecast curve down to nothing."""
if not t_list or not theta_list:
return
t_lo, t_hi = min(t_list), max(t_list)
th_lo, th_hi = min(theta_list), max(theta_list)
t_pad = (t_hi - t_lo) * 0.05 or 1.0
th_pad = (th_hi - th_lo) * 0.05 or 1.0
self.ax.set_xlim(t_lo - t_pad, t_hi + t_pad)
self.ax.set_ylim(th_lo - th_pad, th_hi + th_pad)
def show_dynamic(self, history, t_rem_min, theta_rem, name): def show_dynamic(self, history, t_rem_min, theta_rem, name):
"""Re-anchored forecast for a run in progress: history is the """Re-anchored forecast for a run in progress: history is the
[(t_min, theta), ...] actually measured so far (drawn solid); [(t_min, theta), ...] actually measured so far (drawn solid);
t_rem_min/theta_rem is the projected remainder (dashed), already t_rem_min/theta_rem is the projected remainder (dashed), the
computed by the caller - either the server's simulation-based server's simulation-based SudForecastEstimator result for
SudForecastEstimator (preferred, see Window.on_plot_timer()) or, whatever's left of the schedule, already anchored by the caller
until that arrives, a quick naive abs(delta)/rate fallback.""" (see Window.on_plot_timer()). Either can be None - meaning the
server hasn't recomputed it yet for the step just entered - in
which case the dashed line is simply left showing whatever it
last did, rather than guessing at a replacement."""
hist_t = [t for t, _ in history] hist_t = [t for t, _ in history]
hist_theta = [theta for _, theta in history] hist_theta = [theta for _, theta in history]
self.line.set_data(hist_t, hist_theta) self.line.set_data(hist_t, hist_theta)
if t_rem_min is not None:
self.line_projected.set_data(t_rem_min, theta_rem) self.line_projected.set_data(t_rem_min, theta_rem)
self.ax.relim() self.ax.relim()
@@ -461,15 +465,15 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# projection further right every tick until the next # projection further right every tick until the next
# step change, instead of holding it fixed. # step change, instead of holding it fixed.
anchor_min, t_rem, theta_rem = self.server_remaining_forecast anchor_min, t_rem, theta_rem = self.server_remaining_forecast
else:
# Server's simulation-based remainder hasn't arrived
# yet (it's recomputed on every step change, off the
# event loop) - fall back to the quick naive estimate
# for this one tick.
anchor_min = elapsed_min
t_rem, theta_rem = self.forecast_plot._estimate_course(self._remaining_schedule(), self.plot_temp_ist)
t_rem_min = [anchor_min + seconds / 60.0 for seconds in t_rem] t_rem_min = [anchor_min + seconds / 60.0 for seconds in t_rem]
self.forecast_plot.show_dynamic(self.forecast_history, t_rem_min, theta_rem, self.sud_name) self.forecast_plot.show_dynamic(self.forecast_history, t_rem_min, theta_rem, self.sud_name)
else:
# Server's simulation-based remainder hasn't arrived yet
# for the step just entered (recomputed off the event
# loop on every step change) - leave the dashed
# projection as whatever it last showed instead of
# guessing; it catches up within a tick or two.
self.forecast_plot.show_dynamic(self.forecast_history, None, None, self.sud_name)
else: else:
self.forecast_plot.clear_progress() self.forecast_plot.clear_progress()
@@ -478,30 +482,6 @@ 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),
@@ -844,10 +824,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
elif self.sud_schedule: elif self.sud_schedule:
# Fresh load (or reconnecting to an already-idle # Fresh load (or reconnecting to an already-idle
# server) - no run has happened yet, so the upfront # server) - no run has happened yet, so the upfront
# plan starting from the current temperature is # plan is exactly right; on_sud_forecast_received()
# exactly right. # fills it in once the simulated forecast arrives
start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0 # (a fresh Save request goes out on every connect,
self.forecast_plot.show_schedule(self.sud_schedule, start_theta, self.sud_name) # 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: 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.show_user_message(self.sud_user_message)
@@ -867,6 +848,14 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_hold_remaining = msg['HoldRemaining'] self.sud_hold_remaining = msg['HoldRemaining']
self.update_status_step_label() self.update_status_step_label()
elif "Elapsed" in key: elif "Elapsed" in key:
# Sud.elapsed resets to 0 on every fresh Load too (not just
# on an actual run start - see components/sud.py's
# _reset_run_state()) and broadcasts unconditionally - only
# apply it once the State handling above has already put
# this client into dynamic-view mode (sud_elapsed_seconds
# not None), so a mere Load while idle can't masquerade as
# a run starting.
if self.sud_elapsed_seconds is not None:
self.sud_elapsed_seconds = msg['Elapsed'] self.sud_elapsed_seconds = msg['Elapsed']
elif "RemainingForecast" in key: elif "RemainingForecast" in key:
forecast = msg['RemainingForecast'] forecast = msg['RemainingForecast']
@@ -893,7 +882,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# The server computes this asynchronously (it simulates the whole # The server computes this asynchronously (it simulates the whole
# schedule) and it can arrive slightly after the schedule itself - # schedule) and it can arrive slightly after the schedule itself -
# only apply it if we're still looking at the static "not running # only apply it if we're still looking at the static "not running
# yet" preview show_schedule() drew; a run already in progress (or # yet" preview show_computing() drew; a run already in progress (or
# one that's since finished) owns the plot via show_dynamic()/its # one that's since finished) owns the plot via show_dynamic()/its
# frozen last frame instead. # frozen last frame instead.
if self.sud_empty or self.sud_elapsed_seconds is not None: if self.sud_empty or self.sud_elapsed_seconds is not None:
@@ -923,12 +912,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
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 # on_sud_forecast_received() fills this in once the simulated
# started; fall back to a plausible ambient guess. If a run is # forecast arrives. If a run is already in progress (e.g.
# already in progress (e.g. reconnecting mid-brew), the next # reconnecting mid-brew), the next on_plot_timer() tick switches
# on_plot_timer() tick switches this over to the dynamic view. # this over to the dynamic view instead.
start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0 self.forecast_plot.show_computing(name)
self.forecast_plot.show_schedule(schedule, start_theta, name)
def closeEvent(self, event): def closeEvent(self, event):
print("Exitting gracefully!") print("Exitting gracefully!")