Add unitless unit converter (#85694)

* Add unitless unit converter

* Adjust type hints

* Adjust tests

* Rename to UnitlessRatioConverter
This commit is contained in:
Erik Montnemery 2023-01-12 09:20:00 +01:00 committed by GitHub
parent a176de6d4b
commit b0d4b73874
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 276 additions and 103 deletions

View File

@ -43,6 +43,7 @@ from homeassistant.util.unit_conversion import (
PressureConverter,
SpeedConverter,
TemperatureConverter,
UnitlessRatioConverter,
VolumeConverter,
)
@ -134,6 +135,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
**{unit: PressureConverter for unit in PressureConverter.VALID_UNITS},
**{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS},
**{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS},
**{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS},
**{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS},
}
@ -155,9 +157,6 @@ def get_display_unit(
) -> str | None:
"""Return the unit which the statistic will be displayed in."""
if statistic_unit is None:
return None
if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
return statistic_unit
@ -183,9 +182,6 @@ def _get_statistic_to_display_unit_converter(
"""Return val."""
return val
if statistic_unit is None:
return no_conversion
if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
return no_conversion
@ -226,9 +222,6 @@ def _get_display_to_statistic_unit_converter(
"""Return val."""
return val
if statistic_unit is None:
return no_conversion
if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
return no_conversion
@ -1555,17 +1548,10 @@ def statistic_during_period(
else:
result["change"] = None
def no_conversion(val: float | None) -> float | None:
"""Return val."""
return val
state_unit = unit = metadata[statistic_id][1]["unit_of_measurement"]
if state := hass.states.get(statistic_id):
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if unit is not None:
convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
else:
convert = no_conversion
convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
return {key: convert(value) for key, value in result.items()}
@ -1916,7 +1902,7 @@ def _sorted_statistics_to_dict(
statistic_id = metadata[meta_id]["statistic_id"]
if state := hass.states.get(statistic_id):
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if unit is not None and convert_units:
if convert_units:
convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
else:
convert = no_conversion

View File

@ -56,7 +56,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, UndefinedType
from homeassistant.util import dt as dt_util
from .const import ( # noqa: F401
@ -155,7 +155,7 @@ class SensorEntity(Entity):
)
_invalid_unit_of_measurement_reported = False
_last_reset_reported = False
_sensor_option_unit_of_measurement: str | None = None
_sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
@callback
def add_to_platform_start(
@ -371,7 +371,7 @@ class SensorEntity(Entity):
"""Return the unit of measurement of the entity, after unit conversion."""
# Highest priority, for registered entities: unit set by user,with fallback to
# unit suggested by integration or secondary fallback to unit conversion rules
if self._sensor_option_unit_of_measurement:
if self._sensor_option_unit_of_measurement is not UNDEFINED:
return self._sensor_option_unit_of_measurement
# Second priority, for non registered entities: unit suggested by integration
@ -481,8 +481,6 @@ class SensorEntity(Entity):
native_unit_of_measurement != unit_of_measurement
and device_class in UNIT_CONVERTERS
):
assert unit_of_measurement
assert native_unit_of_measurement
converter = UNIT_CONVERTERS[device_class]
value_s = str(value)
@ -550,28 +548,31 @@ class SensorEntity(Entity):
return super().__repr__()
def _custom_unit_or_none(self, primary_key: str, secondary_key: str) -> str | None:
"""Return a custom unit, or None if it's not compatible with the native unit."""
def _custom_unit_or_undef(
self, primary_key: str, secondary_key: str
) -> str | None | UndefinedType:
"""Return a custom unit, or UNDEFINED if not compatible with the native unit."""
assert self.registry_entry
if (
(sensor_options := self.registry_entry.options.get(primary_key))
and (custom_unit := sensor_options.get(secondary_key))
and secondary_key in sensor_options
and (device_class := self.device_class) in UNIT_CONVERTERS
and self.native_unit_of_measurement
in UNIT_CONVERTERS[device_class].VALID_UNITS
and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS
and (custom_unit := sensor_options[secondary_key])
in UNIT_CONVERTERS[device_class].VALID_UNITS
):
return cast(str, custom_unit)
return None
return UNDEFINED
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._sensor_option_unit_of_measurement = self._custom_unit_or_none(
self._sensor_option_unit_of_measurement = self._custom_unit_or_undef(
DOMAIN, CONF_UNIT_OF_MEASUREMENT
)
if not self._sensor_option_unit_of_measurement:
self._sensor_option_unit_of_measurement = self._custom_unit_or_none(
if self._sensor_option_unit_of_measurement is UNDEFINED:
self._sensor_option_unit_of_measurement = self._custom_unit_or_undef(
f"{DOMAIN}.private", "suggested_unit_of_measurement"
)

View File

@ -46,6 +46,7 @@ from homeassistant.util.unit_conversion import (
PressureConverter,
SpeedConverter,
TemperatureConverter,
UnitlessRatioConverter,
VolumeConverter,
)
@ -421,6 +422,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.CURRENT: ElectricCurrentConverter,
SensorDeviceClass.ENERGY: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter,
SensorDeviceClass.PRECIPITATION: DistanceConverter,
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.SPEED: SpeedConverter,

View File

@ -183,10 +183,7 @@ def _normalize_states(
# We have seen this sensor before, use the unit from metadata
statistics_unit = old_metadata["unit_of_measurement"]
if (
not statistics_unit
or statistics_unit not in statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER
):
if statistics_unit not in statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER:
# The unit used by this sensor doesn't support unit conversion
all_units = _get_units(fstates)
@ -721,7 +718,8 @@ def validate_statistics(
)
elif state_unit not in converter.VALID_UNITS:
# The state unit can't be converted to the unit in metadata
valid_units = ", ".join(sorted(converter.VALID_UNITS))
valid_units = (unit or "<None>" for unit in converter.VALID_UNITS)
valid_units_str = ", ".join(sorted(valid_units))
validation_result[entity_id].append(
statistics.ValidationIssue(
"units_changed",
@ -729,7 +727,7 @@ def validate_statistics(
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
"supported_unit": valid_units,
"supported_unit": valid_units_str,
},
)
)

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from typing import Any
from typing import Any, cast
import subarulink.const as sc
@ -207,11 +207,11 @@ class SubaruSensor(
return None
if unit in LENGTH_UNITS:
return round(unit_system.length(current_value, unit), 1)
return round(unit_system.length(current_value, cast(str, unit)), 1)
if unit in PRESSURE_UNITS and unit_system == US_CUSTOMARY_SYSTEM:
return round(
unit_system.pressure(current_value, unit),
unit_system.pressure(current_value, cast(str, unit)),
1,
)

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.frame import report
from .unit_conversion import PressureConverter
# pylint: disable-next=protected-access
UNIT_CONVERSION: dict[str, float] = PressureConverter._UNIT_CONVERSION
UNIT_CONVERSION: dict[str | None, float] = PressureConverter._UNIT_CONVERSION
VALID_UNITS = PressureConverter.VALID_UNITS

View File

@ -27,7 +27,7 @@ from .unit_conversion import ( # pylint: disable=unused-import # noqa: F401
)
# pylint: disable-next=protected-access
UNIT_CONVERSION: dict[str, float] = SpeedConverter._UNIT_CONVERSION
UNIT_CONVERSION: dict[str | None, float] = SpeedConverter._UNIT_CONVERSION
VALID_UNITS = SpeedConverter.VALID_UNITS

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from homeassistant.const import (
PERCENTAGE,
UNIT_NOT_RECOGNIZED_TEMPLATE,
UnitOfDataRate,
UnitOfElectricCurrent,
@ -56,13 +57,13 @@ class BaseUnitConverter:
"""Define the format of a conversion utility."""
UNIT_CLASS: str
NORMALIZED_UNIT: str
VALID_UNITS: set[str]
NORMALIZED_UNIT: str | None
VALID_UNITS: set[str | None]
_UNIT_CONVERSION: dict[str, float]
_UNIT_CONVERSION: dict[str | None, float]
@classmethod
def convert(cls, value: float, from_unit: str, to_unit: str) -> float:
def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float:
"""Convert one unit of measurement to another."""
if from_unit == to_unit:
return value
@ -85,7 +86,7 @@ class BaseUnitConverter:
return new_value * to_ratio
@classmethod
def get_unit_ratio(cls, from_unit: str, to_unit: str) -> float:
def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float:
"""Get unit ratio between units of measurement."""
return cls._UNIT_CONVERSION[from_unit] / cls._UNIT_CONVERSION[to_unit]
@ -96,7 +97,7 @@ class DataRateConverter(BaseUnitConverter):
UNIT_CLASS = "data_rate"
NORMALIZED_UNIT = UnitOfDataRate.BITS_PER_SECOND
# Units in terms of bits
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfDataRate.BITS_PER_SECOND: 1,
UnitOfDataRate.KILOBITS_PER_SECOND: 1 / 1e3,
UnitOfDataRate.MEGABITS_PER_SECOND: 1 / 1e6,
@ -117,7 +118,7 @@ class DistanceConverter(BaseUnitConverter):
UNIT_CLASS = "distance"
NORMALIZED_UNIT = UnitOfLength.METERS
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfLength.METERS: 1,
UnitOfLength.MILLIMETERS: 1 / _MM_TO_M,
UnitOfLength.CENTIMETERS: 1 / _CM_TO_M,
@ -144,7 +145,7 @@ class ElectricCurrentConverter(BaseUnitConverter):
UNIT_CLASS = "electric_current"
NORMALIZED_UNIT = UnitOfElectricCurrent.AMPERE
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfElectricCurrent.AMPERE: 1,
UnitOfElectricCurrent.MILLIAMPERE: 1e3,
}
@ -156,7 +157,7 @@ class ElectricPotentialConverter(BaseUnitConverter):
UNIT_CLASS = "voltage"
NORMALIZED_UNIT = UnitOfElectricPotential.VOLT
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfElectricPotential.VOLT: 1,
UnitOfElectricPotential.MILLIVOLT: 1e3,
}
@ -171,7 +172,7 @@ class EnergyConverter(BaseUnitConverter):
UNIT_CLASS = "energy"
NORMALIZED_UNIT = UnitOfEnergy.KILO_WATT_HOUR
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfEnergy.WATT_HOUR: 1 * 1000,
UnitOfEnergy.KILO_WATT_HOUR: 1,
UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1000,
@ -191,7 +192,7 @@ class InformationConverter(BaseUnitConverter):
UNIT_CLASS = "information"
NORMALIZED_UNIT = UnitOfInformation.BITS
# Units in terms of bits
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfInformation.BITS: 1,
UnitOfInformation.KILOBITS: 1 / 1e3,
UnitOfInformation.MEGABITS: 1 / 1e6,
@ -222,7 +223,7 @@ class MassConverter(BaseUnitConverter):
UNIT_CLASS = "mass"
NORMALIZED_UNIT = UnitOfMass.GRAMS
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfMass.MICROGRAMS: 1 * 1000 * 1000,
UnitOfMass.MILLIGRAMS: 1 * 1000,
UnitOfMass.GRAMS: 1,
@ -247,7 +248,7 @@ class PowerConverter(BaseUnitConverter):
UNIT_CLASS = "power"
NORMALIZED_UNIT = UnitOfPower.WATT
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfPower.WATT: 1,
UnitOfPower.KILO_WATT: 1 / 1000,
}
@ -262,7 +263,7 @@ class PressureConverter(BaseUnitConverter):
UNIT_CLASS = "pressure"
NORMALIZED_UNIT = UnitOfPressure.PA
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfPressure.PA: 1,
UnitOfPressure.HPA: 1 / 100,
UnitOfPressure.KPA: 1 / 1000,
@ -293,7 +294,7 @@ class SpeedConverter(BaseUnitConverter):
UNIT_CLASS = "speed"
NORMALIZED_UNIT = UnitOfSpeed.METERS_PER_SECOND
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfVolumetricFlux.INCHES_PER_DAY: _DAYS_TO_SECS / _IN_TO_M,
UnitOfVolumetricFlux.INCHES_PER_HOUR: _HRS_TO_SECS / _IN_TO_M,
UnitOfVolumetricFlux.MILLIMETERS_PER_DAY: _DAYS_TO_SECS / _MM_TO_M,
@ -334,7 +335,7 @@ class TemperatureConverter(BaseUnitConverter):
}
@classmethod
def convert(cls, value: float, from_unit: str, to_unit: str) -> float:
def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float:
"""Convert a temperature from one unit to another.
eg. 10°C will return 50°F
@ -411,13 +412,28 @@ class TemperatureConverter(BaseUnitConverter):
return celsius + 273.15
class UnitlessRatioConverter(BaseUnitConverter):
"""Utility to convert unitless ratios."""
UNIT_CLASS = "unitless"
NORMALIZED_UNIT = None
_UNIT_CONVERSION: dict[str | None, float] = {
None: 1,
PERCENTAGE: 100,
}
VALID_UNITS = {
None,
PERCENTAGE,
}
class VolumeConverter(BaseUnitConverter):
"""Utility to convert volume values."""
UNIT_CLASS = "volume"
NORMALIZED_UNIT = UnitOfVolume.CUBIC_METERS
# Units in terms of m³
_UNIT_CONVERSION: dict[str, float] = {
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfVolume.LITERS: 1 / _L_TO_CUBIC_METER,
UnitOfVolume.MILLILITERS: 1 / _ML_TO_CUBIC_METER,
UnitOfVolume.GALLONS: 1 / _GALLON_TO_CUBIC_METER,

View File

@ -1690,7 +1690,7 @@ async def test_clear_statistics(recorder_mock, hass, hass_ws_client):
@pytest.mark.parametrize(
"new_unit, new_unit_class, new_display_unit",
[("dogs", None, "dogs"), (None, None, None), ("W", "power", "kW")],
[("dogs", None, "dogs"), (None, "unitless", None), ("W", "power", "kW")],
)
async def test_update_statistics_metadata(
recorder_mock, hass, hass_ws_client, new_unit, new_unit_class, new_display_unit
@ -2986,7 +2986,7 @@ async def test_adjust_sum_statistics_gas(
("", "", "volume", 1, ("ft³", ""), ("Wh", "kWh", "MWh", "cats", None)),
("ft³", "ft³", "volume", 1, ("ft³", ""), ("Wh", "kWh", "MWh", "cats", None)),
("dogs", "dogs", None, 1, ("dogs",), ("cats", None)),
(None, None, None, 1, (None,), ("cats",)),
(None, None, "unitless", 1, (None,), ("cats",)),
),
)
async def test_adjust_sum_statistics_errors(

View File

@ -17,6 +17,7 @@ from homeassistant.const import (
LENGTH_YARD,
MASS_GRAMS,
MASS_OUNCES,
PERCENTAGE,
PRESSURE_HPA,
PRESSURE_INHG,
PRESSURE_KPA,
@ -546,6 +547,31 @@ async def test_custom_unit(
1000,
SensorDeviceClass.ENERGY,
),
# Power factor
(
None,
PERCENTAGE,
PERCENTAGE,
1.0,
100,
SensorDeviceClass.POWER_FACTOR,
),
(
PERCENTAGE,
None,
None,
100,
1,
SensorDeviceClass.POWER_FACTOR,
),
(
"Cos φ",
None,
"Cos φ",
1.0,
1.0,
SensorDeviceClass.POWER_FACTOR,
),
# Pressure
# Smaller to larger unit, InHg is ~33x larger than hPa -> 1 more decimal
(
@ -686,7 +712,7 @@ async def test_custom_unit_change(
state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(native_value))
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit
entity_registry.async_update_entity_options(
"sensor.test", "sensor", {"unit_of_measurement": custom_unit}
@ -695,7 +721,7 @@ async def test_custom_unit_change(
state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(custom_value))
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == state_unit
entity_registry.async_update_entity_options(
"sensor.test", "sensor", {"unit_of_measurement": native_unit}
@ -704,14 +730,14 @@ async def test_custom_unit_change(
state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(native_value))
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit
entity_registry.async_update_entity_options("sensor.test", "sensor", None)
await hass.async_block_till_done()
state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(native_value))
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit
@pytest.mark.parametrize(

View File

@ -94,13 +94,13 @@ def set_time_zone():
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[
(None, "%", "%", "%", None, 13.050847, -10, 30),
("battery", "%", "%", "%", None, 13.050847, -10, 30),
("battery", None, None, None, None, 13.050847, -10, 30),
(None, "%", "%", "%", "unitless", 13.050847, -10, 30),
("battery", "%", "%", "%", "unitless", 13.050847, -10, 30),
("battery", None, None, None, "unitless", 13.050847, -10, 30),
("distance", "m", "m", "m", "distance", 13.050847, -10, 30),
("distance", "mi", "mi", "mi", "distance", 13.050847, -10, 30),
("humidity", "%", "%", "%", None, 13.050847, -10, 30),
("humidity", None, None, None, None, 13.050847, -10, 30),
("humidity", "%", "%", "%", "unitless", 13.050847, -10, 30),
("humidity", None, None, None, "unitless", 13.050847, -10, 30),
("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30),
("pressure", "hPa", "hPa", "hPa", "pressure", 13.050847, -10, 30),
("pressure", "mbar", "mbar", "mbar", "pressure", 13.050847, -10, 30),
@ -178,7 +178,7 @@ def test_compile_hourly_statistics(
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class",
[
(None, "%", "%", "%", None),
(None, "%", "%", "%", "unitless"),
],
)
def test_compile_hourly_statistics_purged_state_changes(
@ -317,7 +317,7 @@ def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes)
"source": "recorder",
"statistic_id": "sensor.test3",
"statistics_unit_of_measurement": None,
"unit_class": None,
"unit_class": "unitless",
},
{
"statistic_id": "sensor.test6",
@ -1775,8 +1775,8 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog):
@pytest.mark.parametrize(
"state_class, device_class, state_unit, display_unit, statistics_unit, unit_class, statistic_type",
[
("measurement", "battery", "%", "%", "%", None, "mean"),
("measurement", "battery", None, None, None, None, "mean"),
("measurement", "battery", "%", "%", "%", "unitless", "mean"),
("measurement", "battery", None, None, None, "unitless", "mean"),
("measurement", "distance", "m", "m", "m", "distance", "mean"),
("measurement", "distance", "mi", "mi", "mi", "distance", "mean"),
("total", "distance", "m", "m", "m", "distance", "sum"),
@ -1785,8 +1785,8 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog):
("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"),
("measurement", "energy", "Wh", "Wh", "Wh", "energy", "mean"),
("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"),
("measurement", "humidity", "%", "%", "%", None, "mean"),
("measurement", "humidity", None, None, None, None, "mean"),
("measurement", "humidity", "%", "%", "%", "unitless", "mean"),
("measurement", "humidity", None, None, None, "unitless", "mean"),
("total", "monetary", "USD", "USD", "USD", None, "sum"),
("total", "monetary", "None", "None", "None", None, "sum"),
("total", "gas", "", "", "", "volume", "sum"),
@ -1898,10 +1898,10 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes):
@pytest.mark.parametrize(
"device_class, state_unit, state_unit2, unit_class, mean, min, max",
[
(None, None, "cats", None, 13.050847, -10, 30),
(None, "%", "cats", None, 13.050847, -10, 30),
("battery", "%", "cats", None, 13.050847, -10, 30),
("battery", None, "cats", None, 13.050847, -10, 30),
(None, None, "cats", "unitless", 13.050847, -10, 30),
(None, "%", "cats", "unitless", 13.050847, -10, 30),
("battery", "%", "cats", "unitless", 13.050847, -10, 30),
("battery", None, "cats", "unitless", 13.050847, -10, 30),
(None, "kW", "Wh", "power", 13.050847, -10, 30),
# Can't downgrade from ft³ to ft3 or from m³ to m3
(None, "ft³", "ft3", "volume", 13.050847, -10, 30),
@ -1919,7 +1919,10 @@ def test_compile_hourly_statistics_changing_units_1(
min,
max,
):
"""Test compiling hourly statistics where units change from one hour to the next."""
"""Test compiling hourly statistics where units change from one hour to the next.
This tests the case where the recorder can not convert between the units.
"""
zero = dt_util.utcnow()
hass = hass_recorder()
setup_component(hass, "sensor", {})
@ -2014,10 +2017,7 @@ def test_compile_hourly_statistics_changing_units_1(
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[
(None, None, None, None, None, 13.050847, -10, 30),
(None, "%", "%", "%", None, 13.050847, -10, 30),
("battery", "%", "%", "%", None, 13.050847, -10, 30),
("battery", None, None, None, None, 13.050847, -10, 30),
(None, "dogs", "dogs", "dogs", None, 13.050847, -10, 30),
],
)
def test_compile_hourly_statistics_changing_units_2(
@ -2032,7 +2032,11 @@ def test_compile_hourly_statistics_changing_units_2(
min,
max,
):
"""Test compiling hourly statistics where units change during an hour."""
"""Test compiling hourly statistics where units change during an hour.
This tests the behaviour when the sensor units are note supported by any unit
converter.
"""
zero = dt_util.utcnow()
hass = hass_recorder()
setup_component(hass, "sensor", {})
@ -2077,10 +2081,7 @@ def test_compile_hourly_statistics_changing_units_2(
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[
(None, None, None, None, None, 13.050847, -10, 30),
(None, "%", "%", "%", None, 13.050847, -10, 30),
("battery", "%", "%", "%", None, 13.050847, -10, 30),
("battery", None, None, None, None, 13.050847, -10, 30),
(None, "dogs", "dogs", "dogs", None, 13.050847, -10, 30),
],
)
def test_compile_hourly_statistics_changing_units_3(
@ -2095,7 +2096,11 @@ def test_compile_hourly_statistics_changing_units_3(
min,
max,
):
"""Test compiling hourly statistics where units change from one hour to the next."""
"""Test compiling hourly statistics where units change from one hour to the next.
This tests the behaviour when the sensor units are note supported by any unit
converter.
"""
zero = dt_util.utcnow()
hass = hass_recorder()
setup_component(hass, "sensor", {})
@ -2187,6 +2192,132 @@ def test_compile_hourly_statistics_changing_units_3(
assert "Error while processing event StatisticsTask" not in caplog.text
@pytest.mark.parametrize(
"state_unit_1, state_unit_2, unit_class, mean, min, max, factor",
[
(None, "%", "unitless", 13.050847, -10, 30, 100),
("%", None, "unitless", 13.050847, -10, 30, 0.01),
("W", "kW", "power", 13.050847, -10, 30, 0.001),
("kW", "W", "power", 13.050847, -10, 30, 1000),
],
)
def test_compile_hourly_statistics_convert_units_1(
hass_recorder,
caplog,
state_unit_1,
state_unit_2,
unit_class,
mean,
min,
max,
factor,
):
"""Test compiling hourly statistics where units change from one hour to the next.
This tests the case where the recorder can convert between the units.
"""
zero = dt_util.utcnow()
hass = hass_recorder()
setup_component(hass, "sensor", {})
wait_recording_done(hass) # Wait for the sensor recorder platform to be added
attributes = {
"device_class": None,
"state_class": "measurement",
"unit_of_measurement": state_unit_1,
}
four, states = record_states(hass, zero, "sensor.test1", attributes)
four, _states = record_states(
hass, zero + timedelta(minutes=5), "sensor.test1", attributes, seq=[0, 1, None]
)
states["sensor.test1"] += _states["sensor.test1"]
do_adhoc_statistics(hass, start=zero)
wait_recording_done(hass)
assert "does not match the unit of already compiled" not in caplog.text
statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [
{
"statistic_id": "sensor.test1",
"display_unit_of_measurement": state_unit_1,
"has_mean": True,
"has_sum": False,
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": state_unit_1,
"unit_class": unit_class,
},
]
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {
"sensor.test1": [
{
"start": process_timestamp(zero),
"end": process_timestamp(zero + timedelta(minutes=5)),
"mean": approx(mean),
"min": approx(min),
"max": approx(max),
"last_reset": None,
"state": None,
"sum": None,
}
]
}
attributes["unit_of_measurement"] = state_unit_2
four, _states = record_states(
hass, zero + timedelta(minutes=10), "sensor.test1", attributes
)
states["sensor.test1"] += _states["sensor.test1"]
hist = history.get_significant_states(hass, zero, four)
assert dict(states) == dict(hist)
do_adhoc_statistics(hass, start=zero + timedelta(minutes=10))
wait_recording_done(hass)
assert "The unit of sensor.test1 is changing" not in caplog.text
assert (
f"matches the unit of already compiled statistics ({state_unit_1})"
not in caplog.text
)
statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [
{
"statistic_id": "sensor.test1",
"display_unit_of_measurement": state_unit_2,
"has_mean": True,
"has_sum": False,
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": state_unit_1,
"unit_class": unit_class,
},
]
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {
"sensor.test1": [
{
"start": process_timestamp(zero),
"end": process_timestamp(zero + timedelta(minutes=5)),
"mean": approx(mean * factor),
"min": approx(min * factor),
"max": approx(max * factor),
"last_reset": None,
"state": None,
"sum": None,
},
{
"start": process_timestamp(zero + timedelta(minutes=10)),
"end": process_timestamp(zero + timedelta(minutes=15)),
"mean": approx(mean),
"min": approx(min),
"max": approx(max),
"last_reset": None,
"state": None,
"sum": None,
},
]
}
assert "Error while processing event StatisticsTask" not in caplog.text
@pytest.mark.parametrize(
"device_class, state_unit, state_unit2, unit_class, unit_class2, mean, mean2, min, max",
[
@ -2400,7 +2531,10 @@ def test_compile_hourly_statistics_changing_device_class_1(
min,
max,
):
"""Test compiling hourly statistics where device class changes from one hour to the next."""
"""Test compiling hourly statistics where device class changes from one hour to the next.
Device class is ignored, meaning changing device class should not influence the statistics.
"""
zero = dt_util.utcnow()
hass = hass_recorder()
setup_component(hass, "sensor", {})
@ -2586,7 +2720,10 @@ def test_compile_hourly_statistics_changing_device_class_2(
min,
max,
):
"""Test compiling hourly statistics where device class changes from one hour to the next."""
"""Test compiling hourly statistics where device class changes from one hour to the next.
Device class is ignored, meaning changing device class should not influence the statistics.
"""
zero = dt_util.utcnow()
hass = hass_recorder()
setup_component(hass, "sensor", {})
@ -2692,10 +2829,10 @@ def test_compile_hourly_statistics_changing_device_class_2(
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[
(None, None, None, None, None, 13.050847, -10, 30),
(None, None, None, None, "unitless", 13.050847, -10, 30),
],
)
def test_compile_hourly_statistics_changing_statistics(
def test_compile_hourly_statistics_changing_state_class(
hass_recorder,
caplog,
device_class,
@ -2707,7 +2844,7 @@ def test_compile_hourly_statistics_changing_statistics(
min,
max,
):
"""Test compiling hourly statistics where units change during an hour."""
"""Test compiling hourly statistics where state class changes."""
period0 = dt_util.utcnow()
period0_end = period1 = period0 + timedelta(minutes=5)
period1_end = period0 + timedelta(minutes=10)
@ -2737,7 +2874,7 @@ def test_compile_hourly_statistics_changing_statistics(
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": None,
"unit_class": None,
"unit_class": unit_class,
},
]
metadata = get_metadata(hass, statistic_ids=("sensor.test1",))
@ -2773,7 +2910,7 @@ def test_compile_hourly_statistics_changing_statistics(
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": None,
"unit_class": None,
"unit_class": unit_class,
},
]
metadata = get_metadata(hass, statistic_ids=("sensor.test1",))
@ -2965,7 +3102,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog):
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": "%",
"unit_class": None,
"unit_class": "unitless",
},
{
"statistic_id": "sensor.test2",
@ -2975,7 +3112,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog):
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": "%",
"unit_class": None,
"unit_class": "unitless",
},
{
"statistic_id": "sensor.test3",
@ -2985,7 +3122,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog):
"name": None,
"source": "recorder",
"statistics_unit_of_measurement": "%",
"unit_class": None,
"unit_class": "unitless",
},
{
"statistic_id": "sensor.test4",
@ -3496,6 +3633,13 @@ async def test_validate_statistics_unit_ignore_device_class(
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
BATTERY_SENSOR_ATTRIBUTES,
"%",
None,
"%, <None>",
),
],
)
async def test_validate_statistics_unit_change_no_device_class(
@ -3851,8 +3995,8 @@ async def test_validate_statistics_sensor_removed(
@pytest.mark.parametrize(
"attributes, unit1, unit2",
[
(BATTERY_SENSOR_ATTRIBUTES, "%", "dogs"),
(NONE_SENSOR_ATTRIBUTES, None, "dogs"),
(BATTERY_SENSOR_ATTRIBUTES, "cats", "dogs"),
(NONE_SENSOR_ATTRIBUTES, "cats", "dogs"),
],
)
async def test_validate_statistics_unit_change_no_conversion(