Files
brewpi/server/brewpi.py
T
jens 567ab80c9c 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.
2026-06-21 16:46:52 +02:00

159 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
import asyncio
import json
import os
import sys
import time
from utils import ChangedFloat
from ws.message import MessageDispatcher
from ws.server.ws_server_multi_user import WsServerMultiUser
from components.sensor import TempSensorFactory
from components.pid import PidFactory
from components.plant import Pot
from components.actor import HeaterFactory, StirrerFactory
from components.sud import Sud
from components.sud_forecast import SudForecastEstimator
from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask
from tracer import Tracer
import argparse as ap
# Default lumped thermal params, shared by both the real Pot plant and the
# temperature controller's internal Smith-predictor model.
DEFAULT_PLANT_PARAMS = {
"C": 4190,
"M": 25,
"L": 0.2,
"Td": 30
}
class Tee:
"""Duplicates writes to multiple streams - used to mirror stdout/stderr
(everything the rest of the codebase already reaches via print()) into
a log file under logs/ as well as the console, without having to touch
every print() call site."""
def __init__(self, *streams):
self.streams = streams
def write(self, data):
for stream in self.streams:
stream.write(data)
def flush(self):
for stream in self.streams:
stream.flush()
if __name__ == '__main__':
os.makedirs("logs", exist_ok=True)
log_path = "logs/brewpi.{}.log".format(time.strftime("%Y%m%d%H%M%S", time.localtime()))
log_file = open(log_path, "a", buffering=1)
sys.stdout = Tee(sys.stdout, log_file)
sys.stderr = Tee(sys.stderr, log_file)
print("Logging to {}".format(log_path))
parser = ap.ArgumentParser()
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("-c", "--config", default="config.json")
parser.add_argument("-m", "--model", default="model.json")
parser.add_argument("-s", "--sim", action="store_true")
args = parser.parse_args()
config = json.load(open(args.config))
dispatcher = MessageDispatcher()
server = WsServerMultiUser(listener=dispatcher)
taskmgr = TaskManager()
DT = config['Controller']['dt']
DT_TASK = 1.0 / config['Controller']['sim_warp_factor']
DT_TASK_TRACER = DT_TASK / DT
theta_amb = config['ambient_temperature']
plant_sim = "sim" in config['Controller']['plant_name']
# Sensor
sensor = TempSensorFactory.create(config['Controller']['sensor_name'], temp_offset=-0.15, variance=0.01)
sensor_task = TempSensorTask(sensor, DT_TASK, dispatcher.msgio_get("Sensor"))
taskmgr.add(sensor_task)
# Plant
pot = Pot(DT, DEFAULT_PLANT_PARAMS, theta_amb)
taskmgr.add(PotTask(pot, DT_TASK, dispatcher.msgio_get("Pot")))
# Heater
heater = HeaterFactory.create(config['Controller']['heater_name'], config['Heater'])
heater.set_on_changed("power_eff", ChangedFloat(pot.set_power, prec=0).set)
heater_task = HeaterTask(heater, DT_TASK, dispatcher.msgio_get("Heater"))
taskmgr.add(heater_task)
# Temperature Controller
tc = PidFactory.create(config['Controller']['pid_type'], DT, config['TempCtrl'], DEFAULT_PLANT_PARAMS, theta_amb=theta_amb)
tc_task = TcTask(tc, DT_TASK, dispatcher.msgio_get("TempCtrl"))
taskmgr.add(tc_task)
tc_trace_vars = []
trace_tc = Tracer(tc, tc_trace_vars, name='tc_trace')
# Stirrer
stirrer = StirrerFactory.create(config['Controller']['stirrer_name'], DT, config['Stirrer'])
stirrer_task = StirrerTask(stirrer, DT_TASK, dispatcher.msgio_get("Stirrer"))
taskmgr.add(stirrer_task)
# Mash schedule - starts out empty; a client loads one of sude/*.json's
# several schedules onto it later (see components/sud.py's Sud).
sud = 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
taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer")))
# Assign data flow
# Assign tc control value to heater
tc.set_on_changed("y", heater_task.actor)
# Assign temp. sensor readings to tc
sensor_task.set_on_changed("temp", ChangedFloat(tc.set_theta_ist, prec=2).set)
# Assign heater power set to tc model - "Normal" has no internal model
if hasattr(tc, "set_model_power"):
heater.set_on_changed("power_set", tc.set_model_power)
# For simulation
if "sim" in config['Controller']['sensor_name']:
pot.set_on_changed("temp", ChangedFloat(sensor.set_fake_temp, prec=3).set)
sensor.set_fake_temp(pot.temp)
# Static, global info that doesn't belong to any one task - sent once;
# a client connecting later still gets it via the dispatcher's replay
# of accumulated global_state on subscribe. AmbientTemp can also be
# changed live by a client (e.g. the GUI's ambient temperature field).
msg_system = dispatcher.msgio_get("System")
async def send_system_info():
await msg_system.send({'AmbientTemp': theta_amb, 'WarpFactor': DT / DT_TASK, 'PlantSim': plant_sim})
asyncio.ensure_future(send_system_info())
async def on_system_recv(data):
global theta_amb
if 'AmbientTemp' in data:
theta_amb = data['AmbientTemp']
pot.set_ambient_temperature(theta_amb)
if hasattr(tc, 'set_ambient_temperature'):
tc.set_ambient_temperature(theta_amb)
forecast_estimator.set_ambient_temperature(theta_amb)
await msg_system.send({'AmbientTemp': theta_amb})
msg_system.set_recv_handler(on_system_recv)
# Message dispatcher
h_dispatcher = taskmgr.start()
h_server = server.listen("0.0.0.0", 8765)
asyncio.gather(h_dispatcher, h_server)
server.run_forever()