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
205 lines
7.0 KiB
Markdown
205 lines
7.0 KiB
Markdown
# Temperature controller calibration
|
||
|
||
## Controller overview
|
||
|
||
`components/pid/temp_controller_smith.py` implements a Smith predictor. Each tick it computes:
|
||
|
||
```
|
||
theta_ist = theta_ist_model + (theta_ist_plant − theta_ist_model_delay)
|
||
```
|
||
|
||
`theta_ist_model` comes from a fast internal `Pot` (transport delay Td = 0);
|
||
`theta_ist_model_delay` from a second `Pot` with the full configured Td.
|
||
The correction term `(theta_ist_plant − theta_ist_model_delay)` removes the dead
|
||
time from the feedback path.
|
||
|
||
The plant model (`components/plant/pot.py`) integrates:
|
||
|
||
```
|
||
temp += (delayed_power_in − L·M·(temp − theta_amb)) / (M·C) · dt
|
||
```
|
||
|
||
Four parameters must be calibrated:
|
||
|
||
| Parameter | Unit | Meaning |
|
||
|---|---|---|
|
||
| C | J/(kg·K) | Specific thermal capacity |
|
||
| M | kg | Mass of pot + water + grain |
|
||
| L | W/(kg·K) | Heat loss coefficient |
|
||
| Td | s | Transport (heater-to-sensor) delay |
|
||
|
||
|
||
## 1. Verify model-plant match: replay simulation
|
||
|
||
Feed the **recorded `power_eff`** from a log file into a fresh `Pot` instance
|
||
with the same parameters and compare the simulated temperature to `temp_ist`.
|
||
|
||
```python
|
||
from components.plant.pot import Pot
|
||
|
||
pot = Pot(dt=1.0)
|
||
pot.set_plant_params({'C': C, 'M': M, 'L': L, 'Td': Td})
|
||
pot.set_ambient_temperature(theta_amb)
|
||
pot.initial(samples[0]['temp_ist'])
|
||
|
||
sim_temp = []
|
||
for s in samples:
|
||
pot.set_power(s['power_eff'])
|
||
pot.process()
|
||
sim_temp.append(pot.get_temperature())
|
||
```
|
||
|
||
Plot `sim_temp` (model) against `temp_ist` (real) and the residual
|
||
`temp_ist − sim_temp`. What divergence tells you:
|
||
|
||
| Symptom | Likely cause |
|
||
|---|---|
|
||
| Ramp slopes differ | C·M wrong |
|
||
| Phase shift between power step and temperature rise | Td wrong |
|
||
| Wrong equilibrium temperature during HOLD | L wrong |
|
||
| Residual grows over a long run | Model drift / L temperature-dependent |
|
||
|
||
|
||
## 2. Parameter identification from log data
|
||
|
||
### Td — transport delay
|
||
|
||
Cross-correlate `power_eff` with `temp_ist`. The lag at peak correlation is the
|
||
actual Td. Alternatively, find a sharp power step (start of a ramp) and measure
|
||
the visible delay before `temp_ist` begins rising.
|
||
|
||
### L — heat loss coefficient
|
||
|
||
At steady-state HOLD, `d(temp)/dt ≈ 0`, so all input power compensates losses:
|
||
|
||
```
|
||
P_hold = L · M · (T − theta_amb)
|
||
→ L = P_hold / (M · (T − theta_amb))
|
||
```
|
||
|
||
Repeat across hold phases at different temperatures and fit a line through
|
||
`(T − theta_amb)` vs `P_hold` for a more robust estimate.
|
||
|
||
### C — specific heat capacity
|
||
|
||
During a ramp where heat loss is small relative to input power:
|
||
|
||
```
|
||
C ≈ power_eff / (M · rate_ist_K_per_s)
|
||
```
|
||
|
||
`rate_ist` in the log is K/min; divide by 60 to get K/s. Use a mid-ramp window
|
||
where `power_eff` and `rate_ist` are both stable.
|
||
|
||
|
||
## 3. Controller performance metrics
|
||
|
||
These can be read directly from the log without re-simulation:
|
||
|
||
| Metric | How to compute |
|
||
|---|---|
|
||
| Overshoot | `max(temp_ist) − temp_soll` after each setpoint step |
|
||
| Settling time | First time `|temp_ist − temp_soll| < threshold` is sustained after a step |
|
||
| Steady-state error | Mean of `temp_soll − temp_ist` during HOLD phase |
|
||
| Rate tracking error | `rate_ist − rate_soll` during RAMP |
|
||
|
||
### Smith predictor correction signal
|
||
|
||
The term `theta_ist_plant − theta_ist_model_delay` is the Smith correction; a
|
||
growing correction signal indicates model drift. This is not currently written to
|
||
the log. To observe it, log `theta_ist_model` and `theta_ist_model_delay` from
|
||
`temp_controller_smith.py` alongside the existing fields.
|
||
|
||
|
||
## 4. Suggested addition to analyze_log.py
|
||
|
||
A replay panel added to `utils/analyze_log.py` would show:
|
||
- `temp_ist` (blue) vs `sim_temp` (red dashed) — model accuracy at a glance
|
||
- Residual `temp_ist − sim_temp` in a separate subplot — reveals systematic
|
||
parameter errors vs noise floor
|
||
|
||
|
||
## 5. Heat rate noise from stirring
|
||
|
||
Stirring creates low-frequency temperature fluctuations at the sensor. The heat
|
||
rate is computed in both controllers (`temp_controller_smith.py:71-74`,
|
||
identical in `temp_controller.py`) as:
|
||
|
||
```python
|
||
heatrate = (self.theta_ist - self.last_theta_ist) / self.dt * 60
|
||
alpha = 0.1
|
||
self.heatrate_ist = (1-alpha) * self.heatrate_ist + alpha * heatrate
|
||
```
|
||
|
||
Two problems compound:
|
||
|
||
1. **Differentiation amplifies noise.** A temperature fluctuation δT at
|
||
frequency f becomes a rate fluctuation of `2π·f·δT·60` K/min. Low-frequency
|
||
stirrer noise (e.g. 0.05 Hz, δT = 0.05 °C) → ≈ 0.9 K/min rate noise, the
|
||
same order as a typical `rate_soll`.
|
||
|
||
2. **The IIR filter comes after differentiation.** With α=0.1 and dt=1 s the
|
||
time constant is τ = 9 s (cutoff ≈ 0.018 Hz). Filtering *after*
|
||
differentiating is the least effective arrangement: noise has already been
|
||
amplified before the filter sees it.
|
||
|
||
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.
|
||
|
||
### Options
|
||
|
||
#### Option A — Pre-filter theta_ist before differentiating (best return for effort)
|
||
|
||
Apply a separate, heavier IIR to the raw temperature *before* the derivative:
|
||
|
||
```python
|
||
beta = 0.05 # smaller = more smoothing, more lag
|
||
self.theta_ist_filtered = (1-beta)*self.theta_ist_filtered + beta*self.theta_ist
|
||
heatrate = (self.theta_ist_filtered - self.last_theta_ist_filtered) / self.dt * 60
|
||
```
|
||
|
||
This is fundamentally better than post-filtering: the derivative of a smooth
|
||
signal stays smooth. `beta` can be tuned independently of `alpha`.
|
||
|
||
#### Option B — Sliding-window linear regression
|
||
|
||
Fit a line through the last N samples of `theta_ist` and use the slope:
|
||
|
||
```python
|
||
slope, _ = np.polyfit(t_window, theta_window, 1)
|
||
heatrate_ist = slope * 60 # K/s → K/min
|
||
```
|
||
|
||
Gives the minimum-variance rate estimate for additive white noise, with a clean
|
||
constant lag of N/2 seconds. N ≈ 20–30 s is a reasonable starting point.
|
||
|
||
#### Option C — Model-based rate (Smith predictor only)
|
||
|
||
`Pot.process()` already computes `p_pot = delayed_power_in − L·M·(temp − theta_amb)`
|
||
and integrates `d(temp)/dt = p_pot / (M·C)`. This can be used directly:
|
||
|
||
```python
|
||
rate_model = self.model.get_p_pot() / (self.model.M * self.model.C) * 60
|
||
```
|
||
|
||
(`get_p_pot()` is not yet public on `Pot` but trivially added.) The rate is
|
||
completely noise-free because it is derived from the heater power signal, not
|
||
from differentiating the sensor. Accuracy depends on model calibration — see
|
||
sections 1–2 above.
|
||
|
||
#### Option D — Reduce kp of the Heat/Cool PID
|
||
|
||
Halving `kp` from 0.08 to 0.04 halves the power noise at the cost of slower
|
||
rate convergence. Treats the symptom rather than the source.
|
||
|
||
### Recommendation
|
||
|
||
Start with **option A**: one extra IIR state, localised to the rate
|
||
computation, no other structural changes. Set `beta` so that
|
||
`τ = (1−beta)/beta · dt` is 2–3× the dominant stirrer-noise period (visible
|
||
as the ripple frequency in `rate_ist` in the log).
|
||
|
||
For the Smith predictor, **option C** is the most principled long-term
|
||
solution — it replaces a noisy numerical derivative with the model's own
|
||
analytical rate, which the Smith predictor is already tracking.
|