Thermostat Simulation
A bang-bang thermostat reading a delayed sensor overshoots and rings; shorten the delay and it settles.
Level: Beginner
Delays
Learn about delays in systems, how they create oscillations, and their impact on system behavior and decision-making.
Take the QuizFeedback Loops
Understand the balancing and reinforcing feedback loops that drive system behavior and create complex dynamics in systems thinking.
Take the QuizA thermostat that overshoots
A bang-bang heater switches fully on or off to hold a room at the setpoint, but it reads a sensor that lags the true temperature by a few ticks. By the time the delayed reading says "warm enough" the room has already sailed past the setpoint, so it overshoots; the heater cuts out, the room coasts back down below the setpoint before the sensor notices, and the cycle repeats — a self-sustaining oscillation born from delay, not from any reinforcing loop.
The default (sensor_delay: 6) rings; the damped contrast shortens
the delay to 1 so the controller reacts in time and holds a tight band.
One knob — the sensing delay — turns a steady room into an oscillating one.
from tys import probe, progress
Simulate a bang-bang thermostat reading a delayed temperature sensor.
def simulate(cfg: dict):
import simpy
env = simpy.Environment()
Parameters
indoor_temp = cfg["initial_temp"] # true indoor temp (°C)
outside_temp = cfg["outside_temp"] # fixed outside temp
setpoint = cfg["setpoint"] # desired indoor temp
band = cfg["comfort_band"] # ±°C dead-band around setpoint
loss_rate = cfg["loss_coeff"] # heat-loss constant
heater_kw = cfg["heater_power"] # °C per tick while the heater is on
sensor_delay = cfg["sensor_delay"] # perception delay (ticks)
sim_time = cfg["sim_time"]
sensor_history = [indoor_temp] * sensor_delay # delay line for sensed temp
heater_on = False
peak_temp = indoor_temp
done = env.event()
Recorder Log the true temperature, the heater state, and the sensed error the controller is acting on — the lag between them is the whole story.
def recorder():
while True:
probe("indoor_temp", env.now, indoor_temp)
probe("error", env.now, sensor_history[0] - setpoint)
probe("heater_on", env.now, 1 if heater_on else 0)
yield env.timeout(0.5)
env.process(recorder())
Dynamics Bang-bang control with hysteresis: full on once the sensed temperature falls a band below the setpoint, full off once it rises a band above, holding state in between. The heater only ever sees the delayed reading.
def dynamics():
nonlocal indoor_temp, heater_on, peak_temp
for t in range(sim_time):
sensed = sensor_history[0]
if sensed < setpoint - band:
heater_on = True
elif sensed > setpoint + band:
heater_on = False
heat_gain = heater_kw if heater_on else 0.0
heat_loss = loss_rate * (indoor_temp - outside_temp)
indoor_temp += heat_gain - heat_loss
peak_temp = max(peak_temp, indoor_temp)
sensor_history.append(indoor_temp)
sensor_history.pop(0)
progress(int(100 * (t + 1) / sim_time))
yield env.timeout(1)
progress(100)
done.succeed({
"final_temp": indoor_temp,How far the true temperature blew past the setpoint at its worst.
"overshoot": peak_temp - setpoint,
})
env.process(dynamics())
env.run(until=done)
return done.value
def requirements():
return {
"external": ["simpy==4.1.1"],
}
# ringing.yaml — the default, past the damping edge.
# A strong heater (1.8 °C/tick) plus a long sensing delay (6 ticks) means the
# room sails well past the setpoint before the sensor catches up: it rings.
initial_temp: 18
outside_temp: 5
setpoint: 21
comfort_band: 0.5
loss_coeff: 0.05
heater_power: 1.8
sensor_delay: 6
sim_time: 240
| Metric | Value |
|---|---|
| final_temp | 17.15 |
| overshoot | 5.50 |
# damped.yaml — the contrast: only sensor_delay changes (6 -> 1).
# The controller now reacts almost immediately, cutting the heater near the
# setpoint, so the room holds a tight band instead of swinging.
initial_temp: 18
outside_temp: 5
setpoint: 21
comfort_band: 0.5
loss_coeff: 0.05
heater_power: 1.8
sensor_delay: 1
sim_time: 240
| Metric | Value |
|---|---|
| final_temp | 20.55 |
| overshoot | 1.27 |
- What sets the tipping point between settling and ringing?
- It is the heat dumped during the blind window: each tick the heater adds about (heater_power − loss) degrees, and the controller stays blind for sensor_delay ticks after the room crosses the setpoint. When that product overshoots the comfort band, the room sails past the setpoint before the heater cuts out, and the same lag on the way down makes it undershoot — a self-sustaining limit cycle. The default (delay 6) is past that edge; the damped contrast (delay 1) is below it.
- Why does it oscillate instead of just settling at the setpoint?
- Pure delay. The controller acts on a temperature reading from several ticks ago, so it keeps heating after the room is already warm enough and keeps idling after it is already too cold. A fast decision on a slowly-moving state, separated by a delay, is the classic recipe for overshoot and oscillation — no reinforcing loop required.
- What stabilizes it?
- Shrink the lag or soften the action: shorten sensor_delay (the damped config), lower heater_power so each tick injects less heat, or widen the comfort_band so the controller tolerates more drift before reacting. Any of these keeps the blind-window heat below the band.
- How is this different from the water-heater PID example?
- This thermostat is bang-bang: the heater is fully on or fully off, and the oscillation comes from a sensing delay plus a dead-band. The water-heater PID example oscillates for a different reason — integral windup, where accumulated error keeps pushing the actuator after the target is reached. Same symptom (ringing), different mechanism.
- What is the real-world analog?
- Any balancing controller acting through a delay: a home thermostat with a slow wall sensor, a shower where the hot water lags your tap adjustments, or cruise control on a hilly road. The fix is always the same family — sense faster, act gentler, or tolerate a wider band.