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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGQhVQ2Y3yXAQTXhrxVd5u
This commit is contained in:
2026-07-05 22:08:19 +02:00
co-authored by Claude Sonnet 5
parent acfb98e186
commit 11c3d92f25
3 changed files with 119 additions and 90 deletions
+23 -11
View File
@@ -32,13 +32,17 @@ git history for `temp_controller.py`/`temp_controller_smith.py`).
hold_scale)` — the overshoot-compensation scheduling logic now lives hold_scale)` — the overshoot-compensation scheduling logic now lives
entirely in the temp controller, not the generic `Pid` block. 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 (stdlib `unittest`, no pytest) covering FSM transitions, anti-windup
clamping, and Kalman convergence, but was removed again before the clamping, and Kalman convergence was removed before the Kalman-filter
Kalman-filter removal/Smith rewrite, so none of the current removal/Smith rewrite. A new `tests/components/pid/` suite was added
`temp_controller*.py` behavior (backward-difference + low-pass alongside the HOLD-windup fix below (`test_pid.py`,
filtered heat rate, the two-model Smith correction) has test `test_temp_controller_closed_loop.py`) covering the `yi_max` clamp and
coverage. This drives a physical heater — worth re-adding. 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 - [ ] **`set_model_power` isn't defined on every controller, but
`brewpi.py` wires it unconditionally.** `brewpi.py` always does `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 `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. This was also a `docs/overshoot_hold_windup.md` for the full writeup, and
breaking config change (`Hold`/`Heat`/`Cool``Outer`/`Inner.*`) — `docs/fsm_states.png` for a static screenshot of the FSM-states panel
`config.json`, the templates, and the demo scripts were all migrated. of the cascade architecture diagram (states/thresholds, and the
Test coverage for this (closed-loop disturbance/ramp/transition cases) HOLD→HEAT no-reset note). The full interactive diagram (signal-flow
is still outstanding — see the "No automated tests" item above. 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 - [ ] **`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`
+95 -78
View File
@@ -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 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 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 ~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 records the diagnosis, the fix that shipped for it, and the test coverage
are pulled from `logs/log_latest.json`. 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 ## Incident summary
@@ -88,23 +90,24 @@ on which state is currently active.
### Rename for honesty ### Rename for honesty
The current names don't match what actually runs when: The pre-fix names didn't match what actually ran when:
- `pid_hold` already runs unconditionally *every* tick regardless of state - `pid_hold` already ran unconditionally *every* tick regardless of state
(`process_pid()`'s first line, `temp_controller_base.py:134`) — it's the (`process_pid()`'s first line, `temp_controller_base.py:137`) — it's the
outer loop, not "the HOLD-state PID". Rename to **`pid_outer`**. outer loop, not "the HOLD-state PID". Renamed to **`pid_outer`**.
- `pid_heat` already runs in *both* `HOLD` and `HEAT` today (only `IDLE`/ - `pid_heat` already ran in *both* `HOLD` and `HEAT` (only `IDLE`/`COOL`
`COOL` skip it, `temp_controller_base.py:151-158`) — it's the inner loop skip it, `temp_controller_base.py:154-163`) — it's the inner loop for
for the heating direction. Rename to **`pid_inner`**. the heating direction. Renamed to **`pid_inner`**.
- `pid_cool` only ever runs in `COOL` — no `HOLD`-time ambiguity, since a - `pid_cool` only ever ran in `COOL` — no `HOLD`-time ambiguity, since a
disturbance that pushes temp *above* soll during `HOLD` is still handled 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_soll` to 0, and `pid_inner` reacts to the resulting negative
`heatrate_err` — the state only escalates to real `COOL` past `heatrate_err` — the state only escalates to real `COOL` past
`Thresholds.HoldCool`). Rename to **`pid_inner_cool`** for symmetry, kept `Thresholds.HoldCool`). Renamed to **`pid_inner_cool`** for symmetry, kept
as its own instance — today's explicit `reset()` calls when crossing as its own instance — the explicit `reset()` calls when crossing
between heat-direction and cool-direction states between heat-direction and cool-direction states
(`temp_controller_fsm.py:104,108,115,123`) stay exactly as they are; there (`temp_controller_fsm.py:104,108,115,123`) stay exactly as they were;
is no reason to share integrator state across a heater/chiller boundary. there is no reason to share integrator state across a heater/chiller
boundary.
### Config: `Hold`/`Heat`/`Cool` → `Outer` / `Inner.{Heat,Hold,Cool}` ### 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 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 doesn't need its own `Hold` variant the way `Heat` does; one params block
is enough. 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). 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. `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 1. **`components/pid/pid.py`** — the symmetric `yi_max` clamp lives inside
`process()` (already planned): `self.yi = max(-yi_max, min(yi_max, `process()` (line 40): `self.yi = max(-yi_max, min(yi_max, self.yi))`
self.yi))` when `self.params.get('yi_max')` is set, applied right after when `self.params.get('yi_max')` is set, applied right after
accumulating `yi` and before it's summed into `y`. 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_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, 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`**: 3. **`components/pid/temp_controller_base.py`**:
- `set_params()` (line 28-34): `self.pid_outer.set_params(params['Outer'])`; - `set_params()` (lines 30-37): `self.pid_outer.set_params(params['Outer'])`;
store `self._inner_heat_params = params['Inner']['Heat']` and stores `self._inner_heat_params = params['Inner']['Heat']` and
`self._inner_hold_params = params['Inner']['Hold']` for the per-tick `self._inner_hold_params = params['Inner']['Hold']` for the per-tick
lookup below; `self.pid_inner_cool.set_params(params['Inner']['Cool'])`. lookup below; `self.pid_inner_cool.set_params(params['Inner']['Cool'])`.
- `process_pid()` (lines 133-160): rename `pid_hold_y``pid_outer_y` - `process_pid()` (lines 136-163): `pid_hold_y``pid_outer_y`, and the
and the `self.pid_hold.process(...)` call. In the combined `HOLD`/`HEAT` `self.pid_hold.process(...)` call. In the combined `HOLD`/`HEAT`
branch (today's `else`, lines 156-158), select the active param set branch (lines 159-163), the active param set is selected before
before processing: processing:
```python ```python
else: else:
inner_params = self._inner_heat_params if self.state == States.HEAT else self._inner_hold_params 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`), `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`/ 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 `kt` are identical between `Inner.Heat` and `Inner.Hold` in the
config, switching the active set at a `HOLD↔HEAT` transition changes shipped config, switching the active set at a `HOLD↔HEAT` transition
no term of `y` at that instant — only the `yi_max` ceiling going changes no term of `y` at that instant — only the `yi_max` ceiling
forward. No bump at the transition, unlike the freeze/thaw approach. going forward, with one caveat noted in Status below.
4. **Config files** — `config.json`, `config-real.json.tpl`, 4. **Config files** — `config.json`, `config-real.json.tpl`,
`config-sim.json.tpl`: restructure into `Outer`/`Inner.{Heat,Hold,Cool}` `config-sim.json.tpl` restructured into `Outer`/`Inner.{Heat,Hold,Cool}`
as above. Inline `"Cool": {...}` dicts in `scripts/demos/pid/ as above. The inline `"Cool": {...}` dicts in `scripts/demos/pid/
demo_temp_controller_smith.py:21`, `demo_temp_controller.py:21`, and demo_temp_controller_smith.py`, `demo_temp_controller.py`, and
`scripts/demos/sud/demo_sud.py:32` need the same restructure. `scripts/demos/sud/demo_sud.py` got the same restructure.
5. **`utils/replay_sim.py`** — `_apply_gain_overrides()` and the CLI flag 5. **`utils/replay_sim.py`** — `_apply_gain_overrides()` and the CLI flag
loop (lines 50, 214-222) iterate `('Hold','hold'), ('Heat','heat'), loop now iterate the shared `GAIN_SECTIONS = (('Outer','outer'),
('Cool','cool')`; becomes `('Outer','outer'), ('Inner.Heat','inner-heat'), ('Inner.Heat','inner-heat'), ('Inner.Hold','inner-hold'),
('Inner.Hold','inner-hold'), ('Inner.Cool','inner-cool')` (nested dict ('Inner.Cool','inner-cool'))`, with nested dict access for the
access needed since these aren't flat top-level keys any more). The `Inner.*` entries. The params print loop and
`for section in ('Hold','Heat','Cool')` print loop at line 284 and the `_infer_heatrate_soll_set()`'s docstring were updated to match
`_infer_heatrate_soll_set()` docstring's "hold PID" reference (line 66) (`pid_outer.get_y()` instead of "the hold PID").
need the same rename. 6. **`components/pid/TODO.md`** — the windup entry is marked `[x]` and
6. **`components/pid/TODO.md`** — update the cross-link entry added for the points at this section.
previous (superseded) plan to point at this rewritten section instead.
## Test plan (not yet implemented) ## Tests
Stdlib `unittest`, no pytest — `tests/components/pid/test_pid.py`, Stdlib `unittest`, no pytest — `tests/components/pid/test_pid.py` and
discoverable via `python -m unittest discover -t . -s tests/components/pid`. `test_temp_controller_closed_loop.py`, discoverable via
Simpler than the FSM-gating plan's test plan: no engage/disengage hysteresis `python -m unittest discover -t . -s tests/components/pid` (or `-s tests`
or threshold-relationship behavior to cover, since the loop is never turned for the whole repo suite). Simpler than the FSM-gating plan's test plan
off — only its `yi_max` ceiling changes with state. 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 **A. Unit-level, isolated `Pid`** (`test_pid.py`) — no plant involved. Feeds
`err` sequence shaped like the incident (positive `heatrate_err ≈ 0.3` held a synthetic `err` sequence shaped like the incident (positive
for ~130 ticks, then decaying/negative tail, matching the real `heatrate_err = 0.3` held for 130 ticks, matching the outer loop's real
`rate_soll - rate_ist` pulled from the log) into two `Pid` instances with demand during the disturbance, then a flat `-0.3` tail for 200 ticks,
identical gains, one with `yi_max` set (the `Inner.Hold` case) and one matching the real `rate_soll - rate_ist` gap once the outer loop had
without (`Inner.Heat`). Assert: recovery time (ticks after error goes zeroed its target) into two `Pid` instances with identical gains, one with
negative until `y` drops back under a small threshold) is measurably `yi_max` set (the `Inner.Hold` case) and one without (`Inner.Heat`).
shorter when clamped; `yi` never exceeds the configured bound. 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 **B. Closed-loop** (`test_temp_controller_closed_loop.py`) — real `Pot(dt)`
the multi-MB log file. Real numbers from `PlantParams`/`config.json`: plant with `M=27.96, C=3403.43, L=0.2, Td=17`, ambient `20`°C, driven by a
`Pot(dt)` with `M=27.96, C=3403.43, L=0.2, Td=17`, ambient from real `TempController(Smith)` with the shipped `Outer`/`Inner.*` gains.
`config.json`; `TempController` via `PidFactory.create('Smith', dt)` with Controller's own `y` feeds back into the plant each tick (unlike
the real `Outer`/`Inner.*` gains. Run closed-loop (controller's own `y` `utils/replay_sim.py`'s open-loop observe-only mode):
drives the plant, unlike `utils/replay_sim.py`'s open-loop observe-only
mode):
- **Disturbance case**: hold at 30°C until settled, knock `plant.temp` down - **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. 0.5°C to emulate the cold-water event, keep ticking for 600 more ticks —
Assert peak overshoot above 30.0°C is measurably smaller with `Inner.Hold`'s confirms the state stays in `HOLD` throughout, matching the incident.
`yi_max` set than without. Asserts peak overshoot above 30.0°C is measurably smaller with
- **Ramp case**: command a genuine 1.5 K/min ramp (state reaches `HEAT`) and `Inner.Hold`'s `yi_max` set than with an unclamped copy of `Inner.Heat`.
assert the sustained heat rate actually reaches ~1.5 K/min — guards against - **Ramp case**: commands a genuine 1.5 K/min ramp to 40°C (state reaches
reintroducing the flat-clamp regression from the rejected approach above. `HEAT`) and asserts the sustained heat rate actually exceeds 1.4 K/min —
- **Transition case**: drive a `HOLD→HEAT→HOLD` sequence and assert `y` has guards against reintroducing the flat-clamp regression from the rejected
no discontinuity at either transition beyond what the changing `heatrate_err` approach above.
itself would explain — confirms the per-state param swap is truly bumpless. - **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 ## 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) 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 step in `y` rather than the fully bumpless transfer described above. Not
addressed here — flagged for awareness, not a blocker. 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.
+1 -1
View File
@@ -143,7 +143,7 @@ Two problems compound:
differentiating is the least effective arrangement: noise has already been differentiating is the least effective arrangement: noise has already been
amplified before the filter sees it. 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. error. With `kp=0.08`, ±1 K/min rate ripple produces ±8% power output noise.
### Options ### Options