7070DEFAULT_KP = 100
7171DEFAULT_KI = 0
7272DEFAULT_KD = 0
73+ DEFAULT_KE = 0
7374DEFAULT_AUTOTUNE = "none"
7475DEFAULT_NOISEBAND = 0.5
7576DEFAULT_SAMPLING_PERIOD = '00:00:00'
7980
8081CONF_HEATER = "heater"
8182CONF_SENSOR = "target_sensor"
83+ CONF_OUTDOOR_SENSOR = "outdoor_sensor"
8284CONF_MIN_TEMP = "min_temp"
8385CONF_MAX_TEMP = "max_temp"
8486CONF_TARGET_TEMP = "target_temp"
105107CONF_KP = "kp"
106108CONF_KI = "ki"
107109CONF_KD = "kd"
110+ CONF_KE = "ke"
108111CONF_PWM = "pwm"
109112CONF_AUTOTUNE = "autotune"
110113CONF_NOISEBAND = "noiseband"
116119 {
117120 vol .Required (CONF_HEATER ): cv .entity_id ,
118121 vol .Required (CONF_SENSOR ): cv .entity_id ,
122+ vol .Optional (CONF_OUTDOOR_SENSOR ): cv .entity_id ,
119123 vol .Optional (CONF_AC_MODE ): cv .boolean ,
120124 vol .Optional (CONF_MAX_TEMP ): vol .Coerce (float ),
121125 vol .Optional (CONF_MIN_TEMP ): vol .Coerce (float ),
154158 vol .Optional (CONF_KP , default = DEFAULT_KP ): vol .Coerce (float ),
155159 vol .Optional (CONF_KI , default = DEFAULT_KI ): vol .Coerce (float ),
156160 vol .Optional (CONF_KD , default = DEFAULT_KD ): vol .Coerce (float ),
161+ vol .Optional (CONF_KE , default = DEFAULT_KE ): vol .Coerce (float ),
157162 vol .Optional (CONF_PWM , default = DEFAULT_PWM ): vol .All (
158163 cv .time_period , cv .positive_timedelta
159164 ),
@@ -177,6 +182,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
177182 'unique_id' : config .get (CONF_UNIQUE_ID ),
178183 'heater_entity_id' : config .get (CONF_HEATER ),
179184 'sensor_entity_id' : config .get (CONF_SENSOR ),
185+ 'ext_sensor_entity_id' : config .get (CONF_OUTDOOR_SENSOR ),
180186 'min_temp' : config .get (CONF_MIN_TEMP ),
181187 'max_temp' : config .get (CONF_MAX_TEMP ),
182188 'target_temp' : config .get (CONF_TARGET_TEMP ),
@@ -204,6 +210,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
204210 'kp' : config .get (CONF_KP ),
205211 'ki' : config .get (CONF_KI ),
206212 'kd' : config .get (CONF_KD ),
213+ 'ke' : config .get (CONF_KE ),
207214 'pwm' : config .get (CONF_PWM ),
208215 'autotune' : config .get (CONF_AUTOTUNE ),
209216 'noiseband' : config .get (CONF_NOISEBAND ),
@@ -219,6 +226,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
219226 vol .Optional ("kp" ): vol .Coerce (float ),
220227 vol .Optional ("ki" ): vol .Coerce (float ),
221228 vol .Optional ("kd" ): vol .Coerce (float ),
229+ vol .Optional ("ke" ): vol .Coerce (float ),
222230 },
223231 "async_set_pid" ,
224232 )
@@ -258,6 +266,7 @@ def __init__(self, **kwargs):
258266 self ._unique_id = kwargs .get ('unique_id' )
259267 self ._heater_entity_id = kwargs .get ('heater_entity_id' )
260268 self ._sensor_entity_id = kwargs .get ('sensor_entity_id' )
269+ self ._ext_sensor_entity_id = kwargs .get ('ext_sensor_entity_id' )
261270 if self ._unique_id == 'none' :
262271 self ._unique_id = slugify (f"{ DOMAIN } _{ self ._name } _{ self ._heater_entity_id } " )
263272 self ._ac_mode = kwargs .get ('ac_mode' )
@@ -280,6 +289,7 @@ def __init__(self, **kwargs):
280289 self ._cur_temp_time = None
281290 self ._previous_temp = None
282291 self ._previous_temp_time = None
292+ self ._ext_temp = None
283293 self ._temp_lock = asyncio .Lock ()
284294 self ._min_temp = kwargs .get ('min_temp' )
285295 self ._max_temp = kwargs .get ('max_temp' )
@@ -314,19 +324,20 @@ def __init__(self, **kwargs):
314324 self ._kp = kwargs .get ('kp' )
315325 self ._ki = kwargs .get ('ki' )
316326 self ._kd = kwargs .get ('kd' )
327+ self ._ke = kwargs .get ('ke' )
317328 self ._pwm = kwargs .get ('pwm' ).seconds
318- self ._p = self ._i = self ._d = 0
329+ self ._p = self ._i = self ._d = self . _e = 0
319330 self ._control_output = 0
320331 self ._force_on = False
321332 self ._force_off = False
322333 self ._autotune = kwargs .get ('autotune' )
323334 self ._lookback = kwargs .get ('lookback' ).seconds
324335 self ._noiseband = kwargs .get ('noiseband' )
325- self ._sensor_entity_id = kwargs .get ('sensor_entity_id' )
326336 self ._cold_tolerance = abs (kwargs .get ('cold_tolerance' ))
327337 self ._hot_tolerance = abs (kwargs .get ('hot_tolerance' ))
328338 self ._time_changed = 0
329339 self ._last_sensor_update = time .time ()
340+ self ._last_ext_sensor_update = time .time ()
330341 if self ._autotune != "none" :
331342 self ._pidController = None
332343 self ._pidAutotune = pid_controller .PIDAutotune (self ._difference , self ._lookback ,
@@ -348,6 +359,9 @@ async def async_added_to_hass(self):
348359
349360 # Add listener
350361 async_track_state_change (self .hass , self ._sensor_entity_id , self ._async_sensor_changed )
362+ if self ._ext_sensor_entity_id is not None :
363+ async_track_state_change (self .hass , self ._ext_sensor_entity_id ,
364+ self ._async_ext_sensor_changed )
351365 async_track_state_change (self .hass , self ._heater_entity_id , self ._async_switch_changed )
352366
353367 if self ._keep_alive :
@@ -359,6 +373,10 @@ def _async_startup(event):
359373 sensor_state = self .hass .states .get (self ._sensor_entity_id )
360374 if sensor_state and sensor_state .state != STATE_UNKNOWN :
361375 self ._async_update_temp (sensor_state )
376+ if self ._ext_sensor_entity_id is not None :
377+ ext_sensor_state = self .hass .states .get (self ._ext_sensor_entity_id )
378+ if ext_sensor_state and ext_sensor_state .state != STATE_UNKNOWN :
379+ self ._async_update_ext_temp (ext_sensor_state )
362380
363381 self .hass .bus .async_listen_once (EVENT_HOMEASSISTANT_START , _async_startup )
364382
@@ -398,6 +416,8 @@ def _async_startup(event):
398416 if old_state .attributes .get ('Kd' ) is not None and self ._pidController is not None :
399417 self ._kd = float (old_state .attributes .get ('Kd' ))
400418 self ._pidController .set_pid_param (kd = self ._kd )
419+ if old_state .attributes .get ('Ke' ) is not None :
420+ self ._ke = float (old_state .attributes .get ('Ke' ))
401421 if old_state .attributes .get ('pid_mode' ) is not None and self ._pidController is not None :
402422 self ._pidController .mode = old_state .attributes .get ('pid_mode' )
403423
@@ -539,6 +559,11 @@ def pid_control_d(self):
539559 """Return the D output of PID controller."""
540560 return self ._d
541561
562+ @property
563+ def pid_control_e (self ):
564+ """Return the E output of external temperature compensation."""
565+ return self ._e
566+
542567 @property
543568 def pid_control_output (self ):
544569 """Return the pid control output of the thermostat."""
@@ -559,13 +584,15 @@ def extra_state_attributes(self):
559584 "Kp" : self ._kp ,
560585 "Ki" : self ._ki ,
561586 "Kd" : self ._kd ,
587+ "Ke" : self ._ke ,
562588 }
563589 if self ._autotune != "none" :
564590 device_state_attributes .update ({
565591 "pid_mode" : 'off' ,
566592 "pid_p" : 0 ,
567593 "pid_i" : 0 ,
568594 "pid_d" : 0 ,
595+ "pid_e" : 0 ,
569596 "autotune_status" : self ._pidAutotune .state ,
570597 "autotune_sample_time" : self ._pidAutotune .sample_time ,
571598 "autotune_tuning_rule" : self ._autotune ,
@@ -580,6 +607,7 @@ def extra_state_attributes(self):
580607 "pid_p" : self .pid_control_p ,
581608 "pid_i" : self .pid_control_i ,
582609 "pid_d" : self .pid_control_d ,
610+ "pid_e" : self .pid_control_e ,
583611 "autotune_status" : 'off' ,
584612 "autotune_sample_time" : 0.0 ,
585613 "autotune_tuning_rule" : 'none' ,
@@ -630,12 +658,15 @@ async def async_set_pid(self, **kwargs):
630658 kp = kwargs .get ('kp' , None )
631659 ki = kwargs .get ('ki' , None )
632660 kd = kwargs .get ('kd' , None )
661+ ke = kwargs .get ('ke' , None )
633662 if kp is not None :
634663 self ._kp = float (kp )
635664 if ki is not None :
636665 self ._ki = float (ki )
637666 if kd is not None :
638667 self ._kd = float (kd )
668+ if ke is not None :
669+ self ._ke = float (ke )
639670 self ._pidController .set_pid_param (self ._kp , self ._ki , self ._kd )
640671 await self ._async_control_heating (calc_pid = True )
641672
@@ -708,6 +739,17 @@ async def _async_sensor_changed(self, entity_id, old_state, new_state):
708739 self ._current_temp , self ._previous_temp )
709740 await self ._async_control_heating (calc_pid = True )
710741
742+ async def _async_ext_sensor_changed (self , entity_id , old_state , new_state ):
743+ """Handle temperature changes."""
744+ if new_state is None :
745+ return
746+
747+ self ._async_update_ext_temp (new_state )
748+ self ._trigger_source = 'ext_sensor'
749+ _LOGGER .debug ("Received new outdoor temperature sensor input at timestamp %s: %s" ,
750+ time .time (), self ._ext_temp )
751+ await self ._async_control_heating (calc_pid = True )
752+
711753 @callback
712754 def _async_switch_changed (self , entity_id , old_state , new_state ):
713755 """Handle heater switch state changes."""
@@ -725,6 +767,15 @@ def _async_update_temp(self, state):
725767 except ValueError as ex :
726768 _LOGGER .debug ("Unable to update from sensor: %s" , ex )
727769
770+ @callback
771+ def _async_update_ext_temp (self , state ):
772+ """Update thermostat with latest state from sensor."""
773+ try :
774+ self ._ext_temp = float (state .state )
775+ self ._last_ext_sensor_update = time .time ()
776+ except ValueError as ex :
777+ _LOGGER .debug ("Unable to update from sensor: %s" , ex )
778+
728779 async def _async_control_heating (self , time_func = None , calc_pid = False ):
729780 """Run PID controller, optional autotune for faster integration"""
730781 async with self ._temp_lock :
@@ -845,10 +896,15 @@ async def calc_output(self):
845896 self ._d = self ._pidController .D
846897 error = self ._pidController .error
847898 dt = self ._pidController .dt
899+ if update and self ._ext_temp is not None :
900+ # Compensate losses due to external temperature
901+ self ._e = self ._ke * (self ._target_temp - self ._ext_temp )
902+ self ._control_output = max (min (self ._p + self ._i + self ._d + self ._e , self ._maxOut ),
903+ self ._minOut )
848904 if update :
849905 _LOGGER .debug ("New PID control output. %.2f (error = %.2f, dt = %.2f, p=%.2f, "
850- "i=%.2f, d=%.2f)" , self ._control_output , error , dt , self ._p , self . _i ,
851- self ._d )
906+ "i=%.2f, d=%.2f, e=%.2f )" , self ._control_output , error , dt , self ._p ,
907+ self ._i , self . _d , self . _e )
852908
853909 async def set_control_value (self ):
854910 """Set Output value for heater"""
0 commit comments