ESPHome 2026.1.3
Loading...
Searching...
No Matches
pid_autotuner.cpp
Go to the documentation of this file.
1#include "pid_autotuner.h"
2#include "esphome/core/log.h"
3#include <cinttypes>
4
5#ifndef M_PI
6#define M_PI 3.1415926535897932384626433
7#endif
8
9namespace esphome {
10namespace pid {
11
12static const char *const TAG = "pid.autotune";
13
14/*
15 * # PID Autotuner
16 *
17 * Autotuning of PID parameters is a very interesting topic. There has been
18 * a lot of research over the years to create algorithms that can efficiently determine
19 * suitable starting PID parameters.
20 *
21 * The most basic approach is the Ziegler-Nichols method, which can determine good PID parameters
22 * in a manual process:
23 * - Set ki, kd to zero.
24 * - Increase kp until the output oscillates *around* the setpoint. This value kp is called the
25 * "ultimate gain" K_u.
26 * - Additionally, record the period of the observed oscillation as P_u (also called T_u).
27 * - 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
28 * these "magic" factors exist as well [2]).
29 *
30 * Now we'd like to automate that process to get K_u and P_u without the user. So we'd like to somehow
31 * make the observed variable oscillate. One observation is that in many applications of PID controllers
32 * the observed variable has some amount of "delay" to the output value (think heating an object, it will
33 * take a few seconds before the sensor can sense the change of temperature) [3].
34 *
35 * It turns out one way to induce such an oscillation is by using a really dumb heating controller:
36 * When the observed value is below the setpoint, heat at 100%. If it's below, cool at 100% (or disable heating).
37 * We call this the "RelayFunction" - the class is responsible for making the observed value oscillate around the
38 * setpoint. We actually use a hysteresis filter (like the bang bang controller) to make the process immune to
39 * noise in the input data, but the math is the same [1].
40 *
41 * Next, now that we have induced an oscillation, we want to measure the frequency (or period) of oscillation.
42 * This is what "OscillationFrequencyDetector" is for: it records zerocrossing events (when the observed value
43 * crosses the setpoint). From that data, we can determine the average oscillating period. This is the P_u of the
44 * ZN-method.
45 *
46 * Finally, we need to determine K_u, the ultimate gain. It turns out we can calculate this based on the amplitude of
47 * oscillation ("induced amplitude `a`) as described in [1]:
48 * K_u = (4d) / (πa)
49 * where d is the magnitude of the relay function (in range -d to +d).
50 * To measure `a`, we look at the current phase the relay function is in - if it's in the "heating" phase, then we
51 * expect the lowest temperature (=highest error) to be found in the phase because the peak will always happen slightly
52 * after the relay function has changed state (assuming a delay-dominated process).
53 *
54 * Finally, we use some heuristics to determine if the data we've received so far is good:
55 * - First, of course we must have enough data to calculate the values.
56 * - The ZC events need to happen at a relatively periodic rate. If the heating/cooling speeds are very different,
57 * I've observed the ZN parameters are not very useful.
58 * - The induced amplitude should not deviate too much. If the amplitudes deviate too much this means there has
59 * been some outside influence (or noise) on the system, and the measured amplitude values are not reliable.
60 *
61 * There are many ways this method can be improved, but on my simulation data the current method already produces very
62 * good results. Some ideas for future improvements:
63 * - Relay Function improvements:
64 * - Integrator, Preload, Saturation Relay ([1])
65 * - Use phase of measured signal relative to relay function.
66 * - Apply PID parameters from ZN, but continuously tweak them in a second step.
67 *
68 * [1]: https://warwick.ac.uk/fac/cross_fac/iatl/reinvention/archive/volume5issue2/hornsey/
69 * [2]: http://www.mstarlabs.com/control/znrule.html
70 * [3]: https://www.academia.edu/38620114/SEBORG_3rd_Edition_Process_Dynamics_and_Control
71 */
72
73PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float process_variable) {
75 if (this->state_ == AUTOTUNE_SUCCEEDED) {
77 return res;
78 }
79
80 if (!std::isnan(this->setpoint_) && this->setpoint_ != setpoint) {
81 ESP_LOGW(TAG, "%s: Setpoint changed during autotune! The result will not be accurate!", this->id_.c_str());
82 }
83 this->setpoint_ = setpoint;
84
85 float error = setpoint - process_variable;
86 const uint32_t now = millis();
87
88 float output = this->relay_function_.update(error);
89 this->frequency_detector_.update(now, error);
91 res.output = output;
92
93 if (!this->frequency_detector_.has_enough_data() || !this->amplitude_detector_.has_enough_data()) {
94 // not enough data for calculation yet
95 ESP_LOGV(TAG, "%s: Not enough data yet for autotuner", this->id_.c_str());
96 return res;
97 }
98
99 bool zc_symmetrical = this->frequency_detector_.is_increase_decrease_symmetrical();
100 bool amplitude_convergent = this->frequency_detector_.is_increase_decrease_symmetrical();
101 if (!zc_symmetrical || !amplitude_convergent) {
102 // The frequency/amplitude is not fully accurate yet, try to wait
103 // until the fault clears, or terminate after a while anyway
104 if (zc_symmetrical) {
105 ESP_LOGVV(TAG, "%s: ZC is not symmetrical", this->id_.c_str());
106 }
107 if (amplitude_convergent) {
108 ESP_LOGVV(TAG, "%s: Amplitude is not convergent", this->id_.c_str());
109 }
110 uint32_t phase = this->relay_function_.phase_count;
111 ESP_LOGVV(TAG, "%s: >", this->id_.c_str());
112 ESP_LOGVV(TAG, " Phase %" PRIu32 ", enough=%" PRIu32, phase, enough_data_phase_);
113
114 if (this->enough_data_phase_ == 0) {
115 this->enough_data_phase_ = phase;
116 } else if (phase - this->enough_data_phase_ <= 6) {
117 // keep trying for at least 6 more phases
118 return res;
119 } else {
120 // proceed to calculating PID parameters
121 // warning will be shown in "Checks" section
122 }
123 }
124
125 ESP_LOGI(TAG, "%s: PID Autotune finished!", this->id_.c_str());
126
128 float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f;
129 ESP_LOGVV(TAG, " Relay magnitude: %f", d);
130 this->ku_ = 4.0f * d / float(M_PI * osc_ampl);
132
135 this->dump_config();
136
137 return res;
138}
140 if (this->state_ == AUTOTUNE_SUCCEEDED) {
141 ESP_LOGI(TAG,
142 "%s: PID Autotune:\n"
143 " State: Succeeded!",
144 this->id_.c_str());
145 bool has_issue = false;
147 ESP_LOGW(TAG, " Could not reliably determine oscillation amplitude, PID parameters may be inaccurate!\n"
148 " Please make sure you eliminate all outside influences on the measured temperature.");
149 has_issue = true;
150 }
152 ESP_LOGW(TAG,
153 " Oscillation Frequency is not symmetrical. PID parameters may be inaccurate!\n"
154 " This is usually because the heat and cool processes do not change the temperature at the same "
155 "rate.\n"
156 " Please try reducing the positive_output value (or increase negative_output in case of a cooler)");
157 has_issue = true;
158 }
159 if (!has_issue) {
160 ESP_LOGI(TAG, " All checks passed!");
161 }
162
163 auto fac = get_ziegler_nichols_pid_();
164 ESP_LOGI(TAG,
165 " Calculated PID parameters (\"Ziegler-Nichols PID\" rule):\n"
166 "\n"
167 " control_parameters:\n"
168 " kp: %.5f\n"
169 " ki: %.5f\n"
170 " kd: %.5f\n"
171 "\n"
172 " Please copy these values into your YAML configuration! They will reset on the next reboot.",
173 fac.kp, fac.ki, fac.kd);
174
175 ESP_LOGV(TAG,
176 " Oscillation Period: %f\n"
177 " Oscillation Amplitude: %f\n"
178 " Ku: %f, Pu: %f",
180 this->amplitude_detector_.get_mean_oscillation_amplitude(), this->ku_, this->pu_);
181
182 ESP_LOGD(TAG, " Alternative Rules:");
183 // http://www.mstarlabs.com/control/znrule.html
184 print_rule_("Ziegler-Nichols PI", 0.45f, 0.54f, 0.0f);
185 print_rule_("Pessen Integral PID", 0.7f, 1.75f, 0.105f);
186 print_rule_("Some Overshoot PID", 0.333f, 0.667f, 0.111f);
187 print_rule_("No Overshoot PID", 0.2f, 0.4f, 0.0625f);
188 ESP_LOGI(TAG, "%s: Autotune completed", this->id_.c_str());
189 }
190
191 if (this->state_ == AUTOTUNE_RUNNING) {
192 ESP_LOGD(TAG,
193 "%s: PID Autotune:\n"
194 " Autotune is still running!\n"
195 " Status: Trying to reach %.2f °C\n"
196 " Stats so far:\n"
197 " Phases: %" PRIu32 "\n"
198 " Detected %zu zero-crossings\n"
199 " Current Phase Min: %.2f, Max: %.2f",
203 }
204}
205PIDAutotuner::PIDResult PIDAutotuner::calculate_pid_(float kp_factor, float ki_factor, float kd_factor) {
206 float kp = kp_factor * ku_;
207 float ki = ki_factor * ku_ / pu_;
208 float kd = kd_factor * ku_ * pu_;
209 return {
210 .kp = kp,
211 .ki = ki,
212 .kd = kd,
213 };
214}
215void PIDAutotuner::print_rule_(const char *name, float kp_factor, float ki_factor, float kd_factor) {
216 auto fac = calculate_pid_(kp_factor, ki_factor, kd_factor);
217 ESP_LOGD(TAG,
218 " Rule '%s':\n"
219 " kp: %.5f, ki: %.5f, kd: %.5f",
220 name, fac.kp, fac.ki, fac.kd);
221}
222
223// ================== RelayFunction ==================
225 if (this->state == RELAY_FUNCTION_INIT) {
226 bool pos = error > this->noiseband;
228 }
229 bool change = false;
230 if (this->state == RELAY_FUNCTION_POSITIVE && error < -this->noiseband) {
231 // Positive hysteresis reached, change direction
233 change = true;
234 } else if (this->state == RELAY_FUNCTION_NEGATIVE && error > this->noiseband) {
235 // Negative hysteresis reached, change direction
237 change = true;
238 }
239
241 if (change) {
242 this->phase_count++;
243 }
244
245 return output;
246}
247
248// ================== OscillationFrequencyDetector ==================
250 if (this->state == FREQUENCY_DETECTOR_INIT) {
251 bool pos = error > this->noiseband;
252 state = pos ? FREQUENCY_DETECTOR_POSITIVE : FREQUENCY_DETECTOR_NEGATIVE;
253 }
254
255 bool had_crossing = false;
256 if (this->state == FREQUENCY_DETECTOR_POSITIVE && error < -this->noiseband) {
257 this->state = FREQUENCY_DETECTOR_NEGATIVE;
258 had_crossing = true;
259 } else if (this->state == FREQUENCY_DETECTOR_NEGATIVE && error > this->noiseband) {
260 this->state = FREQUENCY_DETECTOR_POSITIVE;
261 had_crossing = true;
262 }
263
264 if (had_crossing) {
265 // Had crossing above hysteresis threshold, record
266 if (this->last_zerocross != 0) {
267 uint32_t dt = now - this->last_zerocross;
268 this->zerocrossing_intervals.push_back(dt);
269 }
270 this->last_zerocross = now;
271 }
272}
274 // Do we have enough data in this detector to generate PID values?
275 return this->zerocrossing_intervals.size() >= 2;
276}
278 // Get the mean oscillation period in seconds
279 // Only call if has_enough_data() has returned true.
280 float sum = 0.0f;
281 for (uint32_t v : this->zerocrossing_intervals)
282 sum += v;
283 // zerocrossings are each half-period, multiply by 2
284 float mean_value = sum / this->zerocrossing_intervals.size();
285 // divide by 1000 to get seconds, multiply by two because zc happens two times per period
286 float mean_period = mean_value / 1000 * 2;
287 return mean_period;
288}
290 // Check if increase/decrease of process value was symmetrical
291 // If the process value increases much faster than it decreases, the generated PID values will
292 // not be very good and the function output values need to be adjusted
293 // Happens for example with a well-insulated heating element.
294 // We calculate this based on the zerocrossing interval.
295 if (zerocrossing_intervals.empty())
296 return false;
297 uint32_t max_interval = zerocrossing_intervals[0];
298 uint32_t min_interval = zerocrossing_intervals[0];
299 for (uint32_t interval : zerocrossing_intervals) {
300 max_interval = std::max(max_interval, interval);
301 min_interval = std::min(min_interval, interval);
302 }
303 float ratio = min_interval / float(max_interval);
304 return ratio >= 0.66;
305}
306
307// ================== OscillationAmplitudeDetector ==================
310 if (relay_state != last_relay_state) {
311 if (last_relay_state == RelayFunction::RELAY_FUNCTION_POSITIVE) {
312 // Transitioned from positive error to negative error.
313 // The positive error peak must have been in previous segment (180° shifted)
314 // record phase_max
315 this->phase_maxs.push_back(phase_max);
316 } else if (last_relay_state == RelayFunction::RELAY_FUNCTION_NEGATIVE) {
317 // Transitioned from negative error to positive error.
318 // The negative error peak must have been in previous segment (180° shifted)
319 // record phase_min
320 this->phase_mins.push_back(phase_min);
321 }
322 // reset phase values for next phase
323 this->phase_min = error;
324 this->phase_max = error;
325 }
326 this->last_relay_state = relay_state;
327
328 this->phase_min = std::min(this->phase_min, error);
329 this->phase_max = std::max(this->phase_max, error);
330
331 // Check arrays sizes, we keep at most 7 items (6 datapoints is enough, and data at beginning might not
332 // have been stabilized)
333 if (this->phase_maxs.size() > 7)
334 this->phase_maxs.erase(this->phase_maxs.begin());
335 if (this->phase_mins.size() > 7)
336 this->phase_mins.erase(this->phase_mins.begin());
337}
339 // Return if we have enough data to generate PID parameters
340 // The first phase is not very useful if the setpoint is not set to the starting process value
341 // So discard first phase. Otherwise we need at least two phases.
342 return std::min(phase_mins.size(), phase_maxs.size()) >= 3;
343}
345 float total_amplitudes = 0;
346 size_t total_amplitudes_n = 0;
347 for (size_t i = 1; i < std::min(phase_mins.size(), phase_maxs.size()) - 1; i++) {
348 total_amplitudes += std::abs(phase_maxs[i] - phase_mins[i + 1]);
349 total_amplitudes_n++;
350 }
351 float mean_amplitude = total_amplitudes / total_amplitudes_n;
352 // Amplitude is measured from center, divide by 2
353 return mean_amplitude / 2.0f;
354}
356 // Check if oscillation amplitude is convergent
357 // We implement this by checking global extrema against average amplitude
358 if (this->phase_mins.empty() || this->phase_maxs.empty())
359 return false;
360
361 float global_max = phase_maxs[0], global_min = phase_mins[0];
362 for (auto v : this->phase_mins)
363 global_min = std::min(global_min, v);
364 for (auto v : this->phase_maxs)
365 global_max = std::min(global_max, v);
366 float global_amplitude = (global_max - global_min) / 2.0f;
367 float mean_amplitude = this->get_mean_oscillation_amplitude();
368 return (mean_amplitude - global_amplitude) / (global_amplitude) < 0.05f;
369}
370
371} // namespace pid
372} // namespace esphome
PIDAutotuneResult update(float setpoint, float process_variable)
struct esphome::pid::PIDAutotuner::OscillationAmplitudeDetector amplitude_detector_
enum esphome::pid::PIDAutotuner::State state_
struct esphome::pid::PIDAutotuner::RelayFunction relay_function_
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)
struct esphome::pid::PIDAutotuner::OscillationFrequencyDetector frequency_detector_
bool state
Definition fan.h:0
Providing packet encoding functions for exchanging data with a remote host.
Definition a01nyub.cpp:7
uint32_t IRAM_ATTR HOT millis()
Definition core.cpp:25
void update(float error, RelayFunction::RelayFunctionState relay_state)
enum esphome::pid::PIDAutotuner::RelayFunction::RelayFunctionState state