fix: smooth HOLD→HEAT power transition (bumpless transfer + cascade timing)

Two root causes for the visible power dip at every ramp-to-ramp
step boundary:

1. One-tick cascade delay: heatrate_soll was computed from
   pid_hold.get_y() before pid_hold.process() ran in that tick,
   so the first tick of a new ramp used the stale hold-phase
   output (≈0) as the rate setpoint, driving pid_heat to near
   zero.  Fix: move heatrate_soll computation inside process_pid(),
   after pid_hold.process().

2. Cold-start reset: pid_heat.reset() at HOLD→HEAT discarded
   the hold-phase integral, so pid_heat had to ramp up from
   zero on every new step.  Fix: remove the reset (bumpless
   transfer) — the hold-phase integral is a warm starting point
   that carries the baseline hold power into the new ramp.

Together these eliminate the ~1s dip to near-zero and replace
the slow 20–30s integral ramp-up with an immediate start at
roughly hold-level power, climbing monotonically to the target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:33:12 +02:00
co-authored by Claude Sonnet 4.6
parent e7824dc297
commit b3923fb1c0
4 changed files with 12 additions and 10 deletions
+1 -4
View File
@@ -16,10 +16,7 @@ class TempController(TempControllerBase):
# Compensate for max heat rate to reduce overshoot
hold_scale = 1.0/self.heatrate_soll_set if self.heatrate_soll_set > 0 else 1.0
self.heatrate_soll = self.heatrate_soll_set * self.pid_hold.get_y()
theta_err = self.theta_soll_set - self.theta_ist
heatrate_err = self.heatrate_soll - self.heatrate_ist
diff = self.theta_soll_set - self.theta_ist
self.process_fsm(diff)
self.process_pid(theta_err, heatrate_err, hold_scale)
self.process_pid(theta_err, hold_scale)
+7 -1
View File
@@ -130,8 +130,14 @@ class TempControllerBase(TempControllerFsm, APid):
def get_heatrate_soll_set(self):
return self.heatrate_soll_set
def process_pid(self, theta_err, heatrate_err, hold_scale=1.0):
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()
heatrate_err = self.heatrate_soll - self.heatrate_ist
# Only the PID actually driving y is advanced - otherwise the
# inactive one (e.g. pid_heat while COOL has pid_cool driving)
+3 -1
View File
@@ -96,7 +96,9 @@ class TempControllerFsm:
elif self.state == States.HOLD:
if diff >= self.thresholds['HoldHeat']:
state_next = States.HEAT
self.pid_heat.reset()
# No pid_heat.reset() here — bumpless transfer: carry the
# hold-phase integral into the new ramp so power doesn't
# drop to near-zero and crawl back up from scratch.
elif diff <= -self.thresholds['HoldCool']:
state_next = States.COOL
self.pid_cool.reset()
+1 -4
View File
@@ -64,10 +64,7 @@ class TempController(TempControllerBase):
# Compensate for max heat rate to reduce overshoot
hold_scale = 1.0/self.heatrate_soll_set if self.heatrate_soll_set > 0 else 1.0
self.heatrate_soll = self.heatrate_soll_set * self.pid_hold.get_y()
theta_err = self.theta_soll_set - self.theta_ist
heatrate_err = self.heatrate_soll - self.heatrate_ist
diff = self.theta_soll_set - self.theta_ist
self.process_fsm(diff)
self.process_pid(theta_err, heatrate_err, hold_scale)
self.process_pid(theta_err, hold_scale)