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
A user_wait_for_continue step used to stop SudForecastEstimator.estimate()
dead, so the GUI's forecast plot only ever showed the first segment on Load.
Model the wait as a zero-delay auto-confirm instead, so the whole schedule's
projected curve is visible right away; correct that assumption piecewise as
real confirmations actually happen, anchored at the real elapsed time and
temperature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
Previously, the dynamic forecast's projected remainder was fully
recomputed on every step change, and a step requiring user
confirmation was simulated with zero added delay ("count it as
instant for estimation purposes"). Both undercut the actual goal of
the forecast: an honest comparison between real control behavior and
a simulation-based prediction. A forecast that keeps re-anchoring
itself to match reality isn't a useful baseline to compare reality
against, and assuming zero confirmation delay quietly understates the
schedule.
components/sud_forecast.py: SudForecastEstimator.estimate() now stops
simulating at the first step requiring user confirmation instead of
auto-confirming, returning the final SudState alongside the data so
the caller knows whether it stopped there or actually finished.
tasks/sud.py: SudTask now accumulates the forecast across piecewise
segments (forecast_t/forecast_theta). send_forecast() computes only
the first segment, at Load/Save. The real Confirm handler triggers
_continue_forecast_after_confirm(), which computes the next segment
anchored at the real elapsed time and real current temperature -
bridging the unforecastable wait with a flat segment rather than
guessing at its length - and sends the updated, stitched Forecast
(now carrying a Finished flag). The old per-step-change
RemainingForecast recompute is gone entirely, along with
remaining_schedule()/send_remaining_forecast().
client/brewpi_gui.py: SudForecastPlot redesigned around this - the
dashed line is the fixed, piecewise forecast (locked axes, untouched
except when a genuinely new segment arrives via show_forecast());
the solid line is purely the actual measured trace
(show_dynamic(), now only touching that). extend_forecast_while_
waiting() repeats the forecast's last value while the real Sud sits
in WAIT_USER, so the dashed line doesn't just stop, then snaps to the
new segment the moment the real confirmation appends one.
Verified via a raw-protocol test (segment boundaries, the Finished
flag, and real-time stitching across a confirm delay) and visually in
the GUI (locked dashed forecast, "X min so far total" title while
incomplete, solid/dashed comparison rendering with realistic
convergence/divergence).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
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
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
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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>
_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>
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>
Pid.scale(k) set self.k as persistent state, mutated from outside the
class and never reset in reset(), so a stale scale factor could survive
a state-transition reset. Replace it with a plain scale=1.0 argument on
Pid.process(), and have temp_controller.py/temp_controller_smith.py
compute the heat-rate-overshoot compensation factor themselves and pass
it through process_pid() each tick instead of mutating pid_hold's state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
brewpi.py passed a literal 20 as theta_amb to Pot, and
temp_controller_smith.py's two internal Pot models (and every demo's
Pot/TempController instantiation) silently relied on Pot's default of
20 instead. Add a top-level ambient_temperature key to
config.json(.templ/.sim), thread it through brewpi.py into both the
real plant and the Smith predictor's models via a new theta_amb param
on TempController, and make the demos pass it explicitly too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The unconditional p_loss/theta_amb/temp prints (left over from the
heat-loss-units fix) ran every tick, spamming stdout in both the
server and every demo script. Drop them and check off the matching
TODO.md item.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both TODO.md backlogs and the README's architecture section still
described the Kalman-filter/heat-diffusion design that's since been
replaced by the delay-line Pot model and Kalman-free Smith predictor.
Check off the items that rewrite already fixed (heat-loss units,
transport delay vs. single-pole lag, three-Kalman-tuning), note how
the kn/sensor-noise item was resolved differently than proposed, and
add newly-spotted issues: brewpi.py wiring set_model_power
unconditionally (breaks pid_type "Normal"), kalman.py being dead code
in production, stale Kalman/kn keys in the config templates, and
debug print()s left in Pot.process().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tc_constants.py only held States and DEFAULT_THRESHOLDS, both used
exclusively by temp_controller_base.py and its subclasses; fold them
into temp_controller_base.py directly and drop the now-empty module.
Also drop heat_diffusion.py, unused since pot.py switched to the
delay-line model, and the matching dead import in pot.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[Sensor] - replace TempSensorSim's hardcoded k_noise with configurable
temp_offset/variance, accept **kwargs on Max31865 too so both sensors
share a constructor signature usable from TempSensorFactory.create()
[Temp Controller] - low-pass filter the backward-difference heat-rate
estimate (alpha=0.1) in both temp_controller.py and
temp_controller_smith.py instead of using the raw, noisy derivative
[Pot] - drop kn from demo_pot.py's params, matching the unused-field
removal already made to pot.py itself
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[brewpi] - drop trace vars for the dtheta_ist_* attributes removed
when temp_controller_smith.py dropped Kalman filtering
[Pot] - drop unused kn field (never read since the noise-injection
code that used it was dropped)
[GUI] - default WS connection to ws://localhost:8765
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
temp_controller_smith.py still relied on Kalman filtering and Pot's old
get_temperature_intermediate(), both removed by the recent Pot/Kalman
simplification. Rebuild it using two Pot model copies (zero-delay and
delayed) and the same backward-difference heat rate as temp_controller.py,
combined into the classic Smith correction:
theta_ist = theta_model_fast + (theta_plant - theta_model_delay).
Also fix set_model_power() never being called, so the internal model
actually receives the controller's power output, and fill in
demo_temp_controller_smith.py to exercise it end-to-end with an extra
plot of plant vs. fast-model vs. delayed-model temperature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>