Keep plant/temp control inert until a Sud is actually loaded

server/brewpi.py no longer pre-configures the real Pot's or Smith's model's
plant params at startup - there's no longer any generic baseline at all,
since the only source of truth is now a loaded Sud's own doc (applied via
tasks/sud.py's SudTask.apply_plant_params(), on Load and on every step).
Ambient temperature and PID gains stay configured at startup, since
they're independent of any Sud (global setting / hardware tuning, not
doc-derived).

Add Pot.is_configured() (already existed)/TempControllerBase.is_configured()/
TempController(Smith).is_configured(), and have tasks/pot.py's PotTask and
tasks/tempctrl.py's TcTask skip process() while not yet configured instead
of letting it raise - so plant and temp control are genuinely inert (not
crashing) until a Sud is loaded. "Normal" has no plant-model dependency,
so it stays active regardless.

Also refreshes README.md: removes stale tracer.py/.mat references (already
removed in an earlier commit), documents SudLogTask's JSON logs, the doc's
L/Td fields, and this inert-until-loaded behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
This commit is contained in:
2026-06-22 20:05:33 +02:00
co-authored by Claude Sonnet 4.6
parent 51add6fd56
commit 77e68afef1
6 changed files with 84 additions and 38 deletions
+46 -21
View File
@@ -70,16 +70,15 @@ components/ Pluggable building blocks behind factories:
tasks/ Async tasks (one per component) that poll tasks/ Async tasks (one per component) that poll
hardware/sim state at a fixed interval and hardware/sim state at a fixed interval and
publish changes through the message dispatcher. publish changes through the message dispatcher.
sud_log.py's SudLogTask additionally records
each Sud run's measured data and forecast to
logs/*.json - see "Logging & analysis" below.
ws/ Minimal WebSocket pub/sub layer: server ws/ Minimal WebSocket pub/sub layer: server
(single- and multi-user), client, and a (single- and multi-user), client, and a
keyed message dispatcher (e.g. "Sensor", keyed message dispatcher (e.g. "Sensor",
"Heater", "TempCtrl", "Stirrer", "Pot", "Sud"). "Heater", "TempCtrl", "Stirrer", "Pot", "Sud").
tracer.py Logs traced variables to .mat files (for
offline analysis/tuning in MATLAB/Octave,
see results.m / results_tc.m).
scripts/demos/ Standalone, eyeballed-plot demos (matplotlib) scripts/demos/ Standalone, eyeballed-plot demos (matplotlib)
exercising components/* in isolation — pid/, exercising components/* in isolation — pid/,
plant/, and a full mash-schedule run in sud/. plant/, and a full mash-schedule run in sud/.
@@ -194,8 +193,9 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
## Mash schedules ## Mash schedules
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`, Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
`pot_material` (fixed for the whole brew), and a `steps` list. Every step `pot_material`, `L` (pot energy loss coefficient), `Td` (transport
ramps toward its `temperature`, then optionally holds: propagation delay) - all fixed for the whole brew - and a `steps` list.
Every step ramps toward its `temperature`, then optionally holds:
- ramping toward `temperature` at `"ramp": {"rate": ...}` (°C/min) is not - ramping toward `temperature` at `"ramp": {"rate": ...}` (°C/min) is not
opt-in - it happens for every step, for as long as the temperature opt-in - it happens for every step, for as long as the temperature
@@ -225,13 +225,27 @@ add malt or check gravity, instead of advancing immediately.
Each step also has its own `grain_mass`/`water_mass` (defaulted like Each step also has its own `grain_mass`/`water_mass` (defaulted like
everything else from `default.step`), since both change over the course of everything else from `default.step`), since both change over the course of
a brew — e.g. malt going in partway through, or water boiling off. Pass a a brew — e.g. malt going in partway through, or water boiling off.
step's `grain_mass`/`water_mass` to `Sud.derive_plant_params()` (together `Sud.derive_plant_params()` folds a step's `grain_mass`/`water_mass`
with the brew-wide `pot_mass`/`pot_material`) to get that step's lumped together with the brew-wide `pot_mass`/`pot_material`/`L`/`Td` into a full
`Pot` `M`/`C`; `scripts/demos/sud/demo_sud.py` recomputes and re-applies `Pot` plant-params dict (`M`/`C`/`L`/`Td`) - `M`/`C` vary with the step,
these (via `Pot.set_thermal_params()`/`TempController.set_model_params()`, `L`/`Td` come straight through unchanged. `tasks/sud.py`'s `SudTask.
on both the real plant and the controller's Smith-predictor model) every apply_plant_params()` pushes the result onto both the real plant
time the current step changes, instead of deriving them once at startup. (`Pot.set_plant_params()`) and the controller's Smith-predictor model
(`TempController.set_model_plant_params()`, where it applies) - once
immediately when a Sud is loaded (using its first step), and again on
every real step transition during a run, rather than deriving it once at
startup (`scripts/demos/sud/demo_sud.py` mirrors the same pattern
standalone).
Neither the real plant nor a model-based controller (Smith) ever gets
generic/placeholder params - they're only ever set from an actually
loaded Sud's own doc. Until one is loaded, both stay inert
(`Pot.is_configured()`/`TempControllerBase.is_configured()` are `False`,
so `tasks/pot.py`'s `PotTask` and `tasks/tempctrl.py`'s `TcTask` simply
skip `process()` rather than running on bogus values) - the temperature
controller's "Normal" variant has no plant-model dependency at all,
though, so it stays active regardless of whether a Sud is loaded.
The top-level `default.step` object gives every field above (including the The top-level `default.step` object gives every field above (including the
nested `ramp`/`hold`/`stirrer` blocks) a default value; a step in `steps` nested `ramp`/`hold`/`stirrer` blocks) a default value; a step in `steps`
@@ -286,9 +300,12 @@ control behavior, not just a progress display.
The forecast is computed server-side (`components/sud_forecast.py`'s The forecast is computed server-side (`components/sud_forecast.py`'s
`SudForecastEstimator`) by actually running the schedule through a `SudForecastEstimator`) by actually running the schedule through a
throwaway `Sud`/`Pot`/temperature-controller trio built from the same throwaway `Sud`/`Pot`/temperature-controller trio built from the same
params (`pid_type`, `TempCtrl` gains, plant params, ambient, heater max params (`pid_type`, `TempCtrl` gains, ambient, heater max power) the real
power) the real server uses - a multi-hour brew simulates in well under a server uses - plant params (`M`/`C`/`L`/`Td`) aren't a constructor
second since it's pure CPU-bound iteration, no real time/IO involved. This argument at all, since they come from the doc being forecast itself
(`Sud.derive_plant_params()`, same as the real run), not from any generic
default. A multi-hour brew simulates in well under a second since it's
pure CPU-bound iteration, no real time/IO involved. This
is deliberately *not* recomputed as a run progresses: a forecast that keeps is deliberately *not* recomputed as a run progresses: a forecast that keeps
re-anchoring itself to match whatever's actually happening would no longer re-anchoring itself to match whatever's actually happening would no longer
be a meaningful baseline to compare reality against. It's computed once, be a meaningful baseline to compare reality against. It's computed once,
@@ -337,12 +354,20 @@ like a control problem.
## Logging & analysis ## Logging & analysis
`tracer.py` (and `tasks/tracer.py`) periodically dump traced signals `tasks/sud_log.py`'s `SudLogTask` records every Sud run's measured data -
(temperatures, heat rates, heater power) to `logs/*.mat` files, which can be the same six signals the GUI's Automatic tab plots live (`temp_ist`/
loaded in MATLAB/Octave — see `results.m` and `results_tc.m` — to evaluate `temp_soll`, `rate_ist`/`rate_soll`, `power_set`/`power_eff`), each sample
and tune the controller. with both simulated-elapsed-seconds and a real wall-clock timestamp - to
`logs/log_<date>T<time>_<sud-name>.json`, plus the forecast it was being
compared against (its *final*, most-corrected version - see "Forecast vs.
actual duration" above) to a paired `logs/forecast_<date>T<time>_
<sud-name>.json`, sharing the same date-time token. A fresh pair starts the
moment a run actually starts (`Sud.state` leaves `IDLE`/`DONE`) and is
written out whole the moment it ends (`DONE`, or aborted via `Stop`) -
never partially mid-run, since a half-written file is of no use before
the run is over anyway.
`server/brewpi.py` also mirrors everything it prints (every component's `server/brewpi.py` also mirrors everything it prints (every component's
`print()`-based status/debug output) to a plain text log, `print()`-based status/debug output) to a plain text log,
`logs/brewpi.<timestamp>.log`, alongside the `.mat` traces - useful for `logs/brewpi.<timestamp>.log`, alongside those JSON logs - useful for
post-mortems without needing to have been watching the console live. post-mortems without needing to have been watching the console live.
+8
View File
@@ -75,6 +75,14 @@ class TempControllerBase(APid):
raise RuntimeError( raise RuntimeError(
"{}.process(): PID gains not set - call set_params() first".format(type(self).__name__)) "{}.process(): PID gains not set - call set_params() first".format(type(self).__name__))
def is_configured(self):
"""Whether set_params() has been called - lets a caller (e.g.
TcTask's own process loop) check upfront and skip processing
gracefully while not yet configured, rather than relying on
process() raising. Overridden by TempController(Smith) to also
require its internal model's plant params/ambient."""
return self.params is not None
def on_state_entered(self, state): def on_state_entered(self, state):
pass pass
+3
View File
@@ -32,6 +32,9 @@ class TempController(TempControllerBase):
self.model.set_ambient_temperature(theta_amb) self.model.set_ambient_temperature(theta_amb)
self.model_delay.set_ambient_temperature(theta_amb) self.model_delay.set_ambient_temperature(theta_amb)
def is_configured(self):
return super().is_configured() and self.model.is_configured() and self.model_delay.is_configured()
def on_state_entered(self, state): def on_state_entered(self, state):
if state in (States.HEAT, States.COOL): if state in (States.HEAT, States.COOL):
self.model.initial(self.theta_ist) self.model.initial(self.theta_ist)
+15 -15
View File
@@ -62,24 +62,23 @@ if __name__ == '__main__':
plant_sim = "sim" in config['Controller']['plant_name'] plant_sim = "sim" in config['Controller']['plant_name']
# 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). Also # several schedules onto it later (see components/sud.py's Sud).
# 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() sud = Sud()
baseline_plant_params = sud.derive_plant_params(0, 25)
# Sensor # Sensor
sensor = TempSensorFactory.create(config['Controller']['sensor_name'], temp_offset=-0.15, variance=0.01) sensor = TempSensorFactory.create(config['Controller']['sensor_name'], temp_offset=-0.15, variance=0.01)
sensor_task = TempSensorTask(sensor, DT_TASK, dispatcher.msgio_get("Sensor")) sensor_task = TempSensorTask(sensor, DT_TASK, dispatcher.msgio_get("Sensor"))
taskmgr.add(sensor_task) taskmgr.add(sensor_task)
# Plant # Plant - deliberately left without plant params (M/C/L/Td) here: a
# Sud's own doc is the only source for those now (see
# tasks/sud.py's SudTask.apply_plant_params(), called immediately on
# Load), so the plant stays inert (Pot.is_configured() is False,
# PotTask skips process() - see there) until one actually is. Ambient
# is independent of any Sud (a global setting, changeable live via
# the System channel - see on_system_recv() below), so it's set
# right away regardless.
pot = Pot(DT) pot = Pot(DT)
pot.set_plant_params(baseline_plant_params)
pot.set_ambient_temperature(theta_amb) pot.set_ambient_temperature(theta_amb)
taskmgr.add(PotTask(pot, DT_TASK, dispatcher.msgio_get("Pot"))) taskmgr.add(PotTask(pot, DT_TASK, dispatcher.msgio_get("Pot")))
@@ -89,13 +88,14 @@ if __name__ == '__main__':
heater_task = HeaterTask(heater, DT_TASK, dispatcher.msgio_get("Heater")) heater_task = HeaterTask(heater, DT_TASK, dispatcher.msgio_get("Heater"))
taskmgr.add(heater_task) taskmgr.add(heater_task)
# Temperature Controller # Temperature Controller - PID gains are server/hardware-level
# config, independent of any Sud, so they're set right away; "Normal"
# has no internal model/ambient at all (see temp_controller.py vs.
# temp_controller_smith.py.), and Smith's model plant params are -
# like the real Pot's above - deliberately left unset here, only
# ever coming from a Sud's own doc.
tc = PidFactory.create(config['Controller']['pid_type'], DT) tc = PidFactory.create(config['Controller']['pid_type'], DT)
tc.set_params(config['TempCtrl']) 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'): if hasattr(tc, 'set_ambient_temperature'):
tc.set_ambient_temperature(theta_amb) tc.set_ambient_temperature(theta_amb)
tc_task = TcTask(tc, DT_TASK, dispatcher.msgio_get("TempCtrl")) tc_task = TcTask(tc, DT_TASK, dispatcher.msgio_get("TempCtrl"))
+5 -1
View File
@@ -33,7 +33,11 @@ class PotTask(ATask):
self.plant.set_on_changed('temp', ChangedFloat(self.on_changed_temp, prec=1).set) self.plant.set_on_changed('temp', ChangedFloat(self.on_changed_temp, prec=1).set)
while True: while True:
self.plant.process() # Stays inert until a Sud actually supplies plant params (see
# tasks/sud.py's SudTask.apply_plant_params()) - process()
# would otherwise raise (components/plant/pot.py's Pot).
if self.plant.is_configured():
self.plant.process()
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)
+7 -1
View File
@@ -71,6 +71,12 @@ class TcTask(ATask):
await self.send({'Enabled': self.tc.enabled}) await self.send({'Enabled': self.tc.enabled})
while True: while True:
self.tc.process() # Stays inert until set_params() (and, for a model-based
# controller like Smith, its model's plant params/ambient too
# - see components/pid/temp_controller_smith.py's
# is_configured()) has actually been supplied - process()
# would otherwise raise.
if self.tc.is_configured():
self.tc.process()
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)