mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00: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:
		
							
								
								
									
										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 | ||||||
		Reference in New Issue
	
	Block a user