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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user