Compare commits
2 Commits
4.2.0.alph
...
4.2.0.alph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a44d0dc4a | ||
|
|
9e15aa48b9 |
@@ -464,8 +464,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._cycle_min * 60,
|
self._cycle_min * 60,
|
||||||
# Needed for time calculation
|
# Needed for time calculation
|
||||||
get_tz(self._hass),
|
get_tz(self._hass),
|
||||||
# one digit after the coma for temperature
|
# two digits after the coma for temperature slope calculation
|
||||||
1,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
@@ -880,6 +880,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
"""Return the unit of measurement."""
|
"""Return the unit of measurement."""
|
||||||
return self._unit
|
return self._unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ema_temperature(self) -> str:
|
||||||
|
"""Return the EMA temperature."""
|
||||||
|
return self._ema_temp
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hvac_mode(self) -> HVACMode | None:
|
def hvac_mode(self) -> HVACMode | None:
|
||||||
"""Return current operation."""
|
"""Return current operation."""
|
||||||
@@ -1671,7 +1676,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
for under in self._underlyings:
|
for under in self._underlyings:
|
||||||
await under.turn_off()
|
await under.turn_off()
|
||||||
|
|
||||||
async def _async_manage_window_auto(self):
|
async def _async_manage_window_auto(self, in_cycle=False):
|
||||||
"""The management of the window auto feature"""
|
"""The management of the window auto feature"""
|
||||||
|
|
||||||
async def dearm_window_auto(_):
|
async def dearm_window_auto(_):
|
||||||
@@ -1701,9 +1706,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
if not self._window_auto_algo:
|
if not self._window_auto_algo:
|
||||||
return
|
return
|
||||||
|
|
||||||
slope = self._window_auto_algo.add_temp_measurement(
|
if in_cycle:
|
||||||
temperature=self._ema_temp, datetime_measure=self._last_temperature_mesure
|
slope = self._window_auto_algo.check_age_last_measurement(
|
||||||
|
temperature=self._ema_temp,
|
||||||
|
datetime_now=datetime.now(get_tz(self._hass)),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
slope = self._window_auto_algo.add_temp_measurement(
|
||||||
|
temperature=self._ema_temp,
|
||||||
|
datetime_measure=self._last_temperature_mesure,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - Window auto is on, check the alert. last slope is %.3f",
|
"%s - Window auto is on, check the alert. last slope is %.3f",
|
||||||
self,
|
self,
|
||||||
@@ -2052,6 +2065,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._attr_preset_mode,
|
self._attr_preset_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# check auto_window conditions
|
||||||
|
await self._async_manage_window_auto(in_cycle=True)
|
||||||
|
|
||||||
# Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it
|
# Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it
|
||||||
for under in self._underlyings:
|
for under in self._underlyings:
|
||||||
if not under.is_initialized:
|
if not under.is_initialized:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ MIN_TIME_DECAY_SEC = 0
|
|||||||
# For example when using a half life of 10 minutes a measurement that is 60 minutes ago
|
# For example when using a half life of 10 minutes a measurement that is 60 minutes ago
|
||||||
# (if there's nothing inbetween) would contribute to the smoothed value with 1,5%,
|
# (if there's nothing inbetween) would contribute to the smoothed value with 1,5%,
|
||||||
# giving the current measurement 98,5% relevance. It could be wise to limit the alpha to e.g. 4x the half life (=0.9375).
|
# giving the current measurement 98,5% relevance. It could be wise to limit the alpha to e.g. 4x the half life (=0.9375).
|
||||||
MAX_ALPHA = 0.9375
|
MAX_ALPHA = 0.5
|
||||||
|
|
||||||
|
|
||||||
class ExponentialMovingAverage:
|
class ExponentialMovingAverage:
|
||||||
@@ -73,11 +73,13 @@ class ExponentialMovingAverage:
|
|||||||
self._last_timestamp = timestamp
|
self._last_timestamp = timestamp
|
||||||
self._current_ema = new_ema
|
self._current_ema = new_ema
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - alpha=%.2f new_ema=%.2f last_timestamp=%s",
|
"%s - timestamp=%s alpha=%.2f measurement=%.2f current_ema=%.2f new_ema=%.2f",
|
||||||
self,
|
self,
|
||||||
|
timestamp,
|
||||||
alpha,
|
alpha,
|
||||||
|
measurement,
|
||||||
self._current_ema,
|
self._current_ema,
|
||||||
self._last_timestamp,
|
new_ema,
|
||||||
)
|
)
|
||||||
|
|
||||||
return round(self._current_ema, self._precision)
|
return round(self._current_ema, self._precision)
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ from datetime import datetime
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# To filter bad values
|
# To filter bad values
|
||||||
MIN_DELTA_T_SEC = 15 # two temp mesure should be > 10 sec
|
MIN_DELTA_T_SEC = 0 # two temp mesure should be > 0 sec
|
||||||
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
|
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
|
||||||
|
|
||||||
|
MAX_DURATION_SEC = 600 # a fake data point is added in the cycle if last measurement was older than 600 sec (10 min)
|
||||||
|
|
||||||
|
MIN_NB_POINT = 4 # do not calculate slope until we have enough point
|
||||||
|
|
||||||
|
|
||||||
class WindowOpenDetectionAlgorithm:
|
class WindowOpenDetectionAlgorithm:
|
||||||
"""The class that implements the algorithm listed above"""
|
"""The class that implements the algorithm listed above"""
|
||||||
@@ -25,6 +29,7 @@ class WindowOpenDetectionAlgorithm:
|
|||||||
_last_slope: float
|
_last_slope: float
|
||||||
_last_datetime: datetime
|
_last_datetime: datetime
|
||||||
_last_temperature: float
|
_last_temperature: float
|
||||||
|
_nb_point: int
|
||||||
|
|
||||||
def __init__(self, alert_threshold, end_alert_threshold) -> None:
|
def __init__(self, alert_threshold, end_alert_threshold) -> None:
|
||||||
"""Initalize a new algorithm with the both threshold"""
|
"""Initalize a new algorithm with the both threshold"""
|
||||||
@@ -32,6 +37,21 @@ class WindowOpenDetectionAlgorithm:
|
|||||||
self._end_alert_threshold = end_alert_threshold
|
self._end_alert_threshold = end_alert_threshold
|
||||||
self._last_slope = None
|
self._last_slope = None
|
||||||
self._last_datetime = None
|
self._last_datetime = None
|
||||||
|
self._nb_point = 0
|
||||||
|
|
||||||
|
def check_age_last_measurement(self, temperature, datetime_now) -> float:
|
||||||
|
""" " Check if last measurement is old and add
|
||||||
|
a fake measurement point if this is the case
|
||||||
|
"""
|
||||||
|
if self._last_datetime is None:
|
||||||
|
return self.add_temp_measurement(temperature, datetime_now)
|
||||||
|
|
||||||
|
delta_t_sec = float((datetime_now - self._last_datetime).total_seconds())
|
||||||
|
if delta_t_sec >= MAX_DURATION_SEC:
|
||||||
|
return self.add_temp_measurement(temperature, datetime_now)
|
||||||
|
else:
|
||||||
|
# do nothing
|
||||||
|
return self._last_slope
|
||||||
|
|
||||||
def add_temp_measurement(
|
def add_temp_measurement(
|
||||||
self, temperature: float, datetime_measure: datetime
|
self, temperature: float, datetime_measure: datetime
|
||||||
@@ -43,6 +63,7 @@ class WindowOpenDetectionAlgorithm:
|
|||||||
_LOGGER.debug("First initialisation")
|
_LOGGER.debug("First initialisation")
|
||||||
self._last_datetime = datetime_measure
|
self._last_datetime = datetime_measure
|
||||||
self._last_temperature = temperature
|
self._last_temperature = temperature
|
||||||
|
self._nb_point = self._nb_point + 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
@@ -72,22 +93,25 @@ class WindowOpenDetectionAlgorithm:
|
|||||||
)
|
)
|
||||||
return lspe
|
return lspe
|
||||||
|
|
||||||
# if self._last_slope is None:
|
if self._last_slope is None:
|
||||||
self._last_slope = round(new_slope, 4)
|
self._last_slope = round(new_slope, 4)
|
||||||
# else:
|
else:
|
||||||
# self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope)
|
self._last_slope = round((0.2 * self._last_slope) + (0.8 * new_slope), 4)
|
||||||
|
|
||||||
self._last_datetime = datetime_measure
|
self._last_datetime = datetime_measure
|
||||||
self._last_temperature = temperature
|
self._last_temperature = temperature
|
||||||
|
|
||||||
|
self._nb_point = self._nb_point + 1
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f",
|
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f nb_point=%s",
|
||||||
delta_t,
|
delta_t,
|
||||||
delta_temp,
|
delta_temp,
|
||||||
new_slope,
|
new_slope,
|
||||||
lspe,
|
lspe,
|
||||||
self._last_slope,
|
self._last_slope,
|
||||||
|
self._nb_point,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._last_slope
|
return self._last_slope
|
||||||
|
|
||||||
def is_window_open_detected(self) -> bool:
|
def is_window_open_detected(self) -> bool:
|
||||||
@@ -95,22 +119,20 @@ class WindowOpenDetectionAlgorithm:
|
|||||||
if self._alert_threshold is None:
|
if self._alert_threshold is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return (
|
if self._nb_point < MIN_NB_POINT or self._last_slope is None:
|
||||||
self._last_slope < -self._alert_threshold
|
return False
|
||||||
if self._last_slope is not None
|
|
||||||
else False
|
return self._last_slope < -self._alert_threshold
|
||||||
)
|
|
||||||
|
|
||||||
def is_window_close_detected(self) -> bool:
|
def is_window_close_detected(self) -> bool:
|
||||||
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
|
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
|
||||||
if self._end_alert_threshold is None:
|
if self._end_alert_threshold is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return (
|
if self._nb_point < MIN_NB_POINT or self._last_slope is None:
|
||||||
self._last_slope >= self._end_alert_threshold
|
return False
|
||||||
if self._last_slope is not None
|
|
||||||
else False
|
return self._last_slope >= self._end_alert_threshold
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_slope(self) -> float:
|
def last_slope(self) -> float:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async def async_setup_entry(
|
|||||||
LastTemperatureSensor(hass, unique_id, name, entry.data),
|
LastTemperatureSensor(hass, unique_id, name, entry.data),
|
||||||
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
|
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
|
||||||
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
|
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
|
||||||
|
EMATemperatureSensor(hass, unique_id, name, entry.data),
|
||||||
]
|
]
|
||||||
if entry.data.get(CONF_DEVICE_POWER):
|
if entry.data.get(CONF_DEVICE_POWER):
|
||||||
entities.append(EnergySensor(hass, unique_id, name, entry.data))
|
entities.append(EnergySensor(hass, unique_id, name, entry.data))
|
||||||
@@ -542,3 +543,54 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
|||||||
def suggested_display_precision(self) -> int | None:
|
def suggested_display_precision(self) -> int | None:
|
||||||
"""Return the suggested number of decimal digits for display."""
|
"""Return the suggested number of decimal digits for display."""
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
|
||||||
|
"""Representation of a Exponential Moving Average temp"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
|
||||||
|
"""Initialize the regulated temperature sensor"""
|
||||||
|
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
|
||||||
|
self._attr_name = "EMA temperature"
|
||||||
|
self._attr_unique_id = f"{self._device_name}_ema_temperature"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def async_my_climate_changed(self, event: Event = None):
|
||||||
|
"""Called when my climate have change"""
|
||||||
|
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
|
||||||
|
|
||||||
|
if math.isnan(self.my_climate.ema_temperature) or math.isinf(
|
||||||
|
self.my_climate.ema_temperature
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Sensor has illegal state {self.my_climate.ema_temperature}"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_state = self._attr_native_value
|
||||||
|
self._attr_native_value = self.my_climate.ema_temperature
|
||||||
|
if old_state != self._attr_native_value:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str | None:
|
||||||
|
return "mdi:thermometer-lines"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self) -> SensorDeviceClass | None:
|
||||||
|
return SensorDeviceClass.TEMPERATURE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_class(self) -> SensorStateClass | None:
|
||||||
|
return SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self) -> str | None:
|
||||||
|
if not self.my_climate:
|
||||||
|
return UnitOfTemperature.CELSIUS
|
||||||
|
return self.my_climate.temperature_unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_display_precision(self) -> int | None:
|
||||||
|
"""Return the suggested number of decimal digits for display."""
|
||||||
|
return 2
|
||||||
|
|||||||
Reference in New Issue
Block a user