fix: split inner-loop yi_max clamp to fix HOLD-state windup overshoot
Renames pid_hold/pid_heat/pid_cool to pid_outer/pid_inner/pid_inner_cool
to match what actually runs when, and splits inner-loop config into
Inner.Heat/Inner.Hold/Inner.Cool so the same PID instance gets a tight
yi_max ceiling only while HOLD drives it, without capping legitimate
1.5 K/min ramps. Fixes the overshoot from docs/overshoot_hold_windup.md
where a cold-water disturbance during HOLD wound up pid_heat's integral
term with no anti-windup engagement, taking ~35s+ to unwind naturally.
Breaking config change: Hold/Heat/Cool -> Outer/Inner.{Heat,Hold,Cool}
in config.json, both .tpl templates, the pid/sud demo scripts, and
replay_sim.py's CLI flags. Adds tests/components/pid/ (stdlib unittest)
covering the Pid clamp/recovery behavior and closed-loop disturbance,
ramp, and HOLD<->HEAT transition cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
This commit is contained in:
@@ -8,9 +8,9 @@ DEFAULT_THRESHOLDS = {
|
||||
# when COOL meant nothing more than going idle - it's an active state
|
||||
# with its own PID now, so a threshold this tight relative to a real
|
||||
# heater's discrete power steps/sensor noise causes the system to
|
||||
# chatter in and out of it every tick, resetting both pid_hold's and
|
||||
# pid_cool's integrators each time and never letting either actually
|
||||
# converge. Symmetric with the others instead.
|
||||
# chatter in and out of it every tick, resetting both pid_outer's and
|
||||
# pid_inner_cool's integrators each time and never letting either
|
||||
# actually converge. Symmetric with the others instead.
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
@@ -30,14 +30,14 @@ class States(enum.Enum):
|
||||
class TempControllerFsm:
|
||||
|
||||
def __init__(self, dt):
|
||||
self.pid_hold = Pid(dt)
|
||||
self.pid_heat = Pid(dt)
|
||||
self.pid_outer = Pid(dt)
|
||||
self.pid_inner = 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.pid_inner_cool = Pid(dt)
|
||||
self.thresholds = None
|
||||
self.state = States.INIT
|
||||
self.is_startup = True
|
||||
@@ -80,13 +80,13 @@ class TempControllerFsm:
|
||||
# which calls this method directly), so an unconditional HOLD
|
||||
# here gets mistaken for "ramp already reached" and can finish
|
||||
# a freshly (re-)started step instantly - see SudTask.
|
||||
# on_process()'s is_holding() check. pid_heat/pid_cool were
|
||||
# frozen (see process_pid()) and possibly stale for as long as
|
||||
# we were disabled - start whichever one matters clean rather
|
||||
# on_process()'s is_holding() check. pid_inner/pid_inner_cool
|
||||
# were frozen (see process_pid()) and possibly stale for as long
|
||||
# as we were disabled - start whichever one matters clean rather
|
||||
# than resuming wherever it last left off.
|
||||
self.pid_hold.reset()
|
||||
self.pid_heat.reset()
|
||||
self.pid_cool.reset()
|
||||
self.pid_outer.reset()
|
||||
self.pid_inner.reset()
|
||||
self.pid_inner_cool.reset()
|
||||
if diff >= self.thresholds['HoldHeat']:
|
||||
state_next = States.HEAT
|
||||
elif diff <= -self.thresholds['HoldCool']:
|
||||
@@ -96,31 +96,31 @@ class TempControllerFsm:
|
||||
elif self.state == States.HOLD:
|
||||
if diff >= self.thresholds['HoldHeat']:
|
||||
state_next = States.HEAT
|
||||
# No pid_heat.reset() here — bumpless transfer: carry the
|
||||
# No pid_inner.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()
|
||||
self.pid_inner_cool.reset()
|
||||
elif self.state == States.HEAT:
|
||||
if diff <= -self.thresholds['HeatCool']:
|
||||
state_next = States.COOL
|
||||
self.pid_cool.reset()
|
||||
self.pid_inner_cool.reset()
|
||||
elif diff <= self.thresholds['HeatHold']:
|
||||
state_next = States.HOLD
|
||||
self.pid_hold.reset()
|
||||
self.pid_outer.reset()
|
||||
elif self.state == States.COOL:
|
||||
if diff >= self.thresholds['CoolHeat']:
|
||||
state_next = States.HEAT
|
||||
self.pid_heat.reset()
|
||||
self.pid_inner.reset()
|
||||
elif diff >= -self.thresholds['CoolHold']:
|
||||
state_next = States.HOLD
|
||||
self.pid_hold.reset()
|
||||
# pid_heat was frozen during COOL (see process_pid()) -
|
||||
self.pid_outer.reset()
|
||||
# pid_inner was frozen during COOL (see process_pid()) -
|
||||
# resume it clean rather than from whatever it last held
|
||||
# before COOL took over, which by now may be a stale fit
|
||||
# for a completely different part of the curve.
|
||||
self.pid_heat.reset()
|
||||
self.pid_inner.reset()
|
||||
|
||||
if state_next != self.state:
|
||||
self.state = state_next
|
||||
|
||||
Reference in New Issue
Block a user