Files
brewpi/docs/temp_control_calibration.md
T
jensandClaude Sonnet 5 11c3d92f25 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
2026-07-05 22:08:19 +02:00

205 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ≈ 2030 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 12 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
`τ = (1beta)/beta · dt` is 23× 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.