Add unit conversion for energy costs (#81379)
Co-authored-by: Franck Nijhof <git@frenck.dev>pull/81423/head
parent
a5f209b219
commit
3aca376374
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
import copy
|
import copy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
@ -22,6 +23,7 @@ from homeassistant.const import (
|
||||||
VOLUME_GALLONS,
|
VOLUME_GALLONS,
|
||||||
VOLUME_LITERS,
|
VOLUME_LITERS,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
|
UnitOfVolume,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
|
@ -34,29 +36,35 @@ from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
from homeassistant.util import unit_conversion
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .data import EnergyManager, async_get_manager
|
from .data import EnergyManager, async_get_manager
|
||||||
|
|
||||||
SUPPORTED_STATE_CLASSES = [
|
SUPPORTED_STATE_CLASSES = {
|
||||||
SensorStateClass.MEASUREMENT,
|
SensorStateClass.MEASUREMENT,
|
||||||
SensorStateClass.TOTAL,
|
SensorStateClass.TOTAL,
|
||||||
SensorStateClass.TOTAL_INCREASING,
|
SensorStateClass.TOTAL_INCREASING,
|
||||||
]
|
}
|
||||||
VALID_ENERGY_UNITS = [
|
VALID_ENERGY_UNITS: set[str] = {
|
||||||
UnitOfEnergy.WATT_HOUR,
|
UnitOfEnergy.WATT_HOUR,
|
||||||
UnitOfEnergy.KILO_WATT_HOUR,
|
UnitOfEnergy.KILO_WATT_HOUR,
|
||||||
UnitOfEnergy.MEGA_WATT_HOUR,
|
UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
UnitOfEnergy.GIGA_JOULE,
|
UnitOfEnergy.GIGA_JOULE,
|
||||||
]
|
}
|
||||||
VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS
|
VALID_ENERGY_UNITS_GAS = {
|
||||||
VALID_VOLUME_UNITS_WATER = [
|
VOLUME_CUBIC_FEET,
|
||||||
|
VOLUME_CUBIC_METERS,
|
||||||
|
*VALID_ENERGY_UNITS,
|
||||||
|
}
|
||||||
|
VALID_VOLUME_UNITS_WATER = {
|
||||||
VOLUME_CUBIC_FEET,
|
VOLUME_CUBIC_FEET,
|
||||||
VOLUME_CUBIC_METERS,
|
VOLUME_CUBIC_METERS,
|
||||||
VOLUME_GALLONS,
|
VOLUME_GALLONS,
|
||||||
VOLUME_LITERS,
|
VOLUME_LITERS,
|
||||||
]
|
}
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -252,8 +260,24 @@ class EnergyCostSensor(SensorEntity):
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_cost(self) -> None: # noqa: C901
|
def _update_cost(self) -> None:
|
||||||
"""Update incurred costs."""
|
"""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(
|
energy_state = self.hass.states.get(
|
||||||
cast(str, self._config[self._adapter.stat_energy_key])
|
cast(str, self._config[self._adapter.stat_energy_key])
|
||||||
)
|
)
|
||||||
|
@ -298,52 +322,27 @@ class EnergyCostSensor(SensorEntity):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
|
||||||
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
|
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||||
f"/{UnitOfEnergy.WATT_HOUR}"
|
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||||
):
|
).partition("/")[2]
|
||||||
energy_price *= 1000.0
|
|
||||||
|
|
||||||
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
|
# For backwards compatibility we don't validate the unit of the price
|
||||||
f"/{UnitOfEnergy.MEGA_WATT_HOUR}"
|
# If it is not valid, we assume it's our default price unit.
|
||||||
):
|
if energy_price_unit not in valid_units:
|
||||||
energy_price /= 1000.0
|
energy_price_unit = default_price_unit
|
||||||
|
|
||||||
if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith(
|
|
||||||
f"/{UnitOfEnergy.GIGA_JOULE}"
|
|
||||||
):
|
|
||||||
energy_price /= 1000 / 3.6
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
energy_price_state = None
|
|
||||||
energy_price = cast(float, self._config["number_energy_price"])
|
energy_price = cast(float, self._config["number_energy_price"])
|
||||||
|
energy_price_unit = default_price_unit
|
||||||
|
|
||||||
if self._last_energy_sensor_state is None:
|
if self._last_energy_sensor_state is None:
|
||||||
# Initialize as it's the first time all required entities are in place.
|
# Initialize as it's the first time all required entities are in place.
|
||||||
self._reset(energy_state)
|
self._reset(energy_state)
|
||||||
return
|
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 is None or energy_unit not in valid_units:
|
||||||
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 not self._wrong_unit_reported:
|
if not self._wrong_unit_reported:
|
||||||
self._wrong_unit_reported = True
|
self._wrong_unit_reported = True
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
@ -373,10 +372,30 @@ class EnergyCostSensor(SensorEntity):
|
||||||
energy_state_copy = copy.copy(energy_state)
|
energy_state_copy = copy.copy(energy_state)
|
||||||
energy_state_copy.state = "0.0"
|
energy_state_copy.state = "0.0"
|
||||||
self._reset(energy_state_copy)
|
self._reset(energy_state_copy)
|
||||||
|
|
||||||
# Update with newly incurred cost
|
# Update with newly incurred cost
|
||||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||||
cur_value = cast(float, self._attr_native_value)
|
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
|
self._last_energy_sensor_state = energy_state
|
||||||
|
|
||||||
|
|
|
@ -19,11 +19,13 @@ from homeassistant.const import (
|
||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
VOLUME_CUBIC_FEET,
|
VOLUME_CUBIC_FEET,
|
||||||
VOLUME_CUBIC_METERS,
|
VOLUME_CUBIC_METERS,
|
||||||
|
VOLUME_GALLONS,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
import homeassistant.util.dt as dt_util
|
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
|
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"
|
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(
|
async def test_cost_sensor_handle_gas(
|
||||||
setup_integration, hass, hass_storage, unit
|
setup_integration, hass, hass_storage, unit
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -933,13 +938,22 @@ async def test_cost_sensor_handle_gas_kwh(
|
||||||
assert state.state == "50.0"
|
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(
|
async def test_cost_sensor_handle_water(
|
||||||
setup_integration, hass, hass_storage, unit
|
setup_integration, hass, hass_storage, unit_system, usage_unit, growth
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test water cost price from sensor entity."""
|
"""Test water cost price from sensor entity."""
|
||||||
|
hass.config.units = unit_system
|
||||||
energy_attributes = {
|
energy_attributes = {
|
||||||
ATTR_UNIT_OF_MEASUREMENT: unit,
|
ATTR_UNIT_OF_MEASUREMENT: usage_unit,
|
||||||
ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING,
|
ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING,
|
||||||
}
|
}
|
||||||
energy_data = data.EnergyManager.default_preferences()
|
energy_data = data.EnergyManager.default_preferences()
|
||||||
|
@ -981,7 +995,7 @@ async def test_cost_sensor_handle_water(
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("sensor.water_consumption_cost")
|
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])
|
@pytest.mark.parametrize("state_class", [None])
|
||||||
|
|
Loading…
Reference in New Issue