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
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+54
View File
@@ -247,6 +247,60 @@ small (~0.07 in testing, well below the pre-fix disturbance's ~0.74 peak)
step in `y` rather than the fully bumpless transfer described above. Not
addressed here — flagged for awareness, not a blocker.
## Follow-up: steady-state overshoot in HOLD (`Outer.y_hold_min`)
Found while testing the rework against a real Sud run (`sude/sud_0030.json`,
`logs/log_20260706T074658_Sud-0030.json`). Visible in `docs/overshoot2.png`
(`theta_ist` vs `theta_soll` across the run): at both the 55°C and 63°C
rests, `theta_ist` overshoots the step and then plateaus above `theta_soll`
for the rest of the hold instead of converging back down — most clearly at
the first rest, where it settles at ~55.5-55.6°C against a 55.0°C target.
A grain-fill-in disturbance during "1. Rast" (mash-in rest, `HOLD` at 55°C)
pushed `temp_ist` to a ~0.4°C overshoot — well under `HoldCool`'s 1.0
threshold, so the FSM stayed in `HOLD` throughout, same as the transient
windup case above. But this overshoot never decayed: `temp_ist` sat in a
55.30-55.44 band for the rest of the 20-minute hold instead of converging
back to 55.0. Distinct failure mode from the transient windup fixed above
(that one unwound over ~35s; this one was flat/permanent for as long as the
hold lasted).
Root cause: `process_pid()`'s HOLD-state floor on `pid_outer_y` (added in
`bb5af3c` to break a limit cycle - see the History section) clamped to
exactly `0.0`. Once `temp_ist > temp_soll`, that floor forces
`heatrate_soll = 0`, i.e. "hold flat" - and `pid_inner` then actively fights
the pot's own ambient heat loss to keep the *overshot* temperature flat,
rather than being allowed to request a genuine decline back toward
setpoint. With `Outer.ki = 0`, there's no integral action to null the
resulting steady-state error any other way, so the offset persists for the
rest of the hold.
Fix: replaced the hardcoded `0.0` floor with a configurable
`Outer.y_hold_min` (default `0.0`, so configs that don't set it keep the old
behavior), set to `-0.1` in `config.json`, both `.tpl` templates, and the
three demo scripts. A small negative floor lets HOLD ask for a gentle
decline that roughly matches
passive ambient cooling, rather than the fully unclamped `[-1, 1]` range
that caused the original limit cycle (a large negative `heatrate_soll` asks
for a decline steeper than passive loss can deliver, pinning power at 0 for
an extended stretch and producing a hard undershoot/rebound). Test coverage
added: `TestHoldOvershootRecoversToSetpoint` in
`tests/components/pid/test_temp_controller_closed_loop.py` reproduces the
sud_0030 disturbance, asserts the old flat-clamp behavior still fails to
recover (regression guard) and the new floor converges close to setpoint,
plus a guard that the recovery doesn't undershoot by more than the injected
disturbance itself (i.e. doesn't reintroduce the `bb5af3c` limit cycle).
`-0.1` was chosen from the real Sud's plant params (`Pot.mass=5.96` +
`water_mass=22` ≈ the test harness's `M=27.96`, `L=0.2`): passive ambient
loss at a ~35°C delta works out to roughly 0.1-0.15 K/min, so
`heatrate_soll_set * -0.1` lands in that same ballpark for a typical
`heatrate_soll_set` of ~1.0-1.5 K/min. Not derived from first-principles
tuning - may need adjustment per installation, same as the other PID gains.
Confirmed against a live re-run of `sud_0030` with the fix applied, not just
the unit test above.
## Architecture diagram
A full signal-flow diagram of the cascade (`Outer`/`Inner.*` PIDs, FSM