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
70 lines
2.1 KiB
Python
70 lines
2.1 KiB
Python
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()
|