From 0a50d0fd4e56399fd0a8470b5b9d5124047ae806 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 4 Feb 2024 08:57:04 +0100 Subject: [PATCH] Issue 348 self regulation use internal temp (#373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add config option * With all features + testu ok * Change algo using underlying internal temp * Algo fixes * Bières ! * Change reset cumulated_error formula * FIX pi algo test with accumulated_error reset --------- Co-authored-by: Jean-Marc Collin --- README-fr.md | 2 +- README.md | 2 +- copy-to-forum.txt | 7 + .../versatile_thermostat/base_thermostat.py | 4 +- .../versatile_thermostat/config_schema.py | 1 + .../versatile_thermostat/const.py | 2 + .../versatile_thermostat/pi_algorithm.py | 28 ++- .../versatile_thermostat/strings.json | 4 + .../thermostat_climate.py | 51 +++++- .../versatile_thermostat/translations/en.json | 4 + .../versatile_thermostat/translations/fr.json | 4 + .../versatile_thermostat/underlyings.py | 13 ++ tests/commons.py | 14 ++ tests/const.py | 11 ++ tests/test_auto_regulation.py | 173 +++++++++++++++++- tests/test_pi.py | 80 ++++---- 16 files changed, 336 insertions(+), 64 deletions(-) create mode 100644 copy-to-forum.txt diff --git a/README-fr.md b/README-fr.md index 2dfb5c3..e2219dd 100644 --- a/README-fr.md +++ b/README-fr.md @@ -128,7 +128,7 @@ En conséquence toute la phase de paramètrage d'un VTherm a été profondemment **Note :** les copies d'écran de la configuration d'un VTherm n'ont pas été mises à jour. # Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78) -Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG @Mexx62, @Someone, @Lajull, @giopeco pour les bières. Ca fait très plaisir et ça m'encourage à continuer ! +Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG @Mexx62, @Someone, @Lajull, @giopeco, @fredericselier, @philpagan pour les bières. Ca fait très plaisir et ça m'encourage à continuer ! # Quand l'utiliser et ne pas l'utiliser diff --git a/README.md b/README.md index 75105d1..3e979c3 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Consequently, the entire configuration phase of a VTherm has been profoundly mod **Note:** the VTherm configuration screenshots have not been updated. # Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78) -Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG, @MattG, @Mexx62, @Someone, @Lajull, @giopeco for the beers. It's very nice and encourages me to continue! +Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG, @MattG, @Mexx62, @Someone, @Lajull, @giopeco, @fredericselier, @philpagan for the beers. It's very nice and encourages me to continue! # When to use / not use This thermostat can control 3 types of equipment: diff --git a/copy-to-forum.txt b/copy-to-forum.txt new file mode 100644 index 0000000..620a9e1 --- /dev/null +++ b/copy-to-forum.txt @@ -0,0 +1,7 @@ +Before copying to forum you need to replace relative images by this command into VSCode: + +Search : +\(images/(.*).png\) + +Replace with: +(https://github.com/jmcollin78/versatile_thermostat/blob/main/images/$1.png?raw=true) \ No newline at end of file diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index e9a0dc7..9e70229 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1233,9 +1233,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): ) # If AC is on maybe we have to change the temperature in force mode, but not in frost mode (there is no Frost protection possible in AC mode) - if self._hvac_mode == HVACMode.COOL: + if self._hvac_mode == HVACMode.COOL and self.preset_mode != PRESET_NONE: if self.preset_mode != PRESET_FROST_PROTECTION: - await self._async_set_preset_mode_internal(self._attr_preset_mode, True) + await self._async_set_preset_mode_internal(self.preset_mode, True) else: await self._async_set_preset_mode_internal(PRESET_ECO, True, False) diff --git a/custom_components/versatile_thermostat/config_schema.py b/custom_components/versatile_thermostat/config_schema.py index 70b15ae..e673626 100644 --- a/custom_components/versatile_thermostat/config_schema.py +++ b/custom_components/versatile_thermostat/config_schema.py @@ -144,6 +144,7 @@ STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name mode="dropdown", ) ), + vol.Optional(CONF_AUTO_REGULATION_USE_DEVICE_TEMP, default=False): cv.boolean, } ) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index eb656c2..b022960 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -106,6 +106,7 @@ CONF_AUTO_REGULATION_STRONG = "auto_regulation_strong" CONF_AUTO_REGULATION_EXPERT = "auto_regulation_expert" CONF_AUTO_REGULATION_DTEMP = "auto_regulation_dtemp" CONF_AUTO_REGULATION_PERIOD_MIN = "auto_regulation_periode_min" +CONF_AUTO_REGULATION_USE_DEVICE_TEMP = "auto_regulation_use_device_temp" CONF_INVERSE_SWITCH = "inverse_switch_command" CONF_AUTO_FAN_MODE = "auto_fan_mode" CONF_AUTO_FAN_NONE = "auto_fan_none" @@ -257,6 +258,7 @@ ALL_CONF = ( CONF_AUTO_REGULATION_MODE, CONF_AUTO_REGULATION_DTEMP, CONF_AUTO_REGULATION_PERIOD_MIN, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, CONF_INVERSE_SWITCH, CONF_AUTO_FAN_MODE, CONF_USE_MAIN_CENTRAL_CONFIG, diff --git a/custom_components/versatile_thermostat/pi_algorithm.py b/custom_components/versatile_thermostat/pi_algorithm.py index a5f79fe..bd0767b 100644 --- a/custom_components/versatile_thermostat/pi_algorithm.py +++ b/custom_components/versatile_thermostat/pi_algorithm.py @@ -47,16 +47,16 @@ class PITemperatureRegulator: def set_target_temp(self, target_temp): """Set the new target_temp""" self.target_temp = target_temp - # Do not reset the accumulated error # Discussion #191. After a target change we should reset the accumulated error which is certainly wrong now. - if self.accumulated_error < 0: - self.accumulated_error = 0 + # Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed + # if self.accumulated_error < 0: + # self.accumulated_error = 0 def calculate_regulated_temperature( - self, internal_temp: float, external_temp: float + self, room_temp: float, external_temp: float ): # pylint: disable=unused-argument """Calculate a new target_temp given some temperature""" - if internal_temp is None: + if room_temp is None: _LOGGER.warning( "Temporarily skipping the self-regulation algorithm while the configured sensor for room temperature is unavailable" ) @@ -68,9 +68,14 @@ class PITemperatureRegulator: return self.target_temp # Calculate the error factor (P) - error = self.target_temp - internal_temp + error = self.target_temp - room_temp # Calculate the sum of error (I) + # Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed + # If the error have change its sign, reset smoothly the accumulated error + if error * self.accumulated_error < 0: + self.accumulated_error = self.accumulated_error / 2.0 + self.accumulated_error += error # Capping of the error @@ -83,19 +88,12 @@ class PITemperatureRegulator: offset = self.kp * error + self.ki * self.accumulated_error # Calculate the exterior offset - # For Maia tests - use the internal_temp vs external_temp and not target_temp - external_temp - offset_ext = self.k_ext * (internal_temp - external_temp) + offset_ext = self.k_ext * (room_temp - external_temp) - # Capping of offset_ext + # Capping of offset total_offset = offset + offset_ext total_offset = min(self.offset_max, max(-self.offset_max, total_offset)) - # If temperature is near the target_temp, reset the accumulated_error - # Issue #199 - don't reset the accumulation error - # if abs(error) < self.stabilization_threshold: - # _LOGGER.debug("Stabilisation") - # self.accumulated_error = 0 - result = round(self.target_temp + total_offset, 1) _LOGGER.debug( diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 04698c0..e6193ea 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -60,6 +60,7 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", "inverse_switch_command": "Inverse switch command", "auto_fan_mode": " Auto fan mode" }, @@ -82,6 +83,7 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } @@ -297,6 +299,7 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", "inverse_switch_command": "Inverse switch command", "auto_fan_mode": " Auto fan mode" }, @@ -319,6 +322,7 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 5fdac39..8189683 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -36,6 +36,7 @@ from .const import ( CONF_AUTO_REGULATION_EXPERT, CONF_AUTO_REGULATION_DTEMP, CONF_AUTO_REGULATION_PERIOD_MIN, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, CONF_AUTO_FAN_MODE, CONF_AUTO_FAN_NONE, CONF_AUTO_FAN_LOW, @@ -90,6 +91,7 @@ class ThermostatOverClimate(BaseThermostat): "current_auto_fan_mode", "auto_activated_fan_mode", "auto_deactivated_fan_mode", + "auto_regulation_use_device_temp", } ) ) @@ -192,8 +194,42 @@ class ThermostatOverClimate(BaseThermostat): self._last_regulation_change = now for under in self._underlyings: + # issue 348 - use device temperature if configured as offset + offset_temp = 0 + device_temp = 0 + if ( + # regulation can use the device_temp + self.auto_regulation_use_device_temp + # and we have access to the device temp + and (device_temp := under.underlying_current_temperature) is not None + # and target is not reach (ie we need regulation) + and ( + ( + self.hvac_mode == HVACMode.COOL + and self.target_temperature < self.current_temperature + ) + or ( + self.hvac_mode == HVACMode.HEAT + and self.target_temperature > self.current_temperature + ) + ) + ): + offset_temp = device_temp - self.current_temperature + + target_temp = self.regulated_target_temp + offset_temp + + _LOGGER.debug( + "%s - The device offset temp for regulation is %.2f - internal temp is %.2f. New target is %.2f", + self, + offset_temp, + device_temp, + target_temp, + ) + await under.set_temperature( - self.regulated_target_temp, self._attr_max_temp, self._attr_min_temp + target_temp, + self._attr_max_temp, + self._attr_min_temp, ) async def _send_auto_fan_mode(self): @@ -284,6 +320,10 @@ class ThermostatOverClimate(BaseThermostat): else CONF_AUTO_FAN_NONE ) + self._auto_regulation_use_device_temp = config_entry.get( + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False + ) + def choose_auto_regulation_mode(self, auto_regulation_mode: str): """Choose or change the regulation mode""" self._auto_regulation_mode = auto_regulation_mode @@ -492,6 +532,10 @@ class ThermostatOverClimate(BaseThermostat): "auto_deactivated_fan_mode" ] = self._auto_deactivated_fan_mode + self._attr_extra_state_attributes[ + "auto_regulation_use_device_temp" + ] = self.auto_regulation_use_device_temp + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -770,6 +814,11 @@ class ThermostatOverClimate(BaseThermostat): """Get the auto fan mode""" return self._auto_fan_mode + @property + def auto_regulation_use_device_temp(self) -> bool | None: + """Returns the value of parameter auto_regulation_use_device_temp""" + return self._auto_regulation_use_device_temp + @property def regulated_target_temp(self) -> float | None: """Get the regulated target temperature""" diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 04698c0..e6193ea 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -60,6 +60,7 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", "inverse_switch_command": "Inverse switch command", "auto_fan_mode": " Auto fan mode" }, @@ -82,6 +83,7 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } @@ -297,6 +299,7 @@ "auto_regulation_mode": "Self-regulation", "auto_regulation_dtemp": "Regulation threshold", "auto_regulation_periode_min": "Regulation minimal period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", "inverse_switch_command": "Inverse switch command", "auto_fan_mode": " Auto fan mode" }, @@ -319,6 +322,7 @@ "auto_regulation_mode": "Auto adjustment of the target temperature", "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" } diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 2904fad..59c6209 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -60,6 +60,7 @@ "auto_regulation_mode": "Auto-régulation", "auto_regulation_dtemp": "Seuil de régulation", "auto_regulation_periode_min": "Période minimale de régulation", + "auto_regulation_use_device_temp": "Utiliser la température interne du sous-jacent", "inverse_switch_command": "Inverser la commande", "auto_fan_mode": " Auto ventilation mode" }, @@ -82,6 +83,7 @@ "auto_regulation_mode": "Ajustement automatique de la température cible", "auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) en-dessous duquel la régulation ne sera pas envoyée", "auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation", + "auto_regulation_use_device_temp": "Utiliser la temperature interne du sous-jacent pour accélérer l'auto-régulation", "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode", "auto_fan_mode": "Active la ventilation automatiquement en cas d'écart important" } @@ -309,6 +311,7 @@ "auto_regulation_mode": "Auto-regulation", "auto_regulation_dtemp": "Seuil de régulation", "auto_regulation_periode_min": "Période minimale de régulation", + "auto_regulation_use_device_temp": "Utiliser la température interne du sous-jacent", "inverse_switch_command": "Inverser la commande", "auto_fan_mode": " Auto fan mode" }, @@ -331,6 +334,7 @@ "auto_regulation_mode": "Ajustement automatique de la consigne", "auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) en-dessous duquel la régulation ne sera pas envoyée", "auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation", + "auto_regulation_use_device_temp": "Utiliser la temperature interne du sous-jacent pour accélérer l'auto-régulation", "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode", "auto_fan_mode": "Active la ventilation automatiquement en cas d'écart important" } diff --git a/custom_components/versatile_thermostat/underlyings.py b/custom_components/versatile_thermostat/underlyings.py index 14ee73d..e558ef6 100644 --- a/custom_components/versatile_thermostat/underlyings.py +++ b/custom_components/versatile_thermostat/underlyings.py @@ -580,6 +580,7 @@ class UnderlyingClimate(UnderlyingEntity): """Set the target temperature""" if not self.is_initialized: return + data = { ATTR_ENTITY_ID: self._entity_id, "temperature": self.cap_sent_value(temperature), @@ -684,6 +685,18 @@ class UnderlyingClimate(UnderlyingEntity): return False return self._underlying_climate.is_aux_heat + @property + def underlying_current_temperature(self) -> float | None: + """Get the underlying current_temperature if it exists + and if initialized""" + if not self.is_initialized: + return None + + if not hasattr(self._underlying_climate, "current_temperature"): + return None + + return self._underlying_climate.current_temperature + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" if not self.is_initialized: diff --git a/tests/commons.py b/tests/commons.py index ccefd54..68bdfda 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -51,6 +51,7 @@ from .const import ( # pylint: disable=unused-import MOCK_TH_OVER_CLIMATE_MAIN_CONFIG, MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, + MOCK_TH_OVER_CLIMATE_TYPE_USE_DEVICE_TEMP_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG, MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG, MOCK_TH_OVER_SWITCH_TPI_CONFIG, @@ -110,6 +111,15 @@ PARTIAL_CLIMATE_CONFIG = ( | MOCK_ADVANCED_CONFIG ) +PARTIAL_CLIMATE_CONFIG_USE_DEVICE_TEMP = ( + MOCK_TH_OVER_CLIMATE_USER_CONFIG + | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG + | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG + | MOCK_TH_OVER_CLIMATE_TYPE_USE_DEVICE_TEMP_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_ADVANCED_CONFIG +) + PARTIAL_CLIMATE_NOT_REGULATED_CONFIG = ( MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG @@ -313,6 +323,10 @@ class MockClimate(ClimateEntity): """Set the HVACaction""" self._attr_hvac_action = hvac_action + def set_current_temperature(self, current_temperature): + """Set the current_temperature""" + self._attr_current_temperature = current_temperature + class MockUnavailableClimate(ClimateEntity): """A Mock Climate class used for Underlying climate mode""" diff --git a/tests/const.py b/tests/const.py index 0c3e324..b00569c 100644 --- a/tests/const.py +++ b/tests/const.py @@ -110,6 +110,17 @@ MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { CONF_AUTO_REGULATION_DTEMP: 0.5, CONF_AUTO_REGULATION_PERIOD_MIN: 2, CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False, +} + +MOCK_TH_OVER_CLIMATE_TYPE_USE_DEVICE_TEMP_CONFIG = { + CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: False, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG, + CONF_AUTO_REGULATION_DTEMP: 0.1, + CONF_AUTO_REGULATION_PERIOD_MIN: 2, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP: True, } MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG = { diff --git a/tests/test_auto_regulation.py b/tests/test_auto_regulation.py index d254153..0302f8b 100644 --- a/tests/test_auto_regulation.py +++ b/tests/test_auto_regulation.py @@ -1,7 +1,7 @@ # pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long """ Test the normal start of a Thermostat """ -from unittest.mock import patch # , call +from unittest.mock import patch, call from datetime import datetime, timedelta from homeassistant.core import HomeAssistant @@ -71,6 +71,7 @@ async def test_over_climate_regulation( assert entity.name == "TheOverClimateMockName" assert entity.is_over_climate is True assert entity.is_regulated is True + assert entity.auto_regulation_use_device_temp is False assert entity.hvac_mode is HVACMode.OFF assert entity.hvac_action is HVACAction.OFF assert entity.target_temperature == entity.min_temp @@ -126,9 +127,7 @@ async def test_over_climate_regulation( # the regulated temperature should be under assert entity.regulated_target_temp < entity.target_temperature - assert ( - entity.regulated_target_temp == 18 - 2 - ) # normally 0.6 but round_to_nearest gives 0.5 + assert entity.regulated_target_temp == 18 - 2.5 @pytest.mark.parametrize("expected_lingering_tasks", [True]) @@ -374,3 +373,169 @@ async def test_over_climate_regulation_limitations( assert ( entity.regulated_target_temp == 17 + 1.5 ) # 0.7 without round_to_nearest + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_over_climate_regulation_use_device_temp( + hass: HomeAssistant, skip_hass_states_is_state, skip_send_event +): + """Test the regulation of an over climate thermostat""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + # This is include a medium regulation + data=PARTIAL_CLIMATE_CONFIG_USE_DEVICE_TEMP, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}) + + assert fake_underlying_climate.current_temperature == 15 + + # Creates the regulated VTherm over climate + # change temperature so that the heating will start + event_timestamp = now - timedelta(minutes=10) + + with patch( + "custom_components.versatile_thermostat.commons.NowClass.get_now", + return_value=event_timestamp, + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + entity: ThermostatOverClimate = await create_thermostat( + hass, entry, "climate.theoverclimatemockname" + ) + assert entity + assert isinstance(entity, ThermostatOverClimate) + + assert entity.name == "TheOverClimateMockName" + assert entity.is_over_climate is True + assert entity.is_regulated is True + assert entity.auto_regulation_use_device_temp is True + + # 1. Activate the heating by changing HVACMode and temperature + # Select a hvacmode, presence and preset + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.regulated_target_temp == entity.min_temp + + await send_temperature_change_event(entity, 18, event_timestamp) + await send_ext_temperature_change_event(entity, 10, event_timestamp) + + # 2. set manual target temp (at now - 7) -> no regulation should occurs + # room temp is 18 + # target is 16 + # internal heater temp is 15 + fake_underlying_climate.set_current_temperature(15) + event_timestamp = now - timedelta(minutes=7) + with patch( + "custom_components.versatile_thermostat.commons.NowClass.get_now", + return_value=event_timestamp, + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await entity.async_set_temperature(temperature=16) + + fake_underlying_climate.set_hvac_action( + HVACAction.HEATING + ) # simulate under heating + assert entity.hvac_action == HVACAction.HEATING + assert entity.preset_mode == PRESET_NONE # Manual mode + + # the regulated temperature should be higher + assert entity.regulated_target_temp < entity.target_temperature + # The calcul is the following: 16 + (16 - 18) x 0.4 (strong) + 0 x ki - 1 (device offset) + assert ( + entity.regulated_target_temp == 15 + ) # round(16 + (16 - 18) * 0.4 + 0 * 0.08) + assert entity.hvac_action == HVACAction.HEATING + + mock_service_call.assert_has_calls( + [ + call.service_call( + "climate", + "set_temperature", + { + "entity_id": "climate.mock_climate", + # because device offset is -3 but not used because target is reach + "temperature": 15.0, + "target_temp_high": 30, + "target_temp_low": 15, + }, + ), + ] + ) + + # 3. change temperature so that the regulated temperature should slow down + # HVACMODE.HEAT + # room temp is 15 + # target is 18 + # internal heater temp is 20 + fake_underlying_climate.set_current_temperature(20) + await entity.async_set_temperature(temperature=18) + await send_ext_temperature_change_event(entity, 9, event_timestamp) + + event_timestamp = now - timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.commons.NowClass.get_now", + return_value=event_timestamp, + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await send_temperature_change_event(entity, 15, event_timestamp) + + # the regulated temperature should be under (device offset is -2) + assert entity.regulated_target_temp > entity.target_temperature + assert entity.regulated_target_temp == 19.4 # 18 + 1.4 + + mock_service_call.assert_has_calls( + [ + call.service_call( + "climate", + "set_temperature", + { + "entity_id": "climate.mock_climate", + "temperature": 24.4, # 19.4 + 5 + "target_temp_high": 30, + "target_temp_low": 15, + }, + ), + ] + ) + + # 4. In cool mode + # room temp is 25 + # target is 23 + # internal heater temp is 27 + await entity.async_set_hvac_mode(HVACMode.COOL) + await entity.async_set_temperature(temperature=23) + fake_underlying_climate.set_current_temperature(27) + await send_ext_temperature_change_event(entity, 30, event_timestamp) + + event_timestamp = now - timedelta(minutes=3) + with patch( + "custom_components.versatile_thermostat.commons.NowClass.get_now", + return_value=event_timestamp, + ), patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call: + await send_temperature_change_event(entity, 25, event_timestamp) + + # the regulated temperature should be upper (device offset is +2) + assert entity.regulated_target_temp < entity.target_temperature + assert entity.regulated_target_temp == 22.4 + + mock_service_call.assert_has_calls( + [ + call.service_call( + "climate", + "set_temperature", + { + "entity_id": "climate.mock_climate", + "temperature": 24.4, # 22.4 + 2° of offset + "target_temp_high": 30, + "target_temp_low": 15, + }, + ), + ] + ) diff --git a/tests/test_pi.py b/tests/test_pi.py index 4d99d93..d72c09d 100644 --- a/tests/test_pi.py +++ b/tests/test_pi.py @@ -42,15 +42,15 @@ def test_pi_algorithm_basics(): assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.6 # +1.7 assert the_algo.calculate_regulated_temperature(19, 10) == 21.6 # +1.7 assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5 - assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.8 - assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.7 - assert the_algo.calculate_regulated_temperature(20, 10) == 21.4 # +0.7 + assert the_algo.calculate_regulated_temperature(21, 10) == 21.1 # error change sign + assert the_algo.calculate_regulated_temperature(21, 10) == 20.9 + assert the_algo.calculate_regulated_temperature(20, 10) == 21.0 # Test temperature external - assert the_algo.calculate_regulated_temperature(20, 12) == 21.2 # +0.8 - assert the_algo.calculate_regulated_temperature(20, 15) == 20.9 # +0.5 - assert the_algo.calculate_regulated_temperature(20, 18) == 20.6 # +0.2 - assert the_algo.calculate_regulated_temperature(20, 20) == 20.4 # = + assert the_algo.calculate_regulated_temperature(20, 12) == 20.8 + assert the_algo.calculate_regulated_temperature(20, 15) == 20.5 + assert the_algo.calculate_regulated_temperature(20, 18) == 20.2 # +0.2 + assert the_algo.calculate_regulated_temperature(20, 20) == 20 # = def test_pi_algorithm_light(): @@ -78,15 +78,15 @@ def test_pi_algorithm_light(): assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.6 # +1.7 assert the_algo.calculate_regulated_temperature(19, 10) == 21.6 # +1.7 assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5 - assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.8 - assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.7 - assert the_algo.calculate_regulated_temperature(20, 10) == 21.4 # +0.7 + assert the_algo.calculate_regulated_temperature(21, 10) == 21.1 # Error sign change + assert the_algo.calculate_regulated_temperature(21, 10) == 20.9 + assert the_algo.calculate_regulated_temperature(20, 10) == 21 # Test temperature external - assert the_algo.calculate_regulated_temperature(20, 12) == 21.2 # +0.8 - assert the_algo.calculate_regulated_temperature(20, 15) == 20.9 # +0.5 - assert the_algo.calculate_regulated_temperature(20, 18) == 20.6 # +0.2 - assert the_algo.calculate_regulated_temperature(20, 20) == 20.4 # = + assert the_algo.calculate_regulated_temperature(20, 12) == 20.8 # +0.8 + assert the_algo.calculate_regulated_temperature(20, 15) == 20.5 # +0.5 + assert the_algo.calculate_regulated_temperature(20, 18) == 20.2 # +0.2 + assert the_algo.calculate_regulated_temperature(20, 20) == 20.0 # = def test_pi_algorithm_medium(): @@ -114,20 +114,20 @@ def test_pi_algorithm_medium(): assert the_algo.calculate_regulated_temperature(18.7, 10) == 22.4 assert the_algo.calculate_regulated_temperature(19, 10) == 22.3 assert the_algo.calculate_regulated_temperature(20, 10) == 21.9 - assert the_algo.calculate_regulated_temperature(21, 10) == 21.4 - assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 - assert the_algo.calculate_regulated_temperature(20, 10) == 21.7 + assert the_algo.calculate_regulated_temperature(21, 10) == 21.0 # error sign change + assert the_algo.calculate_regulated_temperature(21, 10) == 20.7 + assert the_algo.calculate_regulated_temperature(20, 10) == 21.1 # Test temperature external - assert the_algo.calculate_regulated_temperature(20, 8) == 21.9 - assert the_algo.calculate_regulated_temperature(20, 6) == 22.1 - assert the_algo.calculate_regulated_temperature(20, 4) == 22.3 - assert the_algo.calculate_regulated_temperature(20, 2) == 22.5 - assert the_algo.calculate_regulated_temperature(20, 0) == 22.7 - assert the_algo.calculate_regulated_temperature(20, -2) == 22.9 - assert the_algo.calculate_regulated_temperature(20, -4) == 23.0 - assert the_algo.calculate_regulated_temperature(20, -6) == 23.0 - assert the_algo.calculate_regulated_temperature(20, -8) == 23.0 + assert the_algo.calculate_regulated_temperature(20, 8) == 21.3 + assert the_algo.calculate_regulated_temperature(20, 6) == 21.5 + assert the_algo.calculate_regulated_temperature(20, 4) == 21.7 + assert the_algo.calculate_regulated_temperature(20, 2) == 21.9 + assert the_algo.calculate_regulated_temperature(20, 0) == 22.1 + assert the_algo.calculate_regulated_temperature(20, -2) == 22.3 + assert the_algo.calculate_regulated_temperature(20, -4) == 22.5 + assert the_algo.calculate_regulated_temperature(20, -6) == 22.7 + assert the_algo.calculate_regulated_temperature(20, -8) == 22.9 # to reset the accumulated erro the_algo.set_target_temp(20) @@ -173,22 +173,22 @@ def test_pi_algorithm_strong(): assert the_algo.calculate_regulated_temperature(18.7, 10) == 24 assert the_algo.calculate_regulated_temperature(19, 10) == 24 assert the_algo.calculate_regulated_temperature(20, 10) == 23.9 - assert the_algo.calculate_regulated_temperature(21, 10) == 23.3 - assert the_algo.calculate_regulated_temperature(21, 10) == 23.1 - assert the_algo.calculate_regulated_temperature(21, 10) == 22.9 - assert the_algo.calculate_regulated_temperature(21, 10) == 22.7 - assert the_algo.calculate_regulated_temperature(21, 10) == 22.5 - assert the_algo.calculate_regulated_temperature(21, 10) == 22.3 - assert the_algo.calculate_regulated_temperature(21, 10) == 22.1 + assert the_algo.calculate_regulated_temperature(21, 10) == 22.3 # error sign change + assert the_algo.calculate_regulated_temperature(21, 10) == 21.8 + assert the_algo.calculate_regulated_temperature(21, 10) == 21.5 + assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 + assert the_algo.calculate_regulated_temperature(21, 10) == 21.1 + assert the_algo.calculate_regulated_temperature(21, 10) == 20.9 + assert the_algo.calculate_regulated_temperature(21, 10) == 20.7 # Test temperature external - assert the_algo.calculate_regulated_temperature(20, 8) == 22.9 - assert the_algo.calculate_regulated_temperature(20, 6) == 23.3 - assert the_algo.calculate_regulated_temperature(20, 4) == 23.7 - assert the_algo.calculate_regulated_temperature(20, 2) == 24 - assert the_algo.calculate_regulated_temperature(20, 0) == 24 - assert the_algo.calculate_regulated_temperature(20, -2) == 24 - assert the_algo.calculate_regulated_temperature(20, -4) == 24 + assert the_algo.calculate_regulated_temperature(20, 8) == 21.5 + assert the_algo.calculate_regulated_temperature(20, 6) == 21.9 + assert the_algo.calculate_regulated_temperature(20, 4) == 22.3 + assert the_algo.calculate_regulated_temperature(20, 2) == 22.7 + assert the_algo.calculate_regulated_temperature(20, 0) == 23.1 + assert the_algo.calculate_regulated_temperature(20, -2) == 23.5 + assert the_algo.calculate_regulated_temperature(20, -4) == 23.9 assert the_algo.calculate_regulated_temperature(20, -6) == 24 assert the_algo.calculate_regulated_temperature(20, -8) == 24