Add a simulation-based Sud forecast, computed server-side

New components/sud_forecast.py: SudForecastEstimator simulates a whole
schedule with the same kind of plant/controller (and the same
configured params - ambient, plant params, pid_type, TempCtrl gains,
heater max power) the server uses for real, by driving a throwaway
Sud/Pot/controller trio through it exactly as tasks/sud.py's SudTask
would. It's pure CPU-bound iteration (no real time/IO), so a multi-
hour brew simulates in well under a second.

tasks/sud.py: SudTask now takes an optional forecast_estimator and
sends its result ({'Forecast': {'T': ..., 'Theta': ...}}) on the Sud
channel after every successful Save/Load, run in a worker thread via
run_in_executor so the ~100-500ms simulation doesn't stall the other
tasks. server/brewpi.py constructs one with the real server's config
and wires it in; AmbientTemp changes update it too.

client/brewpi_gui.py: the quick naive show_schedule() estimate (drawn
immediately on Load, before the server's simulation finishes) is now
superseded by show_precomputed_course() once the Forecast message
arrives - only while not actively running, so it doesn't fight the
dynamic re-anchored view.

Verified: matches a standalone run of the estimator (177 min for
sude/sud_0010.json) and replaces the old naive 164 min estimate live
within a couple seconds of loading.
This commit is contained in:
2026-06-21 16:46:52 +02:00
parent 1034765e31
commit 567ab80c9c
4 changed files with 156 additions and 3 deletions
+27
View File
@@ -163,9 +163,22 @@ class SudForecastPlot(FigureCanvasQTAgg):
return t, theta return t, theta
def show_schedule(self, schedule, start_theta, name): def show_schedule(self, schedule, start_theta, name):
"""Quick, approximate preview shown immediately when a schedule is
loaded - assumes every ramp instantly achieves/sustains its
declared rate, which real plants/controllers don't. Superseded by
show_precomputed_course() shortly after, once the server's
simulation-based forecast (components/sud_forecast.py) arrives."""
t, theta = self._estimate_course(schedule, start_theta) t, theta = self._estimate_course(schedule, start_theta)
t_min = [seconds / 60.0 for seconds in t] t_min = [seconds / 60.0 for seconds in t]
self._show_static_course(t_min, theta, name)
def show_precomputed_course(self, t_min, theta, name):
"""Like show_schedule(), but (t_min, theta) were already computed
by the server simulating the schedule with its real plant/
controller - see Window.on_sud_forecast_received()."""
self._show_static_course(t_min, theta, name)
def _show_static_course(self, t_min, theta, name):
self.line.set_data(t_min, theta) self.line.set_data(t_min, theta)
self.line_projected.set_data([], []) self.line_projected.set_data([], [])
self.ax.relim() self.ax.relim()
@@ -820,8 +833,22 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
elif "HoldRemaining" in key: elif "HoldRemaining" in key:
self.sud_hold_remaining = msg['HoldRemaining'] self.sud_hold_remaining = msg['HoldRemaining']
self.update_status_step_label() self.update_status_step_label()
elif "Forecast" in key:
self.on_sud_forecast_received(msg['Forecast'])
self.update_sud_actions() self.update_sud_actions()
def on_sud_forecast_received(self, forecast):
# The server computes this asynchronously (it simulates the whole
# schedule) and it can arrive slightly after the schedule itself -
# only apply it if we're still looking at the static "not running
# yet" preview show_schedule() drew; a run already in progress (or
# one that's since finished) owns the plot via show_dynamic()/its
# frozen last frame instead.
if self.sud_empty or self.sud_start_time is not None:
return
t_min = [seconds / 60.0 for seconds in forecast['T']]
self.forecast_plot.show_precomputed_course(t_min, forecast['Theta'], self.sud_name)
def update_status_step_label(self): def update_status_step_label(self):
if not self.sud_step_descr: if not self.sud_step_descr:
self.statusBar().clearMessage() self.statusBar().clearMessage()
+98
View File
@@ -0,0 +1,98 @@
from components.plant import Pot
from components.pid import PidFactory
from components.sud import Sud, SudState
# Real schedules need real time to spin up each ramp and settle within this
# tolerance before "reached" fires - matching tasks/sud.py's
# TEMP_REACHED_TOLERANCE exactly is what makes this simulation's predicted
# duration line up with the real run's.
TEMP_REACHED_TOLERANCE = 0.2
# Safety cap so a schedule whose target a step can never actually reach
# (e.g. a "hold" colder than ambient with no active cooling) can't hang the
# simulation forever - the estimate is simply cut off there.
MAX_TICKS = 200000
class SudForecastEstimator:
"""Predicts how long a Sud schedule will actually take by simulating it
with the same machinery (and params) the real server's brewpi.py wires
up - a fresh Pot and temperature controller of the configured pid_type,
driven through the schedule exactly as tasks/sud.py's SudTask would.
This is deliberately independent of wall-clock/asyncio time: it just
iterates dt-sized ticks as fast as the CPU allows (a multi-hour brew
simulates in well under a second), so it can be run synchronously
whenever a client needs an estimate - the naive "abs(delta)/rate" model
the GUI used to compute itself has no way to see the real PID cascade's
spin-up/settling lag, which is exactly why its estimate drifted so far
from reality (see README.md's "Forecast vs. actual duration")."""
def __init__(self, dt, theta_amb, plant_params, pid_type, tempctrl_params, heater_max_power):
self.dt = dt
self.theta_amb = theta_amb
self.plant_params = plant_params
self.pid_type = pid_type
self.tempctrl_params = tempctrl_params
self.heater_max_power = heater_max_power
def set_ambient_temperature(self, theta_amb):
self.theta_amb = theta_amb
def estimate(self, doc, start_theta=None):
"""Returns (t, theta): parallel lists of elapsed simulated seconds
and temperature, one point per tick, for the given sud.json
document. 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:
start_theta = self.theta_amb
sud = Sud()
if not sud.load(doc) or not sud.schedule:
return [0.0], [start_theta]
pot = Pot(self.dt, self.plant_params, self.theta_amb)
pot.initial(start_theta)
tc = PidFactory.create(self.pid_type, self.dt, self.tempctrl_params, self.plant_params, theta_amb=self.theta_amb)
tc.set_enabled(True)
tc.set_theta_ist(pot.get_temperature())
def on_step_changed(step):
if step is None:
return
params = sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
pot.set_thermal_params(params['M'], params['C'])
if hasattr(tc, 'set_model_params'):
tc.set_model_params(params['M'], params['C'])
ramp = step.get('ramp')
if sud.state == SudState.RAMPING and ramp is not None:
tc.set_theta_soll(step['temperature'])
tc.set_heatrate_soll(ramp['rate'])
sud.set_on_changed('step', on_step_changed)
t = [0.0]
theta = [pot.get_temperature()]
sud.start()
ticks = 0
while sud.state != SudState.DONE and ticks < MAX_TICKS:
pot.process()
tc.set_theta_ist(pot.get_temperature())
tc.process()
pot.set_power(max(0, self.heater_max_power * tc.get_power()))
if sud.state == SudState.RAMPING:
if abs(tc.get_theta_ist() - tc.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE:
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)
t.append(t[-1] + self.dt)
theta.append(pot.get_temperature())
ticks += 1
return t, theta
+8 -1
View File
@@ -12,6 +12,7 @@ from components.pid import PidFactory
from components.plant import Pot from components.plant import Pot
from components.actor import HeaterFactory, StirrerFactory from components.actor import HeaterFactory, StirrerFactory
from components.sud import Sud from components.sud import Sud
from components.sud_forecast import SudForecastEstimator
from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask
from tracer import Tracer from tracer import Tracer
@@ -103,7 +104,12 @@ if __name__ == '__main__':
# Mash schedule - starts out empty; a client loads one of sude/*.json's # Mash schedule - starts out empty; a client loads one of sude/*.json's
# several schedules onto it later (see components/sud.py's Sud). # several schedules onto it later (see components/sud.py's Sud).
sud = Sud() sud = Sud()
taskmgr.add(SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"))) # Predicts how long a loaded schedule will actually take, by simulating
# it with the same kind of plant/controller (and params) as above -
# see components/sud_forecast.py.
forecast_estimator = SudForecastEstimator(
DT, theta_amb, DEFAULT_PLANT_PARAMS, config['Controller']['pid_type'], config['TempCtrl'], heater.get_power_max())
taskmgr.add(SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator))
# Tracer # Tracer
taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer"))) taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer")))
@@ -141,6 +147,7 @@ if __name__ == '__main__':
pot.set_ambient_temperature(theta_amb) pot.set_ambient_temperature(theta_amb)
if hasattr(tc, 'set_ambient_temperature'): if hasattr(tc, 'set_ambient_temperature'):
tc.set_ambient_temperature(theta_amb) tc.set_ambient_temperature(theta_amb)
forecast_estimator.set_ambient_temperature(theta_amb)
await msg_system.send({'AmbientTemp': theta_amb}) await msg_system.send({'AmbientTemp': theta_amb})
msg_system.set_recv_handler(on_system_recv) msg_system.set_recv_handler(on_system_recv)
+23 -2
View File
@@ -11,7 +11,8 @@ TEMP_REACHED_TOLERANCE = 0.2
class SudTask(ATask): class SudTask(ATask):
def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo): def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo,
forecast_estimator=None):
ATask.__init__(self, interval) ATask.__init__(self, interval)
self.sud = sud self.sud = sud
self.tc = tc self.tc = tc
@@ -25,6 +26,12 @@ class SudTask(ATask):
# the same speedup instead of running in real time. # the same speedup instead of running in real time.
self.dt = dt self.dt = dt
self.msg_handler = msg_handler self.msg_handler = msg_handler
# Predicts a schedule's actual duration by simulating it with the
# same plant/controller machinery this server uses for real - see
# components/sud_forecast.py. Optional only so tests/demos that
# build a SudTask without one still work; the server always passes
# one.
self.forecast_estimator = forecast_estimator
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):
@@ -99,6 +106,17 @@ class SudTask(ATask):
def on_hold_remaining_changed(self, value): def on_hold_remaining_changed(self, value):
asyncio.create_task(self.send({'HoldRemaining': value})) asyncio.create_task(self.send({'HoldRemaining': value}))
async def send_forecast(self, doc):
if self.forecast_estimator is None:
return
# Runs the simulation in a worker thread - it's CPU-bound and can
# take a couple hundred ms for a long schedule, which would
# otherwise stall every other task (heater, sensor, ...) for that
# whole window.
loop = asyncio.get_event_loop()
t, theta = await loop.run_in_executor(None, self.forecast_estimator.estimate, doc)
await self.send({'Forecast': {'T': t, 'Theta': theta}})
async def recv(self, data): async def recv(self, data):
for pair in data.items(): for pair in data.items():
if 'Start' in pair[0]: if 'Start' in pair[0]:
@@ -110,11 +128,14 @@ class SudTask(ATask):
elif 'Stop' in pair[0]: elif 'Stop' in pair[0]:
self.sud.stop() self.sud.stop()
elif 'Save' in pair[0]: elif 'Save' in pair[0]:
await self.send({'Json': self.sud.save()}) doc = self.sud.save()
await self.send({'Json': doc})
await self.send_forecast(doc)
elif 'Load' in pair[0]: elif 'Load' in pair[0]:
if self.sud.load(pair[1]): if self.sud.load(pair[1]):
await self.send({'Name': self.sud.name, 'Description': self.sud.description}) await self.send({'Name': self.sud.name, 'Description': self.sud.description})
await self.send({'Json': pair[1]}) await self.send({'Json': pair[1]})
await self.send_forecast(pair[1])
async def send(self, data): async def send(self, data):
await self.msg_handler.send(data) await self.msg_handler.send(data)