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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2ierBoxW3v7nUbDw3M2pE
127 lines
7.5 KiB
Markdown
127 lines
7.5 KiB
Markdown
# components/pid design backlog
|
|
|
|
Findings from a design review, refreshed against the current state of
|
|
`master` after the Kalman-filter removal and Smith-predictor rewrite (see
|
|
git history for `temp_controller.py`/`temp_controller_smith.py`).
|
|
|
|
- [x] **FSM thresholds aren't configurable.** Moved into an optional
|
|
`TempCtrl.Thresholds` config section (`HoldIdle`, `HoldHeat`,
|
|
`IdleHeat`, `IdleHold`, `HeatHold`, `HeatIdle`), merged over
|
|
`DEFAULT_THRESHOLDS` (now in `temp_controller_base.py`, formerly
|
|
`tc_constants.py`) so existing configs without the section keep
|
|
working unchanged.
|
|
|
|
- [x] **matplotlib imported at module level in production code.**
|
|
`temp_controller.py`, `temp_controller_smith.py`, and `kalman.py`
|
|
used to `from matplotlib.pyplot import ...` just to support their
|
|
`__main__` self-test plots. The plotting demos (and `kalman_eval.py`)
|
|
moved to `scripts/demos/pid/demo_*.py`; production modules no longer
|
|
import matplotlib.
|
|
|
|
- [x] **Three Kalman filters share one tuning.** No longer applicable:
|
|
`temp_controller_smith.py` was rewritten to drop Kalman filtering
|
|
entirely (see below), so there's no shared tuning to split anymore.
|
|
|
|
- [x] **`Pid.scale()` is a gain-scheduling hack, not anti-windup.**
|
|
Fixed: removed `Pid.scale()`/`self.k` entirely. `Pid.process(err, d,
|
|
scale=1.0)` now takes the gain multiplier as a plain per-call
|
|
argument instead of mutable state, so there's nothing to go stale
|
|
across a `reset()`. `temp_controller*.py` compute `hold_scale =
|
|
1.0/heatrate_soll_set if heatrate_soll_set > 0 else 1.0` themselves
|
|
and pass it through `process_pid(theta_err, heatrate_err,
|
|
hold_scale)` — the overshoot-compensation scheduling logic now lives
|
|
entirely in the temp controller, not the generic `Pid` block.
|
|
|
|
- [ ] **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 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
|
|
`heater.set_on_changed("power_set", tc.set_model_power)` regardless
|
|
of `Controller.pid_type`. Only `temp_controller_smith.py` (the Smith
|
|
predictor) defines `set_model_power`; `temp_controller.py` (`"Normal"`)
|
|
does not, so starting the server with `"pid_type": "Normal"` crashes
|
|
at wiring time with `AttributeError: 'TempController' object has no
|
|
attribute 'set_model_power'`. Either add a no-op `set_model_power` to
|
|
`TempControllerBase`, or only wire it when the configured controller
|
|
actually exposes a model to feed.
|
|
|
|
- [ ] **Config access is unchecked `dict[key]` everywhere.**
|
|
`params['Hold']`, `params['Td']`, etc., throughout, with no schema
|
|
validation at load time. We already hit this bug class once
|
|
(`config.json.sim`'s `gain`/`Model` nesting mismatch). A small
|
|
schema/dataclass validation layer at config-load would surface
|
|
errors immediately instead of mid-`__init__` — and would have caught
|
|
the dead `TempCtrl.Kalman` / `Model.kn` / `Plant.kn` keys that used
|
|
to sit unused in `config-sim.json.tpl`/`.sim` (since deleted), and the
|
|
`Model.gain`/`Plant.gain` keys that did the same in `config.json.sim`
|
|
before that file was removed entirely — `Pot` dropped `gain`
|
|
entirely, see `components/plant/TODO.md`.
|
|
|
|
- [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
|
|
anti-windup (`Pid.process()`, `pid.py:45-48`) never triggered — the FSM
|
|
never even left `HOLD` (`diff` stayed under `HoldHeat=1.0`). The
|
|
resulting overshoot took ~35s+ to unwind naturally. A flat `yi_max`
|
|
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
|
|
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.
|
|
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
|
|
`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.
|
|
|
|
- [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
|
|
`scripts/demos/pid/demo_kalman.py` and `demo_kalman_eval.py`. Decide
|
|
whether to keep it as a documented standalone filtering example or
|
|
delete it along with the demos.
|