jensandClaude Sonnet 5 d1e556024d fix: thread config.json's Pot.mass into both UI clients' displayed mass
Sud._parse_data() already falls back to config.json's Pot section when
a loaded sud.json doesn't override 'mass', but neither client actually
passed that config through, so displayed pot mass silently read 0 kg
whenever a recipe didn't set its own 'pot.mass'.

client/brewpi_gui.py: capture the System message's 'Pot' key (already
sent by the server, previously ignored) into self.pot_config, and pass
it to Sud._parse_data(doc, self.pot_config).

web/app.js: parseSudDoc()'s potMass now falls back to the module-level
potConfig.mass (already captured from msg.Pot elsewhere, just never
consulted here) before defaulting to 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
2026-07-06 10:25:09 +02:00
2020-11-24 20:42:27 +01:00
2021-08-10 15:05:57 +01:00
2021-10-18 19:15:11 +02:00
2021-10-18 18:08:41 +02:00

BrewPi

A Python-based controller for automating the mash/brewing process of beer: it holds a pot of liquid at target temperatures (or ramps it at a target heating rate) according to a configurable mash schedule, drives a heater and stirrer, and exposes live control/telemetry over a WebSocket so a desktop GUI (or any other client) can monitor and steer the brew.

The project began as a simulation/control-theory playground (Smith-predictor temperature control, pot transport-delay model, see docs/NonLinMPC.pdf) and has grown real-hardware backends for an induction hob and an RTD temperature probe.

Architecture

server/brewpi.py            Server entry point: wires sensor, pot/plant,
                             heater, temperature controller and stirrer
                             together, runs them as asyncio tasks, and
                             serves state/commands over a WebSocket.

client/brewpi_gui.py        PyQt5 desktop client (brewpi.ui) that connects
                             to the server's WebSocket, displays live
                             temperature/power/state, and lets the user set
                             target temperature, heat rate, stirrer speed,
                             ambient temperature, and switch the heater/
                             stirrer/temperature controller on or off (plus
                             a "reset Pot to ambient" button, shown only
                             when the server's plant is simulated) - plus
                             Sud control (New/Load/Save/Start/Pause/Stop),
                             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:

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:

    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:

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

    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):

./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 StepPlates, 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 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.

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.

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
S
Description
No description provided
Readme
14 MiB
Languages
Python 84%
JavaScript 9.4%
HTML 3.1%
CSS 2.4%
M 0.6%
Other 0.5%