Callbacks were registered inside the async on_process() coroutine,
meaning they weren't active until the event loop started ticking.
Moving them to __init__ ensures they're wired up at construction time.
Also removes remaining debug prints from the affected task files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Both handler loops caught all exceptions, printed e, and broke — hiding
errors entirely. Let exceptions propagate naturally; the parent handler's
FIRST_COMPLETED logic already handles task cleanup on disconnect.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Same pattern as set_model_power: these methods only existed on the Smith
controller, forcing five hasattr guards at call sites. No-op stubs on the
base class give all controllers a stable interface; Smith overrides them.
Remove the now-redundant hasattr guards.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
The Normal controller had no set_model_power(), causing callers to
guard with hasattr() instead of relying on a stable interface. Adding a
no-op stub to the base class means all controllers have the method;
TempControllerSmith overrides it to actually update its internal model.
Remove the now-redundant hasattr guards at both call sites.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
Notes Mainsail (Vue 3 + TypeScript + Vuetify, uPlot for live charts) as
the reference point for a future rewrite of app.js's hand-rolled DOM
wiring and the still-missing Automatic/Plot tab charts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
Mirrors client/brewpi_gui.py's actionPause: btn-sud-pause sends
{"Sud": {"Pause": true}} and is only enabled while actually running
(not idle, not already paused), matching update_sud_actions()'s
enabled/disabled logic - Start already doubles as Resume while paused,
so no separate Resume control is needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
New/Load/Save/Start/Stop now live in #sud-controls inside
#connection-bar, visible regardless of which tab is active, instead of
a standalone panel above the tabs. Drops the now-unused .button-row
rule along with it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
Ports show_user_message() from client/brewpi_gui.py: a plain <dialog>
pops with the step's user_message the instant WAIT_USER is newly
entered (tracked via a prevState comparison in onSudChanged()'s State
branch), and sends {"Sud": {"Confirm": true}} on the dialog's close
event - covers both the OK button and Escape dismissal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
Ports on_action_sud_new/_save/_load() and on_action_sud_start/_stop()
from client/brewpi_gui.py: Load/Save stand in for the native file
dialogs with <input type="file">/FileReader and a Blob/<a download>,
New sends the same hardcoded empty-doc Load, and Start/Stop are gated
by updateSudActions() mirroring update_sud_actions()'s enabled/disabled
logic. Also fixes the status line's step number being one below what
the Progress tab's plates show for the same step (same bug just fixed
in the desktop client).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
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
A new HTML/CSS/JS client, added next to client/brewpi_gui.py rather
than replacing it, speaking the exact same WebSocket pub/sub protocol
- no server-side protocol changes needed. server/brewpi.py gains a
--http-port (default 8080) stdlib http.server.ThreadingHTTPServer,
in its own daemon thread and deliberately decoupled from the existing
asyncio WebSocket server, serving web/ statically.
v1 covers the Manual tab (direct heater/stirrer/controller control)
and the new Progress tab - the two most actionable surfaces, neither
needing a charting library. web/app.js ports the relevant logic from
brewpi_gui.py directly: components/sud.py's _build_step()/
_merge_defaults() for resolving the raw schedule doc, and the
StepPlate/_update_step_plates()/update_status_step_label() logic
behind the Progress tab and status line.
Deliberately deferred rather than stubbed (see web/TODO.md for the
full parity backlog against the Qt GUI): Sud control actions (Start/
Pause/Stop/Confirm), the Automatic tab's forecast plot and the Plot
tab's live strip charts, Sud file management, and auth (matching the
WebSocket server's own existing lack of it).
No new Python dependencies - functools/threading/http.server are all
stdlib.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
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
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
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
Both are run-mode knobs (how fast to drive this particular process),
not config.json material (a plant/hardware description that stays the
same regardless of how a run happens to be invoked) - --dt and
--sim-warp-factor, defaulting to 1.0 each (real time, 1-simulated-
second ticks) unless a dev/test run asks for a faster warp explicitly.
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.
heater_name/sensor_name/plant_name were three independent config keys,
each picked via its own factory - in practice sim and real hardware
are never actually mixed and matched, so this could (nonsensically)
disagree, e.g. a real heater paired with a simulated sensor.
Add PlantFactory (components/plant/plant_factory.py): plant_name alone
now builds the whole rig together. "sim" gets HeaterSim/Pot (the
modeled plant, as before)/TempSensorSim. Anything else gets
HeaterHendi/PotReal/TempSensor_max31865 - PotReal is a new, deliberately
unmodeled Pot for the real, physical kettle (components/plant/pot_real.py):
the real temperature comes straight from the real sensor, not from a
model, so it just accepts and ignores set_plant_params()/
set_ambient_temperature()/initial() and reports no temperature of its
own, satisfying PotTask/SudTask's interface with nothing to actually
simulate.
heater_name/sensor_name are gone from config.json.templ; server/brewpi.py
delegates to HeaterFactory/TempSensorFactory lazily from inside
PlantFactory, same as before, so real hardware's spidev/pyserial deps
still aren't needed just to import the module.
PotTask.on_process() only calls Pot.process() once Pot.is_configured()
- which required plant params (M/C/L/Td) that, by design, only ever
came from a Sud's own doc via SudTask.apply_plant_params(). So with no
Sud loaded (or the temp controller disabled), the simulated Pot just
sat inert: manually driving the heater never moved its temperature at
all.
EMPTY_SUD's pot_mass/pot_material defaulted to 0/None, which would
have made a zero-mass plant (a ZeroDivisionError in Pot.process()) -
they're actually the physical kettle's own properties, not really
per-recipe (every sude/*.json so far uses the same pot), so default
them to that real kettle's values instead. server/brewpi.py now seeds
the Pot with derive_plant_params() from the not-yet-loaded Sud right
away, same as ambient temperature already was - a real Sud's own doc
still overrides these the moment one is loaded.
Now that doubleSpinBox_pot_temp carries the target temperature, the
old "Pot reset to ambient temperature" label no longer described what
the button does and reserved far more width than a short label needs.
Shortened to "Set" and repositioned immediately to the right of the
Pot [°C] label/spinbox it applies, with both shifted left to fill the
space the long label used to need.
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.
SudForecastEstimator.estimate() was feeding tc.get_power() straight to
the plant (pot.set_power(heater_max_power * y)), bypassing the duty-
cycling across the heater's discrete power steps that the real run's
HeaterTask/device chain always applies (tasks/heater.py). That let
pid_heat's own oscillatory tendency reach the plant undamped, so the
forecast showed a violent on/off staircase that no actual brew run
ever produces - confirmed by comparing against real sud_log output,
which stays smooth because the real actuator chain duty-cycles the
same PID output down first.
estimate() now duty-cycles tc.get_power() across the heater's own
power steps (mirroring HeaterTask's PWM logic, duplicated rather than
imported to keep components/ from depending on tasks/) before handing
it to the plant, and also feeds the resulting discretized power back
into the Smith predictor's internal model via set_model_power(), same
as the real wiring in brewpi.py does. The constructor takes the
heater's get_powers() list and the configured sim_warp_factor (needed
to convert HeaterTask's 10-real-second PWM period into simulated
ticks) instead of a bare max power.
is_configured() (Smith's model plant params, only ever set once a Sud
loads) gates TcTask's call to tc.process() - the only place theta_ist/
heatrate_ist/heatrate_soll get updated and broadcast. Right after
server start, before any Sud has ever loaded, that left them frozen at
their __init__ defaults forever, so the GUI's Ist Temp/Rate and Soll
Rate displays never showed anything on connect. Mirror the raw sensor
reading through instead, with rates at 0 since there's no controller
output yet.
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
Documents the recent SudTask.send_forecast()/recv() changes (anchored at
get_theta_ist_set(), re-anchored on every fresh Start) and the two
spurious-divergence fixes: client/brewpi_gui.py's forecast_history t=0
seeding, and the hardcoded last_theta_ist=20 bug in both TempController
variants.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
last_theta_ist was hardcoded to 20 in __init__, used to compute the very
first process() tick's heatrate as (theta_ist - 20)/dt*60 - a wildly bogus
spike whenever the real starting temperature wasn't actually 20 (e.g.
1200 deg/min for a 40-degree start). The exponentially-smoothed
heatrate_ist (alpha=0.1) takes several ticks to recover from that, during
which it distorts the PID's rate-error term and visibly throws off the
heating trajectory.
This was invisible everywhere this assumption was implicitly tested,
since ambient (and every start_theta used) was always exactly 20. It
surfaced once SudForecastEstimator.estimate() started being called with a
real, non-20 start_theta: it builds a fresh TempController on every call,
so its first tick always hits this bug fresh - unlike the real controller,
which only ever goes through this once near server startup and has long
since self-corrected (last_theta_ist = theta_ist runs every tick) by the
time any forecast comparison matters. That's exactly what showed up as a
forecast-vs-actual gap during the initial ramp.
last_theta_ist now starts None; process() treats the first tick's
heatrate as 0 (no real history yet) instead of computing it against a
hardcoded, usually-wrong baseline.
Verified: ambient=20 (the only case ever exercised before) is byte-
identical to before the fix. ambient=40/start=40 - reproducing the old
hardcoded-20 behavior side by side - shows the old version visibly lagging
~12 minutes behind the fixed one during the initial ramp before they
converge, matching the reported gap exactly.
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
send_forecast() was defaulting to a cold start at ambient (SudForecastEstimator.
estimate()'s default when start_theta isn't passed), so the forecast's t=0
never matched whatever temperature the pot actually was at - understating
how long the first ramp would really take whenever it wasn't already cold.
Anchoring at tc.get_theta_ist() seemed like the obvious fix but isn't
reliable right after Load: that's only ever updated inside process(), which
a model-based controller (Smith) doesn't run until its plant params are
configured - which now only happens once a Sud is loaded (see the
"inert until loaded" change) - so theta_ist can still be sitting at its
never-updated __init__ default of 0 the instant send_forecast() reads it.
Added TempControllerBase.get_theta_ist_set() (mirroring the existing
get_theta_soll_set()) - the raw sensor reading, updated the instant a
reading comes in regardless of whether process() has ever run - and used
that instead.
That still leaves a gap between Load and Start: the real temperature can
drift (time passing, manual heating via the Manual tab) between loading a
schedule and actually starting it, leaving the forecast anchored to a
now-stale temperature. recv()'s 'Start' handler now re-sends the forecast,
freshly anchored to the real temperature at that moment, on every fresh
start (not a Pause->resume, which keeps the already-established forecast
rather than discarding it mid-run).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
A fine enough dt over a multi-hour brew can produce a single Forecast
message several MB in size - large enough to exceed the websockets
library's default 1 MiB max_size and get the connection closed outright
(code 1009) right after Load. Confirmed by reproducing with dt=0.1: the
276KB message at dt=1 was safe, but the equivalent ~2.7MB at dt=0.1
reliably disconnected the client.
SudTask._send_forecast() now thins T/Theta to at most MAX_FORECAST_POINTS
(1000) via simple decimation before sending - self.forecast_t/
forecast_theta themselves stay at full simulated resolution, since
_continue_forecast_after_confirm()'s bisect-based truncation needs exact
t-value matches, and SudLogTask's logs/forecast_*.json wants full
fidelity. 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.
Verified against a live server+client over the real WebSocket connection
with the dt=0.1 config that originally triggered the disconnect: 4/4
clean runs, forecast arrives capped under 1000 points, and the rendered
plot is visually identical to the un-downsampled version.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
config.json.templ's component names already default to "sim" for every
backend (sensor/heater/stirrer/plant), making the separate .sim template
mostly redundant - it only differed in dt/sim_warp_factor/PID gain tuning,
which .templ now adopts directly. Removes config.json.sim, updates
brewpi.py's CLI args (-m/--logdir replaces the unused -d/--model/--sim
flags), and refreshes the README/TODO references that pointed at the now-
removed file.
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
server/brewpi.py no longer pre-configures the real Pot's or Smith's model's
plant params at startup - there's no longer any generic baseline at all,
since the only source of truth is now a loaded Sud's own doc (applied via
tasks/sud.py's SudTask.apply_plant_params(), on Load and on every step).
Ambient temperature and PID gains stay configured at startup, since
they're independent of any Sud (global setting / hardware tuning, not
doc-derived).
Add Pot.is_configured() (already existed)/TempControllerBase.is_configured()/
TempController(Smith).is_configured(), and have tasks/pot.py's PotTask and
tasks/tempctrl.py's TcTask skip process() while not yet configured instead
of letting it raise - so plant and temp control are genuinely inert (not
crashing) until a Sud is loaded. "Normal" has no plant-model dependency,
so it stays active regardless.
Also refreshes README.md: removes stale tracer.py/.mat references (already
removed in an earlier commit), documents SudLogTask's JSON logs, the doc's
L/Td fields, and this inert-until-loaded behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk