HoldCool=0.1 was inherited from the pre-COOL-state era, where it meant "eagerly give up active holding and coast" - harmless, since coasting had no side effects. COOL is a real, actively-controlled state now (its own pid_cool, reset on every entry), so a threshold this tight relative to a real heater's discrete power steps (or even just sensor noise) caused the system to flip in and out of COOL nearly every tick once resting near a setpoint - resetting both pid_hold's and pid_cool's integrators each time and never letting either actually converge. Confirmed via a full multi-task simulation (real Pot/Sensor/Heater/ TempCtrl tasks, not a simplified loop) of sude/sud_0010.json: 44+ TempCtrl state changes and the run not settling within hours at HoldCool=0.1, dropping to 8 state changes and a clean 186.5 min finish at HoldCool=1.0 (matching the other thresholds). This was the dominant cause of real brews taking far longer than their nominal schedule duration - not just a forecasting inaccuracy, an actual control- quality bug.
BrewPi
A Python-based controller for automating the mash/brewing process of beer: it holds a pot of liquid at target temperatures (or ramps it at a target heating rate) according to a configurable mash schedule, drives a heater and stirrer, and exposes live control/telemetry over a WebSocket so a desktop GUI (or any other client) can monitor and steer the brew.
The project began as a simulation/control-theory playground (Smith-predictor
temperature control, pot transport-delay model, see
docs/NonLinMPC.pdf) and has grown real-hardware
backends for an induction hob and an RTD temperature probe.
Architecture
server/brewpi.py Server entry point: wires sensor, pot/plant,
heater, temperature controller and stirrer
together, runs them as asyncio tasks, and
serves state/commands over a WebSocket.
client/brewpi_gui.py PyQt5 desktop client (brewpi.ui) that connects
to the server's WebSocket, displays live
temperature/power/state, and lets the user set
target temperature, heat rate, stirrer speed,
and switch the heater/stirrer on or off.
components/ Pluggable building blocks behind factories:
pid/ temperature controllers: plain PID
("Normal") or PID + Smith predictor
("Smith", runs two internal pot models —
one with the plant's transport delay, one
without — to compensate for the dead time)
plant/ pot thermal model (transport-delay line)
used in simulation
sensor/ temperature sensors: simulated, or a real
MAX31865 RTD amplifier over SPI
actor/ heater and stirrer drivers: simulated, a
Hendi induction hob (serial protocol), or a
Pololu 1376 stirrer motor controller
sud.py mash-schedule sequencer; starts out empty,
a client loads one of several sude/*.json
schedules onto it and it drives the
temperature controller from it
tasks/ Async tasks (one per component) that poll
hardware/sim state at a fixed interval and
publish changes through the message dispatcher.
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/.
Not used at runtime; run directly, e.g.
`python -m scripts.demos.sud.demo_sud`.
sude/ Mash schedules ("Sud" = brew/wort), each a
JSON list of "heat"/"hold" steps with target
temperature/heat rate or hold duration,
per-step stirrer timing, and optional pause
for user confirmation.
config.json.templ Configuration template (real hardware).
config.json.sim Configuration template for simulation mode.
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.:
- sensor temperature → temperature controller's
theta_ist - temperature controller output
y→ heater power - heater effective power → pot model power (simulation only)
Each component runs inside its own ATask at a configurable interval
(Controller.dt, scaled by sim_warp_factor) and pushes state changes onto a
keyed WebSocket channel that any connected client can subscribe to and send
commands back on (e.g. {"TempCtrl": {"Soll": {"Temp": 65}}}).
Requirements
- Python 3.8+
- Server (
server/requirements.txt):numpy,scipy,websockets,dpath, andpyserial/spidevif using real hardware backends. - Client (
client/requirements.txt):PyQt5.
Install with:
pip install -r server/requirements.txt
pip install -r client/requirements.txt
Running
-
Copy a config template to
config.jsonnext tobrewpi.pyand adjust it. Useconfig.json.simto run entirely in simulation (no hardware needed), orconfig.json.templas a starting point for real hardware (set the heater/stirrer serial ports and sensor type). -
Start the server:
cd server ./brewpi.pyThis serves the WebSocket on
ws://0.0.0.0:8765. -
Start the GUI client and connect to the server's URI:
cd client ./brewpi_gui.py
server/brewpi.sh shows how this is wired up to run under a virtualenvwrapper
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, each a ramp, a
hold, or both, plus a step-level temperature (the target a ramp step ramps
to, and what a hold step holds at):
- a ramp —
"ramp": {"rate": ...}— ramp totemperatureatrate(°C/min), and/or - a hold —
"hold": {"duration": ...}— hold the current target fordurationminutes (omit/0for an immediate step).
A step with both ramps to temperature and then holds there for duration
- useful for a mash rest ("ramp to 63°C, then hold 40 min") without needing
two separate schedule entries.
user_wait_for_continue/user_message(below) apply once the whole step is done, i.e. after the hold phase if there is one.
Both ramp and hold carry a stirrer block (speed, interval_time,
on_ratio): interval_time: 0 runs the stirrer continuously at speed;
interval_time > 0 pulses it on a period of interval_time seconds, on for
on_ratio * interval_time of it. A step may also set
user_wait_for_continue: true (with an optional user_message to prompt
the user with) to pause for confirmation once the step completes, e.g. to
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.
The top-level default.step object gives every field above (including the
nested ramp/hold/stirrer blocks) a default value; a step in steps
only needs to specify the fields it overrides — anything it omits is filled
in from default.step, recursively. A step is a ramp or a hold depending on
which of those two keys it specifies; the other is not defaulted in.
The server always runs a Sud (components/sud.py), starting out empty (no
schedule) - sude/ can hold several schedule files, and a client loads one
of them onto the running Sud ({"Sud": {"Load": <sud.json contents>}}),
kept purely in memory until replaced by another Load or the server
restarts; nothing is ever read from or written to sude/*.json by the
server itself, that's on the client (e.g. the GUI's File menu). Sud
resolves each raw step against default.step and tracks the current
(resolved) step, while tasks/sud.py's SudTask drives the temperature
controller's theta_soll/heatrate_soll from ramp steps (advancing once
theta_ist settles close to the target), counts down hold steps'
duration, and applies each step's stirrer block (interval_time/
on_ratio map directly onto the stirrer's cycle time/duty cycle). It
exposes progress (current step, remaining hold time, state, any
user_message) on the "Sud" WebSocket channel and accepts
{"Sud": {"Start": true}} to begin the schedule and
{"Sud": {"Confirm": true}} to acknowledge a pause and move to the next
step. With no schedule loaded (or all its steps run), it just sits idle -
manual theta_soll/heatrate_soll control via the GUI works the same as
ever.
Forecast vs. actual duration
The GUI's Automatic tab shows two different time estimates, and they can diverge substantially:
- Before a run starts, it shows a static estimate: walk the schedule
assuming every ramp instantly achieves and holds its declared
rate(abs(delta)/rate) and every hold lasts exactly its declaredduration. - Once running, it switches to a dynamic one: the already-elapsed part is
the actual measured trace, and only the remaining steps are re-projected
from the live temperature/step/
hold_remainingeach tick.
In practice the dynamic total tends to run well above the static one - a
schedule estimated at ~160 min commonly finishes closer to ~250-270 min.
The static model assumes the plant instantly tracks the declared rate and
that "reached" is instant once the math says so; the real PID cascade
(theta_err -> pid_hold -> heatrate_soll -> pid_heat -> heater power -> actual heat rate) needs real time to spin up (constrained by the plant's
thermal mass and the Smith predictor's transport-delay compensation) and
then more time to settle within the tight 0.2°C "reached" tolerance
(tasks/sud.py's TEMP_REACHED_TOLERANCE) once nominally at target. That
settling lag is roughly constant per step rather than proportional to the
step's nominal duration, so it eats a larger fraction of short ramps and
keeps accumulating across every ramp/hold transition in the schedule -
many small (~20-90%) per-step overruns compounding into one large gap by
the end, rather than one big error anywhere in particular. The dynamic
re-anchoring is what corrects for this as a brew actually progresses; the
upfront static number has no settling-time data to work with yet.
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.