docs: revise pid_heat windup plan to pid_outer/pid_inner rename + Inner.Hold split

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
This commit is contained in:
2026-07-04 12:48:59 +02:00
co-authored by Claude Sonnet 5
parent 13bb011099
commit de11b849d8
2 changed files with 160 additions and 76 deletions
+12 -5
View File
@@ -73,11 +73,18 @@ git history for `temp_controller.py`/`temp_controller_smith.py`).
clamp was considered and rejected: sustaining a genuine 1.5 K/min ramp clamp was considered and rejected: sustaining a genuine 1.5 K/min ramp
needs `y≈0.7-0.75` from `yi` alone at steady state, the same range the needs `y≈0.7-0.75` from `yi` alone at steady state, the same range the
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. Plan is instead to gate windup without also capping legitimate ramps. An FSM-gating alternative
`pid_heat`'s activity by FSM state (frozen/offline while `HOLD`, engaged (freeze the loop's output in `HOLD` unless engaged) was also superseded.
by command or a new `HoldHeatEngage` threshold) — see Current plan: rename `pid_hold`/`pid_heat`/`pid_cool` to `pid_outer`/
`docs/overshoot_hold_windup.md` for the full writeup, open design `pid_inner`/`pid_inner_cool` (matching what actually runs when), and
question (threshold relationship with `HoldHeat`), and test plan. 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.
- [ ] **`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`
+148 -71
View File
@@ -13,12 +13,12 @@ are pulled from `logs/log_latest.json`.
- `diff` never reached `Thresholds.HoldHeat` (1.0°C, `temp_controller_fsm.py`), - `diff` never reached `Thresholds.HoldHeat` (1.0°C, `temp_controller_fsm.py`),
so **the FSM never left `HOLD`** — the entire episode happened inside what so **the FSM never left `HOLD`** — the entire episode happened inside what
the system still considered "holding". the system still considered "holding".
- `pid_hold` (outer loop, `Hold: {kp:0.6, ki:0, kd:0, kt:0}` — pure - The outer loop (`pid_hold`, `Hold: {kp:0.6, ki:0, kd:0, kt:0}` — pure
proportional) tracked `0.6 * max(0, diff)` exactly, tick for tick, peaking proportional) tracked `0.6 * max(0, diff)` exactly, tick for tick, peaking
at `pid_hold_y ≈ 0.31` — nowhere near its `y_max=1.0` ceiling. Confirmed at `y ≈ 0.31` — nowhere near its `y_max=1.0` ceiling. Confirmed directly
directly against logged samples: `rate_soll` == `0.6*diff` to the last against logged samples: `rate_soll` == `0.6*diff` to the last digit
digit throughout. throughout.
- `pid_heat` (inner loop, `Heat: {kp:0.08, ki:0.02, kd:0, kt:1.5}`) then - The inner loop (`pid_heat`, `Heat: {kp:0.08, ki:0.02, kd:0, kt:1.5}`) then
chased that `heatrate_soll` target, driving commanded power from a chased that `heatrate_soll` target, driving commanded power from a
baseline ~500W burst-cycle up to **~2600W** (`y ≈ 0.74` of the 3500W max, baseline ~500W burst-cycle up to **~2600W** (`y ≈ 0.74` of the 3500W max,
see `tasks/heater.py:58-59`: `power = get_power_max() * y`). see `tasks/heater.py:58-59`: `power = get_power_max() * y`).
@@ -34,8 +34,8 @@ are pulled from `logs/log_latest.json`.
resulting ~0.4°C plateau above soll does not visibly decay within any resulting ~0.4°C plateau above soll does not visibly decay within any
reasonable observation window. reasonable observation window.
**Root cause:** `pid_heat`'s integral term (`yi`) accumulated during the **Root cause:** the inner loop's integral term (`yi`) accumulated during
~130s the outer loop demanded a real (if modest) heat rate, and nothing the ~130s the outer loop demanded a real (if modest) heat rate, and nothing
bounded that accumulation — the existing anti-windup in `Pid.process()` bounded that accumulation — the existing anti-windup in `Pid.process()`
(`components/pid/pid.py:45-48`) is back-calculation that only corrects `yi` (`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 when the combined output `y` is actually clamped at `y_min`/`y_max`. Since
@@ -44,12 +44,11 @@ time and the anti-windup mechanism never engaged. The integrator just had to
unwind naturally against real (delayed) negative error, which took ~35+ unwind naturally against real (delayed) negative error, which took ~35+
seconds after the outer loop had already zeroed its target. seconds after the outer loop had already zeroed its target.
## Rejected approach: flat `yi_max` clamp on `pid_heat` ## Rejected approach: a flat `yi_max` clamp on the inner loop
The first fix considered was bounding `yi` directly The first fix considered was bounding `yi` directly, with one constant for
(`self.yi = max(-yi_max, min(yi_max, self.yi))` in `Pid.process()`, the inner loop regardless of state. Numeric check against the real plant
independent of the existing `y`-clamp anti-windup). Numeric check against model kills this as a standalone fix:
the real plant model kills this as a standalone fix:
| scenario | P needed | y needed | | scenario | P needed | y needed |
|---|---|---| |---|---|---|
@@ -57,91 +56,169 @@ the real plant model kills this as a standalone fix:
| 1.5 K/min ramp @ 50°C | 2547 W | 0.728 | | 1.5 K/min ramp @ 50°C | 2547 W | 0.728 |
| 1.5 K/min ramp @ 66°C | 2636 W | 0.753 | | 1.5 K/min ramp @ 66°C | 2636 W | 0.753 |
| the actual HOLD disturbance (peak) | 2600 W | 0.74 | | 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 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 `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 `yi` alone, for as long as the ramp lasts. That's the *same* range the
disturbance transient itself peaked at. So: 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.**
- `yi_max` set low enough to meaningfully shorten the observed hangover An FSM-gating alternative (freeze the inner loop's output to 0 while in
(well under ~0.74, e.g. ~0.3) would permanently cap a real 1.5 K/min ramp `HOLD` unless explicitly engaged by command or a new threshold) was also
far below its commanded rate — not a transient dip, a persistent, sketched, but left an open question about how a new engage threshold should
uncorrectable shortfall for the entire ramp. relate to the existing `HoldHeat` FSM threshold, and needed extra
- `yi_max` set high enough not to interfere with legitimate ramps (~0.75+) engage/disengage hysteresis to avoid chatter. **Superseded** by the plan
sits at or above what the incident already peaked at, so it never below, which reaches the same effect with less new machinery.
engages and does nothing for the hangover.
A flat magnitude clamp cannot distinguish "leftover integral from an ## Refined plan: split the clamp by *which state is driving the same loop*,
already-resolved disturbance" from "integral correctly holding up a real ## not by freezing the loop
ongoing ramp" — in this plant they occupy the same output range. **Rejected.**
## Refined plan: gate `pid_heat` by FSM state instead 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.
Bound (or freeze) the *rate* at which `pid_heat` reacts based on FSM state, ### Rename for honesty
not a constant on `yi`, since real sustained-rate demand only ever happens
in `HEAT`; in `HOLD` any large `yi` is by definition windup, since the
target rate there is always ~0 outside of a real disturbance.
1. **New flag `heat_loop_active`**, scoped to `HOLD` only, in The current names don't match what actually runs when:
`TempControllerFsm`. `is_holding()` stays `state == HOLD` regardless - `pid_hold` already runs unconditionally *every* tick regardless of state
Sud step "have we reached target" logic is unaffected. (`process_pid()`'s first line, `temp_controller_base.py:134`) — it's the
2. **While `heat_loop_active` is False in `HOLD`:** force `y=0`, skip outer loop, not "the HOLD-state PID". Rename to **`pid_outer`**.
`pid_heat.process()` entirely (freeze it, don't reset — same - `pid_heat` already runs in *both* `HOLD` and `HEAT` today (only `IDLE`/
bumpless-resume convention already used for `HOLD→HEAT`, see `COOL` skip it, `temp_controller_base.py:151-158`) — it's the inner loop
`temp_controller_fsm.py:99-101`). `pid_hold` keeps running every tick for the heating direction. Rename to **`pid_inner`**.
regardless (it's pure-P, no windup risk, and its output is needed to - `pid_cool` only ever runs in `COOL` — no `HOLD`-time ambiguity, since a
evaluate the engage condition below). disturbance that pushes temp *above* soll during `HOLD` is still handled
3. **Engagement (`False→True`)**, checked each tick while in `HOLD`: by `pid_inner` (the outer loop's `max(0.0, pid_hold_y)` floor sends
- Explicit command — a new `engage_heat_loop()`/`force_heat()` call, `heatrate_soll` to 0, and `pid_inner` reacts to the resulting negative
wired to a manual UI action and/or auto-fired from `set_theta_soll()` `heatrate_err` — the state only escalates to real `COOL` past
when the new target is a genuine change (not the same value re-sent `Thresholds.HoldCool`). Rename to **`pid_inner_cool`** for symmetry, kept
every tick). as its own instance — today's explicit `reset()` calls when crossing
- `diff >= Thresholds.HoldHeatEngage` (new config key). between heat-direction and cool-direction states
4. **Disengagement:** once `diff` settles back inside the existing (`temp_controller_fsm.py:104,108,115,123`) stay exactly as they are; there
`HoldHeat`/`HoldCool` band, held for a short dwell time (a few seconds), is no reason to share integrator state across a heater/chiller boundary.
to avoid chatter right at the boundary.
5. **Config/back-compat:** new threshold merges into `DEFAULT_THRESHOLDS`
the same way existing ones do (`temp_controller_base.py:30`).
### Open design question (unresolved) ### Config: `Hold`/`Heat`/`Cool` → `Outer` / `Inner.{Heat,Hold,Cool}`
`HoldHeatEngage` needs to sit **below** the existing `HoldHeat`/`HeatHold` ```json
FSM threshold (currently `1.0°C`) to ever fire from inside `HOLD` — if "TempCtrl": {
`HoldHeat` stays at `1.0`, the FSM fully escalates to `HEAT` (where "pid_type": "Smith",
`pid_heat` always runs anyway) before any larger engage threshold is ever "beta": 0.9,
reached. Two ways to resolve, not yet decided: "Outer": { "kp": 0.6, "ki": 0.0, "kd": 0.0, "kt": 0.0 },
- Raise `HoldHeat`/`HeatHold` too, widening the HOLD↔HEAT band so `HOLD` "Inner": {
covers the full deadband and the new flag is the only gate within it. "Heat": { "kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5 },
- Keep `HoldHeat` at `1.0`, set the engage threshold below it (e.g. "Hold": { "kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5, "yi_max": 0.3 },
~0.7-0.8°C) — loop only goes offline for small disturbances, engaging "Cool": { "kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5 }
before the FSM would escalate to full `HEAT` anyway. },
"Thresholds": { "...": "unchanged" }
}
```
- `Inner.Heat` keeps today's `Heat` gains, no `yi_max` (or a very loose one)
— a real ramp must be able to reach `y≈0.75`.
- `Inner.Hold` starts as a copy of the same gains, with `yi_max≈0.2-0.3`
added — 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.Cool` is `Cool`'s existing gains, moved under `Inner` purely for
structural consistency — introduced now, not because we've observed a
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
old flat `Hold`/`Heat`/`Cool` keys (per the "no compat hacks" convention).
Every deployed `config.json` needs migrating, not just the repo's
`config-real.json.tpl`/`config-sim.json.tpl` templates.
### Code changes (planned, not yet 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
accumulating `yi` and before it's summed into `y`.
2. **`components/pid/temp_controller_fsm.py`** — rename `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
sites and comments at lines 11-12, 83, 87-89, 99, 104, 108, 111, 115,
118-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
`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:
```python
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. 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.
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.
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.
## Test plan (not yet implemented) ## Test plan (not yet implemented)
Stdlib `unittest`, no pytest — `tests/components/pid/test_pid.py`, Stdlib `unittest`, no pytest — `tests/components/pid/test_pid.py`,
discoverable via `python -m unittest discover -t . -s tests/components/pid`. 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 **A. Unit-level, isolated `Pid`** — no plant involved. Feed a synthetic
`err` sequence shaped like the incident (positive `heatrate_err ≈ 0.3` held `err` sequence shaped like the incident (positive `heatrate_err ≈ 0.3` held
for ~130 ticks, then decaying/negative tail, matching the real for ~130 ticks, then decaying/negative tail, matching the real
`rate_soll - rate_ist` pulled from the log) into `pid_heat` with the loop `rate_soll - rate_ist` pulled from the log) into two `Pid` instances with
gated (frozen while "HOLD"/inactive) vs ungated (today's behavior). Assert: identical gains, one with `yi_max` set (the `Inner.Hold` case) and one
recovery time (ticks after error goes negative until `y` drops back under a without (`Inner.Heat`). Assert: recovery time (ticks after error goes
small threshold) is measurably shorter when gated; `yi` never grows during negative until `y` drops back under a small threshold) is measurably
the frozen window. shorter when clamped; `yi` never exceeds the configured bound.
**B. Closed-loop, self-contained synthetic scenario** — no dependency on **B. Closed-loop, self-contained synthetic scenario** — no dependency on
the multi-MB log file. Real numbers from `PlantParams`/`config.json`: 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 `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 `config.json`; `TempController` via `PidFactory.create('Smith', dt)` with
the real `Hold`/`Heat`/`Cool` gains. Run closed-loop (controller's own `y` 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 drives the plant, unlike `utils/replay_sim.py`'s open-loop observe-only
mode): hold at 30°C until settled, knock `plant.temp` down ~0.5°C to mode):
emulate the cold-water event, keep ticking for several minutes. Run twice — - **Disturbance case**: hold at 30°C until settled, knock `plant.temp` down
gating on vs off — and assert peak overshoot above 30.0°C is measurably ~0.5°C to emulate the cold-water event, keep ticking for several minutes.
smaller with gating, while a separate run driving a genuine 1.5 K/min ramp Assert peak overshoot above 30.0°C is measurably smaller with `Inner.Hold`'s
confirms gating does *not* reduce the sustained ramp rate (guards against `yi_max` set than without.
reintroducing the flat-clamp regression above). - **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.
## Status ## Status