Add unit conversion for energy costs (#81379)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Paulus Schoutsen 2022-11-02 07:18:50 -04:00 committed by GitHub
parent 44f63252e7
commit a8c527f6f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 82 additions and 49 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import copy
from dataclasses import dataclass
import logging
@ -22,6 +23,7 @@ from homeassistant.const import (
VOLUME_GALLONS,
VOLUME_LITERS,
UnitOfEnergy,
UnitOfVolume,
)
from homeassistant.core import (
HomeAssistant,
@ -34,29 +36,35 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import unit_conversion
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DOMAIN
from .data import EnergyManager, async_get_manager
SUPPORTED_STATE_CLASSES = [
SUPPORTED_STATE_CLASSES = {
SensorStateClass.MEASUREMENT,
SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING,
]
VALID_ENERGY_UNITS = [
}
VALID_ENERGY_UNITS: set[str] = {
UnitOfEnergy.WATT_HOUR,
UnitOfEnergy.KILO_WATT_HOUR,
UnitOfEnergy.MEGA_WATT_HOUR,
UnitOfEnergy.GIGA_JOULE,
]
VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS
VALID_VOLUME_UNITS_WATER = [
}
VALID_ENERGY_UNITS_GAS = {
VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS,
*VALID_ENERGY_UNITS,
}
VALID_VOLUME_UNITS_WATER = {
VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS,
VOLUME_GALLONS,
VOLUME_LITERS,
]
}
_LOGGER = logging.getLogger(__name__)
@ -252,8 +260,24 @@ class EnergyCostSensor(SensorEntity):
self.async_write_ha_state()
@callback
def _update_cost(self) -> None: # noqa: C901
def _update_cost(self) -> None:
"""Update incurred costs."""
if self._adapter.source_type == "grid":
valid_units = VALID_ENERGY_UNITS
default_price_unit: str | None = UnitOfEnergy.KILO_WATT_HOUR
elif self._adapter.source_type == "gas":
valid_units = VALID_ENERGY_UNITS_GAS
# No conversion for gas.
default_price_unit = None
elif self._adapter.source_type == "water":
valid_units = VALID_VOLUME_UNITS_WATER
if self.hass.config.units is METRIC_SYSTEM:
default_price_unit = UnitOfVolume.CUBIC_METERS
else:
default_price_unit = UnitOfVolume.GALLONS
energy_state = self.hass.states.get(
cast(str, self._config[self._adapter.stat_energy_key])
)
@ -298,52 +322,27 @@ class EnergyCostSensor(SensorEntity):
except ValueError:
return
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
f"/{UnitOfEnergy.WATT_HOUR}"
):
energy_price *= 1000.0
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
f"/{UnitOfEnergy.MEGA_WATT_HOUR}"
):
energy_price /= 1000.0
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
f"/{UnitOfEnergy.GIGA_JOULE}"
):
energy_price /= 1000 / 3.6
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_price_unit
else:
energy_price_state = None
energy_price = cast(float, self._config["number_energy_price"])
energy_price_unit = default_price_unit
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities are in place.
self._reset(energy_state)
return
energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if self._adapter.source_type == "grid":
if energy_unit not in VALID_ENERGY_UNITS:
energy_unit = None
elif self._adapter.source_type == "gas":
if energy_unit not in VALID_ENERGY_UNITS_GAS:
energy_unit = None
elif self._adapter.source_type == "water":
if energy_unit not in VALID_VOLUME_UNITS_WATER:
energy_unit = None
if energy_unit == UnitOfEnergy.WATT_HOUR:
energy_price /= 1000
elif energy_unit == UnitOfEnergy.MEGA_WATT_HOUR:
energy_price *= 1000
elif energy_unit == UnitOfEnergy.GIGA_JOULE:
energy_price *= 1000 / 3.6
if energy_unit is None:
if energy_unit is None or energy_unit not in valid_units:
if not self._wrong_unit_reported:
self._wrong_unit_reported = True
_LOGGER.warning(
@ -373,10 +372,30 @@ class EnergyCostSensor(SensorEntity):
energy_state_copy = copy.copy(energy_state)
energy_state_copy.state = "0.0"
self._reset(energy_state_copy)
# Update with newly incurred cost
old_energy_value = float(self._last_energy_sensor_state.state)
cur_value = cast(float, self._attr_native_value)
self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price
if energy_price_unit is None:
converted_energy_price = energy_price
else:
if self._adapter.source_type == "grid":
converter: Callable[
[float, str, str], float
] = unit_conversion.EnergyConverter.convert
elif self._adapter.source_type in ("gas", "water"):
converter = unit_conversion.VolumeConverter.convert
converted_energy_price = converter(
energy_price,
energy_unit,
energy_price_unit,
)
self._attr_native_value = (
cur_value + (energy - old_energy_value) * converted_energy_price
)
self._last_energy_sensor_state = energy_state

View file

@ -19,11 +19,13 @@ from homeassistant.const import (
STATE_UNKNOWN,
VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS,
VOLUME_GALLONS,
UnitOfEnergy,
)
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from tests.components.recorder.common import async_wait_recording_done
@ -832,7 +834,10 @@ async def test_cost_sensor_handle_price_units(
assert state.state == "20.0"
@pytest.mark.parametrize("unit", (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS))
@pytest.mark.parametrize(
"unit",
(VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS),
)
async def test_cost_sensor_handle_gas(
setup_integration, hass, hass_storage, unit
) -> None:
@ -933,13 +938,22 @@ async def test_cost_sensor_handle_gas_kwh(
assert state.state == "50.0"
@pytest.mark.parametrize("unit", (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS))
@pytest.mark.parametrize(
"unit_system,usage_unit,growth",
(
# 1 cubic foot = 7.47 gl, 100 ft3 growth @ 0.5/ft3:
(US_CUSTOMARY_SYSTEM, VOLUME_CUBIC_FEET, 374.025974025974),
(US_CUSTOMARY_SYSTEM, VOLUME_GALLONS, 50.0),
(METRIC_SYSTEM, VOLUME_CUBIC_METERS, 50.0),
),
)
async def test_cost_sensor_handle_water(
setup_integration, hass, hass_storage, unit
setup_integration, hass, hass_storage, unit_system, usage_unit, growth
) -> None:
"""Test water cost price from sensor entity."""
hass.config.units = unit_system
energy_attributes = {
ATTR_UNIT_OF_MEASUREMENT: unit,
ATTR_UNIT_OF_MEASUREMENT: usage_unit,
ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING,
}
energy_data = data.EnergyManager.default_preferences()
@ -981,7 +995,7 @@ async def test_cost_sensor_handle_water(
await hass.async_block_till_done()
state = hass.states.get("sensor.water_consumption_cost")
assert state.state == "50.0"
assert float(state.state) == pytest.approx(growth)
@pytest.mark.parametrize("state_class", [None])