From 858249f1e7444752249e19f697f3a4ab10ee450a Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 21 Jun 2026 13:41:20 +0200 Subject: [PATCH] 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. --- components/pid/temp_controller_base.py | 72 +++++++++++++------ components/pid/temp_controller_smith.py | 2 +- config.json.sim | 6 ++ config.json.templ | 14 ++-- scripts/demos/pid/demo_temp_controller.py | 7 ++ .../demos/pid/demo_temp_controller_smith.py | 7 ++ scripts/demos/sud/demo_sud.py | 11 ++- 7 files changed, 90 insertions(+), 29 deletions(-) diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index e4c3157..1bd1557 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -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() diff --git a/components/pid/temp_controller_smith.py b/components/pid/temp_controller_smith.py index c3008b0..6ca8ecf 100755 --- a/components/pid/temp_controller_smith.py +++ b/components/pid/temp_controller_smith.py @@ -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) diff --git a/config.json.sim b/config.json.sim index 6aa2d9d..5774f4c 100755 --- a/config.json.sim +++ b/config.json.sim @@ -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": { diff --git a/config.json.templ b/config.json.templ index dc58ee1..15430d1 100644 --- a/config.json.templ +++ b/config.json.templ @@ -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": { diff --git a/scripts/demos/pid/demo_temp_controller.py b/scripts/demos/pid/demo_temp_controller.py index 9b6e765..e4a80b6 100644 --- a/scripts/demos/pid/demo_temp_controller.py +++ b/scripts/demos/pid/demo_temp_controller.py @@ -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) diff --git a/scripts/demos/pid/demo_temp_controller_smith.py b/scripts/demos/pid/demo_temp_controller_smith.py index 0723ae7..e9ca2ef 100644 --- a/scripts/demos/pid/demo_temp_controller_smith.py +++ b/scripts/demos/pid/demo_temp_controller_smith.py @@ -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) diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index d3f8ee3..329f832 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -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)