/* xdrv_49_pid.ino - PID algorithm plugin for Sonoff-Tasmota Copyright (C) 2021 Colin Law and Thomas Herrmann This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ #ifdef USE_PID #ifndef FIRMWARE_MINIMAL /*********************************************************************************************\ * Uses the library https://github.com/colinl/process-control.git from Github * In user_config_override.h include code as follows: #define USE_PID // include the pid feature (+4.3k) #define PID_SETPOINT 19.5 // Setpoint value. This is the process value that the process is // aiming for. // May be adjusted via MQTT using cmnd PidSp #define PID_PROPBAND 5 // Proportional band in process units (eg degrees). This controls // the gain of the loop and is the range of process value over which // the power output will go from 0 to full power. The units are that // of the process and setpoint, so for example in a heating // application it might be set to 1.5 degrees. // May be adjusted via MQTT using cmnd PidPb #define PID_INTEGRAL_TIME 1800 // Integral time seconds. This is a setting for the integral time, // in seconds. It represents the time constant of the integration // effect. The larger the value the slower the integral effect will be. // Obviously the slower the process is the larger this should be. For // example for a domestic room heated by convection radiators a setting // of one hour might be appropriate (in seconds). To disable the // integral effect set this to a large number. // May be adjusted via MQTT using cmnd PidTi #define PID_DERIVATIVE_TIME 15 // Derivative time seconds. This is a setting for the derivative time, // in seconds. It represents the time constant of the derivative effect. // The larger the value the greater will be the derivative effect. // Typically this will be set to somewhat less than 25% of the integral // setting, once the integral has been adjusted to the optimum value. To // disable the derivative effect set this to 0. When initially tuning a // loop it is often sensible to start with derivative zero and wind it in // once other parameters have been setup. // May be adjusted via MQTT using cmnd PidTd #define PID_INITIAL_INT 0.5 // Initial integral value (0:1). This is an initial value which is used // to preset the integrated error value when the flow is deployed in // order to assist in homing in on the setpoint the first time. It should // be set to an estimate of what the power requirement might be in order // to maintain the process at the setpoint. For example for a domestic // room heating application it might be set to 0.2 indicating that 20% of // the available power might be required to maintain the setpoint. The // value is of no consequence apart from device restart. #define PID_MAX_INTERVAL 300 // This is the maximum time in seconds that is expected between samples. // It is provided to cope with unusual situations such as a faulty sensor // that might prevent the node from being supplied with a process value. // If no new process value is received for this time then the power is set // to the value defined for PID_MANUAL_POWER. // May be adjusted via MQTT using cmnd PidMaxInterval #define PID_DERIV_SMOOTH_FACTOR 3 // In situations where the process sensor has limited resolution (such as // the DS18B20), the use of deriviative can be problematic as when the // process is changing only slowly the steps in the value cause spikes in // the derivative. To reduce the effect of these this parameter can be // set to apply a filter to the derivative term. I have found that with // the DS18B20 that a value of 3 here can be beneficial, providing // effectively a low pass filter on the derivative at 1/3 of the derivative // time. This feature may also be useful if the process value is particularly // noisy. The smaller the value the greater the filtering effect but the // more it will reduce the effectiveness of the derivative. A value of zero // disables this feature. // May be adjusted via MQTT using cmnd PidDSmooth #define PID_AUTO 1 // Auto mode 1 or 0 (for manual). This can be used to enable or disable // the control (1=enable, auto mode, 0=disabled, manual mode). When in // manual mode the output is set the value definded for PID_MANUAL_POWER // May be adjusted via MQTT using cmnd PidAuto #define PID_MANUAL_POWER 0 // Power output when in manual mode or fallback mode if too long elapses // between process values // May be adjusted via MQTT using cmnd PidManualPower #define PID_UPDATE_SECS 0 // How often to run the pid algorithm (integer secs) or 0 to run the algorithm // each time a new pv value is received, for most applictions specify 0. // Otherwise set this to a time // that is short compared to the response of the process. For example, // something like 15 seconds may well be appropriate for a domestic room // heating application. // May be adjusted via MQTT using cmnd PidUpdateSecs #define PID_USE_TIMPROP 1 // To use an internal relay for a time proportioned output to drive the // process, set this to indicate which timeprop output to use. For a device // with just one relay then this will be 1. // USE_TIMEPROP will be automativally included. You must set the output as // explained in xdrv_48_timeprop.ino // To disable, override to false in user_config_override.h. If USE_TIMEPROP is // not explicitly defined, then it will not be added by default. #define PID_USE_LOCAL_SENSOR // If defined then the local sensor will be used for pv. Leave undefined if // this is not required. The rate that the sensor is read is defined by TELE_PERIOD // If not using the sensor then you can supply process values via MQTT using // cmnd PidPv #define PID_LOCAL_SENSOR_NAME "DS18B20" // local sensor name when PID_USE_LOCAL_SENSOR is defined above // the JSON payload is parsed for this sensor to find the present value // eg "ESP32":{"Temperature":31.4},"DS18B20":{"Temperature":12.6} #define PID_LOCAL_SENSOR_TYPE D_JSON_TEMPERATURE // Choose one of D_JSON_TEMPERATURE D_JSON_HUMIDITY D_JSON_PRESSURE // or any string as the sensor type. The JSON payload is parsed for the // value in this field // eg "HDC1080":{"Temperature":24.8,"Humidity":79.2} #define PID_SHUTTER 1 // if using the PID to control a 3-way valve, create Tasmota Shutter and define the // number of the shutter here. Otherwise leave this commented out #define PID_REPORT_MORE_SETTINGS true // If defined to true, the SENSOR output will provide more extensive json // output in the PID section. Override to false to reduce json output * Help with using the PID algorithm and with loop tuning can be found at * http://blog.clanlaw.org.uk/2018/01/09/PID-tuning-with-node-red-contrib-pid.html * This is directed towards using the algorithm in the node-red node node-red-contrib-pid but the algorithm here is based on * the code there and the tuning technique described there should work just the same. \*********************************************************************************************/ #ifndef PID_SETPOINT #define PID_SETPOINT 19.5 // [PidSp] Setpoint value. #endif #ifndef PID_PROPBAND #define PID_PROPBAND 5 // [PidPb] Proportional band in process units (eg degrees). #endif #ifndef PID_INTEGRAL_TIME #define PID_INTEGRAL_TIME 1800 // [PidTi] Integral time seconds. #endif #ifndef PID_DERIVATIVE_TIME #define PID_DERIVATIVE_TIME 15 // [PidTd] Derivative time seconds. #endif #ifndef PID_INITIAL_INT #define PID_INITIAL_INT 0.5 // Initial integral value (0:1). #endif #ifndef PID_MAX_INTERVAL #define PID_MAX_INTERVAL 300 // [PidMaxInterval] This is the maximum time in seconds between samples. #endif #ifndef PID_DERIV_SMOOTH_FACTOR #define PID_DERIV_SMOOTH_FACTOR 3 // [PidDSmooth] #endif #ifndef PID_AUTO #define PID_AUTO 1 // [PidAuto] Auto mode 1 or 0 (for manual). #endif #ifndef PID_MANUAL_POWER #define PID_MANUAL_POWER 0 // [PidManualPower] Power output when in manual mode or fallback mode. #endif #ifndef PID_UPDATE_SECS #define PID_UPDATE_SECS 0 // [PidUpdateSecs] How often to run the pid algorithm #endif #ifndef PID_USE_TIMPROP #define PID_USE_TIMPROP 1 // To disable this feature define as false in user_config_override #endif // #define PID_USE_LOCAL_SENSOR // If defined then the local sensor will be used for pv. #ifndef PID_LOCAL_SENSOR_NAME #define PID_LOCAL_SENSOR_NAME "DS18B20" // local sensor name when PID_USE_LOCAL_SENSOR is defined #endif #ifndef PID_LOCAL_SENSOR_TYPE #define PID_LOCAL_SENSOR_TYPE D_JSON_TEMPERATURE // local sensor type #endif //#define PID_SHUTTER 1 // Number of the shutter here. Otherwise leave this commented out #ifndef PID_REPORT_MORE_SETTINGS #define PID_REPORT_MORE_SETTINGS true // Override to false if less details are required in SENSOR JSON #endif #include "PID.h" /* This might need to go to i18n.h */ #define D_PRFX_PID "Pid" #define D_CMND_PID_SETPV "Pv" #define D_CMND_PID_SETSETPOINT "Sp" #define D_CMND_PID_SETPROPBAND "Pb" #define D_CMND_PID_SETINTEGRAL_TIME "Ti" #define D_CMND_PID_SETDERIVATIVE_TIME "Td" #define D_CMND_PID_SETINITIAL_INT "Initint" #define D_CMND_PID_SETDERIV_SMOOTH_FACTOR "DSmooth" #define D_CMND_PID_SETAUTO "Auto" #define D_CMND_PID_SETMANUAL_POWER "ManualPower" #define D_CMND_PID_SETMAX_INTERVAL "MaxInterval" #define D_CMND_PID_SETUPDATE_SECS "UpdateSecs" #define D_CMND_PID_SETSHUTDOWN "Shutdown" const char kPIDCommands[] PROGMEM = D_PRFX_PID "|" // Prefix D_CMND_PID_SETPV "|" D_CMND_PID_SETSETPOINT "|" D_CMND_PID_SETPROPBAND "|" D_CMND_PID_SETINTEGRAL_TIME "|" D_CMND_PID_SETDERIVATIVE_TIME "|" D_CMND_PID_SETINITIAL_INT "|" D_CMND_PID_SETDERIV_SMOOTH_FACTOR "|" D_CMND_PID_SETAUTO "|" D_CMND_PID_SETMANUAL_POWER "|" D_CMND_PID_SETMAX_INTERVAL "|" D_CMND_PID_SETUPDATE_SECS "|" D_CMND_PID_SETSHUTDOWN; ; void (* const PIDCommand[])(void) PROGMEM = { &CmndSetPv, &CmndSetSp, &CmndSetPb, &CmndSetTi, &CmndSetTd, &CmndSetInitialInt, &CmndSetDSmooth, &CmndSetAuto, &CmndSetManualPower, &CmndSetMaxInterval, &CmndSetUpdateSecs, &CmndSetShutdown }; struct { PID pid; int update_secs = PID_UPDATE_SECS <= 0 ? 0 : PID_UPDATE_SECS; // how often (secs) the pid alogorithm is run int max_interval = PID_MAX_INTERVAL; unsigned long last_pv_update_secs = 0; bool run_pid_now = false; // tells PID_Every_Second to run the pid algorithm long current_time_secs = 0; // a counter that counts seconds since initialisation bool shutdown = false; // power commands will be ignored when true } Pid; void PIDInit() { Pid.pid.initialise( PID_SETPOINT, PID_PROPBAND, PID_INTEGRAL_TIME, PID_DERIVATIVE_TIME, PID_INITIAL_INT, PID_MAX_INTERVAL, PID_DERIV_SMOOTH_FACTOR, PID_AUTO, PID_MANUAL_POWER ); } void PIDEverySecond() { static int sec_counter = 0; Pid.current_time_secs++; // increment time // run the pid algorithm if Pid.run_pid_now is true or if the right number of seconds has passed or if too long has // elapsed since last pv update. If too long has elapsed the the algorithm will deal with that. if (Pid.run_pid_now || Pid.current_time_secs - Pid.last_pv_update_secs > Pid.max_interval || (Pid.update_secs != 0 && sec_counter++ % Pid.update_secs == 0)) { if (!Pid.run_pid_now) { PIDShowSensor(); // set actual process value } PIDRun(); Pid.run_pid_now = false; } } void PIDShowSensor() { // Called each time new sensor data available, data in mqtt data in same format // as published in tele/SENSOR // Update period is specified in TELE_PERIOD float sensor_reading = NAN; #if defined PID_USE_LOCAL_SENSOR // copy the string into a new buffer that will be modified and // parsed to find the local sensor reading if it's there String buf = ResponseData(); JsonParser parser((char*)buf.c_str()); JsonParserObject root = parser.getRootObject(); if (root) { JsonParserToken value_token = root[PID_LOCAL_SENSOR_NAME].getObject()[PSTR(PID_LOCAL_SENSOR_TYPE)]; if (value_token.isNum()) { sensor_reading = value_token.getFloat(); } } #endif // PID_USE_LOCAL_SENSOR if (!isnan(sensor_reading)) { // pass the value to the pid alogorithm to use as current pv Pid.last_pv_update_secs = Pid.current_time_secs; Pid.pid.setPv(sensor_reading, Pid.last_pv_update_secs); // also trigger running the pid algorithm if we have been told to run it each pv sample if (Pid.update_secs == 0) { // this runs it at the next second Pid.run_pid_now = true; } } else { // limit sensor not seen message to every 60 seconds to avoid flooding the logs if ((Pid.current_time_secs - Pid.last_pv_update_secs) > Pid.max_interval && ((Pid.current_time_secs - Pid.last_pv_update_secs)%60)==0) { AddLog(LOG_LEVEL_ERROR, PSTR("PID: Local temperature sensor missing for longer than PID_MAX_INTERVAL")); } } } void CmndSetPv(void) { Pid.last_pv_update_secs = Pid.current_time_secs; if (XdrvMailbox.data_len > 0) { Pid.pid.setPv(CharToFloat(XdrvMailbox.data), Pid.last_pv_update_secs); } // also trigger running the pid algorithm if we have been told to run it each pv sample if (Pid.update_secs == 0) { // this runs it at the next second Pid.run_pid_now = true; } ResponseCmndFloat(Pid.pid.getPv(), 1); } void CmndSetSp(void) { if (XdrvMailbox.data_len > 0) { Pid.pid.setSp(CharToFloat(XdrvMailbox.data)); } ResponseCmndFloat(Pid.pid.getSp(), 1); } void CmndSetPb(void) { if (XdrvMailbox.data_len > 0) { Pid.pid.setPb(CharToFloat(XdrvMailbox.data)); } ResponseCmndFloat(Pid.pid.getPb(), 1); } void CmndSetTi(void) { if (XdrvMailbox.data_len > 0) { Pid.pid.setTi(CharToFloat(XdrvMailbox.data)); } ResponseCmndFloat(Pid.pid.getTi(), 1); } void CmndSetTd(void) { if (XdrvMailbox.data_len > 0) { Pid.pid.setTd(CharToFloat(XdrvMailbox.data)); } ResponseCmndFloat(Pid.pid.getTd(), 1); } void CmndSetInitialInt(void) { if (XdrvMailbox.data_len > 0) { Pid.pid.setInitialInt(CharToFloat(XdrvMailbox.data)); } ResponseCmndNumber(Pid.pid.getInitialInt()); } void CmndSetDSmooth(void) { if (XdrvMailbox.data_len > 0) { Pid.pid.setDSmooth(CharToFloat(XdrvMailbox.data)); } ResponseCmndFloat(Pid.pid.getDSmooth(), 1); } void CmndSetAuto(void) { if (XdrvMailbox.payload >= 0) { if(!Pid.shutdown) { Pid.pid.setAuto(XdrvMailbox.payload); } } ResponseCmndNumber(Pid.pid.getAuto()); } void CmndSetManualPower(void) { if (XdrvMailbox.data_len > 0) { if(!Pid.shutdown) { Pid.pid.setManualPower(CharToFloat(XdrvMailbox.data)); } } ResponseCmndFloat(Pid.pid.getManualPower(), 2); } void CmndSetMaxInterval(void) { if (XdrvMailbox.payload >= 0) { Pid.pid.setMaxInterval(XdrvMailbox.payload); Pid.max_interval=XdrvMailbox.payload; } ResponseCmndNumber(Pid.pid.getMaxInterval()); } void CmndSetUpdateSecs(void) { if (XdrvMailbox.payload >= 0) { Pid.update_secs = (XdrvMailbox.payload); } if (Pid.update_secs < 0) { Pid.update_secs = 0; } ResponseCmndNumber(Pid.update_secs); } void CmndSetShutdown(void) { if (XdrvMailbox.payload >= 0) { AddLog(LOG_LEVEL_INFO, PSTR("PID: Shutdown mode %s"), XdrvMailbox.payload>0 ? "activated" : "cleared"); Pid.shutdown = (XdrvMailbox.payload>0); if(Pid.shutdown) { Pid.pid.setAuto(0); Pid.pid.setManualPower(0.0); } } ResponseCmndNumber(Pid.shutdown); } void PIDShowValues(void) { char str_buf[FLOATSZ]; char chr_buf; int i_buf; double d_buf; ResponseAppend_P(PSTR(",\"PID\":{")); d_buf = Pid.pid.getPv(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidPv\":%s,"), str_buf); d_buf = Pid.pid.getSp(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidSp\":%s,"), str_buf); ResponseAppend_P(PSTR("\"PidShutdown\":%d,"), Pid.shutdown); #if PID_REPORT_MORE_SETTINGS d_buf = Pid.pid.getPb(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidPb\":%s,"), str_buf); d_buf = Pid.pid.getTi(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidTi\":%s,"), str_buf); d_buf = Pid.pid.getTd(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidTd\":%s,"), str_buf); d_buf = Pid.pid.getInitialInt(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidInitialInt\":%s,"), str_buf); d_buf = Pid.pid.getDSmooth(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidDSmooth\":%s,"), str_buf); chr_buf = Pid.pid.getAuto(); ResponseAppend_P(PSTR("\"PidAuto\":%d,"), chr_buf); d_buf = Pid.pid.getManualPower(); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidManualPower\":%s,"), str_buf); i_buf = Pid.pid.getMaxInterval(); ResponseAppend_P(PSTR("\"PidMaxInterval\":%d,"), i_buf); i_buf = Pid.current_time_secs - Pid.last_pv_update_secs; ResponseAppend_P(PSTR("\"PidInterval\":%d,"), i_buf); ResponseAppend_P(PSTR("\"PidUpdateSecs\":%d,"), Pid.update_secs); #endif // PID_REPORT_MORE_SETTINGS i_buf = (Pid.current_time_secs - Pid.last_pv_update_secs) > Pid.pid.getMaxInterval(); ResponseAppend_P(PSTR("\"PidSensorLost\":%d,"), i_buf); // The actual power value d_buf = Pid.pid.tick(Pid.current_time_secs); dtostrfd(d_buf, 2, str_buf); ResponseAppend_P(PSTR("\"PidPower\":%s"), str_buf); ResponseAppend_P(PSTR("}")); } #ifdef USE_WEBSERVER #define D_PID_DISPLAY_NAME "PID Controller" #define D_PID_SET_POINT "Set Point" #define D_PID_PRESENT_VALUE "Current Value" #define D_PID_POWER "Power" #define D_PID_MODE "Controller Mode" #define D_PID_MODE_AUTO "Auto" #define D_PID_MODE_MANUAL "Manual" #define D_PID_MODE_OFF "Off" const char HTTP_PID_HL[] PROGMEM = "{s}
{m}
{e}"; const char HTTP_PID_INFO[] PROGMEM = "{s}" D_PID_DISPLAY_NAME "{m}%s{e}"; const char HTTP_PID_SP_FORMAT[] PROGMEM = "{s}%s " "{m}%*_f "; const char HTTP_PID_PV_FORMAT[] PROGMEM = "{s}%s " "{m}%*_f "; const char HTTP_PID_POWER_FORMAT[] PROGMEM = "{s}%s " "{m}%*_f " D_UNIT_PERCENT; void PIDShowValuesWeb(void) { float f_buf; WSContentSend_P(HTTP_PID_HL); WSContentSend_P(HTTP_PID_INFO, (Pid.pid.getAuto()==1) ? D_PID_MODE_AUTO : Pid.pid.tick(Pid.current_time_secs)>0.0f ? D_PID_MODE_MANUAL : D_PID_MODE_OFF); if (Pid.pid.getAuto()==1 || Pid.pid.tick(Pid.current_time_secs)>0.0f) { f_buf = (float)Pid.pid.getSp(); WSContentSend_PD(HTTP_PID_SP_FORMAT, D_PID_SET_POINT, 1, &f_buf); f_buf = (float)Pid.pid.getPv(); WSContentSend_PD(HTTP_PID_PV_FORMAT, D_PID_PRESENT_VALUE, 1, &f_buf); f_buf = Pid.pid.tick(Pid.current_time_secs)*100.0f; WSContentSend_PD(HTTP_PID_POWER_FORMAT, D_PID_POWER, 0, &f_buf); } } #endif // USE_WEBSERVER void PIDRun(void) { double power = Pid.pid.tick(Pid.current_time_secs); #ifdef PID_DONT_USE_PID_TOPIC // This part is left inside to regularly publish the PID Power via // `%topic%/PID {"power":"0.000"}` char str_buf[FLOATSZ]; dtostrfd(power, 3, str_buf); Response_P(PSTR("{\"%s\":\"%s\"}"), "power", str_buf); MqttPublishPrefixTopicRulesProcess_P(TELE, "PID"); #endif // PID_DONT_USE_PID_TOPIC #if defined PID_SHUTTER // send output as a position from 0-100 to defined shutter int pos = power * 100; ShutterSetPosition(PID_SHUTTER, pos); #endif //PID_SHUTTER #if defined(PID_USE_TIMPROP) && (PID_USE_TIMPROP > 0) // send power to appropriate timeprop output TimepropSetPower( PID_USE_TIMPROP-1, power ); #endif // PID_USE_TIMPROP } /*********************************************************************************************\ * Interface \*********************************************************************************************/ #define XDRV_49 49 bool Xdrv49(uint32_t function) { bool result = false; switch (function) { case FUNC_INIT: PIDInit(); break; case FUNC_EVERY_SECOND: PIDEverySecond(); break; case FUNC_SHOW_SENSOR: // only use this if the pid loop is to use the local sensor for pv #if defined PID_USE_LOCAL_SENSOR PIDShowSensor(); #endif // PID_USE_LOCAL_SENSOR break; case FUNC_COMMAND: result = DecodeCommand(kPIDCommands, PIDCommand); break; case FUNC_JSON_APPEND: PIDShowValues(); break; #ifdef USE_WEBSERVER case FUNC_WEB_SENSOR: PIDShowValuesWeb(); break; #endif // USE_WEBSERVER case FUNC_ACTIVE: result = true; break; } return result; } #endif //FIRMWARE_MINIMAL #endif // USE_PID