Water Heater PID Control
PID control keeps a domestic hot water tank near its setpoint while balancing heater power and losses.
Level: Intermediate
simulation.py
Tuning a PID heater for a hot water tank
A simple water heater uses a PID controller to maintain a comfortable delivery temperature. With aggressive gains the controller overshoots and oscillates; gentler tuning brings the tank smoothly to setpoint. Play with the presets to feel the PID balance between responsiveness and stability.
from tys import probe, progress
def simulate(cfg: dict):
"""Simulate a single-zone water heater with a PID controller."""
import math
import simpy
env = simpy.Environment()
Physical parameters
temperature = cfg["initial_temp"] # current water temperature (°C)
ambient_temp = cfg["ambient_temp"] # surrounding temperature (°C)
setpoint = cfg["setpoint"] # desired delivery temperature (°C)
thermal_mass = cfg["thermal_mass"] # thermal inertia (°C gain per unit energy)
heater_power = cfg["heater_power"] # °C per minute at full power
loss_coeff = cfg["loss_coeff"] # heat loss coefficient to ambient
Controller parameters
kp = cfg["kp"] # proportional gain
ki = cfg["ki"] # integral gain
kd = cfg["kd"] # derivative gain
integral_limit = cfg["integral_limit"] # clamp integral windup
dt = cfg.get("time_step", 1.0)
sim_minutes = cfg["sim_minutes"]
integral = 0.0
prev_error = setpoint - temperature
control_signal = 0.0
peak_temperature = temperature
done = env.event()
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
Core dynamics: update controller, apply heater input, apply thermal losses.
def dynamics():
nonlocal temperature, integral, prev_error, control_signal, peak_temperature
steps = max(1, math.ceil(sim_minutes / dt))
progress_interval = max(1, math.ceil(5 / dt))
for step in range(steps):
error = setpoint - temperature
integral += error * dt
integral = clamp(integral, -integral_limit, integral_limit)
derivative = (error - prev_error) / dt
raw_output = kp * error + ki * integral + kd * derivative
control_signal = clamp(raw_output, 0.0, 1.0)
heat_input = heater_power * control_signal
heat_loss = loss_coeff * (temperature - ambient_temp)
temperature += (heat_input - heat_loss) * dt / thermal_mass
peak_temperature = max(peak_temperature, temperature)
prev_error = error
if control_signal in (0.0, 1.0) and step % progress_interval == 0:
msg = "🔥 max power" if control_signal > 0 else "💤 heater idle"
progress(int(100 * step / steps), msg)
if temperature - setpoint > 5:
progress(int(100 * step / steps), "⚠️ Overshoot")
yield env.timeout(dt)
progress(100)
done.succeed({
"final_temp": temperature,
"peak_overshoot": max(0.0, peak_temperature - setpoint),
"steady_error": setpoint - temperature,
})
env.process(dynamics())
Recorder: track temperature, heater command, and error for plotting.
def recorder():
while True:
error = setpoint - temperature
probe("temperature", env.now, temperature)
probe("heater_output", env.now, control_signal)
probe("error", env.now, error)
probe("setpoint", env.now, setpoint)
yield env.timeout(dt / 2)
env.process(recorder())
env.run(until=done)
return done.value
def requirements():
return {
"external": ["simpy==4.1.1"],
}
Aggressive gains.yaml
initial_temp: 20.0
ambient_temp: 15.0
setpoint: 60.0
thermal_mass: 18.0
heater_power: 22.0
loss_coeff: 0.3
kp: 0.22
ki: 0.12
kd: 0.0
integral_limit: 45.0
sim_minutes: 240
time_step: 1.0
Charts (Aggressive gains)
Final Results (Aggressive gains)
| Metric | Value |
|---|---|
| final_temp | 60.00 |
| peak_overshoot | 5.03 |
| steady_error | 0.00 |
Gentle tuning.yaml
initial_temp: 20.0
ambient_temp: 15.0
setpoint: 60.0
thermal_mass: 18.0
heater_power: 22.0
loss_coeff: 0.3
kp: 0.14
ki: 0.04
kd: 0.08
integral_limit: 25.0
sim_minutes: 240
time_step: 1.0
Charts (Gentle tuning)
Final Results (Gentle tuning)
| Metric | Value |
|---|---|
| final_temp | 60.00 |
| peak_overshoot | 1.24 |
| steady_error | 0.00 |
FAQ
- What makes the default run oscillate?
- High integral gain piles up error while the heater saturates, so the controller overshoots before it can unwind.
- How is the heater output converted into temperature change?
- The command scales the heater's maximum °C per minute; losses proportional to the gap from ambient compete with that input through the tank's thermal mass.
- Which probe shows when the controller is saturating?
- heater_output sits at 0 or 1 when the PID is clamped, signalling integral windup or cooldown.