diff --git a/custom_components/quatt/const.py b/custom_components/quatt/const.py index 4478879..9280bfd 100644 --- a/custom_components/quatt/const.py +++ b/custom_components/quatt/const.py @@ -15,3 +15,45 @@ DEFAULT_SCAN_INTERVAL: Final = 10 MIN_SCAN_INTERVAL: Final = 5 MAX_SCAN_INTERVAL: Final = 600 + +# Temperature-dependent conversion factors for water in a central heating system at 2 bar pressure. +# The table below provides specific heat capacity (c_p), density (rho), and conversion factors (k) +# for temperatures ranging from 5°C to 80°C in steps of 5°C. + +# Temperature (C) | c_p (J/kg.C) | rho (kg/m^3) | Conversion Factor (k) +# ------------------------------------------------------------------------- +# 5 | 4200.0 | 999.97 | 1.166667 +# 10 | 4192.0 | 999.70 | 1.164444 +# 15 | 4187.6 | 999.10 | 1.162889 +# 20 | 4184.1 | 998.00 | 1.161111 +# 25 | 4181.8 | 997.05 | 1.157438 +# 30 | 4184.0 | 995.67 | 1.157753 +# 35 | 4186.2 | 994.06 | 1.157931 +# 40 | 4188.4 | 992.22 | 1.157964 +# 45 | 4190.6 | 990.25 | 1.157859 +# 50 | 4192.8 | 988.05 | 1.157617 +# 55 | 4195.0 | 985.65 | 1.157243 +# 60 | 4197.2 | 983.15 | 1.156742 +# 65 | 4199.4 | 980.44 | 1.156117 +# 70 | 4201.6 | 977.63 | 1.155369 +# 75 | 4203.8 | 974.71 | 1.154503 +# 80 | 4206.0 | 971.80 | 1.153528 +# The specific heat capacity (c_p) and density (ρ) values are from the NIST Chemistry WebBook. +CONVERSION_FACTORS = { + 5: 1.166667, + 10: 1.164444, + 15: 1.162889, + 20: 1.161111, + 25: 1.157438, + 30: 1.157753, + 35: 1.157931, + 40: 1.157964, + 45: 1.157859, + 50: 1.157617, + 55: 1.157243, + 60: 1.156742, + 65: 1.156117, + 70: 1.155369, + 75: 1.154503, + 80: 1.153528, +} diff --git a/custom_components/quatt/coordinator.py b/custom_components/quatt/coordinator.py index a12f823..339d207 100644 --- a/custom_components/quatt/coordinator.py +++ b/custom_components/quatt/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import QuattApiClient, QuattApiClientAuthenticationError, QuattApiClientError -from .const import CONF_POWER_SENSOR, DOMAIN, LOGGER +from .const import CONF_POWER_SENSOR, CONVERSION_FACTORS, DOMAIN, LOGGER # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities @@ -67,6 +67,13 @@ def boilerOpenTherm(self): LOGGER.debug(self.getValue("boiler.otFbChModeActive")) return self.getValue("boiler.otFbChModeActive") is not None + def getConversionFactor(self, temperature: float): + """Get the conversion factor for the nearest temperature.""" + nearestTemperature = min( + CONVERSION_FACTORS.keys(), key=lambda t: abs(t - temperature) + ) + return CONVERSION_FACTORS[nearestTemperature] + def electricalPower(self): """Get heatpump power from sensor.""" if self._power_sensor_id is None: @@ -109,42 +116,118 @@ def computedWaterDelta(self, parent_key: str | None = None): def computedHeatPower(self, parent_key: str | None = None): """Compute heatPower.""" - computedWaterDelta = ( - self.computedWaterDelta(None) - if self.heatpump2Active() - else self.computedWaterDelta("hp1") - ) + + # Retrieve the supervisory control mode state first + state = self.getValue("qc.supervisoryControlMode") + LOGGER.debug("computedBoilerHeatPower.supervisoryControlMode: %s", state) + + # If the state is not valid or the heatpump is not active, no need to proceed + if state is None: + return None + if state not in [2, 3]: + return 0.0 + + if self.heatpump2Active(): + computedWaterDelta = self.computedWaterDelta(None) + temperatureWaterOut = self.getValue("hp2.temperatureWaterOut") + else: + computedWaterDelta = self.computedWaterDelta("hp1") + temperatureWaterOut = self.getValue("hp1.temperatureWaterOut") flowRate = self.getValue("qc.flowRateFiltered") LOGGER.debug("computedHeatPower.computedWaterDelta %s", computedWaterDelta) LOGGER.debug("computedHeatPower.flowRate %s", flowRate) + LOGGER.debug("computedHeatPower.temperatureWaterOut %s", temperatureWaterOut) - if computedWaterDelta is None or flowRate is None: + if ( + computedWaterDelta is None + or flowRate is None + or temperatureWaterOut is None + ): return None value = round( - computedWaterDelta * flowRate * 1.137888, + computedWaterDelta + * flowRate + * self.getConversionFactor(temperatureWaterOut), 2, ) - # Prevent negative sign for 0 values (like: -0.0) - if value == 0: - return math.copysign(0.0, 1) - return value + # Prevent any negative numbers + return max(value, 0.00) - def computedPowerInput(self, parent_key: str | None = None): - """Compute total powerInput.""" - powerInputHp1 = self.getValue("hp1.powerInput", 0) - powerInputHp2 = self.getValue("hp2.powerInput", 0) + def computedBoilerHeatPower(self, parent_key: str | None = None) -> float | None: + """Compute the boiler's added heat power.""" - return float(powerInputHp1) + float(powerInputHp2) + # Retrieve the supervisory control mode state first + state = self.getValue("qc.supervisoryControlMode") + LOGGER.debug("computedBoilerHeatPower.supervisoryControlMode: %s", state) - def computedPower(self, parent_key: str | None = None): + # If the state is not valid or the boiler is not active, no need to proceed + if state is None: + return None + if state not in [3, 4]: + return 0.0 + + # Retrieve other required values + heatpumpWaterOut = ( + self.getValue("hp2.temperatureWaterOut") + if self.heatpump2Active() + else self.getValue("hp1.temperatureWaterOut") + ) + flowRate = self.getValue("qc.flowRateFiltered") + flowWaterTemperature = self.getValue("flowMeter.waterSupplyTemperature") + + # Log debug information + LOGGER.debug( + "computedBoilerHeatPower.temperatureWaterOut: %s", heatpumpWaterOut + ) + LOGGER.debug("computedBoilerHeatPower.flowRate: %s", flowRate) + LOGGER.debug( + "computedBoilerHeatPower.waterSupplyTemperature: %s", flowWaterTemperature + ) + + # Validate other inputs + if heatpumpWaterOut is None or flowRate is None or flowWaterTemperature is None: + return None + + # Compute the heat power using the conversion factor + conversionFactor = self.getConversionFactor(flowWaterTemperature) + value = round( + (flowWaterTemperature - heatpumpWaterOut) * flowRate * conversionFactor, 2 + ) + + # Prevent any negative numbers + return max(value, 0.00) + + def computedSystemPower(self, parent_key: str | None = None): + """Compute total system power.""" + boilerPower = self.computedBoilerHeatPower(parent_key) + heatpumpPower = self.computedPower(parent_key) + + # Log debug information + LOGGER.debug("computedSystemPower.boilerPower: %s", boilerPower) + LOGGER.debug("computedSystemPower.heatpumpPower: %s", heatpumpPower) + + # Validate inputs + if boilerPower is None or heatpumpPower is None: + return None + + return float(boilerPower) + float(heatpumpPower) + + def computedPowerInput(self, parent_key: str | None = None): """Compute total powerInput.""" - powerHp1 = self.getValue("hp1.power", 0) - powerHp2 = self.getValue("hp2.power", 0) + powerInputHp1 = float(self.getValue("hp1.powerInput", 0)) + powerInputHp2 = ( + float(self.getValue("hp2.powerInput", 0)) if self.heatpump2Active() else 0 + ) + return powerInputHp1 + powerInputHp2 - return float(powerHp1) + float(powerHp2) + def computedPower(self, parent_key: str | None = None): + """Compute total power.""" + powerHp1 = float(self.getValue("hp1.power", 0)) + powerHp2 = float(self.getValue("hp2.power", 0)) if self.heatpump2Active() else 0 + return powerHp1 + powerHp2 def computedCop(self, parent_key: str | None = None): """Compute COP.""" @@ -167,11 +250,8 @@ def computedCop(self, parent_key: str | None = None): def computedQuattCop(self, parent_key: str | None = None): """Compute Quatt COP.""" if parent_key is None: - parent_key = "" - powerInput = self.getValue("hp1.powerInput", 0) + self.getValue( - "hp2.powerInput", 0 - ) - powerOutput = self.getValue("hp1.power", 0) + self.getValue("hp2.power", 0) + powerInput = self.computedPowerInput(parent_key) + powerOutput = self.computedPower(parent_key) else: powerInput = self.getValue(parent_key + ".powerInput") powerOutput = self.getValue(parent_key + ".power") @@ -187,7 +267,10 @@ def computedQuattCop(self, parent_key: str | None = None): if powerInput == 0: return None - return round(powerOutput / powerInput, 2) + value = round(powerOutput / powerInput, 2) + + # Prevent negative sign for 0 values (like: -0.0) + return math.copysign(0.0, 1) if value == 0 else value def computedDefrost(self, parent_key: str | None = None): """Compute Quatt Defrost State.""" diff --git a/custom_components/quatt/sensor.py b/custom_components/quatt/sensor.py index 5a6ff62..bfdd424 100644 --- a/custom_components/quatt/sensor.py +++ b/custom_components/quatt/sensor.py @@ -193,7 +193,6 @@ icon="mdi:lightning-bolt", native_unit_of_measurement="W", device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=False, suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, quatt_duo=True, @@ -204,18 +203,25 @@ icon="mdi:heat-wave", native_unit_of_measurement="W", device_class=SensorDeviceClass.POWER, - entity_registry_enabled_default=False, suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, quatt_duo=True, ), + QuattSensorEntityDescription( + name="Total system power", + key="computedSystemPower", + icon="mdi:heat-wave", + native_unit_of_measurement="W", + device_class=SensorDeviceClass.POWER, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), QuattSensorEntityDescription( name="Total water delta", key="computedWaterDelta", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - entity_registry_enabled_default=False, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, quatt_duo=True, @@ -225,7 +231,6 @@ key="computedQuattCop", icon="mdi:heat-pump", native_unit_of_measurement="CoP", - entity_registry_enabled_default=False, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, quatt_duo=True, @@ -251,6 +256,15 @@ state_class=SensorStateClass.MEASUREMENT, quatt_opentherm=True, ), + QuattSensorEntityDescription( + name="Boiler heat power", + key="boiler.computedBoilerHeatPower", + icon="mdi:heat-wave", + native_unit_of_measurement="W", + device_class=SensorDeviceClass.POWER, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), # Flowmeter QuattSensorEntityDescription( name="Flowmeter temperature",