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
hardware/sim state at a fixed interval and
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
(single- and multi-user), client, and a
keyed message dispatcher (e.g. "Sensor",
"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)
exercising components/* in isolation — pid/,
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
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
ramps toward its `temperature`, then optionally holds:
`pot_material`, `L` (pot energy loss coefficient), `Td` (transport
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
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
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
step's `grain_mass`/`water_mass` to `Sud.derive_plant_params()` (together
with the brew-wide `pot_mass`/`pot_material`) to get that step's lumped
`Pot` `M`/`C`; `scripts/demos/sud/demo_sud.py` recomputes and re-applies
these (via `Pot.set_thermal_params()`/`TempController.set_model_params()`,
on both the real plant and the controller's Smith-predictor model) every
time the current step changes, instead of deriving them once at startup.
a brew — e.g. malt going in partway through, or water boiling off.
`Sud.derive_plant_params()` folds a step's `grain_mass`/`water_mass`
together with the brew-wide `pot_mass`/`pot_material`/`L`/`Td` into a full
`Pot` plant-params dict (`M`/`C`/`L`/`Td`) - `M`/`C` vary with the step,
`L`/`Td` come straight through unchanged. `tasks/sud.py`'s `SudTask.
apply_plant_params()` pushes the result onto both the real plant
(`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
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
`SudForecastEstimator`) by actually running the schedule through a
throwaway `Sud`/`Pot`/temperature-controller trio built from the same
params (`pid_type`, `TempCtrl` gains, plant params, ambient, heater max
power) the real server uses - a multi-hour brew simulates in well under a
second since it's pure CPU-bound iteration, no real time/IO involved. This
params (`pid_type`, `TempCtrl` gains, ambient, heater max power) the real
server uses - plant params (`M`/`C`/`L`/`Td`) aren't a constructor
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
re-anchoring itself to match whatever's actually happening would no longer
be a meaningful baseline to compare reality against. It's computed once,
@@ -337,12 +354,20 @@ like a control problem.
## Logging & analysis
`tracer.py` (and `tasks/tracer.py`) periodically dump traced signals
(temperatures, heat rates, heater power) to `logs/*.mat` files, which can be
loaded in MATLAB/Octave — see `results.m` and `results_tc.m` — to evaluate
and tune the controller.
`tasks/sud_log.py`'s `SudLogTask` records every Sud run's measured data -
the same six signals the GUI's Automatic tab plots live (`temp_ist`/
`temp_soll`, `rate_ist`/`rate_soll`, `power_set`/`power_eff`), each sample
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
`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.
+8
View File
@@ -75,6 +75,14 @@ class TempControllerBase(APid):
raise RuntimeError(
"{}.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):
pass
+3
View File
@@ -32,6 +32,9 @@ class TempController(TempControllerBase):
self.model.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):
if state in (States.HEAT, States.COOL):
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']
# 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.
# several schedules onto it later (see components/sud.py's Sud).
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
# 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.set_plant_params(baseline_plant_params)
pot.set_ambient_temperature(theta_amb)
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"))
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.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"))
+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)
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)
+7 -1
View File
@@ -71,6 +71,12 @@ class TcTask(ATask):
await self.send({'Enabled': self.tc.enabled})
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)