Advanced On/Off Control for Heating with Self-Calibrating Predictor


This thread is aimed specifically at those who are familiar with control theory.

The idea is to reduce the inherent overshoot of classic on/off control to a level where it can still be used in cases where PID would normally be the go-to solution—but might feel like overkill in terms of complexity and tuning effort.

What I’m trying to achieve is a significantly more precise heating behavior while preserving the simplicity and robustness of on/off control.

The concept is based on making use of the overshoot portion of the temperature curve and the associated delay after switching off the heater.

The question I’d like to raise is:
Am I on a reasonable track here, or am I missing something fundamental?


Core Idea (briefly)

Instead of switching off at the setpoint, the system switches off earlier, based on an estimate of the expected thermal overshoot:

T_off = T_set − ΔT

Where ΔT is the temperature rise after switching off.

This ΔT is not fixed, but continuously updated using a simple adaptive rule:

ΔT_new = α · ΔT_measured + (1 − α) · ΔT_old

with typically:

α ≈ 0.1 … 0.3

So effectively:

  • measure how much the system overshoots
  • adjust the next cutoff point accordingly
  • repeat

This can be seen as a very lightweight form of dead-time compensation, without explicitly modeling the system.


Practical approach

To avoid issues far away from the operating point (e.g. cold start), a two-stage strategy seems necessary:

  • Phase 1 (bulk heating): classic on/off until near setpoint
  • Phase 2 (fine control): predictive cutoff using ΔT

ΔT is learned over time and should converge to a stable value if the system is reasonably repeatable.


Open point

The whole idea relies on the assumption that the thermal overshoot is sufficiently reproducible to be “learned” this way.

From a control-theory perspective, I’d be interested in:

  • whether this approach is known under a specific name beyond basic dead-time compensation
  • how sensitive it is to disturbances and non-linearities in practice
  • whether there are obvious stability or convergence issues I might be overlooking

Curious to hear your thoughts!

This idea has been around for a long time, and in fact is built in to the controller for my Trane heat pump home heating system, which was installed over 15 years ago. Simple and works fine!

The system also has a learning curve for night/day thermostat settings, in that it takes into account the morning heating lag and adjusts the startup time, so that the target temperature is reached by the time you set.

Interesting—honestly, that’s a bit surprising. I would have expected a PID-based solution in that kind of system.

Good to hear that this kind of predictive approach has been used successfully in practice for quite some time. The learning aspect for day/night transitions sounds especially interesting, since that’s essentially extending the same idea beyond steady-state into scheduling.

Do you know if the controller is purely based on this kind of predictive cutoff, or if there’s additional feedback logic layered on top?

I don't believe that the heat pump has proportional control of any sort, and it certainly would not tolerate rapid on/off cycles. The system also includes an emergency 15 kW electric heater and that is strictly on or off, which I have verified by measuring current consumption.

The only information I have on the Trane system consists of a couple of phrases in the controller operating manual, which warn the user that the system takes a few days to "learn" the characteristics of the home environment before settling down, plus the results of personal observation.

However, a quick web search for "bang bang controller with overshoot prediction" turns up several discussions of similar approaches, like this one, entitled "Simple On-Off Control for First Order and Overdamped Systems"

If I am understanding what you want, it's been around even in basic bimetallic thermostats since forever

A common heat thermostat anticipator is a small, adjustable resistor located inside mechanical (usually mercury-bulb) thermostats that acts as a miniature heater. Its primary purpose is to prevent the room from overheating by turning off the furnace slightly before the room reaches the desired temperature.

It accomplishes this by "tricking" the thermostat into thinking the room is warmer than it actually is, which compensates for the residual heat still in the ductwork after the furnace shuts down.

How it Works

  1. Activates with Heat: When the thermostat calls for heat (the bimetallic coil closes the circuit), electricity flows through the heat anticipator wire.
  2. Generates Internal Heat: As electricity passes through the resistor wire, it generates a small amount of heat inside the thermostat casing.
  3. Tricks the Sensor: This "false" heat warms the bimetallic coil inside the thermostat faster than the actual room temperature rises.
  4. Premature Shutdown: The thermostat believes the room has reached the set temperature (because its internal sensor is now hot), and it breaks the circuit, turning off the furnace burner, often a few minutes early.
  5. Utilizes Residual Heat: The furnace fan continues to run, distributing the remaining, "leftover" heat from the heat exchanger, which brings the room up to the actual set temperature without burning extra fuel.

