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.
This commit is contained in:
2026-06-23 20:22:51 +02:00
parent 67cf5fd00b
commit ead900b7b1
6 changed files with 116 additions and 29 deletions
+17 -11
View File
@@ -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
+2
View File
@@ -1 +1,3 @@
from components.plant.pot import *
from components.plant.pot_real import *
from components.plant.plant_factory import *
+30
View File
@@ -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
+46
View File
@@ -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
-2
View File
@@ -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"
},
+21 -16
View File
@@ -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)