Hi folks, I’ve been working on a PID-style controller for a while, and I’m now on my 3rd iteration, which I feel is ready to drop so folks can take it up and adapt it to their own needs.
So my heat pump is a Daikin 8kw unit, the default setup uses the thermostat but Daikins implementation is very erratic. The “dumb” leaving water temperature is a lot more efficent but it’s tricky to fine-tune that and we have to contend with solar gain etc. So I’ve built the following package.
It uses my solar PV setup to read solar generation for a clue on solar gain based on the volume of solar being generated. Weather integration looks ahead on the forecast to also support solar gain logic.
It then reads internal and internal temps and then uses the PID logic to control the heatcurve. This is slow and steady but a bit more aggressive above 10 degrees to help save power. It also scales up and down and can be programmed, mine focuses on 20 degrees.
to clarify, my heatcurve sits between 30 and 40 degrees from 13 to -3 degrees. The top end of the curve when its very cold but these shoulder months are hard for a heatcurve to manage if your heat pump isn’t absolutely spot on with sizing.
Code below.
###############################################################################
# PACKAGE: Forecast-Aware PID + Seasonal Supervisor (Tuned for Overshoot)
###############################################################################
input_number:
heat_curve_integral:
name: "Heat Curve Integral"
min: -20
max: 20
step: 0.1
mode: box
heating_room_setpoint:
name: "Heating Room Setpoint"
min: 18
max: 23
step: 0.1
unit_of_measurement: "°C"
sensor:
- platform: filter
entity_id: sensor.altherma_heat_pump_climatecontrol_room_temperature
name: "Room Temp Smoothed"
filters:
- filter: lowpass
time_constant: 10
precision: 2
template:
# 1. FETCH MET.NO FORECAST
- trigger:
- trigger: time_pattern
hours: "/1"
- trigger: homeassistant
event: start
action:
- action: weather.get_forecasts
data:
type: hourly
target:
entity_id: weather.forecast_home
response_variable: hourly_data
sensor:
- name: "Outdoor Temperature 1h Forecast"
unique_id: outdoor_temperature_1h_forecast
unit_of_measurement: "°C"
device_class: temperature
state: >
{% set target_entity = 'weather.forecast_home' %}
{% if hourly_data[target_entity] is defined and hourly_data[target_entity].forecast | length > 0 %}
{{ hourly_data[target_entity].forecast[0].temperature | float }}
{% else %}
{{ states('sensor.altherma_heat_pump_climatecontrol_outdoor_temperature') | float(10) }}
{% endif %}
# 2. TUNED PID LOGIC SENSORS
- sensor:
- name: "HP PID Forecast Bias"
unique_id: hp_pid_forecast_bias
state: >
{% set pv = states('sensor.solcast_pv_forecast_forecast_next_hour') | float(0) %}
{% set pv_capacity = 5000 %}
{% set room = states('sensor.room_temp_smoothed') | float(20) %}
{% set target = states('input_number.heating_room_setpoint') | float(20) %}
{% set room_deadband = 0.2 %}
{% set temp_now = states('sensor.altherma_heat_pump_climatecontrol_outdoor_temperature') | float(0) %}
{% set temp_1h = states('sensor.outdoor_temperature_1h_forecast') | float(temp_now) %}
{% set Smax, Wmax, Rmax, Bmax = 1.2, 0.5, 1.5, 2.5 %}
{% set solar_bias = ([0, [pv / pv_capacity, 1] | min] | max) * Smax %}
{% set weather_bias = ([0, [(temp_1h - temp_now) / 3, 1] | min] | max) * Wmax %}
{% set room_bias = ([0, [(room - target - room_deadband) / 2, 1] | min] | max) * Rmax %}
{{ [0, [solar_bias + weather_bias + room_bias, Bmax] | min] | max | round(3) }}
- name: "HP PID Output"
unique_id: hp_pid_output
state: >
{% set t = states('sensor.room_temp_smoothed') | float(20) %}
{% set target = states('input_number.heating_room_setpoint') | float(20) %}
{% set error = target - t %}
{# TUNING: Weight negative error 1.5x to drop heat faster when over setpoint #}
{% set error_weighted = error * 1.5 if error < 0 else error %}
{% set integral = states('input_number.heat_curve_integral') | float(0) %}
{% set bias = states('sensor.hp_pid_forecast_bias') | float(0) %}
{# Kp increased to 1.5 for sharper reaction #}
{% set Kp, Ki = 1.5, 0.05 %}
{{ ((Kp * error_weighted) + (Ki * integral) - bias) | round(1) }}
# 3. SEASONAL BINARY SENSOR
- binary_sensor:
- name: "Heating Season Active"
unique_id: heating_season_active
device_class: running
delay_on: "02:00:00"
delay_off: "02:00:00"
state: >
{% set t_now = states('sensor.altherma_heat_pump_climatecontrol_outdoor_temperature') | float(10) %}
{% set t_fore = states('sensor.outdoor_temperature_1h_forecast') | float(t_now) %}
{% if t_now < 12 and t_fore < 12 %}
true
{% elif t_now > 14 and t_fore > 14 %}
false
{% else %}
{{ is_state('binary_sensor.heating_season_active', 'on') }}
{% endif %}
automation:
- alias: "Heat Pump - PID Loop"
id: hp_pid_loop
trigger:
- trigger: time_pattern
minutes: "/30"
actions:
- variables:
target_temp: "{{ states('input_number.heating_room_setpoint') | float(20) }}"
current_temp: "{{ states('sensor.room_temp_smoothed') | float(20) }}"
error: "{{ target_temp - current_temp }}"
pid_raw: "{{ states('sensor.hp_pid_output') | float(0) }}"
pid_clamped: "{{ [ [-5, (pid_raw | round(0))] | max, 5] | min }}"
# 1. Update the Integral with faster downward accumulation
- action: input_number.set_value
target:
entity_id: input_number.heat_curve_integral
data:
value: >
{% set weight = 0.08 if error < 0 else 0.04 %}
{{ [ [-20, (states('input_number.heat_curve_integral')|float(0) + (error * weight))] | max, 20] | min | round(2) }}
# 2. Update Visual Counter
- action: input_number.set_value
target:
entity_id: input_number.heat_curve_offset_counter
data:
value: "{{ pid_clamped }}"
# 3. Send command to Altherma
- action: climate.set_temperature
target:
entity_id: climate.heating_leaving_water_offset
data:
temperature: "{{ pid_clamped }}"
- alias: "Heat Pump - Seasonal Supervisor"
id: hp_seasonal_supervisor
trigger:
- trigger: state
entity_id: binary_sensor.heating_season_active
actions:
- action: climate.set_hvac_mode
target:
entity_id: climate.heating_leaving_water_offset
data:
hvac_mode: "{{ 'heat' if is_state('binary_sensor.heating_season_active', 'on') else 'off' }}"