More modern controls allow setting by degrees. I always used 1 degree (F as it is finer than C) cheaper models were often hard wired at 3.

I had forgotten about those "anticipator" controls. They probably date back 50 or more years.

Thanks for sharing that link—very interesting read.

As far as I understood it, the Rockwell approach goes in a very similar direction: it tries to compensate the dead time and resulting overshoot of bang-bang control by effectively “anticipating” the system response. They do it via a filter-based model inside the control loop, whereas my idea was more on the side of measuring the actual overshoot (ΔT) and adapting the cutoff point from cycle to cycle.

So conceptually it seems to target the same problem, just with a more model-based vs. measurement-based approach.

Would you say that’s a fair interpretation?

That's fair, but you have a system model, too. You just haven't written it out in its complete form.

Just for fun I asked the free version of Anthropic's Claude to code and simulate your idea (as I understand it). It took a few cycles of cajoling the bot to get it to produce code that seems to work, and the temperature does oscillate above the setpoint (at least with the smoothing filter constants chosen). Measurement noise is not to be underestimated!

See the result here: https://claude.ai/share/9560be0a-23f0-42c2-890d-49bfc9d8c13c

Note: to run C code like this on a Windows machine, I use the free Code::Blocks IDE.

Would it be possible for you to post the code here directly? It would make it much easier for everyone to understand what exactly was implemented and to discuss the behavior in more detail.

Claude C code:

#include <stdio.h>
#include <stdbool.h>
#include <time.h>

// --- Configuration ---
#define TARGET_TEMP        22.0f   // Desired room temperature (°C)
#define HYST_ON_OFFSET     (-0.5f) // Turn ON  when temp <= target + this  → 21.5°C
#define HYST_OFF_BASE      0.5f    // Base turn-OFF offset above target     → 22.5°C
#define HYST_OFF_MIN       0.05f   // Minimum turn-off offset (floor)
#define DECAY_RATE         0.02f   // How fast off-offset shrinks per second while ON
#define RECOVERY_RATE      0.005f  // How fast off-offset recovers per second while OFF
#define SAMPLE_INTERVAL_S  1.0f    // Control loop period (seconds)

// --- Controller State ---
typedef struct {
    bool   heater_on;
    float  hyst_off_offset;   // Dynamic upper hysteresis threshold offset
    time_t last_update;
} HeaterController;

// Initialize controller
void controller_init(HeaterController *ctrl) {
    ctrl->heater_on       = false;
    ctrl->hyst_off_offset = HYST_OFF_BASE;
    ctrl->last_update     = time(NULL);
}

// Update controller given current temperature; returns heater state
bool controller_update(HeaterController *ctrl, float current_temp) {
    time_t now     = time(NULL);
    float  dt      = (float)difftime(now, ctrl->last_update);
    ctrl->last_update = now;

    float turn_on_temp  = TARGET_TEMP + HYST_ON_OFFSET;   // e.g. 21.5°C
    float turn_off_temp = TARGET_TEMP + ctrl->hyst_off_offset; // dynamic

    if (ctrl->heater_on) {
        // --- Heater is ON ---
        // Decay the off-threshold toward the target to preempt overshoot
        ctrl->hyst_off_offset -= DECAY_RATE * dt;
        if (ctrl->hyst_off_offset < HYST_OFF_MIN)
            ctrl->hyst_off_offset = HYST_OFF_MIN;

        // Re-evaluate turn-off with updated threshold
        turn_off_temp = TARGET_TEMP + ctrl->hyst_off_offset;

        if (current_temp >= turn_off_temp) {
            ctrl->heater_on = false;
            printf("  → Heater OFF (%.2f°C >= turn-off %.2f°C)\n",
                   current_temp, turn_off_temp);
        }
    } else {
        // --- Heater is OFF ---
        // Let the offset recover slowly back toward base
        ctrl->hyst_off_offset += RECOVERY_RATE * dt;
        if (ctrl->hyst_off_offset > HYST_OFF_BASE)
            ctrl->hyst_off_offset = HYST_OFF_BASE;

        if (current_temp <= turn_on_temp) {
            ctrl->heater_on = true;
            printf("  → Heater ON  (%.2f°C <= turn-on  %.2f°C)\n",
                   current_temp, turn_on_temp);
        }
    }

    return ctrl->heater_on;
}

// Print current controller diagnostics
void controller_print_status(const HeaterController *ctrl, float temp) {
    printf("  Temp: %6.2f°C | Heater: %-3s | Off-threshold: %.2f°C | Offset: %.3f\n",
           temp,
           ctrl->heater_on ? "ON" : "OFF",
           TARGET_TEMP + ctrl->hyst_off_offset,
           ctrl->hyst_off_offset);
}

// --- Demo: simulated temperature trace ---
int main(void) {
    HeaterController ctrl;
    controller_init(&ctrl);

    // Simulated temperature readings (°C) — a realistic warm-up/cool-down trace
    float sim_temps[] = {
        20.0f, 20.2f, 20.5f, 20.9f, 21.3f,   // Heating up
        21.5f, 21.8f, 22.0f, 22.3f, 22.6f,   // Crosses thresholds
        22.4f, 22.1f, 21.9f, 21.6f, 21.4f,   // Cooling
        21.2f, 21.0f, 20.8f, 21.1f, 21.5f,   // Crosses turn-on again
        21.8f, 22.0f, 22.2f, 22.5f, 22.3f,   // Second cycle
        22.0f, 21.8f, 21.5f, 21.3f, 21.1f
    };
    int n = sizeof(sim_temps) / sizeof(sim_temps[0]);

    printf("=== Heater Controller Demo ===\n");
    printf("Target: %.1f°C  |  Turn-on: %.2f°C  |  Base turn-off: %.2f°C\n\n",
           TARGET_TEMP,
           TARGET_TEMP + HYST_ON_OFFSET,
           TARGET_TEMP + HYST_OFF_BASE);

    for (int i = 0; i < n; i++) {
        printf("Step %2d: ", i + 1);
        controller_update(&ctrl, sim_temps[i]);
        controller_print_status(&ctrl, sim_temps[i]);

        // In real code: sleep(SAMPLE_INTERVAL_S);
    }

    return 0;
}

Plotted behavior:

Microsoft Copilot produced a python program that works quite a bit better:

import matplotlib.pyplot as plt

# --- Adaptive controller (C-like logic) ---

class AdaptiveHeaterController:
    def __init__(self, setpoint=22.0, hysteresis=0.5, adapt_rate=0.2, overshoot_margin=0.1):
        self.setpoint = setpoint
        self.upper_limit = setpoint + hysteresis
        self.lower_limit = setpoint - hysteresis
        self.adapt_rate = adapt_rate
        self.overshoot_margin = overshoot_margin

        self.heater_on = False
        self.in_cycle = False
        self.peak_temp = None

    def update(self, T):
        # Track peak temperature during heating cycle
        if self.heater_on:
            if self.peak_temp is None or T > self.peak_temp:
                self.peak_temp = T

        # Hysteresis control
        if not self.heater_on and T < self.lower_limit:
            self.heater_on = True
            self.in_cycle = True
            self.peak_temp = T

        elif self.heater_on and T > self.upper_limit:
            self.heater_on = False
            self._end_cycle()

        return self.heater_on

    def _end_cycle(self):
        if not self.in_cycle:
            return

        # Check overshoot
        if self.peak_temp is not None and self.peak_temp > self.setpoint + self.overshoot_margin:
            overshoot = self.peak_temp - self.setpoint
            self.upper_limit -= self.adapt_rate * overshoot

            # Keep hysteresis sane
            min_hyst = 0.2
            if self.upper_limit < self.lower_limit + min_hyst:
                self.upper_limit = self.lower_limit + min_hyst

        self.in_cycle = False
        self.peak_temp = None


# --- Simulation parameters ---

ambient = 15.0
setpoint = 22.0
hysteresis = 0.5
adapt_rate = 0.2
overshoot_margin = 0.1

controller = AdaptiveHeaterController(setpoint, hysteresis, adapt_rate, overshoot_margin)

T = 18.0  # initial room temperature
dt = 1.0  # time step (arbitrary units)

times = []
temps = []
upper_limits = []
lower_limits = []
heater_states = []

time = 0.0
cycles_completed = 0
prev_heater_on = controller.heater_on

# --- Run until N OFF transitions (N heating cycles) ---
N = 20
while cycles_completed < N:
    heater_on = controller.update(T)

    # Thermal model:
    # T_next = T + 0.1*(heater_on) - 0.02*(T - ambient)
    T = T + 0.25*heater_on - 0.015 * (T - ambient)

    times.append(time)
    temps.append(T)
    upper_limits.append(controller.upper_limit)
    lower_limits.append(controller.lower_limit)
    heater_states.append(1 if heater_on else 0)

    # Detect OFF transition (end of a heating cycle)
    if prev_heater_on and not heater_on:
        cycles_completed += 1
    prev_heater_on = heater_on

    time += dt

# --- Plot ---

fig, ax1 = plt.subplots(figsize=(10, 5))

ax1.plot(times, temps, label="Temperature (°C)", color="tab:blue")
ax1.plot(times, upper_limits, "--", label="Upper limit (°C)", color="tab:red")
ax1.plot(times, lower_limits, "--", label="Lower limit (°C)", color="tab:green")
ax1.axhline(setpoint, color="gray", linestyle=":", label="Setpoint")

ax1.set_xlabel("Time (steps)")
ax1.set_ylabel("Temperature (°C)")
ax1.grid(True)

# Heater state as secondary axis (0/1)
ax2 = ax1.twinx()
ax2.step(times, heater_states, where="post", color="black", alpha=0.3, label="Heater ON")
ax2.set_ylabel("Heater state")

# Combine legends
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc="best")

plt.tight_layout()
plt.show()

Resulting plot, for this simple thermal model of the room:

T_next = T + 0.25*(heater_on) - 0.015*(T - ambient)

Thanks for sharing the code—very interesting approach.

It took me a bit to fully understand it, and as far as I can tell, this is not quite the same idea. Here the switch-off point is shifted over time (based on how long the heater is on), whereas my thought was to base it directly on the observed overshoot (ΔT) and adapt that from cycle to cycle.

So this version seems more like a time-based heuristic, while the approach I had in mind would be measurement-based and self-calibrating.

Still, it’s definitely targeting the same problem from a different angle.

In any case, thanks for the effort—I’ll try to turn my idea into code as well.

I don't think that is a valid or useful assessment. The room temperature varies with time, and your "measurement" method is also time-based. Differences between successive temperature measurements, deltaT estimation, etc. depend on the time between measurements.

The python program is much simpler and could easily be modified to accommodate your scheme, which is not very clearly stated.

In both posted examples, the algorithms are tested by simulating the room temperature variations over time using well defined models, so no actual measurements are required.

I've never seen an adjustable resistor in any of the UK On/Off controls I've looked at, but several have had fixed value resistors.
It's a simple but elegant improvement over the basic bimetallic strip ones.
Commonly called accelerators here.
The majority of home heating here is by gas fired central heating.
Much older systems used the boiler thermostat to control the room temperatures and by tweaking valves on the radiators.
When the Sundial (Honeywell) Y-Plan was introduced, it brought in the room thermostat and a hot water cylinder thermostat as well as the motorised valve and boiler programmer.
I'm looking forward to getting rid of "wet" heating and going all electric.
However, that won't be before the price of electricity comes down - currently five times the cost of equivalent gas energy - and houses are built to a much better standard, mainly in how well they are insulated and ventilated.
A big problem is the age of housing stock, poor quality new builds and energy infrastructure well past its sell by date.
I'd like to think that an Arduino or similar could take centre stage in controlling everything.

this seems to describe a thermostat heat anticipator, the disc in an older thermostat that provide additional heat in the thermostat to shut it off before actually reaching the room temperature to avoid exceeding it due to the furnace needing to run the fan after the furnace is turned off

if you know how much higher the temperature gets after turning off the furnace, couldn't you just add an offset to the temperature threshold when the furnace is on?

1 Like

I see your point, and first of all—thanks again for putting in the effort, I really appreciate it.

If my assessment wasn’t quite right, that’s on me. I’m approaching this more from an intuitive/practical angle, and I’m probably missing some of the deeper theoretical aspects. If I had a fully solid grasp of it, I wouldn’t have started this thread asking for feedback in the first place.

My intention wasn’t to dismiss your approach at all—just trying to understand the differences as I currently see them. Your simulation and code are definitely helpful in that regard.

I’ll take another look at it with your comments in mind.

Edit: I'm also working on a small example sketch to translate the idea into code.

apparently there is a recommended setting for the heat anticipator.

but a software approach could self-calibrate by evaluating the time it takes the temperature to start rising when the furnace is first turned on and then measure the rate of change, °/t to determine a temperature offset for shutting off the furance to account for overshoot

I made an attempt to translate my idea into a simple Arduino sketch—mainly to make the discussion a bit more concrete.

It’s not meant to be a finished or validated solution, just a minimal implementation of the core concept (predictive switch-off based on learned overshoot). There are certainly edge cases and limitations I may have overlooked.

If you spot conceptual issues or have suggestions for improvement, I’d really appreciate the feedback.

/*
  Advanced On/Off Heating Control with Self-Calibrating Predictor
  ---------------------------------------------------------------

  Core idea:
  - Learn thermal overshoot (deltaT) after switching OFF
  - Use it to switch OFF earlier next cycle:
        T_off = T_set - deltaT

  Features:
  - Two-stage control (bulk + predictive)
  - Adaptive deltaT (EMA filter)
  - Robust peak detection (no fixed timing assumption)
  - Protection against noise and invalid values

  This is NOT PID. It is a simple adaptive feedforward approach.

  ---------------------------------------------------------------
*/

const int   PIN_HEATER = 5;

// --- User parameters ---
const float T_set = 60.0;        // Target temperature
const float BULK_MARGIN = 5.0;   // Switch to predictive mode near target
const float HYST = 1.0;          // Re-enable hysteresis

// Adaptive learning
float deltaT = 2.0;              // Initial guess (must be reasonable!)
const float alpha = 0.2;         // Learning rate (0.1...0.3 recommended)

// Safety limits
const float DELTA_MIN = 0.1;
const float DELTA_MAX = 20.0;

// Overshoot detection
const float FALL_THRESHOLD = 0.05;     // Detect falling temperature
const unsigned long MAX_TRACK_TIME = 30000; // ms safety timeout

// --- State machine ---
enum State {
  HEATING_BULK,
  HEATING_PREDICTIVE,
  TRACK_OVERSHOOT
};

State state = HEATING_BULK;

// --- Runtime variables ---
float T_current = 0.0;

float T_switch_off = 0.0;
float T_peak = 0.0;

unsigned long t_switch_off = 0;

// ---------------------------------------------------------------
// Replace this with your real sensor!
float readTemperature() {
  return T_current;
}
// ---------------------------------------------------------------

void setup() {
  pinMode(PIN_HEATER, OUTPUT);
  digitalWrite(PIN_HEATER, LOW);

  Serial.begin(115200);
}

// ---------------------------------------------------------------

void loop() {
  T_current = readTemperature();

  switch (state) {

    // -----------------------------------------------------------
    case HEATING_BULK:
      // Full heating until close to setpoint
      digitalWrite(PIN_HEATER, HIGH);

      if (T_current >= (T_set - BULK_MARGIN)) {
        state = HEATING_PREDICTIVE;
      }
      break;

    // -----------------------------------------------------------
    case HEATING_PREDICTIVE: {
      float T_off = T_set - deltaT;

      digitalWrite(PIN_HEATER, HIGH);

      if (T_current >= T_off) {
        // Switch OFF early
        digitalWrite(PIN_HEATER, LOW);

        T_switch_off = T_current;
        T_peak = T_current;
        t_switch_off = millis();

        state = TRACK_OVERSHOOT;
      }
      break;
    }

    // -----------------------------------------------------------
    case TRACK_OVERSHOOT: {
      digitalWrite(PIN_HEATER, LOW);

      // Track peak temperature
      if (T_current > T_peak) {
        T_peak = T_current;
      }

      bool falling = (T_current < (T_peak - FALL_THRESHOLD));
      bool timeout = (millis() - t_switch_off > MAX_TRACK_TIME);

      if (falling || timeout) {
        float measuredDelta = T_peak - T_switch_off;

        // Plausibility check
        if (measuredDelta > DELTA_MIN && measuredDelta < DELTA_MAX) {
          deltaT = alpha * measuredDelta + (1 - alpha) * deltaT;
        }

        // Re-enable heating with hysteresis
        if (T_current < (T_set - HYST)) {
          state = HEATING_BULK;
        } else {
          state = HEATING_PREDICTIVE;
        }
      }
      break;
    }
  }

  // --- Debug output ---
  Serial.print("T=");
  Serial.print(T_current);
  Serial.print(" | deltaT=");
  Serial.print(deltaT);
  Serial.print(" | state=");
  Serial.println(state);

  delay(500);
}

Are you taking into account outside temp, wind, heat captured at start up in the ductwork? There is also a factor that is extremely difficult to capture. Humans for one reason or the other 'feel' comfortable at different temps. This can be as much as 2 or 3 F degrees.