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
9.8 KiB
components/pid design backlog
Findings from a design review, refreshed against the current state of
master after the Kalman-filter removal and Smith-predictor rewrite (see
git history for temp_controller.py/temp_controller_smith.py).
-
FSM thresholds aren't configurable. Moved into an optional
TempCtrl.Thresholdsconfig section (HoldIdle,HoldHeat,IdleHeat,IdleHold,HeatHold,HeatIdle), merged overDEFAULT_THRESHOLDS(now intemp_controller_base.py, formerlytc_constants.py) so existing configs without the section keep working unchanged. -
matplotlib imported at module level in production code.
temp_controller.py,temp_controller_smith.py, andkalman.pyused tofrom matplotlib.pyplot import ...just to support their__main__self-test plots. The plotting demos (andkalman_eval.py) moved toscripts/demos/pid/demo_*.py; production modules no longer import matplotlib. -
Three Kalman filters share one tuning. No longer applicable:
temp_controller_smith.pywas rewritten to drop Kalman filtering entirely (see below), so there's no shared tuning to split anymore. -
Pid.scale()is a gain-scheduling hack, not anti-windup. Fixed: removedPid.scale()/self.kentirely.Pid.process(err, d, scale=1.0)now takes the gain multiplier as a plain per-call argument instead of mutable state, so there's nothing to go stale across areset().temp_controller*.pycomputehold_scale = 1.0/heatrate_soll_set if heatrate_soll_set > 0 else 1.0themselves and pass it throughprocess_pid(theta_err, heatrate_err, hold_scale)— the overshoot-compensation scheduling logic now lives entirely in the temp controller, not the genericPidblock. -
No automated tests for the heat-rate filtering or Smith correction specifically. An earlier
tests/components/pid/suite (stdlibunittest, no pytest) covering FSM transitions, anti-windup clamping, and Kalman convergence was removed before the Kalman-filter removal/Smith rewrite. A newtests/components/pid/suite was added alongside the HOLD-windup fix below (test_pid.py,test_temp_controller_closed_loop.py) covering theyi_maxclamp and closed-loop disturbance/ramp/transition behavior, but_compute_heatrate()'s backward-difference + low-pass filtering and the two-model Smith correction itself still have no dedicated coverage. This drives a physical heater — worth extending. -
set_model_powerisn't defined on every controller, butbrewpi.pywires it unconditionally.brewpi.pyalways doesheater.set_on_changed("power_set", tc.set_model_power)regardless ofController.pid_type. Onlytemp_controller_smith.py(the Smith predictor) definesset_model_power;temp_controller.py("Normal") does not, so starting the server with"pid_type": "Normal"crashes at wiring time withAttributeError: 'TempController' object has no attribute 'set_model_power'. Either add a no-opset_model_powertoTempControllerBase, or only wire it when the configured controller actually exposes a model to feed. -
Config access is unchecked
dict[key]everywhere.params['Hold'],params['Td'], etc., throughout, with no schema validation at load time. We already hit this bug class once (config.json.sim'sgain/Modelnesting mismatch). A small schema/dataclass validation layer at config-load would surface errors immediately instead of mid-__init__— and would have caught the deadTempCtrl.Kalman/Model.kn/Plant.knkeys that used to sit unused inconfig-sim.json.tpl/.sim(since deleted), and theModel.gain/Plant.gainkeys that did the same inconfig.json.simbefore that file was removed entirely —Potdroppedgainentirely, seecomponents/plant/TODO.md. -
pid_heatcan wind up during aHOLD-state disturbance with no anti-windup engagement. A cold-water disturbance while holding drovepid_heat's integral term up without ever saturatingy(peaked aty≈0.74of the1.0ceiling), so the existing back-calculation anti-windup (Pid.process(),pid.py:45-48) never triggered — the FSM never even leftHOLD(diffstayed underHoldHeat=1.0). The resulting overshoot took ~35s+ to unwind naturally. A flatyi_maxclamp was considered and rejected: sustaining a genuine 1.5 K/min ramp needsy≈0.7-0.75fromyialone at steady state, the same range the disturbance itself peaked at, so no single clamp value can suppress the windup without also capping legitimate ramps. An FSM-gating alternative (freeze the loop's output inHOLDunless engaged) was also superseded. Fixed: renamedpid_hold/pid_heat/pid_cooltopid_outer/pid_inner/pid_inner_cool(matching what actually runs when), and split the inner loop's config intoInner.Heat/Inner.Hold/Inner.Coolso the same PID instance gets a tightyi_maxonly whileHOLDis driving it and stays unclamped for realHEATramps — no freeze/thaw, bumpless transfer preserved for free. Seedocs/overshoot_hold_windup.mdfor the full writeup, anddocs/fsm_states.pngfor a static screenshot of the FSM-states panel of the cascade architecture diagram (states/thresholds, and the HOLD→HEAT no-reset note). The full interactive diagram (signal-flow cascade + FSM inset + config-mapping table) is a Claude Artifact, not a repo file: https://claude.ai/code/artifact/a32e4752-b4a6-4153-b344-eb2423eb6512 — only reachable by whoever has access to the Claude account/session that created it, not a durable link for the team;docs/fsm_states.pngis the durable copy. This was also a breaking config change (Hold/Heat/Cool→Outer/Inner.*) —config.json, the templates, and the demo scripts were all migrated. Test coverage (closed-loop disturbance/ramp/transition cases) was added undertests/components/pid/— see the "No automated tests" item above. -
HOLD overshoot was a permanent steady-state offset, not just a transient windup. Found testing the rework against a real Sud run (
sude/sud_0030.json): a grain-fill-in disturbance during aHOLDovershot by ~0.4°C (under theHoldCoolthreshold, so the FSM stayed inHOLD) and then never converged back —temp_istsat in a 55.3-55.4°C band for the rest of the 20-minute hold instead of returning to 55.0. Cause:process_pid()'s HOLD-state floor onpid_outer_yclamped to exactly0.0, so once overshot,heatrate_soll= 0 ("hold flat") andpid_inneractively fought the pot's own ambient loss to keep the overshot temperature flat instead of declining back to setpoint. Fixed: the floor is now a configurableOuter.y_hold_min(default0.0, backward compatible), set to-0.1inconfig.json/both.tpltemplates, letting HOLD request a gentle decline that roughly matches passive ambient cooling without reopening thebb5af3climit cycle (fully unclamped negative). See the "Follow-up" section indocs/overshoot_hold_windup.mdandTestHoldOvershootRecoversToSetpointintests/components/pid/test_temp_controller_closed_loop.py. -
Architecture isn't ready for a real active cooler yet, only a heat-only actuator. Asked (not yet built): would the cascade/FSM design still hold up if an active cooler (compressor/chiller, some finite cooling power) replaced "heater off + passive ambient loss"? Mostly yes at the PID/plant-model level:
Pid.y_min/y_maxare already symmetric-1.0/1.0(pid.py:12-13),Pot.set_power()/process()are sign-agnostic so the simulator already models negative power correctly, and the FSM already has a dedicatedCOOLstate with its own PID instance (pid_inner_cool,temp_controller_fsm.py:35-39) whose comment explicitly anticipates this ("the actuator ... is responsible for clamping the resulting negative power to whatever it's actually capable of"). The gap is at the actuator boundary: todayyonly ever reaches one consumer,tasks/heater.py:59'sself.power_actor = max(0, ... * y), which silently discards every negative command — that's what's made tuning mismatches free so far. A real cooler needs (a) a second actuator/device class consuming the negative half instead of thatmax(0, ...)clamp — there's noCooler/chiller abstraction anywhere yet (grep -rniE "cooler|chiller|compressor"across all.pyis empty, fully greenfield), andheater_hendi.py:80'sset_poweronly clamps to a max, not a min, so feeding it a negative value today would callsetPowerWatts(negative)on real hardware with undefined result; and (b) its own power ceiling, since one normalizedyscaled by oneget_power_max()breaks once heater and cooler have different max power. Biggest risk once that clamp is removed: two separate paths start issuing real negative-power commands that were previously harmless - HOLD'sOuter.y_hold_minfloor (viaInner.Hold, see the steady-state overshoot item above) and the pre-existingpid_inner_cool(viaInner.Cool) - and neither has been tuned against actual active-cooling dynamics (compressor lag, min-on-time).Inner.Coolin the demo configs is literally a copy ofInner.Heat's gains, never validated independently. So adding a real cooler is a wiring task (new actuator class, per-actuator power scaling) plus a re-tuning task (Inner.Cool, and re-checking theHoldCool/CoolHoldthresholds intemp_controller_fsm.py:14-17), not just wiring. -
kalman.pyis now dead code in production. Neithertemp_controller.pynortemp_controller_smith.pyusesKalmananymore; the only remaining references arescripts/demos/pid/demo_kalman.pyanddemo_kalman_eval.py. Decide whether to keep it as a documented standalone filtering example or delete it along with the demos.