Commit Graph
60 Commits
Author SHA1 Message Date
jensandClaude Sonnet 4.6 77e68afef1 Keep plant/temp control inert until a Sud is actually loaded
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
2026-06-22 20:05:33 +02:00
jensandClaude Sonnet 4.6 51add6fd56 Apply a loaded Sud's plant params immediately, not just once a run starts
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
2026-06-22 19:37:20 +02:00
jensandClaude Sonnet 4.6 f1688d3c71 Derive Pot's L/Td from the Sud doc instead of a fixed server default
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
2026-06-22 18:45:07 +02:00
jensandClaude Sonnet 4.6 e0bd3acc26 Remove TracerTask
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
2026-06-22 18:03:48 +02:00
jensandClaude Sonnet 4.6 6c50a819fc Remove the unused tc_trace logging
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
2026-06-22 17:50:46 +02:00
jensandClaude Sonnet 4.6 c1f4bac3f2 Log each Sud run's measured data and forecast to JSON for offline analysis
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
2026-06-22 17:44:08 +02:00
jensandClaude Sonnet 4.6 031b13ca2c Fill the post-confirm forecast gap with the real setpoint, not a mid-ramp snapshot
_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
2026-06-22 17:24:02 +02:00
jensandClaude Sonnet 4.6 84b1607c78 Forecast a Sud schedule through its confirm steps, not just up to the first one
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
2026-06-22 17:12:19 +02:00
jensandClaude Sonnet 4.6 57b86bc075 Make the Sud forecast piecewise, computed once per segment
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
2026-06-22 16:33:40 +02:00
jensandClaude Sonnet 4.6 8c23a2c6ac Clear the Sud Load-rejection Error so it doesn't get replayed as stale
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
2026-06-22 13:20:24 +02:00
jensandClaude Sonnet 4.6 1af8c39926 Surface Sud's Load rejection as a server-driven error, not GUI guesswork
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
2026-06-22 13:01:01 +02:00
jensandClaude Sonnet 4.6 44b1b0c705 Have Sud report its own tick-counted elapsed time to clients
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
2026-06-22 11:21:39 +02:00
jensandClaude Sonnet 4.6 2bdd3203cf Make Sud steps ramp based on the actual temperature gap, not a 'ramp' key
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
2026-06-22 09:35:53 +02:00
jens d91bba1ba2 Use the server's simulated forecast for the dynamic remainder too
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.
2026-06-21 17:24:26 +02:00
jens 567ab80c9c Add a simulation-based Sud forecast, computed server-side
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.
2026-06-21 16:46:52 +02:00
jens 929b8758c4 Remove the ad-hoc cooling override now that COOL handles it natively
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.
2026-06-21 13:48:23 +02:00
jens 3fea29cc58 Add an enable/disable switch for the temperature controller
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.
2026-06-21 12:54:09 +02:00
jens 19be2a7670 Add a "Pot reset to ambient temperature" button, sim-only
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.
2026-06-21 12:15:35 +02:00
jens 194156f244 Move Sud step's target temperature from ramp.temp to step.temperature
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.
2026-06-21 09:47:45 +02:00
jens 3a19e2a9dc Fix Sud cooldown detection crashing the websocket on a real run
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.
2026-06-21 09:29:46 +02:00
jens 1170718c3b Force the heater off and show a "Cool down" indicator during a passive cooldown
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.
2026-06-21 09:10:13 +02:00
jens 9bd9889cb0 Allow a Sud step to combine a ramp and a hold
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.
2026-06-21 09:01:04 +02:00
jens 56de5cf4d6 Show ambient temperature and warp factor in the GUI's status bar
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).
2026-06-21 00:38:31 +02:00
jens 189d1e24d2 Scale the Sud forecast progress by the server's actual warp factor
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.
2026-06-21 00:27:14 +02:00
jens e171892804 Keep the plant/controller model in sync as the Sud advances
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).
2026-06-21 00:05:55 +02:00
jens 13e507e2b5 Rename Sud's Download/Upload to Save/Load throughout
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.
2026-06-20 23:58:21 +02:00
jens 5d884ca3da Wire Pause/Stop to the Sud state machine, with a resumable pause
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.
2026-06-20 23:48:03 +02:00
jens 3289288488 Pre-visualize the estimated Sud course in the Automatic tab
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.
2026-06-20 22:54:34 +02:00
jens 93b5667a64 Add a real-time Plot tab to brewpi_gui, mirroring demo_sud figure 1
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.
2026-06-20 22:31:07 +02:00
jens db57f3305c Add upload/download of sud.json over the Sud websocket channel
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.
2026-06-20 22:14:27 +02:00
jensandClaude Sonnet 4.6 123318ac85 Redesign Sud schedule format: separate ramp/hold keys with defaults
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
2026-06-20 07:20:52 +02:00
jensandClaude Sonnet 4.6 b33f89a569 Adapt Sud/SudTask/demo to the new flat heat/hold schedule schema
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>
2026-06-19 19:09:43 +02:00
jensandClaude Sonnet 4.6 7e131df4ca Add mash-schedule (Sud) automation: temp/rate sequencing + stirrer switching
brewpi.py had a dead -m/--model CLI arg and sude/*.json schedules were
pure data with no code to drive them, despite the README documenting
them as if they were already wired up.

Add components/sud.py's Sud, which loads a sude/*.json schedule and
steps through its Rasten (ramp -> hold -> optional wait-for-user ->
next rest), and tasks/sud.py's SudTask, which drives the temperature
controller's theta_soll/heatrate_soll from the current rest and
switches the stirrer between continuous (ramping) and intermittent
(holding, via stirrSpeedRast/stirrDutyRast/stirrCycleTime) operation.
Exposes progress on a new "Sud" WebSocket channel and accepts
Start/Confirm commands. Enabled via a new optional top-level "sud"
config key (see config.json.templ/.sim).

"Reached target" is detected via abs(theta_ist - theta_soll_set) <
tolerance rather than tc.state == HOLD, since the latter can still
read HOLD left over from the previous rest for one tick after a new
target is pushed (tc.state only updates on the controller's own,
independently-scheduled process() tick).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:54:42 +02:00
jensandClaude Sonnet 4.6 b3d965fec9 Drop debug prints from TcTask's ist-value change handlers
on_temp_ist_changed/on_rate_ist_changed printed every update in
addition to forwarding it over the WebSocket, spamming stdout once
per controller tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:17:19 +02:00
jens a267e4004e - improved tempsensor exception 2021-10-19 11:42:52 +02:00
jens 6e7f8c2c59 - removed AtemperatureSensor::process
- task is AttributeChange
- hareden against Tempsensor exceptions
2021-10-19 11:29:11 +02:00
jens 54f2322c07 - added generic tracer class
- create tracer for TC
- tracer task runs TC-Tracer
2021-10-18 17:57:40 +02:00
jens 236c33d8d6 - removed white space 2021-10-18 17:20:50 +02:00
jens 9b997b8348 - generalized heaters 2021-10-18 14:32:56 +02:00
jens 6dc5e90956 - permit heater to override power 2021-10-12 11:37:24 +01:00
jens 588f87592c - fixed path 2021-10-09 12:03:19 +01:00
jens eb58a39159 - use path for logs 2021-10-09 12:59:09 +02:00
jens 33fd6fa5ab - fixed heater 2021-10-09 12:45:01 +02:00
jens ac6139cae7 - fixed heater 2021-10-09 11:40:29 +01:00
jens fb71f9554c - fixed heater 2021-10-09 12:35:39 +02:00
jens c50f02510c - fixed heater 2021-10-09 12:33:21 +02:00
jens bf019733b2 - added abstarct PID APid
- added PidFactory
- use variable args for factory
2021-10-02 11:10:43 +02:00
jens d3e61ab72f - added heater power 2020-12-20 13:27:52 +01:00
jens a3868b0439 - added vars to tracer
- added result.m for octave
2020-12-20 12:33:52 +01:00
jens cccfd539c6 - added tracer 2020-12-20 12:15:42 +01:00