Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Energy distance units #136933

Merged
merged 10 commits into from
Jan 31, 2025
11 changes: 11 additions & 0 deletions homeassistant/components/number/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfEnergyDistance,
UnitOfFrequency,
UnitOfInformation,
UnitOfIrradiance,
Expand Down Expand Up @@ -166,6 +167,15 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
"""

ENERGY_DISTANCE = "energy_distance"
"""Energy distance.

Use this device class for sensors measuring energy by distance, for example the amount
of electric energy consumed by an electric car.

Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh`
"""

ENERGY_STORAGE = "energy_storage"
"""Stored energy.

Expand Down Expand Up @@ -447,6 +457,7 @@ class NumberDeviceClass(StrEnum):
UnitOfTime.MILLISECONDS,
},
NumberDeviceClass.ENERGY: set(UnitOfEnergy),
NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance),
NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy),
NumberDeviceClass.FREQUENCY: set(UnitOfFrequency),
NumberDeviceClass.GAS: {
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/recorder/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
ElectricCurrentConverter,
ElectricPotentialConverter,
EnergyConverter,
EnergyDistanceConverter,
InformationConverter,
MassConverter,
PowerConverter,
Expand Down Expand Up @@ -147,6 +148,7 @@
for unit in ElectricPotentialConverter.VALID_UNITS
},
**{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS},
**{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS},
**{unit: InformationConverter for unit in InformationConverter.VALID_UNITS},
**{unit: MassConverter for unit in MassConverter.VALID_UNITS},
**{unit: PowerConverter for unit in PowerConverter.VALID_UNITS},
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/recorder/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ElectricCurrentConverter,
ElectricPotentialConverter,
EnergyConverter,
EnergyDistanceConverter,
InformationConverter,
MassConverter,
PowerConverter,
Expand Down Expand Up @@ -67,6 +68,7 @@
vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS),
vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS),
vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS),
vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS),
vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS),
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
Expand Down
14 changes: 14 additions & 0 deletions homeassistant/components/sensor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfEnergyDistance,
UnitOfFrequency,
UnitOfInformation,
UnitOfIrradiance,
Expand Down Expand Up @@ -51,6 +52,7 @@
ElectricCurrentConverter,
ElectricPotentialConverter,
EnergyConverter,
EnergyDistanceConverter,
InformationConverter,
MassConverter,
PowerConverter,
Expand Down Expand Up @@ -194,6 +196,15 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal`
"""

ENERGY_DISTANCE = "energy_distance"
"""Energy distance.

