From b0d4b7387437cee17f1a4f4671e7eb839c800d18 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Jan 2023 09:20:00 +0100 Subject: [PATCH] Add unitless unit converter (#85694) * Add unitless unit converter * Adjust type hints * Adjust tests * Rename to UnitlessRatioConverter --- .../components/recorder/statistics.py | 22 +- homeassistant/components/sensor/__init__.py | 27 +-- homeassistant/components/sensor/const.py | 2 + homeassistant/components/sensor/recorder.py | 10 +- homeassistant/components/subaru/sensor.py | 6 +- homeassistant/util/pressure.py | 2 +- homeassistant/util/speed.py | 2 +- homeassistant/util/unit_conversion.py | 50 ++-- .../components/recorder/test_websocket_api.py | 4 +- tests/components/sensor/test_init.py | 34 ++- tests/components/sensor/test_recorder.py | 220 +++++++++++++++--- 11 files changed, 276 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index fab4a67a749..b8a58500747 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -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 diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 28b3b835e6c..929af262cdb 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -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" ) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index f3baca58f8b..ed79823cc32 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -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, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 5224514f0e4..656e9fb00f0 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -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 "" 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, }, ) ) diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index c5b2b86fda4..e0f8c243c27 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -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, ) diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index eccd358ad81..78a69e15a34 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -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 diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py index de076701c55..a1b6e0a7227 100644 --- a/homeassistant/util/speed.py +++ b/homeassistant/util/speed.py @@ -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 diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 274f13cd0b5..930e5a71e42 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -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, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index fefc8dbdda1..39250b7f499 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -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( ("m³", "m³", "volume", 1, ("ft³", "m³"), ("Wh", "kWh", "MWh", "cats", None)), ("ft³", "ft³", "volume", 1, ("ft³", "m³"), ("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( diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index f9178200126..84830807c7f 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -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( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a252a85b8d7..b1c5f441002 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -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", "m³", "m³", "m³", "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, + "%, ", + ), ], ) 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(