Skip to content

Commit e8f51e1

Browse files
committed
Add optional external temperature compensation.
1 parent 2a6512f commit e8f51e1

2 files changed

Lines changed: 77 additions & 4 deletions

File tree

custom_components/smart_thermostat/climate.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
DEFAULT_KP = 100
7171
DEFAULT_KI = 0
7272
DEFAULT_KD = 0
73+
DEFAULT_KE = 0
7374
DEFAULT_AUTOTUNE = "none"
7475
DEFAULT_NOISEBAND = 0.5
7576
DEFAULT_SAMPLING_PERIOD = '00:00:00'
@@ -79,6 +80,7 @@
7980

8081
CONF_HEATER = "heater"
8182
CONF_SENSOR = "target_sensor"
83+
CONF_OUTDOOR_SENSOR = "outdoor_sensor"
8284
CONF_MIN_TEMP = "min_temp"
8385
CONF_MAX_TEMP = "max_temp"
8486
CONF_TARGET_TEMP = "target_temp"
@@ -105,6 +107,7 @@
105107
CONF_KP = "kp"
106108
CONF_KI = "ki"
107109
CONF_KD = "kd"
110+
CONF_KE = "ke"
108111
CONF_PWM = "pwm"
109112
CONF_AUTOTUNE = "autotune"
110113
CONF_NOISEBAND = "noiseband"
@@ -116,6 +119,7 @@
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),
@@ -154,6 +158,7 @@
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"""

custom_components/smart_thermostat/services.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ set_pid_gain:
101101
max: 1000000
102102
step: 0.001
103103
mode: box
104+
ke:
105+
# Field name as shown in UI
106+
name: Ke gain
107+
# Description of the field
108+
description: External temperature compensation gain
109+
# Whether or not field is required (default = false)
110+
required: false
111+
# Advanced fields are only shown when the advanced mode is enabled for the user (default = false)
112+
advanced: false
113+
# Example value that can be passed for this field
114+
example: 1.2
115+
selector:
116+
number:
117+
min: 0
118+
max: 1000000
119+
step: 0.001
120+
mode: box
104121
set_preset_temp:
105122
# Service name as shown in UI
106123
name: Set preset modes temperature

0 commit comments

Comments
 (0)