The write-up still described the old print()-mirroring behavior; recent commits (a1864a5..9f262c4) routed task/component output through self.log (stdlib logging) instead, added Sud lifecycle/connect-state log lines, and gave SudForecastEstimator its own silenced logger. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
821 lines
47 KiB
Markdown
821 lines
47 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
|
||
|
||
```
|
||
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),
|
||
its forecast plot on the Automatic tab, and a
|
||
per-step Progress tab (see "Progress tab"
|
||
below).
|
||
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.
|
||
server_log.py's ServerLogTask additionally
|
||
records temp/power samples for the whole server
|
||
session, and sud_log.py's SudLogTask (a thin
|
||
subclass reusing the same sample format) does
|
||
the same but only while a Sud run is active -
|
||
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").
|
||
Abrupt client disconnects (no WebSocket close
|
||
frame — the normal case for browser tab closes
|
||
and Safari session teardowns) are handled
|
||
gracefully: `User.send()` catches
|
||
`ConnectionClosed` silently, and `handler_recv()`
|
||
exits cleanly on it, so a vanishing client never
|
||
produces an uncaught exception or "connection
|
||
handler failed" log noise.
|
||
|
||
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-sim.json.tpl Configuration template for simulation - all
|
||
component names default to "sim"; no hardware
|
||
needed.
|
||
config-real.json.tpl Configuration template for real hardware -
|
||
real plant/heater/stirrer backends with the
|
||
correct serial ports pre-filled.
|
||
|
||
web/ Browser client - static HTML/CSS/JS (no build
|
||
step, no dependencies), speaking the exact
|
||
same WebSocket protocol as client/brewpi_gui.py
|
||
(see "Browser client" below). Served by
|
||
server/brewpi.py itself over plain HTTP, so a
|
||
browser anywhere on the network can reach the
|
||
rig with nothing installed beyond the browser.
|
||
```
|
||
|
||
### Data flow
|
||
|
||
The server (`server/brewpi.py`) loads `config.json`, builds the configured
|
||
stirrer/controller via factories (`*Factory.create(name, ...)`), and the
|
||
heater/pot/sensor trio together as one consistent rig via `PlantFactory`
|
||
(`components/plant/plant_factory.py`) - based on the `Controller` section
|
||
(`stirrer_name`, `pid_type`, `plant_name`, 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 - PotReal, the
|
||
real, unmodeled kettle, has no model to feed)
|
||
|
||
Each component runs inside its own `ATask` at a configurable interval
|
||
(`--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}}}`).
|
||
|
||
### State is server-only; GUIs only reflect it
|
||
|
||
**The server is the single source of truth for all state. Neither GUI client
|
||
ever maintains its own model of what the server should be doing.**
|
||
|
||
When a user moves a slider or clicks a button, the client sends a command
|
||
and *waits* for the server's reply before updating any display — the reply
|
||
is the only authority. Consequences:
|
||
|
||
- **Multiple clients stay in sync automatically.** Every state change is
|
||
broadcast to all connected clients simultaneously. A second browser tab
|
||
and the desktop GUI connected to the same server both see the same update
|
||
at essentially the same time, with no cross-client coordination needed.
|
||
- **Controls always reflect the last value the server pushed**, not what the
|
||
user last touched. `ChangedFloat`/`ChangedInteger` wrappers on the server
|
||
side mean a broadcast only goes out when a value actually changes — the
|
||
client never needs to suppress its own echoes, since the server handles that.
|
||
- **No special "resume" handshake is needed on reconnect**, because the
|
||
client never diverged from the server in the first place — it only ever
|
||
holds what the server pushed.
|
||
|
||
The Closed-loop checkbox is a concrete example: clicking it sends
|
||
`{Heater: {ClosedLoop: false}}`, but the checkbox's own display is only
|
||
updated when the server echoes `{ClosedLoop: false}` back through
|
||
`onHeaterChanged()`. If the command is lost or refused, the UI stays at
|
||
whatever the server last reported — no local flag, no "optimistic update."
|
||
|
||
**State replay on connect** is how a freshly-connected client gets that
|
||
full server picture instantly. The GUI might be the first thing to ever
|
||
talk to a freshly started server, or it might connect to one that's been
|
||
running unattended for hours, mid-brew, with a schedule already loaded and
|
||
paused partway through step 4. A client can also disconnect and reconnect
|
||
later, at any server state. In every case the client must end up with the
|
||
*same* full picture as one that's been connected the whole time.
|
||
|
||
This is what `WsServerMultiUser.global_state`
|
||
(`ws/server/ws_server_multi_user.py`) exists for: every message ever
|
||
broadcast over the dispatcher is merged into it (`ws/user.py`'s `update()`),
|
||
keyed exactly like the live channels are. Subscribing to a channel
|
||
(`{"+": "Sud"}`, sent automatically for every channel on connect - see
|
||
`MessageDispatcherSync(auto_subscribe=True)`) doesn't just start a future
|
||
stream of changes; the server immediately replays the *entire*
|
||
currently-known state for that channel in one message, via `User.send()`'s
|
||
`dpath` search over `global_state`. A reconnecting client gets exactly the
|
||
same replay a brand-new one would — there's no "first connect"
|
||
special-casing anywhere in this path.
|
||
|
||
A single `state_pump()` task (started once, independent of any connection)
|
||
drains the dispatcher's queue and folds every message into `global_state`
|
||
regardless of how many clients are connected. This matters for a client
|
||
that silently drops without a close frame (e.g. an iPad going to sleep):
|
||
nothing queues up waiting for it, so on reconnect it gets the one
|
||
`global_state` replay above, not a burst of every message broadcast while
|
||
it was gone.
|
||
|
||
One corollary, easy to miss: this mechanism has no concept of "transient".
|
||
Anything sent through it is implicitly durable state that will be replayed
|
||
to whoever connects next, even long after the fact. A one-shot *event*
|
||
(something that happened, rather than something that's currently true) has
|
||
to be modeled as a value that gets explicitly cleared back to neutral right
|
||
after sending, or it will linger in `global_state` and get handed to some
|
||
unrelated future client as if it just happened to them. `tasks/sud.py`'s
|
||
`SudTask.recv()` hit this directly: a `Load` rejected because a run is in
|
||
progress sends `{"Sud": {"Error": "..."}}` to explain why, immediately
|
||
followed by `{"Sud": {"Error": None}}` to clear it - without that second
|
||
send, every client connecting afterwards, no matter how much later or how
|
||
unrelated to the rejected `Load`, would immediately see that same stale
|
||
error on connect. The client mirrors this by treating a falsy `Error` as a
|
||
no-op rather than an empty dialog.
|
||
|
||
## 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.
|
||
- Desktop client (`client/requirements.txt`): `PyQt5`.
|
||
- Browser client (`web/`): nothing - just a browser. Served by
|
||
`server/brewpi.py` itself.
|
||
|
||
Install with:
|
||
|
||
```bash
|
||
pip install -r server/requirements.txt
|
||
pip install -r client/requirements.txt
|
||
```
|
||
|
||
## Running
|
||
|
||
1. Copy `config-sim.json.tpl` (simulation, no hardware needed) or
|
||
`config-real.json.tpl` (real hardware) to `config.json` next to
|
||
`brewpi.py`. `plant_name` picks the heater/pot/sensor trio together
|
||
(see `PlantFactory`) - `"sim"` gets `HeaterSim`/`Pot`/`TempSensorSim`,
|
||
anything else gets `HeaterHendi`/`PotReal`/`TempSensor_max31865`.
|
||
2. Start the server:
|
||
|
||
```bash
|
||
cd server
|
||
./brewpi.py
|
||
```
|
||
|
||
This serves the WebSocket on `ws://0.0.0.0:8765` and the browser client
|
||
(see "Browser client" below) on `http://0.0.0.0:8080`, ticking in real
|
||
time (1 simulated second per tick, no speedup) by default. `--dt`
|
||
(simulated seconds/tick) and `--sim-warp-factor` (how many of those fit
|
||
into one real second) are run-mode knobs, not `config.json` material -
|
||
a plant/hardware description shouldn't change just because of how a
|
||
particular run happens to be invoked - so they're CLI-only:
|
||
|
||
```bash
|
||
./brewpi.py --sim-warp-factor 50 # 50x speedup, e.g. for dev/testing
|
||
./brewpi.py --http-port 8888 # change the browser client's port
|
||
```
|
||
|
||
`--sim-warp-factor` only ever reciprocally scales the *wait* time
|
||
between task ticks (`DT_TASK`) - it never scales `--dt` itself, and has
|
||
no effect at all against real hardware (`Heater.type` other than
|
||
`"sim"`): real hardware ticks at its own physical pace regardless of
|
||
this flag, so a real run is always paced at `DT_TASK == 1.0` (true real
|
||
time) no matter what `--sim-warp-factor` is set to.
|
||
3. Either connect with the desktop GUI:
|
||
|
||
```bash
|
||
cd client
|
||
./brewpi_gui.py
|
||
```
|
||
|
||
or open `http://<server-host>:8080/` in any browser (same machine or
|
||
anywhere else on the network) for the browser client.
|
||
|
||
`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.
|
||
|
||
### Running as a systemd service
|
||
|
||
`deploy/install.sh` installs `deploy/brewpi.service` as a systemd unit
|
||
(`BREWPI_HOME` is derived from the script's own location, substituted into
|
||
the unit via `envsubst`):
|
||
|
||
```bash
|
||
./deploy/install.sh
|
||
sudo systemctl start brewpi
|
||
journalctl -u brewpi -f
|
||
```
|
||
|
||
`systemctl stop`/`restart` send `SIGTERM`, which `server/brewpi.py` routes
|
||
through the same shutdown path as Ctrl-C (`SIGINT`/`KeyboardInterrupt`) -
|
||
so a service stop still cancels all tasks, forces the heater to 0 W and the
|
||
stirrer to a stopped state (`AHeater`/`AStirrer.activate(False)`, via each
|
||
task's `with device.open():`), and flushes the server/sud logs before the
|
||
process exits, rather than being killed mid-state.
|
||
|
||
## Mash schedules
|
||
|
||
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
|
||
`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
|
||
controller's own gap-tracking FSM (`components/pid/temp_controller_base.py`)
|
||
says the gap actually warrants it; a step omitting `temperature` simply
|
||
has no new target of its own and keeps whatever the previous step left
|
||
running, so the ramp resolves immediately. `ramp.rate` itself still comes
|
||
from `default.step.ramp` unless a step overrides it.
|
||
- a hold — `"hold": {"duration": ...}` — hold the current target for
|
||
`duration` minutes (omit/`0` for an immediate step) - this part stays
|
||
opt-in: only a step that specifies `hold` gets a hold-duration phase
|
||
after the ramp.
|
||
|
||
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.
|
||
`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`
|
||
only needs to specify the fields it overrides — anything it omits is filled
|
||
in from `default.step`, recursively. `ramp` is always defaulted in this way
|
||
(every step ramps); `hold` stays opt-in - only present if the step specifies
|
||
it. `temperature` is the one exception: it's never defaulted from
|
||
`default.step` (that's just inert template filler in every `sude/*.json`) -
|
||
a step either specifies its own, or has none and doesn't change the target.
|
||
|
||
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` toward each step's target
|
||
(advancing once the controller's own FSM reports the gap closed -
|
||
`TempControllerBase.is_holding()` - rather than a separate ad hoc
|
||
tolerance), 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, elapsed run time -
|
||
tick-counted, not derived from wall-clock time times the warp factor, since
|
||
the latter is only nominal and drifts under real scheduling load - 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, `{"Sud": {"Confirm": true}}` to acknowledge a pause and move to
|
||
the next step, and `{"Sud": {"Load": <sud.json contents>}}` to replace the
|
||
running schedule - refused (with an explicit, one-shot `Error` reply - see
|
||
"State replay on connect" above) while a run is already in progress, rather
|
||
than the client trying to guess that from its own view of the state.
|
||
|
||
The temperature controller has a master enabled switch (off by default -
|
||
see `components/pid/temp_controller_base.py`). `HeaterTask` owns this
|
||
switch via its `closed_loop` flag: the TC is enabled whenever the system is
|
||
in closed-loop mode (the default) and disabled in open-loop mode. This is
|
||
wired up in `server/brewpi.py` via
|
||
`heater_task.set_on_closed_loop_changed(tc.set_enabled)`, and fired once
|
||
at startup from `HeaterTask.on_process()` so the TC is live immediately.
|
||
`SudTask` no longer manages the TC's enabled state directly; instead, when
|
||
a run ends (state reaches `DONE` or `IDLE` — whether the schedule completed
|
||
or Stop was pressed), it fires an `_on_end` callback that calls
|
||
`HeaterTask.shutdown()`: this zeros `power_soll`, disables the TC, switches
|
||
`closed_loop` to `False`, and broadcasts `{ClosedLoop: false}` to all clients
|
||
so their checkboxes uncheck immediately. Re-enabling for manual use after a
|
||
stop requires the user to check the Closed-loop checkbox again (or the
|
||
next `Start` press does it automatically — see "Heater control modes"). See
|
||
"Heater control modes" below.
|
||
|
||
### Heater control modes
|
||
|
||
The heater operates in one of two modes, selectable by the user while the
|
||
Sud is paused or stopped. Switching while a run is active is blocked —
|
||
the mode is automatically reset to Closed-loop the moment a run starts
|
||
(the client sends `{Heater: {ClosedLoop: true}}` before `{Sud: {Start: true}}`).
|
||
|
||
**Closed-loop** (default): the temperature controller drives the heater
|
||
exclusively. Its output `y` flows to `HeaterTask.actor()` — `power_soll`
|
||
(any direct power command from a client) is ignored entirely. While paused
|
||
or stopped, the user can adjust the temperature setpoint and heat rate;
|
||
the heater-power slider is disabled. Because the TC is always enabled in
|
||
this mode (see above), adjusting the temperature setpoint even with no Sud
|
||
loaded or running causes the heater to respond immediately.
|
||
|
||
**Open-loop**: the TC is disconnected — `HeaterTask` uses `power_soll`
|
||
(the last `{Heater: {Power: X}}` received from a client) instead of
|
||
`power_actor` (the TC output), and the TC is disabled (`y` forced to 0).
|
||
Only the heater-power slider is active; temperature and heat-rate controls
|
||
are disabled. Only available when the Sud is paused or stopped.
|
||
|
||
The `ClosedLoop` boolean is part of the server's `global_state` and is
|
||
broadcast to every client on connect, so all clients always see the same
|
||
mode (see "State is server-only" above) — flipping the checkbox in one
|
||
browser tab is immediately reflected in every other connected client.
|
||
|
||
On every resume from pause, `SudTask.recv()` restores the current step's
|
||
schedule parameters (temperature setpoint, heat rate, stirrer) before the
|
||
run continues, overwriting any manual adjustments the user made while
|
||
paused. Steps that carry no `temperature` of their own (`temperature=None`
|
||
— they inherit whatever the previous step left the TC targeting) are
|
||
handled via `_paused_temp_soll`: the setpoint is saved the moment Pause is
|
||
pressed and restored as a fallback on resume, so the TC always snaps back
|
||
to the correct target even for inherited-temperature steps.
|
||
|
||
### Forecast vs. actual duration
|
||
|
||
The GUI's Automatic tab plots two things against the same time axis: a
|
||
*forecast* (faded, via alpha) and, once a run starts, the *actual*
|
||
measured trace (solid, full opacity) overlaid on top of it - a comparison
|
||
between predicted and real 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, 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.
|
||
|
||
It's anchored to the real current temperature
|
||
(`TempControllerBase.get_theta_ist_set()` - the raw sensor reading, valid
|
||
even before this controller's first `process()` tick, unlike
|
||
`get_theta_ist()`), not a cold start at ambient - `SudTask.send_forecast()`
|
||
computes it once on Load (a preview of what's coming), and again on every
|
||
fresh `Start` (not a Pause→resume, which keeps whatever forecast a run
|
||
already established rather than discarding it mid-run), since the real
|
||
temperature can have drifted in the meantime (time passing, manual heating
|
||
via the Manual tab in between).
|
||
|
||
`SudTask._send_forecast()` thins the curve to at most `MAX_FORECAST_POINTS`
|
||
(1000) before broadcasting it - a fine enough `dt` over a multi-hour brew
|
||
can otherwise produce a single `Forecast` message of several MB, large
|
||
enough to exceed the `websockets` library's default 1 MiB `max_size` and
|
||
get the connection closed outright (code 1009). `SudTask.forecast_t`/
|
||
`forecast_theta` themselves stay at full simulated resolution (used for
|
||
the exact-match truncation in `_reanchor_forecast()` below) - only the copy
|
||
actually sent to clients is thinned, which the GUI's few-hundred-pixel-wide
|
||
plot can't show the difference from anyway.
|
||
|
||
The one wrinkle is a step with `user_wait_for_continue`: a human's response
|
||
time genuinely can't be forecast. Rather than stall the whole estimate
|
||
there, `SudForecastEstimator.estimate()` models it as a zero-delay
|
||
auto-confirm and keeps simulating straight through to the schedule's actual
|
||
end - so the full projected curve is visible right away on Load, instead of
|
||
stopping at the first such step.
|
||
|
||
That optimistic guess - and, more generally, *any* divergence the original
|
||
simulation couldn't have predicted (a malt fill-in's actual cooldown, a
|
||
ramp that runs faster or slower than modeled, ...) - gets corrected the
|
||
moment the schedule actually reaches the next real step boundary, not just
|
||
at a user confirmation: `tasks/sud.py`'s `SudTask._reanchor_forecast()`,
|
||
called from `on_step_changed()` on *every* transition (a full step change
|
||
and a step's own ramp→hold phase switch alike), truncates the forecast
|
||
and splices in a freshly anchored simulation of the rest of the schedule -
|
||
anchored at the real elapsed time and the real current temperature
|
||
(`TempControllerBase.get_theta_ist()` - trustworthy here, unlike at Load,
|
||
since a real run has been actively ticking for a while by the time this
|
||
runs). The corrected forecast is sent in full each time, so the GUI's
|
||
`SudForecastPlot.show_forecast()` simply redraws the (faded) forecast line
|
||
outright rather than patching it up itself.
|
||
|
||
The truncation point is `forecast_step_starts[index]` (the *old* forecast's
|
||
own belief of where step `index` begins), not the real elapsed time itself
|
||
- those can diverge badly for exactly the `user_wait_for_continue` case
|
||
above: if a human's real confirmation takes far longer than the zero-delay
|
||
guess assumed, the old forecast's entire speculative remainder (which
|
||
raced straight through the rest of the schedule) still carries timestamps
|
||
numerically *less than* the now-much-larger real elapsed time, so cutting
|
||
against real elapsed time directly would find nothing to discard and just
|
||
append the fresh, correct simulation after it - a doubled-back, self-
|
||
overlapping curve instead of a clean cut. `forecast_step_starts[index]`
|
||
lives in the same (speculative) coordinate space as the forecast being
|
||
truncated, so it isn't fooled by how far real and speculated time have
|
||
drifted apart.
|
||
|
||
Two transitions can fire in close succession (a step whose hold duration
|
||
is already `0` advances right through it within a single tick, and a
|
||
fresh `Start` triggers both the explicit Load-time forecast *and* the
|
||
first step's own reanchor at once) - each spawns its own
|
||
`_reanchor_forecast()`/`send_forecast()` call awaiting a worker-thread
|
||
simulation, so whichever resumes second could otherwise blindly splice
|
||
its own (older) tail onto whatever the other already finished writing.
|
||
A monotonically increasing `SudTask._forecast_generation` counter, bumped
|
||
at the start of each such call and checked again after its simulation
|
||
returns, guards against this: only the very latest call's result is ever
|
||
committed, and an older one resuming after a newer one already has discards
|
||
itself instead.
|
||
|
||
Per-step start times (`SudTask.forecast_step_starts`, sent as `StepStarts`
|
||
on the `Forecast` message - the schedule's absolute step index mapped to
|
||
where it begins on the same timeline as `forecast_t`) power the Progress
|
||
tab's remaining-time countdown (see "Progress tab" below) - both the real
|
||
time for an already-passed step and the predicted time for one still
|
||
ahead. These are recorded *synchronously* in `on_step_changed()` itself,
|
||
not only as part of `_reanchor_forecast()`'s own (correct, but async, and
|
||
thus subject to the same race above) recomputation - an async
|
||
recomputation that loses that race is simply discarded before ever
|
||
committing, and without the synchronous copy, a later reanchor's "anything
|
||
before my own index is already real, leave it alone" filter would then
|
||
preserve whatever stale prediction was sitting there from before instead.
|
||
|
||
A client connecting (or reconnecting) mid-brew must not perturb any of
|
||
this: `{"Sud": {"Save": true}}` - sent unconditionally by every client on
|
||
connect, to fetch the currently-loaded schedule for preview - used to
|
||
always trigger `send_forecast()`'s cold, from-step-0 simulation, which has
|
||
no notion of "already mid-run" and would silently overwrite the
|
||
accurate, continuously-maintained forecast/`forecast_step_starts` with a
|
||
context-free "starting fresh right now" guess. It's now only taken for a
|
||
genuinely not-yet-started schedule (`IDLE`/`DONE`); an already-running one
|
||
just gets what's already there re-sent. The GUI mirrors this on its own
|
||
side - `update_sud_forecast()` only resets the displayed step/forecast
|
||
state when the incoming `Json` is actually a *different* schedule
|
||
(`sud_last_loaded_doc` equality check), not a re-fetch of the same running
|
||
one, since that standalone `Json`+`Forecast` reply (unlike the subscribe-
|
||
triggered replay, which carries `Step`/`State` alongside it) has nothing
|
||
in the same message to restore the wiped state afterward.
|
||
|
||
Comparing the two lines: real-world divergence from the forecast - more
|
||
than a transient blip during a ramp's settling - is worth investigating as
|
||
an actual control issue (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). The
|
||
GUI plots the actual trace using `Sud.elapsed` (tick-counted simulated
|
||
seconds, see "State replay on connect" above) rather than wall-clock time
|
||
times the configured `--sim-warp-factor` - the latter is only nominal, and
|
||
real asyncio/Python scheduling overhead means the actually-achieved
|
||
speedup runs measurably below it (confirmed: ~80x instead of a configured
|
||
100x), which on its own used to produce a divergence large enough to look
|
||
like a control problem.
|
||
|
||
Two other sources of spurious (non-control-related) divergence, both
|
||
fixed:
|
||
|
||
- `client/brewpi_gui.py`'s actual-trace history used to only get its first
|
||
sample at `on_plot_timer()`'s next 1-second (real time) tick - under a
|
||
real `sim_warp_factor`, simulated time has already moved measurably past
|
||
`t=0` by then, leaving a small visible gap between the forecast's exact
|
||
`t=0` anchor and where the actual line first appears. `on_sud_changed()`
|
||
now seeds `forecast_history` with the real `(elapsed, plot_temp_ist)`
|
||
point the moment the first `Elapsed` push of a *fresh* run arrives
|
||
(gated on elapsed still being small, `FRESH_RUN_ELAPSED_THRESHOLD_S`, so
|
||
reconnecting mid-brew doesn't get a bogus point plotted at `x=0`).
|
||
- Both `TempController` variants used to hardcode `last_theta_ist = 20` at
|
||
construction, used to compute the very first `process()` tick's
|
||
`heatrate_ist` - a wildly bogus rate spike whenever the real starting
|
||
temperature wasn't actually 20 (invisible until a forecast actually got
|
||
anchored to something other than 20, per the fix above), which then took
|
||
several exponentially-smoothed ticks to decay out of the rate-PID loop,
|
||
visibly distorting the trajectory during exactly that window. It now
|
||
starts `None` and treats the first tick's rate as `0` (no real history
|
||
yet) instead of computing it against a hardcoded, usually-wrong
|
||
baseline - `SudForecastEstimator` is the case that actually surfaced
|
||
this: it builds a fresh `TempController` on every call, so its first
|
||
tick always hit this fresh, unlike the real controller, which only ever
|
||
goes through it once near server startup and has long since
|
||
self-corrected by the time any forecast comparison matters.
|
||
|
||
### Progress tab
|
||
|
||
Where the Automatic tab's forecast plot shows the brew as one curve, the
|
||
GUI's Progress tab shows it as a stack of `StepPlate`s, one per schedule
|
||
step (built entirely in code, not via `brewpi.ui`/`main_window.py` - its
|
||
content is already fully dynamic, so there's nothing for Designer to
|
||
usefully own). Each plate has:
|
||
|
||
- an LED: off (not yet entered), solid green (finished), or flashing -
|
||
orange while actively ramping, green while actively holding. `WAIT_USER`
|
||
and `PAUSED` don't change the step's `Type` (`Sud._finish_step()`/
|
||
`pause()` leave it as whatever phase was actually last real), so the LED
|
||
keeps flashing whichever color that was rather than going stale.
|
||
- target temperature, resolved through any step that doesn't set its own
|
||
(inherits whatever the previous one left running, same as the real
|
||
controller).
|
||
- actual temperature and stirrer state: live while this is the active
|
||
step, frozen at their last value the moment a later `Step` push reports
|
||
the schedule has moved past it (`Window.on_sud_changed()`), blank before
|
||
it's ever been reached.
|
||
- grain/water mass and ramp rate, straight from the schedule.
|
||
- energy consumption (Wh, see below) and a remaining-time countdown,
|
||
derived from `forecast_step_starts`/`StepStarts` (see "Forecast vs.
|
||
actual duration" above) - this step's total span (next step's start
|
||
minus this one's, or the forecast's own final point if it's the last
|
||
step and finished) minus how much of it has elapsed so far.
|
||
|
||
Energy has no forecast equivalent to preview before a run starts - what's
|
||
actually used can only be measured, not predicted. `SudTask` integrates it
|
||
server-side every tick from the heater's own live effective power
|
||
(`Pot.get_power()`, already fed from `heater.power_eff` - see "Data flow"
|
||
above), in Joules, for whichever step is current; `on_step_changed()`
|
||
banks the finished total (converted to Wh) into `energy_by_step` on every
|
||
genuine step transition - including the final one, to `DONE` - the same
|
||
index-change check that drives `forecast_step_starts`' bookkeeping, so a
|
||
ramp→hold phase switch doesn't reset it mid-step. Counted through
|
||
`WAIT_USER`/`PAUSED` too: the controller stays enabled and may well still
|
||
be actively (and genuinely) drawing power even though the schedule itself
|
||
isn't progressing. Both `energy_by_step` and the running step's total are
|
||
reset on every fresh Load.
|
||
|
||
The status bar additionally sums these into running totals for the whole
|
||
brew so far: total process time (just `Sud.elapsed` - every step's time,
|
||
`WAIT_USER` dwell included, already adds up sequentially with no gaps) and
|
||
total energy in kWh (no single running counter exists server-side for that
|
||
one, since energy is banked per step rather than accumulated as a grand
|
||
total - summed client-side instead, from every finished step's own Wh
|
||
total plus whatever the active one has used so far).
|
||
|
||
### Browser client
|
||
|
||
`web/` is a second, independent client - plain HTML/CSS/JS, no build step,
|
||
no dependencies - for the same server, added so the rig is reachable from
|
||
any browser on the network rather than only from a machine with the PyQt5
|
||
desktop app installed. It speaks the exact same WebSocket pub/sub protocol
|
||
`client/brewpi_gui.py` does (same subscribe-on-connect handshake, same
|
||
message shapes on every channel), so nothing about the protocol or
|
||
`server/brewpi.py`'s data flow needed to change for it to exist - only a
|
||
plain static-file HTTP server (`http.server.ThreadingHTTPServer`, stdlib,
|
||
in its own daemon thread, `--http-port`, default `8080`) needed adding,
|
||
deliberately independent of the existing `websockets`-based asyncio server
|
||
so neither can affect the other's behavior or availability.
|
||
|
||
The browser client covers the Manual panel (direct heater/stirrer/TC
|
||
control) and the Progress tab (above). `web/app.js` ports the relevant
|
||
logic from `client/brewpi_gui.py` directly: `components/sud.py`'s
|
||
`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is
|
||
unresolved — every step still needs `default.step` merged in), and the
|
||
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
|
||
behind the Progress tab and status line. New/Load/Save (a plain
|
||
`<input type="file">`/`FileReader` and a `Blob`/`<a download>` standing in
|
||
for the desktop app's native file dialogs), Start/Pause/Stop, and Confirm
|
||
(a plain `<dialog>` pops with the step's message on `WAIT_USER` and sends
|
||
Confirm when dismissed) are all wired up.
|
||
|
||
The page carries a `<meta name="viewport">` tag so Safari and other mobile
|
||
browsers render at device CSS pixels rather than the 980 px virtual layout
|
||
they otherwise default to — essential for touch usability on iPad. The
|
||
header is a two-row layout: the top row holds the host address input,
|
||
Connect button, connection status, and the three large (76 × 76 px)
|
||
circular transport buttons (Start/Pause/Stop) with the countdown to their
|
||
right; the New/Load/Save file buttons occupy their own row directly below.
|
||
The LCD panel (Temperature/Heat rate/Power) uses a four-column grid, with
|
||
unit labels (°C, °C/min, W) in a dedicated column to the right of the Soll
|
||
value rather than embedded in the row labels.
|
||
|
||
The browser client applies the "state is server-only" principle strictly
|
||
throughout `web/app.js`: every slider, readout, and checkbox is updated
|
||
only when a push arrives from the server, never speculatively on user
|
||
input. The Closed-loop/Open-loop checkbox in the Heater panel is the
|
||
clearest example — see "Heater control modes" above and "State is
|
||
server-only" for the full rationale.
|
||
|
||
Additional features beyond the initial port:
|
||
|
||
- **Heater control modes**: a "Closed-loop" checkbox in the Heater panel
|
||
(checked = Closed-loop, unchecked = Open-loop). Enabled only when the
|
||
Sud is paused or stopped; auto-reset to Closed-loop on play. Slider
|
||
enable/disable follows the mode: temperature setpoint and heat rate
|
||
active in Closed-loop + not running; heater power active in Open-loop +
|
||
not running. When a run ends (DONE or Stop), `HeaterTask.shutdown()`
|
||
fires automatically — the checkbox unchecks, power goes to 0, TC is
|
||
disabled (see "Heater control modes" above).
|
||
- **Header countdown**: three monospace rows next to the transport buttons,
|
||
always in `h:mm:ss` format: *total* (time until the schedule finishes),
|
||
*ramp* (ramp phase remaining for the current step — total step span minus
|
||
the configured hold duration), and *hold* (`Sud.hold_remaining` pushed
|
||
live from the server). All three derive from `forecast_step_starts`/
|
||
`StepStarts`; "ramp" shows `0:00:00` once the hold phase begins.
|
||
- **Step-plate countdowns**: each plate's top-right shows the remaining
|
||
ramp time in green digits. The active step overrides this with a "Ramp
|
||
h:mm:ss" or "Hold h:mm:ss" prefix (same element reused for both phases).
|
||
Configured hold duration (`hold.duration` formatted as `h:mm:ss`) is
|
||
shown as a static field on each plate; steps with no hold phase show
|
||
"Hold —".
|
||
- **Controls always reflect server values**: sliders and readouts track
|
||
every server push unconditionally — there are no "initial sync only"
|
||
guards that could leave a control stale after a reconnect or while
|
||
another client is active.
|
||
|
||
See `web/TODO.md` for what's still missing. Deliberately deferred: the
|
||
Automatic tab's forecast-vs-actual plot and the live Plot tab's strip
|
||
charts — both need a charting approach, revisit once the rest of the
|
||
backlog is settled.
|
||
|
||
No authentication - matching the existing WebSocket server's own complete
|
||
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
||
reachable from anywhere on the network" is a meaningfully wider exposure
|
||
than "desktop app, reachable from wherever its process happens to be
|
||
running" if this is ever reachable off a trusted LAN.
|
||
|
||
## Logging & analysis
|
||
|
||
Both loggers record 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, plus any plant-param changes along the way (see
|
||
`apply_plant_params()`) - written as JSON via `write()`, always the whole
|
||
accumulated sample list rewritten to the same file, never appended.
|
||
|
||
Each sample's simulated-elapsed-seconds (`'t'`) is tick-counted, never
|
||
derived from wall-clock time (see `_elapsed()` in `tasks/server_log.py`) -
|
||
`SudLogTask`'s is `Sud.elapsed` itself (so it lines up with
|
||
`SudForecastEstimator`'s own tick-counted timeline for forecast
|
||
comparison), while `ServerLogTask`'s is its own independent counter since
|
||
server startup, on a different timeline (the server usually starts well
|
||
before any Sud does). `PlantParams` entries are always stamped with
|
||
*that same logger's* `_elapsed()` too, for exactly this reason - using the
|
||
wrong one (e.g. `Sud.elapsed` in a `ServerLogTask`) would put
|
||
`PlantParams`' timestamps on a different scale than that log's own
|
||
`Samples`, breaking anything (like `utils/replay_sim.py`) that correlates
|
||
the two by `t`.
|
||
|
||
`tasks/server_log.py`'s `ServerLogTask` records continuously for the whole
|
||
server session, regardless of Sud state - useful for verifying controller
|
||
and heater behaviour outside of a scheduled brew. It starts on construction
|
||
and is written to `logs/log_<date>T<time>.json` on shutdown, and again
|
||
every `log_interval` seconds (`config.json`, default 300) in between so a
|
||
hard crash or power loss only loses up to that much data. Every write also
|
||
overwrites a fixed-name `logs/log_latest.json` with the same content, so a
|
||
dashboard/tail-style consumer can point at one unchanging path instead of
|
||
tracking the current session's own timestamped filename.
|
||
|
||
`tasks/sud_log.py`'s `SudLogTask` subclasses `ServerLogTask` to reuse the
|
||
same sample format and `log_interval` checkpointing, but only records while
|
||
a Sud run is actually active. A fresh run starts recording on a genuine
|
||
`Start` from `IDLE`/`DONE` (a `Pause`→resume `Start` is a no-op - it's still
|
||
the same run) and is written out on `Stop` or natural completion (`DONE`),
|
||
to `logs/log_<date>T<time>_<sud-name>.json` - and, the same way, also to a
|
||
fixed `logs/log_latest_sud.json`.
|
||
|
||
`logs/log_latest.json` and `logs/log_latest_sud.json` are updated on
|
||
independent schedules (`ServerLogTask`'s own periodic checkpoint is
|
||
completely decoupled from any particular Sud run starting or stopping), so
|
||
at any given moment they can reflect different points in the same overall
|
||
session - don't assume they're a matched pair unless you've just watched a
|
||
run finish (`log_latest_sud.json` is only ever current *immediately* after
|
||
a `Stop`/`DONE`).
|
||
|
||
`server/brewpi.py` also mirrors console output to a plain text log,
|
||
`logs/brewpi.<timestamp>.log` (plus an always-truncated `logs/brewpi.latest.log`
|
||
for tail-style consumers), alongside those JSON logs - useful for post-mortems
|
||
without needing to have been watching the console live. Every task/component
|
||
logs through the stdlib `logging` module rather than `print()`: `AttributeChange`
|
||
(`utils/value.py`, the common base of every `ATask` and component ABC) gives
|
||
each one a `self.log = logging.getLogger(type(self).__name__)`, so messages
|
||
are automatically tagged with the component's class name with no hand-typing
|
||
needed. `server/brewpi.py`'s `Tee` class mirrors `stdout`/`stderr` to both log
|
||
files and stamps each line with a `<date>T<time>:` prefix; `logging.basicConfig()`
|
||
uses a bare `%(name)s:%(message)s` formatter, so the combined line reads
|
||
`<date>T<time>:<component>:<message>` without double-stamping (and third-party
|
||
loggers like `websockets`/`asyncio` get the same formatting for free).
|
||
`components/sud.py`'s `Sud` logs its own lifecycle (load/start/pause/continue/
|
||
stop, each step's `"<descr>(<number>) Heating"`/`"Reached"` transitions,
|
||
waiting for user interaction, finished), and `HeaterTask`/`StirrerTask` log
|
||
connected/disconnected state. `SudForecastEstimator` drives its own throwaway
|
||
`Sud` through an entire schedule with no real waiting to predict duration, so
|
||
it's given its own `"SudForecastEstimator"` logger (silenced to `WARNING` by
|
||
default in `components/sud_forecast.py`) rather than sharing `Sud`'s, keeping
|
||
an instant internal forecast run out of the log next to actual brew activity.
|
||
|
||
Both JSON logs also carry `dt`, the effective `SimWarpFactor` (already
|
||
clamped to `1.0` for real hardware - see above), `Config` (the whole
|
||
`config.json` this run used), and `HeaterPowers` (`heater.get_powers()`,
|
||
queried live since it can't be reconstructed offline for real hardware
|
||
like `HeaterHendi`, which opens a serial port on construction) - enough to
|
||
rebuild a `SudForecastEstimator` for that exact run without needing
|
||
`config.json`, a live server, or any hardware at all. A `SudLogTask`'s own
|
||
log additionally carries `Name` (the Sud's name), `Doc` (the raw sud.json
|
||
document as loaded, via `Sud.save()`) and `ForecastAnchors` (one
|
||
`{t, index, theta_ist}` entry per real `_reanchor_forecast()` trigger -
|
||
see "Forecast vs. actual duration" above).
|
||
|
||
`utils/analyze_log.py` uses all of this to render the same forecast-vs-
|
||
actual comparison the GUI's Automatic tab shows live, purely offline:
|
||
`build_forecast()` re-simulates the logged `Doc` with
|
||
`SudForecastEstimator`, then replays every logged `ForecastAnchors` entry
|
||
through the exact same splice-per-transition correction
|
||
`_reanchor_forecast()` applies live, so the reconstructed forecast matches
|
||
what a connected client would actually have seen throughout the run -
|
||
not just the single, uncorrected cold-start estimate.
|
||
|
||
```bash
|
||
python utils/analyze_log.py <date_time> <sud_name> # e.g. 20260701T192824 Sud-0010
|
||
python utils/analyze_log.py path/to/log_*.json # or a direct path
|
||
```
|