fix: clamp pid_hold output to [0,1] in HOLD state to break limit cycle

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:13:30 +02:00
co-authored by Claude Sonnet 4.6
parent b3923fb1c0
commit bb5af3c0ed
+9 -5
View File
@@ -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