Make the Sud forecast piecewise, computed once per segment

Previously, the dynamic forecast's projected remainder was fully
recomputed on every step change, and a step requiring user
confirmation was simulated with zero added delay ("count it as
instant for estimation purposes"). Both undercut the actual goal of
the forecast: an honest comparison between real control behavior and
a simulation-based prediction. A forecast that keeps re-anchoring
itself to match reality isn't a useful baseline to compare reality
against, and assuming zero confirmation delay quietly understates the
schedule.

components/sud_forecast.py: SudForecastEstimator.estimate() now stops
simulating at the first step requiring user confirmation instead of
auto-confirming, returning the final SudState alongside the data so
the caller knows whether it stopped there or actually finished.

tasks/sud.py: SudTask now accumulates the forecast across piecewise
segments (forecast_t/forecast_theta). send_forecast() computes only
the first segment, at Load/Save. The real Confirm handler triggers
_continue_forecast_after_confirm(), which computes the next segment
anchored at the real elapsed time and real current temperature -
bridging the unforecastable wait with a flat segment rather than
guessing at its length - and sends the updated, stitched Forecast
(now carrying a Finished flag). The old per-step-change
RemainingForecast recompute is gone entirely, along with
remaining_schedule()/send_remaining_forecast().

client/brewpi_gui.py: SudForecastPlot redesigned around this - the
dashed line is the fixed, piecewise forecast (locked axes, untouched
except when a genuinely new segment arrives via show_forecast());
the solid line is purely the actual measured trace
(show_dynamic(), now only touching that). extend_forecast_while_
waiting() repeats the forecast's last value while the real Sud sits
in WAIT_USER, so the dashed line doesn't just stop, then snaps to the
new segment the moment the real confirmation appends one.

Verified via a raw-protocol test (segment boundaries, the Finished
flag, and real-time stitching across a confirm delay) and visually in
the GUI (locked dashed forecast, "X min so far total" title while
incomplete, solid/dashed comparison rendering with realistic
convergence/divergence).

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 16:33:40 +02:00
co-authored by Claude Sonnet 4.6
parent 41b06d8961
commit 57b86bc075
3 changed files with 172 additions and 167 deletions
+96 -105
View File
@@ -109,22 +109,22 @@ class RealtimePlot(FigureCanvasQTAgg):
class SudForecastPlot(FigureCanvasQTAgg):
"""A preview of a Sud schedule's temperature course, simulated
server-side with the same plant/controller model the real run uses
"""Compares a Sud schedule's actual control behavior (line, solid)
against a fixed, simulation-based forecast (line_projected, dashed) -
the same plant/controller model the real run uses
(components/sud_forecast.py's SudForecastEstimator - see
show_computing()/show_precomputed_course()). There used to also be an
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 any 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)."""
show_computing()/show_forecast()). The forecast is computed once per
piecewise segment and never revisited once a run is using it - the
whole point is an honest, unmoving baseline to compare reality
against, not a prediction that keeps re-anchoring itself to match
whatever's actually happening. The one exception is a step requiring
user confirmation: a human's response time genuinely can't be
forecast, so the segment just stops there, and the next one is
computed for real (anchored at the real elapsed time and
temperature) only once the user actually confirms - see
tasks/sud.py's SudTask._continue_forecast_after_confirm().
extend_forecast_while_waiting() keeps the dashed line visually flat
through the wait in the meantime."""
def __init__(self):
figure = Figure(facecolor='white')
@@ -143,32 +143,66 @@ class SudForecastPlot(FigureCanvasQTAgg):
figure.tight_layout()
# The forecast as last received from the server (see
# show_forecast()) - kept around so extend_forecast_while_waiting()
# can repeat its last value without the caller needing to pass it
# again every tick.
self._forecast_t_min = []
self._forecast_theta = []
def show_computing(self, name):
"""Shown right after a schedule loads, while the simulation-based
forecast is still being computed server-side (see
Window.on_sud_forecast_received()) - typically resolved within a
second. Deliberately shows nothing rather than an approximate
placeholder; leaves the axes' current view alone (there's nothing
meaningful to fit yet), so whatever was on screen before just
stays frozen underneath the title change until show_precomputed_
course() replaces it."""
"""Shown right after a schedule loads, while the first
simulation-based forecast segment is still being computed
server-side (see Window.on_sud_forecast_received()) - typically
resolved within a second. Deliberately shows nothing rather than
an approximate placeholder; leaves the axes' current view alone
(there's nothing meaningful to fit yet), so whatever was on
screen before just stays frozen underneath the title change
until show_forecast() replaces it."""
self._forecast_t_min = []
self._forecast_theta = []
self.line.set_data([], [])
self.line_projected.set_data([], [])
self.ax.set_title("{} - computing forecast...".format(name), fontsize='small')
self.figure.tight_layout()
self.draw_idle()
def show_precomputed_course(self, t_min, theta, name):
"""Static forecast computed by the server simulating the schedule
with its real plant/controller (components/sud_forecast.py) - see
Window.on_sud_forecast_received()."""
self.line.set_data(t_min, theta)
self.line_projected.set_data([], [])
def show_forecast(self, t_min, theta, name, finished):
"""The forecast curve (dashed) - computed once, piecewise, by the
server simulating the schedule with its real plant/controller
(components/sud_forecast.py) - see Window.on_sud_forecast_
received(). Called again, with a longer t_min/theta, each time a
further piecewise segment gets appended after a real user
confirmation; finished is False while the forecast doesn't yet
cover the rest of the schedule (stopped at a step requiring
confirmation - see extend_forecast_while_waiting()). Locks the
axes to fit the forecast alone, regardless of whatever the
actual trace (line) is currently doing."""
self._forecast_t_min = t_min
self._forecast_theta = theta
self.line_projected.set_data(t_min, theta)
self._set_fixed_limits(t_min, theta)
self.ax.set_title("{} - {:.0f} min total".format(name, t_min[-1] if t_min else 0.0), fontsize='small')
total = t_min[-1] if t_min else 0.0
suffix = "" if finished else " so far"
self.ax.set_title("{} - {:.0f} min{} total".format(name, total, suffix), fontsize='small')
self.figure.tight_layout()
self.draw_idle()
def extend_forecast_while_waiting(self, elapsed_min):
"""Called every tick while the real Sud is waiting on user
confirmation: a human's response time can't be forecast, so
rather than guess, the dashed line just repeats its last value
out to 'now' until the next segment is appended for real (see
tasks/sud.py's SudTask._continue_forecast_after_confirm(), fired
the moment the real confirmation happens) - at which point
show_forecast() overwrites this temporary extension with the
actual next segment."""
if not self._forecast_t_min or elapsed_min <= self._forecast_t_min[-1]:
return
last_theta = self._forecast_theta[-1]
self.line_projected.set_data(self._forecast_t_min + [elapsed_min], self._forecast_theta + [last_theta])
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
@@ -187,30 +221,22 @@ class SudForecastPlot(FigureCanvasQTAgg):
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):
"""Re-anchored forecast for a run in progress: history is the
[(t_min, theta), ...] actually measured so far (drawn solid);
t_rem_min/theta_rem is the projected remainder (dashed), the
server's simulation-based SudForecastEstimator result for
whatever's left of the schedule, already anchored by the caller
(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."""
def show_dynamic(self, history):
"""Updates the actual measured trace (solid) only - the forecast
(dashed) is left exactly as show_forecast() last drew it (that's
the whole point: an honest, unmoving baseline - see this class's
docstring), and the axes stay locked to whatever it set, so
divergence between the two is visible rather than auto-hidden by
the view rescaling to chase whichever line is currently
drawing."""
hist_t = [t for t, _ in history]
hist_theta = [theta for _, theta in history]
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.ax.relim()
self.ax.autoscale_view()
total_min = t_rem_min[-1] if t_rem_min else (hist_t[-1] if hist_t else 0.0)
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._forecast_t_min = []
self._forecast_theta = []
self.line.set_data([], [])
self.line_projected.set_data([], [])
self.clear_progress()
@@ -286,10 +312,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# load, which used to make the live trace visibly drift from the
# forecast.
self.sud_elapsed_seconds = 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
@@ -297,16 +319,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_step_type = 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.
# started - the "actual" half of the forecast-vs-actual
# comparison (SudForecastPlot.show_dynamic()).
self.forecast_history = []
# (anchor_min, T, Theta) from the server's simulation-based estimate
# of the remaining steps, recomputed on every step change -
# anchor_min is elapsed_min as of receipt, so on_plot_timer() can
# re-anchor the projection without it drifting right every tick.
# None until it arrives (or after a step change, until the next one
# does), in which case on_plot_timer() falls back to the naive
# estimate.
self.server_remaining_forecast = None
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')
@@ -450,30 +465,20 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
if elapsed_min is not None:
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.
# 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 trace 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))
if self.server_remaining_forecast is not None:
# Anchored to elapsed_min as of when the server computed
# this forecast, not the current tick's - it's only
# recomputed on step changes, so re-adding the live,
# ever-growing elapsed_min here would push the whole
# projection further right every tick until the next
# step change, instead of holding it fixed.
anchor_min, t_rem, theta_rem = self.server_remaining_forecast
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)
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)
self.forecast_plot.show_dynamic(self.forecast_history)
if self.sud_state == SUD_WAIT_USER_STATE:
# A human's confirm time can't be forecast - the dashed
# line just repeats its last value out to 'now' until
# the real confirmation appends the next segment (see
# tasks/sud.py's SudTask._continue_forecast_after_
# confirm()).
self.forecast_plot.extend_forecast_while_waiting(elapsed_min)
else:
self.forecast_plot.clear_progress()
@@ -523,7 +528,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_step_type = None
self.sud_hold_remaining = 0.0
self.forecast_history = []
self.server_remaining_forecast = None
self.set_tc_cooling(False)
self.update_sud_actions()
self.update_status_env_label()
@@ -839,10 +843,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_step_index = step.get('Index')
self.sud_step_descr = step.get('Descr')
self.sud_step_type = step.get('Type')
# Stale for the step that just ended - the server is
# already recomputing it (see on_plot_timer()'s fallback
# to the naive estimate in the meantime).
self.server_remaining_forecast = None
self.update_status_step_label()
elif "HoldRemaining" in key:
self.sud_hold_remaining = msg['HoldRemaining']
@@ -857,15 +857,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# a run starting.
if self.sud_elapsed_seconds is not None:
self.sud_elapsed_seconds = msg['Elapsed']
elif "RemainingForecast" in key:
forecast = msg['RemainingForecast']
# Anchor to elapsed_min as of receipt - close enough to
# when the server actually computed it (it's seeded from
# the live theta_ist at that moment) - rather than the
# live elapsed_min on_plot_timer() sees on later ticks.
anchor_min = self._elapsed_min()
if anchor_min is not None:
self.server_remaining_forecast = (anchor_min, forecast['T'], forecast['Theta'])
elif "Forecast" in key:
self.on_sud_forecast_received(msg['Forecast'])
elif "Error" in key:
@@ -879,16 +870,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.update_sud_actions()
def on_sud_forecast_received(self, forecast):
# The server computes this asynchronously (it simulates the whole
# schedule) and it can arrive slightly after the schedule itself -
# only apply it if we're still looking at the static "not running
# yet" preview show_computing() drew; a run already in progress (or
# one that's since finished) owns the plot via show_dynamic()/its
# frozen last frame instead.
if self.sud_empty or self.sud_elapsed_seconds is not None:
# Computed asynchronously server-side and can arrive slightly
# after the schedule itself - applies regardless of whether a
# run is in progress, since this can also be a later piecewise
# segment appended after a real user confirmation (see
# tasks/sud.py's SudTask._continue_forecast_after_confirm()), not
# just the initial one computed at Load.
if self.sud_empty:
return
t_min = [seconds / 60.0 for seconds in forecast['T']]
self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name)
self.forecast_plot.show_forecast(t_min, forecast['Theta'], self.sud_name, forecast['Finished'])
def update_status_step_label(self):
if not self.sud_step_descr: