diff --git a/README.md b/README.md index db513e0..a97effc 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,10 @@ client/brewpi_gui.py PyQt5 desktop client (brewpi.ui) that connects stirrer/temperature controller on or off (plus a "reset Pot to ambient" button, shown only when the server's plant is simulated) - plus - Sud control (New/Load/Save/Start/Pause/Stop) - and its forecast plot on the Automatic tab. + 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 @@ -188,7 +190,16 @@ pip install -r client/requirements.txt ./brewpi.py ``` - This serves the WebSocket on `ws://0.0.0.0:8765`. + This serves the WebSocket on `ws://0.0.0.0:8765`, 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 + ``` 3. Start the GUI client and connect to the server's URI: ```bash @@ -323,11 +334,8 @@ even before this controller's first `process()` tick, unlike 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). Once a run -is actually progressing, though, it's deliberately *not* continuously -re-anchored to match whatever's happening: a forecast that keeps chasing -reality would no longer be a meaningful baseline to compare it against. +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 @@ -335,37 +343,74 @@ 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 `_continue_forecast_after_confirm()` 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 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. `estimate()` also returns `confirm_points`: -where (in step index and simulated time) each of those zero-delay -assumptions was made. +stopping at the first such step. -That assumption gets corrected once the real confirmation actually -happens: `tasks/sud.py`'s `SudTask._continue_forecast_after_confirm()` -looks up the relevant `confirm_points` entry, truncates the forecast right -back to that point, and splices in a freshly anchored simulation of the -remaining steps - anchored at the real elapsed time and real current -temperature, so an actual delay shows up honestly as a gap in the timeline -rather than as the assumed zero. That gap is filled at the real -controller's setpoint at the moment of confirmation (`recv()` captures -`tc.get_theta_soll_set()` *before* calling `Sud.confirm()`, since confirming -synchronously pushes the next step's own target onto it) rather than -whatever value the forecast happened to log the instant `WAIT_USER` -tripped - the controller stays enabled and actively holding throughout -`WAIT_USER`, so the setpoint is what it was actually converging toward -during the wait, not a possibly-still-mid-ramp snapshot. 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. +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 @@ -375,7 +420,7 @@ 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 +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 @@ -408,6 +453,55 @@ fixed: 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). + ## Logging & analysis `tasks/sud_log.py`'s `SudLogTask` records every Sud run's measured data -