From ead900b7b18292378684786cbcc0eed0972d36ca Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 23 Jun 2026 20:22:51 +0200 Subject: [PATCH] Build the heater/pot/sensor rig as one unit, picked by plant_name alone heater_name/sensor_name/plant_name were three independent config keys, each picked via its own factory - in practice sim and real hardware are never actually mixed and matched, so this could (nonsensically) disagree, e.g. a real heater paired with a simulated sensor. Add PlantFactory (components/plant/plant_factory.py): plant_name alone now builds the whole rig together. "sim" gets HeaterSim/Pot (the modeled plant, as before)/TempSensorSim. Anything else gets HeaterHendi/PotReal/TempSensor_max31865 - PotReal is a new, deliberately unmodeled Pot for the real, physical kettle (components/plant/pot_real.py): the real temperature comes straight from the real sensor, not from a model, so it just accepts and ignores set_plant_params()/ set_ambient_temperature()/initial() and reports no temperature of its own, satisfying PotTask/SudTask's interface with nothing to actually simulate. heater_name/sensor_name are gone from config.json.templ; server/brewpi.py delegates to HeaterFactory/TempSensorFactory lazily from inside PlantFactory, same as before, so real hardware's spidev/pyserial deps still aren't needed just to import the module. --- README.md | 28 +++++++++++-------- components/plant/__init__.py | 2 ++ components/plant/plant_factory.py | 30 ++++++++++++++++++++ components/plant/pot_real.py | 46 +++++++++++++++++++++++++++++++ config.json.templ | 2 -- server/brewpi.py | 37 ++++++++++++++----------- 6 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 components/plant/plant_factory.py create mode 100644 components/plant/pot_real.py diff --git a/README.md b/README.md index 4c3402d..10dde9b 100644 --- a/README.md +++ b/README.md @@ -92,22 +92,26 @@ sude/ Mash schedules ("Sud" = brew/wort), each a for user confirmation. config.json.templ Configuration template - the "sim" component - names (sensor/heater/stirrer/plant) already - default to simulation; point Heater/Stirrer - at real serial ports to switch to hardware. + names (stirrer/plant) already default to + simulation; point Stirrer/Heater at real + serial ports and plant_name at a real + backend to switch to hardware. ``` ### Data flow The server (`server/brewpi.py`) loads `config.json`, builds the configured -sensor/heater/stirrer/controller via factories (`*Factory.create(name, ...)`) -based on the `Controller` section (`sensor_name`, `heater_name`, -`stirrer_name`, `pid_type`, or `"sim"` for any of them), and connects their -outputs to each other's inputs via `set_on_changed` callbacks, e.g.: +stirrer/controller via factories (`*Factory.create(name, ...)`), and the +heater/pot/sensor trio together as one consistent rig via `PlantFactory` +(`components/plant/plant_factory.py`) - based on the `Controller` section +(`stirrer_name`, `pid_type`, `plant_name`, or `"sim"` for any of them) - +and connects their outputs to each other's inputs via `set_on_changed` +callbacks, e.g.: - sensor temperature → temperature controller's `theta_ist` - temperature controller output `y` → heater power -- heater effective power → pot model power (simulation only) +- heater effective power → pot model power (simulation only - PotReal, the + real, unmodeled kettle, has no model to feed) Each component runs inside its own `ATask` at a configurable interval (`Controller.dt`, scaled by `sim_warp_factor`) and pushes state changes onto a @@ -172,9 +176,11 @@ pip install -r client/requirements.txt 1. Copy `config.json.templ` to `config.json` next to `brewpi.py` - it already runs entirely in simulation (no hardware needed) as-is; switch - any of the `sensor_name`/`heater_name`/`stirrer_name`/`plant_name` - `"sim"` values to a real backend, and set the corresponding serial - port, to use real hardware. + `stirrer_name`/`plant_name`'s `"sim"` value(s) to a real backend, and + set the corresponding serial port, to use real hardware. `plant_name` + alone picks the heater/pot/sensor trio together (see `PlantFactory`) - + `"sim"` gets `HeaterSim`/`Pot`/`TempSensorSim`, anything else gets + `HeaterHendi`/`PotReal`/`TempSensor_max31865`. 2. Start the server: ```bash diff --git a/components/plant/__init__.py b/components/plant/__init__.py index 62def6d..b2a9389 100644 --- a/components/plant/__init__.py +++ b/components/plant/__init__.py @@ -1 +1,3 @@ from components.plant.pot import * +from components.plant.pot_real import * +from components.plant.plant_factory import * diff --git a/components/plant/plant_factory.py b/components/plant/plant_factory.py new file mode 100644 index 0000000..08d382d --- /dev/null +++ b/components/plant/plant_factory.py @@ -0,0 +1,30 @@ +from components.actor import HeaterFactory +from components.sensor import TempSensorFactory +from components.plant.pot import Pot +from components.plant.pot_real import PotReal + + +class PlantFactory: + """Builds the heater/pot/sensor trio together, as one consistent rig, + rather than each picked independently via its own *_name config key - + sim and real hardware are never actually mixed and matched in + practice, so Controller.plant_name alone now decides all three (see + server/brewpi.py). + + Delegates the actual construction to HeaterFactory/TempSensorFactory + rather than importing HeaterHendi/TempSensor_max31865 directly, so + those classes' hardware-only deps (spidev, pyserial) stay lazily + imported - only actually needed when plant_name picks the real rig.""" + + @staticmethod + def create(plant_name, dt, heater_config): + if "sim" in plant_name: + heater = HeaterFactory.create("sim", heater_config) + pot = Pot(dt) + sensor = TempSensorFactory.create("sim", temp_offset=-0.15, variance=0.01) + return heater, pot, sensor + + heater = HeaterFactory.create("Hendi", heater_config) + pot = PotReal() + sensor = TempSensorFactory.create("max31865", temp_offset=-0.15) + return heater, pot, sensor diff --git a/components/plant/pot_real.py b/components/plant/pot_real.py new file mode 100644 index 0000000..a228370 --- /dev/null +++ b/components/plant/pot_real.py @@ -0,0 +1,46 @@ +from components.aplant import APlant + + +class PotReal(APlant): + """The real kettle - unlike Pot (components/plant/pot.py), this doesn't + model the plant's thermal behavior at all: the real kettle does its + own physics, and the real temperature comes straight from the real + temperature sensor, not from here (see PlantFactory). Exists purely so + a real rig still satisfies the same informal interface PotTask/ + SudTask expect from a Pot - set_plant_params()/set_ambient_temperature()/ + initial() are accepted and ignored, and get_temperature() has nothing + to report.""" + + def __init__(self): + APlant.__init__(self) + self.p_in = 0 + + def is_configured(self): + return True + + def set_plant_params(self, params): + pass + + def set_ambient_temperature(self, theta_amb): + pass + + def initial(self, temp): + pass + + def activate(self, enable): + pass + + def is_activated(self): + return True + + def process(self): + pass + + def set_power(self, power): + self.p_in = power + + def get_power(self): + return round(self.p_in, 1) + + def get_temperature(self): + return None diff --git a/config.json.templ b/config.json.templ index 2ff95df..7f5e5ce 100644 --- a/config.json.templ +++ b/config.json.templ @@ -4,8 +4,6 @@ "dt": 1, "sim_warp_factor": 50.0, "stirrer_name": "sim", - "heater_name": "sim", - "sensor_name": "sim", "plant_name": "sim", "pid_type": "Smith" }, diff --git a/server/brewpi.py b/server/brewpi.py index 34f24d0..949581f 100755 --- a/server/brewpi.py +++ b/server/brewpi.py @@ -7,10 +7,9 @@ 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.plant import PlantFactory +from components.actor import StirrerFactory from components.sud import Sud from components.sud_forecast import SudForecastEstimator from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, SudTask, SudLogTask @@ -64,24 +63,29 @@ if __name__ == '__main__': # several schedules onto it later (see components/sud.py's Sud). sud = Sud() - # Sensor - sensor = TempSensorFactory.create(config['Controller']['sensor_name'], temp_offset=-0.15, variance=0.01) + # Heater/Pot/Sensor - built together as one consistent rig by a single + # Controller.plant_name, rather than three independently-named + # components that in practice are never actually mixed and matched + # (see components/plant/plant_factory.py): "sim" gets HeaterSim/Pot + # (the modeled plant)/TempSensorSim; anything else gets HeaterHendi/ + # PotReal (no model - the real kettle does its own physics)/ + # TempSensor_max31865. + heater, pot, sensor = PlantFactory.create(config['Controller']['plant_name'], DT, config['Heater']) + sensor_task = TempSensorTask(sensor, DT_TASK, dispatcher.msgio_get("Sensor")) taskmgr.add(sensor_task) - # Plant - seeded with the not-yet-loaded sud's own (now physically - # sane, see Sud.EMPTY_SUD) plant params right away, same as ambient - # below, so the simulated Pot already runs - manual heater - # power/Sud disabled or not - instead of sitting inert until a real - # Sud loads. A real Sud's own doc still overrides these the moment - # one is loaded (see tasks/sud.py's SudTask.apply_plant_params()). - pot = Pot(DT) + # Pot - seeded with the not-yet-loaded sud's own (now physically sane, + # see Sud.EMPTY_SUD) plant params right away, same as ambient below, so + # a simulated Pot already runs - manual heater power/Sud disabled or + # not - instead of sitting inert until a real Sud loads (PotReal just + # ignores this - see there). A real Sud's own doc still overrides + # these the moment one is loaded (see tasks/sud.py's SudTask. + # apply_plant_params()). pot.set_ambient_temperature(theta_amb) pot.set_plant_params(sud.derive_plant_params(sud.grain_mass, sud.water_mass)) 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) @@ -128,8 +132,9 @@ if __name__ == '__main__': 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']: + # For simulation - the sim sensor has no real plant to read, so it + # just reports back whatever the modeled Pot's own temperature is. + if plant_sim: pot.set_on_changed("temp", ChangedFloat(sensor.set_fake_temp, prec=3).set) sensor.set_fake_temp(pot.temp)