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:
@@ -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 -
|
||||
|
||||
Reference in New Issue
Block a user