From 2d032bf9b2f7a568f1db50e065db03c4aa2b1fee Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 6 Jul 2026 08:19:10 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE --- components/pid/TODO.md | 19 +++ components/pid/temp_controller_base.py | 25 +++- config-real.json.tpl | 3 +- config-sim.json.tpl | 3 +- docs/overshoot2.png | Bin 0 -> 49497 bytes docs/overshoot_hold_windup.md | 54 ++++++++ scripts/demos/pid/demo_temp_controller.py | 3 +- .../demos/pid/demo_temp_controller_smith.py | 3 +- scripts/demos/sud/demo_sud.py | 3 +- sude/sud_0030.json | 119 ++++++++++++++++++ .../pid/test_temp_controller_closed_loop.py | 55 +++++++- 11 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 docs/overshoot2.png create mode 100644 sude/sud_0030.json diff --git a/components/pid/TODO.md b/components/pid/TODO.md index 064bf30..1041177 100644 --- a/components/pid/TODO.md +++ b/components/pid/TODO.md @@ -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 diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index 9693b02..6d0cc83 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -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 diff --git a/config-real.json.tpl b/config-real.json.tpl index 2cf1812..b6ed3e9 100644 --- a/config-real.json.tpl +++ b/config-real.json.tpl @@ -8,7 +8,8 @@ "kp": 0.4, "ki": 0.0, "kd": 0.0, - "kt": 0.0 + "kt": 0.0, + "y_hold_min": -0.1 }, "Inner": { "Heat": { diff --git a/config-sim.json.tpl b/config-sim.json.tpl index ede94e6..c911a7c 100644 --- a/config-sim.json.tpl +++ b/config-sim.json.tpl @@ -8,7 +8,8 @@ "kp": 0.4, "ki": 0.0, "kd": 0.0, - "kt": 0.0 + "kt": 0.0, + "y_hold_min": -0.1 }, "Inner": { "Heat": { diff --git a/docs/overshoot2.png b/docs/overshoot2.png new file mode 100644 index 0000000000000000000000000000000000000000..6bafb7bc829926f5b8ab31c20f60fc4ee7338e30 GIT binary patch literal 49497 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 M8@5CVB9agP4eS&~C;$Ke literal 0 HcmV?d00001 diff --git a/docs/overshoot_hold_windup.md b/docs/overshoot_hold_windup.md index 28f47be..9dea9cf 100644 --- a/docs/overshoot_hold_windup.md +++ b/docs/overshoot_hold_windup.md @@ -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 diff --git a/scripts/demos/pid/demo_temp_controller.py b/scripts/demos/pid/demo_temp_controller.py index 0c43dc5..f055828 100644 --- a/scripts/demos/pid/demo_temp_controller.py +++ b/scripts/demos/pid/demo_temp_controller.py @@ -10,7 +10,8 @@ if __name__ == '__main__': "kp": 0.4, "ki": 0.0, "kd": 0.0, - "kt": 0.0 + "kt": 0.0, + "y_hold_min": -0.1 }, "Inner": { "Heat": { diff --git a/scripts/demos/pid/demo_temp_controller_smith.py b/scripts/demos/pid/demo_temp_controller_smith.py index 71078fb..0813ddf 100644 --- a/scripts/demos/pid/demo_temp_controller_smith.py +++ b/scripts/demos/pid/demo_temp_controller_smith.py @@ -10,7 +10,8 @@ if __name__ == '__main__': "kp": 0.4, "ki": 0.0, "kd": 0.0, - "kt": 0.0 + "kt": 0.0, + "y_hold_min": -0.1 }, "Inner": { "Heat": { diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index e28c3b7..444cc78 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -21,7 +21,8 @@ if __name__ == '__main__': "kp": 0.4, "ki": 0.0, "kd": 0.0, - "kt": 0.0 + "kt": 0.0, + "y_hold_min": -0.1 }, "Inner": { "Heat": { diff --git a/sude/sud_0030.json b/sude/sud_0030.json new file mode 100644 index 0000000..d88a226 --- /dev/null +++ b/sude/sud_0030.json @@ -0,0 +1,119 @@ +{ + "Name": "Sud-0030", + "Description": "Münchner Hell", + "pot": { + "grain_mass": 0, + "water_mass": 22, + "volumen": 30 + }, + "default": { + "step": { + "descr": "Put description here", + "user_message": "Put user message here", + "user_wait_for_continue": false, + "pot": { + "grain_mass": 5.21, + "water_mass": 22 + }, + "temperature": 0, + "ramp": { + "rate": 1.0, + "stirrer": { + "speed": 50, + "interval_time": 0, + "on_ratio": 1.0 + } + }, + "hold": { + "duration": 0, + "stirrer": { + "speed": 30, + "interval_time": 60, + "on_ratio": 0.5 + } + } + } + }, + "steps": [ + { + "pot": { + "grain_mass": 0 + }, + "descr": "Aufheizen", + "temperature": 57, + "ramp": { + "rate": 1.5, + "stirrer": { + "speed": 75 + } + } + }, + { + "pot": { + "grain_mass": 0 + }, + "descr": "Einmaischen", + "user_message": "Bitte Malz einfüllen und bestätigen", + "user_wait_for_continue": true, + "hold": { + "stirrer": { + "speed": 0 + } + } + }, + { + "descr": "1. Rast", + "temperature": 55, + "hold": { + "duration": 20 + } + }, + { + "descr": "Glucose-Rast", + "temperature": 63, + "ramp": { + "rate": 1.0 + }, + "hold": { + "duration": 40, + "stirrer": { + "speed": 20, + "interval_time": 90, + "on_ratio": 0.8 + } + } + }, + { + "descr": "Verzuckerungs-Rast", + "temperature": 72, + "ramp": { + "rate": 1.0 + }, + "hold": { + "duration": 30, + "stirrer": { + "speed": 35, + "interval_time": 120 + } + } + }, + { + "pot": { + "water_mass": 20 + }, + "descr": "Abmaischen", + "user_message": "Quittieren zum Abmaischen", + "user_wait_for_continue": true, + "temperature": 76, + "ramp": { + "rate": 1.0 + }, + "hold": { + "duration": 30, + "stirrer": { + "speed": 50 + } + } + } + ] +} diff --git a/tests/components/pid/test_temp_controller_closed_loop.py b/tests/components/pid/test_temp_controller_closed_loop.py index 13521c0..35c58ba 100644 --- a/tests/components/pid/test_temp_controller_closed_loop.py +++ b/tests/components/pid/test_temp_controller_closed_loop.py @@ -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