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 distance",
jschlyter marked this conversation as resolved.
Show resolved Hide resolved
"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 distance changes",
jschlyter marked this conversation as resolved.
Show resolved Hide resolved
"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 distance"
jschlyter marked this conversation as resolved.
Show resolved Hide resolved
},
"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
37 changes: 37 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[tuple[str, 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._is_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._is_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,15 @@ 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 _is_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool:
jschlyter marked this conversation as resolved.
Show resolved Hide resolved
"""Return true if units are inverses to one another."""
return (from_unit, to_unit) in cls._UNIT_INVERSES or (
to_unit,
from_unit,
) in cls._UNIT_INVERSES


class DataRateConverter(BaseUnitConverter):
"""Utility to convert data rate values."""
Expand Down Expand Up @@ -284,6 +299,28 @@ 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[tuple[str, str]] = {
jschlyter marked this conversation as resolved.
Show resolved Hide resolved
(
UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR,
),
(
UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM,
UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR,
),
}
VALID_UNITS = set(UnitOfEnergyDistance)


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

Expand Down
35 changes: 35 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 @@ -486,6 +489,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 Expand Up @@ -793,7 +828,7 @@
"""Ensure all unit converters are tested."""
assert converter in _ALL_CONVERTERS, "converter is not present in _ALL_CONVERTERS"

assert converter in _GET_UNIT_RATIO, "converter is not present in _GET_UNIT_RATIO"

Check failure on line 831 in tests/util/test_unit_conversion.py

View workflow job for this annotation

GitHub Actions / Run tests Python 3.13 (1)

test_all_converters[EnergyDistanceConverter] AssertionError: converter is not present in _GET_UNIT_RATIO assert <class 'homeassistant.util.unit_conversion.EnergyDistanceConverter'> in {<class 'homeassistant.util.unit_conversion.AreaConverter'>: (<UnitOfArea.SQUARE_KILOMETERS: 'km²'>, <UnitOfArea.SQUAR...ion.DataRateConverter'>: (<UnitOfDataRate.BITS_PER_SECOND: 'bit/s'>, <UnitOfDataRate.BYTES_PER_SECOND: 'B/s'>, 8), ...}
unit_ratio_item = _GET_UNIT_RATIO[converter]
assert unit_ratio_item[0] != unit_ratio_item[1], "ratio units should be different"

Expand Down
Loading