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.
This commit is contained in:
@@ -3,19 +3,20 @@ from components.pid.pid import Pid
|
||||
import enum
|
||||
|
||||
DEFAULT_THRESHOLDS = {
|
||||
"HoldIdle": 0.1,
|
||||
"HoldHeat": 1.0,
|
||||
"IdleHeat": 1.0,
|
||||
"IdleHold": 0.1,
|
||||
"HoldCool": 0.1,
|
||||
"HeatHold": 1.0,
|
||||
"HeatIdle": 1.0
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0,
|
||||
}
|
||||
|
||||
class States(enum.Enum):
|
||||
INIT = -1,
|
||||
IDLE = 0,
|
||||
HEAT = 1,
|
||||
HOLD = 2
|
||||
HOLD = 2,
|
||||
COOL = 3
|
||||
|
||||
class TempControllerBase(APid):
|
||||
|
||||
@@ -23,6 +24,12 @@ class TempControllerBase(APid):
|
||||
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
|
||||
@@ -36,16 +43,19 @@ class TempControllerBase(APid):
|
||||
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: the heater can't actively cool, so rather
|
||||
# than wait for the FSM's own (slower, hysteresis-based) IDLE
|
||||
# transition, force y to 0 as soon as the caller (SudTask) knows
|
||||
# the step needs to passively cool down.
|
||||
# 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 controller tries not to
|
||||
# drive the heater at all (output forced to 0) regardless of the
|
||||
# FSM/setpoints - off by default, enabled either by the user
|
||||
# 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
|
||||
|
||||
@@ -96,27 +106,40 @@ class TempControllerBase(APid):
|
||||
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:
|
||||
if diff >= self.thresholds['IdleHeat']:
|
||||
state_next = States.HEAT
|
||||
self.pid_rate.reset()
|
||||
elif diff >= -self.thresholds['IdleHold']:
|
||||
state_next = States.HOLD
|
||||
self.pid_rate.reset()
|
||||
# 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['HoldIdle']:
|
||||
state_next = States.IDLE
|
||||
elif diff <= -self.thresholds['HoldCool']:
|
||||
state_next = States.COOL
|
||||
self.pid_cool.reset()
|
||||
elif self.state == States.HEAT:
|
||||
if diff <= -self.thresholds['HeatIdle']:
|
||||
state_next = States.IDLE
|
||||
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
|
||||
@@ -125,9 +148,12 @@ class TempControllerBase(APid):
|
||||
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 or not self.enabled:
|
||||
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()
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class TempController(TempControllerBase):
|
||||
self.model_delay.set_ambient_temperature(theta_amb)
|
||||
|
||||
def on_state_entered(self, state):
|
||||
if state == States.HEAT:
|
||||
if state in (States.HEAT, States.COOL):
|
||||
self.model.initial(self.theta_ist)
|
||||
self.model_delay.initial(self.theta_ist)
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
"ki": 0.01,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.05,
|
||||
"ki": 0.01,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
|
||||
+10
-4
@@ -22,13 +22,19 @@
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.05,
|
||||
"ki": 0.01,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldIdle": 0.1,
|
||||
"HoldHeat": 1.0,
|
||||
"IdleHeat": 1.0,
|
||||
"IdleHold": 0.1,
|
||||
"HoldCool": 0.1,
|
||||
"HeatHold": 1.0,
|
||||
"HeatIdle": 1.0
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
|
||||
@@ -17,6 +17,12 @@ if __name__ == '__main__':
|
||||
"ki": 0.008,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.008,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +44,7 @@ if __name__ == '__main__':
|
||||
temp_soll = 20
|
||||
heatrate_soll = 1.25
|
||||
ctrl = TempController(dt, ctrl_params)
|
||||
ctrl.set_enabled(True)
|
||||
plant = Pot(dt, plant_params, theta_amb)
|
||||
_y = np.empty(0)
|
||||
_fb = np.empty(0)
|
||||
|
||||
@@ -17,6 +17,12 @@ if __name__ == '__main__':
|
||||
"ki": 0.008,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.008,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +44,7 @@ if __name__ == '__main__':
|
||||
temp_soll = 20
|
||||
heatrate_soll = 1.25
|
||||
ctrl = TempController(dt, ctrl_params, plant_params, theta_amb)
|
||||
ctrl.set_enabled(True)
|
||||
plant = Pot(dt, plant_params, theta_amb)
|
||||
_y = np.empty(0)
|
||||
_fb = np.empty(0)
|
||||
|
||||
@@ -31,6 +31,12 @@ if __name__ == '__main__':
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +105,12 @@ if __name__ == '__main__':
|
||||
def on_state_changed(state):
|
||||
if state == SudState.WAIT_USER and sud.user_message:
|
||||
print("Sud: {}".format(sud.user_message))
|
||||
elif state == SudState.DONE:
|
||||
if state in (SudState.DONE, SudState.IDLE):
|
||||
stirrer.set_duty_cycle(1.0)
|
||||
stirrer.set_speed(0)
|
||||
ctrl.set_enabled(False)
|
||||
else:
|
||||
ctrl.set_enabled(True)
|
||||
|
||||
sud.set_on_changed('step', on_step_changed)
|
||||
sud.set_on_changed('state', on_state_changed)
|
||||
|
||||
Reference in New Issue
Block a user