# 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_heat.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.
