Skip to content

Instantly share code, notes, and snippets.

@Rudd-O
Created August 2, 2023 07:06
Show Gist options
  • Save Rudd-O/0d1089e8be05ecc4ab6b534c9c32a6a0 to your computer and use it in GitHub Desktop.
Save Rudd-O/0d1089e8be05ecc4ab6b534c9c32a6a0 to your computer and use it in GitHub Desktop.
Master / slave air conditioning with PID controlling actual unit
substitutions:
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"
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"
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 1
external_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#refresh
globals:
- id: mode_changed
type: bool
restore_value: no
initial_value: "false"
esp8266:
# ESP8285 IR transceiver
board: esp01_1m
restore_from_flash: true
# Enable logging
logger:
level: debug
# Enable Home Assistant API
api:
encryption:
key: <api encryption key>
services:
- service: send_pronto
variables:
data: string
then:
- remote_transmitter.transmit_pronto:
data: !lambda 'return data;'
ota:
password: <ota password>
wifi: <add your wifi stuff here>
ssid: !secret wifi_ssid
password: !secret wifi_password
button:
- 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-alert
binary_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}
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_space
number:
- 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: pronto
remote_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;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment