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:
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
|
||||
from components.pid.pid import Pid
|
||||
|
||||
|
||||
GAINS = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
|
||||
|
||||
|
||||
def _feed(pid, err_sequence):
|
||||
"""Runs pid.process(err, 0) over err_sequence, returns the y trace."""
|
||||
ys = []
|
||||
for err in err_sequence:
|
||||
pid.process(err, 0)
|
||||
ys.append(pid.get_y())
|
||||
return ys
|
||||
|
||||
|
||||
def _incident_err_sequence():
|
||||
"""Shaped like the HOLD-disturbance incident: a positive heatrate_err
|
||||
held for ~130 ticks (outer loop asking for real heat), then a negative
|
||||
tail (outer loop has zeroed its target while heatrate_ist is still
|
||||
high, matching the real rate_soll - rate_ist gap from the log)."""
|
||||
return [0.3] * 130 + [-0.3] * 200
|
||||
|
||||
|
||||
class TestPidYiMax(unittest.TestCase):
|
||||
def test_yi_never_exceeds_configured_bound(self):
|
||||
pid = Pid(dt=1.0)
|
||||
pid.set_params({**GAINS, "yi_max": 0.3})
|
||||
for err in _incident_err_sequence():
|
||||
pid.process(err, 0)
|
||||
self.assertLessEqual(abs(pid.yi), 0.3 + 1e-9)
|
||||
|
||||
def test_clamped_recovers_faster_than_unclamped(self):
|
||||
err_sequence = _incident_err_sequence()
|
||||
|
||||
clamped = Pid(dt=1.0)
|
||||
clamped.set_params({**GAINS, "yi_max": 0.3})
|
||||
ys_clamped = _feed(clamped, err_sequence)
|
||||
|
||||
unclamped = Pid(dt=1.0)
|
||||
unclamped.set_params(dict(GAINS))
|
||||
ys_unclamped = _feed(unclamped, err_sequence)
|
||||
|
||||
# Ticks after the error goes negative (index 130) until y drops
|
||||
# back under a small threshold.
|
||||
threshold = 0.05
|
||||
negative_start = 130
|
||||
|
||||
def recovery_time(ys):
|
||||
for i in range(negative_start, len(ys)):
|
||||
if ys[i] < threshold:
|
||||
return i - negative_start
|
||||
return len(ys) - negative_start
|
||||
|
||||
self.assertLess(recovery_time(ys_clamped), recovery_time(ys_unclamped))
|
||||
|
||||
def test_no_yi_max_configured_is_unbounded(self):
|
||||
pid = Pid(dt=1.0)
|
||||
pid.set_params(dict(GAINS))
|
||||
peak_yi = 0.0
|
||||
for err in _incident_err_sequence():
|
||||
pid.process(err, 0)
|
||||
peak_yi = max(peak_yi, pid.yi)
|
||||
self.assertGreater(peak_yi, 0.3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
import unittest
|
||||
|
||||
from components.pid.temp_controller_smith import TempController
|
||||
from components.pid.temp_controller_fsm import States
|
||||
from components.plant.pot import Pot
|
||||
|
||||
|
||||
DT = 1.0
|
||||
AMBIENT = 20.0
|
||||
PLANT_PARAMS = {"M": 27.96, "C": 3403.43, "L": 0.2, "Td": 17}
|
||||
|
||||
OUTER_PARAMS = {"kp": 0.6, "ki": 0.0, "kd": 0.0, "kt": 0.0}
|
||||
INNER_HEAT_PARAMS = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
|
||||
INNER_HOLD_CLAMPED = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5, "yi_max": 0.3}
|
||||
INNER_HOLD_UNCLAMPED = dict(INNER_HEAT_PARAMS)
|
||||
|
||||
|
||||
def make_controller(inner_hold_params):
|
||||
ctrl = TempController(DT)
|
||||
ctrl.set_params({
|
||||
"beta": 0.9,
|
||||
"Outer": dict(OUTER_PARAMS),
|
||||
"Inner": {
|
||||
"Heat": dict(INNER_HEAT_PARAMS),
|
||||
"Hold": dict(inner_hold_params),
|
||||
"Cool": dict(INNER_HEAT_PARAMS),
|
||||
},
|
||||
})
|
||||
ctrl.set_model_plant_params(PLANT_PARAMS)
|
||||
ctrl.set_ambient_temperature(AMBIENT)
|
||||
ctrl.set_enabled(True)
|
||||
return ctrl
|
||||
|
||||
|
||||
def make_plant(initial_temp):
|
||||
plant = Pot(DT)
|
||||
plant.set_plant_params(PLANT_PARAMS)
|
||||
plant.set_ambient_temperature(AMBIENT)
|
||||
plant.initial(initial_temp)
|
||||
return plant
|
||||
|
||||
|
||||
def tick(ctrl, plant, temp_soll, heatrate_soll):
|
||||
plant.process()
|
||||
ctrl.set_theta_ist(plant.get_temperature())
|
||||
ctrl.set_theta_soll(temp_soll)
|
||||
ctrl.set_heatrate_soll(heatrate_soll)
|
||||
ctrl.process()
|
||||
power = 3500.0 * max(0.0, ctrl.get_power())
|
||||
plant.set_power(power)
|
||||
ctrl.set_model_power(power)
|
||||
|
||||
|
||||
class TestHoldDisturbanceOvershoot(unittest.TestCase):
|
||||
"""Reproduces the cold-water-during-HOLD incident from
|
||||
docs/overshoot_hold_windup.md and checks that Inner.Hold's yi_max
|
||||
measurably reduces the resulting overshoot."""
|
||||
|
||||
def _run_disturbance(self, inner_hold_params):
|
||||
ctrl = make_controller(inner_hold_params)
|
||||
plant = make_plant(30.0)
|
||||
|
||||
# Settle at HOLD, 30 degC.
|
||||
for _ in range(60):
|
||||
tick(ctrl, plant, 30.0, 1.0)
|
||||
self.assertEqual(ctrl.state, States.HOLD)
|
||||
|
||||
# Cold-water disturbance: knock plant.temp down ~0.5 degC.
|
||||
plant.temp -= 0.5
|
||||
|
||||
peak = plant.get_temperature()
|
||||
for _ in range(600):
|
||||
tick(ctrl, plant, 30.0, 1.0)
|
||||
peak = max(peak, plant.get_temperature())
|
||||
# Disturbance must stay within HOLD the whole time, matching
|
||||
# the incident (diff never reached HoldHeat).
|
||||
self.assertEqual(ctrl.state, States.HOLD)
|
||||
|
||||
return peak
|
||||
|
||||
def test_clamped_overshoot_smaller_than_unclamped(self):
|
||||
peak_clamped = self._run_disturbance(INNER_HOLD_CLAMPED)
|
||||
peak_unclamped = self._run_disturbance(INNER_HOLD_UNCLAMPED)
|
||||
|
||||
overshoot_clamped = peak_clamped - 30.0
|
||||
overshoot_unclamped = peak_unclamped - 30.0
|
||||
|
||||
self.assertLess(overshoot_clamped, overshoot_unclamped)
|
||||
|
||||
|
||||
class TestRealRampStillReachesTarget(unittest.TestCase):
|
||||
"""Guards against reintroducing the rejected flat-clamp regression:
|
||||
Inner.Heat has no yi_max, so a genuine ramp must still be able to
|
||||
reach its commanded heat rate."""
|
||||
|
||||
def test_ramp_reaches_commanded_heatrate(self):
|
||||
ctrl = make_controller(INNER_HOLD_CLAMPED)
|
||||
plant = make_plant(20.0)
|
||||
|
||||
heatrate_soll = 1.5
|
||||
temp_soll = 40.0
|
||||
|
||||
max_heatrate_ist = 0.0
|
||||
reached_heat = False
|
||||
for _ in range(600):
|
||||
tick(ctrl, plant, temp_soll, heatrate_soll)
|
||||
if ctrl.state == States.HEAT:
|
||||
reached_heat = True
|
||||
max_heatrate_ist = max(max_heatrate_ist, ctrl.get_heatrate_ist())
|
||||
|
||||
self.assertTrue(reached_heat)
|
||||
self.assertGreater(max_heatrate_ist, 1.4)
|
||||
|
||||
|
||||
class TestHoldHeatHoldTransitionIsBumpless(unittest.TestCase):
|
||||
"""Per-state yi_max swap must not itself introduce a discontinuity in y
|
||||
at a HOLD<->HEAT transition - the same Pid instance/state carries over,
|
||||
only the yi_max ceiling changes going forward."""
|
||||
|
||||
def test_no_bump_at_transitions(self):
|
||||
ctrl = make_controller(INNER_HOLD_CLAMPED)
|
||||
plant = make_plant(20.0)
|
||||
|
||||
y_prev = None
|
||||
state_prev = None
|
||||
max_bump = 0.0
|
||||
for _ in range(1000):
|
||||
tick(ctrl, plant, 40.0, 1.5)
|
||||
y = ctrl.get_power()
|
||||
if state_prev is not None and ctrl.state != state_prev and \
|
||||
{state_prev, ctrl.state} <= {States.HOLD, States.HEAT}:
|
||||
max_bump = max(max_bump, abs(y - y_prev))
|
||||
y_prev = y
|
||||
state_prev = ctrl.state
|
||||
|
||||
# A "bump" here would be a y-step far larger than what one tick of
|
||||
# normal PID evolution produces elsewhere in the same run - not
|
||||
# literally zero, since heatrate_err itself keeps evolving tick to
|
||||
# tick regardless of the state transition. Note: a HEAT->HOLD
|
||||
# transition can retroactively clamp yi if a sustained ramp pushed
|
||||
# it past Inner.Hold's yi_max before HeatHold's threshold fired,
|
||||
# producing a small (not literally bumpless) step - bounded well
|
||||
# below the full-freeze/thaw magnitude this replaces.
|
||||
self.assertLess(max_bump, 0.1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user