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:
+96
-105
@@ -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
@@ -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
@@ -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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user