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
+54
View File
@@ -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]: