Use the server's simulated forecast for the dynamic remainder too
SudForecastPlot.show_dynamic()'s dashed "remaining" line was still
using the naive abs(delta)/rate estimate even after a run started,
even though the (much more accurate) server-side
SudForecastEstimator was already available - the static forecast got
the upgrade, the live one didn't.
tasks/sud.py: SudTask now recomputes a forecast for just the remaining
steps (seeded from the live current temperature) on every step change
and sends it as {'RemainingForecast': {...}}. Building the synthetic
"rest of this step" entry for a step currently HOLDING needs to keep
the current step's grain_mass/water_mass/temperature/etc - a bare
{'hold': {...}} re-resolves those to None against the synthetic doc's
empty default.step, breaking derive_plant_params() (caught live: a
TypeError on every step change, silently swallowed by asyncio).
client/brewpi_gui.py: on_plot_timer() now uses the server's
RemainingForecast for the dashed projection whenever available,
falling back to the naive estimate only for the brief window before
each step's recomputation arrives. show_dynamic() takes the already-
computed (t_rem, theta_rem) instead of computing it internally, so the
GUI/server sources are interchangeable from its point of view.
This commit is contained in:
+30
-13
@@ -187,24 +187,21 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
||||
self.figure.tight_layout()
|
||||
self.draw_idle()
|
||||
|
||||
def show_dynamic(self, history, remaining_schedule, theta_now, elapsed_min, name):
|
||||
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),
|
||||
remaining_schedule the not-yet-done steps, projected forward
|
||||
(dashed) from (elapsed_min, theta_now) using the same nominal-rate
|
||||
estimate as show_schedule(), just re-seeded each call instead of
|
||||
computed once at t=0."""
|
||||
[(t_min, theta), ...] actually measured so far (drawn solid);
|
||||
t_rem_min/theta_rem is the projected remainder (dashed), already
|
||||
computed by the caller - either the server's simulation-based
|
||||
SudForecastEstimator (preferred, see Window.on_plot_timer()) or,
|
||||
until that arrives, a quick naive abs(delta)/rate fallback."""
|
||||
hist_t = [t for t, _ in history]
|
||||
hist_theta = [theta for _, theta in history]
|
||||
self.line.set_data(hist_t, hist_theta)
|
||||
|
||||
t_rem, theta_rem = self._estimate_course(remaining_schedule, theta_now)
|
||||
t_rem_min = [elapsed_min + seconds / 60.0 for seconds in t_rem]
|
||||
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 elapsed_min
|
||||
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()
|
||||
@@ -294,6 +291,11 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
# [(t_min, theta), ...] actually measured since the current run
|
||||
# started - the "already happened" part of the dynamic forecast.
|
||||
self.forecast_history = []
|
||||
# (T, Theta) from the server's simulation-based estimate of the
|
||||
# remaining steps, recomputed on every step change - 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')
|
||||
@@ -432,9 +434,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
# the projection 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))
|
||||
remaining = self._remaining_schedule()
|
||||
self.forecast_plot.show_dynamic(
|
||||
self.forecast_history, remaining, self.plot_temp_ist, elapsed_min, self.sud_name)
|
||||
if self.server_remaining_forecast is not None:
|
||||
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.
|
||||
t_rem, theta_rem = self.forecast_plot._estimate_course(self._remaining_schedule(), self.plot_temp_ist)
|
||||
t_rem_min = [elapsed_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:
|
||||
self.forecast_plot.clear_progress()
|
||||
|
||||
@@ -510,6 +519,7 @@ 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()
|
||||
@@ -829,10 +839,17 @@ 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']
|
||||
self.update_status_step_label()
|
||||
elif "RemainingForecast" in key:
|
||||
forecast = msg['RemainingForecast']
|
||||
self.server_remaining_forecast = (forecast['T'], forecast['Theta'])
|
||||
elif "Forecast" in key:
|
||||
self.on_sud_forecast_received(msg['Forecast'])
|
||||
self.update_sud_actions()
|
||||
|
||||
@@ -84,6 +84,7 @@ class SudTask(ATask):
|
||||
'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,
|
||||
}}))
|
||||
asyncio.create_task(self.send_remaining_forecast())
|
||||
|
||||
def on_state_changed(self, value):
|
||||
asyncio.create_task(self.send({'State': str(value)}))
|
||||
@@ -117,6 +118,59 @@ class SudTask(ATask):
|
||||
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc)
|
||||
await self.send({'Forecast': {'T': t, 'Theta': theta}})
|
||||
|
||||
def remaining_schedule(self):
|
||||
"""The not-yet-done part of the running schedule - the current
|
||||
step's already-finished phase is dropped, and its still-to-run
|
||||
phase is left for SudForecastEstimator to size from the live
|
||||
start_theta/hold_remaining it's seeded with, rather than the
|
||||
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."""
|
||||
schedule = self.sud.schedule
|
||||
index = self.sud.index
|
||||
if not schedule or not (0 <= index < len(schedule)):
|
||||
return []
|
||||
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/temperature/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(). Only the ramp
|
||||
# phase is actually done; drop it and 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 != 'ramp'}
|
||||
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
|
||||
doc = {
|
||||
'Name': self.sud.name,
|
||||
'Description': self.sud.description,
|
||||
'pot_mass': self.sud.pot_mass,
|
||||
'pot_material': self.sud.pot_material,
|
||||
'steps': remaining,
|
||||
}
|
||||
start_theta = self.tc.get_theta_ist()
|
||||
loop = asyncio.get_event_loop()
|
||||
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc, start_theta)
|
||||
await self.send({'RemainingForecast': {'T': t, 'Theta': theta}})
|
||||
|
||||
async def recv(self, data):
|
||||
for pair in data.items():
|
||||
if 'Start' in pair[0]:
|
||||
|
||||
Reference in New Issue
Block a user