[pid] Pid climate add set deadband parameters action (#14730)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
ngolf
2026-09-23 17:21:25 +01:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent f6e97b7ada
commit cf3552b46e
9 changed files with 236 additions and 99 deletions
+61 -38
View File
@@ -3,19 +3,13 @@ import esphome.codegen as cg
from esphome.components import climate, output, sensor
import esphome.config_validation as cv
from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR
from esphome.core import ID
from esphome.core import ID, Lambda
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
pid_ns = cg.esphome_ns.namespace("pid")
PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component)
PIDAutotuneAction = pid_ns.class_("PIDAutotuneAction", automation.Action)
PIDResetIntegralTermAction = pid_ns.class_(
"PIDResetIntegralTermAction", automation.Action
)
PIDSetControlParametersAction = pid_ns.class_(
"PIDSetControlParametersAction", automation.Action
)
CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature"
@@ -43,6 +37,24 @@ CONF_KP_MULTIPLIER = "kp_multiplier"
CONF_KI_MULTIPLIER = "ki_multiplier"
CONF_KD_MULTIPLIER = "kd_multiplier"
def _validate_thresholds(config: ConfigType) -> ConfigType:
# Same rule as PIDClimate::set_deadband_thresholds; equal is allowed since 0/0 is the default.
if config[CONF_THRESHOLD_LOW] > config[CONF_THRESHOLD_HIGH]:
raise cv.Invalid(
f"{CONF_THRESHOLD_LOW} must not be greater than {CONF_THRESHOLD_HIGH}"
)
return config
def _validate_threshold_action(config: ConfigType) -> ConfigType:
threshold_low = config[CONF_THRESHOLD_LOW]
threshold_high = config[CONF_THRESHOLD_HIGH]
if isinstance(threshold_low, Lambda) or isinstance(threshold_high, Lambda):
return config
return _validate_thresholds(config)
CONFIG_SCHEMA = cv.All(
climate.climate_schema(PIDClimate).extend(
{
@@ -51,7 +63,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_DEFAULT_TARGET_TEMPERATURE): cv.temperature,
cv.Optional(CONF_COOL_OUTPUT): cv.use_id(output.FloatOutput),
cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput),
cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema(
cv.Optional(CONF_DEADBAND_PARAMETERS): cv.All(
{
cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta,
cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta,
@@ -61,7 +73,8 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1
): cv.positive_not_null_int,
}
},
_validate_thresholds,
),
cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema(
{
@@ -134,24 +147,15 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE]))
@automation.register_action(
automation.register_apply_action(
"climate.pid.reset_integral_term",
PIDResetIntegralTermAction,
automation.maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(PIDClimate),
}
),
synchronous=True,
automation.ApplyCall("reset_integral_term()"),
)
async def pid_reset_integral_term(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@automation.register_action(
@@ -185,9 +189,8 @@ async def esp8266_set_frequency_to_code(
return var
@automation.register_action(
automation.register_apply_action(
"climate.pid.set_control_parameters",
PIDSetControlParametersAction,
automation.maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(PIDClimate),
@@ -196,24 +199,44 @@ async def esp8266_set_frequency_to_code(
cv.Optional(CONF_KD, default=0.0): cv.templatable(cv.float_),
}
),
synchronous=True,
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
automation.ApplyField(CONF_KD, "set_kd", cg.float_),
)
async def set_control_parameters(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
kp_template_ = await cg.templatable(config[CONF_KP], args, cg.float_)
cg.add(var.set_kp(kp_template_))
ki_template_ = await cg.templatable(config[CONF_KI], args, cg.float_)
cg.add(var.set_ki(ki_template_))
automation.register_apply_action(
"climate.pid.set_deadband_control_parameters_multipliers",
automation.maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(PIDClimate),
# kp_multiplier is required for compatibility with the original action API;
# ki_multiplier and kd_multiplier are optional overrides.
cv.Required(CONF_KP_MULTIPLIER): cv.templatable(cv.float_),
cv.Optional(CONF_KI_MULTIPLIER): cv.templatable(cv.float_),
cv.Optional(CONF_KD_MULTIPLIER): cv.templatable(cv.float_),
}
),
automation.ApplyField(CONF_KP_MULTIPLIER, "set_kp_multiplier", cg.float_),
automation.ApplyField(CONF_KI_MULTIPLIER, "set_ki_multiplier", cg.float_),
automation.ApplyField(CONF_KD_MULTIPLIER, "set_kd_multiplier", cg.float_),
)
kd_template_ = await cg.templatable(config[CONF_KD], args, cg.float_)
cg.add(var.set_kd(kd_template_))
return var
automation.register_apply_action(
"climate.pid.set_deadband_threshold_parameters",
automation.maybe_simple_id(
cv.All(
{
cv.Required(CONF_ID): cv.use_id(PIDClimate),
cv.Required(CONF_THRESHOLD_HIGH): cv.templatable(cv.temperature_delta),
cv.Required(CONF_THRESHOLD_LOW): cv.templatable(cv.temperature_delta),
},
_validate_threshold_action,
)
),
automation.ApplyCall(
"set_deadband_thresholds({}, {})",
((CONF_THRESHOLD_LOW, cg.float_), (CONF_THRESHOLD_HIGH, cg.float_)),
),
)
+11
View File
@@ -5,6 +5,17 @@ namespace esphome::pid {
static const char *const TAG = "pid.climate";
bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) {
if (threshold_low > threshold_high) {
ESP_LOGW(TAG, "Deadband threshold low %.2f must not be greater than high %.2f", threshold_low, threshold_high);
return false;
}
this->set_threshold_low(threshold_low);
this->set_threshold_high(threshold_high);
return true;
}
void PIDClimate::setup() {
this->sensor_->add_on_state_callback([this](float state) {
// only publish if state/current temperature has changed in two digits of precision
+1 -32
View File
@@ -26,6 +26,7 @@ class PIDClimate final : public climate::Climate, public Component {
void set_kd(float kd) { controller_.kd_ = kd; }
void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; }
void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; }
bool set_deadband_thresholds(float threshold_low, float threshold_high);
void set_output_samples(int in) { controller_.output_samples_ = in; }
void set_derivative_samples(int in) {
controller_.derivative_samples_ = in;
@@ -131,36 +132,4 @@ template<typename... Ts> class PIDAutotuneAction final : public Action<Ts...> {
PIDClimate *parent_;
};
template<typename... Ts> class PIDResetIntegralTermAction final : public Action<Ts...> {
public:
PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {}
void play(const Ts &...x) { this->parent_->reset_integral_term(); }
protected:
PIDClimate *parent_;
};
template<typename... Ts> class PIDSetControlParametersAction final : public Action<Ts...> {
public:
PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {}
void play(const Ts &...x) {
auto kp = this->kp_.value(x...);
auto ki = this->ki_.value(x...);
auto kd = this->kd_.value(x...);
this->parent_->set_kp(kp);
this->parent_->set_ki(ki);
this->parent_->set_kd(kd);
}
protected:
TEMPLATABLE_VALUE(float, kp)
TEMPLATABLE_VALUE(float, ki)
TEMPLATABLE_VALUE(float, kd)
PIDClimate *parent_;
};
} // namespace esphome::pid
+57 -15
View File
@@ -2,7 +2,15 @@ import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_CLIMATE_ID
import esphome.config_validation as cv
from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT
from esphome.const import (
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
ICON_GAUGE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_PERCENT,
)
from esphome.types import ConfigType
from ..climate import PIDClimate, pid_ns
@@ -11,22 +19,55 @@ PIDClimateSensor = pid_ns.class_("PIDClimateSensor", sensor.Sensor, cg.Component
PIDClimateSensorType = pid_ns.enum("PIDClimateSensorType")
PID_CLIMATE_SENSOR_TYPES = {
"RESULT": PIDClimateSensorType.PID_SENSOR_TYPE_RESULT,
"ERROR": PIDClimateSensorType.PID_SENSOR_TYPE_ERROR,
"PROPORTIONAL": PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL,
"INTEGRAL": PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL,
"DERIVATIVE": PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE,
"HEAT": PIDClimateSensorType.PID_SENSOR_TYPE_HEAT,
"COOL": PIDClimateSensorType.PID_SENSOR_TYPE_COOL,
"KP": PIDClimateSensorType.PID_SENSOR_TYPE_KP,
"KI": PIDClimateSensorType.PID_SENSOR_TYPE_KI,
"KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD,
"RESULT": (PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, UNIT_PERCENT),
"ERROR": (PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, UNIT_PERCENT),
"PROPORTIONAL": (PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, UNIT_PERCENT),
"INTEGRAL": (PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, UNIT_PERCENT),
"DERIVATIVE": (PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, UNIT_PERCENT),
"HEAT": (PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, UNIT_PERCENT),
"COOL": (PIDClimateSensorType.PID_SENSOR_TYPE_COOL, UNIT_PERCENT),
"KP": (PIDClimateSensorType.PID_SENSOR_TYPE_KP, UNIT_PERCENT),
"KI": (PIDClimateSensorType.PID_SENSOR_TYPE_KI, UNIT_PERCENT),
"KD": (PIDClimateSensorType.PID_SENSOR_TYPE_KD, UNIT_PERCENT),
"DEADBAND_THRESHOLD_HIGH": (
PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH,
UNIT_CELSIUS,
),
"DEADBAND_THRESHOLD_LOW": (
PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW,
UNIT_CELSIUS,
),
"KP_DEADBAND_MULTIPLIER": (
PIDClimateSensorType.PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER,
UNIT_EMPTY,
),
"KI_DEADBAND_MULTIPLIER": (
PIDClimateSensorType.PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER,
UNIT_EMPTY,
),
"KD_DEADBAND_MULTIPLIER": (
PIDClimateSensorType.PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER,
UNIT_EMPTY,
),
}
CONFIG_SCHEMA = (
PID_CLIMATE_SENSOR_ENUMS = {
sensor_type: sensor_config[0]
for sensor_type, sensor_config in PID_CLIMATE_SENSOR_TYPES.items()
}
def set_default_unit_of_measurement(config: ConfigType) -> ConfigType:
sensor_type = config[CONF_TYPE]
config.setdefault(
CONF_UNIT_OF_MEASUREMENT, PID_CLIMATE_SENSOR_TYPES[sensor_type][1]
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
PIDClimateSensor,
unit_of_measurement=UNIT_PERCENT,
icon=ICON_GAUGE,
accuracy_decimals=1,
state_class=STATE_CLASS_MEASUREMENT,
@@ -34,10 +75,11 @@ CONFIG_SCHEMA = (
.extend(
{
cv.GenerateID(CONF_CLIMATE_ID): cv.use_id(PIDClimate),
cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_TYPES, upper=True),
cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_ENUMS, upper=True),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
set_default_unit_of_measurement,
)
@@ -14,43 +14,55 @@ void PIDClimateSensor::update_from_parent_() {
float value;
switch (this->type_) {
case PID_SENSOR_TYPE_RESULT:
value = this->parent_->get_output_value();
value = this->parent_->get_output_value() * 100.0f;
break;
case PID_SENSOR_TYPE_ERROR:
value = this->parent_->get_error_value();
value = this->parent_->get_error_value() * 100.0f;
break;
case PID_SENSOR_TYPE_PROPORTIONAL:
value = this->parent_->get_proportional_term();
value = this->parent_->get_proportional_term() * 100.0f;
break;
case PID_SENSOR_TYPE_INTEGRAL:
value = this->parent_->get_integral_term();
value = this->parent_->get_integral_term() * 100.0f;
break;
case PID_SENSOR_TYPE_DERIVATIVE:
value = this->parent_->get_derivative_term();
value = this->parent_->get_derivative_term() * 100.0f;
break;
case PID_SENSOR_TYPE_HEAT:
value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f);
value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f;
break;
case PID_SENSOR_TYPE_COOL:
value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f);
value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f;
break;
case PID_SENSOR_TYPE_KP:
value = this->parent_->get_kp();
this->publish_state(value);
return;
break;
case PID_SENSOR_TYPE_KI:
value = this->parent_->get_ki();
this->publish_state(value);
return;
break;
case PID_SENSOR_TYPE_KD:
value = this->parent_->get_kd();
this->publish_state(value);
return;
break;
case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH:
value = this->parent_->get_threshold_high();
break;
case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW:
value = this->parent_->get_threshold_low();
break;
case PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER:
value = this->parent_->get_kp_multiplier();
break;
case PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER:
value = this->parent_->get_ki_multiplier();
break;
case PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER:
value = this->parent_->get_kd_multiplier();
break;
default:
value = NAN;
break;
}
this->publish_state(value * 100.0f);
this->publish_state(value);
}
void PIDClimateSensor::dump_config() { LOG_SENSOR("", "PID Climate Sensor", this); }
@@ -16,6 +16,11 @@ enum PIDClimateSensorType {
PID_SENSOR_TYPE_KP,
PID_SENSOR_TYPE_KI,
PID_SENSOR_TYPE_KD,
PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH,
PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW,
PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER,
PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER,
PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER,
};
class PIDClimateSensor final : public sensor::Sensor, public Component {
+5
View File
@@ -0,0 +1,5 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.dependencies = manifest.dependencies + ["climate", "output", "sensor"]
+29
View File
@@ -12,6 +12,15 @@ esphome:
kp: 0.0
ki: 0.0
kd: 0.0
- climate.pid.set_deadband_control_parameters_multipliers:
id: pid_climate
kp_multiplier: 0.0
ki_multiplier: 0.0
kd_multiplier: 0.0
- climate.pid.set_deadband_threshold_parameters:
id: pid_climate
threshold_high: 0.5°C
threshold_low: !lambda return -0.5f;
- climate.pid.reset_integral_term: pid_climate
output:
@@ -30,6 +39,26 @@ sensor:
}
return 0.0;
update_interval: 60s
- platform: pid
id: pid_kp_multiplier
climate_id: pid_climate
type: KP_DEADBAND_MULTIPLIER
- platform: pid
id: pid_ki_multiplier
climate_id: pid_climate
type: KI_DEADBAND_MULTIPLIER
- platform: pid
id: pid_kd_multiplier
climate_id: pid_climate
type: KD_DEADBAND_MULTIPLIER
- platform: pid
id: pid_threshold_high
climate_id: pid_climate
type: DEADBAND_THRESHOLD_HIGH
- platform: pid
id: pid_threshold_low
climate_id: pid_climate
type: DEADBAND_THRESHOLD_LOW
climate:
- platform: pid
@@ -0,0 +1,41 @@
#include <gtest/gtest.h>
#include "esphome/components/pid/pid_climate.h"
namespace esphome::pid {
TEST(PIDClimateDeadbandThresholds, InvalidThresholdsDoNotChangeController) {
PIDClimate climate;
climate.set_threshold_low(-1.0f);
climate.set_threshold_high(1.0f);
EXPECT_FALSE(climate.set_deadband_thresholds(2.0f, 1.0f));
EXPECT_FLOAT_EQ(climate.get_threshold_low(), -1.0f);
EXPECT_FLOAT_EQ(climate.get_threshold_high(), 1.0f);
}
TEST(PIDClimateDeadbandThresholds, ValidThresholdsChangeController) {
PIDClimate climate;
climate.set_threshold_low(-1.0f);
climate.set_threshold_high(1.0f);
EXPECT_TRUE(climate.set_deadband_thresholds(-2.0f, 0.5f));
EXPECT_FLOAT_EQ(climate.get_threshold_low(), -2.0f);
EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.5f);
}
TEST(PIDClimateDeadbandThresholds, EqualThresholdsDisableDeadband) {
PIDClimate climate;
climate.set_threshold_low(-1.0f);
climate.set_threshold_high(1.0f);
EXPECT_TRUE(climate.set_deadband_thresholds(0.0f, 0.0f));
EXPECT_FLOAT_EQ(climate.get_threshold_low(), 0.0f);
EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.0f);
EXPECT_FALSE(climate.in_deadband());
}
} // namespace esphome::pid