Smart Dynamic Preheat — calculates lead time based on warm-up rate rather than a fixed offset (pure YAML, no extra dependencies)

The Problem

Most preheat implementations fire a fixed number of minutes before the scheduled heating time, say 30 minutes early regardless of conditions. This works sometimes but is wasteful on mild days when the house barely needs heating, and insufficient on cold days when it needs longer. One of my biggest frustrations moving from Nest to a Hive thermostat was losing the smart preheat feature. Nest learns how long your home takes to heat up and starts early automatically so you wake up to a warm house. Hive has no equivalent (with no subscription at least). I wanted to recreate that behaviour in Home Assistant without relying on a learning algorithm, using a simple calibrated warm-up rate instead.

The Approach

This is a pure YAML implementation with no additional dependencies beyond standard HA helpers. If you want a self-learning approach that automatically refines its own timing from real heating cycles, there is a more advanced Pyscript-based option here. But if you want a transparent, easy to understand solution you can adapt to your own setup, read on.

This automation calculates the required lead time dynamically based on:

  • The difference between current temperature and the next scheduled target
  • A calibrated warm-up rate (degrees per minute your home heats up)
  • A small fixed margin to ensure you hit target on time

On a mild day when the house is already close to target it fires late or not at all. On a cold morning when there's a large temperature gap it fires early. It adapts automatically.

How It Works

The automation runs every 5 minutes during two windows, 05:00–08:00 ahead of the morning schedule, and 16:00–22:00 ahead of the evening schedule. It looks up the next scheduled heating time, calculates how many minutes are needed to reach the target at the current warm-up rate, adds a margin, and fires if the time to target is within that threshold.

A preheat_done boolean prevents it firing repeatedly once it has triggered. This is reset after each window closes.

What You Need

Create these helpers first:

  • input_datetime.morning_start_time — your morning heating start time
  • input_datetime.evening_start_time — your evening heating start time
  • input_number.morning_target_temperature — morning target in °C
  • input_number.evening_target_temperature — evening target in °C
  • input_number.heating_warm_up_rate — degrees per minute your home heats up (start with 0.1 and calibrate)
  • input_boolean.preheat_done — preheat lock flag
  • input_boolean.hive_boost_mode — boost mode flag (or adapt to your own)
  • input_boolean.holiday_mode — holiday mode flag (or adapt to your own)

Calibrating the Warm-Up Rate

Note the current temperature and target temperature, then time how long it takes to reach target after your boiler fires. Divide the temperature rise by the minutes taken. For example, if the house rises 2°C in 20 minutes, your rate is 0.1°C/min. Adjust input_number.heating_warm_up_rate accordingly. You may need to set different values for winter and summer shoulder seasons.

The Automation

yaml

alias: Heating - Smart Preheat
description: >
  Preheat before scheduled times using dynamic inputs. Calculates minutes
  needed based on temp difference divided by heating_warm_up_rate, plus a
  5-minute margin.
triggers:
  - minutes: /5
    trigger: time_pattern
conditions:
  - condition: state
    entity_id: input_boolean.holiday_mode
    state: "off"
  - condition: state
    entity_id: person.your_person
    state: home
  - condition: state
    entity_id: input_boolean.hive_boost_mode
    state: "off"
  - condition: state
    entity_id: input_boolean.preheat_done
    state: "off"
  - condition: or
    conditions:
      - condition: time
        after: 05:00:00
        before: 08:00:00
      - condition: time
        after: "16:00:00"
        before: "22:00:00"
actions:
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ (preheat_calc | from_json).should_preheat }}"
        sequence:
          - variables:
              target_temp: "{{ (preheat_calc | from_json).next_target }}"
              current_set_temp: >
                {{ state_attr('climate.your_thermostat','temperature') | float(0) }}
              hvac_mode: "{{ states('climate.your_thermostat') }}"
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ hvac_mode != 'heat' }}"
                sequence:
                  - action: climate.set_hvac_mode
                    target:
                      entity_id: climate.your_thermostat
                    data:
                      hvac_mode: heat
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ current_set_temp != target_temp }}"
                sequence:
                  - action: climate.set_temperature
                    target:
                      entity_id: climate.your_thermostat
                    data:
                      temperature: "{{ target_temp }}"
          - target:
              entity_id: input_datetime.preheat_start_time
            data:
              datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
            action: input_datetime.set_datetime
          - data:
              title: Smart Preheat Status
              message: >
                Smart Preheat fired! Current Temp: {{
                state_attr('climate.your_thermostat','current_temperature') }}°C
                Target Temp: {{ (preheat_calc | from_json).next_target }}°C
            action: persistent_notification.create
          - target:
              entity_id: input_boolean.preheat_done
            action: input_boolean.turn_on
variables:
  preheat_calc: >
    {% set now_dt = now() %}
    {% set current_temp = state_attr('climate.your_thermostat','current_temperature') | float(0) %}
    {% set warmup_rate = states('input_number.heating_warm_up_rate') | float(0.1) %}
    {% set preheat_margin = 5 %}
    {% set schedules = [
      {'name':'Morning', 'entity':'input_datetime.morning_start_time',
       'target': states('input_number.morning_target_temperature') | float(21.0)},
      {'name':'Evening', 'entity':'input_datetime.evening_start_time',
       'target': states('input_number.evening_target_temperature') | float(22.0)}
    ] %}
    {% set today_midnight = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) %}
    {% set ns = namespace(next_name='None', next_dt=now_dt + timedelta(days=365),
       next_target=states('input_number.morning_target_temperature') | float(21.0)) %}
    {% for s in schedules %}
      {% set sched_str = states(s.entity) %}
      {% if sched_str not in ['unknown','unavailable',''] %}
        {% set parts = sched_str.split(':') %}
        {% set h = parts[0] | int %}
        {% set m = parts[1] | int %}
        {% set sched_dt = today_midnight + timedelta(hours=h, minutes=m) %}
        {% if sched_dt <= now_dt %}
          {% set sched_dt = sched_dt + timedelta(days=1) %}
        {% endif %}
        {% if sched_dt < ns.next_dt %}
          {% set ns.next_name = s.name %}
          {% set ns.next_dt = sched_dt %}
          {% set ns.next_target = s.target %}
        {% endif %}
      {% endif %}
    {% endfor %}
    {% if ns.next_name == 'None' %}
      {{ {'next_name': None, 'next_dt': None, 'next_target': None,
          'minutes_needed': 0, 'minutes_to_target': 0, 'preheat_margin': preheat_margin,
          'trigger_threshold': 0, 'should_preheat': False} | to_json }}
    {% else %}
      {% set temp_diff = ns.next_target - current_temp %}
      {% if temp_diff > 0 %}
        {% set minutes_needed = (temp_diff / warmup_rate) | round(0, 'ceil') %}
      {% else %}
        {% set minutes_needed = 0 %}
      {% endif %}
      {% set minutes_to_target = ((ns.next_dt - now_dt).total_seconds() / 60) %}
      {% set trigger_threshold = minutes_needed + preheat_margin %}
      {% set should_preheat = (trigger_threshold >= minutes_to_target) %}
      {{ {'next_name': ns.next_name, 'next_dt': ns.next_dt.isoformat(),
          'next_target': ns.next_target, 'minutes_needed': minutes_needed,
          'minutes_to_target': minutes_to_target, 'preheat_margin': preheat_margin,
          'trigger_threshold': trigger_threshold, 'should_preheat': should_preheat} | to_json }}
    {% endif %}
mode: single

Reset Preheat Lock

You also need this automation to reset the lock after each window:

yaml

alias: Reset Preheat Lock
description: Resets preheat_done flag after morning and evening preheat windows close
triggers:
  - at: 09:00:00
    trigger: time
  - at: "22:15:00"
    trigger: time
actions:
  - target:
      entity_id: input_boolean.preheat_done
    action: input_boolean.turn_off
mode: single

Notes

  • Replace climate.your_thermostat and person.your_person with your own entities
  • The preheat windows (05:00–08:00 and 16:00–22:00) can be adjusted to suit your schedule
  • The 5-minute margin (preheat_margin) can be increased if you want a bigger safety buffer
  • If you have TRV radiator valves, trigger a separate automation to open them to room temperatures when input_boolean.preheat_done flips to on