Refresh README: Progress tab, reanchor mechanism, CLI dt/warp flags

The "Forecast vs. actual duration" section still described the old
confirm_points/_continue_forecast_after_confirm() mechanism and
claimed the forecast was deliberately not re-anchored mid-run - both
no longer true since _reanchor_forecast() now fires on every step
boundary. Replaced with the actual current mechanism (the reanchor
itself, the _forecast_generation race guard, forecast_step_starts/
StepStarts, and the Save-while-running fix on both server and
client), added a new "Progress tab" section (LED semantics, per-step
fields, energy integration/banking, status-bar sums), and documented
--dt/--sim-warp-factor replacing config.json's Controller.dt/
sim_warp_factor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
This commit is contained in:
2026-06-24 19:26:17 +02:00
co-authored by Claude Sonnet 4.6
parent fa6acf7c82
commit 3c5b3053e9
+127 -33
View File
@@ -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 -