jens d91bba1ba2 Use the server's simulated forecast for the dynamic remainder too
SudForecastPlot.show_dynamic()'s dashed "remaining" line was still
using the naive abs(delta)/rate estimate even after a run started,
even though the (much more accurate) server-side
SudForecastEstimator was already available - the static forecast got
the upgrade, the live one didn't.

tasks/sud.py: SudTask now recomputes a forecast for just the remaining
steps (seeded from the live current temperature) on every step change
and sends it as {'RemainingForecast': {...}}. Building the synthetic
"rest of this step" entry for a step currently HOLDING needs to keep
the current step's grain_mass/water_mass/temperature/etc - a bare
{'hold': {...}} re-resolves those to None against the synthetic doc's
empty default.step, breaking derive_plant_params() (caught live: a
TypeError on every step change, silently swallowed by asyncio).

client/brewpi_gui.py: on_plot_timer() now uses the server's
RemainingForecast for the dashed projection whenever available,
falling back to the naive estimate only for the brief window before
each step's recomputation arrives. show_dynamic() takes the already-
computed (t_rem, theta_rem) instead of computing it internally, so the
GUI/server sources are interchangeable from its point of view.
2026-06-21 17:24:26 +02:00
2021-10-13 18:35:58 +02:00
2020-11-24 20:42:27 +01:00
2020-12-17 16:28:50 +01:00
2021-08-10 15:05:57 +01:00
2021-10-18 19:15:11 +02:00
2021-10-18 18:08:41 +02:00
2021-10-19 06:32:45 +01:00

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,
                             ambient temperature, and switch the heater/
                             stirrer/temperature controller on or off (plus
                             a "reset Pot to ambient" button, shown only
                             when the server's plant is simulated) - plus
                             Sud control (New/Load/Save/Start/Pause/Stop)
                             and its forecast plot on the Automatic tab.
client/user_config.py       Small JSON-backed key/value store
                             (~/.config/brewpi/gui.json) for GUI preferences
                             that should survive restarts (last Sud file
                             dialog directory, last ambient temperature) -
                             distinct from the server's config.json and
                             from sude/*.json schedules.

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).
                                Both are a 4-state FSM (IDLE/HEAT/HOLD/COOL)
                                with a master enabled switch - IDLE means
                                disabled (output forced to 0); COOL handles
                                a target below the current temperature with
                                its own gains, output forced to 0 only by
                                the actuator that can't act on it (e.g. a
                                heat-only heater), not by the controller
                                assuming it can't cool
  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
  sud_forecast.py              predicts how long a loaded schedule will
                                actually take by simulating it with the
                                same kind of plant/controller (and params)
                                as the real server - see "Forecast vs.
                                actual duration" below

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 "ramp"/"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, matplotlib (only for scripts/demos/*, not the running server itself), websockets, dpath, and pyserial/spidev if using real hardware backends.
  • Client (client/requirements.txt): PyQt5.

Install with:

pip install -r server/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:

    cd server
    ./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:

    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 to temperature 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 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 (also restarts one that's already finished), {"Sud": {"Pause": true}} to freeze progress without losing it, and {"Sud": {"Confirm": true}} to acknowledge a pause and move to the next step.

The temperature controller has a master enabled switch (off by default - see components/pid/temp_controller_base.py): SudTask enables it for as long as a run is in progress (any state other than idle/finished) and disables it again - forcing the heater output to 0 - the moment it stops or finishes, handing control back to manual mode. The GUI's Manual tab has an "Enabled" checkbox for driving this directly outside of a Sud run; while disabled, its temperature/heat-rate setpoint controls are inactive.

Forecast vs. actual duration

The GUI's Automatic tab shows up to three time estimates for a schedule:

  • Immediately on load, a quick naive preview: walk the schedule assuming every ramp instantly achieves and holds its declared rate (abs(delta)/rate) and every hold lasts exactly its declared duration. Deliberately approximate - it's just a placeholder until the next one arrives a moment later.
  • A simulated estimate, 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 replaces the naive preview a moment after a schedule is loaded.
  • Once running, 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_remaining each tick.

The naive estimate is structurally optimistic - it assumes the plant instantly tracks the declared rate and that "reached" is instant once the math says so, when the real PID cascade (theta_err -> pid_hold -> heatrate_soll -> pid_heat -> heater power -> actual heat rate) needs real time to spin up and settle within the tight 0.2°C "reached" tolerance (tasks/sud.py's TEMP_REACHED_TOLERANCE). The simulated estimate accounts for that (it's driven by the real controller dynamics), so it's normally within a few percent of how the dynamic one settles once a run actually finishes - if the two diverge by far more than that, suspect a real control issue rather than a forecasting one (see the HoldCool threshold note in components/pid/temp_controller_base.py - too tight a threshold there once caused exactly this kind of large, otherwise-unexplained gap, by making the controller chatter in and out of COOL and never actually converge).

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.

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 post-mortems without needing to have been watching the console live.

S
Description
No description provided
Readme
14 MiB
Languages
Python 84%
JavaScript 9.4%
HTML 3.1%
CSS 2.4%
M 0.6%
Other 0.5%