Commit Graph
249 Commits
Author SHA1 Message Date
jensandClaude Sonnet 5 7b162cf1f5 refactor: drop firmware-version display, move Enable toggle to Controller
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
2026-07-03 20:23:55 +02:00
jensandClaude Sonnet 5 0877e7754b fix: guard HeaterHendi/StirrerPololu1376 activate() against comm errors
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
2026-07-03 19:39:07 +02:00
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 698c019581 refactor: make ATemperatureSensor's temp observable directly on the sensor
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
2026-07-02 22:03:50 +02:00
jensandClaude Sonnet 5 75a886950c refactor: collect hendi firmware images under firmware/hendi_ctrl/
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
2026-07-02 18:58:32 +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 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
jens 1bcabb0ccc added firmware info to hendi and motor driver 2026-07-02 16:33:37 +02:00
jensandClaude Sonnet 4.6 bb5af3c0ed fix: clamp pid_hold output to [0,1] in HOLD state to break limit cycle
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>
2026-06-30 21:13:30 +02:00
jensandClaude Sonnet 4.6 b3923fb1c0 fix: smooth HOLD→HEAT power transition (bumpless transfer + cascade timing)
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>
2026-06-30 20:33:12 +02:00
jensandClaude Sonnet 4.6 d0a137e39e config: seed plant params from Pot.water_mass at server startup
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>
2026-06-30 19:45:13 +02:00
jensandClaude Sonnet 4.6 5142fcbf3d refactor: move pot hardware params and pid_type out of per-brew docs
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>
2026-06-30 19:41:04 +02:00
jensandClaude Sonnet 4.6 0495e177c4 refactor: replace plant_name with per-component type keys in config
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>
2026-06-30 19:31:39 +02:00
jensandClaude Sonnet 4.6 0156e689ca config: wire TempSensor section from config.json to TempSensorSim
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>
2026-06-30 19:27:04 +02:00
jensandClaude Sonnet 4.6 ca1953c9ee sensor: model real temp sensor noise in TempSensorSim
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>
2026-06-30 19:17:48 +02:00
jens 801e9809a6 hendiCtrl: fixed graceful close 2026-06-30 18:59:33 +02:00
jensandClaude Sonnet 4.6 5751d26873 server: graceful Ctrl-C shutdown; HendiCtrl closes connection cleanly
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>
2026-06-30 18:26:54 +02:00
jens 29dd5267a9 - fixed hendiCtrl exceptions 2026-06-30 17:05:43 +02:00
jensandClaude Sonnet 4.6 5be1d4e862 pid: option A heat-rate pre-filter (beta IIR before differentiation)
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>
2026-06-30 17:02:14 +02:00
jens 8ca4e4d803 fixed duplicate class declaration 2026-06-30 16:38:51 +02:00
jens 6547478d00 HendiCtrl: better Exceptions 2026-06-30 16:22:06 +02:00
jensandClaude Sonnet 4.6 c6e9258351 Rename config templates: .templ → -sim.json.tpl, config_real.json → -real.json.tpl
Consistent naming: config-sim.json.tpl and config-real.json.tpl.
Update README and TODO references accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
2026-06-28 20:23:29 +02:00
jensandClaude Sonnet 4.6 8c845ed5c5 Fix crash: rename shadowing 'pot' variable in forecast closure
In estimate()'s on_step_changed closure, the new 'pot = step.get("pot", {})'
local variable shadowed the outer 'pot' (Pot plant instance), causing
pot.set_plant_params() to be called on a plain dict and crash on load.
Renamed to 'step_pot' to avoid the collision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T52JH848ojhXXHn1bAzdC3
2026-06-26 08:02:53 +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 44541220f9 Fix FSM regression: swap TempControllerBase base class order
With TempControllerBase(APid, TempControllerFsm), APid.is_holding()
(abstract stub returning None) shadowed TempControllerFsm.is_holding()
in the MRO, so tc.is_holding() always returned None. The forecast
simulation never called sud.temp_reached() and always hit MAX_TICKS.

Swapping to TempControllerBase(TempControllerFsm, APid) puts the
concrete FSM methods before APid's abstract stubs in the MRO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 21:22:52 +02:00
jensandClaude Sonnet 4.6 42d7776a85 Extract FSM logic from TempControllerBase into TempControllerFsm
States, DEFAULT_THRESHOLDS, the three PIDs, FSM state vars, process_fsm(),
on_state_entered(), set_enabled(), and is_holding() move to a new
TempControllerFsm class in temp_controller_fsm.py. TempControllerBase
inherits from both APid and TempControllerFsm; States is re-exported so
existing imports in temp_controller_smith.py are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 20:50:20 +02:00
jensandClaude Sonnet 4.6 f7355270c8 Extract shared factory dispatch into ComponentFactory base class
All four name-dispatch factories repeated the same if/elif lazy-import
pattern. Replace with a ComponentFactory base class holding a callable
registry and a _lazy() helper for the common import-and-construct case.
PlantFactory is an orchestrator, not a dispatcher, and is left as-is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 20:44:43 +02:00
jensandClaude Sonnet 4.6 6e696f44e4 Add set_ambient_temperature and set_model_plant_params stubs to TempControllerBase
Same pattern as set_model_power: these methods only existed on the Smith
controller, forcing five hasattr guards at call sites. No-op stubs on the
base class give all controllers a stable interface; Smith overrides them.
Remove the now-redundant hasattr guards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 20:22:33 +02:00
jensandClaude Sonnet 4.6 2f2067d2b9 Add set_model_power no-op stub to TempControllerBase
The Normal controller had no set_model_power(), causing callers to
guard with hasattr() instead of relying on a stable interface. Adding a
no-op stub to the base class means all controllers have the method;
TempControllerSmith overrides it to actually update its internal model.
Remove the now-redundant hasattr guards at both call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 20:19:33 +02:00
jensandClaude Sonnet 4.6 740fe8eee9 Remove debug prints from HeaterFactory
print(**kwargs) crashes at runtime since print doesn't accept
arbitrary keyword arguments. Both debug lines were leftover scaffolding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tqxrk8uj4M3w3d3eXm3xK8
2026-06-25 20:17:42 +02:00
jensandClaude Sonnet 4.6 35d33530ef Fix step-1-skipped-on-restart race and Progress tab step number mismatch
Re-enabling the temp controller landed unconditionally in HOLD and
relied on the next tick to correct it, but SudTask.on_process() reads
is_holding() synchronously in the same call chain that pushes a fresh
step's setpoint - so a restart could finish a hold-less step instantly
instead of actually ramping. Resolve the FSM against the real gap right
away instead. Also fix the status bar showing one step number below
what the Progress tab's plates show for the same step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189FkmyJkY77HqsNomr1DwV
2026-06-24 23:30:37 +02:00
jensandClaude Sonnet 4.6 e49af8a347 Add a Progress tab: one step plate per schedule step, stacked
A glance at the Automatic tab's forecast plot couldn't show where the
brew actually stands within its own schedule - just a curve. The new
Progress tab stacks one StepPlate per step instead: an LED for its
status (off/done/active-ramping/active-holding), its resolved target
temp, masses and ramp rate, plus, for whichever step is currently
active, live actual temp and stirrer state (frozen at their last
value once that step finishes) and a forecast-based remaining-time
countdown.

The countdown needs to know where each step actually begins in the
forecast's timeline, which the server didn't track before -
SudForecastEstimator.estimate() now returns per-step start times
alongside t/theta, and SudTask threads them through send_forecast()/
_reanchor_forecast() the same way (including the same generation-guard
against the concurrent-call race) as a new 'StepStarts' field on the
'Forecast' message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 17:15:14 +02:00
jens 0be8ffcbe0 Revert "Run the Pot simulation from server startup, not just once a Sud loads"
This reverts commit 63fa093a8a.
2026-06-24 16:09:49 +02:00
jensandClaude Sonnet 4.6 17e8386002 Generalize Sud forecast reanchoring to every step boundary
Reanchoring used to only happen when a user confirmed a WAIT_USER
step, so anything the schedule advanced through on its own (a ramp
reaching target, a hold timing out) left the forecast showing a
stale, increasingly wrong prediction once reality diverged from it.
_reanchor_forecast() now fires from on_step_changed() on every real
transition, splicing in a fresh simulation anchored at the real
current temperature/elapsed time instead.

Also fixes two bugs surfaced while testing that change:
- estimate()'s early-return for an empty/not-yet-loaded schedule still
  returned the old 4-tuple shape, crashing every connection before a
  Sud was ever Loaded.
- send_forecast() and _reanchor_forecast() can run concurrently (e.g.
  a fresh Start triggers both at once), and whichever resumed second
  after its own worker-thread simulation would blindly splice its tail
  onto whatever the other had already written, producing a spurious
  connecting line across the plot. Both now carry a generation counter
  and discard their result if a newer call has since committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
2026-06-24 10:50:17 +02:00
jens ead900b7b1 Build the heater/pot/sensor rig as one unit, picked by plant_name alone
heater_name/sensor_name/plant_name were three independent config keys,
each picked via its own factory - in practice sim and real hardware
are never actually mixed and matched, so this could (nonsensically)
disagree, e.g. a real heater paired with a simulated sensor.

Add PlantFactory (components/plant/plant_factory.py): plant_name alone
now builds the whole rig together. "sim" gets HeaterSim/Pot (the
modeled plant, as before)/TempSensorSim. Anything else gets
HeaterHendi/PotReal/TempSensor_max31865 - PotReal is a new, deliberately
unmodeled Pot for the real, physical kettle (components/plant/pot_real.py):
the real temperature comes straight from the real sensor, not from a
model, so it just accepts and ignores set_plant_params()/
set_ambient_temperature()/initial() and reports no temperature of its
own, satisfying PotTask/SudTask's interface with nothing to actually
simulate.

heater_name/sensor_name are gone from config.json.templ; server/brewpi.py
delegates to HeaterFactory/TempSensorFactory lazily from inside
PlantFactory, same as before, so real hardware's spidev/pyserial deps
still aren't needed just to import the module.
2026-06-23 20:22:51 +02:00
jens 63fa093a8a Run the Pot simulation from server startup, not just once a Sud loads
PotTask.on_process() only calls Pot.process() once Pot.is_configured()
- which required plant params (M/C/L/Td) that, by design, only ever
came from a Sud's own doc via SudTask.apply_plant_params(). So with no
Sud loaded (or the temp controller disabled), the simulated Pot just
sat inert: manually driving the heater never moved its temperature at
all.

