Daikin Heat pump controller using PID and heat curve adjustment for smart thermostats controls

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' }}"
1 Like

Sounds like a new thing I wasn’t aware of. Interesting.

So I’m trying to wrap my head around the new (to me) concept… for this heat pump, is this air-to-air, water-to-air, or ground-to-air heat pump? Daikin probably has all of those.
This is not heat pump water heater, right?

Where do I go to learn more about concepts and relationships around “solar gain”, “dumb leaving water temperature”, “heat curve”, etc…? Why is it a lot more efficient? Compare to what? What exactly is/are “internal and internal temps”…? Internal to your house or internal to the heat pump unit?
Do you have other documentation or photos regarding what is implemented here?

Sorry a lot of questions. I’m just curious to understand what is going on here.

Wait, is this a heat pump of air to water heat exchange, to heat a place, via radiator? So not a air handler type, and not applicable to summer time for AC…?

If so, that should have been mentioned / explained in the first place, regarding what exactly your thing do exactly.