Solar Energy Management with Home Assistant: Pool Heating

We have a solar power system with a battery, and a pool heated by a heat pump. The obvious goal: use excess solar energy to heat the pool for free. The naive solution — turn the heat pump on when there is surplus, turn it off when there isn't — sounds right but creates a problem in practice. Heat pumps are not fans of short cycles. Starting and stopping repeatedly throughout the day causes excessive wear and reduces efficiency significantly.

What I really wanted was for the heat pump to run once a day, for as long as conditions allow, in the window where solar production is highest. This article describes how I built exactly that in Home Assistant, using template sensors and a small automation.

The Scenario

  • Solar panels feed into a SolarEdge inverter with a battery
  • A heat pump heats the pool and draws up to 2500 W at full load
  • Home Assistant has access to live power sensors and a solar forecast via Solcast
  • The pool filter pump runs on a schedule (schedule.pool_filter_pump_schedule) — the heat pump should only operate within this window

The key insight: the battery should act as an energy buffer, not a competitor. If PV is producing 4 kW and the house only needs 1 kW, those 3 kW are currently going into the battery. The heat pump can take 2.5 kW of that instead — and as long as the remaining solar forecast for the day is sufficient, the battery will still be fully charged by evening.

The Logic

The heat pump should start when two conditions are simultaneously true:

  1. Current surplus ≥ 2500 W — enough PV power above house consumption to run the heat pump right now, without drawing from the battery or grid.

  2. End-of-day energy balance is positive — the remaining PV forecast for today is enough to cover:

    • the projected house load for the rest of the schedule window,
    • the heat pump running for the entire remaining schedule window,
    • and whatever energy the battery still needs to reach 100% charge.

The heat pump should stop when:

  • The filter pump schedule ends, or
  • The projected energy balance turns negative — meaning the remaining PV forecast is no longer sufficient to cover house load, continued heat pump operation, and a full battery charge by end of day.

Once stopped, the heat pump does not restart until the next day, regardless of whether conditions improve. This enforces exactly one heating cycle per day.

Surplus Calculation

There is no direct "house consumption" sensor available. Instead, surplus is derived from the sensors that are available:

surplus = battery_charging − battery_discharging + grid_export − grid_import

This gives the net power currently flowing out of the house system — into the battery or onto the grid. That is exactly the power that could be redirected to the heat pump instead.

When the heat pump is already running, this value stays positive as long as PV covers everything. If it goes negative the heat pump is drawing from storage or the grid, which will also be reflected in the energy balance.

Energy Balance Calculation

All values are in Wh:

energy_balance =
    remaining_pv_forecast × 1000
  − battery_deficit
  − house_power × remaining_daylight_hours
  − heat_pump_power × remaining_schedule_hours

Where:

  • remaining_pv_forecast comes from sensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute (kWh, multiplied by 1000 to get Wh)
  • battery_deficit = battery capacity × (1 − state of charge) in Wh
  • house_power = current house consumption in W, estimated from the live power sensors
  • remaining_daylight_hours = hours until sunset (sun.sun next_setting) — house consumption continues after the schedule ends, competing for the same remaining PV energy
  • heat_pump_power = 2500 W if the heat pump is currently off, or 0 W if it is already running (its load is already included in house_power and would be double-counted otherwise)
  • remaining_schedule_hours = hours until the filter pump schedule turns off

A positive energy balance means: even if the heat pump runs for the full remaining schedule window, there will still be enough solar energy to cover house consumption until sunset and fill the battery completely by end of day.

Implementation

Everything lives in a single Home Assistant package file, which keeps the helper, sensors, and automations together and makes the setup self-contained.

Entities Used

Entity Purpose
sensor.power_solar_generation Live PV production (W)
sensor.power_grid_import / sensor.power_grid_export Grid power flow (W)
sensor.power_battery_charging / sensor.power_battery_discharging Battery power flow (W)
sensor.solaredge_battery1_size_max Battery capacity (Wh)
sensor.solaredge_battery1_state_of_charge Battery SoC (%)
sensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute Remaining PV forecast today (kWh)
sun.sun Sunset time (for remaining daylight)
schedule.pool_filter_pump_schedule Allowed operating window
climate.pool_heat_pump Heat pump control (mode heat / off)

Template Sensors

Five template sensors expose the intermediate values, making it easy to see what the automation is "thinking" in the Home Assistant UI:

sensor.pool_heating_surplus_power — current available surplus in watts:

state: >
  {% set bat_charge  = states('sensor.power_battery_charging')    | float %}
  {% set bat_disch   = states('sensor.power_battery_discharging') | float %}
  {% set grid_export = states('sensor.power_grid_export')         | float %}
  {% set grid_import = states('sensor.power_grid_import')         | float %}
  {{ (bat_charge - bat_disch + grid_export - grid_import) | round(0) | int }}

sensor.pool_heating_energy_balance — net Wh available if the heat pump runs for the rest of the schedule window:

state: >
  {% set remaining_pv      = states('sensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute') | float * 1000 %}
  {% set battery_deficit   = states('sensor.pool_heating_battery_deficit')          | float %}
  {% set remaining_sched_h = states('sensor.pool_heating_remaining_schedule_hours') | float %}
  {% set house_w           = states('sensor.pool_heating_house_power')              | float %}
  {% set heat_pump_w       = 0 if not is_state('climate.pool_heat_pump', 'off') else 2500 %}
  {% if is_state('sun.sun', 'below_horizon') %}
    {% set remaining_daylight_h = 0 %}
  {% else %}
    {% set next_setting = state_attr('sun.sun', 'next_setting') | as_datetime %}
    {% set remaining_daylight_h = [(next_setting - now()).total_seconds() / 3600, 0] | max %}
  {% endif %}
  {{ (remaining_pv - battery_deficit - house_w * remaining_daylight_h - heat_pump_w * remaining_sched_h) | round(0) | int }}

The other three sensors (pool_heating_house_power, pool_heating_battery_deficit, pool_heating_remaining_schedule_hours) feed into the energy balance calculation and are also useful for debugging.

Automations

Start — triggered every 5 minutes and immediately when the schedule activates:

condition:
  - condition: state
    entity_id: schedule.pool_filter_pump_schedule
    state: "on"
  - condition: state
    entity_id: input_boolean.pool_heat_pump_ran_today
    state: "off"
  - condition: state
    entity_id: climate.pool_heat_pump
    state: "off"
  - condition: numeric_state
    entity_id: sensor.pool_heating_surplus_power
    above: 2500
  - condition: numeric_state
    entity_id: sensor.pool_heating_energy_balance
    above: 0
action:
  - action: climate.set_hvac_mode
    target:
      entity_id: climate.pool_heat_pump
    data:
      hvac_mode: heat
  - action: input_boolean.turn_on
    target:
      entity_id: input_boolean.pool_heat_pump_ran_today

Stop — triggered on schedule end and every 5 minutes (to catch the energy balance turning negative):

condition:
  - condition: not
    conditions:
      - condition: state
        entity_id: climate.pool_heat_pump
        state: "off"
  - condition: or
    conditions:
      - condition: state
        entity_id: schedule.pool_filter_pump_schedule
        state: "off"
      - condition: numeric_state
        entity_id: sensor.pool_heating_energy_balance
        below: 0

Using the energy balance as the stop condition (rather than instantaneous surplus) means brief cloud shadows do not trigger a stop: a passing cloud does not change the Solcast day forecast, so the energy balance stays positive and the heat pump keeps running. A stop only fires when the overall day forecast genuinely deteriorates.

Daily reset — clears the input_boolean.pool_heat_pump_ran_today flag at midnight.

Setup

Add the package to configuration.yaml:

homeassistant:
  packages:
    pool_heating: !include packages/pool_heating.yaml

That's it — no manual helper creation required, as the input_boolean is declared inside the package file itself.