IDLE conflated two unrelated things: "controller disabled" and "needs
to cool, which this heat-only actuator fakes via zero output." Split
them apart:
- IDLE now means disabled only - the FSM forces it whenever
enabled=False, regardless of the temperature gap, and process_pid()
zeroes y for it same as before.
- New COOL state (mirroring HEAT) takes over the negative-diff case,
with its own pid_cool (separate "Cool" gains, alongside Hold/Heat)
instead of reusing pid_rate. Its output can legitimately be negative
- it's the actuator (tasks/heater.py's actor(), already max(0, ...))
that clamps it to 0 because *this* plant (Pot) can only heat. A
plant with real cooling capability could one day honor it directly.
- Thresholds renamed accordingly (HoldIdle->HoldCool, HeatIdle->
HeatCool, new CoolHold/CoolHeat); config.json/.sim/.templ and the
hand-rolled ctrl_params in scripts/demos/pid/ and demo_sud.py
updated with a "Cool" params section (mirrors "Heat" for now, since
there's no real cooling actuator to tune against yet).
Also fixes a regression in demo_sud.py/the other PID demos: none of
them ever called set_enabled(True), so since enabled defaults to
False they never drove the heater at all - only caught because this
change's demo_sud.py re-run got stuck in RAMPING forever.
New TempControllerBase.enabled (default False) forces y=0 in
process_pid() when off, same mechanism as the existing cooling
override - the controller tries not to drive the heater at all.
tasks/tempctrl.py: new 'Enable' recv command and 'Enabled' broadcast
on the TempCtrl channel. tasks/sud.py: SudTask now enables the
controller on any non-IDLE/DONE Sud state and disables it (forcing
power back to 0) on IDLE/DONE, so automatic mode owns it for the
duration of a run and hands back manual control once it stops/finishes.
gui: new "Enabled" checkbox on the Manual tab's Controller group,
synced from the server; the temp/heatrate setpoint controls are
disabled whenever the controller itself is disabled.
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.
Pid.scale(k) set self.k as persistent state, mutated from outside the
class and never reset in reset(), so a stale scale factor could survive
a state-transition reset. Replace it with a plain scale=1.0 argument on
Pid.process(), and have temp_controller.py/temp_controller_smith.py
compute the heat-rate-overshoot compensation factor themselves and pass
it through process_pid() each tick instead of mutating pid_hold's state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tc_constants.py only held States and DEFAULT_THRESHOLDS, both used
exclusively by temp_controller_base.py and its subclasses; fold them
into temp_controller_base.py directly and drop the now-empty module.
Also drop heat_diffusion.py, unused since pot.py switched to the
delay-line model, and the matching dead import in pot.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
THRESH_HOLD_IDLE/HOLD_HEAT/IDLE_HEAT/IDLE_HOLD/HEAT_HOLD/HEAT_IDLE in
tc_constants.py were hardcoded module-level constants shared by every
controller instance, so tuning the IDLE/HOLD/HEAT hysteresis required
a code change.
Replace them with DEFAULT_THRESHOLDS plus an optional
TempCtrl.Thresholds config section, merged at construction time in
TempControllerBase.__init__ so configs that omit the section keep
behaving exactly as before. Documented the new section in
config.json.templ; left config.json.sim without it to exercise the
default-fallback path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TempControllerBase no longer threads model_params through (only the
Smith subclass needs it, for its own Pot model and Kalman filters).
Enable the Smith predictor's actual delay-compensated error term
(theta_err now uses theta_ist_plant - theta_ist_model_delay +
theta_ist_model instead of the plain plant reading), which is the
correction this controller is named for.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both controllers had nearly identical process_fsm(), process_pid(),
and all getters/setters; only Kalman/model setup and process()
genuinely differed, and the duplication had already drifted (the
Smith variant resets model state on entering HEAT, the plain one
didn't).
Add TempControllerBase with the shared logic and three small hooks
(init_kalman, on_state_entered, post_pid) subclasses use to plug in
their own Kalman/model behavior. Each subclass now contains only what
makes it different.
Also fixes a latent crash: TempController's constructor only accepted
(dt, params), but PidFactory/brewpi.py always call it with a third
model_params arg, so pid_type "Normal" would have raised TypeError.
The shared base's model_params=None default fixes this. Also
initializes the Smith controller's trace attributes in __init__
instead of leaving them undefined until the first process() call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>