Thermostat Simulation

A bang-bang thermostat reading a delayed sensor overshoots and rings; shorten the delay and it settles.

Level: Beginner

controltime-delaybalancing-loop

  • Stocks: indoor_temp
  • Flows: heat_gain, heat_loss
  • Feedback Loops: controller chasing set-point (balancing)
  • Probes: indoor_temp, error, heater_on

Delays

Learn about delays in systems, how they create oscillations, and their impact on system behavior and decision-making.

Take the Quiz

Feedback Loops

Understand the balancing and reinforcing feedback loops that drive system behavior and create complex dynamics in systems thinking.

Take the Quiz
simulation.py

A 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"],
    }
Overshoot (delay 6).yaml
# 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
Charts (Overshoot (delay 6))

indoor_temp

Samples481 @ 0.00–240.00
Valuesmin 16.54, mean 21.41, median 21.52, max 26.50, σ 2.88

error

Samples481 @ 0.00–240.00
Valuesmin -4.46, mean 0.41, median 0.52, max 5.50, σ 2.88

heater_on

Samples481 @ 0.00–240.00
Valuesmin 0.00, mean 0.45, median 0.00, max 1.00, σ 0.50
Final Results (Overshoot (delay 6))
MetricValue
final_temp17.15
overshoot5.50
Damped (delay 1).yaml
# 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
Charts (Damped (delay 1))

indoor_temp

Samples481 @ 0.00–240.00
Valuesmin 18.00, mean 21.00, median 21.00, max 22.27, σ 0.74

error

Samples481 @ 0.00–240.00
Valuesmin -3.00, mean -0.00, median -0.00, max 1.27, σ 0.74

heater_on

Samples481 @ 0.00–240.00
Valuesmin 0.00, mean 0.45, median 0.00, max 1.00, σ 0.50
Final Results (Damped (delay 1))
MetricValue
final_temp20.55
overshoot1.27
FAQ
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.