Files
brewpi/components/pid/temp_controller_base.py
T
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

164 lines
4.6 KiB
Python

from components import APid
from components.pid.pid import Pid
import enum
DEFAULT_THRESHOLDS = {
"HoldHeat": 1.0,
"HoldCool": 0.1,
"HeatHold": 1.0,
"HeatCool": 1.0,
"CoolHold": 1.0,
"CoolHeat": 1.0,
}
class States(enum.Enum):
INIT = -1,
IDLE = 0,
HEAT = 1,
HOLD = 2,
COOL = 3
class TempControllerBase(APid):
def __init__(self, dt, params):
APid.__init__(self)
self.pid_hold = Pid(dt)
self.pid_rate = Pid(dt)
# Separate gains for ramping down (negative diff) - the actuator
# (e.g. a heat-only Pot/heater) is responsible for clamping the
# resulting negative power to whatever it's actually capable of;
# the controller itself no longer assumes "can't cool" == "must go
# idle".
self.pid_cool = Pid(dt)
self.theta_ist_set = 0
self.theta_soll_set = 0
self.heatrate_ist_set = 0
self.heatrate_soll_set = 1.0
self.heatrate_soll = 1.0
self.theta_ist = 0
self.heatrate_ist = 0
self.params = params
self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})}
self.y = -1
self.state = States.INIT
self.pid_hold.set_params(params['Hold'])
self.pid_rate.set_params(params['Heat'])
self.pid_cool.set_params(params['Cool'])
self.is_startup = True
# Explicit override for a ramp step whose target is below the
# current temperature: forces y to 0 immediately rather than
# waiting for the FSM's own (slower, hysteresis-based) COOL
# transition, as soon as the caller (SudTask) knows the step needs
# to passively cool down. Mostly superseded by the COOL state
# below for a heat-only actuator (both end up clamped to 0
# power) - kept for the immediate, decided-in-advance response.
self.cooling = False
# Master on/off switch: while disabled, the FSM is held in IDLE and
# the controller tries not to drive the heater at all (output
# forced to 0) - off by default, enabled either by the user
# (manual mode) or by SudTask for the duration of a run.
self.enabled = False
def on_state_entered(self, state):
pass
def post_pid(self):
pass
def set_theta_ist(self, value):
self.theta_ist_set = value
if self.is_startup:
self.is_startup = False
def get_theta_ist(self):
return self.theta_ist
def set_heatrate_ist(self, value):
self.heatrate_ist_set = value
def get_heatrate_ist(self):
return self.heatrate_ist
def set_theta_soll(self, value):
self.theta_soll_set = value
def get_theta_soll(self):
return self.theta_soll
def get_theta_soll_set(self):
return self.theta_soll_set
def set_heatrate_soll(self, value):
self.heatrate_soll_set = value
def set_cooling(self, value):
self.cooling = value
def set_enabled(self, value):
self.enabled = value
def get_heatrate_soll(self):
return self.heatrate_soll
def get_heatrate_soll_set(self):
return self.heatrate_soll_set
def process_fsm(self, diff):
state_next = self.state
if self.state == States.INIT:
# Wait for a real sensor reading before acting on anything,
# regardless of enabled - avoids reacting to the bogus
# theta_ist=0 default.
if not self.is_startup:
state_next = States.IDLE
elif not self.enabled:
state_next = States.IDLE
elif self.state == States.IDLE:
# Just (re-)enabled - land in HOLD; the very next tick's
# threshold check (below) moves it on to HEAT/COOL if the gap
# actually warrants it.
state_next = States.HOLD
self.pid_hold.reset()
elif self.state == States.HOLD:
if diff >= self.thresholds['HoldHeat']:
state_next = States.HEAT
self.pid_rate.reset()
elif diff <= -self.thresholds['HoldCool']:
state_next = States.COOL
self.pid_cool.reset()
elif self.state == States.HEAT:
if diff <= -self.thresholds['HeatCool']:
state_next = States.COOL
self.pid_cool.reset()
elif diff <= self.thresholds['HeatHold']:
state_next = States.HOLD
self.pid_hold.reset()
elif self.state == States.COOL:
if diff >= self.thresholds['CoolHeat']:
state_next = States.HEAT
self.pid_rate.reset()
elif diff >= -self.thresholds['CoolHold']:
state_next = States.HOLD
self.pid_hold.reset()
if state_next != self.state:
self.state = state_next
self.on_state_entered(state_next)
def process_pid(self, theta_err, heatrate_err, hold_scale=1.0):
self.pid_hold.process(theta_err, -self.theta_ist, hold_scale)
self.pid_rate.process(heatrate_err, -self.heatrate_ist)
self.pid_cool.process(heatrate_err, -self.heatrate_ist)
if self.state == States.IDLE or self.cooling:
self.y = 0
elif self.state == States.COOL:
self.y = self.pid_cool.get_y()
else:
self.y = self.pid_rate.get_y()
self.post_pid()
def get_power(self):
return self.y