Re-enabling the temp controller landed unconditionally in HOLD and
relied on the next tick to correct it, but SudTask.on_process() reads
is_holding() synchronously in the same call chain that pushes a fresh
step's setpoint - so a restart could finish a hold-less step instantly
instead of actually ramping. Resolve the FSM against the real gap right
away instead. Also fix the status bar showing one step number below
what the Progress tab's plates show for the same step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
sud_elapsed_seconds is intentionally reset to None once a run leaves
a running state (it gates the forecast plot's dynamic-vs-static view
switch) - but the status bar/Progress tab reused it too, so they lost
the final total exactly when it mattered most: right as the run
finished. Added sud_elapsed_last, which only ever gets overwritten by
a real 'Elapsed' push and is reset on a fresh Start/Load, for those
two displays to use instead.
Separately, a fresh Start (restarting an already-loaded, now IDLE/
DONE schedule) never sent a new Load, so SudTask's energy bookkeeping
- previously only reset in the Load handler - kept showing the
previous run's banked totals until each step was revisited. Now also
reset on a fresh Start (not a Pause->resume, which should keep it).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
Elapsed is just Sud's own tick-counted total (every step's time,
WAIT_USER dwell included, already adds up sequentially with no gaps).
Energy has no equivalent single running counter server-side (it's
banked per step, not accumulated as one grand total - see tasks/
sud.py's SudTask) - summed here instead from every finished step's
own Wh total plus the active step's running one, then converted to
kWh.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
Each StepPlate on the Progress tab now shows a step's energy use in
Wh - live and growing while it's the active step, frozen at its final
total once finished, blank before it's ever been reached.
Integrated server-side from the heater's own live effective power
(pot.get_power(), already fed from heater.power_eff - see server/
brewpi.py's wiring), since actual energy used can't be predicted from
the forecast like the timing/temperature fields are, only measured as
it happens. Banked into energy_by_step on every genuine step
transition (on_step_changed(), keyed off the same index-change check
used for the ramp->hold phase switch), including the final transition
to DONE; reset on every fresh Load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
Reconnecting to (or any client-side Save fetch of) an already-running
Sud showed the wrong step active and bogus remaining times, traced to
two independent bugs:
- recv()'s 'Save' handler always called send_forecast(doc), which
resimulates the *entire* schedule cold from step 0 - fine for a
not-yet-started Sud, but for one already running it silently
overwrote the forecast/forecast_step_starts that _reanchor_forecast()
had been accurately, continuously maintaining with a context-free
"starting now" guess. Now skipped whenever a run is already in
progress, re-sending what's already there instead.
- _reanchor_forecast() recomputes each step's real start time
asynchronously, so that recomputation can itself be (and routinely
is) superseded and discarded by a later transition's reanchor before
ever committing - and the next *successful* commit's "everything
before my index is real" filter would then preserve whatever stale
prediction was sitting there before, sometimes for a step that
hadn't even happened yet by the schedule's real position.
on_step_changed() now also records each step's start synchronously,
immune to that race.
- Client-side, update_sud_forecast() unconditionally reset sud_step_
index/forecast_step_starts/etc. on every 'Json' push - correct for
an actual Load, but connect()'s unconditional Save request produces
a standalone 'Json' push (no Step/State alongside it, unlike the
subscribe-triggered replay) for the *same* already-running schedule,
wiping the correct state with nothing left to restore it. Now only
resets when the incoming doc actually differs from the last one
processed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
A glance at the Automatic tab's forecast plot couldn't show where the
brew actually stands within its own schedule - just a curve. The new
Progress tab stacks one StepPlate per step instead: an LED for its
status (off/done/active-ramping/active-holding), its resolved target
temp, masses and ramp rate, plus, for whichever step is currently
active, live actual temp and stirrer state (frozen at their last
value once that step finishes) and a forecast-based remaining-time
countdown.
The countdown needs to know where each step actually begins in the
forecast's timeline, which the server didn't track before -
SudForecastEstimator.estimate() now returns per-step start times
alongside t/theta, and SudTask threads them through send_forecast()/
_reanchor_forecast() the same way (including the same generation-guard
against the concurrent-call race) as a new 'StepStarts' field on the
'Forecast' message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
Reanchoring used to only happen when a user confirmed a WAIT_USER
step, so anything the schedule advanced through on its own (a ramp
reaching target, a hold timing out) left the forecast showing a
stale, increasingly wrong prediction once reality diverged from it.
_reanchor_forecast() now fires from on_step_changed() on every real
transition, splicing in a fresh simulation anchored at the real
current temperature/elapsed time instead.
Also fixes two bugs surfaced while testing that change:
- estimate()'s early-return for an empty/not-yet-loaded schedule still
returned the old 4-tuple shape, crashing every connection before a
Sud was ever Loaded.
- send_forecast() and _reanchor_forecast() can run concurrently (e.g.
a fresh Start triggers both at once), and whichever resumed second
after its own worker-thread simulation would blindly splice its tail
onto whatever the other had already written, producing a spurious
connecting line across the plot. Both now carry a generation counter
and discard their result if a newer call has since committed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
doubleSpinBox_pot_temp no longer seeds itself from the remembered
ambient value at startup - it now starts at its plain default and
only changes once the user actually dials in a value, rather than
silently implying "ambient" is the reset target.
btn_pot_reset always reset the simulated Pot to the ambient
temperature. Add doubleSpinBox_pot_temp next to it (shown/hidden
together, same as the button) so the user can dial in a different
starting temperature before committing it - it seeds from the same
remembered ambient value on startup but is otherwise independent and
not persisted.
tasks/pot.py's PotTask.recv() now resets the plant to whatever
temperature the 'Reset' message carries, falling back to ambient for
the bare True a caller might still send.
doubleSpinBox_ambient now allows -999..999 in whole degrees (was
0..50 with 1 decimal), and main_window.py is regenerated to match.
The ambient temperature is also now a properly client-owned setting:
it's written to the user config on shutdown (not on every spinbox
tick), restored into the spinbox immediately on GUI start, resent to
the server on every (re)connect using the spinbox's live value, and
no longer reset to the spinbox minimum on disconnect.
Replaces lineEdit_ambient (QLineEdit) with doubleSpinBox_ambient
(QDoubleSpinBox, 0-50 degC, 0.5 deg steps) in brewpi.ui, regenerated
main_window.py via pyuic5 (same generator version, verified minimal diff).
brewpi_gui.py wires valueChanged instead of returnPressed - no more
manual float()/ValueError parsing of typed text, the signal already
hands over a valid float - with blockSignals guards around server-driven
updates (mirroring the existing doubleSpinBox_heatrate_soll pattern) so
applying a server echo doesn't immediately re-send the same value back.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
The actual trace's first sample otherwise only lands at on_plot_timer()'s
next 1-second tick - under a real sim_warp_factor that's already a
noticeable way into the run, leaving a small visible gap between the
forecast's exact t=0 anchor and where the actual trace first appears.
Seeds forecast_history with (elapsed, plot_temp_ist) the moment a fresh
Elapsed push arrives, gated on elapsed still being small
(FRESH_RUN_ELAPSED_THRESHOLD_S) so reconnecting mid-brew - where the
current reading has nothing to do with that run's actual t=0 - doesn't get
a bogus point plotted at x=0.
Verified live: without this, the actual trace's first point landed at
(0.5min, 20.6°) against a forecast anchored at (0min, 19.92°) - a visible
gap. With it, the seeded point (0.017min, 19.9°) lines up with the anchor
almost exactly, and the following natural samples continue smoothly from
there.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Same color, same alpha-based fade distinguishing it from the actual
measured trace - just '-b' instead of '--b'. Updates the surrounding
docstrings/comments and README that described it as dashed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Sud now parses top-level grain_mass/water_mass (sibling to pot_mass/
pot_material/L/Td, defaulting to 0/0) - what's actually in the pot before
the brew starts, as opposed to default.step's grain_mass/water_mass, which
is just inert template filler. All sude/*.json docs gain explicit values
matching what their first step already resolved to.
tasks/sud.py's apply_plant_params() now takes grain_mass/water_mass
directly instead of a step dict: on_step_changed() still passes the active
step's own (these vary as malt goes in/water boils off), but the Load
handler now reads Sud.grain_mass/water_mass directly instead of parsing
schedule[0]. The GUI's status-line mass preview (shown immediately on
Load, before Start) does the same.
Also fixes a latent bug found along the way: _continue_forecast_after_confirm()'s
reconstructed sub-doc was missing L/Td/grain_mass/water_mass entirely,
silently falling back to generic defaults instead of the real Sud's own
values - invisible only because every current sude/*.json happens to use
those same defaults.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Appends a "Pot: X kg, Water: Y kg, Grain Z kg, total W kg" breakdown to
the status bar's step line, using the pot_mass already in the loaded doc
(captured from Sud._parse_data(), previously discarded) and the current
step's already-resolved grain_mass/water_mass from self.sud_schedule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Sud now parses top-level L/Td fields from sude/*.json (defaulting to 0.2/30,
matching the old hardcoded server constants), and derive_plant_params()
returns them alongside M/C - constant for the whole brew, unlike M/C which
vary per step with grain/water mass. All sude/*.json docs gain explicit
L/Td fields.
Callers (tasks/sud.py, SudForecastEstimator, demo_sud.py) switch from the
narrow set_thermal_params(M, C)/set_model_params(M, C) to the full
set_plant_params(params)/set_model_plant_params(params), since
derive_plant_params() now always returns all four keys; both narrow setters
are removed as dead code.
Caught along the way: Pot.set_plant_params() unconditionally rebuilt the
Delay ring buffer, which was harmless when only ever called once at
construction - but now running on every Sud step change, it was wiping
in-flight delayed power at each step boundary. Fixed to only rebuild when
Td actually changes; verified this restores the exact original forecast
result for sud_0010.json.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
A user_wait_for_continue step used to stop SudForecastEstimator.estimate()
dead, so the GUI's forecast plot only ever showed the first segment on Load.
Model the wait as a zero-delay auto-confirm instead, so the whole schedule's
projected curve is visible right away; correct that assumption piecewise as
real confirmations actually happen, anchored at the real elapsed time and
temperature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Previously, the dynamic forecast's projected remainder was fully
recomputed on every step change, and a step requiring user
confirmation was simulated with zero added delay ("count it as
instant for estimation purposes"). Both undercut the actual goal of
the forecast: an honest comparison between real control behavior and
a simulation-based prediction. A forecast that keeps re-anchoring
itself to match reality isn't a useful baseline to compare reality
against, and assuming zero confirmation delay quietly understates the
schedule.
components/sud_forecast.py: SudForecastEstimator.estimate() now stops
simulating at the first step requiring user confirmation instead of
auto-confirming, returning the final SudState alongside the data so
the caller knows whether it stopped there or actually finished.
tasks/sud.py: SudTask now accumulates the forecast across piecewise
segments (forecast_t/forecast_theta). send_forecast() computes only
the first segment, at Load/Save. The real Confirm handler triggers
_continue_forecast_after_confirm(), which computes the next segment
anchored at the real elapsed time and real current temperature -
bridging the unforecastable wait with a flat segment rather than
guessing at its length - and sends the updated, stitched Forecast
(now carrying a Finished flag). The old per-step-change
RemainingForecast recompute is gone entirely, along with
remaining_schedule()/send_remaining_forecast().
client/brewpi_gui.py: SudForecastPlot redesigned around this - the
dashed line is the fixed, piecewise forecast (locked axes, untouched
except when a genuinely new segment arrives via show_forecast());
the solid line is purely the actual measured trace
(show_dynamic(), now only touching that). extend_forecast_while_
waiting() repeats the forecast's last value while the real Sud sits
in WAIT_USER, so the dashed line doesn't just stop, then snaps to the
new segment the moment the real confirmation appends one.
Verified via a raw-protocol test (segment boundaries, the Finished
flag, and real-time stitching across a confirm delay) and visually in
the GUI (locked dashed forecast, "X min so far total" title while
incomplete, solid/dashed comparison rendering with realistic
convergence/divergence).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
Root cause of "forecast vanishes right after loading, before Start":
Sud.elapsed resets to 0 on every fresh Load too, not just on an actual
run start (components/sud.py's _reset_run_state()), and that reset
broadcasts unconditionally. The GUI's Elapsed handler applied it
unconditionally too, flipping sud_elapsed_seconds from None to 0.0
purely from loading a schedule - which made on_plot_timer() think a
run had started, hijacking the plot into show_dynamic() (empty
history, visible progress line, "(est.)" title) instead of the static
forecast preview. Fixed by only applying an incoming Elapsed value
once the State-transition logic has already put the client into
dynamic-view mode, so a mere Load while idle can't masquerade as a
run starting.
Separately, eliminates the naive abs(delta)/rate forecast entirely, as
instructed - both the immediate static placeholder shown the instant a
schedule loaded (show_schedule()/_estimate_course()) and the dynamic
per-tick fallback used until the server's simulated RemainingForecast
arrived after a step change. The static preview now always waits for
and shows only the simulation-based forecast (show_computing() while
it's pending, then show_precomputed_course()); the dynamic dashed
projection simply holds its last value during that brief gap instead
of guessing. _remaining_schedule() and SUD_HOLDING_STATE, now unused,
removed too.
Also fixes the axes-scaling bug found along the way: relim()+
autoscale_view() leaves autoscaling switched on, so a later, unrelated
redraw (e.g. set_current_temp()'s continuous updates, which keep that
indicator live regardless of whether a forecast is even showing) could
silently re-autoscale and stretch the view out to fit it - e.g.
leftover heat from a previous, different run sitting well outside a
newly loaded schedule's own range. show_precomputed_course() now
computes and locks explicit xlim/ylim from the forecast data alone via
the new _set_fixed_limits().
Verified live: a fresh load shows only the simulated forecast,
correctly scaled, with no premature dynamic-mode hijacking; loading a
schedule after a much hotter previous run no longer distorts the axes
to fit the leftover actual temperature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
WsServerMultiUser.global_state persists every message ever broadcast
and replays the full accumulated state to any client that subscribes
- by design, so a client connecting fresh or reconnecting mid-run
always gets the same complete picture (see README's new "State replay
on connect" section). That mechanism has no concept of "transient":
the {'Sud': {'Error': ...}} added for Load-rejection got treated as
durable state just like everything else, so any client connecting
after the fact - even one that never touched Load - got the stale
error replayed on connect. Confirmed live before this fix.
tasks/sud.py: send {'Sud': {'Error': None}} immediately after the
error itself, clearing it back to neutral in global_state.
client/brewpi_gui.py: on_sud_changed() treats a falsy Error as a
no-op instead of popping an empty dialog.
README.md: documents the underlying principle (client state replay
on connect, and the corollary that one-shot events need explicit
clearing) under a new Architecture subsection, and refreshes the Sud
channel/forecast sections to match this session's other changes
(Elapsed-based dynamic forecast anchoring instead of wall-clock time
times the nominal warp factor, Load's Error reply).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
Sud.load() already refuses a new schedule while a run is in progress
(state not IDLE/DONE) - but tasks/sud.py's SudTask.recv() silently
dropped that refusal, and an earlier attempt to handle it client-side
by pre-emptively greying out the File menu based on the GUI's own
(replicated, laggy) view of sud_state was reverted: the intelligence
belongs on the server, which already knows definitively whether it's
running, not on the client guessing from a mirrored state that can
lag or miss the message that changed it.
tasks/sud.py: when Sud.load() returns False, send {'Sud': {'Error':
...}} explaining why, instead of doing nothing.
client/brewpi_gui.py: New/Save/Load Sud stay enabled unconditionally;
on_sud_changed() shows whatever 'Error' the server sends via
QMessageBox.warning(). on_action_sud_new()'s comment updated to match
(it goes through the same Load path, so gets the same rejection/error
for free).
Verified live: triggering Load while a run is in progress on an
isolated test server round-trips the Error over the wire and pops the
expected dialog ("Cannot load a new schedule while a run is in
progress - stop it first.").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
Window._elapsed_min() (client/brewpi_gui.py) reconstructed the dynamic
forecast's x-axis from wall-clock time times the server's nominal
configured sim_warp_factor. But that warp factor is only nominal -
real asyncio/Python scheduling overhead across the 7 concurrent tasks
means the actually-achieved speedup runs measurably below it (~80x
instead of 100x, confirmed by timing HoldRemaining countdowns against
real elapsed time), especially under heavy message/print load. Since
the GUI's reconstruction assumed a precise, constant mapping, the live
measured trace visibly drifted behind the forecast - most visible on
short, transition-dense test schedules where the timing slip is a
large fraction of the total duration.
components/sud.py: Sud now tracks 'elapsed' (tick-counted simulated
seconds since the run started, frozen while PAUSED/IDLE/DONE, reset on
start()) - the ground truth for run progress, immune to real-world
pacing jitter. tasks/sud.py broadcasts it as {'Sud': {'Elapsed': ...}}.
client/brewpi_gui.py: replaced sud_start_time/sud_paused_total/
sud_pause_started_at and all the wall-clock reconstruction in
_elapsed_min() with sud_elapsed_seconds, set directly from the
server's 'Elapsed' broadcasts - simpler too, since pause-freezing is
now handled server-side rather than needing separate client-side
bookkeeping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
WsClient's background thread (ws/client/ws_client.py) called every
on_*_changed handler - and on_ws_connect_changed - directly, but
they all mutate Qt widgets (lcdNumber.display(), setText(),
setChecked(), the plot canvas, ...), which Qt only allows from the
GUI thread. Caught live as "QObject: Cannot create children for a
parent that is in a different thread" on startup, and is a latent
crash/connection-drop risk under load (a cross-thread Qt exception
would be swallowed by ws_client.py's bare except, silently tearing
down the connection).
Adds a generic recv_signal (pyqtSignal(object, object)) carrying the
real handler and its message; _dispatch_recv (its slot) runs on the
GUI thread regardless of which thread emitted, so every handler's
body is now safe. user_message_signal - previously the only handler
already using this pattern, just for one case - becomes redundant
now that on_sud_changed itself always runs on the GUI thread, so its
modal dialog can be shown directly (renamed to show_user_message()).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
Previously, whether a schedule step ramped at all was decided purely by
the presence of a 'ramp' key (components/sud.py's _advance()) - a
hold-only step jumped straight into its hold countdown, assuming the
plant was already at temperature. Now every step ramps toward
'temperature' first, for as long as the controller's own gap-tracking
FSM (TempControllerBase, already distinguishing HEAT/COOL/HOLD) says
the gap actually warrants it - via the new is_holding() (on APid and
TempControllerBase), which replaces a separate, redundant
TEMP_REACHED_TOLERANCE constant duplicated across tasks/sud.py,
components/sud_forecast.py, and scripts/demos/sud/demo_sud.py.
set_theta_soll() now recomputes the FSM eagerly so is_holding() can't
read stale HOLD for a tick after a much-further-away target is pushed.
components/sud.py's _build_step() always synthesizes a 'ramp' block
from default.step.ramp (so 'rate' is always available), but leaves
'temperature' undefaulted - every real sude/*.json's
default.step.temperature is inert template filler, and defaulting it
would send hold-only steps chasing 0 degrees. SudTask/
SudForecastEstimator/the demo only push a new theta_soll/heatrate_soll
when a step actually specifies its own temperature; otherwise the
controller keeps whatever the previous step left running.
Also fixes a related crash this surfaced: SudTask.remaining_schedule()'s
synthetic mid-hold step dropped 'ramp' to signal "don't re-ramp" - now
that every step always carries a 'ramp' dict, dropping it left 'rate'
missing the moment the synthetic step was re-resolved by
SudForecastEstimator. Drops 'temperature' instead, which is what
actually signals "no new target" under the new model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
on_plot_timer() re-anchored the server's RemainingForecast to the
live, ever-advancing elapsed_min on every 1s tick, even though that
forecast is only recomputed on step changes. Between step changes,
the unchanged (t_rem, theta_rem) array kept getting pushed further
right by the full live elapsed_min each tick instead of staying
anchored to when it was actually computed.
Store server_remaining_forecast as (anchor_min, T, Theta), with
anchor_min captured at receipt via a new _elapsed_min() helper, and
anchor the dashed projection to that fixed point instead of the live
tick's elapsed_min.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
SudForecastPlot.show_dynamic()'s dashed "remaining" line was still
using the naive abs(delta)/rate estimate even after a run started,
even though the (much more accurate) server-side
SudForecastEstimator was already available - the static forecast got
the upgrade, the live one didn't.
tasks/sud.py: SudTask now recomputes a forecast for just the remaining
steps (seeded from the live current temperature) on every step change
and sends it as {'RemainingForecast': {...}}. Building the synthetic
"rest of this step" entry for a step currently HOLDING needs to keep
the current step's grain_mass/water_mass/temperature/etc - a bare
{'hold': {...}} re-resolves those to None against the synthetic doc's
empty default.step, breaking derive_plant_params() (caught live: a
TypeError on every step change, silently swallowed by asyncio).
client/brewpi_gui.py: on_plot_timer() now uses the server's
RemainingForecast for the dashed projection whenever available,
falling back to the naive estimate only for the brief window before
each step's recomputation arrives. show_dynamic() takes the already-
computed (t_rem, theta_rem) instead of computing it internally, so the
GUI/server sources are interchangeable from its point of view.
New components/sud_forecast.py: SudForecastEstimator simulates a whole
schedule with the same kind of plant/controller (and the same
configured params - ambient, plant params, pid_type, TempCtrl gains,
heater max power) the server uses for real, by driving a throwaway
Sud/Pot/controller trio through it exactly as tasks/sud.py's SudTask
would. It's pure CPU-bound iteration (no real time/IO), so a multi-
hour brew simulates in well under a second.
tasks/sud.py: SudTask now takes an optional forecast_estimator and
sends its result ({'Forecast': {'T': ..., 'Theta': ...}}) on the Sud
channel after every successful Save/Load, run in a worker thread via
run_in_executor so the ~100-500ms simulation doesn't stall the other
tasks. server/brewpi.py constructs one with the real server's config
and wires it in; AmbientTemp changes update it too.
client/brewpi_gui.py: the quick naive show_schedule() estimate (drawn
immediately on Load, before the server's simulation finishes) is now
superseded by show_precomputed_course() once the Forecast message
arrives - only while not actively running, so it doesn't fight the
dynamic re-anchored view.
Verified: matches a standalone run of the estimator (177 min for
sude/sud_0010.json) and replaces the old naive 164 min estimate live
within a couple seconds of loading.
On DONE/Stop, the forecast used to revert to show_schedule() seeded
with the *current* temperature - which by then is wherever the brew
ended up (e.g. near the last step's target), so walking the whole
original schedule again from there produced a nonsensical total
("152 min" for a brew that actually took ~250). Now it just leaves the
dynamic view's last frame (the real measured history) up instead.
Recomputing from the current temperature is still correct for a fresh
Load, where no run has happened yet - distinguished via whether
sud_start_time was set before this transition.
TempControllerBase.cooling/set_cooling() forced y=0 in advance of a
Sud ramp-down step; redundant now that the FSM's own COOL state
detects and handles a negative diff itself. Removed from the
controller, SudTask's anticipatory detection (and the Step message's
now-unused "Cooling" field), and demo_sud.py's mirror of it.
gui: the "Cool down" status-bar indicator now reflects the
controller's actual State ("States.COOL") via the TempCtrl channel,
rather than Sud's removed anticipatory flag - renamed
sud_cooling/set_sud_cooling to tc_cooling/set_tc_cooling to match.
New TempControllerBase.enabled (default False) forces y=0 in
process_pid() when off, same mechanism as the existing cooling
override - the controller tries not to drive the heater at all.
tasks/tempctrl.py: new 'Enable' recv command and 'Enabled' broadcast
on the TempCtrl channel. tasks/sud.py: SudTask now enables the
controller on any non-IDLE/DONE Sud state and disables it (forcing
power back to 0) on IDLE/DONE, so automatic mode owns it for the
duration of a run and hands back manual control once it stops/finishes.
gui: new "Enabled" checkbox on the Manual tab's Controller group,
synced from the server; the temp/heatrate setpoint controls are
disabled whenever the controller itself is disabled.
Persists whenever set via the entry, and re-applies automatically on
connect - so it survives a server restart (which would otherwise fall
back to config.json's ambient_temperature) without retyping it.
New btn_pot_reset next to the ambient entry, sends {"Pot": {"Reset":
true}}; tasks/pot.py's PotTask.recv() (previously a no-op) now resets
the plant to its current ambient temperature (Pot.initial()) on that
command.
The button only appears when the server reports its Pot is simulated
(new "PlantSim" field on the System channel, derived from
config.json's previously-unused Controller.plant_name) - resetting a
simulated plant's temperature is meaningless for a real one.
New lineEdit_ambient next to the host/connect row - type a value and
press Enter to send {"System": {"AmbientTemp": ...}}. The server now
has a recv handler for the System channel (previously send-only):
updates the real Pot's ambient temperature and, where the controller
has an internal model (Smith), its model Pots too (new
set_ambient_temperature() on TempControllerSmith), then echoes the new
value back so the status bar label and entry itself stay in sync.
New client/user_config.py - a small JSON-backed key/value store at
~/.config/brewpi/gui.json for GUI preferences that should survive
restarts (distinct from the server's config.json and from sude/*.json
schedules). First use: Save/Load Sud's file dialogs now open in
whichever directory was last used, instead of always starting at cwd.
The status bar now appends "(MM:SS remaining)" to a hold step's text,
kept live by recomputing it on every HoldRemaining message (not just
when the step itself changes) - tasks/sud.py already sent this, the
GUI just wasn't tracking/displaying it.
Nonideal ramp rates and user pauses make the schedule's nominal-rate
estimate drift from reality the longer a brew runs. While a run is in
progress, the forecast plot now redraws every tick: the already-
elapsed part is the actual measured trace (solid line), and only the
remaining steps are projected forward (dashed line) from the live
current temperature, step index, and hold_remaining - rather than
recomputing the whole curve once from t=0 at load time.
components/sud.py's Step/HoldRemaining messages already carried
everything needed; the GUI just wasn't tracking step index or
hold_remaining at all before this. Reverts to the static full-schedule
view once the run stops/finishes.
Progress line switches from red to green; a new horizontal red line
tracks the live measured temperature, drawn at a lower zorder than the
forecast/progress lines so it never visually covers them.
Caption becomes "BrewPi - <name> - <description>" once a schedule
with both is loaded, degrading gracefully to just the name, or plain
"BrewPi", when either is empty.
Window caption becomes "BrewPi - <name>" once a schedule is loaded
(back to plain "BrewPi" when empty/disconnected); the Automatic tab's
forecast title is now "<name> - N min total", replacing the generic
"Estimated course..." text while keeping the time estimate.
On (re)connect, the manual-mode controls (heater power/activate, temp
soll, heatrate soll, stirrer speed/activate) sync themselves once to
the server's current value via setValue()/setCheckState(). Without
blockSignals(), that programmatic update also fires valueChanged/
stateChanged, echoing the value straight back as a command.
For the heater power slider this was the cause of the "stale power >
0 after restart" bug: if the slider happened to sync to a nonzero
power (e.g. reconnecting mid-heat), the echoed {'Power': ...} set
tasks/heater.py's power_soll, which power_soll = max(power_soll,
power_actor) then latches as a floor that never drops back down on
its own - the heater would then stay at least that hot regardless of
what the actual controller/Sud wanted afterwards.
File > New Sud loads an empty schedule (zero steps) onto the server,
same mechanism as Load but with a built-in empty document. Tracks
whether the loaded schedule actually has steps (sud_empty) separately
from whether a Sud is configured at all (sud_loaded) - Start/Pause/Stop
now require both, and the Automatic tab's forecast shows "No sud
loaded" instead of an empty plot while sud_empty.
A step's target temperature applies to the whole step (it's what a
ramp ramps to and what a hold then holds at), not just its ramp phase
- promote it from a nested "ramp": {"temp": ...} to a step-level
"temperature" field, defaulted like descr/grain_mass/etc. Updates all
consumers (Sud._build_step, SudTask, the GUI's forecast estimator,
demo_sud.py) and migrates sude/sud_0010.json and the README.
A ramp step whose target is below the current actual temperature can
only be reached by ambient cooling - the heater can't actively cool.
SudTask now detects this once per ramp phase and calls the controller's
new set_cooling() override (forces y=0, bypassing the FSM) instead of
waiting for its hysteresis-based IDLE transition. The GUI shows a
blinking blue "Cool down" label in the status bar while this is active.
Progression already only advances once theta_ist matches theta_soll
regardless of direction, so no change was needed there.
A step can now carry both 'ramp' and 'hold': it ramps to the target
temp, then holds there for the given duration, instead of needing two
separate schedule entries. Sud.temp_reached() switches such a step
from its ramp phase into its hold phase in place (re-firing the
step-changed callback so SudTask/demo_sud re-apply the hold's stirrer
settings); user_wait_for_continue still applies once the whole step -
both phases - is done.
Merges sud_0010.json's three ramp/hold pairs into combined steps.
Adds a 'System' channel for static, global info that doesn't belong
to any one task - sent once at startup directly from brewpi.py's
__main__ (a client connecting later still gets it via the
dispatcher's global_state replay on subscribe, same mechanism
SudTask's startup Name/Description already relies on).
Moves warp_factor out of SudTask's startup message and into this new
channel, since it's not actually Sud-specific - it's now available
(and the status bar shows it) even without a Sud configured. The
forecast plot's progress-line scaling switches to the same general
Window.warp_factor field.
Displayed via a permanent status bar widget (not cleared by the
existing transient step/schedule showMessage() calls).
Reverts 19dc7a9 ("Fix Sud hold duration units: seconds, not minutes")
per explicit instruction - duration is minutes again. Re-adds the
*60.0 conversion in Sud._advance() (hold_remaining is ticked in
simulated seconds), the matching conversion in the forecast plot's
_estimate_course(), and flips the README wording back.
SudTask now computes warp_factor = dt/interval (the same simulated-vs-
wall-clock split Pot/TempController/Stirrer already use internally)
and sends it once at startup as 'WarpFactor'. The GUI uses it to scale
real elapsed time into the forecast's simulated-schedule time axis,
so the progress line lines up with the actual sim speed instead of
assuming real time.
Also fixes SudTask.tick() to decrement hold_remaining by dt (simulated
seconds) instead of interval (wall-clock seconds) - it was the only
place in the sim using the wall-clock interval for something meant to
track simulated time, so hold steps were taking warp_factor times
longer in real time than their declared duration intends. Without
this fix the GUI's new warp-scaled progress line would race ahead of
the real server during holds.
Adds a thread-safe path from on_sud_changed() (websocket recv thread)
to a modal QMessageBox on the GUI thread via Qt's cross-thread
signal/slot queuing, fired when the State transition lands on
WAIT_USER - which the schedule is already blocked on server-side, so
the dialog just gives it a UI: closing it (OK) sends Confirm.
Deliberately scoped to WAIT_USER transitions, not every UserMessage -
most steps in sude/sud_0010.json inherit the schedule's default
placeholder text ("Put user message here") rather than overriding it,
so popping a dialog for every step would mostly show that placeholder.