A step can now carry both 'ramp' and 'hold': it ramps to the target temp, then holds there for the given duration, instead of needing two separate schedule entries. Sud.temp_reached() switches such a step from its ramp phase into its hold phase in place (re-firing the step-changed callback so SudTask/demo_sud re-apply the hold's stirrer settings); user_wait_for_continue still applies once the whole step - both phases - is done. Merges sud_0010.json's three ramp/hold pairs into combined steps.
191 lines
9.1 KiB
Markdown
191 lines
9.1 KiB
Markdown
# 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`](docs/NonLinMPC.pdf)) and has grown real-hardware
|
|
backends for an induction hob and an RTD temperature probe.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
brewpi/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 optional mash-schedule sequencer that steps
|
|
through a sude/*.json schedule and 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 (`brewpi/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 (`brewpi/requirements.txt`): `numpy`, `scipy`, `websockets`, `dpath`,
|
|
and `pyserial`/`spidev` if using real hardware backends.
|
|
- Client (`client/requirements.txt`): `PyQt5`.
|
|
|
|
Install with:
|
|
|
|
```bash
|
|
pip install -r brewpi/requirements.txt
|
|
pip install -r client/requirements.txt
|
|
```
|
|
|
|
## Running
|
|
|
|
1. Copy a config template to `config.json` next to `brewpi.py` and adjust it.
|
|
Use `config.json.sim` to run entirely in simulation (no hardware needed),
|
|
or `config.json.templ` as a starting point for real hardware (set the
|
|
heater/stirrer serial ports and sensor type).
|
|
2. Start the server:
|
|
|
|
```bash
|
|
cd brewpi
|
|
./brewpi.py
|
|
```
|
|
|
|
This serves the WebSocket on `ws://0.0.0.0:8765`.
|
|
3. Start the GUI client and connect to the server's URI:
|
|
|
|
```bash
|
|
cd client
|
|
./brewpi_gui.py
|
|
```
|
|
|
|
`brewpi/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:
|
|
|
|
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
|
|
(°C/min), and/or
|
|
- a hold — `"hold": {"duration": ...}` — hold the current target for
|
|
`duration` minutes (omit/`0` for an immediate step).
|
|
|
|
A step with both ramps to `temp` 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.
|
|
|
|
Set the top-level `sud` config key to a path under `sude/` (see
|
|
`config.json.sim`/`config.json.templ`) to have the server step through it
|
|
automatically: `components/sud.py`'s `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. Omit `sud` from the config to run without schedule
|
|
automation (manual `theta_soll`/`heatrate_soll` control via the GUI, as
|
|
before).
|
|
|
|
## 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.
|