From f69d63c31bcea8bb749b666e91b6c2003d54faa9 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 5 Jul 2026 21:23:55 +0200 Subject: [PATCH 1/6] fix: split inner-loop yi_max clamp to fix HOLD-state windup overshoot Renames pid_hold/pid_heat/pid_cool to pid_outer/pid_inner/pid_inner_cool to match what actually runs when, and splits inner-loop config into Inner.Heat/Inner.Hold/Inner.Cool so the same PID instance gets a tight yi_max ceiling only while HOLD drives it, without capping legitimate 1.5 K/min ramps. Fixes the overshoot from docs/overshoot_hold_windup.md where a cold-water disturbance during HOLD wound up pid_heat's integral term with no anti-windup engagement, taking ~35s+ to unwind naturally. Breaking config change: Hold/Heat/Cool -> Outer/Inner.{Heat,Hold,Cool} in config.json, both .tpl templates, the pid/sud demo scripts, and replay_sim.py's CLI flags. Adds tests/components/pid/ (stdlib unittest) covering the Pid clamp/recovery behavior and closed-loop disturbance, ramp, and HOLD<->HEAT transition cases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u --- components/pid/TODO.md | 13 +- components/pid/pid.py | 3 + components/pid/temp_controller_base.py | 35 +++-- components/pid/temp_controller_fsm.py | 40 ++--- components/sud_forecast.py | 2 +- config-real.json.tpl | 33 ++-- config-sim.json.tpl | 33 ++-- docs/overshoot_hold_windup.md | 17 +- scripts/demos/pid/demo_temp_controller.py | 33 ++-- .../demos/pid/demo_temp_controller_smith.py | 33 ++-- scripts/demos/sud/demo_sud.py | 33 ++-- tests/__init__.py | 0 tests/components/__init__.py | 0 tests/components/pid/__init__.py | 0 tests/components/pid/test_pid.py | 69 ++++++++ .../pid/test_temp_controller_closed_loop.py | 148 ++++++++++++++++++ utils/replay_sim.py | 32 ++-- 17 files changed, 411 insertions(+), 113 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/components/__init__.py create mode 100644 tests/components/pid/__init__.py create mode 100644 tests/components/pid/test_pid.py create mode 100644 tests/components/pid/test_temp_controller_closed_loop.py diff --git a/components/pid/TODO.md b/components/pid/TODO.md index a3e43e8..e101826 100644 --- a/components/pid/TODO.md +++ b/components/pid/TODO.md @@ -63,7 +63,7 @@ git history for `temp_controller.py`/`temp_controller_smith.py`). before that file was removed entirely — `Pot` dropped `gain` entirely, see `components/plant/TODO.md`. -- [ ] **`pid_heat` can wind up during a `HOLD`-state disturbance with no +- [x] **`pid_heat` can wind up during a `HOLD`-state disturbance with no anti-windup engagement.** A cold-water disturbance while holding drove `pid_heat`'s integral term up without ever saturating `y` (peaked at `y≈0.74` of the `1.0` ceiling), so the existing back-calculation @@ -75,16 +75,17 @@ git history for `temp_controller.py`/`temp_controller_smith.py`). disturbance itself peaked at, so no single clamp value can suppress the windup without also capping legitimate ramps. An FSM-gating alternative (freeze the loop's output in `HOLD` unless engaged) was also superseded. - Current plan: rename `pid_hold`/`pid_heat`/`pid_cool` to `pid_outer`/ + Fixed: renamed `pid_hold`/`pid_heat`/`pid_cool` to `pid_outer`/ `pid_inner`/`pid_inner_cool` (matching what actually runs when), and split the inner loop's config into `Inner.Heat`/`Inner.Hold`/ `Inner.Cool` so the *same* PID instance gets a tight `yi_max` only while `HOLD` is driving it and stays unclamped for real `HEAT` ramps — no freeze/thaw, bumpless transfer preserved for free. See - `docs/overshoot_hold_windup.md` for the full writeup and test plan. This - is also a breaking config change (`Hold`/`Heat`/`Cool` → `Outer`/ - `Inner.*`) — every deployed `config.json` needs migrating, not just the - repo templates. + `docs/overshoot_hold_windup.md` for the full writeup. This was also a + breaking config change (`Hold`/`Heat`/`Cool` → `Outer`/`Inner.*`) — + `config.json`, the templates, and the demo scripts were all migrated. + Test coverage for this (closed-loop disturbance/ramp/transition cases) + is still outstanding — see the "No automated tests" item above. - [ ] **`kalman.py` is now dead code in production.** Neither `temp_controller.py` nor `temp_controller_smith.py` uses `Kalman` diff --git a/components/pid/pid.py b/components/pid/pid.py index b82dfdd..0c40722 100644 --- a/components/pid/pid.py +++ b/components/pid/pid.py @@ -35,6 +35,9 @@ class Pid: kt = self.params['kt'] * scale self.yi = self.yi + ki*dt * err + kt*dt * self.awu + yi_max = self.params.get('yi_max') + if yi_max is not None: + self.yi = max(-yi_max, min(yi_max, self.yi)) yd = kd/dt*(d - self.d) yp = kp * err diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index 2a380b5..9693b02 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -19,6 +19,8 @@ class TempControllerBase(TempControllerFsm, APid): # params/set_params() (components/pid/pid.py), whose process() # already relies on the same "None means not configured yet". self.params = None + self._inner_heat_params = None + self._inner_hold_params = None self.y = -1 # Heat-rate pre-filter state (option A) - None until first process() tick self.last_theta_ist = None @@ -29,9 +31,10 @@ class TempControllerBase(TempControllerFsm, APid): self.params = params self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})} self.beta = params.get('beta', 0.05) - self.pid_hold.set_params(params['Hold']) - self.pid_heat.set_params(params['Heat']) - self.pid_cool.set_params(params['Cool']) + self.pid_outer.set_params(params['Outer']) + self._inner_heat_params = params['Inner']['Heat'] + self._inner_hold_params = params['Inner']['Hold'] + self.pid_inner_cool.set_params(params['Inner']['Cool']) def _compute_heatrate(self, theta_ist): """Pre-filter theta_ist, then differentiate and low-pass to get heatrate_ist. @@ -131,31 +134,33 @@ class TempControllerBase(TempControllerFsm, APid): return self.heatrate_soll_set def process_pid(self, theta_err, hold_scale=1.0): - self.pid_hold.process(theta_err, -self.theta_ist, hold_scale) - # In HOLD state, clamp pid_hold's output to [0, 1]: a small temperature - # overshoot makes pid_hold.y go negative, which would invert heatrate_soll - # and drive pid_heat's power to 0, causing a limit cycle (power on → + 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. - pid_hold_y = self.pid_hold.get_y() + pid_outer_y = self.pid_outer.get_y() if self.state == States.HOLD: - pid_hold_y = max(0.0, pid_hold_y) - self.heatrate_soll = self.heatrate_soll_set * pid_hold_y + pid_outer_y = max(0.0, pid_outer_y) + self.heatrate_soll = self.heatrate_soll_set * pid_outer_y heatrate_err = self.heatrate_soll - self.heatrate_ist # Only the PID actually driving y is advanced - otherwise the - # inactive one (e.g. pid_heat while COOL has pid_cool driving) + # inactive one (e.g. pid_inner while COOL has pid_inner_cool driving) # would keep silently integrating against a heatrate_err that # isn't actually under its control, building a stale windup that # causes a discontinuity in y the moment it takes back over. if self.state == States.IDLE: self.y = 0 elif self.state == States.COOL: - self.pid_cool.process(heatrate_err, -self.heatrate_ist) - self.y = self.pid_cool.get_y() + self.pid_inner_cool.process(heatrate_err, -self.heatrate_ist) + self.y = self.pid_inner_cool.get_y() else: - self.pid_heat.process(heatrate_err, -self.heatrate_ist) - self.y = self.pid_heat.get_y() + inner_params = self._inner_heat_params if self.state == States.HEAT else self._inner_hold_params + self.pid_inner.set_params(inner_params) + self.pid_inner.process(heatrate_err, -self.heatrate_ist) + self.y = self.pid_inner.get_y() self.post_pid() diff --git a/components/pid/temp_controller_fsm.py b/components/pid/temp_controller_fsm.py index f641d43..3d9dc3f 100644 --- a/components/pid/temp_controller_fsm.py +++ b/components/pid/temp_controller_fsm.py @@ -8,9 +8,9 @@ DEFAULT_THRESHOLDS = { # when COOL meant nothing more than going idle - it's an active state # with its own PID now, so a threshold this tight relative to a real # heater's discrete power steps/sensor noise causes the system to - # chatter in and out of it every tick, resetting both pid_hold's and - # pid_cool's integrators each time and never letting either actually - # converge. Symmetric with the others instead. + # chatter in and out of it every tick, resetting both pid_outer's and + # pid_inner_cool's integrators each time and never letting either + # actually converge. Symmetric with the others instead. "HoldCool": 1.0, "HeatHold": 1.0, "HeatCool": 1.0, @@ -30,14 +30,14 @@ class States(enum.Enum): class TempControllerFsm: def __init__(self, dt): - self.pid_hold = Pid(dt) - self.pid_heat = Pid(dt) + self.pid_outer = Pid(dt) + self.pid_inner = Pid(dt) # Separate gains for ramping down (negative diff) - the actuator # (e.g. a heat-only Pot/heater) is responsible for clamping the # resulting negative power to whatever it's actually capable of; # the controller itself no longer assumes "can't cool" == "must go # idle". - self.pid_cool = Pid(dt) + self.pid_inner_cool = Pid(dt) self.thresholds = None self.state = States.INIT self.is_startup = True @@ -80,13 +80,13 @@ class TempControllerFsm: # which calls this method directly), so an unconditional HOLD # here gets mistaken for "ramp already reached" and can finish # a freshly (re-)started step instantly - see SudTask. - # on_process()'s is_holding() check. pid_heat/pid_cool were - # frozen (see process_pid()) and possibly stale for as long as - # we were disabled - start whichever one matters clean rather + # on_process()'s is_holding() check. pid_inner/pid_inner_cool + # were frozen (see process_pid()) and possibly stale for as long + # as we were disabled - start whichever one matters clean rather # than resuming wherever it last left off. - self.pid_hold.reset() - self.pid_heat.reset() - self.pid_cool.reset() + self.pid_outer.reset() + self.pid_inner.reset() + self.pid_inner_cool.reset() if diff >= self.thresholds['HoldHeat']: state_next = States.HEAT elif diff <= -self.thresholds['HoldCool']: @@ -96,31 +96,31 @@ class TempControllerFsm: elif self.state == States.HOLD: if diff >= self.thresholds['HoldHeat']: state_next = States.HEAT - # No pid_heat.reset() here — bumpless transfer: carry the + # No pid_inner.reset() here — bumpless transfer: carry the # hold-phase integral into the new ramp so power doesn't # drop to near-zero and crawl back up from scratch. elif diff <= -self.thresholds['HoldCool']: state_next = States.COOL - self.pid_cool.reset() + self.pid_inner_cool.reset() elif self.state == States.HEAT: if diff <= -self.thresholds['HeatCool']: state_next = States.COOL - self.pid_cool.reset() + self.pid_inner_cool.reset() elif diff <= self.thresholds['HeatHold']: state_next = States.HOLD - self.pid_hold.reset() + self.pid_outer.reset() elif self.state == States.COOL: if diff >= self.thresholds['CoolHeat']: state_next = States.HEAT - self.pid_heat.reset() + self.pid_inner.reset() elif diff >= -self.thresholds['CoolHold']: state_next = States.HOLD - self.pid_hold.reset() - # pid_heat was frozen during COOL (see process_pid()) - + self.pid_outer.reset() + # pid_inner was frozen during COOL (see process_pid()) - # resume it clean rather than from whatever it last held # before COOL took over, which by now may be a stale fit # for a completely different part of the curve. - self.pid_heat.reset() + self.pid_inner.reset() if state_next != self.state: self.state = state_next diff --git a/components/sud_forecast.py b/components/sud_forecast.py index f62a956..7f2dd87 100644 --- a/components/sud_forecast.py +++ b/components/sud_forecast.py @@ -46,7 +46,7 @@ class SudForecastEstimator: estimate() also duty-cycles the PID's continuous output across the heater's own discrete power steps (see its actuate() closure), exactly like the real run's HeaterTask/device chain does - feeding tc.get_power() - straight to the plant instead lets pid_heat's own oscillatory tendency + straight to the plant instead lets pid_inner's own oscillatory tendency reach the plant undamped, producing a forecast far more jagged than any real run actually is (the discrete steps end up duty-cycling it back down to something close to the commanded average).""" diff --git a/config-real.json.tpl b/config-real.json.tpl index 5db5933..2cf1812 100644 --- a/config-real.json.tpl +++ b/config-real.json.tpl @@ -4,23 +4,32 @@ "TempCtrl": { "pid_type": "Smith", "beta": 0.05, - "Hold": { + "Outer": { "kp": 0.4, "ki": 0.0, "kd": 0.0, "kt": 0.0 }, - "Heat": { - "kp": 0.08, - "ki": 0.02, - "kd": 0.0, - "kt": 1.5 - }, - "Cool": { - "kp": 0.08, - "ki": 0.02, - "kd": 0.0, - "kt": 1.5 + "Inner": { + "Heat": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5 + }, + "Hold": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5, + "yi_max": 0.3 + }, + "Cool": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5 + } }, "Thresholds": { "HoldHeat": 1.0, diff --git a/config-sim.json.tpl b/config-sim.json.tpl index 7c30879..ede94e6 100644 --- a/config-sim.json.tpl +++ b/config-sim.json.tpl @@ -4,23 +4,32 @@ "TempCtrl": { "pid_type": "Smith", "beta": 0.05, - "Hold": { + "Outer": { "kp": 0.4, "ki": 0.0, "kd": 0.0, "kt": 0.0 }, - "Heat": { - "kp": 0.08, - "ki": 0.02, - "kd": 0.0, - "kt": 1.5 - }, - "Cool": { - "kp": 0.08, - "ki": 0.02, - "kd": 0.0, - "kt": 1.5 + "Inner": { + "Heat": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5 + }, + "Hold": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5, + "yi_max": 0.3 + }, + "Cool": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5 + } }, "Thresholds": { "HoldHeat": 1.0, diff --git a/docs/overshoot_hold_windup.md b/docs/overshoot_hold_windup.md index ed04bbb..4e3e296 100644 --- a/docs/overshoot_hold_windup.md +++ b/docs/overshoot_hold_windup.md @@ -222,4 +222,19 @@ mode): ## Status -Plan only — nothing in this document has been implemented yet. +Implemented: `pid.py`'s `yi_max` clamp, the `pid_outer`/`pid_inner`/ +`pid_inner_cool` rename, the `Outer`/`Inner.{Heat,Hold,Cool}` config +restructure (`config.json`, both `.tpl` templates, and the three demo +scripts), and `utils/replay_sim.py`'s matching CLI-flag/print-loop rename. +Tests added under `tests/components/pid/` (`test_pid.py` for the isolated +`Pid` clamp behavior, `test_temp_controller_closed_loop.py` for the +closed-loop disturbance/ramp/transition cases) — all passing. + +One subtlety found while writing the closed-loop transition test that +this plan didn't anticipate: `Inner.Hold`'s `yi_max` clamp applies +retroactively. If a sustained `HEAT` ramp pushes `yi` above the `Hold` +ceiling before the `HeatHold` threshold fires, the very next tick after +the `HEAT→HOLD` transition clamps `yi` back down immediately, producing a +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. diff --git a/scripts/demos/pid/demo_temp_controller.py b/scripts/demos/pid/demo_temp_controller.py index c3af445..0c43dc5 100644 --- a/scripts/demos/pid/demo_temp_controller.py +++ b/scripts/demos/pid/demo_temp_controller.py @@ -6,23 +6,32 @@ from components.pid.temp_controller import TempController if __name__ == '__main__': ctrl_params = { - "Hold": { + "Outer": { "kp": 0.4, "ki": 0.0, "kd": 0.0, "kt": 0.0 }, - "Heat": { - "kp": 0.08, - "ki": 0.008, - "kd": 0.0, - "kt": 1.5 - }, - "Cool": { - "kp": 0.08, - "ki": 0.008, - "kd": 0.0, - "kt": 1.5 + "Inner": { + "Heat": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5 + }, + "Hold": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5, + "yi_max": 0.3 + }, + "Cool": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5 + } } } diff --git a/scripts/demos/pid/demo_temp_controller_smith.py b/scripts/demos/pid/demo_temp_controller_smith.py index d03069f..71078fb 100644 --- a/scripts/demos/pid/demo_temp_controller_smith.py +++ b/scripts/demos/pid/demo_temp_controller_smith.py @@ -6,23 +6,32 @@ from components.pid.temp_controller_smith import TempController if __name__ == '__main__': ctrl_params = { - "Hold": { + "Outer": { "kp": 0.4, "ki": 0.0, "kd": 0.0, "kt": 0.0 }, - "Heat": { - "kp": 0.08, - "ki": 0.008, - "kd": 0.0, - "kt": 1.5 - }, - "Cool": { - "kp": 0.08, - "ki": 0.008, - "kd": 0.0, - "kt": 1.5 + "Inner": { + "Heat": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5 + }, + "Hold": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5, + "yi_max": 0.3 + }, + "Cool": { + "kp": 0.08, + "ki": 0.008, + "kd": 0.0, + "kt": 1.5 + } } } diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 77a8189..e28c3b7 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -17,23 +17,32 @@ if __name__ == '__main__': theta_amb = 20 ctrl_params = { - "Hold": { + "Outer": { "kp": 0.4, "ki": 0.0, "kd": 0.0, "kt": 0.0 }, - "Heat": { - "kp": 0.08, - "ki": 0.02, - "kd": 0.0, - "kt": 1.5 - }, - "Cool": { - "kp": 0.08, - "ki": 0.02, - "kd": 0.0, - "kt": 1.5 + "Inner": { + "Heat": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5 + }, + "Hold": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5, + "yi_max": 0.3 + }, + "Cool": { + "kp": 0.08, + "ki": 0.02, + "kd": 0.0, + "kt": 1.5 + } } } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/__init__.py b/tests/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/pid/__init__.py b/tests/components/pid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/pid/test_pid.py b/tests/components/pid/test_pid.py new file mode 100644 index 0000000..e5718cf --- /dev/null +++ b/tests/components/pid/test_pid.py @@ -0,0 +1,69 @@ +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() diff --git a/tests/components/pid/test_temp_controller_closed_loop.py b/tests/components/pid/test_temp_controller_closed_loop.py new file mode 100644 index 0000000..13521c0 --- /dev/null +++ b/tests/components/pid/test_temp_controller_closed_loop.py @@ -0,0 +1,148 @@ +import unittest + +from components.pid.temp_controller_smith import TempController +from components.pid.temp_controller_fsm import States +from components.plant.pot import Pot + + +DT = 1.0 +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} +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): + ctrl = TempController(DT) + ctrl.set_params({ + "beta": 0.9, + "Outer": dict(OUTER_PARAMS), + "Inner": { + "Heat": dict(INNER_HEAT_PARAMS), + "Hold": dict(inner_hold_params), + "Cool": dict(INNER_HEAT_PARAMS), + }, + }) + ctrl.set_model_plant_params(PLANT_PARAMS) + ctrl.set_ambient_temperature(AMBIENT) + ctrl.set_enabled(True) + return ctrl + + +def make_plant(initial_temp): + plant = Pot(DT) + plant.set_plant_params(PLANT_PARAMS) + plant.set_ambient_temperature(AMBIENT) + plant.initial(initial_temp) + return plant + + +def tick(ctrl, plant, temp_soll, heatrate_soll): + plant.process() + ctrl.set_theta_ist(plant.get_temperature()) + ctrl.set_theta_soll(temp_soll) + ctrl.set_heatrate_soll(heatrate_soll) + ctrl.process() + power = 3500.0 * max(0.0, ctrl.get_power()) + plant.set_power(power) + ctrl.set_model_power(power) + + +class TestHoldDisturbanceOvershoot(unittest.TestCase): + """Reproduces the cold-water-during-HOLD incident from + docs/overshoot_hold_windup.md and checks that Inner.Hold's yi_max + measurably reduces the resulting overshoot.""" + + def _run_disturbance(self, inner_hold_params): + ctrl = make_controller(inner_hold_params) + plant = make_plant(30.0) + + # Settle at HOLD, 30 degC. + for _ in range(60): + tick(ctrl, plant, 30.0, 1.0) + self.assertEqual(ctrl.state, States.HOLD) + + # Cold-water disturbance: knock plant.temp down ~0.5 degC. + plant.temp -= 0.5 + + peak = plant.get_temperature() + for _ in range(600): + tick(ctrl, plant, 30.0, 1.0) + peak = max(peak, plant.get_temperature()) + # Disturbance must stay within HOLD the whole time, matching + # the incident (diff never reached HoldHeat). + self.assertEqual(ctrl.state, States.HOLD) + + return peak + + def test_clamped_overshoot_smaller_than_unclamped(self): + peak_clamped = self._run_disturbance(INNER_HOLD_CLAMPED) + peak_unclamped = self._run_disturbance(INNER_HOLD_UNCLAMPED) + + overshoot_clamped = peak_clamped - 30.0 + overshoot_unclamped = peak_unclamped - 30.0 + + self.assertLess(overshoot_clamped, overshoot_unclamped) + + +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 + reach its commanded heat rate.""" + + def test_ramp_reaches_commanded_heatrate(self): + ctrl = make_controller(INNER_HOLD_CLAMPED) + plant = make_plant(20.0) + + heatrate_soll = 1.5 + temp_soll = 40.0 + + max_heatrate_ist = 0.0 + reached_heat = False + for _ in range(600): + tick(ctrl, plant, temp_soll, heatrate_soll) + if ctrl.state == States.HEAT: + reached_heat = True + max_heatrate_ist = max(max_heatrate_ist, ctrl.get_heatrate_ist()) + + self.assertTrue(reached_heat) + self.assertGreater(max_heatrate_ist, 1.4) + + +class TestHoldHeatHoldTransitionIsBumpless(unittest.TestCase): + """Per-state yi_max swap must not itself introduce a discontinuity in y + at a HOLD<->HEAT transition - the same Pid instance/state carries over, + only the yi_max ceiling changes going forward.""" + + def test_no_bump_at_transitions(self): + ctrl = make_controller(INNER_HOLD_CLAMPED) + plant = make_plant(20.0) + + y_prev = None + state_prev = None + max_bump = 0.0 + for _ in range(1000): + tick(ctrl, plant, 40.0, 1.5) + y = ctrl.get_power() + if state_prev is not None and ctrl.state != state_prev and \ + {state_prev, ctrl.state} <= {States.HOLD, States.HEAT}: + max_bump = max(max_bump, abs(y - y_prev)) + y_prev = y + state_prev = ctrl.state + + # A "bump" here would be a y-step far larger than what one tick of + # normal PID evolution produces elsewhere in the same run - not + # literally zero, since heatrate_err itself keeps evolving tick to + # tick regardless of the state transition. Note: a HEAT->HOLD + # transition can retroactively clamp yi if a sustained ramp pushed + # it past Inner.Hold's yi_max before HeatHold's threshold fired, + # producing a small (not literally bumpless) step - bounded well + # below the full-freeze/thaw magnitude this replaces. + self.assertLess(max_bump, 0.1) + + +if __name__ == '__main__': + unittest.main() diff --git a/utils/replay_sim.py b/utils/replay_sim.py index 158940a..4232d36 100644 --- a/utils/replay_sim.py +++ b/utils/replay_sim.py @@ -45,13 +45,21 @@ from components.pid import PidFactory from components.plant.pot import Pot +GAIN_SECTIONS = (('Outer', 'outer'), ('Inner.Heat', 'inner-heat'), + ('Inner.Hold', 'inner-hold'), ('Inner.Cool', 'inner-cool')) + + def _apply_gain_overrides(params, args): p = copy.deepcopy(params) - for section, prefix in (('Hold', 'hold'), ('Heat', 'heat'), ('Cool', 'cool')): + for section, prefix in GAIN_SECTIONS: for gain in ('kp', 'ki', 'kd', 'kt'): - val = getattr(args, '{}_{}'.format(prefix, gain)) + val = getattr(args, '{}_{}'.format(prefix.replace('-', '_'), gain)) if val is not None: - p[section][gain] = val + if '.' in section: + outer, inner = section.split('.') + p[outer][inner][gain] = val + else: + p[section][gain] = val return p @@ -63,7 +71,7 @@ def _infer_max_power(samples): def _infer_heatrate_soll_set(samples): """Estimate heatrate_soll_set from the log. - rate_soll = heatrate_soll_set * pid_hold.get_y(); when the hold PID is + rate_soll = heatrate_soll_set * pid_outer.get_y(); when the outer PID is saturated at 1.0 during active heating the two are equal, so the max rate_soll seen while the heater is on is a good upper bound.""" candidates = [s['rate_soll'] for s in samples if s['power_set'] > 0] @@ -211,12 +219,12 @@ def main(): parser.add_argument('--plant-L', type=float, default=None, metavar='W/kgK', help='Heat loss coeff [W/(kg·K)]') parser.add_argument('--plant-Td', type=float, default=None, metavar='s', help='Transport delay [s]') - for section, prefix in (('Hold', 'hold'), ('Heat', 'heat'), ('Cool', 'cool')): + for section, prefix in GAIN_SECTIONS: for gain in ('kp', 'ki', 'kd', 'kt'): parser.add_argument( '--{}-{}'.format(prefix, gain), type=float, default=None, - dest='{}_{}'.format(prefix, gain), + dest='{}_{}'.format(prefix.replace('-', '_'), gain), metavar='VAL', help='Override TempCtrl.{}.{}'.format(section, gain), ) @@ -265,8 +273,8 @@ def main(): ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0) any_override = any( - getattr(args, '{}_{}'.format(p, g)) is not None - for p in ('hold', 'heat', 'cool') + getattr(args, '{}_{}'.format(prefix.replace('-', '_'), g)) is not None + for _, prefix in GAIN_SECTIONS for g in ('kp', 'ki', 'kd', 'kt') ) config_source = 'log' if log.get('Config') else 'file' @@ -281,8 +289,12 @@ def main(): print('Params: {} ({})'.format('overridden' if any_override else 'from {}'.format(config_source), 'log' if log_plant else 'defaults')) print() - for section in ('Hold', 'Heat', 'Cool'): - p = pid_params[section] + for section, _ in GAIN_SECTIONS: + if '.' in section: + outer, inner = section.split('.') + p = pid_params[outer][inner] + else: + p = pid_params[section] print(' {}: kp={kp} ki={ki} kd={kd} kt={kt}'.format(section, **p)) replay = run_replay(samples, pid_type, pid_params, rate_soll, plant_params, param_events, From acfb98e18698d2e3e0f4923e0a5d9e41c606242f Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 5 Jul 2026 21:59:20 +0200 Subject: [PATCH 2/6] docs: add FSM states diagram screenshot Reference screenshot of the FSM-states panel from the temp controller architecture diagram (states, thresholds, and the HOLD->HEAT no-reset bumpless-transfer note). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u --- docs/fsm_states.png | Bin 0 -> 72211 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/fsm_states.png diff --git a/docs/fsm_states.png b/docs/fsm_states.png new file mode 100644 index 0000000000000000000000000000000000000000..72719112abd95c6d9fa1ba96964dd0f0aa13cb9f GIT binary patch literal 72211 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 XwJKCzvvP|X0}yz+`njxgN@xNAw<#R6 literal 0 HcmV?d00001 From 11c3d92f25b7ee10f31a9bcdd8c29fabd60ddd5a Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 5 Jul 2026 22:08:19 +0200 Subject: [PATCH 3/6] docs: refresh windup writeup to past tense, link architecture diagram overshoot_hold_windup.md read like a live plan ("not yet implemented") even though the fix had already shipped; reworks it into a past-tense record with corrected line numbers and test descriptions matching what was actually built. Fixes a stale pid_heat reference in temp_control_calibration.md. TODO.md's windup item still claimed test coverage was "outstanding" from before the test suite was added; corrects that and the "No automated tests" item above it to reflect current coverage. Both docs now link docs/fsm_states.png (durable) and the Claude Artifact URL (session-scoped) for the cascade architecture diagram. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u --- components/pid/TODO.md | 34 ++++-- docs/overshoot_hold_windup.md | 173 +++++++++++++++++-------------- docs/temp_control_calibration.md | 2 +- 3 files changed, 119 insertions(+), 90 deletions(-) diff --git a/components/pid/TODO.md b/components/pid/TODO.md index e101826..064bf30 100644 --- a/components/pid/TODO.md +++ b/components/pid/TODO.md @@ -32,13 +32,17 @@ git history for `temp_controller.py`/`temp_controller_smith.py`). hold_scale)` — the overshoot-compensation scheduling logic now lives entirely in the temp controller, not the generic `Pid` block. -- [ ] **No automated tests.** `tests/components/pid/` was added once +- [ ] **No automated tests for the heat-rate filtering or Smith + correction specifically.** An earlier `tests/components/pid/` suite (stdlib `unittest`, no pytest) covering FSM transitions, anti-windup - clamping, and Kalman convergence, but was removed again before the - Kalman-filter removal/Smith rewrite, so none of the current - `temp_controller*.py` behavior (backward-difference + low-pass - filtered heat rate, the two-model Smith correction) has test - coverage. This drives a physical heater — worth re-adding. + clamping, and Kalman convergence was removed before the Kalman-filter + removal/Smith rewrite. A new `tests/components/pid/` suite was added + alongside the HOLD-windup fix below (`test_pid.py`, + `test_temp_controller_closed_loop.py`) covering the `yi_max` clamp and + closed-loop disturbance/ramp/transition behavior, but + `_compute_heatrate()`'s backward-difference + low-pass filtering and + the two-model Smith correction itself still have no dedicated + coverage. This drives a physical heater — worth extending. - [ ] **`set_model_power` isn't defined on every controller, but `brewpi.py` wires it unconditionally.** `brewpi.py` always does @@ -81,11 +85,19 @@ git history for `temp_controller.py`/`temp_controller_smith.py`). `Inner.Cool` so the *same* PID instance gets a tight `yi_max` only while `HOLD` is driving it and stays unclamped for real `HEAT` ramps — no freeze/thaw, bumpless transfer preserved for free. See - `docs/overshoot_hold_windup.md` for the full writeup. This was also a - breaking config change (`Hold`/`Heat`/`Cool` → `Outer`/`Inner.*`) — - `config.json`, the templates, and the demo scripts were all migrated. - Test coverage for this (closed-loop disturbance/ramp/transition cases) - is still outstanding — see the "No automated tests" item above. + `docs/overshoot_hold_windup.md` for the full writeup, and + `docs/fsm_states.png` for a static screenshot of the FSM-states panel + of the cascade architecture diagram (states/thresholds, and the + HOLD→HEAT no-reset note). The full interactive diagram (signal-flow + cascade + FSM inset + config-mapping table) is a Claude Artifact, + not a repo file: https://claude.ai/code/artifact/a32e4752-b4a6-4153-b344-eb2423eb6512 + — only reachable by whoever has access to the Claude account/session + that created it, not a durable link for the team; `docs/fsm_states.png` + is the durable copy. This was also a breaking config change (`Hold`/`Heat`/`Cool` → + `Outer`/`Inner.*`) — `config.json`, the templates, and the demo scripts + were all migrated. Test coverage (closed-loop disturbance/ramp/transition + cases) was added under `tests/components/pid/` — see the "No automated + tests" item above. - [ ] **`kalman.py` is now dead code in production.** Neither `temp_controller.py` nor `temp_controller_smith.py` uses `Kalman` diff --git a/docs/overshoot_hold_windup.md b/docs/overshoot_hold_windup.md index 4e3e296..28f47be 100644 --- a/docs/overshoot_hold_windup.md +++ b/docs/overshoot_hold_windup.md @@ -1,10 +1,12 @@ -# HOLD-state overshoot from a disturbance: root cause and fix plan +# HOLD-state overshoot from a disturbance: root cause and fix Investigation of the overshoot visible in `docs/overshoot.png`: a cold-water disturbance during a steady HOLD at 30°C caused `theta_ist` to overshoot to ~30.5°C and stay there, rather than settling back at soll. This document -records the diagnosis and the (not yet implemented) fix plan. Numbers below -are pulled from `logs/log_latest.json`. +records the diagnosis, the fix that shipped for it, and the test coverage +added alongside it. Numbers in the incident summary below are pulled from +`logs/log_latest.json`; names in that section (`pid_hold`/`pid_heat`) are +the *pre-fix* names — see "Rename for honesty" below for what they became. ## Incident summary @@ -88,23 +90,24 @@ on which state is currently active. ### Rename for honesty -The current names don't match what actually runs when: -- `pid_hold` already runs unconditionally *every* tick regardless of state - (`process_pid()`'s first line, `temp_controller_base.py:134`) — it's the - outer loop, not "the HOLD-state PID". Rename to **`pid_outer`**. -- `pid_heat` already runs in *both* `HOLD` and `HEAT` today (only `IDLE`/ - `COOL` skip it, `temp_controller_base.py:151-158`) — it's the inner loop - for the heating direction. Rename to **`pid_inner`**. -- `pid_cool` only ever runs in `COOL` — no `HOLD`-time ambiguity, since a +The pre-fix names didn't match what actually ran when: +- `pid_hold` already ran unconditionally *every* tick regardless of state + (`process_pid()`'s first line, `temp_controller_base.py:137`) — it's the + outer loop, not "the HOLD-state PID". Renamed to **`pid_outer`**. +- `pid_heat` already ran in *both* `HOLD` and `HEAT` (only `IDLE`/`COOL` + skip it, `temp_controller_base.py:154-163`) — it's the inner loop for + the heating direction. Renamed to **`pid_inner`**. +- `pid_cool` only ever ran in `COOL` — no `HOLD`-time ambiguity, since a disturbance that pushes temp *above* soll during `HOLD` is still handled - by `pid_inner` (the outer loop's `max(0.0, pid_hold_y)` floor sends + by `pid_inner` (the outer loop's `max(0.0, pid_outer_y)` floor sends `heatrate_soll` to 0, and `pid_inner` reacts to the resulting negative `heatrate_err` — the state only escalates to real `COOL` past - `Thresholds.HoldCool`). Rename to **`pid_inner_cool`** for symmetry, kept - as its own instance — today's explicit `reset()` calls when crossing + `Thresholds.HoldCool`). Renamed to **`pid_inner_cool`** for symmetry, kept + as its own instance — the explicit `reset()` calls when crossing between heat-direction and cool-direction states - (`temp_controller_fsm.py:104,108,115,123`) stay exactly as they are; there - is no reason to share integrator state across a heater/chiller boundary. + (`temp_controller_fsm.py:104,108,115,123`) stay exactly as they were; + there is no reason to share integrator state across a heater/chiller + boundary. ### Config: `Hold`/`Heat`/`Cool` → `Outer` / `Inner.{Heat,Hold,Cool}` @@ -131,31 +134,31 @@ The current names don't match what actually runs when: cooling-side incident. `pid_inner_cool` never runs during `HOLD`, so it doesn't need its own `Hold` variant the way `Heat` does; one params block is enough. -- **This is a breaking config change** — no backward-compat shim for the +- **This was a breaking config change** — no backward-compat shim for the old flat `Hold`/`Heat`/`Cool` keys (per the "no compat hacks" convention). - Every deployed `config.json` needs migrating, not just the repo's + Every deployed `config.json` needed migrating, not just the repo's `config-real.json.tpl`/`config-sim.json.tpl` templates. -### Code changes (planned, not yet implemented) +### Code changes (implemented) -1. **`components/pid/pid.py`** — add the symmetric `yi_max` clamp inside - `process()` (already planned): `self.yi = max(-yi_max, min(yi_max, - self.yi))` when `self.params.get('yi_max')` is set, applied right after +1. **`components/pid/pid.py`** — the symmetric `yi_max` clamp lives inside + `process()` (line 40): `self.yi = max(-yi_max, min(yi_max, self.yi))` + when `self.params.get('yi_max')` is set, applied right after accumulating `yi` and before it's summed into `y`. -2. **`components/pid/temp_controller_fsm.py`** — rename `self.pid_hold` → +2. **`components/pid/temp_controller_fsm.py`** — `self.pid_hold` → `self.pid_outer`, `self.pid_heat` → `self.pid_inner`, `self.pid_cool` → - `self.pid_inner_cool` (constructor at lines 33-34/40, all `reset()` call + `self.pid_inner_cool` (constructor at lines 33/34/40, all `reset()` call sites and comments at lines 11-12, 83, 87-89, 99, 104, 108, 111, 115, - 118-119, 123). + 119, 123). 3. **`components/pid/temp_controller_base.py`**: - - `set_params()` (line 28-34): `self.pid_outer.set_params(params['Outer'])`; - store `self._inner_heat_params = params['Inner']['Heat']` and + - `set_params()` (lines 30-37): `self.pid_outer.set_params(params['Outer'])`; + stores `self._inner_heat_params = params['Inner']['Heat']` and `self._inner_hold_params = params['Inner']['Hold']` for the per-tick lookup below; `self.pid_inner_cool.set_params(params['Inner']['Cool'])`. - - `process_pid()` (lines 133-160): rename `pid_hold_y` → `pid_outer_y` - and the `self.pid_hold.process(...)` call. In the combined `HOLD`/`HEAT` - branch (today's `else`, lines 156-158), select the active param set - before processing: + - `process_pid()` (lines 136-163): `pid_hold_y` → `pid_outer_y`, and the + `self.pid_hold.process(...)` call. In the combined `HOLD`/`HEAT` + branch (lines 159-163), the active param set is selected before + processing: ```python else: inner_params = self._inner_heat_params if self.state == States.HEAT else self._inner_hold_params @@ -165,60 +168,65 @@ The current names don't match what actually runs when: ``` `set_params()` is a cheap dict-reference assignment (`pid.py:22-23`), so calling it every tick has no meaningful cost. Because `kp`/`ki`/`kd`/ - `kt` are identical between `Inner.Heat` and `Inner.Hold` in the starting - config, switching the active set at a `HOLD↔HEAT` transition changes - no term of `y` at that instant — only the `yi_max` ceiling going - forward. No bump at the transition, unlike the freeze/thaw approach. + `kt` are identical between `Inner.Heat` and `Inner.Hold` in the + shipped config, switching the active set at a `HOLD↔HEAT` transition + changes no term of `y` at that instant — only the `yi_max` ceiling + going forward, with one caveat noted in Status below. 4. **Config files** — `config.json`, `config-real.json.tpl`, - `config-sim.json.tpl`: restructure into `Outer`/`Inner.{Heat,Hold,Cool}` - as above. Inline `"Cool": {...}` dicts in `scripts/demos/pid/ - demo_temp_controller_smith.py:21`, `demo_temp_controller.py:21`, and - `scripts/demos/sud/demo_sud.py:32` need the same restructure. + `config-sim.json.tpl` restructured into `Outer`/`Inner.{Heat,Hold,Cool}` + as above. The inline `"Cool": {...}` dicts in `scripts/demos/pid/ + demo_temp_controller_smith.py`, `demo_temp_controller.py`, and + `scripts/demos/sud/demo_sud.py` got the same restructure. 5. **`utils/replay_sim.py`** — `_apply_gain_overrides()` and the CLI flag - loop (lines 50, 214-222) iterate `('Hold','hold'), ('Heat','heat'), - ('Cool','cool')`; becomes `('Outer','outer'), ('Inner.Heat','inner-heat'), - ('Inner.Hold','inner-hold'), ('Inner.Cool','inner-cool')` (nested dict - access needed since these aren't flat top-level keys any more). The - `for section in ('Hold','Heat','Cool')` print loop at line 284 and the - `_infer_heatrate_soll_set()` docstring's "hold PID" reference (line 66) - need the same rename. -6. **`components/pid/TODO.md`** — update the cross-link entry added for the - previous (superseded) plan to point at this rewritten section instead. + loop now iterate the shared `GAIN_SECTIONS = (('Outer','outer'), + ('Inner.Heat','inner-heat'), ('Inner.Hold','inner-hold'), + ('Inner.Cool','inner-cool'))`, with nested dict access for the + `Inner.*` entries. The params print loop and + `_infer_heatrate_soll_set()`'s docstring were updated to match + (`pid_outer.get_y()` instead of "the hold PID"). +6. **`components/pid/TODO.md`** — the windup entry is marked `[x]` and + points at this section. -## Test plan (not yet implemented) +## Tests -Stdlib `unittest`, no pytest — `tests/components/pid/test_pid.py`, -discoverable via `python -m unittest discover -t . -s tests/components/pid`. -Simpler than the FSM-gating plan's test plan: no engage/disengage hysteresis -or threshold-relationship behavior to cover, since the loop is never turned -off — only its `yi_max` ceiling changes with state. +Stdlib `unittest`, no pytest — `tests/components/pid/test_pid.py` and +`test_temp_controller_closed_loop.py`, discoverable via +`python -m unittest discover -t . -s tests/components/pid` (or `-s tests` +for the whole repo suite). Simpler than the FSM-gating plan's test plan +would have needed: no engage/disengage hysteresis or threshold-relationship +behavior to cover, since the loop is never turned off — only its `yi_max` +ceiling changes with state. -**A. Unit-level, isolated `Pid`** — no plant involved. Feed a synthetic -`err` sequence shaped like the incident (positive `heatrate_err ≈ 0.3` held -for ~130 ticks, then decaying/negative tail, matching the real -`rate_soll - rate_ist` pulled from the log) into two `Pid` instances with -identical gains, one with `yi_max` set (the `Inner.Hold` case) and one -without (`Inner.Heat`). Assert: recovery time (ticks after error goes -negative until `y` drops back under a small threshold) is measurably -shorter when clamped; `yi` never exceeds the configured bound. +**A. Unit-level, isolated `Pid`** (`test_pid.py`) — no plant involved. Feeds +a synthetic `err` sequence shaped like the incident (positive +`heatrate_err = 0.3` held for 130 ticks, matching the outer loop's real +demand during the disturbance, then a flat `-0.3` tail for 200 ticks, +matching the real `rate_soll - rate_ist` gap once the outer loop had +zeroed its target) into two `Pid` instances with identical gains, one with +`yi_max` set (the `Inner.Hold` case) and one without (`Inner.Heat`). +Asserts: recovery time (ticks after the error goes negative until `y` +drops back under a small threshold) is measurably shorter when clamped; +`yi` never exceeds the configured bound; the unclamped instance's `yi` +does exceed it (sanity-checks the test itself isn't vacuous). -**B. Closed-loop, self-contained synthetic scenario** — no dependency on -the multi-MB log file. Real numbers from `PlantParams`/`config.json`: -`Pot(dt)` with `M=27.96, C=3403.43, L=0.2, Td=17`, ambient from -`config.json`; `TempController` via `PidFactory.create('Smith', dt)` with -the real `Outer`/`Inner.*` gains. Run closed-loop (controller's own `y` -drives the plant, unlike `utils/replay_sim.py`'s open-loop observe-only -mode): +**B. Closed-loop** (`test_temp_controller_closed_loop.py`) — real `Pot(dt)` +plant with `M=27.96, C=3403.43, L=0.2, Td=17`, ambient `20`°C, driven by a +real `TempController(Smith)` with the shipped `Outer`/`Inner.*` gains. +Controller's own `y` feeds back into the plant each tick (unlike +`utils/replay_sim.py`'s open-loop observe-only mode): - **Disturbance case**: hold at 30°C until settled, knock `plant.temp` down - ~0.5°C to emulate the cold-water event, keep ticking for several minutes. - Assert peak overshoot above 30.0°C is measurably smaller with `Inner.Hold`'s - `yi_max` set than without. -- **Ramp case**: command a genuine 1.5 K/min ramp (state reaches `HEAT`) and - assert the sustained heat rate actually reaches ~1.5 K/min — guards against - reintroducing the flat-clamp regression from the rejected approach above. -- **Transition case**: drive a `HOLD→HEAT→HOLD` sequence and assert `y` has - no discontinuity at either transition beyond what the changing `heatrate_err` - itself would explain — confirms the per-state param swap is truly bumpless. + 0.5°C to emulate the cold-water event, keep ticking for 600 more ticks — + confirms the state stays in `HOLD` throughout, matching the incident. + Asserts peak overshoot above 30.0°C is measurably smaller with + `Inner.Hold`'s `yi_max` set than with an unclamped copy of `Inner.Heat`. +- **Ramp case**: commands a genuine 1.5 K/min ramp to 40°C (state reaches + `HEAT`) and asserts the sustained heat rate actually exceeds 1.4 K/min — + guards against reintroducing the flat-clamp regression from the rejected + approach above. +- **Transition case**: drives a `HOLD→HEAT→HOLD` sequence and asserts the + `y` step at either transition stays under `0.1` — see the retroactive-clamp + caveat in Status below for why this isn't a stricter "no discontinuity" + assertion. ## Status @@ -238,3 +246,12 @@ the `HEAT→HOLD` transition clamps `yi` back down immediately, producing a 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. + +## Architecture diagram + +A full signal-flow diagram of the cascade (`Outer`/`Inner.*` PIDs, FSM +state gating, Smith-predictor feedback) exists as a Claude Artifact: +https://claude.ai/code/artifact/a32e4752-b4a6-4153-b344-eb2423eb6512 — this +is a session-scoped link, not a durable one, so it may not resolve for +everyone with repo access. `docs/fsm_states.png` is a static screenshot of +just the diagram's FSM-states panel, checked in as the durable copy. diff --git a/docs/temp_control_calibration.md b/docs/temp_control_calibration.md index c25d070..e1d8f53 100644 --- a/docs/temp_control_calibration.md +++ b/docs/temp_control_calibration.md @@ -143,7 +143,7 @@ Two problems compound: differentiating is the least effective arrangement: noise has already been amplified before the filter sees it. -The noisy `heatrate_err` feeds into `pid_heat.process()` as the proportional +The noisy `heatrate_err` feeds into `pid_inner.process()` as the proportional error. With `kp=0.08`, ±1 K/min rate ripple produces ±8% power output noise. ### Options From 2d032bf9b2f7a568f1db50e065db03c4aa2b1fee Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 6 Jul 2026 08:19:10 +0200 Subject: [PATCH 4/6] 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 From 6d17419067fd734bc4802d991960353ac59c4195 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 6 Jul 2026 08:26:25 +0200 Subject: [PATCH 5/6] docs: note active-cooler architecture assessment in pid TODO Captures the answer to "would the cascade/FSM design still hold up with a real active cooler instead of heat-only + passive loss": PID/plant model already generalize (symmetric y range, sign-agnostic plant math, existing COOL state/pid_inner_cool), but the actor-level max(0, y) clamp in tasks/heater.py is the one thing making today's tuning mismatches free - removing it exposes both Outer.y_hold_min and pid_inner_cool as live, untuned negative-power paths. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE --- components/pid/TODO.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/components/pid/TODO.md b/components/pid/TODO.md index 1041177..8be35a9 100644 --- a/components/pid/TODO.md +++ b/components/pid/TODO.md @@ -118,6 +118,43 @@ git history for `temp_controller.py`/`temp_controller_smith.py`). `TestHoldOvershootRecoversToSetpoint` in `tests/components/pid/test_temp_controller_closed_loop.py`. +- [ ] **Architecture isn't ready for a real active cooler yet, only a + heat-only actuator.** Asked (not yet built): would the cascade/FSM + design still hold up if an active cooler (compressor/chiller, some + finite cooling power) replaced "heater off + passive ambient loss"? + Mostly yes at the PID/plant-model level: `Pid.y_min`/`y_max` are + already symmetric `-1.0`/`1.0` (`pid.py:12-13`), `Pot.set_power()`/ + `process()` are sign-agnostic so the simulator already models negative + power correctly, and the FSM already has a dedicated `COOL` state with + its own PID instance (`pid_inner_cool`, `temp_controller_fsm.py:35-39`) + whose comment explicitly anticipates this ("the actuator ... is + responsible for clamping the resulting negative power to whatever it's + actually capable of"). + The gap is at the actuator boundary: today `y` only ever reaches one + consumer, `tasks/heater.py:59`'s `self.power_actor = max(0, ... * y)`, + which silently discards every negative command — that's what's made + tuning mismatches free so far. A real cooler needs (a) a second + actuator/device class consuming the negative half instead of that + `max(0, ...)` clamp — there's no `Cooler`/chiller abstraction anywhere + yet (`grep -rniE "cooler|chiller|compressor"` across all `.py` is + empty, fully greenfield), and `heater_hendi.py:80`'s `set_power` only + clamps to a max, not a min, so feeding it a negative value today would + call `setPowerWatts(negative)` on real hardware with undefined result; + and (b) its own power ceiling, since one normalized `y` scaled by one + `get_power_max()` breaks once heater and cooler have different max + power. + Biggest risk once that clamp is removed: two separate paths start + issuing real negative-power commands that were previously harmless - + HOLD's `Outer.y_hold_min` floor (via `Inner.Hold`, see the steady-state + overshoot item above) and the pre-existing `pid_inner_cool` (via + `Inner.Cool`) - and neither has been tuned against actual active-cooling + dynamics (compressor lag, min-on-time). `Inner.Cool` in the demo + configs is literally a copy of `Inner.Heat`'s gains, never validated + independently. So adding a real cooler is a wiring task (new actuator + class, per-actuator power scaling) plus a re-tuning task (`Inner.Cool`, + and re-checking the `HoldCool`/`CoolHold` thresholds in + `temp_controller_fsm.py:14-17`), not just wiring. + - [ ] **`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 From 5a75d1541641fb0b68cc80be040be551d2f845bc Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 6 Jul 2026 08:30:40 +0200 Subject: [PATCH 6/6] docs: note grounding y_hold_min in passive-cooling capacity in pid TODO Captures the idea of computing the HOLD-state negative floor from the Smith controller's internal Pot model (L*(theta_ist-theta_amb)/C) instead of the fixed -0.1 heuristic, plus the caveat that the Normal controller has no internal plant model to draw the same estimate from. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE --- components/pid/TODO.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/components/pid/TODO.md b/components/pid/TODO.md index 8be35a9..62a7c9b 100644 --- a/components/pid/TODO.md +++ b/components/pid/TODO.md @@ -155,6 +155,33 @@ git history for `temp_controller.py`/`temp_controller_smith.py`). and re-checking the `HoldCool`/`CoolHold` thresholds in `temp_controller_fsm.py:14-17`), not just wiring. +- [ ] **`Outer.y_hold_min` is a fixed heuristic constant, not grounded in + the pot's actual passive-cooling capacity.** Related to the active-cooler + item above. `Pot.process()` (`components/plant/pot.py:86-89`) already + computes `p_loss = L * M * (temp - amb)` unconditionally every tick, + independent of `y` - passive cooling isn't actually *driven by* the + negative half of `y` today, it's a physics term that happens regardless + of what the controller commands. `-0.1` (see the steady-state overshoot + fix above) was picked by estimating a typical passive loss rate by hand + for one plant/ambient combination; it doesn't adapt to a different pot, + water mass, or `theta_ist - theta_amb` delta. + `TempController` (Smith, `temp_controller_smith.py:12-16,22-32`) already + carries its own internal `Pot` model with `L`/`M`/`C` and ambient + temperature set (needed for the Smith prediction) - it could expose a + `heatrate_loss_max(theta_ist) = L * (theta_ist - theta_amb) / C * 60` + (K/min) from that model and use it to size `y_hold_min` (and bound + `pid_inner_cool`'s target) dynamically per-tick, instead of a fixed + config constant - the "cooling path" would always ask for exactly what + passive loss can actually deliver, no more and no less. Also a natural + stepping stone toward the active-cooler item above: same "ceiling" + concept, just a bigger number once real cooling power exists. + Caveat: `temp_controller.py` ("Normal", non-Smith) has no internal plant + model at all - `set_model_plant_params()`/`set_ambient_temperature()` + are no-ops in `TempControllerBase` and only overridden in the Smith + subclass - so this only works for Smith out of the box; Normal would + need either its own lightweight loss estimate (e.g. from observed + `heatrate_ist` decay while power is ~0) or keep the fixed fallback. + - [ ] **`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