on_step_changed() only re-applies plant params (pot mass/material, L, Td, grain/water mass) once Sud.step actually changes to a real step - which doesn't happen on Load (Sud.load() resets step to None), so the controller kept using whatever the previously loaded Sud (or the generic startup baseline) had until Start. SudTask.recv()'s Load handler now calls apply_plant_params() against the new schedule's first step right away, so the controller already matches the expected plant the moment a Sud is loaded. Also replaces server/brewpi.py's hardcoded DEFAULT_PLANT_PARAMS with a baseline derived from Sud's own (still-unloaded) generic defaults (EMPTY_SUD's pot_mass/L/Td), used only for the brief startup window before any real Sud is ever loaded - numerically identical to the old constant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
163 lines
6.1 KiB
Python
Executable File
163 lines
6.1 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, SudTask, SudLogTask
|
|
|
|
import argparse as ap
|
|
|
|
|
|
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']
|
|
theta_amb = config['ambient_temperature']
|
|
plant_sim = "sim" in config['Controller']['plant_name']
|
|
|
|
# Mash schedule - starts out empty; a client loads one of sude/*.json's
|
|
# several schedules onto it later (see components/sud.py's Sud). Also
|
|
# doubles as the source of baseline plant params for the Plant/
|
|
# Temperature Controller below, before any real schedule is loaded -
|
|
# derive_plant_params() falls back to Sud's own (generic) defaults
|
|
# while unloaded (see components/sud.py's EMPTY_SUD); water_mass=25
|
|
# is just a placeholder thermal mass standing in for "some water in
|
|
# the pot," not a literal recipe.
|
|
sud = Sud()
|
|
baseline_plant_params = sud.derive_plant_params(0, 25)
|
|
|
|
# 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)
|
|
pot.set_plant_params(baseline_plant_params)
|
|
pot.set_ambient_temperature(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)
|
|
tc.set_params(config['TempCtrl'])
|
|
# "Normal" has no internal model/ambient - see temp_controller.py vs.
|
|
# temp_controller_smith.py.
|
|
if hasattr(tc, 'set_model_plant_params'):
|
|
tc.set_model_plant_params(baseline_plant_params)
|
|
if hasattr(tc, 'set_ambient_temperature'):
|
|
tc.set_ambient_temperature(theta_amb)
|
|
tc_task = TcTask(tc, DT_TASK, dispatcher.msgio_get("TempCtrl"))
|
|
taskmgr.add(tc_task)
|
|
|
|
# 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)
|
|
|
|
# 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, config['Controller']['pid_type'], config['TempCtrl'], heater.get_power_max())
|
|
sud_task = SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"), forecast_estimator)
|
|
taskmgr.add(sud_task)
|
|
# Records every Sud run's measured data and forecast to their own
|
|
# JSON files under logs/, for later offline analysis - see
|
|
# tasks/sud_log.py.
|
|
taskmgr.add(SudLogTask(sud, tc, heater, heater_task, sud_task, DT_TASK))
|
|
|
|
# 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()
|