Use this device class for sensors measuring energy by distance, for example the amount
of electric energy consumed by an electric car.

Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh`
"""

ENERGY_STORAGE = "energy_storage"
"""Stored energy.

Expand Down Expand Up @@ -500,6 +511,7 @@ class SensorStateClass(StrEnum):
SensorDeviceClass.DISTANCE: DistanceConverter,
SensorDeviceClass.DURATION: DurationConverter,
SensorDeviceClass.ENERGY: EnergyConverter,
SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter,
SensorDeviceClass.ENERGY_STORAGE: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
SensorDeviceClass.POWER: PowerConverter,
Expand Down Expand Up @@ -541,6 +553,7 @@ class SensorStateClass(StrEnum):
UnitOfTime.MILLISECONDS,
},
SensorDeviceClass.ENERGY: set(UnitOfEnergy),
SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance),
SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy),
SensorDeviceClass.FREQUENCY: set(UnitOfFrequency),
SensorDeviceClass.GAS: {
Expand Down Expand Up @@ -622,6 +635,7 @@ class SensorStateClass(StrEnum):
SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING,
},
SensorDeviceClass.ENERGY_DISTANCE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.ENUM: set(),
SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT},
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/sensor/device_condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
CONF_IS_DISTANCE = "is_distance"
CONF_IS_DURATION = "is_duration"
CONF_IS_ENERGY = "is_energy"
CONF_IS_ENERGY_DISTANCE = "is_energy_distance"
CONF_IS_FREQUENCY = "is_frequency"
CONF_IS_HUMIDITY = "is_humidity"
CONF_IS_GAS = "is_gas"
Expand Down Expand Up @@ -102,6 +103,7 @@
SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}],
SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}],
SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}],
SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_IS_ENERGY_DISTANCE}],
SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}],
SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}],
SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}],
Expand Down Expand Up @@ -168,6 +170,7 @@
CONF_IS_DISTANCE,
CONF_IS_DURATION,
CONF_IS_ENERGY,
CONF_IS_ENERGY_DISTANCE,
CONF_IS_FREQUENCY,
CONF_IS_GAS,
CONF_IS_HUMIDITY,
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/sensor/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
CONF_DISTANCE = "distance"
CONF_DURATION = "duration"
CONF_ENERGY = "energy"
CONF_ENERGY_DISTANCE = "energy_distance"
CONF_FREQUENCY = "frequency"
CONF_GAS = "gas"
CONF_HUMIDITY = "humidity"
Expand Down Expand Up @@ -101,6 +102,7 @@
SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}],
SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}],
SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}],
SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_ENERGY_DISTANCE}],
SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}],
SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}],
SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}],
Expand Down Expand Up @@ -168,6 +170,7 @@
CONF_DISTANCE,
CONF_DURATION,
CONF_ENERGY,
CONF_ENERGY_DISTANCE,
CONF_FREQUENCY,
CONF_GAS,
CONF_HUMIDITY,
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/sensor/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"is_distance": "Current {entity_name} distance",
"is_duration": "Current {entity_name} duration",
"is_energy": "Current {entity_name} energy",
"is_energy_distance": "Current {entity_name} energy per distance",
"is_frequency": "Current {entity_name} frequency",
"is_gas": "Current {entity_name} gas",
"is_humidity": "Current {entity_name} humidity",
Expand Down Expand Up @@ -69,6 +70,7 @@
"distance": "{entity_name} distance changes",
"duration": "{entity_name} duration changes",
"energy": "{entity_name} energy changes",
"energy_distance": "{entity_name} energy per distance changes",
"frequency": "{entity_name} frequency changes",
"gas": "{entity_name} gas changes",
"humidity": "{entity_name} humidity changes",
Expand Down Expand Up @@ -183,6 +185,9 @@
"energy": {
"name": "Energy"
},
"energy_distance": {
"name": "Energy per distance"
},
"energy_storage": {
"name": "Stored energy"
},
Expand Down
9 changes: 9 additions & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,15 @@ class UnitOfEnergy(StrEnum):
GIGA_CALORIE = "Gcal"


# Energy Distance units
class UnitOfEnergyDistance(StrEnum):
"""Energy Distance units."""

KILO_WATT_HOUR_PER_100_KM = "kWh/100km"
MILES_PER_KILO_WATT_HOUR = "mi/kWh"
KM_PER_KILO_WATT_HOUR = "km/kWh"


# Electric_current units
class UnitOfElectricCurrent(StrEnum):
"""Electric current units."""
Expand Down
28 changes: 28 additions & 0 deletions homeassistant/util/unit_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfEnergyDistance,
UnitOfInformation,
UnitOfLength,
UnitOfMass,
Expand Down Expand Up @@ -90,6 +91,7 @@ class BaseUnitConverter:
VALID_UNITS: set[str | None]

_UNIT_CONVERSION: dict[str | None, float]
_UNIT_INVERSES: set[str] = set()

@classmethod
def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float:
Expand All @@ -105,6 +107,8 @@ def converter_factory(
if from_unit == to_unit:
return lambda value: value
from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
if cls._are_unit_inverses(from_unit, to_unit):
return lambda val: to_ratio / (val / from_ratio)
return lambda val: (val / from_ratio) * to_ratio

@classmethod
Expand All @@ -129,6 +133,8 @@ def converter_factory_allow_none(
if from_unit == to_unit:
return lambda value: value
from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
if cls._are_unit_inverses(from_unit, to_unit):
return lambda val: None if val is None else to_ratio / (val / from_ratio)
return lambda val: None if val is None else (val / from_ratio) * to_ratio

@classmethod
Expand All @@ -138,6 +144,12 @@ def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float:
from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
return from_ratio / to_ratio

@classmethod
@lru_cache
def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool:
"""Return true if one unit is an inverse but not the other."""
return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES)


class DataRateConverter(BaseUnitConverter):
"""Utility to convert data rate values."""
Expand Down Expand Up @@ -284,6 +296,22 @@ class EnergyConverter(BaseUnitConverter):
VALID_UNITS = set(UnitOfEnergy)


class EnergyDistanceConverter(BaseUnitConverter):
"""Utility to convert vehicle energy consumption values."""

UNIT_CLASS = "energy_distance"
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM: 1,
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR: 100 * _KM_TO_M / _MILE_TO_M,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR: 100,
}
_UNIT_INVERSES: set[str] = {
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR,
}
VALID_UNITS = set(UnitOfEnergyDistance)


class InformationConverter(BaseUnitConverter):
"""Utility to convert information values."""

Expand Down
40 changes: 40 additions & 0 deletions tests/util/test_unit_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfEnergyDistance,
UnitOfInformation,
UnitOfLength,
UnitOfMass,
Expand All @@ -43,6 +44,7 @@
ElectricCurrentConverter,
ElectricPotentialConverter,
EnergyConverter,
EnergyDistanceConverter,
InformationConverter,
MassConverter,
PowerConverter,
Expand Down Expand Up @@ -79,6 +81,7 @@
SpeedConverter,
TemperatureConverter,
UnitlessRatioConverter,
EnergyDistanceConverter,
VolumeConverter,
VolumeFlowRateConverter,
)
Expand Down Expand Up @@ -115,6 +118,11 @@
1000,
),
EnergyConverter: (UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000),
EnergyDistanceConverter: (
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR,
0.621371,
),
InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8),
MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473),
PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000),
Expand Down Expand Up @@ -486,6 +494,38 @@
(10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE),
(10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR),
],
EnergyDistanceConverter: [
(
10,
UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
6.213712,
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
),
(
25,
UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
4,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR,
),
(
20,
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
3.106856,
UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
),
(
10,
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
16.09344,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR,
),
(
16.09344,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR,
10,
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
),
],
InformationConverter: [
(8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS),
(8e6, UnitOfInformation.BITS, 8, UnitOfInformation.MEGABITS),
Expand Down