From bb5af3c0ede75fdf6c0491b53ed0425a04d6fd84 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Tue, 30 Jun 2026 21:13:30 +0200 Subject: [PATCH] fix: clamp pid_hold output to [0,1] in HOLD state to break limit cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During hold, a small temperature overshoot causes pid_hold.y to go slightly negative, inverting heatrate_soll and driving pid_heat's power to 0. With no power the pot coasts back down, pid_hold goes positive again, power builds back up, overshoot repeats — a ~110s limit cycle visible as pumping in the power trace. Clamping pid_hold.y to [0, 1] in HOLD state lets the outer loop reduce the inner rate setpoint to zero (stop adding heat) but not invert it (actively demand cooling), breaking the limit cycle while preserving normal HEAT/COOL cascade behaviour. Note: Hold.kt must remain 0 when Hold.ki=0 — a non-zero kt drains pid_hold.yi during ramp saturation, suppressing heatrate_soll at the start of each hold phase and leaking into the next ramp via the bumpless HOLD→HEAT transfer. Co-Authored-By: Claude Sonnet 4.6 --- components/pid/temp_controller_base.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index f58d68d..2a380b5 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -132,11 +132,15 @@ class TempControllerBase(TempControllerFsm, APid): def process_pid(self, theta_err, hold_scale=1.0): self.pid_hold.process(theta_err, -self.theta_ist, hold_scale) - # Compute heatrate_soll HERE, after pid_hold ticks, so it uses - # this tick's output rather than last tick's — eliminates the - # one-tick cascade delay that caused p_set to dip near zero on - # the first tick of a new ramp (HOLD→HEAT transition). - self.heatrate_soll = self.heatrate_soll_set * self.pid_hold.get_y() + # In HOLD state, clamp pid_hold's output to [0, 1]: a small temperature + # overshoot makes pid_hold.y go negative, which would invert heatrate_soll + # and drive pid_heat's power to 0, causing a limit cycle (power on → + # overshoot → power off → coast down → repeat). Clamping to 0 lets the + # outer loop reduce the inner setpoint to zero but no further. + pid_hold_y = self.pid_hold.get_y() + if self.state == States.HOLD: + pid_hold_y = max(0.0, pid_hold_y) + self.heatrate_soll = self.heatrate_soll_set * pid_hold_y heatrate_err = self.heatrate_soll - self.heatrate_ist # Only the PID actually driving y is advanced - otherwise the