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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
This commit is contained in:
2026-07-05 21:23:55 +02:00
co-authored by Claude Sonnet 5
parent da832b961e
commit f69d63c31b
17 changed files with 411 additions and 113 deletions
+7 -6
View File
@@ -63,7 +63,7 @@ git history for `temp_controller.py`/`temp_controller_smith.py`).
before that file was removed entirely — `Pot` dropped `gain` before that file was removed entirely — `Pot` dropped `gain`
entirely, see `components/plant/TODO.md`. 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 anti-windup engagement.** A cold-water disturbance while holding drove
`pid_heat`'s integral term up without ever saturating `y` (peaked at `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 `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 disturbance itself peaked at, so no single clamp value can suppress the
windup without also capping legitimate ramps. An FSM-gating alternative windup without also capping legitimate ramps. An FSM-gating alternative
(freeze the loop's output in `HOLD` unless engaged) was also superseded. (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 `pid_inner`/`pid_inner_cool` (matching what actually runs when), and
split the inner loop's config into `Inner.Heat`/`Inner.Hold`/ 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 `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 `HOLD` is driving it and stays unclamped for real `HEAT` ramps — no
freeze/thaw, bumpless transfer preserved for free. See freeze/thaw, bumpless transfer preserved for free. See
`docs/overshoot_hold_windup.md` for the full writeup and test plan. This `docs/overshoot_hold_windup.md` for the full writeup. This was also a
is also a breaking config change (`Hold`/`Heat`/`Cool``Outer`/ breaking config change (`Hold`/`Heat`/`Cool``Outer`/`Inner.*`) —
`Inner.*`) — every deployed `config.json` needs migrating, not just the `config.json`, the templates, and the demo scripts were all migrated.
repo templates. 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 - [ ] **`kalman.py` is now dead code in production.** Neither
`temp_controller.py` nor `temp_controller_smith.py` uses `Kalman` `temp_controller.py` nor `temp_controller_smith.py` uses `Kalman`
+3
View File
@@ -35,6 +35,9 @@ class Pid:
kt = self.params['kt'] * scale kt = self.params['kt'] * scale
self.yi = self.yi + ki*dt * err + kt*dt * self.awu 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) yd = kd/dt*(d - self.d)
yp = kp * err yp = kp * err
+20 -15
View File
@@ -19,6 +19,8 @@ class TempControllerBase(TempControllerFsm, APid):
# params/set_params() (components/pid/pid.py), whose process() # params/set_params() (components/pid/pid.py), whose process()
# already relies on the same "None means not configured yet". # already relies on the same "None means not configured yet".
self.params = None self.params = None
self._inner_heat_params = None
self._inner_hold_params = None
self.y = -1 self.y = -1
# Heat-rate pre-filter state (option A) - None until first process() tick # Heat-rate pre-filter state (option A) - None until first process() tick
self.last_theta_ist = None self.last_theta_ist = None
@@ -29,9 +31,10 @@ class TempControllerBase(TempControllerFsm, APid):
self.params = params self.params = params
self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})} self.thresholds = {**DEFAULT_THRESHOLDS, **params.get('Thresholds', {})}
self.beta = params.get('beta', 0.05) self.beta = params.get('beta', 0.05)
self.pid_hold.set_params(params['Hold']) self.pid_outer.set_params(params['Outer'])
self.pid_heat.set_params(params['Heat']) self._inner_heat_params = params['Inner']['Heat']
self.pid_cool.set_params(params['Cool']) self._inner_hold_params = params['Inner']['Hold']
self.pid_inner_cool.set_params(params['Inner']['Cool'])
def _compute_heatrate(self, theta_ist): def _compute_heatrate(self, theta_ist):
"""Pre-filter theta_ist, then differentiate and low-pass to get heatrate_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 return self.heatrate_soll_set
def process_pid(self, theta_err, hold_scale=1.0): def process_pid(self, theta_err, hold_scale=1.0):
self.pid_hold.process(theta_err, -self.theta_ist, hold_scale) self.pid_outer.process(theta_err, -self.theta_ist, hold_scale)
# In HOLD state, clamp pid_hold's output to [0, 1]: a small temperature # In HOLD state, clamp pid_outer's output to [0, 1]: a small temperature
# overshoot makes pid_hold.y go negative, which would invert heatrate_soll # overshoot makes pid_outer.y go negative, which would invert heatrate_soll
# and drive pid_heat's power to 0, causing a limit cycle (power on → # 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 # overshoot → power off → coast down → repeat). Clamping to 0 lets the
# outer loop reduce the inner setpoint to zero but no further. # 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: if self.state == States.HOLD:
pid_hold_y = max(0.0, pid_hold_y) pid_outer_y = max(0.0, pid_outer_y)
self.heatrate_soll = self.heatrate_soll_set * pid_hold_y self.heatrate_soll = self.heatrate_soll_set * pid_outer_y
heatrate_err = self.heatrate_soll - self.heatrate_ist heatrate_err = self.heatrate_soll - self.heatrate_ist
# Only the PID actually driving y is advanced - otherwise the # 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 # would keep silently integrating against a heatrate_err that
# isn't actually under its control, building a stale windup that # isn't actually under its control, building a stale windup that
# causes a discontinuity in y the moment it takes back over. # causes a discontinuity in y the moment it takes back over.
if self.state == States.IDLE: if self.state == States.IDLE:
self.y = 0 self.y = 0
elif self.state == States.COOL: elif self.state == States.COOL:
self.pid_cool.process(heatrate_err, -self.heatrate_ist) self.pid_inner_cool.process(heatrate_err, -self.heatrate_ist)
self.y = self.pid_cool.get_y() self.y = self.pid_inner_cool.get_y()
else: else:
self.pid_heat.process(heatrate_err, -self.heatrate_ist) inner_params = self._inner_heat_params if self.state == States.HEAT else self._inner_hold_params
self.y = self.pid_heat.get_y() 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() self.post_pid()
+20 -20
View File
@@ -8,9 +8,9 @@ DEFAULT_THRESHOLDS = {
# when COOL meant nothing more than going idle - it's an active state # 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 # 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 # 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 # chatter in and out of it every tick, resetting both pid_outer's and
# pid_cool's integrators each time and never letting either actually # pid_inner_cool's integrators each time and never letting either
# converge. Symmetric with the others instead. # actually converge. Symmetric with the others instead.
"HoldCool": 1.0, "HoldCool": 1.0,
"HeatHold": 1.0, "HeatHold": 1.0,
"HeatCool": 1.0, "HeatCool": 1.0,
@@ -30,14 +30,14 @@ class States(enum.Enum):
class TempControllerFsm: class TempControllerFsm:
def __init__(self, dt): def __init__(self, dt):
self.pid_hold = Pid(dt) self.pid_outer = Pid(dt)
self.pid_heat = Pid(dt) self.pid_inner = Pid(dt)
# Separate gains for ramping down (negative diff) - the actuator # Separate gains for ramping down (negative diff) - the actuator
# (e.g. a heat-only Pot/heater) is responsible for clamping the # (e.g. a heat-only Pot/heater) is responsible for clamping the
# resulting negative power to whatever it's actually capable of; # resulting negative power to whatever it's actually capable of;
# the controller itself no longer assumes "can't cool" == "must go # the controller itself no longer assumes "can't cool" == "must go
# idle". # idle".
self.pid_cool = Pid(dt) self.pid_inner_cool = Pid(dt)
self.thresholds = None self.thresholds = None
self.state = States.INIT self.state = States.INIT
self.is_startup = True self.is_startup = True
@@ -80,13 +80,13 @@ class TempControllerFsm:
# which calls this method directly), so an unconditional HOLD # which calls this method directly), so an unconditional HOLD
# here gets mistaken for "ramp already reached" and can finish # here gets mistaken for "ramp already reached" and can finish
# a freshly (re-)started step instantly - see SudTask. # a freshly (re-)started step instantly - see SudTask.
# on_process()'s is_holding() check. pid_heat/pid_cool were # on_process()'s is_holding() check. pid_inner/pid_inner_cool
# frozen (see process_pid()) and possibly stale for as long as # were frozen (see process_pid()) and possibly stale for as long
# we were disabled - start whichever one matters clean rather # as we were disabled - start whichever one matters clean rather
# than resuming wherever it last left off. # than resuming wherever it last left off.
self.pid_hold.reset() self.pid_outer.reset()
self.pid_heat.reset() self.pid_inner.reset()
self.pid_cool.reset() self.pid_inner_cool.reset()
if diff >= self.thresholds['HoldHeat']: if diff >= self.thresholds['HoldHeat']:
state_next = States.HEAT state_next = States.HEAT
elif diff <= -self.thresholds['HoldCool']: elif diff <= -self.thresholds['HoldCool']:
@@ -96,31 +96,31 @@ class TempControllerFsm:
elif self.state == States.HOLD: elif self.state == States.HOLD:
if diff >= self.thresholds['HoldHeat']: if diff >= self.thresholds['HoldHeat']:
state_next = States.HEAT 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 # hold-phase integral into the new ramp so power doesn't
# drop to near-zero and crawl back up from scratch. # drop to near-zero and crawl back up from scratch.
elif diff <= -self.thresholds['HoldCool']: elif diff <= -self.thresholds['HoldCool']:
state_next = States.COOL state_next = States.COOL
self.pid_cool.reset() self.pid_inner_cool.reset()
elif self.state == States.HEAT: elif self.state == States.HEAT:
if diff <= -self.thresholds['HeatCool']: if diff <= -self.thresholds['HeatCool']:
state_next = States.COOL state_next = States.COOL
self.pid_cool.reset() self.pid_inner_cool.reset()
elif diff <= self.thresholds['HeatHold']: elif diff <= self.thresholds['HeatHold']:
state_next = States.HOLD state_next = States.HOLD
self.pid_hold.reset() self.pid_outer.reset()
elif self.state == States.COOL: elif self.state == States.COOL:
if diff >= self.thresholds['CoolHeat']: if diff >= self.thresholds['CoolHeat']:
state_next = States.HEAT state_next = States.HEAT
self.pid_heat.reset() self.pid_inner.reset()
elif diff >= -self.thresholds['CoolHold']: elif diff >= -self.thresholds['CoolHold']:
state_next = States.HOLD state_next = States.HOLD
self.pid_hold.reset() self.pid_outer.reset()
# pid_heat was frozen during COOL (see process_pid()) - # pid_inner was frozen during COOL (see process_pid()) -
# resume it clean rather than from whatever it last held # resume it clean rather than from whatever it last held
# before COOL took over, which by now may be a stale fit # before COOL took over, which by now may be a stale fit
# for a completely different part of the curve. # for a completely different part of the curve.
self.pid_heat.reset() self.pid_inner.reset()
if state_next != self.state: if state_next != self.state:
self.state = state_next self.state = state_next
+1 -1
View File
@@ -46,7 +46,7 @@ class SudForecastEstimator:
estimate() also duty-cycles the PID's continuous output across the estimate() also duty-cycles the PID's continuous output across the
heater's own discrete power steps (see its actuate() closure), exactly heater's own discrete power steps (see its actuate() closure), exactly
like the real run's HeaterTask/device chain does - feeding tc.get_power() 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 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 real run actually is (the discrete steps end up duty-cycling it back
down to something close to the commanded average).""" down to something close to the commanded average)."""
+21 -12
View File
@@ -4,23 +4,32 @@
"TempCtrl": { "TempCtrl": {
"pid_type": "Smith", "pid_type": "Smith",
"beta": 0.05, "beta": 0.05,
"Hold": { "Outer": {
"kp": 0.4, "kp": 0.4,
"ki": 0.0, "ki": 0.0,
"kd": 0.0, "kd": 0.0,
"kt": 0.0 "kt": 0.0
}, },
"Heat": { "Inner": {
"kp": 0.08, "Heat": {
"ki": 0.02, "kp": 0.08,
"kd": 0.0, "ki": 0.02,
"kt": 1.5 "kd": 0.0,
}, "kt": 1.5
"Cool": { },
"kp": 0.08, "Hold": {
"ki": 0.02, "kp": 0.08,
"kd": 0.0, "ki": 0.02,
"kt": 1.5 "kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
}, },
"Thresholds": { "Thresholds": {
"HoldHeat": 1.0, "HoldHeat": 1.0,
+21 -12
View File
@@ -4,23 +4,32 @@
"TempCtrl": { "TempCtrl": {
"pid_type": "Smith", "pid_type": "Smith",
"beta": 0.05, "beta": 0.05,
"Hold": { "Outer": {
"kp": 0.4, "kp": 0.4,
"ki": 0.0, "ki": 0.0,
"kd": 0.0, "kd": 0.0,
"kt": 0.0 "kt": 0.0
}, },
"Heat": { "Inner": {
"kp": 0.08, "Heat": {
"ki": 0.02, "kp": 0.08,
"kd": 0.0, "ki": 0.02,
"kt": 1.5 "kd": 0.0,
}, "kt": 1.5
"Cool": { },
"kp": 0.08, "Hold": {
"ki": 0.02, "kp": 0.08,
"kd": 0.0, "ki": 0.02,
"kt": 1.5 "kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
}, },
"Thresholds": { "Thresholds": {
"HoldHeat": 1.0, "HoldHeat": 1.0,
+16 -1
View File
@@ -222,4 +222,19 @@ mode):
## Status ## 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.
+21 -12
View File
@@ -6,23 +6,32 @@ from components.pid.temp_controller import TempController
if __name__ == '__main__': if __name__ == '__main__':
ctrl_params = { ctrl_params = {
"Hold": { "Outer": {
"kp": 0.4, "kp": 0.4,
"ki": 0.0, "ki": 0.0,
"kd": 0.0, "kd": 0.0,
"kt": 0.0 "kt": 0.0
}, },
"Heat": { "Inner": {
"kp": 0.08, "Heat": {
"ki": 0.008, "kp": 0.08,
"kd": 0.0, "ki": 0.008,
"kt": 1.5 "kd": 0.0,
}, "kt": 1.5
"Cool": { },
"kp": 0.08, "Hold": {
"ki": 0.008, "kp": 0.08,
"kd": 0.0, "ki": 0.008,
"kt": 1.5 "kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.008,
"kd": 0.0,
"kt": 1.5
}
} }
} }
+21 -12
View File
@@ -6,23 +6,32 @@ from components.pid.temp_controller_smith import TempController
if __name__ == '__main__': if __name__ == '__main__':
ctrl_params = { ctrl_params = {
"Hold": { "Outer": {
"kp": 0.4, "kp": 0.4,
"ki": 0.0, "ki": 0.0,
"kd": 0.0, "kd": 0.0,
"kt": 0.0 "kt": 0.0
}, },
"Heat": { "Inner": {
"kp": 0.08, "Heat": {
"ki": 0.008, "kp": 0.08,
"kd": 0.0, "ki": 0.008,
"kt": 1.5 "kd": 0.0,
}, "kt": 1.5
"Cool": { },
"kp": 0.08, "Hold": {
"ki": 0.008, "kp": 0.08,
"kd": 0.0, "ki": 0.008,
"kt": 1.5 "kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.008,
"kd": 0.0,
"kt": 1.5
}
} }
} }
+21 -12
View File
@@ -17,23 +17,32 @@ if __name__ == '__main__':
theta_amb = 20 theta_amb = 20
ctrl_params = { ctrl_params = {
"Hold": { "Outer": {
"kp": 0.4, "kp": 0.4,
"ki": 0.0, "ki": 0.0,
"kd": 0.0, "kd": 0.0,
"kt": 0.0 "kt": 0.0
}, },
"Heat": { "Inner": {
"kp": 0.08, "Heat": {
"ki": 0.02, "kp": 0.08,
"kd": 0.0, "ki": 0.02,
"kt": 1.5 "kd": 0.0,
}, "kt": 1.5
"Cool": { },
"kp": 0.08, "Hold": {
"ki": 0.02, "kp": 0.08,
"kd": 0.0, "ki": 0.02,
"kt": 1.5 "kd": 0.0,
"kt": 1.5,
"yi_max": 0.3
},
"Cool": {
"kp": 0.08,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
} }
} }
View File
View File
View File
+69
View File
@@ -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()
@@ -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()
+22 -10
View File
@@ -45,13 +45,21 @@ from components.pid import PidFactory
from components.plant.pot import Pot 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): def _apply_gain_overrides(params, args):
p = copy.deepcopy(params) 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'): 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: 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 return p
@@ -63,7 +71,7 @@ def _infer_max_power(samples):
def _infer_heatrate_soll_set(samples): def _infer_heatrate_soll_set(samples):
"""Estimate heatrate_soll_set from the log. """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 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.""" 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] 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-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]') 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'): for gain in ('kp', 'ki', 'kd', 'kt'):
parser.add_argument( parser.add_argument(
'--{}-{}'.format(prefix, gain), '--{}-{}'.format(prefix, gain),
type=float, default=None, type=float, default=None,
dest='{}_{}'.format(prefix, gain), dest='{}_{}'.format(prefix.replace('-', '_'), gain),
metavar='VAL', metavar='VAL',
help='Override TempCtrl.{}.{}'.format(section, gain), 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) ambient = args.ambient if args.ambient is not None else config.get('ambient_temperature', 20.0)
any_override = any( any_override = any(
getattr(args, '{}_{}'.format(p, g)) is not None getattr(args, '{}_{}'.format(prefix.replace('-', '_'), g)) is not None
for p in ('hold', 'heat', 'cool') for _, prefix in GAIN_SECTIONS
for g in ('kp', 'ki', 'kd', 'kt') for g in ('kp', 'ki', 'kd', 'kt')
) )
config_source = 'log' if log.get('Config') else 'file' 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), print('Params: {} ({})'.format('overridden' if any_override else 'from {}'.format(config_source),
'log' if log_plant else 'defaults')) 'log' if log_plant else 'defaults'))
print() print()
for section in ('Hold', 'Heat', 'Cool'): for section, _ in GAIN_SECTIONS:
p = pid_params[section] 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)) 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, replay = run_replay(samples, pid_type, pid_params, rate_soll, plant_params, param_events,