Water Heater PID Control

PID control keeps a domestic hot water tank near its setpoint while balancing heater power and losses.

Level: Intermediate

pid-controlthermostatenergybalancing-loophouseholdcontrol-systemstemperature

  • Stocks: water_temperature
  • Flows: heater_input, heat_loss
  • Feedback Loops: PID control (balancing)
  • Probes: temperature, heater_output, error, setpoint
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)

temperature

Samples481 @ 0.00–240.00
Valuesmin 21.14, mean 56.52, median 60.00, max 65.03, σ 8.82

heater_output

Samples481 @ 0.00–240.00
Valuesmin 0.00, mean 0.70, median 0.61, max 1.00, σ 0.22

error

Samples481 @ 0.00–240.00
Valuesmin -5.03, mean 3.48, median 0.00, max 38.86, σ 8.82

setpoint

Samples481 @ 0.00–240.00
Valuesmin 60.00, mean 60.00, median 60.00, max 60.00, σ 0.00
Final Results (Aggressive gains)
MetricValue
final_temp60.00
peak_overshoot5.03
steady_error0.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)

temperature

Samples481 @ 0.00–240.00
Valuesmin 21.14, mean 56.40, median 60.00, max 61.24, σ 8.73

heater_output

Samples481 @ 0.00–240.00
Valuesmin 0.49, mean 0.70, median 0.61, max 1.00, σ 0.16

error

Samples481 @ 0.00–240.00
Valuesmin -1.24, mean 3.60, median 0.00, max 38.86, σ 8.73

setpoint

Samples481 @ 0.00–240.00
Valuesmin 60.00, mean 60.00, median 60.00, max 60.00, σ 0.00
Final Results (Gentle tuning)
MetricValue
final_temp60.00
peak_overshoot1.24
steady_error0.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.