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): class SudForecastPlot(FigureCanvasQTAgg):
"""A preview of a Sud schedule's temperature course, simulated """Compares a Sud schedule's actual control behavior (line, solid)
server-side with the same plant/controller model the real run uses 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 (components/sud_forecast.py's SudForecastEstimator - see
show_computing()/show_precomputed_course()). There used to also be an show_computing()/show_forecast()). The forecast is computed once per
immediate, naive abs(delta)/rate placeholder shown the instant a piecewise segment and never revisited once a run is using it - the
schedule loaded, before the simulated one arrived a moment later - whole point is an honest, unmoving baseline to compare reality
removed because it was structurally wrong often enough (no model of against, not a prediction that keeps re-anchoring itself to match
PID settling time, ramp dead time, etc.) to not be worth showing even whatever's actually happening. The one exception is a step requiring
briefly. user confirmation: a human's response time genuinely can't be
forecast, so the segment just stops there, and the next one is
Once running, nonideal ramp rates/user pauses make any static estimate computed for real (anchored at the real elapsed time and
drift from reality - show_dynamic() instead re-anchors the projection temperature) only once the user actually confirms - see
every tick: the already-elapsed part is the actual measured trace tasks/sud.py's SudTask._continue_forecast_after_confirm().
(line, solid), and only the remaining steps are projected forward from extend_forecast_while_waiting() keeps the dashed line visually flat
the live current temperature/step/hold_remaining (line_projected, through the wait in the meantime."""
dashed)."""
def __init__(self): def __init__(self):
figure = Figure(facecolor='white') figure = Figure(facecolor='white')
@@ -143,32 +143,66 @@ class SudForecastPlot(FigureCanvasQTAgg):
figure.tight_layout() 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): def show_computing(self, name):
"""Shown right after a schedule loads, while the simulation-based """Shown right after a schedule loads, while the first
forecast is still being computed server-side (see simulation-based forecast segment is still being computed
Window.on_sud_forecast_received()) - typically resolved within a server-side (see Window.on_sud_forecast_received()) - typically
second. Deliberately shows nothing rather than an approximate resolved within a second. Deliberately shows nothing rather than
placeholder; leaves the axes' current view alone (there's nothing an approximate placeholder; leaves the axes' current view alone
meaningful to fit yet), so whatever was on screen before just (there's nothing meaningful to fit yet), so whatever was on
stays frozen underneath the title change until show_precomputed_ screen before just stays frozen underneath the title change
course() replaces it.""" until show_forecast() replaces it."""
self._forecast_t_min = []
self._forecast_theta = []
self.line.set_data([], []) self.line.set_data([], [])
self.line_projected.set_data([], []) self.line_projected.set_data([], [])
self.ax.set_title("{} - computing forecast...".format(name), fontsize='small') self.ax.set_title("{} - computing forecast...".format(name), fontsize='small')
self.figure.tight_layout() self.figure.tight_layout()
self.draw_idle() self.draw_idle()
def show_precomputed_course(self, t_min, theta, name): def show_forecast(self, t_min, theta, name, finished):
"""Static forecast computed by the server simulating the schedule """The forecast curve (dashed) - computed once, piecewise, by the
with its real plant/controller (components/sud_forecast.py) - see server simulating the schedule with its real plant/controller
Window.on_sud_forecast_received().""" (components/sud_forecast.py) - see Window.on_sud_forecast_
self.line.set_data(t_min, theta) received(). Called again, with a longer t_min/theta, each time a
self.line_projected.set_data([], []) 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._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.figure.tight_layout()
self.draw_idle() 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): def _set_fixed_limits(self, t_list, theta_list):
"""Explicitly sets (and, unlike relim()+autoscale_view(), locks) """Explicitly sets (and, unlike relim()+autoscale_view(), locks)
the axes' view to fit exactly the given data, with matplotlib's 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_xlim(t_lo - t_pad, t_hi + t_pad)
self.ax.set_ylim(th_lo - th_pad, th_hi + th_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):
"""Re-anchored forecast for a run in progress: history is the """Updates the actual measured trace (solid) only - the forecast
[(t_min, theta), ...] actually measured so far (drawn solid); (dashed) is left exactly as show_forecast() last drew it (that's
t_rem_min/theta_rem is the projected remainder (dashed), the the whole point: an honest, unmoving baseline - see this class's
server's simulation-based SudForecastEstimator result for docstring), and the axes stay locked to whatever it set, so
whatever's left of the schedule, already anchored by the caller divergence between the two is visible rather than auto-hidden by
(see Window.on_plot_timer()). Either can be None - meaning the the view rescaling to chase whichever line is currently
server hasn't recomputed it yet for the step just entered - in drawing."""
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.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() self.draw_idle()
def show_no_schedule(self): def show_no_schedule(self):
self._forecast_t_min = []
self._forecast_theta = []
self.line.set_data([], []) self.line.set_data([], [])
self.line_projected.set_data([], []) self.line_projected.set_data([], [])
self.clear_progress() 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 # load, which used to make the live trace visibly drift from the
# forecast. # forecast.
self.sud_elapsed_seconds = None 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_name = ''
self.sud_schedule = [] self.sud_schedule = []
self.sud_step_index = None self.sud_step_index = None
@@ -297,16 +319,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_step_type = None self.sud_step_type = None
self.sud_hold_remaining = 0.0 self.sud_hold_remaining = 0.0
# [(t_min, theta), ...] actually measured since the current run # [(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 = [] 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_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')
@@ -450,30 +465,20 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
if elapsed_min is not None: if elapsed_min is not None:
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 # Frozen while PAUSED (elapsed_min isn't advancing either, and
# the static, increasingly-stale estimate from t=0. Frozen while # the paused phase - ramp or hold - isn't visible to the client
# PAUSED (elapsed_min isn't advancing either, and the paused # to resume the trace from correctly) - just leave the last
# phase - ramp or hold - isn't visible to the client to resume # draw up.
# the projection from correctly) - just leave the last draw up.
if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist: if self.sud_state != SUD_PAUSED_STATE and self.plot_temp_ist:
self.forecast_history.append((elapsed_min, self.plot_temp_ist)) self.forecast_history.append((elapsed_min, self.plot_temp_ist))
if self.server_remaining_forecast is not None: self.forecast_plot.show_dynamic(self.forecast_history)
# Anchored to elapsed_min as of when the server computed if self.sud_state == SUD_WAIT_USER_STATE:
# this forecast, not the current tick's - it's only # A human's confirm time can't be forecast - the dashed
# recomputed on step changes, so re-adding the live, # line just repeats its last value out to 'now' until
# ever-growing elapsed_min here would push the whole # the real confirmation appends the next segment (see
# projection further right every tick until the next # tasks/sud.py's SudTask._continue_forecast_after_
# step change, instead of holding it fixed. # confirm()).
anchor_min, t_rem, theta_rem = self.server_remaining_forecast self.forecast_plot.extend_forecast_while_waiting(elapsed_min)
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)
else: else:
self.forecast_plot.clear_progress() self.forecast_plot.clear_progress()
@@ -523,7 +528,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.sud_step_type = None self.sud_step_type = None
self.sud_hold_remaining = 0.0 self.sud_hold_remaining = 0.0
self.forecast_history = [] self.forecast_history = []
self.server_remaining_forecast = None
self.set_tc_cooling(False) self.set_tc_cooling(False)
self.update_sud_actions() self.update_sud_actions()
self.update_status_env_label() 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_index = step.get('Index')
self.sud_step_descr = step.get('Descr') self.sud_step_descr = step.get('Descr')
self.sud_step_type = step.get('Type') 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() self.update_status_step_label()
elif "HoldRemaining" in key: elif "HoldRemaining" in key:
self.sud_hold_remaining = msg['HoldRemaining'] self.sud_hold_remaining = msg['HoldRemaining']
@@ -857,15 +857,6 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# a run starting. # a run starting.
if self.sud_elapsed_seconds is not None: if self.sud_elapsed_seconds is not None:
self.sud_elapsed_seconds = msg['Elapsed'] 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: elif "Forecast" in key:
self.on_sud_forecast_received(msg['Forecast']) self.on_sud_forecast_received(msg['Forecast'])
elif "Error" in key: elif "Error" in key:
@@ -879,16 +870,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
self.update_sud_actions() self.update_sud_actions()
def on_sud_forecast_received(self, forecast): def on_sud_forecast_received(self, forecast):
# The server computes this asynchronously (it simulates the whole # Computed asynchronously server-side and can arrive slightly
# schedule) and it can arrive slightly after the schedule itself - # after the schedule itself - applies regardless of whether a
# only apply it if we're still looking at the static "not running # run is in progress, since this can also be a later piecewise
# yet" preview show_computing() drew; a run already in progress (or # segment appended after a real user confirmation (see
# one that's since finished) owns the plot via show_dynamic()/its # tasks/sud.py's SudTask._continue_forecast_after_confirm()), not
# frozen last frame instead. # just the initial one computed at Load.
if self.sud_empty or self.sud_elapsed_seconds is not None: if self.sud_empty:
return return
t_min = [seconds / 60.0 for seconds in forecast['T']] 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): def update_status_step_label(self):
if not self.sud_step_descr: if not self.sud_step_descr:
+18 -12
View File
@@ -34,17 +34,27 @@ class SudForecastEstimator:
self.theta_amb = theta_amb self.theta_amb = theta_amb
def estimate(self, doc, start_theta=None): def estimate(self, doc, start_theta=None):
"""Returns (t, theta): parallel lists of elapsed simulated seconds """Returns (t, theta, final_state): t/theta are parallel lists of
and temperature, one point per tick, for the given sud.json elapsed simulated seconds and temperature for one piecewise
document. start_theta defaults to the configured ambient segment of the schedule, starting from doc['steps'][0] - either
temperature - i.e. a cold start, same as the GUI's static all the way to the end (final_state is SudState.DONE), or, if a
estimate.""" step requires user confirmation, only up to and including that
step's hold (final_state is SudState.WAIT_USER). A real user's
confirm time can't be forecast, so the simulation simply stops
there instead of guessing zero delay or otherwise - the caller is
expected to compute the next segment for real once the user
actually confirms (see tasks/sud.py's SudTask), anchored at
whatever the real elapsed time and temperature are by then,
rather than this estimate continuing to guess at it.
start_theta defaults to the configured ambient temperature - i.e.
a cold start, same as the GUI's static estimate."""
if start_theta is None: if start_theta is None:
start_theta = self.theta_amb start_theta = self.theta_amb
sud = Sud() sud = Sud()
if not sud.load(doc) or not sud.schedule: if not sud.load(doc) or not sud.schedule:
return [0.0], [start_theta] return [0.0], [start_theta], SudState.DONE
pot = Pot(self.dt, self.plant_params, self.theta_amb) pot = Pot(self.dt, self.plant_params, self.theta_amb)
pot.initial(start_theta) pot.initial(start_theta)
@@ -80,7 +90,7 @@ class SudForecastEstimator:
sud.start() sud.start()
ticks = 0 ticks = 0
while sud.state != SudState.DONE and ticks < MAX_TICKS: while sud.state not in (SudState.DONE, SudState.WAIT_USER) and ticks < MAX_TICKS:
pot.process() pot.process()
tc.set_theta_ist(pot.get_temperature()) tc.set_theta_ist(pot.get_temperature())
tc.process() tc.process()
@@ -89,14 +99,10 @@ class SudForecastEstimator:
if sud.state == SudState.RAMPING: if sud.state == SudState.RAMPING:
if tc.is_holding(): if tc.is_holding():
sud.temp_reached() sud.temp_reached()
elif sud.state == SudState.WAIT_USER:
# A real user's confirm time isn't predictable - count it
# as instant for estimation purposes.
sud.confirm()
sud.tick(self.dt) sud.tick(self.dt)
t.append(t[-1] + self.dt) t.append(t[-1] + self.dt)
theta.append(pot.get_temperature()) theta.append(pot.get_temperature())
ticks += 1 ticks += 1
return t, theta return t, theta, sud.state
+58 -50
View File
@@ -29,6 +29,18 @@ class SudTask(ATask):
# build a SudTask without one still work; the server always passes # build a SudTask without one still work; the server always passes
# one. # one.
self.forecast_estimator = forecast_estimator self.forecast_estimator = forecast_estimator
# Accumulated forecast across all piecewise segments computed so
# far (see send_forecast()/_continue_forecast_after_confirm()) -
# T in simulated seconds, Theta in degrees, parallel lists, both
# growing monotonically as segments get appended; never re-walked
# from scratch once a run is in progress, except for the one
# genuinely unforecastable case (a real user confirmation).
self.forecast_t = []
self.forecast_theta = []
# Whether the most recently computed segment stopped at a step
# requiring user confirmation rather than reaching the
# schedule's actual end - see _continue_forecast_after_confirm().
self.forecast_waiting_for_confirm = False
msg_handler.set_recv_handler(self.recv) msg_handler.set_recv_handler(self.recv)
def apply_plant_params(self, step): def apply_plant_params(self, step):
@@ -81,7 +93,6 @@ class SudTask(ATask):
'Duration': hold.get('duration') if (hold is not None and not ramping) else None, 'Duration': hold.get('duration') if (hold is not None and not ramping) else None,
'WaitForUser': step.get('user_wait_for_continue', False) if step else None, 'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
}})) }}))
asyncio.create_task(self.send_remaining_forecast())
def on_state_changed(self, value): def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)})) asyncio.create_task(self.send({'State': str(value)}))
@@ -108,6 +119,12 @@ class SudTask(ATask):
asyncio.create_task(self.send({'Elapsed': value})) asyncio.create_task(self.send({'Elapsed': value}))
async def send_forecast(self, doc): async def send_forecast(self, doc):
"""Computes and sends the first piecewise segment of doc's
forecast - from the very start, up to either the schedule's end
or its first step requiring user confirmation (see
components/sud_forecast.py's SudForecastEstimator.estimate()).
Always a fresh start: resets any segments accumulated for
whatever was loaded before."""
if self.forecast_estimator is None: if self.forecast_estimator is None:
return return
# Runs the simulation in a worker thread - it's CPU-bound and can # Runs the simulation in a worker thread - it's CPU-bound and can
@@ -115,67 +132,57 @@ class SudTask(ATask):
# otherwise stall every other task (heater, sensor, ...) for that # otherwise stall every other task (heater, sensor, ...) for that
# whole window. # whole window.
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc) t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc)
await self.send({'Forecast': {'T': t, 'Theta': theta}}) self.forecast_t = t
self.forecast_theta = theta
self.forecast_waiting_for_confirm = (final_state == SudState.WAIT_USER)
await self._send_forecast()
def remaining_schedule(self): async def _send_forecast(self):
"""The not-yet-done part of the running schedule - the current await self.send({'Forecast': {
step's already-finished phase is dropped, and its still-to-run 'T': self.forecast_t,
phase is left for SudForecastEstimator to size from the live 'Theta': self.forecast_theta,
start_theta/hold_remaining it's seeded with, rather than the 'Finished': not self.forecast_waiting_for_confirm,
step's nominal start. Mirrors client/brewpi_gui.py's }})
Window._remaining_schedule(), which does the same thing
client-side for its own (less accurate) naive fallback.""" async def _continue_forecast_after_confirm(self):
"""Computes and appends the next piecewise segment once a real
user confirmation has actually happened, rather than guessing at
the delay - see SudForecastEstimator.estimate()'s docstring for
why. Anchored at the real elapsed time (self.sud.elapsed), so the
unforecastable wait shows up honestly as a gap in the forecast
rather than as zero delay, and at the real current temperature
(more trustworthy than whatever the now-superseded previous
segment's simulation predicted it would be by now)."""
if self.forecast_estimator is None or not self.forecast_waiting_for_confirm:
return
schedule = self.sud.schedule schedule = self.sud.schedule
index = self.sud.index index = self.sud.index
if not schedule or not (0 <= index < len(schedule)): if not (0 <= index < len(schedule)):
return [] self.forecast_waiting_for_confirm = False
current = schedule[index]
rest = schedule[index + 1:]
if self.sud.state == SudState.WAIT_USER:
return rest
if self.sud.state == SudState.HOLDING:
# Keep the rest of the current (already fully-resolved) step -
# grain_mass/water_mass/etc. - SudForecastEstimator re-resolves
# each step against an empty default.step, so a bare
# {'hold': {...}} here would leave those None instead of
# inherited, breaking derive_plant_params(). The ramp phase is
# already done, so drop 'temperature' (not 'ramp' - every step
# always carries a 'ramp' dict now, dropping it would leave
# 'rate' missing the moment this synthetic step is re-resolved):
# without a 'temperature' of its own, the rebuilt step pushes
# no new target, start_theta already seeds the simulation at
# the target anyway, so it resolves out of its (now synthetic)
# ramp phase in a tick or two regardless. Override the hold's
# duration to what's actually left.
remaining_minutes = max(self.sud.hold_remaining, 0.0) / 60.0
synthetic = {k: v for k, v in current.items() if k != 'temperature'}
synthetic['hold'] = {**current.get('hold', {}), 'duration': remaining_minutes}
return [synthetic] + rest
return [current] + rest
async def send_remaining_forecast(self):
"""Like send_forecast(), but for the remaining steps only, seeded
from the live current temperature - the dynamic forecast's
projected-remainder line (GUI's SudForecastPlot.line_projected)
uses this instead of its own naive abs(delta)/rate estimate
whenever it's available."""
if self.forecast_estimator is None:
return
remaining = self.remaining_schedule()
if not remaining:
return return
doc = { doc = {
'Name': self.sud.name, 'Name': self.sud.name,
'Description': self.sud.description, 'Description': self.sud.description,
'pot_mass': self.sud.pot_mass, 'pot_mass': self.sud.pot_mass,
'pot_material': self.sud.pot_material, 'pot_material': self.sud.pot_material,
'steps': remaining, 'steps': schedule[index:],
} }
start_theta = self.tc.get_theta_ist() start_theta = self.tc.get_theta_ist()
real_elapsed = self.sud.elapsed
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta) t, theta, final_state = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
await self.send({'RemainingForecast': {'T': t, 'Theta': theta}}) # Bridge the gap between where the previous segment's own
# simulated time left off and the real elapsed time the confirm
# actually happened at, holding flat at its last temperature -
# then append the new segment, offset to start exactly there.
if self.forecast_t and self.forecast_t[-1] < real_elapsed:
self.forecast_t.append(real_elapsed)
self.forecast_theta.append(self.forecast_theta[-1])
self.forecast_t.extend(real_elapsed + seconds for seconds in t)
self.forecast_theta.extend(theta)
self.forecast_waiting_for_confirm = (final_state == SudState.WAIT_USER)
await self._send_forecast()
async def recv(self, data): async def recv(self, data):
for pair in data.items(): for pair in data.items():
@@ -183,6 +190,7 @@ class SudTask(ATask):
self.sud.start() self.sud.start()
elif 'Confirm' in pair[0]: elif 'Confirm' in pair[0]:
self.sud.confirm() self.sud.confirm()
asyncio.create_task(self._continue_forecast_after_confirm())
elif 'Pause' in pair[0]: elif 'Pause' in pair[0]:
self.sud.pause() self.sud.pause()
elif 'Stop' in pair[0]: elif 'Stop' in pair[0]: