This article describes various automations I’ve implemented for a hot water tank immersion heating. Rather than focusing on control and monitoring (which this setup definitely helps with), the intent is to let the system run by itself, with minimal interventions. What’s covered:
- Automatic on/off to consume excess solar energy from my solar panels
- Scheduled night-time heating to levarage off-peak tariffs (e.g. Octopus Go)
- Predictive target setting based on solar forecast and outside temparate (for night-time heating)
- Evening top-up in case of low solar output
Why all this? Having had solar panels and battery installed in late 2022 (thank you FDG Group!), I found that on sunny days we had lots of surplus power that even the battery and any house load couldn’t suck up. And because E.On Energy comptelety messed up our initial solar install, we didn’t have our export sorted for months. This effectively meant we use it or loose it.
First, my plan was to purchase a solar immersion diverter (e.g. iBoost/Eddi), but due to high purchase cost I hesitated. After extensive research I decided to buy Sonoff POWR3 (£26), a smart switch with energy monitoring rated up to 25A, which is well above the 3kW immersion heater we have on our hot water cylinder. Thanks to our local electrician we had that installed in no time and it was ready for action!
After the initial few days of testing the on/off solar automation, the immersion heater started leaking. With our preferred plumber not being available for the following 3 weeks, I went to buy an immersion heater spanner (£9) to try to tighten the immersion heater, hoping this would fix it. With about a quarter of a turn it finally seemed tight enough and… no more leaks!
With the leakage fixed and already being on an off-peak tariff (Octopus Go), I realised that in addition to just using it for solar excess, we could also replace the need for the gas boiler heating our hot water tank, with only using the immersion heater. The result (for our 210L tank and family of four):
- hot water with zero gas burned at home
- and after a whole month of February being off gas, the cost are looking over 50% cheaper!
- For last July we used 368kWh of gas for just hot water, so approx. £37
- Assuming in cold winter months it takes 20% more gas to heat colder water, that would be over £50 for mid-winter months
- The result for February on just electricity (121kWh off-peak, 70kWh solar, 8kWh peak) came at £17 for the whole month with using the new setup (automations and solar+battery)
- In total, the saving is somewhere between £20 and £35 for just February, so pretty amazing so far!
Clearly some of the lower cost can be attributed to using surpus solar (and some to bugs in automation scripts not heating enough ), but the night-time heating is so far twice the kWhs compared to day-time solar. With off-peak electricity being almost the same price per kWh as gas, this solution should be very cost effective, and more importantly, better for the planet! What’s more, you don’t need all the automations described here, although I do think they should give you a more complete setup where you can let go of gas for ever, even if you have no solar or battery!
Here is the screenshot of the dashboard:
This is night-time heating with evening top-up (no excess solar):
And limiting night-time heating when solar forecast is looking good (no evening top-up necessary):
Configuration files
Below are code snippets you can adopt to your needs - for all my setup please see my github repo.
automations.yaml
Setting night immersion target
Probably most complex automation, taking into account solar forecast and weather conditions. Double trigger time to ensure this runs every night.
- id: '12233445566789'
alias: Sinks - Hot Water - Set immersion target level
description: Set target immersion target heating level based on anticipated solar production
trigger:
- platform: time
at: "23:45:00"
- platform: time
at: "23:55:00"
condition: []
action:
- service: input_number.set_value
data_template:
entity_id: input_number.night_immersion_target
# Assumptions: 2.5kWh/h for 4hrs (10min pause), can use 2.5kWh for every 5kWh generated above 15kWh (to cater for weather fluctuation)
# total capacity of the 210L tank is about 15kWh (from 5 to 65C)
# at 5C outside, on average it could take 7.5kWh per night to heat to max,
# also daily average (night+day) was 7.5kWh so target shouldn't be more than 8kWh, minimum to up of 3kWh
# end of night tariff 04:30am
# Note: instead of using separate tomorrow/today's forecasts, use a single state (energy_production_upcoming) and then its average over a few hours (filtered)
# Note: simple weather compensation adding kWh to target (+2 for below -3C, +1 for below 2C, -1 for above 12C, -2 for above 17C)
# Note: if todays solar top up was low, ignore upcoming forecast and heat to max
value: >
{% set solar = states('sensor.filtered_upcoming_solar_forecast') %}
{% set todaysSolarTopUp = states('sensor.daily_hot_water_peak')|float %}
{% set currentTemperature = state_attr('weather.forecast_home', 'temperature') %}
{% set min_immersion = states('input_number.night_min_immersion_target')|float %}
{% set weatherAdjustment = 0 %}
{% if is_number(currentTemperature) %}
{% set weatherAdjustment = 2 if (currentTemperature|float <= -3) else weatherAdjustment %}
{% set weatherAdjustment = 1 if (currentTemperature|float > -3 and currentTemperature|float <= 2) else weatherAdjustment %}
{% set weatherAdjustment = 0 if (currentTemperature|float > 2 and currentTemperature|float <= 12) else weatherAdjustment %}
{% set weatherAdjustment = -1 if (currentTemperature|float > 12 and currentTemperature|float <= 17) else weatherAdjustment %}
{% set weatherAdjustment = -2 if (currentTemperature|float > 17) else weatherAdjustment %}
{% endif %}
{% set solarThreshold = 15 %}
{% set target = 8 + weatherAdjustment %}
{% if is_number(solar) %}
{% set target = target - ((solar|float - solarThreshold)*2.5/5)|round(1) if (solar|float > solarThreshold and todaysSolarTopUp > 2.5) else target %}
{% endif %}
{{ target if (target > min_immersion) else min_immersion }}
- delay:
hours: 0
minutes: 0
seconds: 5
milliseconds: 0
- service: input_number.set_value
data_template:
entity_id: input_number.max_temp_idle_seconds
# Allow high idle time when target is high (super hot water), low in case high solar forecast (not so hot water)
value: >
{% set immersion = states('input_number.night_immersion_target') %}
{% set target = 180 %}
{% if is_number(immersion) %}
{% set target = 30 if (immersion|float <= 2) else target %}
{% set target = 60 if (immersion|float > 2 and immersion|float <= 3) else target %}
{% set target = 90 if (immersion|float > 3 and immersion|float <= 4) else target %}
{% set target = 120 if (immersion|float > 4 and immersion|float <= 5) else target %}
{% set target = 150 if (immersion|float > 5 and immersion|float <= 6) else target %}
{% set target = 180 if (immersion|float > 6 and immersion|float <= 7) else target %}
{% set target = 240 if (immersion|float > 7 and immersion|float < 8) else target %}
{% set target = 300 if (immersion|float >= 8) else target %}
{% endif %}
{{ target }}
mode: single
Use excess solar energy
This automation uses a binary sensor calculated separately, and then wait for turning on/off.
- id: '1105783920393'
alias: Sinks - Hot Water - Start immersion heater (excess solar)
description: ''
trigger:
- platform: state
entity_id: binary_sensor.request_3kw_immersion
for:
hours: 0
minutes: 2
seconds: 0
to: "on"
action:
- repeat:
sequence:
- type: turn_on
device_id: 0e56664e1317e879ea8852efe217d98b
entity_id: switch.sonoff_100168acb6
domain: switch
- delay:
hours: 0
minutes: 0
seconds: 15
until:
- condition: template
# Try up to 3 times if the updated setting doen't reflect the target
value_template: >-
{{ states('switch.sonoff_100168acb6') == 'on' or repeat.index == 3 }}
mode: single
Evening top-up
This uses an input number that can be adjusted through the dashboard.
- id: '11001293920394'
alias: Sinks - Hot Water - Run immersion heater (evening top-up in case of low solar)
description: ''
trigger:
- platform: time
at: "19:35:00"
condition:
- condition: template
# Only turn on immersion if usage so far is less than the target
value_template: >-
{{ states('sensor.daily_hot_water_peak')|float < states('input_number.evening_immersion_target')|float }}
action:
- repeat:
sequence:
- type: turn_on
device_id: 0e56664e1317e879ea8852efe217d98b
entity_id: switch.sonoff_100168acb6
domain: switch
- delay:
hours: 0
minutes: 0
seconds: 15
until:
- condition: template
# Try up to 3 times if the updated setting doen't reflect the target
value_template: >-
{{ states('switch.sonoff_100168acb6') == 'on' or repeat.index == 3 }}
- repeat:
sequence:
- delay:
hours: 0
minutes: 1
seconds: 0
until:
- condition: template
# Keep it in on state until at least 1kWh
value_template: >-
{{ states('sensor.daily_hot_water_peak')|float >= states('input_number.evening_immersion_target')|float }}
- repeat:
sequence:
- type: turn_off
device_id: 0e56664e1317e879ea8852efe217d98b
entity_id: switch.sonoff_100168acb6
domain: switch
- delay:
hours: 0
minutes: 0
seconds: 15
milliseconds: 0
until:
- condition: template
# Try up to 3 times if the updated setting doen't reflect the target
value_template: >-
{{ states('switch.sonoff_100168acb6') == 'off' or repeat.index == 3 }}
mode: single
Night-time (off-peak) heating cycles
Rather than running a continuous X-hour heating, my intention was to let the immersion heater cool down for 10min (25min past finish, 35min past next start) to try to minimise any potential issues - might be pointless though
- id: '1105783920394'
alias: Sinks - Hot Water - Start immersion heater (night start)
description: ''
trigger:
- platform: time
at: "00:35:00"
- platform: time
at: "01:35:00"
- platform: time
at: "02:35:00"
- platform: time
at: "03:35:00"
condition:
- condition: template
# Only turn on immersion if usage so far is less than the target
value_template: >-
{{ states('sensor.daily_hot_water_offpeak')|float < states('input_number.night_immersion_target')|float }}
action:
- repeat:
sequence:
- type: turn_on
device_id: 0e56664e1317e879ea8852efe217d98b
entity_id: switch.sonoff_100168acb6
domain: switch
- delay:
hours: 0
minutes: 0
seconds: 15
until:
- condition: template
# Try up to 3 times if the updated setting doen't reflect the target
value_template: >-
{{ states('switch.sonoff_100168acb6') == 'on' or repeat.index == 3 }}
mode: single
When to stop...
Here are various triggers to stop heating.
- id: '1029486719232'
alias: Sinks - Hot Water - Stop immersion heater (max temp idle time or night target reached)
description: Turn off completely if thermostat starts turning off the heating (hot enough) or target achieved
trigger:
- platform: numeric_state
entity_id: sensor.sonoff_100168acb6_power
for:
seconds: "{{ states('input_number.max_temp_idle_seconds')|int }}"
below: 1000
- platform: template
value_template: >-
{{ states('sensor.daily_hot_water_offpeak')|float >= states('input_number.night_immersion_target')|float }}
condition:
- condition: time
after: "00:30:00"
before: "04:30:00"
action:
- repeat:
sequence:
- type: turn_off
device_id: 0e56664e1317e879ea8852efe217d98b
entity_id: switch.sonoff_100168acb6
domain: switch
- delay:
hours: 0
minutes: 0
seconds: 15
milliseconds: 0
until:
- condition: template
# Try up to 3 times if the updated setting doen't reflect the target
value_template: >-
{{ states('switch.sonoff_100168acb6') == 'off' or repeat.index == 3 }}
mode: single
- id: '1283957837432'
alias: Sinks - Hot Water - Stop immersion heater (low solar or night end)
description: ''
trigger:
- platform: state
entity_id: binary_sensor.request_3kw_immersion
for:
hours: 0
minutes: 0
seconds: 30
to: "off"
- platform: time
at: "01:25:00"
- platform: time
at: "02:25:00"
- platform: time
at: "03:25:00"
- platform: time
at: "04:25:00"
action:
- repeat:
sequence:
- type: turn_off
device_id: 0e56664e1317e879ea8852efe217d98b
entity_id: switch.sonoff_100168acb6
domain: switch
- delay:
hours: 0
minutes: 0
seconds: 15
until:
- condition: template
# Try up to 3 times if the updated setting doen't reflect the target
value_template: >-
{{ states('switch.sonoff_100168acb6') == 'off' or repeat.index == 3 }}
mode: single
configuration.yaml
Below are the relevant snippets require for the immersion automations. For complete configuration please see my git repo.
Calculations of the binary sensor used for excess solar energy are heavily reliant on my solar & battery setup described in Solax X1 Hybrid G4 (local & cloud API). The main idea is to let the battery charge first, unless there is so much power from solar that it makes sense to put it on earlier. The requirements for high solar output gradually decreases with battery getting fuller. There is also mention of a heater
switch which is another sink (2kW electric heater), but with a lower priority than the immersion heater.
input_number:
evening_immersion_target:
name: evening_immersion_target
unit_of_measurement: 'Wh'
initial: 2.0
min: 0.0
max: 5.0
night_min_immersion_target:
name: night_min_immersion_target
unit_of_measurement: 'kWh'
initial: 3
min: 0
max: 8
night_immersion_target:
name: night_immersion_target
unit_of_measurement: 'kWh'
initial: 9
min: 0
max: 12
max_temp_idle_seconds:
name: max_temp_idle_seconds
initial: 180
min: 15
max: 300
automation: !include automations.yaml
template:
- sensor:
# Logic divided into base conditions (bc) that apply regardless, and battery/solar specific
- name: "Request 3kW Immersion"
state: >
{% set heaterOn = is_state('switch.heater', 'on') %}
{% set alreadyOn = is_state('switch.sonoff_100168acb6', 'on') %}
{% set heatingPower = states('sensor.sonoff_100168acb6_power')|float(default=0) %}
{% set batteryCharging = states('sensor.solax_local_battery_power_adjusted')|int %}
{% set pv = states('sensor.solax_local_pv_output')|int %}
{% set feedIn = states('sensor.solax_local_feedin_power')|int %}
{% set load = states('sensor.solax_local_load_power')|int %}
{% set batteryLevel = states('sensor.solax_local_battery_soc')|int %}
{% set sparePower = feedIn + batteryCharging %}
{% set bc = (feedIn > -100) and (
((alreadyOn==false or heatingPower < 1000) and load < 2500)
or ((alreadyOn==false or heatingPower < 1000) and load < 4500 and heaterOn)
or (alreadyOn==true and heatingPower > 1000 and load < 5500)) %}
{{ bc and ((pv > 3400 and alreadyOn==false and sparePower > 3200)
or (pv > 3400 and alreadyOn==true and sparePower > 100)
or (pv > 2500 and alreadyOn==false and batteryLevel > 70)
or (pv > 2500 and alreadyOn==true and batteryLevel >= 70)
or (pv > 1500 and alreadyOn==false and batteryLevel > 80)
or (pv > 1500 and alreadyOn==true and batteryLevel >= 80)
or (pv > 1000 and alreadyOn==false and batteryLevel > 85)
or (pv > 1000 and alreadyOn==true and batteryLevel >= 85)
or (pv > 400 and alreadyOn==false and batteryLevel > 90)
or (pv > 400 and alreadyOn==true and batteryLevel >= 90)) }}
sensor powercalc_label: !include powercalc.yaml
utility_meter:
# Need to use powercalc for more granular monitoring as the total gets aggregated too slowly by Sonoff
daily_hot_water:
#source: sensor.sonoff_100168acb6_energy
source: sensor.immersion_heater_aggregated_energy
name: Daily Hot Water
cycle: daily
tariffs:
- peak
- offpeak
monthly_hot_water:
source: sensor.sonoff_100168acb6_energy
name: Hot Water
cycle: monthly
tariffs:
- peak
- offpeak
powercalc.yaml
This utility helps measuring the power usage throughout the day.
### Variable load sinks ###
- platform: powercalc
entity_id: sensor.sonoff_100168acb6_power
# Not reusing existing power sensor so that new energy sensor is created
#power_sensor_id: sensor.sonoff_100168acb6_power
name: Immersion Heater Aggregated
fixed:
power: "{{states('sensor.sonoff_100168acb6_power')}}"
Summary
I’m sure this setup could (and will) be improved further, so any suggestions would be highly appreciated.
Any feedback, good or bad, is always welcome!
For more on my solar & battery setup please see Solax X1 Hybrid G4 (local & cloud API).
Happy heating!