mirror of
https://github.com/esphome/esphome.git
synced 2024-12-22 13:34:54 +01:00
PID Climate (#885)
* PID Climate * Add sensor for debugging PID output value * Add dump_config, use percent * Add more observable values * Update * Set target temperature * Add autotuner * Add algorithm explanation * Add autotuner action, update controller * Add simulator * Format * Change defaults * Updates
This commit is contained in:
parent
05f9dede70
commit
a6d31f05ee
11 changed files with 1064 additions and 0 deletions
0
esphome/components/pid/__init__.py
Normal file
0
esphome/components/pid/__init__.py
Normal file
79
esphome/components/pid/climate.py
Normal file
79
esphome/components/pid/climate.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome import automation
|
||||
from esphome.components import climate, sensor, output
|
||||
from esphome.const import CONF_ID, CONF_SENSOR
|
||||
|
||||
pid_ns = cg.esphome_ns.namespace('pid')
|
||||
PIDClimate = pid_ns.class_('PIDClimate', climate.Climate, cg.Component)
|
||||
PIDAutotuneAction = pid_ns.class_('PIDAutotuneAction', automation.Action)
|
||||
|
||||
CONF_DEFAULT_TARGET_TEMPERATURE = 'default_target_temperature'
|
||||
|
||||
CONF_KP = 'kp'
|
||||
CONF_KI = 'ki'
|
||||
CONF_KD = 'kd'
|
||||
CONF_CONTROL_PARAMETERS = 'control_parameters'
|
||||
CONF_COOL_OUTPUT = 'cool_output'
|
||||
CONF_HEAT_OUTPUT = 'heat_output'
|
||||
CONF_NOISEBAND = 'noiseband'
|
||||
CONF_POSITIVE_OUTPUT = 'positive_output'
|
||||
CONF_NEGATIVE_OUTPUT = 'negative_output'
|
||||
CONF_MIN_INTEGRAL = 'min_integral'
|
||||
CONF_MAX_INTEGRAL = 'max_integral'
|
||||
|
||||
CONFIG_SCHEMA = cv.All(climate.CLIMATE_SCHEMA.extend({
|
||||
cv.GenerateID(): cv.declare_id(PIDClimate),
|
||||
cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor),
|
||||
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.Required(CONF_CONTROL_PARAMETERS): cv.Schema({
|
||||
cv.Required(CONF_KP): cv.float_,
|
||||
cv.Optional(CONF_KI, default=0.0): cv.float_,
|
||||
cv.Optional(CONF_KD, default=0.0): cv.float_,
|
||||
cv.Optional(CONF_MIN_INTEGRAL, default=-1): cv.float_,
|
||||
cv.Optional(CONF_MAX_INTEGRAL, default=1): cv.float_,
|
||||
}),
|
||||
}), cv.has_at_least_one_key(CONF_COOL_OUTPUT, CONF_HEAT_OUTPUT))
|
||||
|
||||
|
||||
def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
yield cg.register_component(var, config)
|
||||
yield climate.register_climate(var, config)
|
||||
|
||||
sens = yield cg.get_variable(config[CONF_SENSOR])
|
||||
cg.add(var.set_sensor(sens))
|
||||
|
||||
if CONF_COOL_OUTPUT in config:
|
||||
out = yield cg.get_variable(config[CONF_COOL_OUTPUT])
|
||||
cg.add(var.set_cool_output(out))
|
||||
if CONF_HEAT_OUTPUT in config:
|
||||
out = yield cg.get_variable(config[CONF_HEAT_OUTPUT])
|
||||
cg.add(var.set_heat_output(out))
|
||||
params = config[CONF_CONTROL_PARAMETERS]
|
||||
cg.add(var.set_kp(params[CONF_KP]))
|
||||
cg.add(var.set_ki(params[CONF_KI]))
|
||||
cg.add(var.set_kd(params[CONF_KD]))
|
||||
if CONF_MIN_INTEGRAL in params:
|
||||
cg.add(var.set_min_integral(params[CONF_MIN_INTEGRAL]))
|
||||
if CONF_MAX_INTEGRAL in params:
|
||||
cg.add(var.set_max_integral(params[CONF_MAX_INTEGRAL]))
|
||||
|
||||
cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE]))
|
||||
|
||||
|
||||
@automation.register_action('climate.pid.autotune', PIDAutotuneAction, automation.maybe_simple_id({
|
||||
cv.Required(CONF_ID): cv.use_id(PIDClimate),
|
||||
cv.Optional(CONF_NOISEBAND, default=0.25): cv.float_,
|
||||
cv.Optional(CONF_POSITIVE_OUTPUT, default=1.0): cv.possibly_negative_percentage,
|
||||
cv.Optional(CONF_NEGATIVE_OUTPUT, default=-1.0): cv.possibly_negative_percentage,
|
||||
}))
|
||||
def esp8266_set_frequency_to_code(config, action_id, template_arg, args):
|
||||
paren = yield cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
cg.add(var.set_noiseband(config[CONF_NOISEBAND]))
|
||||
cg.add(var.set_positive_output(config[CONF_POSITIVE_OUTPUT]))
|
||||
cg.add(var.set_negative_output(config[CONF_NEGATIVE_OUTPUT]))
|
||||
yield var
|
358
esphome/components/pid/pid_autotuner.cpp
Normal file
358
esphome/components/pid/pid_autotuner.cpp
Normal file
|
@ -0,0 +1,358 @@
|
|||
#include "pid_autotuner.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
static const char *TAG = "pid.autotune";
|
||||
|
||||
/*
|
||||
* # PID Autotuner
|
||||
*
|
||||
* Autotuning of PID parameters is a very interesting topic. There has been
|
||||
* a lot of research over the years to create algorithms that can efficiently determine
|
||||
* suitable starting PID parameters.
|
||||
*
|
||||
* The most basic approach is the Ziegler-Nichols method, which can determine good PID parameters
|
||||
* in a manual process:
|
||||
* - Set ki, kd to zero.
|
||||
* - Increase kp until the output oscillates *around* the setpoint. This value kp is called the
|
||||
* "ultimate gain" K_u.
|
||||
* - Additionally, record the period of the observed oscillation as P_u (also called T_u).
|
||||
* - suitable PID parameters are then: kp=0.6*K_u, ki=1.2*K_u/P_u, kd=0.075*K_u*P_u (additional variants of
|
||||
* these "magic" factors exist as well [2]).
|
||||
*
|
||||
* Now we'd like to automate that process to get K_u and P_u without the user. So we'd like to somehow
|
||||
* make the observed variable oscillate. One observation is that in many applications of PID controllers
|
||||
* the observed variable has some amount of "delay" to the output value (think heating an object, it will
|
||||
* take a few seconds before the sensor can sense the change of temperature) [3].
|
||||
*
|
||||
* It turns out one way to induce such an oscillation is by using a really dumb heating controller:
|
||||
* When the observed value is below the setpoint, heat at 100%. If it's below, cool at 100% (or disable heating).
|
||||
* We call this the "RelayFunction" - the class is responsible for making the observed value oscillate around the
|
||||
* setpoint. We actually use a hysteresis filter (like the bang bang controller) to make the process immune to
|
||||
* noise in the input data, but the math is the same [1].
|
||||
*
|
||||
* Next, now that we have induced an oscillation, we want to measure the frequency (or period) of oscillation.
|
||||
* This is what "OscillationFrequencyDetector" is for: it records zerocrossing events (when the observed value
|
||||
* crosses the setpoint). From that data, we can determine the average oscillating period. This is the P_u of the
|
||||
* ZN-method.
|
||||
*
|
||||
* Finally, we need to determine K_u, the ultimate gain. It turns out we can calculate this based on the amplitude of
|
||||
* oscillation ("induced amplitude `a`) as described in [1]:
|
||||
* K_u = (4d) / (πa)
|
||||
* where d is the magnitude of the relay function (in range -d to +d).
|
||||
* To measure `a`, we look at the current phase the relay function is in - if it's in the "heating" phase, then we
|
||||
* expect the lowest temperature (=highest error) to be found in the phase because the peak will always happen slightly
|
||||
* after the relay function has changed state (assuming a delay-dominated process).
|
||||
*
|
||||
* Finally, we use some heuristics to determine if the data we've received so far is good:
|
||||
* - First, of course we must have enough data to calculate the values.
|
||||
* - The ZC events need to happen at a relatively periodic rate. If the heating/cooling speeds are very different,
|
||||
* I've observed the ZN parameters are not very useful.
|
||||
* - The induced amplitude should not deviate too much. If the amplitudes deviate too much this means there has
|
||||
* been some outside influence (or noise) on the system, and the measured amplitude values are not reliable.
|
||||
*
|
||||
* There are many ways this method can be improved, but on my simulation data the current method already produces very
|
||||
* good results. Some ideas for future improvements:
|
||||
* - Relay Function improvements:
|
||||
* - Integrator, Preload, Saturation Relay ([1])
|
||||
* - Use phase of measured signal relative to relay function.
|
||||
* - Apply PID parameters from ZN, but continuously tweak them in a second step.
|
||||
*
|
||||
* [1]: https://warwick.ac.uk/fac/cross_fac/iatl/reinvention/archive/volume5issue2/hornsey/
|
||||
* [2]: http://www.mstarlabs.com/control/znrule.html
|
||||
* [3]: https://www.academia.edu/38620114/SEBORG_3rd_Edition_Process_Dynamics_and_Control
|
||||
*/
|
||||
|
||||
PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float process_variable) {
|
||||
PIDAutotuner::PIDAutotuneResult res;
|
||||
if (this->state_ == AUTOTUNE_SUCCEEDED) {
|
||||
res.result_params = this->get_ziegler_nichols_pid_();
|
||||
return res;
|
||||
}
|
||||
|
||||
if (!isnan(this->setpoint_) && this->setpoint_ != setpoint) {
|
||||
ESP_LOGW(TAG, "Setpoint changed during autotune! The result will not be accurate!");
|
||||
}
|
||||
this->setpoint_ = setpoint;
|
||||
|
||||
float error = setpoint - process_variable;
|
||||
const uint32_t now = millis();
|
||||
|
||||
float output = this->relay_function_.update(error);
|
||||
this->frequency_detector_.update(now, error);
|
||||
this->amplitude_detector_.update(error, this->relay_function_.state);
|
||||
res.output = output;
|
||||
|
||||
if (!this->frequency_detector_.has_enough_data() || !this->amplitude_detector_.has_enough_data()) {
|
||||
// not enough data for calculation yet
|
||||
ESP_LOGV(TAG, " Not enough data yet for aututuner");
|
||||
return res;
|
||||
}
|
||||
|
||||
bool zc_symmetrical = this->frequency_detector_.is_increase_decrease_symmetrical();
|
||||
bool amplitude_convergent = this->frequency_detector_.is_increase_decrease_symmetrical();
|
||||
if (!zc_symmetrical || !amplitude_convergent) {
|
||||
// The frequency/amplitude is not fully accurate yet, try to wait
|
||||
// until the fault clears, or terminate after a while anyway
|
||||
if (zc_symmetrical) {
|
||||
ESP_LOGVV(TAG, " ZC is not symmetrical");
|
||||
}
|
||||
if (amplitude_convergent) {
|
||||
ESP_LOGVV(TAG, " Amplitude is not convergent");
|
||||
}
|
||||
uint32_t phase = this->relay_function_.phase_count;
|
||||
ESP_LOGVV(TAG, " Phase %u, enough=%u", phase, enough_data_phase_);
|
||||
|
||||
if (this->enough_data_phase_ == 0) {
|
||||
this->enough_data_phase_ = phase;
|
||||
} else if (phase - this->enough_data_phase_ <= 6) {
|
||||
// keep trying for at least 6 more phases
|
||||
return res;
|
||||
} else {
|
||||
// proceed to calculating PID parameters
|
||||
// warning will be shown in "Checks" section
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "PID Autotune finished!");
|
||||
|
||||
float osc_ampl = this->amplitude_detector_.get_mean_oscillation_amplitude();
|
||||
float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f;
|
||||
ESP_LOGVV(TAG, " Relay magnitude: %f", d);
|
||||
this->ku_ = 4.0f * d / float(M_PI * osc_ampl);
|
||||
this->pu_ = this->frequency_detector_.get_mean_oscillation_period();
|
||||
|
||||
this->state_ = AUTOTUNE_SUCCEEDED;
|
||||
res.result_params = this->get_ziegler_nichols_pid_();
|
||||
this->dump_config();
|
||||
|
||||
return res;
|
||||
}
|
||||
void PIDAutotuner::dump_config() {
|
||||
ESP_LOGI(TAG, "PID Autotune:");
|
||||
if (this->state_ == AUTOTUNE_SUCCEEDED) {
|
||||
ESP_LOGI(TAG, " State: Succeeded!");
|
||||
bool has_issue = false;
|
||||
if (!this->amplitude_detector_.is_amplitude_convergent()) {
|
||||
ESP_LOGW(TAG, " Could not reliable determine oscillation amplitude, PID parameters may be inaccurate!");
|
||||
ESP_LOGW(TAG, " Please make sure you eliminate all outside influences on the measured temperature.");
|
||||
has_issue = true;
|
||||
}
|
||||
if (!this->frequency_detector_.is_increase_decrease_symmetrical()) {
|
||||
ESP_LOGW(TAG, " Oscillation Frequency is not symmetrical. PID parameters may be inaccurate!");
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
" This is usually because the heat and cool processes do not change the temperature at the same rate.");
|
||||
ESP_LOGW(TAG,
|
||||
" Please try reducing the positive_output value (or increase negative_output in case of a cooler)");
|
||||
has_issue = true;
|
||||
}
|
||||
if (!has_issue) {
|
||||
ESP_LOGI(TAG, " All checks passed!");
|
||||
}
|
||||
|
||||
auto fac = get_ziegler_nichols_pid_();
|
||||
ESP_LOGI(TAG, " Calculated PID parameters (\"Ziegler-Nichols PID\" rule):");
|
||||
ESP_LOGI(TAG, " ");
|
||||
ESP_LOGI(TAG, " control_parameters:");
|
||||
ESP_LOGI(TAG, " kp: %.5f", fac.kp);
|
||||
ESP_LOGI(TAG, " ki: %.5f", fac.ki);
|
||||
ESP_LOGI(TAG, " kd: %.5f", fac.kd);
|
||||
ESP_LOGI(TAG, " ");
|
||||
ESP_LOGI(TAG, " Please copy these values into your YAML configuration! They will reset on the next reboot.");
|
||||
|
||||
ESP_LOGV(TAG, " Oscillation Period: %f", this->frequency_detector_.get_mean_oscillation_period());
|
||||
ESP_LOGV(TAG, " Oscillation Amplitude: %f", this->amplitude_detector_.get_mean_oscillation_amplitude());
|
||||
ESP_LOGV(TAG, " Ku: %f, Pu: %f", this->ku_, this->pu_);
|
||||
|
||||
ESP_LOGD(TAG, " Alternative Rules:");
|
||||
// http://www.mstarlabs.com/control/znrule.html
|
||||
print_rule_("Ziegler-Nichols PI", 0.45f, 0.54f, 0.0f);
|
||||
print_rule_("Pessen Integral PID", 0.7f, 1.75f, 0.105f);
|
||||
print_rule_("Some Overshoot PID", 0.333f, 0.667f, 0.111f);
|
||||
print_rule_("No Overshoot PID", 0.2f, 0.4f, 0.0625f);
|
||||
}
|
||||
|
||||
if (this->state_ == AUTOTUNE_RUNNING) {
|
||||
ESP_LOGI(TAG, " Autotune is still running!");
|
||||
ESP_LOGD(TAG, " Status: Trying to reach %.2f °C", setpoint_ - relay_function_.current_target_error());
|
||||
ESP_LOGD(TAG, " Stats so far:");
|
||||
ESP_LOGD(TAG, " Phases: %u", relay_function_.phase_count);
|
||||
ESP_LOGD(TAG, " Detected %u zero-crossings", frequency_detector_.zerocrossing_intervals.size()); // NOLINT
|
||||
ESP_LOGD(TAG, " Current Phase Min: %.2f, Max: %.2f", amplitude_detector_.phase_min,
|
||||
amplitude_detector_.phase_max);
|
||||
}
|
||||
}
|
||||
PIDAutotuner::PIDResult PIDAutotuner::calculate_pid_(float kp_factor, float ki_factor, float kd_factor) {
|
||||
float kp = kp_factor * ku_;
|
||||
float ki = ki_factor * ku_ / pu_;
|
||||
float kd = kd_factor * ku_ * pu_;
|
||||
return {
|
||||
.kp = kp,
|
||||
.ki = ki,
|
||||
.kd = kd,
|
||||
};
|
||||
}
|
||||
void PIDAutotuner::print_rule_(const char *name, float kp_factor, float ki_factor, float kd_factor) {
|
||||
auto fac = calculate_pid_(kp_factor, ki_factor, kd_factor);
|
||||
ESP_LOGD(TAG, " Rule '%s':", name);
|
||||
ESP_LOGD(TAG, " kp: %.5f, ki: %.5f, kd: %.5f", fac.kp, fac.ki, fac.kd);
|
||||
}
|
||||
|
||||
// ================== RelayFunction ==================
|
||||
float PIDAutotuner::RelayFunction::update(float error) {
|
||||
if (this->state == RELAY_FUNCTION_INIT) {
|
||||
bool pos = error > this->noiseband;
|
||||
state = pos ? RELAY_FUNCTION_POSITIVE : RELAY_FUNCTION_NEGATIVE;
|
||||
}
|
||||
bool change = false;
|
||||
if (this->state == RELAY_FUNCTION_POSITIVE && error < -this->noiseband) {
|
||||
// Positive hysteresis reached, change direction
|
||||
this->state = RELAY_FUNCTION_NEGATIVE;
|
||||
change = true;
|
||||
} else if (this->state == RELAY_FUNCTION_NEGATIVE && error > this->noiseband) {
|
||||
// Negative hysteresis reached, change direction
|
||||
this->state = RELAY_FUNCTION_POSITIVE;
|
||||
change = true;
|
||||
}
|
||||
|
||||
float output = state == RELAY_FUNCTION_POSITIVE ? output_positive : output_negative;
|
||||
if (change) {
|
||||
this->phase_count++;
|
||||
ESP_LOGV(TAG, "Autotune: Turning output to %.1f%%", output * 100);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ================== OscillationFrequencyDetector ==================
|
||||
void PIDAutotuner::OscillationFrequencyDetector::update(uint32_t now, float error) {
|
||||
if (this->state == FREQUENCY_DETECTOR_INIT) {
|
||||
bool pos = error > this->noiseband;
|
||||
state = pos ? FREQUENCY_DETECTOR_POSITIVE : FREQUENCY_DETECTOR_NEGATIVE;
|
||||
}
|
||||
|
||||
bool had_crossing = false;
|
||||
if (this->state == FREQUENCY_DETECTOR_POSITIVE && error < -this->noiseband) {
|
||||
this->state = FREQUENCY_DETECTOR_NEGATIVE;
|
||||
had_crossing = true;
|
||||
} else if (this->state == FREQUENCY_DETECTOR_NEGATIVE && error > this->noiseband) {
|
||||
this->state = FREQUENCY_DETECTOR_POSITIVE;
|
||||
had_crossing = true;
|
||||
}
|
||||
|
||||
if (had_crossing) {
|
||||
// Had crossing above hysteresis threshold, record
|
||||
ESP_LOGV(TAG, "Autotune: Detected Zero-Cross at %u", now);
|
||||
if (this->last_zerocross != 0) {
|
||||
uint32_t dt = now - this->last_zerocross;
|
||||
ESP_LOGV(TAG, " dt: %u", dt);
|
||||
this->zerocrossing_intervals.push_back(dt);
|
||||
}
|
||||
this->last_zerocross = now;
|
||||
}
|
||||
}
|
||||
bool PIDAutotuner::OscillationFrequencyDetector::has_enough_data() const {
|
||||
// Do we have enough data in this detector to generate PID values?
|
||||
return this->zerocrossing_intervals.size() >= 2;
|
||||
}
|
||||
float PIDAutotuner::OscillationFrequencyDetector::get_mean_oscillation_period() const {
|
||||
// Get the mean oscillation period in seconds
|
||||
// Only call if has_enough_data() has returned true.
|
||||
float sum = 0.0f;
|
||||
for (uint32_t v : this->zerocrossing_intervals)
|
||||
sum += v;
|
||||
// zerocrossings are each half-period, multiply by 2
|
||||
float mean_value = sum / this->zerocrossing_intervals.size();
|
||||
// divide by 1000 to get seconds, multiply by two because zc happens two times per period
|
||||
float mean_period = mean_value / 1000 * 2;
|
||||
return mean_period;
|
||||
}
|
||||
bool PIDAutotuner::OscillationFrequencyDetector::is_increase_decrease_symmetrical() const {
|
||||
// Check if increase/decrease of process value was symmetrical
|
||||
// If the process value increases much faster than it decreases, the generated PID values will
|
||||
// not be very good and the function output values need to be adjusted
|
||||
// Happens for example with a well-insulated heating element.
|
||||
// We calculate this based on the zerocrossing interval.
|
||||
if (zerocrossing_intervals.empty())
|
||||
return false;
|
||||
uint32_t max_interval = zerocrossing_intervals[0];
|
||||
uint32_t min_interval = zerocrossing_intervals[0];
|
||||
for (uint32_t interval : zerocrossing_intervals) {
|
||||
max_interval = std::max(max_interval, interval);
|
||||
min_interval = std::min(min_interval, interval);
|
||||
}
|
||||
float ratio = min_interval / float(max_interval);
|
||||
return ratio >= 0.66;
|
||||
}
|
||||
|
||||
// ================== OscillationAmplitudeDetector ==================
|
||||
void PIDAutotuner::OscillationAmplitudeDetector::update(float error,
|
||||
PIDAutotuner::RelayFunction::RelayFunctionState relay_state) {
|
||||
if (relay_state != last_relay_state) {
|
||||
if (last_relay_state == RelayFunction::RELAY_FUNCTION_POSITIVE) {
|
||||
// Transitioned from positive error to negative error.
|
||||
// The positive error peak must have been in previous segment (180° shifted)
|
||||
// record phase_max
|
||||
this->phase_maxs.push_back(phase_max);
|
||||
ESP_LOGV(TAG, "Autotune: Phase Max: %f", phase_max);
|
||||
} else if (last_relay_state == RelayFunction::RELAY_FUNCTION_NEGATIVE) {
|
||||
// Transitioned from negative error to positive error.
|
||||
// The negative error peak must have been in previous segment (180° shifted)
|
||||
// record phase_min
|
||||
this->phase_mins.push_back(phase_min);
|
||||
ESP_LOGV(TAG, "Autotune: Phase Min: %f", phase_min);
|
||||
}
|
||||
// reset phase values for next phase
|
||||
this->phase_min = error;
|
||||
this->phase_max = error;
|
||||
}
|
||||
this->last_relay_state = relay_state;
|
||||
|
||||
this->phase_min = std::min(this->phase_min, error);
|
||||
this->phase_max = std::max(this->phase_max, error);
|
||||
|
||||
// Check arrays sizes, we keep at most 7 items (6 datapoints is enough, and data at beginning might not
|
||||
// have been stabilized)
|
||||
if (this->phase_maxs.size() > 7)
|
||||
this->phase_maxs.erase(this->phase_maxs.begin());
|
||||
if (this->phase_mins.size() > 7)
|
||||
this->phase_mins.erase(this->phase_mins.begin());
|
||||
}
|
||||
bool PIDAutotuner::OscillationAmplitudeDetector::has_enough_data() const {
|
||||
// Return if we have enough data to generate PID parameters
|
||||
// The first phase is not very useful if the setpoint is not set to the starting process value
|
||||
// So discard first phase. Otherwise we need at least two phases.
|
||||
return std::min(phase_mins.size(), phase_maxs.size()) >= 3;
|
||||
}
|
||||
float PIDAutotuner::OscillationAmplitudeDetector::get_mean_oscillation_amplitude() const {
|
||||
float total_amplitudes = 0;
|
||||
size_t total_amplitudes_n = 0;
|
||||
for (int i = 1; i < std::min(phase_mins.size(), phase_maxs.size()) - 1; i++) {
|
||||
total_amplitudes += std::abs(phase_maxs[i] - phase_mins[i + 1]);
|
||||
total_amplitudes_n++;
|
||||
}
|
||||
float mean_amplitude = total_amplitudes / total_amplitudes_n;
|
||||
// Amplitude is measured from center, divide by 2
|
||||
return mean_amplitude / 2.0f;
|
||||
}
|
||||
bool PIDAutotuner::OscillationAmplitudeDetector::is_amplitude_convergent() const {
|
||||
// Check if oscillation amplitude is convergent
|
||||
// We implement this by checking global extrema against average amplitude
|
||||
if (this->phase_mins.empty() || this->phase_maxs.empty())
|
||||
return false;
|
||||
|
||||
float global_max = phase_maxs[0], global_min = phase_mins[0];
|
||||
for (auto v : this->phase_mins)
|
||||
global_min = std::min(global_min, v);
|
||||
for (auto v : this->phase_maxs)
|
||||
global_max = std::min(global_max, v);
|
||||
float global_amplitude = (global_max - global_min) / 2.0f;
|
||||
float mean_amplitude = this->get_mean_oscillation_amplitude();
|
||||
return (mean_amplitude - global_amplitude) / (global_amplitude) < 0.05f;
|
||||
}
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
110
esphome/components/pid/pid_autotuner.h
Normal file
110
esphome/components/pid/pid_autotuner.h
Normal file
|
@ -0,0 +1,110 @@
|
|||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/optional.h"
|
||||
#include "pid_controller.h"
|
||||
#include "pid_simulator.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
class PIDAutotuner {
|
||||
public:
|
||||
struct PIDResult {
|
||||
float kp;
|
||||
float ki;
|
||||
float kd;
|
||||
};
|
||||
struct PIDAutotuneResult {
|
||||
float output;
|
||||
optional<PIDResult> result_params;
|
||||
};
|
||||
|
||||
void config(float output_min, float output_max) {
|
||||
relay_function_.output_negative = std::max(relay_function_.output_negative, output_min);
|
||||
relay_function_.output_positive = std::min(relay_function_.output_positive, output_max);
|
||||
}
|
||||
PIDAutotuneResult update(float setpoint, float process_variable);
|
||||
bool is_finished() const { return state_ != AUTOTUNE_RUNNING; }
|
||||
|
||||
void dump_config();
|
||||
|
||||
void set_noiseband(float noiseband) {
|
||||
relay_function_.noiseband = noiseband;
|
||||
// ZC detector uses 1/4 the noiseband of relay function (noise suppression)
|
||||
frequency_detector_.noiseband = noiseband / 4;
|
||||
}
|
||||
void set_output_positive(float output_positive) { relay_function_.output_positive = output_positive; }
|
||||
void set_output_negative(float output_negative) { relay_function_.output_negative = output_negative; }
|
||||
|
||||
protected:
|
||||
struct RelayFunction {
|
||||
float update(float error);
|
||||
|
||||
float current_target_error() const {
|
||||
if (state == RELAY_FUNCTION_INIT)
|
||||
return 0;
|
||||
if (state == RELAY_FUNCTION_POSITIVE)
|
||||
return -noiseband;
|
||||
return noiseband;
|
||||
}
|
||||
|
||||
enum RelayFunctionState {
|
||||
RELAY_FUNCTION_INIT,
|
||||
RELAY_FUNCTION_POSITIVE,
|
||||
RELAY_FUNCTION_NEGATIVE,
|
||||
} state = RELAY_FUNCTION_INIT;
|
||||
float noiseband = 0.5;
|
||||
float output_positive = 1;
|
||||
float output_negative = -1;
|
||||
uint32_t phase_count = 0;
|
||||
} relay_function_;
|
||||
struct OscillationFrequencyDetector {
|
||||
void update(uint32_t now, float error);
|
||||
|
||||
bool has_enough_data() const;
|
||||
|
||||
float get_mean_oscillation_period() const;
|
||||
|
||||
bool is_increase_decrease_symmetrical() const;
|
||||
|
||||
enum FrequencyDetectorState {
|
||||
FREQUENCY_DETECTOR_INIT,
|
||||
FREQUENCY_DETECTOR_POSITIVE,
|
||||
FREQUENCY_DETECTOR_NEGATIVE,
|
||||
} state;
|
||||
float noiseband = 0.05;
|
||||
uint32_t last_zerocross{0};
|
||||
std::vector<uint32_t> zerocrossing_intervals;
|
||||
} frequency_detector_;
|
||||
struct OscillationAmplitudeDetector {
|
||||
void update(float error, RelayFunction::RelayFunctionState relay_state);
|
||||
|
||||
bool has_enough_data() const;
|
||||
|
||||
float get_mean_oscillation_amplitude() const;
|
||||
|
||||
bool is_amplitude_convergent() const;
|
||||
|
||||
float phase_min = NAN;
|
||||
float phase_max = NAN;
|
||||
std::vector<float> phase_mins;
|
||||
std::vector<float> phase_maxs;
|
||||
RelayFunction::RelayFunctionState last_relay_state = RelayFunction::RELAY_FUNCTION_INIT;
|
||||
} amplitude_detector_;
|
||||
PIDResult calculate_pid_(float kp_factor, float ki_factor, float kd_factor);
|
||||
void print_rule_(const char *name, float kp_factor, float ki_factor, float kd_factor);
|
||||
PIDResult get_ziegler_nichols_pid_() { return calculate_pid_(0.6f, 1.2f, 0.075f); }
|
||||
|
||||
uint32_t enough_data_phase_ = 0;
|
||||
float setpoint_ = NAN;
|
||||
enum State {
|
||||
AUTOTUNE_RUNNING,
|
||||
AUTOTUNE_SUCCEEDED,
|
||||
} state_ = AUTOTUNE_RUNNING;
|
||||
float ku_;
|
||||
float pu_;
|
||||
};
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
152
esphome/components/pid/pid_climate.cpp
Normal file
152
esphome/components/pid/pid_climate.cpp
Normal file
|
@ -0,0 +1,152 @@
|
|||
#include "pid_climate.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
static const char *TAG = "pid.climate";
|
||||
|
||||
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
|
||||
this->do_publish_ = roundf(state * 100) != roundf(this->current_temperature * 100);
|
||||
this->current_temperature = state;
|
||||
this->update_pid_();
|
||||
});
|
||||
this->current_temperature = this->sensor_->state;
|
||||
// restore set points
|
||||
auto restore = this->restore_state_();
|
||||
if (restore.has_value()) {
|
||||
restore->to_call(this).perform();
|
||||
} else {
|
||||
// restore from defaults, change_away handles those for us
|
||||
this->mode = climate::CLIMATE_MODE_AUTO;
|
||||
this->target_temperature = this->default_target_temperature_;
|
||||
}
|
||||
}
|
||||
void PIDClimate::control(const climate::ClimateCall &call) {
|
||||
if (call.get_mode().has_value())
|
||||
this->mode = *call.get_mode();
|
||||
if (call.get_target_temperature().has_value())
|
||||
this->target_temperature = *call.get_target_temperature();
|
||||
|
||||
// If switching to non-auto mode, set output immediately
|
||||
if (this->mode != climate::CLIMATE_MODE_AUTO)
|
||||
this->handle_non_auto_mode_();
|
||||
|
||||
this->publish_state();
|
||||
}
|
||||
climate::ClimateTraits PIDClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_supports_auto_mode(true);
|
||||
traits.set_supports_two_point_target_temperature(false);
|
||||
traits.set_supports_cool_mode(this->supports_cool_());
|
||||
traits.set_supports_heat_mode(this->supports_heat_());
|
||||
traits.set_supports_action(true);
|
||||
return traits;
|
||||
}
|
||||
void PIDClimate::dump_config() {
|
||||
LOG_CLIMATE("", "PID Climate", this);
|
||||
ESP_LOGCONFIG(TAG, " Control Parameters:");
|
||||
ESP_LOGCONFIG(TAG, " kp: %.5f, ki: %.5f, kd: %.5f", controller_.kp, controller_.ki, controller_.kd);
|
||||
|
||||
if (this->autotuner_ != nullptr) {
|
||||
this->autotuner_->dump_config();
|
||||
}
|
||||
}
|
||||
void PIDClimate::write_output_(float value) {
|
||||
this->output_value_ = value;
|
||||
|
||||
// first ensure outputs are off (both outputs not active at the same time)
|
||||
if (this->supports_cool_() && value >= 0)
|
||||
this->cool_output_->set_level(0.0f);
|
||||
if (this->supports_heat_() && value <= 0)
|
||||
this->heat_output_->set_level(0.0f);
|
||||
|
||||
// value < 0 means cool, > 0 means heat
|
||||
if (this->supports_cool_() && value < 0)
|
||||
this->cool_output_->set_level(std::min(1.0f, -value));
|
||||
if (this->supports_heat_() && value > 0)
|
||||
this->heat_output_->set_level(std::min(1.0f, value));
|
||||
|
||||
// Update action variable for user feedback what's happening
|
||||
climate::ClimateAction new_action;
|
||||
if (this->supports_cool_() && value < 0)
|
||||
new_action = climate::CLIMATE_ACTION_COOLING;
|
||||
else if (this->supports_heat_() && value > 0)
|
||||
new_action = climate::CLIMATE_ACTION_HEATING;
|
||||
else if (this->mode == climate::CLIMATE_MODE_OFF)
|
||||
new_action = climate::CLIMATE_ACTION_OFF;
|
||||
else
|
||||
new_action = climate::CLIMATE_ACTION_IDLE;
|
||||
|
||||
if (new_action != this->action) {
|
||||
this->action = new_action;
|
||||
this->do_publish_ = true;
|
||||
}
|
||||
this->pid_computed_callback_.call();
|
||||
}
|
||||
void PIDClimate::handle_non_auto_mode_() {
|
||||
// in non-auto mode, switch directly to appropriate action
|
||||
// - HEAT mode / COOL mode -> Output at ±100%
|
||||
// - OFF mode -> Output at 0%
|
||||
if (this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
this->write_output_(1.0);
|
||||
} else if (this->mode == climate::CLIMATE_MODE_COOL) {
|
||||
this->write_output_(-1.0);
|
||||
} else if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
this->write_output_(0.0);
|
||||
} else {
|
||||
assert(false);
|
||||
}
|
||||
}
|
||||
void PIDClimate::update_pid_() {
|
||||
float value;
|
||||
if (isnan(this->current_temperature) || isnan(this->target_temperature)) {
|
||||
// if any control parameters are nan, turn off all outputs
|
||||
value = 0.0;
|
||||
} else {
|
||||
// Update PID controller irrespective of current mode, to not mess up D/I terms
|
||||
// In non-auto mode, we just discard the output value
|
||||
value = this->controller_.update(this->target_temperature, this->current_temperature);
|
||||
|
||||
// Check autotuner
|
||||
if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) {
|
||||
auto res = this->autotuner_->update(this->target_temperature, this->current_temperature);
|
||||
if (res.result_params.has_value()) {
|
||||
this->controller_.kp = res.result_params->kp;
|
||||
this->controller_.ki = res.result_params->ki;
|
||||
this->controller_.kd = res.result_params->kd;
|
||||
// keep autotuner instance so that subsequent dump_configs will print the long result message.
|
||||
} else {
|
||||
value = res.output;
|
||||
if (mode != climate::CLIMATE_MODE_AUTO) {
|
||||
ESP_LOGW(TAG, "For PID autotuner you need to set AUTO (also called heat/cool) mode!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this->mode != climate::CLIMATE_MODE_AUTO) {
|
||||
this->handle_non_auto_mode_();
|
||||
} else {
|
||||
this->write_output_(value);
|
||||
}
|
||||
|
||||
if (this->do_publish_)
|
||||
this->publish_state();
|
||||
}
|
||||
void PIDClimate::start_autotune(std::unique_ptr<PIDAutotuner> &&autotune) {
|
||||
this->autotuner_ = std::move(autotune);
|
||||
float min_value = this->supports_cool_() ? -1.0f : 0.0f;
|
||||
float max_value = this->supports_heat_() ? 1.0f : 0.0f;
|
||||
this->autotuner_->config(min_value, max_value);
|
||||
this->set_interval("autotune-progress", 10000, [this]() {
|
||||
if (this->autotuner_ != nullptr && !this->autotuner_->is_finished())
|
||||
this->autotuner_->dump_config();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
94
esphome/components/pid/pid_climate.h
Normal file
94
esphome/components/pid/pid_climate.h
Normal file
|
@ -0,0 +1,94 @@
|
|||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include "pid_controller.h"
|
||||
#include "pid_autotuner.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
class PIDClimate : public climate::Climate, public Component {
|
||||
public:
|
||||
PIDClimate() = default;
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_sensor(sensor::Sensor *sensor) { sensor_ = sensor; }
|
||||
void set_cool_output(output::FloatOutput *cool_output) { cool_output_ = cool_output; }
|
||||
void set_heat_output(output::FloatOutput *heat_output) { heat_output_ = heat_output; }
|
||||
void set_kp(float kp) { controller_.kp = kp; }
|
||||
void set_ki(float ki) { controller_.ki = ki; }
|
||||
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; }
|
||||
|
||||
float get_output_value() const { return output_value_; }
|
||||
float get_error_value() const { return controller_.error; }
|
||||
float get_proportional_term() const { return controller_.proportional_term; }
|
||||
float get_integral_term() const { return controller_.integral_term; }
|
||||
float get_derivative_term() const { return controller_.derivative_term; }
|
||||
void add_on_pid_computed_callback(std::function<void()> &&callback) {
|
||||
pid_computed_callback_.add(std::move(callback));
|
||||
}
|
||||
void set_default_target_temperature(float default_target_temperature) {
|
||||
default_target_temperature_ = default_target_temperature;
|
||||
}
|
||||
void start_autotune(std::unique_ptr<PIDAutotuner> &&autotune);
|
||||
|
||||
protected:
|
||||
/// Override control to change settings of the climate device.
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
/// Return the traits of this controller.
|
||||
climate::ClimateTraits traits() override;
|
||||
|
||||
void update_pid_();
|
||||
|
||||
bool supports_cool_() const { return this->cool_output_ != nullptr; }
|
||||
bool supports_heat_() const { return this->heat_output_ != nullptr; }
|
||||
|
||||
void write_output_(float value);
|
||||
void handle_non_auto_mode_();
|
||||
|
||||
/// The sensor used for getting the current temperature
|
||||
sensor::Sensor *sensor_;
|
||||
output::FloatOutput *cool_output_ = nullptr;
|
||||
output::FloatOutput *heat_output_ = nullptr;
|
||||
PIDController controller_;
|
||||
/// Output value as reported by the PID controller, for PIDClimateSensor
|
||||
float output_value_;
|
||||
CallbackManager<void()> pid_computed_callback_;
|
||||
float default_target_temperature_;
|
||||
std::unique_ptr<PIDAutotuner> autotuner_;
|
||||
bool do_publish_ = false;
|
||||
};
|
||||
|
||||
template<typename... Ts> class PIDAutotuneAction : public Action<Ts...> {
|
||||
public:
|
||||
PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {}
|
||||
|
||||
void play(Ts... x) {
|
||||
auto tuner = make_unique<PIDAutotuner>();
|
||||
tuner->set_noiseband(this->noiseband_);
|
||||
tuner->set_output_negative(this->negative_output_);
|
||||
tuner->set_output_positive(this->positive_output_);
|
||||
this->parent_->start_autotune(std::move(tuner));
|
||||
}
|
||||
|
||||
void set_noiseband(float noiseband) { noiseband_ = noiseband; }
|
||||
void set_positive_output(float positive_output) { positive_output_ = positive_output; }
|
||||
void set_negative_output(float negative_output) { negative_output_ = negative_output; }
|
||||
|
||||
protected:
|
||||
float noiseband_;
|
||||
float positive_output_;
|
||||
float negative_output_;
|
||||
PIDClimate *parent_;
|
||||
};
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
79
esphome/components/pid/pid_controller.h
Normal file
79
esphome/components/pid/pid_controller.h
Normal file
|
@ -0,0 +1,79 @@
|
|||
#pragma once
|
||||
|
||||
#include "esphome/core/esphal.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
struct PIDController {
|
||||
float update(float setpoint, float process_value) {
|
||||
// e(t) ... error at timestamp t
|
||||
// r(t) ... setpoint
|
||||
// y(t) ... process value (sensor reading)
|
||||
// u(t) ... output value
|
||||
|
||||
float dt = calculate_relative_time_();
|
||||
|
||||
// e(t) := r(t) - y(t)
|
||||
error = setpoint - process_value;
|
||||
|
||||
// p(t) := K_p * e(t)
|
||||
proportional_term = kp * error;
|
||||
|
||||
// i(t) := K_i * \int_{0}^{t} e(t) dt
|
||||
accumulated_integral_ += error * dt * ki;
|
||||
// constrain accumulated integral value
|
||||
if (!isnan(min_integral) && accumulated_integral_ < min_integral)
|
||||
accumulated_integral_ = min_integral;
|
||||
if (!isnan(max_integral) && accumulated_integral_ > max_integral)
|
||||
accumulated_integral_ = max_integral;
|
||||
integral_term = accumulated_integral_;
|
||||
|
||||
// d(t) := K_d * de(t)/dt
|
||||
float derivative = 0.0f;
|
||||
if (dt != 0.0f)
|
||||
derivative = (error - previous_error_) / dt;
|
||||
previous_error_ = error;
|
||||
derivative_term = kd * derivative;
|
||||
|
||||
// u(t) := p(t) + i(t) + d(t)
|
||||
return proportional_term + integral_term + derivative_term;
|
||||
}
|
||||
|
||||
/// Proportional gain K_p.
|
||||
float kp = 0;
|
||||
/// Integral gain K_i.
|
||||
float ki = 0;
|
||||
/// Differential gain K_d.
|
||||
float kd = 0;
|
||||
|
||||
float min_integral = NAN;
|
||||
float max_integral = NAN;
|
||||
|
||||
// Store computed values in struct so that values can be monitored through sensors
|
||||
float error;
|
||||
float proportional_term;
|
||||
float integral_term;
|
||||
float derivative_term;
|
||||
|
||||
protected:
|
||||
float calculate_relative_time_() {
|
||||
uint32_t now = millis();
|
||||
uint32_t dt = now - this->last_time_;
|
||||
if (last_time_ == 0) {
|
||||
last_time_ = now;
|
||||
return 0.0f;
|
||||
}
|
||||
last_time_ = now;
|
||||
return dt / 1000.0f;
|
||||
}
|
||||
|
||||
/// Error from previous update used for derivative term
|
||||
float previous_error_ = 0;
|
||||
/// Accumulated integral value
|
||||
float accumulated_integral_ = 0;
|
||||
uint32_t last_time_ = 0;
|
||||
};
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
75
esphome/components/pid/pid_simulator.h
Normal file
75
esphome/components/pid/pid_simulator.h
Normal file
|
@ -0,0 +1,75 @@
|
|||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
class PIDSimulator : public PollingComponent, public output::FloatOutput {
|
||||
public:
|
||||
PIDSimulator() : PollingComponent(1000) {}
|
||||
|
||||
float surface = 1; /// surface area in m²
|
||||
float mass = 3; /// mass of simulated object in kg
|
||||
float temperature = 21; /// current temperature of object in °C
|
||||
float efficiency = 0.98; /// heating efficiency, 1 is 100% efficient
|
||||
float thermal_conductivity = 15; /// thermal conductivity of surface are in W/(m*K), here: steel
|
||||
float specific_heat_capacity = 4.182; /// specific heat capacity of mass in kJ/(kg*K), here: water
|
||||
float heat_power = 500; /// Heating power in W
|
||||
float ambient_temperature = 20; /// Ambient temperature in °C
|
||||
float update_interval = 1; /// The simulated updated interval in seconds
|
||||
std::vector<float> delayed_temps; /// storage of past temperatures for delaying temperature reading
|
||||
size_t delay_cycles = 15; /// how many update cycles to delay the output
|
||||
float output_value = 0.0; /// Current output value of heating element
|
||||
sensor::Sensor *sensor = new sensor::Sensor();
|
||||
|
||||
float delta_t(float power) {
|
||||
// P = Q / t
|
||||
// Q = c * m * 𝚫t
|
||||
// 𝚫t = (P*t) / (c*m)
|
||||
float c = this->specific_heat_capacity;
|
||||
float t = this->update_interval;
|
||||
float p = power / 1000; // in kW
|
||||
float m = this->mass;
|
||||
return (p * t) / (c * m);
|
||||
}
|
||||
|
||||
float update_temp() {
|
||||
float value = clamp(output_value, 0.0f, 1.0f);
|
||||
|
||||
// Heat
|
||||
float power = value * heat_power * efficiency;
|
||||
temperature += this->delta_t(power);
|
||||
|
||||
// Cool
|
||||
// Q = k_w * A * (T_mass - T_ambient)
|
||||
// P = Q / t
|
||||
float dt = temperature - ambient_temperature;
|
||||
float cool_power = (thermal_conductivity * surface * dt) / update_interval;
|
||||
temperature -= this->delta_t(cool_power);
|
||||
|
||||
// Delay temperature readings
|
||||
delayed_temps.push_back(temperature);
|
||||
if (delayed_temps.size() > delay_cycles)
|
||||
delayed_temps.erase(delayed_temps.begin());
|
||||
float prev_temp = this->delayed_temps[0];
|
||||
float alpha = 0.1f;
|
||||
float ret = (1 - alpha) * prev_temp + alpha * prev_temp;
|
||||
return ret;
|
||||
}
|
||||
|
||||
void setup() override { sensor->publish_state(this->temperature); }
|
||||
void update() override {
|
||||
float new_temp = this->update_temp();
|
||||
sensor->publish_state(new_temp);
|
||||
}
|
||||
|
||||
protected:
|
||||
void write_state(float state) override { this->output_value = state; }
|
||||
};
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
36
esphome/components/pid/sensor/__init__.py
Normal file
36
esphome/components/pid/sensor/__init__.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.components import sensor
|
||||
from esphome.const import CONF_ID, UNIT_PERCENT, ICON_GAUGE, CONF_TYPE
|
||||
from ..climate import pid_ns, PIDClimate
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
CONF_CLIMATE_ID = 'climate_id'
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(UNIT_PERCENT, ICON_GAUGE, 1).extend({
|
||||
cv.GenerateID(): cv.declare_id(PIDClimateSensor),
|
||||
cv.GenerateID(CONF_CLIMATE_ID): cv.use_id(PIDClimate),
|
||||
|
||||
cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_TYPES, upper=True),
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def to_code(config):
|
||||
parent = yield cg.get_variable(config[CONF_CLIMATE_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
yield sensor.register_sensor(var, config)
|
||||
yield cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_parent(parent))
|
||||
cg.add(var.set_type(config[CONF_TYPE]))
|
47
esphome/components/pid/sensor/pid_climate_sensor.cpp
Normal file
47
esphome/components/pid/sensor/pid_climate_sensor.cpp
Normal file
|
@ -0,0 +1,47 @@
|
|||
#include "pid_climate_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
static const char *TAG = "pid.sensor";
|
||||
|
||||
void PIDClimateSensor::setup() {
|
||||
this->parent_->add_on_pid_computed_callback([this]() { this->update_from_parent_(); });
|
||||
this->update_from_parent_();
|
||||
}
|
||||
void PIDClimateSensor::update_from_parent_() {
|
||||
float value;
|
||||
switch (this->type_) {
|
||||
case PID_SENSOR_TYPE_RESULT:
|
||||
value = this->parent_->get_output_value();
|
||||
break;
|
||||
case PID_SENSOR_TYPE_ERROR:
|
||||
value = this->parent_->get_error_value();
|
||||
break;
|
||||
case PID_SENSOR_TYPE_PROPORTIONAL:
|
||||
value = this->parent_->get_proportional_term();
|
||||
break;
|
||||
case PID_SENSOR_TYPE_INTEGRAL:
|
||||
value = this->parent_->get_integral_term();
|
||||
break;
|
||||
case PID_SENSOR_TYPE_DERIVATIVE:
|
||||
value = this->parent_->get_derivative_term();
|
||||
break;
|
||||
case PID_SENSOR_TYPE_HEAT:
|
||||
value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f);
|
||||
break;
|
||||
case PID_SENSOR_TYPE_COOL:
|
||||
value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f);
|
||||
break;
|
||||
default:
|
||||
value = NAN;
|
||||
break;
|
||||
}
|
||||
this->publish_state(value * 100.0f);
|
||||
}
|
||||
void PIDClimateSensor::dump_config() { LOG_SENSOR("", "PID Climate Sensor", this); }
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
34
esphome/components/pid/sensor/pid_climate_sensor.h
Normal file
34
esphome/components/pid/sensor/pid_climate_sensor.h
Normal file
|
@ -0,0 +1,34 @@
|
|||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/pid/pid_climate.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace pid {
|
||||
|
||||
enum PIDClimateSensorType {
|
||||
PID_SENSOR_TYPE_RESULT,
|
||||
PID_SENSOR_TYPE_ERROR,
|
||||
PID_SENSOR_TYPE_PROPORTIONAL,
|
||||
PID_SENSOR_TYPE_INTEGRAL,
|
||||
PID_SENSOR_TYPE_DERIVATIVE,
|
||||
PID_SENSOR_TYPE_HEAT,
|
||||
PID_SENSOR_TYPE_COOL,
|
||||
};
|
||||
|
||||
class PIDClimateSensor : public sensor::Sensor, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void set_parent(PIDClimate *parent) { parent_ = parent; }
|
||||
void set_type(PIDClimateSensorType type) { type_ = type; }
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void update_from_parent_();
|
||||
PIDClimate *parent_;
|
||||
PIDClimateSensorType type_;
|
||||
};
|
||||
|
||||
} // namespace pid
|
||||
} // namespace esphome
|
Loading…
Reference in a new issue