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:
2026-06-21 17:24:26 +02:00
parent df2be52298
commit d91bba1ba2
2 changed files with 84 additions and 13 deletions
+30 -13
View File
@@ -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()