From c785db4ffad3600b61df58f72a4aed4a80585ec2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 29 Jun 2021 12:20:10 +0200 Subject: [PATCH] Normalize energy statistics to kWh (#52238) --- homeassistant/components/sensor/recorder.py | 53 +++++++++++++++++++-- tests/components/sensor/test_recorder.py | 36 ++++++++++---- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 6de5ee6338a..35039de93c4 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime import itertools +import logging from homeassistant.components.recorder import history, statistics from homeassistant.components.sensor import ( @@ -15,12 +16,19 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, ) -from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, +) from homeassistant.core import HomeAssistant, State import homeassistant.util.dt as dt_util from . import DOMAIN +_LOGGER = logging.getLogger(__name__) + DEVICE_CLASS_STATISTICS = { DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, DEVICE_CLASS_ENERGY: {"sum"}, @@ -30,6 +38,15 @@ DEVICE_CLASS_STATISTICS = { DEVICE_CLASS_MONETARY: {"sum"}, } +UNIT_CONVERSIONS = { + DEVICE_CLASS_ENERGY: { + ENERGY_KILO_WATT_HOUR: lambda x: x, + ENERGY_WATT_HOUR: lambda x: x / 1000, + } +} + +WARN_UNSUPPORTED_UNIT = set() + def _get_entities(hass: HomeAssistant) -> list[tuple[str, str]]: """Get (entity_id, device_class) of all sensors for which to compile statistics.""" @@ -92,6 +109,36 @@ def _time_weighted_average( return accumulated / (end - start).total_seconds() +def _normalize_states( + entity_history: list[State], device_class: str, entity_id: str +) -> list[tuple[float, State]]: + """Normalize units.""" + + if device_class not in UNIT_CONVERSIONS: + # We're not normalizing this device class, return the state as they are + return [(float(el.state), el) for el in entity_history if _is_number(el.state)] + + fstates = [] + + for state in entity_history: + # Exclude non numerical states from statistics + if not _is_number(state.state): + continue + + fstate = float(state.state) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if entity_id not in WARN_UNSUPPORTED_UNIT: + WARN_UNSUPPORTED_UNIT.add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue + + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) # type: ignore + + return fstates + + def compile_statistics( hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> dict: @@ -115,9 +162,7 @@ def compile_statistics( continue entity_history = history_list[entity_id] - fstates = [ - (float(el.state), el) for el in entity_history if _is_number(el.state) - ] + fstates = _normalize_states(entity_history, device_class, entity_id) if not fstates: continue diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 47a950f9eaa..b8411e69a7f 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -49,7 +49,11 @@ def test_compile_hourly_energy_statistics(hass_recorder): hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - sns1_attr = {"device_class": "energy", "state_class": "measurement"} + sns1_attr = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", + } sns2_attr = {"device_class": "energy"} sns3_attr = {} @@ -109,9 +113,21 @@ def test_compile_hourly_energy_statistics2(hass_recorder): hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - sns1_attr = {"device_class": "energy", "state_class": "measurement"} - sns2_attr = {"device_class": "energy", "state_class": "measurement"} - sns3_attr = {"device_class": "energy", "state_class": "measurement"} + sns1_attr = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", + } + sns2_attr = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", + } + sns3_attr = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "Wh", + } zero, four, eight, states = record_energy_states( hass, sns1_attr, sns2_attr, sns3_attr @@ -201,8 +217,8 @@ def test_compile_hourly_energy_statistics2(hass_recorder): "mean": None, "min": None, "last_reset": process_timestamp_to_utc_isoformat(zero), - "state": approx(5.0), - "sum": approx(5.0), + "state": approx(5.0 / 1000), + "sum": approx(5.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -211,8 +227,8 @@ def test_compile_hourly_energy_statistics2(hass_recorder): "mean": None, "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), - "state": approx(50.0), - "sum": approx(30.0), + "state": approx(50.0 / 1000), + "sum": approx(30.0 / 1000), }, { "statistic_id": "sensor.test3", @@ -221,8 +237,8 @@ def test_compile_hourly_energy_statistics2(hass_recorder): "mean": None, "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), - "state": approx(90.0), - "sum": approx(70.0), + "state": approx(90.0 / 1000), + "sum": approx(70.0 / 1000), }, ], }