From d91bba1ba26178063c4250e5b75bdf5ff2d22771 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 21 Jun 2026 17:24:26 +0200 Subject: [PATCH] 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. --- client/brewpi_gui.py | 43 ++++++++++++++++++++++++----------- tasks/sud.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index a3c7f9b..a93ed81 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -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() diff --git a/tasks/sud.py b/tasks/sud.py index 64fd92f..53fb635 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -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]: