How to get sub-degree accuracy in home temperature, using a dumb A/C and ESPHome

The secret sauce for harmony at home is a PID controller and some grade school math.

It's summer.  For summer, I have a variable compressor mini-split A/C unit which works great — it can cool the place rather quickly, so we don't have to suffer 35 degrees Celsius in our home.

The mini-split is driven using an extremely cheap infrared ESP device, running a custom component for ESPHome which I wrote.  It's nice to change temperatures and fan speeds, or shut the unit off completely, directly from our Home Assistant dashboards.  All code is available here, as well as an explanation of what it does.

The problem

One issue remained: the girlfriend and I were routinely arguing about the temperature.  During regular summer days, if we set the unit to 25 degrees, it's perfect for me, but it's too chilly for her.  If we set the unit to 26 degrees, I'm sweating, but she's happy.  If the fan of the unit is set to low, it won't appreciably cool the home (especially if it's  too warm outside).  If the fan of the unit is set to high, it's too noisy to sleep.

To compound the issue, we would have the tendency to lower the A/C setpoint on extra warm days — just because we felt the air was warmer.  Which, of course, would make for unbearably cold evenings, when the Sun stopped hitting hard.

These problems usually boil down to data.  Looking at the graphs of temperature while the unit is running,  it's clear the unit doesn't reach its target, when we compare it to our living space thermometers — if we set it to 25, it will get the place to 26, and fluctuate down to one whole degree from that, while sometimes going up to 26.5.  Horribly inaccurate!  To compound the issue, the A/C doesn't really know the actual temperature of the place; it really only knows the temperature of the air it's ingesting, which is not representative of the whole living space.  Finally, on hot days, the A/C was not "running hard enough" — we could see the indoor temperature sensors go up measurably, without a corresponding increase in compressor workload — which led to us needing to monkey around with the setpoint, just to get that compressor to output what it's supposed to.

Stressful and annoying.  We're not cavemen.  We don't need to be doing any of this.

What if, instead of letting the A/C's thermostat control the operation of the A/C, we instead had sub-degree (tenth-of-a-degree) automatic control of the unit?  What if we don't have to fiddle with the thermostat at all?  Maybe then we can select a temperature that both of us are happy with — and even reduce the fan noise when the place is at a nice, comfortable temperature already.

That's exactly what I did.  And it solved our problem, I'm happy to report.  With a setpoint of 26.7 degrees Celsius, the girlfriend and I are very happy with the temperature (which oscillates 95% of the time no more than 0.15 degrees around the setpoint!).  Furthermore, the unit cools extremely fast when we get home, the home is warm, and the unit is turned on.

How did I solve the issue?

I use a PID controller climate entity (technically, a proportional/integral controller), plus some simple math to take facts of reality into account.

The code for the complete solution is provided below, of course.

What's a PID controller?

A PID controller is a cybernetic control mechanism.  A complete explanation of PID controllers, which helped me, is in this YouTube video.

The PID controller takes an input parameter (e.g. a temperature), and a target parameter (e.g. another temperature) and it produces an output (say, a percentage of speed that a motor must run at).  The output is calculated by summing up the proportional, integral and derivative terms... wait, what's that mean?

• The proportional term is an indication of how far off your system is from the target right now.
• The proportional term is useful to rapidly correct a system that is off-track.
• Mathematically, it's simply the difference between the input and the target, multiplied by a proportional factor.
• With a factor of 1, a current temperature of 26, and a target temperature of 25, the proportional term would be -1 because (25 -26) * 1 = -1.
• In the provided sketch, the proportional factor is called `initial_kp`.
• The integral term is an indication of how far off your system has been from the target so far.
• The integral term helps maintain a correction factor once the system has hit its target.
• Mathematically, the controller calculates the difference between the input and the target, multiplies it by the time difference between the current reading and the last reading, multiplies it by an integral factor, and then sums the result to the old integral term value.
• With a factor of 1, a current temperature of 26, and a target temperature of 25, every five seconds your integral factor would go down by -5.
• In practice, many PID implementations will constrain the range that the integral term can visit — otherwise it may "accumulate to infinity" and cause the output to be permanently stuck at max or at min "forever".
• In the provided sketch, the integral factor is called `initial_ki`.  Since the temperature is sampled every 5 seconds, the integral is accumulated every 5 seconds.
• The derivative term I'm not using, so I can't really explain.
• In the provided sketch, the derivative factor is called `initial_kd`.
• The deadband is a range of inputs encompassing the target, within which each term factor is usually scaled down by some amount (the deadband multiplier).
• When the input lies within the deadband, because of the multipliers, changes in the input have a reduced effect on the output.  The integral term also accumulates more slowly (depending on the multiplier).
• The deadband helps the system be more stable once it's close to its target.
• In the provided sketch, these are controlled by variables `deadband_kp_multiplier`, `deadband_ki_multiplier` and `deadband_kd_multiplier`.

The important thing is that, once these terms are added, they will add up to some value, which in many applications is understood as the percentage of power input that should be applied to your control system (a car engine, a heating system, a rudder, an air conditioner).

Many PID implementations will constrain the output to a number between a minimum such as -1.0 and a maximum such as 1.0, since one usually can't drive a control device beyond 100%, or perhaps below -100%.

The details of the solution

• The PID controller climate (called `living_space` in the code) has its traditional target temperature (which can be set to sub-degree accuracy).
• It also has a process input (current temperature, called `living_space_ambient_temperature`), which is fed from a Home Assistant sensor (that ultimately uses multiple temperature probes around the home).
• To ensure temporary (within less than a minute) changes in input temperature, the input temperature is smoothed using an exponential moving average.  This eliminates noise from the input temperature, which is rare, but happens.  No noise means no odd fluctuations in the output (discussed below) of the PID controller.
• The output of the PID controller is a variable (`living_space_pid_output`) which ranges from 100% to -100% (in practice, from 0% to -100%).  I don't directly use the output provided by the climate PID controller my design, except as an event to drive the recalculation of the `living_space_pid_output` variable.
• There is a deadband set up in the PID controller; if the current temperature is within the deadband, sudden changes in input temperature cause changes to the output to be slow over time.
• This output is then used to recalculate the target temperature (`temperature_for_novamatic_cl_1590`), with a mapping formula that more or less means:
• 0% -> target temperature must be set to current temperature
• -100% -> target temperature must be set to current temperature minus "max delta from setpoint" (at home, this is 4 degrees).
• When the target temperature changes, we puppet the actual A/C climate entity to correspond to it.
• Since the output is a floating point number, and the A/C unit only accepts integers, the control code rounds the value up to the nearest integer.
• However, there is an important detail here: we can't just use the rounded value as is, because that can cause the unit to flicker between temperature instantly when the process input temperature changes even minutely.  To aid with this, we have a stiction parameter — this makes the current setting stick to its guns, unless the delta between the new target and the old target differs by more than the stiction.  E.g. if the current target is 26, and the new target is 26.5, without stiction it would immediately change to 27 — but with a stiction of 0.1, a change to 27 would require the new target to go to 26.6.  This prevents wear and tear on the unit when temperatures indoors fluctuate.
• Fan speeds are also adjusted here.  If the temperature is close to the "medium speed" threshold, the fan is adjusted from high to medium.  If then the temperature progresses to the "low speed" threshold, the fan is then set to low.  There's a stiction parameter for the fan as well, which prevents the fan from changing rapidly when the input temperature changes rapidly too.

Show me the code!

Here is the full sketch I am using.  The sketch has some substitutions that let you rename the entities easily.  Many parameters of the PID model in this sketch can be altered at runtime by way of input numbers (with sensible defaults) which you can tweak using sliders in Home Assistant.  This way you can see the effect these parameters have in the PID output and target temperature sensors.

`substitutions:  # Tunables.  These values can be changed using number entities in HA.  initial_kp: "0.5"  initial_ki: "0.01"  initial_kd: "0.0"  initial_temperature_stiction: "0.1"  initial_fan_stiction: "0.1"  initial_medium_fan_threshold: "0.2"  initial_high_fan_threshold: "1.5"  initial_max_delta_from_setpoint: "5.0"  # Hard-coded.  These values can only be changed in the sketch.  min_integral: "-0.3"  max_integral: "0.05"  deadband_low: -0.1°C  deadband_high: 0.2°C  deadband_kp_multiplier: "1.0"  deadband_ki_multiplier: "0.05"  deadband_kd_multiplier: "0.0"  # Names of entities.  pid_climate_id: "living_space"  pid_climate_name: "Living space"  target_climate_id: "novamatic_cl_1590"  target_climate_name: "Novamatic CL 1590"  target_climate_min_temp: "16.0"  target_climate_max_temp: "30.0"  source_temperature_sensor: "living_space_ambient_temperature"  source_temperature_sampling_alpha: "0.1"#  skip_first_temperature_samples: "6"esphome:  name: ir-transceiver-1  friendly_name: IR transceiver 1external_components:  - source:      type: git      url: https://github.com/Rudd-O/novamatic_climate      ref: master    components:    - novamatic_climate    # refresh: 60s # https://esphome.io/components/external_components.html#refreshglobals:- id: mode_changed  type: bool  restore_value: no  initial_value: "false"esp8266:  # ESP8285 IR transceiver  board: esp01_1m  restore_from_flash: true# Enable logginglogger:  level: info# Enable Home Assistant APIapi:  encryption:    key: <put your own key here>  services:  - service: send_pronto    variables:      data: string    then:    - remote_transmitter.transmit_pronto:        data: !lambda 'return data;'ota:  password: <put your own password here>wifi:  ssid: !secret wifi_ssid  password: !secret wifi_passwordbutton:- platform: restart  name: Restart  entity_category: diagnostic  icon: mdi:restart- platform: safe_mode  name: Safe mode restart  entity_category: diagnostic  icon: mdi:restart-alert- platform: template  name: Reset PID integral value of \${pid_climate_name}  entity_category: diagnostic  on_press:    - then:      - climate.pid.reset_integral_term: \${pid_climate_id}- platform: factory_reset  name: Factory reset  entity_category: diagnostic  icon: mdi:restore-alertbinary_sensor:- platform: status  name: API connected  id: api_connected  entity_category: diagnostic- platform: template  name: "\${pid_climate_name} in deadband"  id: \${pid_climate_id}_in_deadband  disabled_by_default: true  entity_category: "diagnostic"climate:- platform: novamatic_climate  name: \${target_climate_name}  id: \${target_climate_id}  transmitter_id: transmitter  icon: mdi:air-conditioner  sensor: \${source_temperature_sensor}- platform: pid  name: "\${pid_climate_name}"  id: "\${pid_climate_id}"  sensor: sampled_temperature_for_\${pid_climate_id}  default_target_temperature: 24°C  cool_output: \${pid_climate_id}_output  control_parameters:    kp: \${initial_kp}    ki: \${initial_ki}    kd: \${initial_kd}    min_integral: \${min_integral}    max_integral: \${max_integral}  deadband_parameters:    threshold_low: \${deadband_low}    threshold_high: \${deadband_high}    kp_multiplier: \${deadband_kp_multiplier}    ki_multiplier: \${deadband_ki_multiplier}    kd_multiplier: \${deadband_kd_multiplier}  on_control:    - globals.set:        id: mode_changed        value: "true"    - delay: 1s    - script.execute:        id: actuate_target_climate    # Obsolete comment below.    # FIXME the integral should not be reset every time the A/C is controlled.    # It should only be reset when the climate call against this PID unit    # contains a get_mode().value() of CLIMATE_MODE_COOL and perhaps    # when the current mode of the climate device is not CLIMATE_MODE_COOL.    # Requires: https://github.com/esphome/esphome/pull/5028    # - logger.log:    #     format: Resetting the integral term due to incoming change in state    #     tag: PID    #     level: info    # With the new setup, it does not appear to be necessary to    # reset the PID integral when settings are changed.    # - climate.pid.reset_integral_term: living_spacenumber:- platform: template  name: "\${pid_climate_name} PID Kp"  id: \${pid_climate_id}_pid_kp  min_value: 0  max_value: 50  step: 0.000001  initial_value: \${initial_kp}  optimistic: true  entity_category: config  restore_value: true  on_value:    then:      - climate.pid.set_control_parameters:          id: living_space          kp: !lambda "return x;"          ki: !lambda "return id(living_space).get_ki();"          kd: !lambda "return id(living_space).get_kd();"  icon: mdi:chart-line-variant- platform: template  name: "\${pid_climate_name} PID Ki"  id: \${pid_climate_id}_pid_ki  min_value: 0  max_value: 5  step: 0.000001  initial_value: \${initial_ki}  optimistic: true  entity_category: config  restore_value: true  on_value:    then:      - climate.pid.set_control_parameters:          id: living_space          kp: !lambda "return id(living_space).get_kp();"          ki: !lambda "return x;"          kd: !lambda "return id(living_space).get_kd();"  icon: mdi:math-integral-box- platform: template  name: "\${pid_climate_name} PID Kd"  id: \${pid_climate_id}_pid_kd  min_value: 0  max_value: 50  step: 0.000001  initial_value: \${initial_kd}  optimistic: true  entity_category: config  restore_value: true  on_value:    then:      - climate.pid.set_control_parameters:          id: living_space          kp: !lambda "return id(living_space).get_kp();"          ki: !lambda "return id(living_space).get_ki();"          kd: !lambda "return x;"  icon: mdi:delta- platform: template  name: "\${pid_climate_name} maximum delta from setpoint"  id: \${pid_climate_id}_maximum_delta_from_setpoint  min_value: 0  max_value: 10  step: 0.1  initial_value: \${initial_max_delta_from_setpoint}  optimistic: true  entity_category: config  restore_value: true  icon: mdi:delta- platform: template  name: "\${target_climate_name} temperature stiction"  id: \${target_climate_id}_temperature_stiction  min_value: 0  max_value: 1  step: 0.01  initial_value: \${initial_temperature_stiction}  optimistic: true  entity_category: config  restore_value: true  icon: mdi:car-brake-temperature- platform: template  name: "\${target_climate_name} fan stiction"  id: \${target_climate_id}_fan_stiction  min_value: 0  max_value: 1  step: 0.01  initial_value: \${initial_fan_stiction}  optimistic: true  entity_category: config  restore_value: true  icon: mdi:car-brake-temperature- platform: template  name: "\${target_climate_name} high fan threshold"  id: \${target_climate_id}_high_fan_threshold  min_value: 0  max_value: 5  step: 0.01  initial_value: \${initial_high_fan_threshold}  optimistic: true  entity_category: config  restore_value: true  icon: mdi:fan-speed-3  unit_of_measurement: "°C"- platform: template  name: "\${target_climate_name} medium fan threshold"  id: \${target_climate_id}_medium_fan_threshold  min_value: 0  max_value: 5  step: 0.01  initial_value: \${initial_medium_fan_threshold}  optimistic: true  entity_category: config  restore_value: true  icon: mdi:fan-speed-2  unit_of_measurement: "°C"remote_receiver:  id: receiver  pin:    number: GPIO14    inverted: True  dump: prontoremote_transmitter:  id: transmitter  pin: GPIO4  carrier_duty_percent: 50%# Dummy output needed by PID climate.# We compute our own PID output which is allowed# to be nonzero when the PID climate entity is off# since we must compute the target temperature# independently of the mode of the PID climate.output:- platform: template  id: \${pid_climate_id}_output  type: float  write_action:    - lambda: |-        ESP_LOGD("PID", "Written to output: %.2f", state);    - component.update: \${pid_climate_id}_pid_output    - binary_sensor.template.publish:        id: \${pid_climate_id}_in_deadband        state: !lambda 'return id(\${pid_climate_id}).in_deadband();'sensor:- platform: homeassistant  entity_id: sensor.\${source_temperature_sensor}  id: \${source_temperature_sensor}  device_class: temperature  state_class: measurement  unit_of_measurement: "°C"  internal: true- platform: template  id: sampled_temperature_for_\${pid_climate_id}  lambda: |-    auto val = id(\${source_temperature_sensor}).state;    if (!isnan(val)) {      return val;    } else {      return {};    }  update_interval: 5s  internal: true  filters:    # - skip_initial: \${skip_first_temperature_samples}    - exponential_moving_average:        alpha: \${source_temperature_sampling_alpha}        send_every: 1    # - throttle_average: 30s    # - heartbeat: 30s- platform: pid  name: "\${pid_climate_name} PID P"  id: \${pid_climate_id}_pid_p  type: PROPORTIONAL  disabled_by_default: true  entity_category: "diagnostic"- platform: pid  name: "\${pid_climate_name} PID I"  id: \${pid_climate_id}_pid_i  type: INTEGRAL  disabled_by_default: true  entity_category: "diagnostic"- platform: pid  name: "\${pid_climate_name} PID D"  id: \${pid_climate_id}_pid_d  type: DERIVATIVE  disabled_by_default: true  entity_category: "diagnostic"- platform: pid  name: "\${pid_climate_name} PID error"  id: \${pid_climate_id}_error  type: ERROR  disabled_by_default: true  entity_category: "diagnostic"- platform: template  name: "\${pid_climate_name} PID output"  id: \${pid_climate_id}_pid_output  update_interval: never  disabled_by_default: true  unit_of_measurement: "%"  entity_category: "diagnostic"  lambda: |-    return id(\${pid_climate_id}_pid_p).state + id(\${pid_climate_id}_pid_i).state + id(\${pid_climate_id}_pid_d).state;  on_value:    - component.update: temperature_for_\${target_climate_id}- platform: template  name: Temperature for \${target_climate_name}  id: temperature_for_\${target_climate_id}  update_interval: never  device_class: temperature  unit_of_measurement: "°C"  accuracy_decimals: 2  disabled_by_default: true  entity_category: "diagnostic"  lambda: |-    // The setpoint is the temperature you set in the PID climate.    float setpoint = id(\${pid_climate_id}).target_temperature;    // The output is the PID output, ranging from zero to -anything,    // but in practice this will be clamped to +100% — -100% below.    float output = id(\${pid_climate_id}_pid_output).state / 100.0;    // This is the maximum deviation of the temperature that will    // be set on the target climate unit.    float max_delta_from_setpoint = id(\${pid_climate_id}_maximum_delta_from_setpoint).state;    // These are the target unit's maximum boundaries.    float hard_minimum = \${target_climate_min_temp};    float hard_maximum = \${target_climate_max_temp};    float max_from_setpoint = min(hard_maximum, setpoint + max_delta_from_setpoint);    float min_from_setpoint = max(hard_minimum, setpoint - max_delta_from_setpoint);    // The value is computed by adding the setpoint to the output    // multiplied by the maximum delta from setpoint.  E.g. if    // the PID output is -50% (-0.5), the setpoint is 22, and the max    // delta from setpoint is 4, then the value will be 22 + -0.5 * 4    // => in other words = 20.    float val = setpoint + output * max_delta_from_setpoint;    // And here we do the clamping, so even if the PID output is -200%    // we still only permit a minimum equivalent to    // setpoint - min_from_setpoint which takes into account the    // max deviation allowed as well as the hard minimum.  In practice,    // if the PID output is -150% (-1.5), the setpoint is 22, and the max    // delta from setpoint is 4, then the value will be 22 + -1.5 * 4    // => 16, but clamped to 18, in other words = 18.    float clamped = max(min(val, max_from_setpoint), min_from_setpoint);    return clamped;  on_value:    - script.execute:        id: actuate_target_climate- platform: wifi_signal  name: "Wi-Fi signal strength"  update_interval: 5s  entity_category: "diagnostic"script:- id: actuate_target_climate  then:  - lambda: |-      bool mc = id(mode_changed);      id(mode_changed) = false;      auto master_ac = id(\${pid_climate_id});      auto slave_ac = id(\${target_climate_id});      auto pid_mode = master_ac->mode;      auto slave_mode = slave_ac->mode;      if (pid_mode == esphome::climate::CLIMATE_MODE_OFF) {        if (slave_mode != esphome::climate::CLIMATE_MODE_OFF) {          esphome::climate::ClimateCall *c = new esphome::climate::ClimateCall(slave_ac);          c->set_mode(esphome::climate::CLIMATE_MODE_OFF);          ESP_LOGI("PID", "Turning off slave A/C because master A/C was turned off.");          c->perform();          delete c;        }        return;      }      /* From this point on, pid_mode is always CLIMATE_MODE_COOL. */      float new_slave_target_temperature = id(temperature_for_\${target_climate_id}).state;      auto current_temperature = id(sampled_temperature_for_\${pid_climate_id}).state;      if (new_slave_target_temperature >= current_temperature) {        if (slave_mode != esphome::climate::CLIMATE_MODE_OFF) {          esphome::climate::ClimateCall *c = new esphome::climate::ClimateCall(slave_ac);          c->set_mode(esphome::climate::CLIMATE_MODE_OFF);          ESP_LOGI("PID",                   "Turning off slave A/C because target temperature %.2f is above current temperature %.2f.",                   new_slave_target_temperature, current_temperature);          c->perform();          delete c;        }        return;      }      /* From this point on, new_slave_target_temperature is always < than current_temperature. */      auto slave_target_temperature = slave_ac->target_temperature;      esphome::climate::ClimateCall *c = new esphome::climate::ClimateCall(slave_ac);      bool perform_call = false;      // implement stiction on temperature control      // slave_target_temperature is always a rounded version of previously-set new_slave_target_temperature val      auto diff = abs(slave_target_temperature - new_slave_target_temperature);      auto stiction = id(\${target_climate_id}_temperature_stiction).state;      if (diff >= 0.5 + stiction || (mc && slave_mode != esphome::climate::CLIMATE_MODE_COOL)) {        int wanttmp_int = floor(new_slave_target_temperature + 0.5);        if (mc && slave_mode != esphome::climate::CLIMATE_MODE_COOL) {          ESP_LOGI("PID",                   "Setting temp of \${target_climate_name} to %d (current temp %.2f) due to \${pid_climate_name} turning on.",                   wanttmp_int, current_temperature);        }        else {          ESP_LOGI("PID",                   "Temp of \${target_climate_name} is %.2f, diff is %.2f, changing it to %d (current temp %.2f).",                   slave_target_temperature, diff, wanttmp_int, current_temperature);        }        c->set_target_temperature(wanttmp_int);        if (wanttmp_int >= current_temperature) {          ESP_LOGI("PID", "The target temperature is higher than the current temperature, turning \${target_climate_id} off.");          c->set_mode(esphome::climate::CLIMATE_MODE_OFF);          perform_call = true;        } else {          ESP_LOGI("PID", "Turning \${target_climate_id} on.");          c->set_mode(esphome::climate::CLIMATE_MODE_COOL);          perform_call = true;        }      }      auto slave_fan_mode = esphome::climate::CLIMATE_FAN_AUTO;      auto high_medium_boundary = id(\${target_climate_id}_high_fan_threshold).state;      auto medium_low_boundary = id(\${target_climate_id}_medium_fan_threshold).state;      auto fan_stiction = id(\${target_climate_id}_fan_stiction).state;      if (slave_ac->fan_mode.has_value()) {        slave_fan_mode = slave_ac->fan_mode.value();      }      float delta = master_ac->target_temperature - new_slave_target_temperature;      /*      truth table      stiction                 h/m                                         m/l      -> 0.1   >2.1   <=2.1   >2   <=2   >1.9   <=1.9   ↔   >1.1    <=1.1   >1   <=1   >0.9   <=0.9      if high     3       3    3     3      3       2   2      2      2    2     1      1       1      if med      3          2       2     2      2       2   2      2      2    2     2      2       1      if low      3       3    3     2      2       2   2      2      1    1     1      1       1      */      if (slave_fan_mode == esphome::climate::CLIMATE_FAN_HIGH) {        if (delta > high_medium_boundary - stiction) {          // do nothing        } else if (delta > medium_low_boundary) {          ESP_LOGI("PID", "Fan is %d, must be set to medium (delta %.2f).", slave_fan_mode, delta);          c->set_fan_mode(esphome::climate::CLIMATE_FAN_MEDIUM);          perform_call = true;        } else {          ESP_LOGI("PID", "Fan is %d, must be set to low (delta %.2f).", slave_fan_mode, delta);          c->set_fan_mode(esphome::climate::CLIMATE_FAN_LOW);          perform_call = true;        }      } else if (slave_fan_mode == esphome::climate::CLIMATE_FAN_MEDIUM) {        if (delta > high_medium_boundary + stiction) {          ESP_LOGI("PID", "Fan is %d, must be set to high (delta %.2f).", slave_fan_mode, delta);          c->set_fan_mode(esphome::climate::CLIMATE_FAN_HIGH);          perform_call = true;        } else if (delta <= high_medium_boundary + stiction                   &&                   delta >= medium_low_boundary - stiction) {          // do nothing        } else {          ESP_LOGI("PID", "Fan is %d, must be set to low (delta %.2f).", slave_fan_mode, delta);          c->set_fan_mode(esphome::climate::CLIMATE_FAN_LOW);          perform_call = true;        }      } else /* (slave_fan_mode == esphome::climate::CLIMATE_FAN_LOW) */ {        if (delta > high_medium_boundary) {          ESP_LOGI("PID", "Fan is %d, must be set to high (delta %.2f).", slave_fan_mode, delta);          c->set_fan_mode(esphome::climate::CLIMATE_FAN_HIGH);          perform_call = true;        } else if (delta > medium_low_boundary + stiction) {          ESP_LOGI("PID", "Fan is %d, must be set to medium (delta %.2f).", slave_fan_mode, delta);          c->set_fan_mode(esphome::climate::CLIMATE_FAN_MEDIUM);          perform_call = true;        } else {          // do nothing        }      }      if (perform_call) {        ESP_LOGI("PID", "Performing climate call to \${target_climate_id}.");        c->perform();      }      delete c;`