fix: allow gentle negative HOLD floor to fix steady-state overshoot

Testing the windup-fix rework live against sude/sud_0030.json surfaced a
distinct bug: a HOLD overshoot from grain-fill-in cooling never decayed -
process_pid()'s pid_outer_y floor clamped to exactly 0.0, so pid_inner
fought the pot's own ambient loss to hold the overshot temperature flat
instead of declining back to setpoint (see docs/overshoot2.png).

Replace the hardcoded 0.0 floor with a configurable Outer.y_hold_min
(default 0.0, backward compatible), set to -0.1 in config.json, both
.tpl templates, and the demo scripts - small enough to avoid
reintroducing the bb5af3c limit cycle while letting HOLD request a
gentle decline matching passive ambient cooling.

Adds TestHoldOvershootRecoversToSetpoint and documents the finding in
docs/overshoot_hold_windup.md's Follow-up section and
components/pid/TODO.md. Confirmed against a live sud_0030 re-run, not
just the unit test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
This commit is contained in:
2026-07-06 08:19:10 +02:00
co-authored by Claude Sonnet 5
parent 11c3d92f25
commit 2d032bf9b2
11 changed files with 274 additions and 13 deletions
@@ -10,16 +10,17 @@ 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}
OUTER_PARAMS_HOLD_COOL = {"kp": 0.6, "ki": 0.0, "kd": 0.0, "kt": 0.0, "y_hold_min": -0.1}
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):
def make_controller(inner_hold_params, outer_params=None):
ctrl = TempController(DT)
ctrl.set_params({
"beta": 0.9,
"Outer": dict(OUTER_PARAMS),
"Outer": dict(outer_params if outer_params is not None else OUTER_PARAMS),
"Inner": {
"Heat": dict(INNER_HEAT_PARAMS),
"Hold": dict(inner_hold_params),
@@ -88,6 +89,56 @@ class TestHoldDisturbanceOvershoot(unittest.TestCase):
self.assertLess(overshoot_clamped, overshoot_unclamped)
class TestHoldOvershootRecoversToSetpoint(unittest.TestCase):
"""Reproduces the sud_0030 grain-fill-in incident: a HOLD overshoot
(post-recovery temp_ist above temp_soll, well under HoldCool's
threshold so the FSM never leaves HOLD) must coast back down to
setpoint rather than sitting in a permanent flat-clamp dead zone -
see docs/overshoot_hold_windup.md and the Outer.y_hold_min floor in
temp_controller_base.py's process_pid()."""
def _run_overshoot(self, outer_params):
ctrl = make_controller(INNER_HOLD_CLAMPED, outer_params)
plant = make_plant(30.0)
for _ in range(60):
tick(ctrl, plant, 30.0, 1.0)
self.assertEqual(ctrl.state, States.HOLD)
# Overshoot above setpoint, small enough to stay inside HOLD
# (matches the ~0.3-0.4 degC band observed in the real trace).
plant.temp += 0.4
min_temp = plant.get_temperature()
for _ in range(900):
tick(ctrl, plant, 30.0, 1.0)
self.assertEqual(ctrl.state, States.HOLD)
min_temp = min(min_temp, plant.get_temperature())
return plant.get_temperature(), min_temp
def test_negative_floor_recovers_flat_clamp_does_not(self):
final_flat, _ = self._run_overshoot(OUTER_PARAMS)
final_floored, min_floored = self._run_overshoot(OUTER_PARAMS_HOLD_COOL)
# Old behaviour (y clamped to exactly 0.0): pid_inner fights the
# pot's own ambient loss to hold the overshot temperature flat -
# it stays measurably above setpoint within this window.
self.assertGreater(final_flat - 30.0, 0.05)
# New behaviour (small negative floor): the outer loop can ask
# for a gentle decline, so the overshoot recovers much closer to
# setpoint than the flat clamp manages in the same window.
self.assertLess(abs(final_floored - 30.0), 0.15)
# Guard against reintroducing the violent limit cycle the original
# [0, 1] clamp was added to break (bb5af3c): recovery should coast
# down smoothly, undershooting by no more than the injected
# disturbance itself (0.4 degC) - not the ~1 degC+ swings of a
# runaway power on/off cycle.
self.assertGreater(min_floored, 30.0 - 0.4)
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