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:
+80
-92
@@ -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!")
|
||||||
|
|||||||
Reference in New Issue
Block a user