Ports on_action_sud_new/_save/_load() and on_action_sud_start/_stop() from client/brewpi_gui.py: Load/Save stand in for the native file dialogs with <input type="file">/FileReader and a Blob/<a download>, New sends the same hardcoded empty-doc Load, and Start/Stop are gated by updateSudActions() mirroring update_sud_actions()'s enabled/disabled logic. Also fixes the status line's step number being one below what the Progress tab's plates show for the same step (same bug just fixed in the desktop client). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
575 lines
33 KiB
Markdown
575 lines
33 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.
|
|
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").
|
|
|
|
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 - the "sim" component
|
|
names (stirrer/plant) already default to
|
|
simulation; point Stirrer/Heater at real
|
|
serial ports and plant_name at a real
|
|
backend to switch to hardware.
|
|
|
|
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 replay on connect
|
|
|
|
A fundamental assumption baked into this protocol: a client never knows what
|
|
it's connecting to. 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 (network hiccup, the
|
|
user closing and reopening the GUI) and reconnect later, at any server state,
|
|
with no special "resume" handshake. In every one of these cases, the client
|
|
must end up with the *same* full picture of current state as one that's been
|
|
connected the whole time - not just whatever changes happen after it joins.
|
|
|
|
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, because the
|
|
server doesn't distinguish between the two cases at all - there's no
|
|
"first connect" special-casing anywhere in this path.
|
|
|
|
The 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.json.templ` to `config.json` next to `brewpi.py` - it
|
|
already runs entirely in simulation (no hardware needed) as-is; switch
|
|
`stirrer_name`/`plant_name`'s `"sim"` value(s) to a real backend, and
|
|
set the corresponding serial port, to use real hardware. `plant_name`
|
|
alone 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
|
|
```
|
|
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.
|
|
|
|
## 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`): `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 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, and for
|
|
`SudLogTask`'s full-fidelity `logs/forecast_*.json` - see "Logging &
|
|
analysis") - 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
|
|
back to right now 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.
|
|
|
|
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.
|
|
|
|
v1 covers only the Manual tab (direct heater/stirrer/controller control)
|
|
and the Progress tab (above) - the two most actionable surfaces, and
|
|
neither needs a charting library. `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) and Start/Stop are wired up too
|
|
- see `web/TODO.md` for what's still missing (Pause/Confirm, `WAIT_USER`
|
|
surfacing, and more). Deliberately deferred rather than stubbed: the
|
|
Automatic tab's forecast-vs-actual plot and the live Plot tab's strip
|
|
charts - both need a charting approach, revisit once Manual + Progress are
|
|
validated.
|
|
|
|
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
|
|
|
|
`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 those JSON logs - useful for
|
|
post-mortems without needing to have been watching the console live.
|