Commit Graph
35 Commits
Author SHA1 Message Date
jensandClaude Sonnet 5 f20e617d81 feat: add connect/disconnect status for real heater/stirrer hardware
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
2026-07-03 19:18:59 +02:00
jensandClaude Sonnet 5 5bc278e875 feat: add --debug tracing to hendi_ctrl_app.py, document v1.16 fix
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
2026-07-02 18:44:00 +02:00
jensandClaude Sonnet 5 d079db8b37 feat: add --reset and --trigger-error-state to hendi_ctrl_app.py
--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
2026-07-02 17:35:05 +02:00
jensandClaude Sonnet 5 5522b9c491 refactor: move hendiCtrl.py's main() into scripts/hendi_ctrl_app.py
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
2026-07-02 17:02:21 +02:00
jensandClaude Sonnet 4.6 9d12dd9a95 Move scipy and matplotlib from server to demos requirements
Both are only used by scripts/demos — the server itself only needs numpy
(used by kalman.py, delay.py, tempSensorSim.py, hendiCtrl.py).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 20:11:43 +02:00
jensandClaude Sonnet 4.6 e2e3baab08 Refactor sud JSON pot params under a nested 'pot' key
Top-level pot_mass/pot_material/L/Td/grain_mass/water_mass and their
per-step overrides are now grouped under a 'pot' sub-object, matching
the structure already applied to sud_0010.json's parent level. Updated
all sude/*.json files and the parsing/consuming code in components/sud.py,
tasks/sud.py, components/sud_forecast.py, and scripts/demos/sud/demo_sud.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T52JH848ojhXXHn1bAzdC3
2026-06-26 07:31:27 +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 96fe7ce90c Require TempController(Smith)'s model params/ambient via setters; add comprehensive process() checks
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
2026-06-22 18:31:58 +02:00
jensandClaude Sonnet 4.6 0a6d3e6462 TempControllerBase: require PID gains via set_params(), not the constructor
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
2026-06-22 18:21:20 +02:00
jensandClaude Sonnet 4.6 296d3a333d Pot: require plant params and ambient temperature via setters, not the constructor
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
2026-06-22 18:15:08 +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 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 858249f1e7 pid: replace IDLE-as-cooling with a real COOL state
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.
2026-06-21 13:41:20 +02:00
jens 2c25b8be50 Sud: server always starts empty; sude/*.json is purely a client concern
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.
2026-06-21 10:24:45 +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 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 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 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 ac0f13897c Plot the Sud demo's plant M/C over the run
Adds a third figure tracking plant.M/plant.C alongside the existing
temperature/state plots, making the per-step mass changes (malt going
in, water boiling off) visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
2026-06-20 07:56:28 +02:00
jensandClaude Sonnet 4.6 241a796ffa Make grain_mass/water_mass per-step and re-derive plant params on change
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
2026-06-20 07:45:42 +02:00
jensandClaude Sonnet 4.6 33f75c22d2 Print each Sud step's number and description in the demo
Makes it easier to follow which schedule step is currently active
when watching the demo run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
2026-06-20 07:28:26 +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 b6551c6433 Derive Pot's M/C from pot/grain/water mass instead of hardcoding them
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>
2026-06-19 19:34:15 +02:00
jensandClaude Sonnet 4.6 18d9af960f Remove the stirrer duty cycle trace from the Sud demo plot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:26:09 +02:00
jensandClaude Sonnet 4.6 15650b21fb Plot the Sud demo's time axis in seconds instead of minutes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:23:38 +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 2845051350 Refresh design-backlog docs after Pot's gain/theta cleanup
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>
2026-06-19 18:37:03 +02:00
jensandClaude Sonnet 4.6 395eda5eb4 Add a comprehensive mash-schedule demo
Drives a real Smith-predictor TempController, Pot, HeaterSim, and
StirrerSim through sude/sud_0010.json end-to-end (mirroring brewpi.py's
wiring, including the model/plant power-discretization mismatch and
tasks/sud.py's stirrer-switching rules), auto-confirming waitForUser
pauses so it completes unattended. Plots theta/heatrate (actual vs.
target), heater power_set/power_eff, Sud state, rest index, and
stirrer speed/duty cycle over time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:11:13 +02:00
jensandClaude Sonnet 4.6 80da55da85 Make ambient temperature configurable and wire it everywhere it was hardcoded
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>
2026-06-19 17:16:57 +02:00
jensandClaude Sonnet 4.6 ac8de9da55 Add sensor variance config and smooth heat-rate estimate
[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>
2026-06-19 15:47:24 +02:00
jensandClaude Sonnet 4.6 95eacabe28 Rewrite Smith predictor controller and demo for the new Pot/no-Kalman design
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>
2026-06-19 14:42:10 +02:00
jens 29e85dbc9c [Pot]
- added demo
2026-06-19 14:21:55 +02:00
jens 5c7cffa3a7 [Pot]
- remove HeatDiffusion
- use delay line for heat propagation modeling

[Temp Controller]
- remove Kalman
2026-06-19 14:21:23 +02:00
jensandClaude Sonnet 4.6 81e66dbcd1 Move PID matplotlib demo harnesses out of production modules
temp_controller.py, temp_controller_smith.py, and kalman.py imported
matplotlib at module level just to support eyeballed-plot __main__
blocks, coupling the live server's import graph to a GUI plotting lib
it never uses at runtime. Relocate those demos (and kalman_eval.py) to
scripts/demos/pid/ and strip the now-unused imports/__main__ blocks
from the production files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 08:21:47 +02:00