diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 25bbebd..8737609 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -880,6 +880,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): """Return the unit of measurement.""" return self._unit + @property + def ema_temperature(self) -> str: + """Return the EMA temperature.""" + return self._ema_temp + @property def hvac_mode(self) -> HVACMode | None: """Return current operation.""" @@ -1671,7 +1676,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): for under in self._underlyings: 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""" async def dearm_window_auto(_): @@ -1701,9 +1706,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity): if not self._window_auto_algo: return - slope = self._window_auto_algo.add_temp_measurement( - temperature=self._ema_temp, datetime_measure=self._last_temperature_mesure - ) + if in_cycle: + 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( "%s - Window auto is on, check the alert. last slope is %.3f", self, @@ -2052,6 +2065,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): 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 for under in self._underlyings: if not under.is_initialized: @@ -2095,9 +2111,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): force, ) - # calculate the smooth_temperature with EMA calculation - await self._async_manage_window_auto() - self.update_custom_attributes() return True diff --git a/custom_components/versatile_thermostat/open_window_algorithm.py b/custom_components/versatile_thermostat/open_window_algorithm.py index 2bbd418..267efb7 100644 --- a/custom_components/versatile_thermostat/open_window_algorithm.py +++ b/custom_components/versatile_thermostat/open_window_algorithm.py @@ -13,9 +13,13 @@ from datetime import datetime _LOGGER = logging.getLogger(__name__) # 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_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: """The class that implements the algorithm listed above""" @@ -25,6 +29,7 @@ class WindowOpenDetectionAlgorithm: _last_slope: float _last_datetime: datetime _last_temperature: float + _nb_point: int def __init__(self, alert_threshold, end_alert_threshold) -> None: """Initalize a new algorithm with the both threshold""" @@ -32,6 +37,21 @@ class WindowOpenDetectionAlgorithm: self._end_alert_threshold = end_alert_threshold self._last_slope = 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( self, temperature: float, datetime_measure: datetime @@ -43,6 +63,7 @@ class WindowOpenDetectionAlgorithm: _LOGGER.debug("First initialisation") self._last_datetime = datetime_measure self._last_temperature = temperature + self._nb_point = self._nb_point + 1 return None _LOGGER.debug( @@ -72,22 +93,25 @@ class WindowOpenDetectionAlgorithm: ) return lspe - # if self._last_slope is None: - self._last_slope = round(new_slope, 4) - # else: - # self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope) + if self._last_slope is None: + self._last_slope = round(new_slope, 4) + else: + self._last_slope = round((0.2 * self._last_slope) + (0.8 * new_slope), 4) self._last_datetime = datetime_measure self._last_temperature = temperature + self._nb_point = self._nb_point + 1 _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_temp, new_slope, lspe, self._last_slope, + self._nb_point, ) + return self._last_slope def is_window_open_detected(self) -> bool: @@ -95,22 +119,20 @@ class WindowOpenDetectionAlgorithm: if self._alert_threshold is None: return False - return ( - self._last_slope < -self._alert_threshold - if self._last_slope is not None - else False - ) + if self._nb_point < MIN_NB_POINT or self._last_slope is None: + return False + + return self._last_slope < -self._alert_threshold def is_window_close_detected(self) -> bool: """True if the last calculated slope is above (cause negative) the _end_alert_threshold""" if self._end_alert_threshold is None: return False - return ( - self._last_slope >= self._end_alert_threshold - if self._last_slope is not None - else False - ) + if self._nb_point < MIN_NB_POINT or self._last_slope is None: + return False + + return self._last_slope >= self._end_alert_threshold @property def last_slope(self) -> float: diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index f02ebdf..79bad92 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -51,6 +51,7 @@ async def async_setup_entry( LastTemperatureSensor(hass, unique_id, name, entry.data), LastExtTemperatureSensor(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): entities.append(EnergySensor(hass, unique_id, name, entry.data)) @@ -542,3 +543,54 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" 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