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:
@@ -99,6 +99,25 @@ git history for `temp_controller.py`/`temp_controller_smith.py`).
|
||||
cases) was added under `tests/components/pid/` — see the "No automated
|
||||
tests" item above.
|
||||
|
||||
- [x] **HOLD overshoot was a permanent steady-state offset, not just a
|
||||
transient windup.** Found testing the rework against a real Sud run
|
||||
(`sude/sud_0030.json`): a grain-fill-in disturbance during a `HOLD`
|
||||
overshot by ~0.4°C (under the `HoldCool` threshold, so the FSM stayed in
|
||||
`HOLD`) and then never converged back — `temp_ist` sat in a 55.3-55.4°C
|
||||
band for the rest of the 20-minute hold instead of returning to 55.0.
|
||||
Cause: `process_pid()`'s HOLD-state floor on `pid_outer_y` clamped to
|
||||
exactly `0.0`, so once overshot, `heatrate_soll` = 0 ("hold flat") and
|
||||
`pid_inner` actively fought the pot's own ambient loss to keep the
|
||||
overshot temperature flat instead of declining back to setpoint. Fixed:
|
||||
the floor is now a configurable `Outer.y_hold_min` (default `0.0`,
|
||||
backward compatible), set to `-0.1` in `config.json`/both `.tpl`
|
||||
templates, letting HOLD request a gentle decline that roughly matches
|
||||
passive ambient cooling without reopening the `bb5af3c` limit cycle
|
||||
(fully unclamped negative). See the "Follow-up" section in
|
||||
`docs/overshoot_hold_windup.md` and
|
||||
`TestHoldOvershootRecoversToSetpoint` in
|
||||
`tests/components/pid/test_temp_controller_closed_loop.py`.
|
||||
|
||||
- [ ] **`kalman.py` is now dead code in production.** Neither
|
||||
`temp_controller.py` nor `temp_controller_smith.py` uses `Kalman`
|
||||
anymore; the only remaining references are
|
||||
|
||||
@@ -21,6 +21,7 @@ class TempControllerBase(TempControllerFsm, APid):
|
||||
self.params = None
|
||||
self._inner_heat_params = None
|
||||
self._inner_hold_params = None
|
||||
self._outer_hold_y_min = 0.0
|
||||
self.y = -1
|
||||
# Heat-rate pre-filter state (option A) - None until first process() tick
|
||||
self.last_theta_ist = None
|
||||
@@ -32,6 +33,11 @@ class TempControllerBase(TempControllerFsm, APid):
|
||||
self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})}
|
||||
self.beta = params.get('beta', 0.05)
|
||||
self.pid_outer.set_params(params['Outer'])
|
||||
# How far into "actively cool" pid_outer is allowed to reach during
|
||||
# HOLD - see the floor in process_pid() below. Defaults to 0.0
|
||||
# (old flat-clamp behaviour) so configs that don't set it are
|
||||
# unaffected.
|
||||
self._outer_hold_y_min = params['Outer'].get('y_hold_min', 0.0)
|
||||
self._inner_heat_params = params['Inner']['Heat']
|
||||
self._inner_hold_params = params['Inner']['Hold']
|
||||
self.pid_inner_cool.set_params(params['Inner']['Cool'])
|
||||
@@ -135,14 +141,21 @@ class TempControllerBase(TempControllerFsm, APid):
|
||||
|
||||
def process_pid(self, theta_err, hold_scale=1.0):
|
||||
self.pid_outer.process(theta_err, -self.theta_ist, hold_scale)
|
||||
# In HOLD state, clamp pid_outer's output to [0, 1]: a small temperature
|
||||
# overshoot makes pid_outer.y go negative, which would invert heatrate_soll
|
||||
# and drive pid_inner's power to 0, causing a limit cycle (power on →
|
||||
# overshoot → power off → coast down → repeat). Clamping to 0 lets the
|
||||
# outer loop reduce the inner setpoint to zero but no further.
|
||||
# In HOLD state, floor pid_outer's output at Outer.y_hold_min (<= 0)
|
||||
# rather than letting it swing all the way to -1: a large negative
|
||||
# heatrate_soll asks for a decline far steeper than the pot's own
|
||||
# ambient heat loss can deliver, so pid_inner pins power at 0 for an
|
||||
# extended stretch, undershoots, and swings back hard - a ~110s
|
||||
# limit cycle (see git history for this clamp). A small negative
|
||||
# floor instead lets HOLD ask for a gentle decline that roughly
|
||||
# matches passive cooling, so a small overshoot coasts back down to
|
||||
# setpoint instead of sitting in a permanent flat-clamp dead zone
|
||||
# (the outer loop otherwise commands "stay flat" forever, and
|
||||
# pid_inner actively fights the pot's own ambient loss to hold that
|
||||
# flat line - see docs/overshoot_hold_windup.md).
|
||||
pid_outer_y = self.pid_outer.get_y()
|
||||
if self.state == States.HOLD:
|
||||
pid_outer_y = max(0.0, pid_outer_y)
|
||||
pid_outer_y = max(self._outer_hold_y_min, pid_outer_y)
|
||||
self.heatrate_soll = self.heatrate_soll_set * pid_outer_y
|
||||
heatrate_err = self.heatrate_soll - self.heatrate_ist
|
||||
|
||||
|
||||
Reference in New Issue
Block a user