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()