EMPTY_SUD's pot_mass/pot_material defaulted to 0/None, which would
have made a zero-mass plant (a ZeroDivisionError in Pot.process()) -
they're actually the physical kettle's own properties, not really
per-recipe (every sude/*.json so far uses the same pot), so default
them to that real kettle's values instead. server/brewpi.py now seeds
the Pot with derive_plant_params() from the not-yet-loaded Sud right
away, same as ambient temperature already was - a real Sud's own doc
still overrides these the moment one is loaded.
2026-06-23 20:01:59 +02:00
jens e4f8b15ab0 Route the forecast estimator's commanded power through the heater PWM chain
SudForecastEstimator.estimate() was feeding tc.get_power() straight to
the plant (pot.set_power(heater_max_power * y)), bypassing the duty-
cycling across the heater's discrete power steps that the real run's
HeaterTask/device chain always applies (tasks/heater.py). That let
pid_heat's own oscillatory tendency reach the plant undamped, so the
forecast showed a violent on/off staircase that no actual brew run
ever produces - confirmed by comparing against real sud_log output,
which stays smooth because the real actuator chain duty-cycles the
same PID output down first.

estimate() now duty-cycles tc.get_power() across the heater's own
power steps (mirroring HeaterTask's PWM logic, duplicated rather than
imported to keep components/ from depending on tasks/) before handing
it to the plant, and also feeds the resulting discretized power back
into the Smith predictor's internal model via set_model_power(), same
as the real wiring in brewpi.py does. The constructor takes the
heater's get_powers() list and the configured sim_warp_factor (needed
to convert HeaterTask's 10-real-second PWM period into simulated
ticks) instead of a bare max power.
2026-06-23 19:19:21 +02:00
jensandClaude Sonnet 4.6 d981c36194 Fix bogus first-tick heatrate from hardcoded last_theta_ist=20
last_theta_ist was hardcoded to 20 in __init__, used to compute the very
first process() tick's heatrate as (theta_ist - 20)/dt*60 - a wildly bogus
spike whenever the real starting temperature wasn't actually 20 (e.g.
1200 deg/min for a 40-degree start). The exponentially-smoothed
heatrate_ist (alpha=0.1) takes several ticks to recover from that, during
which it distorts the PID's rate-error term and visibly throws off the
heating trajectory.

This was invisible everywhere this assumption was implicitly tested,
since ambient (and every start_theta used) was always exactly 20. It
surfaced once SudForecastEstimator.estimate() started being called with a
real, non-20 start_theta: it builds a fresh TempController on every call,
so its first tick always hits this bug fresh - unlike the real controller,
which only ever goes through this once near server startup and has long
since self-corrected (last_theta_ist = theta_ist runs every tick) by the
time any forecast comparison matters. That's exactly what showed up as a
forecast-vs-actual gap during the initial ramp.

last_theta_ist now starts None; process() treats the first tick's
heatrate as 0 (no real history yet) instead of computing it against a
hardcoded, usually-wrong baseline.

Verified: ambient=20 (the only case ever exercised before) is byte-
identical to before the fix. ambient=40/start=40 - reproducing the old
hardcoded-20 behavior side by side - shows the old version visibly lagging
~12 minutes behind the fixed one during the initial ramp before they
converge, matching the reported gap exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 22:41:14 +02:00
jensandClaude Sonnet 4.6 d5a8c2422b Anchor the forecast to the real current temperature, not a cold start
send_forecast() was defaulting to a cold start at ambient (SudForecastEstimator.
estimate()'s default when start_theta isn't passed), so the forecast's t=0
never matched whatever temperature the pot actually was at - understating
how long the first ramp would really take whenever it wasn't already cold.

Anchoring at tc.get_theta_ist() seemed like the obvious fix but isn't
reliable right after Load: that's only ever updated inside process(), which
a model-based controller (Smith) doesn't run until its plant params are
configured - which now only happens once a Sud is loaded (see the
"inert until loaded" change) - so theta_ist can still be sitting at its
never-updated __init__ default of 0 the instant send_forecast() reads it.
Added TempControllerBase.get_theta_ist_set() (mirroring the existing
get_theta_soll_set()) - the raw sensor reading, updated the instant a
reading comes in regardless of whether process() has ever run - and used
that instead.

That still leaves a gap between Load and Start: the real temperature can
drift (time passing, manual heating via the Manual tab) between loading a
schedule and actually starting it, leaving the forecast anchored to a
now-stale temperature. recv()'s 'Start' handler now re-sends the forecast,
freshly anchored to the real temperature at that moment, on every fresh
start (not a Pause->resume, which keeps the already-established forecast
rather than discarding it mid-run).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 22:03:35 +02:00
jensandClaude Sonnet 4.6 4d98dacb49 Consolidate config.json.sim into config.json.templ
config.json.templ's component names already default to "sim" for every
backend (sensor/heater/stirrer/plant), making the separate .sim template
mostly redundant - it only differed in dt/sim_warp_factor/PID gain tuning,
which .templ now adopts directly. Removes config.json.sim, updates
brewpi.py's CLI args (-m/--logdir replaces the unused -d/--model/--sim
flags), and refreshes the README/TODO references that pointed at the now-
removed file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 21:27:58 +02:00
jensandClaude Sonnet 4.6 5ccfcef679 Add explicit initial grain_mass/water_mass to the Sud doc
Sud now parses top-level grain_mass/water_mass (sibling to pot_mass/
pot_material/L/Td, defaulting to 0/0) - what's actually in the pot before
the brew starts, as opposed to default.step's grain_mass/water_mass, which
is just inert template filler. All sude/*.json docs gain explicit values
matching what their first step already resolved to.

tasks/sud.py's apply_plant_params() now takes grain_mass/water_mass
directly instead of a step dict: on_step_changed() still passes the active
step's own (these vary as malt goes in/water boils off), but the Load
handler now reads Sud.grain_mass/water_mass directly instead of parsing
schedule[0]. The GUI's status-line mass preview (shown immediately on
Load, before Start) does the same.

Also fixes a latent bug found along the way: _continue_forecast_after_confirm()'s
reconstructed sub-doc was missing L/Td/grain_mass/water_mass entirely,
silently falling back to generic defaults instead of the real Sud's own
values - invisible only because every current sude/*.json happens to use
those same defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 20:19:25 +02:00
jensandClaude Sonnet 4.6 77e68afef1 Keep plant/temp control inert until a Sud is actually loaded
server/brewpi.py no longer pre-configures the real Pot's or Smith's model's
plant params at startup - there's no longer any generic baseline at all,
since the only source of truth is now a loaded Sud's own doc (applied via
tasks/sud.py's SudTask.apply_plant_params(), on Load and on every step).
Ambient temperature and PID gains stay configured at startup, since
they're independent of any Sud (global setting / hardware tuning, not
doc-derived).

Add Pot.is_configured() (already existed)/TempControllerBase.is_configured()/
TempController(Smith).is_configured(), and have tasks/pot.py's PotTask and
tasks/tempctrl.py's TcTask skip process() while not yet configured instead
of letting it raise - so plant and temp control are genuinely inert (not
crashing) until a Sud is loaded. "Normal" has no plant-model dependency,
so it stays active regardless.

Also refreshes README.md: removes stale tracer.py/.mat references (already
removed in an earlier commit), documents SudLogTask's JSON logs, the doc's
L/Td fields, and this inert-until-loaded behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 20:05:33 +02:00
jensandClaude Sonnet 4.6 868c35f4e8 SudForecastEstimator no longer needs plant_params - the doc supplies it
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
2026-06-22 19:01:28 +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 84b1607c78 Forecast a Sud schedule through its confirm steps, not just up to the first one
A user_wait_for_continue step used to stop SudForecastEstimator.estimate()
dead, so the GUI's forecast plot only ever showed the first segment on Load.
Model the wait as a zero-delay auto-confirm instead, so the whole schedule's
projected curve is visible right away; correct that assumption piecewise as
real confirmations actually happen, anchored at the real elapsed time and
temperature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk
2026-06-22 17:12:19 +02:00
jensandClaude Sonnet 4.6 57b86bc075 Make the Sud forecast piecewise, computed once per segment
Previously, the dynamic forecast's projected remainder was fully
recomputed on every step change, and a step requiring user
confirmation was simulated with zero added delay ("count it as
instant for estimation purposes"). Both undercut the actual goal of
the forecast: an honest comparison between real control behavior and
a simulation-based prediction. A forecast that keeps re-anchoring
itself to match reality isn't a useful baseline to compare reality
against, and assuming zero confirmation delay quietly understates the
schedule.

components/sud_forecast.py: SudForecastEstimator.estimate() now stops
simulating at the first step requiring user confirmation instead of
auto-confirming, returning the final SudState alongside the data so
the caller knows whether it stopped there or actually finished.

tasks/sud.py: SudTask now accumulates the forecast across piecewise
segments (forecast_t/forecast_theta). send_forecast() computes only
the first segment, at Load/Save. The real Confirm handler triggers
_continue_forecast_after_confirm(), which computes the next segment
anchored at the real elapsed time and real current temperature -
bridging the unforecastable wait with a flat segment rather than
guessing at its length - and sends the updated, stitched Forecast
(now carrying a Finished flag). The old per-step-change
RemainingForecast recompute is gone entirely, along with
remaining_schedule()/send_remaining_forecast().

client/brewpi_gui.py: SudForecastPlot redesigned around this - the
dashed line is the fixed, piecewise forecast (locked axes, untouched
except when a genuinely new segment arrives via show_forecast());
the solid line is purely the actual measured trace
(show_dynamic(), now only touching that). extend_forecast_while_
waiting() repeats the forecast's last value while the real Sud sits
in WAIT_USER, so the dashed line doesn't just stop, then snaps to the
new segment the moment the real confirmation appends one.

Verified via a raw-protocol test (segment boundaries, the Finished
flag, and real-time stitching across a confirm delay) and visually in
the GUI (locked dashed forecast, "X min so far total" title while
incomplete, solid/dashed comparison rendering with realistic
convergence/divergence).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 16:33:40 +02:00
jensandClaude Sonnet 4.6 44b1b0c705 Have Sud report its own tick-counted elapsed time to clients
Window._elapsed_min() (client/brewpi_gui.py) reconstructed the dynamic
forecast's x-axis from wall-clock time times the server's nominal
configured sim_warp_factor. But that warp factor is only nominal -
real asyncio/Python scheduling overhead across the 7 concurrent tasks
means the actually-achieved speedup runs measurably below it (~80x
instead of 100x, confirmed by timing HoldRemaining countdowns against
real elapsed time), especially under heavy message/print load. Since
the GUI's reconstruction assumed a precise, constant mapping, the live
measured trace visibly drifted behind the forecast - most visible on
short, transition-dense test schedules where the timing slip is a
large fraction of the total duration.

components/sud.py: Sud now tracks 'elapsed' (tick-counted simulated
seconds since the run started, frozen while PAUSED/IDLE/DONE, reset on
start()) - the ground truth for run progress, immune to real-world
pacing jitter. tasks/sud.py broadcasts it as {'Sud': {'Elapsed': ...}}.

client/brewpi_gui.py: replaced sud_start_time/sud_paused_total/
sud_pause_started_at and all the wall-clock reconstruction in
_elapsed_min() with sud_elapsed_seconds, set directly from the
server's 'Elapsed' broadcasts - simpler too, since pause-freezing is
now handled server-side rather than needing separate client-side
bookkeeping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
2026-06-22 11:21:39 +02:00