Rename "1. Rast"/"Glucose-Rast" to "Protease-Rast"/"Maltose-Rast" to
match what each rest actually targets, drop the Maltose rest to 62°C,
and raise "Abmaischen" (mash-out) to 78°C.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
Sud._parse_data() already falls back to config.json's Pot section when
a loaded sud.json doesn't override 'mass', but neither client actually
passed that config through, so displayed pot mass silently read 0 kg
whenever a recipe didn't set its own 'pot.mass'.
client/brewpi_gui.py: capture the System message's 'Pot' key (already
sent by the server, previously ignored) into self.pot_config, and pass
it to Sud._parse_data(doc, self.pot_config).
web/app.js: parseSudDoc()'s potMass now falls back to the module-level
potConfig.mass (already captured from msg.Pot elsewhere, just never
consulted here) before defaulting to 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
Captures the idea of computing the HOLD-state negative floor from the
Smith controller's internal Pot model (L*(theta_ist-theta_amb)/C) instead
of the fixed -0.1 heuristic, plus the caveat that the Normal controller
has no internal plant model to draw the same estimate from.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
Captures the answer to "would the cascade/FSM design still hold up with
a real active cooler instead of heat-only + passive loss": PID/plant
model already generalize (symmetric y range, sign-agnostic plant math,
existing COOL state/pid_inner_cool), but the actor-level max(0, y)
clamp in tasks/heater.py is the one thing making today's tuning
mismatches free - removing it exposes both Outer.y_hold_min and
pid_inner_cool as live, untuned negative-power paths.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
Testing the windup-fix rework live against sude/sud_0030.json surfaced a
distinct bug: a HOLD overshoot from grain-fill-in cooling never decayed -
process_pid()'s pid_outer_y floor clamped to exactly 0.0, so pid_inner
fought the pot's own ambient loss to hold the overshot temperature flat
instead of declining back to setpoint (see docs/overshoot2.png).
Replace the hardcoded 0.0 floor with a configurable Outer.y_hold_min
(default 0.0, backward compatible), set to -0.1 in config.json, both
.tpl templates, and the demo scripts - small enough to avoid
reintroducing the bb5af3c limit cycle while letting HOLD request a
gentle decline matching passive ambient cooling.
Adds TestHoldOvershootRecoversToSetpoint and documents the finding in
docs/overshoot_hold_windup.md's Follow-up section and
components/pid/TODO.md. Confirmed against a live sud_0030 re-run, not
just the unit test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
overshoot_hold_windup.md read like a live plan ("not yet implemented")
even though the fix had already shipped; reworks it into a past-tense
record with corrected line numbers and test descriptions matching what
was actually built. Fixes a stale pid_heat reference in
temp_control_calibration.md.
TODO.md's windup item still claimed test coverage was "outstanding"
from before the test suite was added; corrects that and the "No
automated tests" item above it to reflect current coverage. Both docs
now link docs/fsm_states.png (durable) and the Claude Artifact URL
(session-scoped) for the cascade architecture diagram.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
Renames pid_hold/pid_heat/pid_cool to pid_outer/pid_inner/pid_inner_cool
to match what actually runs when, and splits inner-loop config into
Inner.Heat/Inner.Hold/Inner.Cool so the same PID instance gets a tight
yi_max ceiling only while HOLD drives it, without capping legitimate
1.5 K/min ramps. Fixes the overshoot from docs/overshoot_hold_windup.md
where a cold-water disturbance during HOLD wound up pid_heat's integral
term with no anti-windup engagement, taking ~35s+ to unwind naturally.
Breaking config change: Hold/Heat/Cool -> Outer/Inner.{Heat,Hold,Cool}
in config.json, both .tpl templates, the pid/sud demo scripts, and
replay_sim.py's CLI flags. Adds tests/components/pid/ (stdlib unittest)
covering the Pid clamp/recovery behavior and closed-loop disturbance,
ramp, and HOLD<->HEAT transition cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
Supersedes the earlier flat yi_max clamp / FSM-gating ideas: same inner
PID instance switches its active param set (Inner.Heat vs Inner.Hold)
by state instead of freezing, giving bumpless transfer for free and a
tight yi_max that only applies while HOLD is driving it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KefnwbGDM8CGrhb4sFhVq9
Records the root cause of the cold-water overshoot in docs/overshoot.png
(integral windup in pid_heat with no anti-windup engagement, since y never
saturated) and the refined fix plan: gate pid_heat by FSM state instead of
a flat yi_max clamp, which was rejected after the numbers showed it would
also cripple legitimate high-rate ramps.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KefnwbGDM8CGrhb4sFhVq9
A manual Disconnect command left the last commanded power/speed in
place server-side, so a reconnect (or the UI's PowerSet/Speed echo)
could show a stale nonzero setpoint for a device that's no longer
attached.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5WEDCc4v473DKqSaQhTQt
updateDeviceStatus() gains a tri-state: true/false once the server has
actually reported it, or null for "unknown" - rendered as a blank
'---' badge with both Connect/Disconnect buttons disabled.
setConnected() now resets Heater/Stirrer to null whenever the browser's
own websocket connection drops, so a stale Connected/Disconnected badge
from before the disconnect doesn't keep misleadingly showing as current.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Same bug class as the earlier heater fix: TempSensorTask.on_process()
called self.sensor.temperature() every tick with no exception handling
at all, and the constructor even called it once synchronously at
startup to prime the value. A read failure there would either crash
the whole server before the event loop started, or permanently kill
TempSensorTask's coroutine mid-run - nothing restarts a dead ATask, so
the sensor would never be read again for the rest of the process's
life.
TempSensor_max31865.temperature() now catches read failures and calls
a new reopen() (closes/reopens the spidev handle - the closest
equivalent to unplug/replug for a bus peripheral) instead of raising,
holding the last-known-good reading meanwhile. reopen() itself never
raises either, learned from HendiCtrl.disconnect() previously letting
a failure escape the same way. Belt-and-suspenders guards added at the
TempSensorTask level too, matching HeaterTask/StirrerTask.
Verified against a fake spidev: a read failure no longer raises,
reopen() is attempted automatically, and readings resume once the bus
responds again - even when reopen() itself also fails.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
config.json isn't tracked in git, so this records what changed on the
Pi during today's connect/disconnect testing: Heater/Stirrer ports
switched from /dev/ttyUSB0//dev/ttyACM0 to stable /dev/serial/by-id/...
paths after a replug renumbered both devices, plus a reminder to
remove the pre-edit config.json.bak-20260703212821 left on the Pi.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Root cause (from the brewpi Pi's log): HendiCtrl.disconnect() only
caught HendiException (a protocol-level NAK), but a genuine unplug
fails deep inside pyserial itself - flushInput()/readline() raise
termios.error/OSError on the now-dead fd. That escaped disconnect()
uncaught (skipping self.ser.close() too), then kept propagating up
through every caller that invoked disconnect() from its own except
handler (HeaterHendi.process()/activate(), AHeater.open()'s
context-manager exit), all the way out of HeaterTask.on_process()'s
coroutine entirely. Nothing restarts a dead ATask, so the heater's
process()/duty-cycle loop was gone for the rest of the process's
life - reconnecting afterward changed `connected` back to True but
nothing was left running to ever call process() again.
Fixes:
- HendiCtrl.disconnect() now catches broadly and always closes the
port, matching Pololu1376.disconnect()'s already-correct pattern.
- HeaterTask/StirrerTask.on_process() gain an outer retry loop as a
safety net: even an unanticipated exception now logs, marks
disconnected, and re-enters rather than permanently killing the
task.
Verified by reproducing the exact failure (comm error raising a raw
OSError, not HendiException) against a fake device - the task now
survives and resumes process()ing once reconnected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Firmware version is already printed to the log at connect() time
(HendiCtrl/Pololu1376) - remove the live GUI/websocket firmware
reporting entirely (Connectable, HeaterHendi/StirrerPololu1376,
Heater/StirrerTask, web GUI) as redundant.
Also move the Heater panel's Closed-loop checkbox into the Controller
panel and rename it "Enable" - the Controller panel now shows a
live Enabled (green) / Disabled (gray) status badge next to its
heading, matching the Connected/Disconnected badges on the Heater/
Stirrer panels.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
The Connected/Disconnected badge, F/W version text, and both Connect/
Disconnect buttons were crammed into one row, clipping the Disconnect
button off-panel. Move the status badge into the h3 heading itself
(right-aligned) so the row below has room for the F/W text and both
buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
The previous hotfix's disconnect()-on-comm-error path can now fire
during server/brewpi.py's own final best-effort hardware-release
cleanup (finally: block's heater.close()/stirrer.activate(False)),
which runs after the event loop has already stopped - previously
nothing about hardware teardown touched an AttributeChange-tracked
field, so this path was dormant. asyncio.create_task() there raises
RuntimeError: no running event loop, crashing the shutdown sequence.
Adds fire_and_forget() (tasks/task.py) - asyncio.create_task() that
silently drops the message instead of raising when there's no loop to
schedule on - and uses it for the Connected/FirmwareVersion broadcasts.
SudTask.check_connections() also now no-ops entirely without a running
loop, since self.sud.stop() cascades into further unguarded
create_task() calls (on_state_changed, heater_task.shutdown(), ...)
that a stopped loop can't help with anyway.
Reproduced and verified fixed directly against HeaterTask/HeaterHendi
with no event loop running (mirrors the exact server/brewpi.py
shutdown-time call path that crashed on the brewpi Pi).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Live-testing on the brewpi Pi found that a connected-but-unresponsive
device (e.g. the hendi sitting in its ungraceful-disconnect lockout, see
docs/hendi_lockout_findings.md) crashed the whole server: activate() -
called both from HeaterTask's `with device.open():` and from a client's
Connect/Disconnect command - raised uncaught, escaping the gathered task
and killing the entire asyncio.gather in TaskManager.start().
activate()/is_activated()/process() on both real actors now catch comm
exceptions and self-heal via disconnect() (the same path a genuine
unplug already takes), instead of letting the exception propagate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
Heater and Stirrer can be real serial hardware (hendi, Pololu1376), but
there was no connection concept at all - the constructors opened the
port and crashed the whole server if the device was missing, with no
way to see connection status or firmware version and no way to
reconnect without a restart.
Adds an observable Connectable mixin (components/connectable.py) shared
by AHeater/AStirrer; real devices defer opening the serial port to an
explicit connect(), auto-connect on server startup, and surface
Connected/FirmwareVersion/Simulated plus manual Connect/Disconnect over
the web GUI. Heating/stirring and Sud Start are all gated on connection
state, and a disconnect mid-brew force-stops the run via the same path
as a manual Stop.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
TempSensorSim/TempSensor_max31865's temperature() now stores its
reading on self.temp, so ATemperatureSensor's inherited AttributeChange
(previously never triggered by anything) actually fires. TempSensorTask
no longer keeps its own shadow copy of the reading - it registers its
websocket-push callback on self.sensor directly and just drives the
read each tick; server/brewpi.py's TC-feeding registration moved from
sensor_task to sensor for the same reason.
Priming read happens before registering the callback (not after) since
all tasks are built synchronously at module level, before the asyncio
event loop starts - registering first would fire on_temp_changed's
asyncio.create_task() with no running loop.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
Moves components/actor/HendiCtrl.srec (v1.12) alongside the v1.14/
v1.15 images gathered during lockout testing, all under one versioned
naming scheme (hendi_ctrl_0112/0114/0115.srec) instead of scattered
across the repo root and components/actor/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
Confirmed on hardware: 15s idle while remote-enabled with the heater
on and powered auto-suspends (R? and S? both drop to 0). P? does not
read literal 0 though - it lands on 4095, the raw register's max value
past the calibrated curve's low end, not the digit 0. Any check
expecting P?=="0" to confirm zero power would be wrong on this
firmware; S? is the correct check.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
HendiCtrl.cmd() previously raised a bare "Communication error" for any
non-OK response, discarding the firmware's actual reply. It now
includes the raw echo/answer bytes, and --debug prints every
request/response. Using this, the rejected R1 during lockout turned
out to be an explicit "ERR:Invalid state" reply, not a timeout.
Also flashed and retested v1.16: plain reconnect (no --reset) now
recovers from the lockout reliably (4/4 trials), fixing the
intermittent behavior seen on v1.15.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
--reset still reliably clears the lockout on v1.15, same as v1.14. A
plain reconnect without --reset now succeeds intermittently (1 of 3
trials) where it always failed before, but that's unreliable enough to
flag as a remaining race condition rather than a fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
Firmware v1.14 makes --reset (DTR/RTS toggle) reliably clear the
ungraceful-disconnect lockout, unlike v1.12. A plain reconnect without
--reset still fails on both versions, so recovery is specifically tied
to the reset line, not just a fresh connection.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
--trigger-error-state deliberately reproduces the Hendi's
ungraceful-disconnect lockout (remoteEnable(True) without a matching
disable) so it can be tested on demand. --reset toggles the reset
line, but per hardware testing it does NOT clear that lockout - only
the device's physical knob/power cycle does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
hendiCtrl.py is now import-only. The new CLI app exposes serial
port/baudrate, version/capability info, converter test, heater test,
and firmware update as separate flags, instead of one fixed script.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpePKZiEZWbGo9HrfuML6U
Covers the last two commits: tick-counted t/_elapsed() semantics and why
PlantParams must be stamped with each logger's own (not Sud.elapsed
universally), plus the log_latest.json/log_latest_sud.json fixed-name
copies and the caveat that the two aren't guaranteed to be in sync.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
log_plant_params() stored whatever elapsed it was handed - always
Sud.elapsed - regardless of which logger's own Samples.t timeline it
needed to match. SudLogTask._elapsed() is Sud.elapsed, so its PlantParams
lined up fine, but ServerLogTask._elapsed() is a separate tick counter
since server startup - using Sud.elapsed there put PlantParams.t on a
different scale than its own Samples.t entirely, causing
utils/replay_sim.py to apply grain/water-mass changes at the wrong point
when replaying a server-session log (spotted by comparing its output for
log_latest.json vs log_latest_sud.json from the same session).
Fixed by dropping the elapsed parameter - log_plant_params(params) now
stamps with self._elapsed(), so each logger always uses its own correct
timeline. Cascades into SudTask.set_on_plant_params()'s callback signature
and its wiring in server/brewpi.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
Alongside the dated log files, ServerLogTask/SudLogTask now also write
(and overwrite) a fixed-name copy on every write() - logs/log_latest.json
and logs/log_latest_sud.json - so a dashboard/tail-style consumer can
point at one unchanging path instead of tracking the current run's
timestamped filename.
Also switches SudLogTask's own dated/latest filenames from .log to .json,
matching what they've always actually contained. This incidentally fixes
utils/analyze_log.py's two-argument CLI form, which already assumed a
.json extension for sud logs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
sim_warp_factor was scaling DT_TASK unconditionally, including against real
hardware, and was leaking into recorded sample timestamps via wall-clock
time - both violate its intended meaning (a reciprocal scale on task wait
time only). Clamp it to 1.0 whenever Heater.type isn't "sim", and track
elapsed time via tick-counted accumulators (Sud.elapsed for SudLogTask, a
matching one for ServerLogTask) instead of time.monotonic().
Sud/server logs now also carry HeaterPowers, the effective SimWarpFactor,
the raw Doc, a correct Name, and ForecastAnchors (one entry per real
_reanchor_forecast() trigger) - enough for utils/analyze_log.py to rebuild
a SudForecastEstimator and replay the exact same splice-per-transition
forecast correction offline that a live client sees, replacing the old
(always-dead) forecast_*.json sidecar lookup.
Two bugs found and fixed along the way:
- ServerLogTask's periodic wall-clock checkpoint had no way to know a
SudLogTask run had just ended, so it would immediately re-write the
just-completed log with PlantParams/ForecastAnchors cleared back to
empty. Gated the checkpoint on _should_sample().
- _reanchor_forecast() truncated the old forecast against real elapsed
time, which can end up numerically less than the old forecast's own
(very wrong) speculative timestamps once a WAIT_USER confirmation runs
long - leaving a "ghost" segment instead of a clean cut. Truncate
against forecast_step_starts[index] instead, which lives in the same
coordinate space as the forecast being cut.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDeoTKBDkXRhPfnyDEoBt
deploy/install.sh and deploy/brewpi.service existed but were never
mentioned in the README - the only documented deployment path was the
older virtualenvwrapper-based server/brewpi.sh. Also ties in the
SIGTERM handling fix, since systemctl stop is exactly the path it
protects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
Both loggers previously only wrote their JSON log on their end trigger
(shutdown / Stop-or-DONE), losing the whole session/run's data on a
hard crash or power loss. Add a log_interval (config.json, default
300s) that rewrites the same file whole every N seconds in between.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
systemctl stop/restart send SIGTERM, whose default disposition kills
the process immediately - skipping the shutdown finally: block that
cancels tasks and forces both actors safe via their activate(False).
Route SIGTERM through the same KeyboardInterrupt path SIGINT already
uses, and add stirrer.activate(False) alongside the existing
heater.close() as a belt-and-suspenders fallback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
The Logging & analysis section and a couple of cross-references still
described the old SudLogTask (forecast_*.json pairing) that predated
the ServerLogTask refactor and the just-reintroduced per-run SudLogTask.
Also documents the state_pump() task's role in reconnect behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
SudLogTask reuses ServerLogTask's sample format but only records while
a Sud run is active: starts on Play (fresh start only, not a Pause
resume) and writes logs/log_{date_time}_{sud_name}.log on Stop or
natural completion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
Each connection used to run its own handler_send loop consuming from
the shared dispatcher queue. With zero clients connected (e.g. an
iPad that silently drops on sleep), nothing drained the queue, so
state updates piled up unbounded and got replayed as a burst on
reconnect. Now one pump task drains the queue continuously regardless
of connection count, and reconnecting clients resync from
global_state instead of a backlog.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvgC7oy9MxaA4ZQxXqkNdS
HeaterTask.shutdown() switched the heater to open-loop when a brew
ended, but nothing switched it back when a new Sud was started. The
browser happened to send {ClosedLoop: true} as part of its UI setup,
so its connect-and-start path worked; PyQt (and any other client that
doesn't send that message) left the heater in open-loop and the TC
never drove it.
Add SudTask._on_start / set_on_start() hook, HeaterTask.start_closed_loop()
(mirror of shutdown()), and wire them in brewpi.py so the server
switches to closed-loop and broadcasts the new state to all clients the
moment any client starts a brew.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
server/brewpi.py: pass physics DT to ServerLogTask (not wall-clock DT_TASK)
so logs carry the correct integration timestep; log startup plant params as
the first PlantParams event so replay_sim can reconstruct them even when no
Sud is ever loaded (previously fell back to hardcoded defaults, causing ~3°C
temperature offset in replay).
tasks/server_log.py: accept and embed dt in log JSON; switch
log_plant_params() to wall-clock monotonic time (same base as sample t),
since the elapsed argument was simulated time and not comparable.
utils/replay_sim.py: switch to open-loop replay (plant driven by logged
power_eff, not sim controller output); use PidFactory so Smith vs Normal
controller type is honoured; set Td=0 in replay plant for Smith mode (logged
temp_ist is already Smith-corrected); feed Smith predictor's internal model
with real power; apply embedded PlantParams mid-run; prefer embedded dt over
sample-based fallback; clamp sim power ≥ 0; replace meaningless sim-power
trace in power subplot with power_set vs power_eff from the log.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SudLogTask is removed; ServerLogTask now covers the full server session
and gains PlantParams tracking (log_plant_params callback wired from
SudTask.set_on_plant_params) so step transitions are still captured in
the log.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ServerLogTask now embeds the full config in its JSON output, matching
SudLogTask — so replay_sim and analyze_log work on server logs without
needing a separate config file.
replay_sim.py switches from two positional args (date_time, sud_name) to
a single log file path, with config and plant params resolved from the
log's embedded "Config"/"PlantParams" sections when present and falling
back to config.json / hardcoded defaults for older logs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each log_*.json now carries a "Config" key (the full server config) and
a "PlantParams" list of {t, params} entries — one per step transition —
so offline analysis tools have the PID gains and plant model that were
active at every point of the run without having to cross-reference a
separate config file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
During hold, a small temperature overshoot causes pid_hold.y to go
slightly negative, inverting heatrate_soll and driving pid_heat's
power to 0. With no power the pot coasts back down, pid_hold goes
positive again, power builds back up, overshoot repeats — a ~110s
limit cycle visible as pumping in the power trace.
Clamping pid_hold.y to [0, 1] in HOLD state lets the outer loop
reduce the inner rate setpoint to zero (stop adding heat) but not
invert it (actively demand cooling), breaking the limit cycle while
preserving normal HEAT/COOL cascade behaviour.
Note: Hold.kt must remain 0 when Hold.ki=0 — a non-zero kt drains
pid_hold.yi during ramp saturation, suppressing heatrate_soll at the
start of each hold phase and leaking into the next ramp via the
bumpless HOLD→HEAT transfer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two root causes for the visible power dip at every ramp-to-ramp
step boundary:
1. One-tick cascade delay: heatrate_soll was computed from
pid_hold.get_y() before pid_hold.process() ran in that tick,
so the first tick of a new ramp used the stale hold-phase
output (≈0) as the rate setpoint, driving pid_heat to near
zero. Fix: move heatrate_soll computation inside process_pid(),
after pid_hold.process().
2. Cold-start reset: pid_heat.reset() at HOLD→HEAT discarded
the hold-phase integral, so pid_heat had to ramp up from
zero on every new step. Fix: remove the reset (bumpless
transfer) — the hold-phase integral is a warm starting point
that carries the baseline hold power into the new ramp.
Together these eliminate the ~1s dip to near-zero and replace
the slow 20–30s integral ramp-up with an immediate start at
roughly hold-level power, climbing monotonically to the target.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pot config (water_mass, volumen) sent in System message at startup.
app.js stores potConfig and uses it in updatePotVisualization/
updateStatusLine when sudEmpty, showing startup fill level and
"No schedule – N L water" without requiring a sud to be loaded first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sud._parse_data now falls through to pot_config for all hardware keys
(mass, material, L, Td) — EMPTY_SUD no longer shadows them. brewpi.py
seeds pot/tc plant params from Pot.water_mass on startup so the
simulated plant is configured before any sud is loaded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pot hardware constants (mass, material, L, Td) moved from sude/*.json
to config.json's Pot section; Sud/SudForecastEstimator accept pot_config
as baseline, per-brew pot sections may still override. Controller key
removed; pid_type moved into TempCtrl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PlantFactory.create() no longer takes plant_name; sim vs real is derived
from Heater.type. StirrerFactory is wired from Stirrer.type. HeaterFactory
registry key lowercased to "hendi" to match config. Controller.plant_name
removed from both config templates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PlantFactory.create() now accepts sensor_config dict; extracts type and
kwargs, falls back to sigma=0.05/temp_offset=-0.15 defaults. brewpi.py
passes config.get('TempSensor', {}). Templates updated with full
TempSensor sections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the opaque variance/√12 formula with two explicit noise components:
sigma (default 0.05 °C) — white Gaussian noise, calibrated against the
20260628T184903 Sud-0010 log (detrended hold-phase tick-to-tick std ≈ 0.053 °C).
stirrer_sigma / stirrer_tau — optional AR(1) low-frequency component for
stirrer-induced fluctuations (off by default); steady-state std equals
stirrer_sigma, correlation time equals stirrer_tau ticks.
plant_factory.py updated to use sigma=0.05 instead of the old variance=0.01
(which gave std ≈ 0.003 °C — far too quiet).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two-argument form (date_time sud_name) is unchanged. When called
with a single argument it is treated as a direct path to any log_*.json
(e.g. a server-session log without a Sud name); the forecast panel is
shown only if a matching forecast_*.json exists alongside it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ServerLogTask (tasks/server_log.py) which records temp/power samples
from startup to shutdown regardless of Sud state, in the same Name/Samples
JSON format as SudLogTask so analyze_log.py and replay_sim.py can consume
it directly. The file is written on graceful exit (Ctrl-C) from brewpi.py's
finally block, just before the heater is closed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On KeyboardInterrupt, brewpi.py cancels all asyncio tasks (letting
HeaterTask's `with device.open()` finally block run), then calls
heater.close() as belt-and-suspenders before closing the event loop.
On shutdown HeaterHendi.activate(False) now sets the switch to 0 instead
of disabling remote control, so the Hendi stays in remote mode at 0 W
rather than dropping back to its local panel state. HendiCtrl.close()
does the same (setSwitch(0)) then closes the serial port.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a configurable IIR pre-filter (beta, default 0.05, τ≈19 s at dt=1 s)
applied to theta_ist before the derivative, so stirrer-induced temperature
noise is attenuated before being amplified by differentiation rather than
after. The shared filter/derivative/post-filter logic is factored into
TempControllerBase._compute_heatrate(); both Normal and Smith subclasses
call it, replacing their duplicated inline rate blocks.
beta is read from TempCtrl.beta in the config (default 0.05 if absent) and
added to config-sim.json.tpl and config-real.json.tpl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the open-loop (feed real temp_ist) approach with a proper
closed-loop simulation: a Pot plant model is driven by the controller's
power output each tick, generating sim_temp_ist that diverges from the
real log's real_temp_ist whenever the PID params produce different
behaviour. Temperature panel now shows real_temp_ist vs sim_temp_ist
vs temp_soll so the effect of candidate gains is immediately visible.
Plant params (M, C, L, Td) default to 20 kg water / L=0.2 / Td=30 s
and can be overridden via --plant-* flags. Ambient defaults from config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY449HByk6UpKhDN2HTnfK
Feeds real temp_ist/temp_soll readings from a run log back into a fresh
TempController (Normal mode) tick by tick, then plots a 3-panel comparison
of heat rate and normalized power against the original logged values. Any
PID gain can be overridden via --<section>-<gain> flags so candidate params
can be evaluated against a past run without touching hardware.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY449HByk6UpKhDN2HTnfK
Documents the stirring-induced rate noise problem, its root cause
(post-differentiation filtering), and three mitigation options: pre-filter
before differentiating, sliding-window regression, and model-based rate
from Pot.p_pot (Smith only).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
Renders the same two plots the PyQt GUI shows (realtime 3-panel strip
chart and forecast-vs-actual comparison) from a log/forecast JSON pair,
specified by date_time and sud_name on the command line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
pyserial and spidev are Linux/Pi-only (spidev requires the kernel SPI
driver and won't install on macOS/Windows). Dev machines use
requirements.txt; the Pi uses requirements-hw.txt which pulls in the
base file via -r.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
HoldRemaining now calls updateHeaderCountdown() directly so the header
hold row refreshes every second from its own message, not only via the
Elapsed → updateStepPlates → updateHeaderCountdown chain.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- Power soll label centered below flame area (amber, clears when off)
- Stirrer RPM label next to motor housing (clears when stopped)
- Flame base moved 20 units closer to pot bottom
- Drop Ist/Soll LCD header row to save vertical space
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Catch ConnectionClosed in User.send() and handler_recv() so that an
unclean client disconnect no longer produces "Task exception was never
retrieved" / "connection handler failed" log noise.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- Add viewport meta tag so Safari uses device CSS pixels
- Move LCD units to a dedicated right-hand column; strip them from labels
- Tighten LCD grid: 4-column layout, narrower gap, font-size 1.9em
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- HeaterTask.shutdown() on DONE/IDLE: document the _on_end callback,
power-zero, TC disable, and ClosedLoop:false broadcast to clients.
- Resume with inherited temperature: document _paused_temp_soll fallback
for steps that carry no temperature of their own.
- Browser client additional features: update Header countdown (now three
rows: total/ramp/hold, always h:mm:ss) and describe the step-plate
Ramp/Hold green countdown and configured hold-duration field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Each step plate now shows the scheduled hold time ("Hold h:mm:ss") as a
static field instead of the live actual temperature. The value is set once
at plate-creation time from the step's hold.duration; steps without a hold
phase show "Hold —".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Step plates now show "Ramp h:mm:ss" (ramp time only) during ramp phase
and "Hold h:mm:ss" (hold remaining) during hold phase, both in the same
digit area in green (#4ade80). Inactive steps show planned ramp duration.
Header countdown gains a "hold" row alongside the existing "ramp" (renamed
from "step") and "total" rows. All three use the same h:mm:ss format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Adds HeaterTask.shutdown() which switches to open-loop at power 0,
disables TC, and broadcasts ClosedLoop=false to clients so the browser
checkbox unchecks immediately. SudTask fires it via a new set_on_end()
callback whenever state reaches DONE or IDLE, giving both a completed
schedule and a manual Stop the same safe behaviour: stirrer and heater
both off, no heater left running unattended.
Re-enabling after shutdown: check the closed-loop checkbox (sends
ClosedLoop=true → TC re-enabled) or adjust the power slider directly;
the next brew's Play press already sends ClosedLoop=true automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Total remaining now appears above step remaining in the header. Both
countdowns and all other duration displays always use h:mm:ss format
regardless of whether hours is zero, so the layout never shifts width.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Steps with no 'temperature' field inherit their target from a prior step
(temperature=None in the resolved schedule). The resume-from-pause code
only called set_theta_soll when the current step had an explicit temperature,
so any manual setpoint change made during pause was never reverted for
inherited steps (e.g. sud_0010 steps 2-3 both hold at step 1's 57 °C).
Fix: save theta_soll_set at pause time; use it as fallback on resume when
the current step's temperature is None. Also set initialTempSollSync=true
in the browser on play so the slider accepts the next server Soll push
regardless of message arrival order relative to the State update.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- Rename "State replay on connect" to "State is server-only; GUIs only
reflect it" and expand it with the core design principle: neither GUI
client ever maintains its own model of server state — controls update
only on server push, never speculatively on user input. Multi-client
sync, stale-overwrite safety, and reconnect correctness all follow from
this single rule.
- Update TC-enable description: HeaterTask now owns the TC's enabled
switch via the closed_loop flag; SudTask no longer touches it.
- Add "Heater control modes" subsection describing Closed-loop/Open-loop,
the enable/disable rules per mode and sud state, the auto-reset on play,
and the schedule parameter restore on resume.
- Refresh the Browser client section: document the Heater control mode
checkbox, header countdown timers, and the always-reflect-server-values
slider policy; call out the server-only-state principle explicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
On resume from PAUSED, on_step_changed does not re-fire (same step
continues), so any manual TC/stirrer adjustments made while paused
would persist into the run. Explicitly re-apply the current step's
temperature, heatrate, and stirrer config on the resume path in
SudTask.recv().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Closed-loop (default, checkbox checked): TC has exclusive control over
the heater; temp-soll and heatrate-soll sliders are active; direct
heater power entry is disabled. TC now stays enabled at server startup
and whenever closed-loop mode is active — SudTask no longer disables it
on IDLE/DONE, so adjusting temp-soll manually with no sud running
immediately drives the heater via the TC.
Open-loop (checkbox unchecked, only selectable when paused/stopped):
TC is disconnected from the heater; only the heater-power slider is
active. Switching back to closed-loop resets power_soll to 0 and
re-enables the TC.
On play, the mode is automatically reset to closed-loop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Shows remaining time for the current step and the entire sud in large
accent-coloured monospace text to the right of the transport buttons,
matching the per-step plate colour. Hidden until a step is active.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Remove one-time sync guards (initialPowerSync, initialSpeedSync) so heater
power and stirrer speed sliders track the server's ist readbacks on every
push, not just on first connect. Also remove the zero-out on sud
stop/pause: with continuous ist tracking the animation and slider always
agree, and the zero-out caused them to diverge when the server kept sending
non-zero values during a pause.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX