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:
Current surplus ≥ 2500 W — enough PV power above house consumption to run the heat pump right now, without drawing from the battery or grid.
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_forecastcomes fromsensor.solcast_pv_forecast_prognose_verbleibende_leistung_heute(kWh, multiplied by 1000 to get Wh)battery_deficit= battery capacity × (1 − state of charge) in Whhouse_power= current house consumption in W, estimated from the live power sensorsremaining_daylight_hours= hours until sunset (sun.sunnext_setting) — house consumption continues after the schedule ends, competing for the same remaining PV energyheat_pump_power= 2500 W if the heat pump is currently off, or 0 W if it is already running (its load is already included inhouse_powerand 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.