# 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").
                             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.

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
   ```
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`). `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, 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.

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

`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.
