Supersedes the earlier flat yi_max clamp / FSM-gating ideas: same inner PID instance switches its active param set (Inner.Heat vs Inner.Hold) by state instead of freezing, giving bumpless transfer for free and a tight yi_max that only applies while HOLD is driving it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KefnwbGDM8CGrhb4sFhVq9
12 KiB
HOLD-state overshoot from a disturbance: root cause and fix plan
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.
Incident summary
- Steady HOLD at 30.0°C. Cold water added →
theta_ist(Smith-corrected) dipped to ~29.49°C (diff ≈ 0.51°C). diffnever reachedThresholds.HoldHeat(1.0°C,temp_controller_fsm.py), so the FSM never leftHOLD— the entire episode happened inside what the system still considered "holding".- The outer loop (
pid_hold,Hold: {kp:0.6, ki:0, kd:0, kt:0}— pure proportional) tracked0.6 * max(0, diff)exactly, tick for tick, peaking aty ≈ 0.31— nowhere near itsy_max=1.0ceiling. Confirmed directly against logged samples:rate_soll==0.6*diffto the last digit throughout. - The inner loop (
pid_heat,Heat: {kp:0.08, ki:0.02, kd:0, kt:1.5}) then chased thatheatrate_solltarget, driving commanded power from a baseline ~500W burst-cycle up to ~2600W (y ≈ 0.74of the 3500W max, seetasks/heater.py:58-59:power = get_power_max() * y). - Plant dead time (
Td=17s) and thermal mass (M=27.96 kg,C≈3403 J/(kg·K), fromPlantParamsin the log) meant heat from that burst kept arriving aftertheta_isthad already crossed back over soll. By the timerate_sollhad fallen back to exactly0(outer loop asking for zero heat rate),power_setwas still 1442 W, decaying only gradually over the next ~35s (1193 → 979 → 817 → 632 → 487 → 337 → 142 W) rather than snapping to 0. - No active cooling actuator exists; the passive loss coefficient
(
L=0.2 W/(kg·K)) gives a time constantC/L ≈ 17,000s (~4.7h), so the resulting ~0.4°C plateau above soll does not visibly decay within any reasonable observation window.
Root cause: the inner loop's integral term (yi) accumulated during
the ~130s the outer loop demanded a real (if modest) heat rate, and nothing
bounded that accumulation — the existing anti-windup in Pid.process()
(components/pid/pid.py:45-48) is back-calculation that only corrects yi
when the combined output y is actually clamped at y_min/y_max. Since
y peaked at ~0.74, well under the 1.0 ceiling, awu was 0 the entire
time and the anti-windup mechanism never engaged. The integrator just had to
unwind naturally against real (delayed) negative error, which took ~35+
seconds after the outer loop had already zeroed its target.
Rejected approach: a flat yi_max clamp on the inner loop
The first fix considered was bounding yi directly, with one constant for
the inner loop regardless of state. Numeric check against the real plant
model kills this as a standalone fix:
| scenario | P needed | y needed |
|---|---|---|
| 1.5 K/min ramp @ 30°C | 2435 W | 0.696 |
| 1.5 K/min ramp @ 50°C | 2547 W | 0.728 |
| 1.5 K/min ramp @ 66°C | 2636 W | 0.753 |
| the actual HOLD disturbance (peak) | 2600 W | 0.74 |
| steady HOLD loss compensation @ 66°C | ~257 W | ~0.073 |
At steady state (err=0), kp*err contributes nothing — the entire
y≈0.7-0.75 needed to sustain a genuine 1.5 K/min ramp has to come from
yi alone, for as long as the ramp lasts. That's the same range the
disturbance transient itself peaked at, but far above what real steady-HOLD
loss compensation ever needs (~0.07-0.15 at realistic brew temperatures). A
single constant can't serve both: tight enough to matter for the disturbance
(well under ~0.74) permanently starves a real ramp of the ~0.7 it needs;
loose enough not to interfere with ramps (~0.75+) never engages during the
disturbance at all. Rejected as a single global constant.
An FSM-gating alternative (freeze the inner loop's output to 0 while in
HOLD unless explicitly engaged by command or a new threshold) was also
sketched, but left an open question about how a new engage threshold should
relate to the existing HoldHeat FSM threshold, and needed extra
engage/disengage hysteresis to avoid chatter. Superseded by the plan
below, which reaches the same effect with less new machinery.
Refined plan: split the clamp by which state is driving the same loop,
not by freezing the loop
The insight above — real HOLD-time demand (~0.07-0.15) and real ramp demand
(~0.7-0.75) occupy clearly different ranges — means a per-state parameter
set on the same PID instance solves this more cleanly than gating the
loop on/off: same accumulated yi/d state carried across HOLD↔HEAT
transitions (bumpless transfer for free, no explicit reset, no engage/
disengage hysteresis to tune), just a different yi_max ceiling depending
on which state is currently active.
Rename for honesty
The current names don't match what actually runs when:
pid_holdalready 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 topid_outer.pid_heatalready runs in bothHOLDandHEATtoday (onlyIDLE/COOLskip it,temp_controller_base.py:151-158) — it's the inner loop for the heating direction. Rename topid_inner.pid_coolonly ever runs inCOOL— noHOLD-time ambiguity, since a disturbance that pushes temp above soll duringHOLDis still handled bypid_inner(the outer loop'smax(0.0, pid_hold_y)floor sendsheatrate_sollto 0, andpid_innerreacts to the resulting negativeheatrate_err— the state only escalates to realCOOLpastThresholds.HoldCool). Rename topid_inner_coolfor symmetry, kept as its own instance — today's explicitreset()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.
Config: Hold/Heat/Cool → Outer / Inner.{Heat,Hold,Cool}
"TempCtrl": {
"pid_type": "Smith",
"beta": 0.9,
"Outer": { "kp": 0.6, "ki": 0.0, "kd": 0.0, "kt": 0.0 },
"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": { "...": "unchanged" }
}
Inner.Heatkeeps today'sHeatgains, noyi_max(or a very loose one) — a real ramp must be able to reachy≈0.75.Inner.Holdstarts as a copy of the same gains, withyi_max≈0.2-0.3added — comfortably above realistic steady-loss compensation (~0.07-0.15) but well below what turned a 0.5°C dip into a 2600W burst.Inner.CoolisCool's existing gains, moved underInnerpurely for structural consistency — introduced now, not because we've observed a cooling-side incident.pid_inner_coolnever runs duringHOLD, so it doesn't need its ownHoldvariant the wayHeatdoes; one params block is enough.- This is a breaking config change — no backward-compat shim for the
old flat
Hold/Heat/Coolkeys (per the "no compat hacks" convention). Every deployedconfig.jsonneeds migrating, not just the repo'sconfig-real.json.tpl/config-sim.json.tpltemplates.
Code changes (planned, not yet implemented)
components/pid/pid.py— add the symmetricyi_maxclamp insideprocess()(already planned):self.yi = max(-yi_max, min(yi_max, self.yi))whenself.params.get('yi_max')is set, applied right after accumulatingyiand before it's summed intoy.components/pid/temp_controller_fsm.py— renameself.pid_hold→self.pid_outer,self.pid_heat→self.pid_inner,self.pid_cool→self.pid_inner_cool(constructor at lines 33-34/40, allreset()call sites and comments at lines 11-12, 83, 87-89, 99, 104, 108, 111, 115, 118-119, 123).components/pid/temp_controller_base.py:set_params()(line 28-34):self.pid_outer.set_params(params['Outer']); storeself._inner_heat_params = params['Inner']['Heat']andself._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): renamepid_hold_y→pid_outer_yand theself.pid_hold.process(...)call. In the combinedHOLD/HEATbranch (today'selse, lines 156-158), select the active param set before processing:else: 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()set_params()is a cheap dict-reference assignment (pid.py:22-23), so calling it every tick has no meaningful cost. Becausekp/ki/kd/ktare identical betweenInner.HeatandInner.Holdin the starting config, switching the active set at aHOLD↔HEATtransition changes no term ofyat that instant — only theyi_maxceiling going forward. No bump at the transition, unlike the freeze/thaw approach.
- Config files —
config.json,config-real.json.tpl,config-sim.json.tpl: restructure intoOuter/Inner.{Heat,Hold,Cool}as above. Inline"Cool": {...}dicts inscripts/demos/pid/ demo_temp_controller_smith.py:21,demo_temp_controller.py:21, andscripts/demos/sud/demo_sud.py:32need the same restructure. 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). Thefor 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.components/pid/TODO.md— update the cross-link entry added for the previous (superseded) plan to point at this rewritten section instead.
Test plan (not yet implemented)
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.
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.
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):
- Disturbance case: hold at 30°C until settled, knock
plant.tempdown ~0.5°C to emulate the cold-water event, keep ticking for several minutes. Assert peak overshoot above 30.0°C is measurably smaller withInner.Hold'syi_maxset 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→HOLDsequence and assertyhas no discontinuity at either transition beyond what the changingheatrate_erritself would explain — confirms the per-state param swap is truly bumpless.
Status
Plan only — nothing in this document has been implemented yet.