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.