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
on_step_changed() only re-applies plant params (pot mass/material, L, Td,
grain/water mass) once Sud.step actually changes to a real step - which
doesn't happen on Load (Sud.load() resets step to None), so the controller
kept using whatever the previously loaded Sud (or the generic startup
baseline) had until Start. SudTask.recv()'s Load handler now calls
apply_plant_params() against the new schedule's first step right away, so
the controller already matches the expected plant the moment a Sud is
loaded.
Also replaces server/brewpi.py's hardcoded DEFAULT_PLANT_PARAMS with a
baseline derived from Sud's own (still-unloaded) generic defaults
(EMPTY_SUD's pot_mass/L/Td), used only for the brief startup window before
any real Sud is ever loaded - numerically identical to the old constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
sud.start() synchronously fires the first step's on_step_changed callback
before it even returns (assigning Sud.step triggers callbacks immediately),
which sets M/C/L/Td from this doc's own derive_plant_params() before any
pot.process()/tc.process() tick ever runs - so the constructor's
plant_params was being set, then immediately discarded, unused. theta_amb
stays, since it's not part of the Sud doc at all.
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
TempController(Smith).__init__ no longer takes model_params/theta_amb -
set_model_plant_params() and the existing set_ambient_temperature() must be
called instead (mirrors components/plant/pot.py's own Pot constructor
refactor). temp_controller.py's now-pointless model_params/theta_amb
constructor placeholders (kept only for "compatibility" with Smith) are
dropped too.
Both TempController variants now raise a clear RuntimeError from process()
itself if set_params() wasn't called, instead of letting it surface deep
inside Pid.process() as an opaque "'NoneType' object is not subscriptable".
Smith additionally checks its internal models via Pot.is_configured() (new)
and raises if set_model_plant_params()/set_ambient_temperature() weren't
called either.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Mirrors components/pid/pid.py's own Pid.set_params() pattern: params/
thresholds start out None, and set_params() configures pid_hold/pid_heat/
pid_cool from them. process()/process_fsm() fail naturally (TypeError on
None) if called before set_params() - same as Pid.process() already does -
rather than a new bespoke check. Updates all callers (server/brewpi.py,
SudForecastEstimator, and the pid/sud demo scripts) to call set_params()
right after construction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Pot.__init__ now only takes dt; set_plant_params() and set_ambient_temperature()
must be called before process(), which now raises RuntimeError if either is
missing instead of silently computing on whatever the constructor happened to
default to. set_ambient_temperature() only seeds the plant's starting
temperature the first time it's called, so changing ambient mid-run still
can't clobber an in-progress simulated temperature. Updates all callers
(server/brewpi.py, TempController(Smith)'s two internal models,
SudForecastEstimator, and the plant/pid/sud demo scripts) to call the setters
right after construction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Its brewpi.mat output had no consumer - the GUI never subscribed to the
'Tracer' channel, and SudLogTask's run-scoped JSON logs now cover the same
sensor/heater/tc data for actual analysis. Drop the task, its DT_TASK_TRACER
interval, and the wiring in server/brewpi.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
trace_tc (tasks/tracer.py's TracerTask wired to a generic Tracer instance)
was logging to tc_trace.mat, but its var_list was empty, so it only ever
wrote a bare tick counter - no actual tc data, despite the name. Drop the
dead plumbing and the now-unused tracer.py module entirely; the real,
populated brewpi.mat logging (sensor/heater/tc data, built directly inside
TracerTask) is untouched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Add SudLogTask: starts a fresh in-memory buffer the moment a run actually
starts (Sud.state leaves IDLE/DONE) and writes it out whole the moment it
ends (DONE, or aborted via Stop) - log_<date>T<time>_<sud-name>.json holds
the same six signals the GUI's Automatic tab plots live (temp/rate ist+soll,
power set+eff) with per-sample timestamps; forecast_<date>T<time>_<sud-name>.json
holds the final, most-corrected forecast curve from the same run, sharing
the run's date-time token so the two pair up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
_continue_forecast_after_confirm()'s bridge was repeating forecast_theta[-1]
to cover the real-world wait, but that's whatever value the simulation
happened to log the instant WAIT_USER tripped - often still mid-ramp, not the
step's actual hold target. The real controller stays enabled and actively
holding throughout WAIT_USER, so capture its setpoint (tc.get_theta_soll_set())
in recv() before calling Sud.confirm() - confirming synchronously pushes the
next step's own target onto it - and use that to fill the gap instead.
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
Rewrites the "Forecast vs. actual duration" section to match the
previous commit: no more naive abs(delta)/rate placeholder, no more
per-tick re-anchoring of the projected remainder - the forecast is
computed once, piecewise per segment, deliberately left alone as a
fixed comparison baseline. Documents the Finished flag, the
real-confirm-anchored segment stitching for steps requiring user
confirmation, and extend_forecast_while_waiting()'s flat-repeat
display in the meantime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
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
SudForecastEstimator.estimate() builds a fresh Pot/temperature
controller per call and never set its initial theta_soll, leaving it
at TempControllerBase's default of 0. For the upfront, full-schedule
Forecast this was harmless - the first real step always carries its
own 'temperature', overwriting it instantly. But the dynamic
RemainingForecast is recomputed from wherever the live run currently
is, and since ramping is no longer gated by a 'ramp' key, the
remaining schedule's first step often has no 'temperature' of its
own (e.g. resuming from a WAIT_USER pause into a hold-only step) - a
fresh tc has no persisted target to inherit it from like the real
run's tc does, so it span chasing 0 degrees, hit MAX_TICKS, and
produced a ~200,000-point result instead of the expected ~10,000.
That oversized payload (~6MB) is what was actually tripping
websockets' default 1MB max_size and disconnecting clients with
"1009 (message too big)" - not the GUI threading issue fixed
separately. Verified live: the same remaining-schedule case that
previously hit MAX_TICKS (200001 points) now resolves in 8348, and a
full driven run (load, start, auto-confirm both WAIT_USER steps,
completion) produces no oversized messages.
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
Captures the ramp/hold/user-confirm phase order and a couple of
non-obvious clarifications from a design discussion (ramp is gated
purely by the schedule's 'ramp' key, not actual-vs-target temp;
WAIT_USER is a distinct phase, not an extended hold), plus one open
gap: pure-hold steps never re-set the controller's target temperature.
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.
Two compounding problems, found by actually running it:
- pot_mass=1/water_mass=1 (~2kg) is far lighter than the mass the real
PID gains are tuned for (~30kg, matching the real recipes) - same
heater power swings a 2kg pot far faster than the gains expect,
causing massive overshoot (24 -> 37.5 instead of holding at 24).
Fixed by using the same realistic mass as sud_0010.json/sud_0020.json.
- The overshoot then needed a passive cooldown back down to the next
step's target - independent of any declared rate, governed by the
plant's L/C time constant (~350 min here), regardless of mass.
Removed the (now unnecessary, since the overshoot is gone) explicit
cooldown step too.
Predicted/actual duration drops from ~408 min to ~18-21 min - verified
both via components/sud_forecast.py's estimator and a live run against
the real server (reaches DONE, no controller chattering).
- Architecture diagram: add client/user_config.py and
components/sud_forecast.py; note the pid/ controllers' 4-state FSM
(IDLE/HEAT/HOLD/COOL) and master enabled switch; expand the GUI
client's description (ambient temp, controller enable, pot reset,
Sud New/Load/Save/Start/Pause/Stop, forecast plot).
- Mash schedules: document Start's restart-from-finished behavior,
Pause's freeze-and-resume semantics, and the temperature
controller's enabled lifecycle (SudTask owns it during a run, off by
default otherwise, GUI checkbox for manual mode).
- Fix sude/'s "heat"/"hold" wording to the actual "ramp"/"hold" keys;
add matplotlib (demos-only) to the server requirements list.
- Logging & analysis: mention the text log alongside the .mat traces.
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.
HoldCool=0.1 was inherited from the pre-COOL-state era, where it meant
"eagerly give up active holding and coast" - harmless, since coasting
had no side effects. COOL is a real, actively-controlled state now
(its own pid_cool, reset on every entry), so a threshold this tight
relative to a real heater's discrete power steps (or even just sensor
noise) caused the system to flip in and out of COOL nearly every tick
once resting near a setpoint - resetting both pid_hold's and
pid_cool's integrators each time and never letting either actually
converge.
Confirmed via a full multi-task simulation (real Pot/Sensor/Heater/
TempCtrl tasks, not a simplified loop) of sude/sud_0010.json: 44+
TempCtrl state changes and the run not settling within hours at
HoldCool=0.1, dropping to 8 state changes and a clean 186.5 min finish
at HoldCool=1.0 (matching the other thresholds). This was the dominant
cause of real brews taking far longer than their nominal schedule
duration - not just a forecasting inaccuracy, an actual control-
quality bug.
6 steps covering a plain ramp, a wait-for-confirm step, a plain hold,
a combined ramp+hold (up), a combined ramp+hold (down, exercises
COOL), and a final confirm - all small temperature deltas and 1-minute
holds so a full run finishes in well under a minute at typical warp
factors, for quickly cycling through Load/Start/Pause/Stop/restart
scenarios without waiting through a real brew.
start() only allowed a fresh run from IDLE, so once a brew reached
DONE, clicking Start again (the GUI correctly re-enables it once not
running) was silently a no-op. DONE now restarts the same way IDLE
does.
Documents why a brew commonly takes ~250-270 min against a ~160 min
static estimate: the PID cascade needs real time to spin up and settle
within the tight 0.2°C "reached" tolerance, a roughly per-step-constant
lag that accumulates across every ramp/hold transition. Found and
confirmed by a live measured run on the real server.
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.
process_pid() was advancing pid_rate and pid_cool unconditionally on
every tick regardless of which one actually drove y. While COOL had
pid_cool driving, pid_rate kept silently integrating against the same
(very negative) heatrate_err with no way to act on it, building a
large stale windup. The moment the FSM returned to HOLD (which only
reset pid_hold), y read straight from that saturated pid_rate instead
of a fresh value, so the heater stayed off well past the target
before the windup finally unwound - an undershoot of about 1 degree
in scripts/demos/pid/demo_temp_controller.py's last (cooling) step.
Now only the PID currently driving y is process()'d each tick, and
pid_rate is also reset on IDLE->HOLD and COOL->HOLD (it was already
reset on the transitions into HEAT/COOL). Undershoot drops to ~0.06
degrees, in line with normal PID settling.
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.
IDLE conflated two unrelated things: "controller disabled" and "needs
to cool, which this heat-only actuator fakes via zero output." Split
them apart:
- IDLE now means disabled only - the FSM forces it whenever
enabled=False, regardless of the temperature gap, and process_pid()
zeroes y for it same as before.
- New COOL state (mirroring HEAT) takes over the negative-diff case,
with its own pid_cool (separate "Cool" gains, alongside Hold/Heat)
instead of reusing pid_rate. Its output can legitimately be negative
- it's the actuator (tasks/heater.py's actor(), already max(0, ...))
that clamps it to 0 because *this* plant (Pot) can only heat. A
plant with real cooling capability could one day honor it directly.
- Thresholds renamed accordingly (HoldIdle->HoldCool, HeatIdle->
HeatCool, new CoolHold/CoolHeat); config.json/.sim/.templ and the
hand-rolled ctrl_params in scripts/demos/pid/ and demo_sud.py
updated with a "Cool" params section (mirrors "Heat" for now, since
there's no real cooling actuator to tune against yet).
Also fixes a regression in demo_sud.py/the other PID demos: none of
them ever called set_enabled(True), so since enabled defaults to
False they never drove the heater at all - only caught because this
change's demo_sud.py re-run got stuck in RAMPING forever.
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.
The codebase logs everything via plain print() scattered across
tasks/ws/components - rather than touch every call site, stdout/stderr
are now duplicated (Tee) into logs/brewpi.<timestamp>.log alongside
the existing tracer .mat files, line-buffered so a crash doesn't lose
the tail of the log.
Pure rename (brewpi.py, brewpi.sh, requirements.txt, __init__.py) plus
the README's path references - no code changes. "server/" describes
the directory's role rather than duplicating the project name already
in brewpi.py itself.
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.
Several sude/*.json schedules can exist on disk; only one runs at a
time, chosen by a client Load. Sud no longer takes a path at all - it
starts empty (EMPTY_SUD) and the server config no longer names a
specific schedule file, removing the implicit link to sud_0010.json
in particular. Reading/writing sude/*.json is now entirely the
client's job (e.g. the GUI's Load/Save file dialogs); the server's Sud
only ever holds whatever was last Load()ed, in memory, until replaced
or the server restarts.
Updates demo_sud.py/demo_sud_save_load.py to construct an empty Sud()
and load() a schedule explicitly, matching the new constructor.
load() used to immediately persist whatever was loaded back to
self.path - the file the server was originally configured with. This
meant any Load (including "New Sud"'s empty schedule) silently
clobbered the currently-configured brew file, with no way to get it
back. load()/save() now operate purely on an in-memory copy; a client
that wants to persist a schedule does so explicitly itself (the GUI's
Save already prompts for a destination - this just removes the
hidden, automatic disk write load() was doing underneath it).
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.
Found while live-testing the previous fix: brewpi.py unconditionally
wired heater.power_set to tc.set_model_power, but "Normal" has no
internal model and thus no such method - same hasattr guard already
used elsewhere (tasks/sud.py's apply_plant_params) for this.
PidFactory.create() calls both controllers with the same (dt, params,
model_params, theta_amb=...) signature, but TempController ("Normal")
only accepted (dt, params) - any config using pid_type "Normal" would
crash with a TypeError on startup. It has no internal model, so just
accepts and ignores the extra arguments.
get_theta_ist() can return numpy.float64 (Pot's transport delay line
is a numpy array internally), so the cooldown comparison produced a
numpy.bool_ instead of a plain bool. json.dumps() can't serialize that,
which silently killed the websocket connection's send loop the moment
a ramp step started - found by actually driving the feature through a
live server+GUI run instead of only unit-style tests with a directly
constructed controller.
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.
SudTask.on_step_changed() set the controller's target temp/rate on
every step, but never touched the real Pot's or the Smith predictor's
internal model's thermal mass (M/C) - those stayed frozen at
brewpi.py's startup DEFAULT_PLANT_PARAMS for the whole brew, even
though grain_mass/water_mass change per step (malt going in, water
boiling off) and demo_sud.py already recomputes them every step via
derive_plant_params(). Production SudTask just never got the same
treatment.
Adds SudTask.apply_plant_params(), called alongside the existing
theta_soll/heatrate_soll push, mirroring the demo. Needs the real Pot
now, so SudTask takes a `pot` constructor arg; set_model_params() is
only called if the configured controller actually defines one (the
"Normal" pid_type doesn't).
Renames everywhere the concept appears: Sud.download()/upload() ->
save()/load(), the 'Download'/'Upload' websocket messages ->
'Save'/'Load', the brewpi_gui menu actions, handlers and
sud_download_path -> sud_save_path, and the demo script. Behavior is
unchanged - this is naming only, from the client's perspective (save
to / load from a local file) rather than the server's.
components/sud.py: adds SudState.PAUSED. pause() freezes RAMPING/
HOLDING (the hold countdown and ramp-reached checks both no-op while
PAUSED, since they're gated on the exact state they froze); start()
now doubles as resume when paused. stop() aborts back to IDLE from
any in-progress state, reusing the same reset logic as __init__/
upload() (factored into _reset_run_state()).
tasks/sud.py: maps 'Pause'/'Stop' messages to the new methods, and
turns the stirrer off on IDLE (not just DONE) so stop() actually
silences it instead of leaving the last step's stirring running.
client/brewpi_gui.py: wires the Pause/Stop toolbar actions, extends
update_sud_actions() so Start doubles as Resume and Stop stays usable
while paused, and freezes the forecast plot's progress line for the
duration of a pause (tracked via accumulated paused time) instead of
letting it drift ahead of actual progress.
on_sud_changed() now handles 'Step' messages, showing the step's
description (and clearing it once there's no active step), and tracks
IDLE/DONE <-> running transitions to time a red vertical progress line
on the Automatic tab's forecast - elapsed real time since the brew
started, plotted against the forecast's nominal-schedule time axis so
you can see how actual progress compares to the plan.
Sends {'Start': True} on the Sud channel, which SudTask.recv() already
maps to sud.start(). Only reachable while enabled (loaded and idle),
per update_sud_actions().
Start is enabled only once a schedule is loaded and idle; Pause/Stop
only while it's actually running (RAMPING/HOLDING/WAIT_USER). All
three stay disabled until something arrives on the Sud channel (which
only happens if a SudTask is actually configured server-side), and
get disabled again on disconnect since nothing reported over a dead
connection can still be trusted.
Also drops actionStop's leftover wiring to disconnect() - now that
Stop is meant to stop a running brew, it shouldn't tear down the
websocket connection instead.
ws_client.disconnect() blocks until the close handshake completes
(observed ~10s when the server doesn't ack promptly), and was being
called straight from the Connect/Disconnect button's click handler -
joining that wait on the GUI thread, so the click appeared to do
nothing until the timeout passed. Run it off a throwaway thread
instead; on_ws_connect_changed() already flips the button once the
disconnect actually finishes.
MessageDispatcherSync gains an optional on_connect_changed(bool)
callback, fired from the websocket client's background loop once the
connection is actually live/lost, so the button only flips state on a
real connection change rather than optimistically on click.
Start/Pause/Stop on the toolbar were never about the websocket
connection - Start now does nothing until that's wired to its own
purpose, and connecting is its own explicit action next to the host
text entry.
Adds a static forecast plot to the (previously empty) Automatic tab,
showing the planned temperature course derived from a schedule's
declared ramp rates and hold durations alone (no plant/controller
model - just how long the schedule itself says each step takes).
The client now requests the schedule via Download right after
connecting, routing the reply to the forecast instead of a save
dialog since sud_download_path is only set for the user-initiated
download. SudTask also echoes the just-applied document back after a
successful Upload, so editing the schedule refreshes the forecast
without an extra round trip.
Reformats it to json.dump()'s standard 2-space-free, ensure_ascii
output (from exercising the new upload feature against the real
schedule) and adds the matching downloaded copy. No schedule content
changed.
Drop dark_background in favor of a plain white figure, matching
demo_sud's blue/red ist/soll color convention. Add faint major/minor
gridlines, drop the top/right spines, and use label_outer() so only
the bottom subplot repeats x tick labels across the shared time axis.
Embeds a dark-themed matplotlib canvas (3 stacked strip charts: theta
ist/soll, heatrate ist/soll, heater power_set/power_eff) in a new Plot
tab, sampled once per second from the latest websocket-received values.
HeaterTask now also emits a PowerSet message (the pre-discretization
target power) alongside the existing Power (effective) message, since
the GUI had no way to see the set-point side of figure 1's bottom plot
otherwise.
Adds File menu actions to save the running Sud's schedule to a local
sud.json and to replace it from one, via the Sud websocket channel's
new Download/Upload messages. File dialogs are opened up front on the
GUI thread; the actual read/write happens off the websocket recv
callback, which runs on a worker thread and must not touch Qt itself.
main_window.py is regenerated from brewpi.ui via pyuic5 5.15.11, which
also picks up an unrelated pre-existing drift (the default URI had
fallen out of sync with the .ui source).
Sud.download() returns the on-disk document; Sud.upload() validates,
persists, and resets the schedule, refusing mid-brew or malformed input
so the current schedule is never left half-applied.
Both blocks were nearly identical (config.json.sim's differing M and
unused gain key being the only divergence), so there's no need for
per-config tuning here. DEFAULT_PLANT_PARAMS in brewpi.py now feeds
both the real Pot plant and the temperature controller's
Smith-predictor model. No demo scripts read these config keys, so
nothing else needed updating.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
grain_mass and water_mass now live on each step (defaulted from
default.step) instead of being fixed for the whole brew, since both
change over a mash (malt going in, water boiling off).
Sud.derive_plant_params() takes them as arguments so it can be
recomputed per step; the demo re-applies the resulting M/C to both the
real plant and the controller's Smith-predictor model on every step
change via the new Pot.set_thermal_params()/
TempController.set_model_params().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
Steps now declare a "ramp" or "hold" key instead of a flat "type", and
unspecified fields (including nested stirrer settings) fall back to a
new top-level default.step structure. Stirrer config is now
interval_time/on_ratio, mapping directly onto AStirrer's cycle
time/duty cycle instead of separate on/off durations. Update
components/sud.py, tasks/sud.py, the Sud demo, sude/sud_0010.json, and
the README to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
Add Sud.derive_plant_params(), which combines pot_mass/pot_material/
grain_mass/water_mass into a single lumped (mass, specific heat) pair
using approximate specific-heat constants for water, grain, and (by a
small material lookup table) the pot itself. demo_sud.py now builds
its plant_params from this instead of hardcoded C/M values, keeping
L/Td as the only manually-tuned plant parameters.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Now that duration is correctly read as seconds, bump the sample
schedule's hold steps from the placeholder 30-40s to more realistic
200-400s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_advance() multiplied "duration" by 60, treating it as minutes;
stirrer_speed (percent) and stirrer_time_on/off (seconds) were already
handled correctly with no conversion. Also fix the README's "duration
minutes" wording to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sude/sud_0010.json moved from a list of combined ramp+hold+wait
"Rasten" with global stirrer constants to a flat "schedule" list of
explicit {"type": "heat"|"hold", ...} steps, each carrying its own
stirrer_speed/stirrer_time_on/stirrer_time_off and an optional
user_wait_for_continue (+ user_message).
- components/sud.py: Sud now tracks the current `step` instead of
`rast`; "heat" steps enter RAMPING (advanced externally via
temp_reached()), "hold" steps enter HOLDING and count down
`duration` minutes (0 if omitted). Either step type can pause in
WAIT_USER via user_wait_for_continue, surfaced through the new
user_message attribute.
- tasks/sud.py: SudTask only pushes theta_soll/heatrate_soll on
"heat" steps, converts each step's stirrer_time_on/off into a
duty_cycle/cycle_time on every step change (replacing the old
state-based RAMPING/HOLDING stirrer switching), and broadcasts
user_message changes. Stirring is now fully schedule-driven instead
of implicitly stopped on WAIT_USER/DONE (DONE still stops it, since
there's no step left to read settings from).
- scripts/demos/sud/demo_sud.py: mirrors the same step-driven wiring.
- README's Mash schedules section rewritten for the new schema.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop the now-stale "config.json.templ/.sim still carry dead
Model.kn/Plant.kn keys" note (they were deleted), note that Pot now
always starts at theta_amb (no separate initial-temp param) and that
dropping the gain (efficiency) factor entirely is a partial realism
regression, not just a fix. Point the config-validation item at the
gain key that's still dead in config.json.sim. Also update demo_sud.py
to match the Pot params Pot.__init__ actually reads.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[Pot] - removed the gain (efficiency) factor and the separate
"theta" initial-temperature param; Pot now starts at theta_amb and
set_power()/get_power() operate on p_in directly. Added
set_ambient_temperature() for live ambient updates. Dropped the
now-unused "theta"/"gain" keys from Model/Plant in config.json.sim
and config.json.templ (also fixes a JSON syntax error in
config.json.templ: trailing commas left over after removing "gain"
made Model/Plant fail to parse).
[Sensor] - fix missing space in TempSensorSim.temperature()'s
offset/variance expression (cosmetic, no behavior change).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>