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, PressureConverter,
SpeedConverter, SpeedConverter,
TemperatureConverter, TemperatureConverter,
UnitlessRatioConverter,
VolumeConverter, VolumeConverter,
) )
@ -134,6 +135,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
**{unit: PressureConverter for unit in PressureConverter.VALID_UNITS}, **{unit: PressureConverter for unit in PressureConverter.VALID_UNITS},
**{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS}, **{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS},
**{unit: TemperatureConverter for unit in TemperatureConverter.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}, **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS},
} }
@ -155,9 +157,6 @@ def get_display_unit(
) -> str | None: ) -> str | None:
"""Return the unit which the statistic will be displayed in.""" """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: if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
return statistic_unit return statistic_unit
@ -183,9 +182,6 @@ def _get_statistic_to_display_unit_converter(
"""Return val.""" """Return val."""
return val return val
if statistic_unit is None:
return no_conversion
if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None: if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
return no_conversion return no_conversion
@ -226,9 +222,6 @@ def _get_display_to_statistic_unit_converter(
"""Return val.""" """Return val."""
return val return val
if statistic_unit is None:
return no_conversion
if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None: if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None:
return no_conversion return no_conversion
@ -1555,17 +1548,10 @@ def statistic_during_period(
else: else:
result["change"] = None 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"] state_unit = unit = metadata[statistic_id][1]["unit_of_measurement"]
if state := hass.states.get(statistic_id): if state := hass.states.get(statistic_id):
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) 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)
convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
else:
convert = no_conversion
return {key: convert(value) for key, value in result.items()} 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"] statistic_id = metadata[meta_id]["statistic_id"]
if state := hass.states.get(statistic_id): if state := hass.states.get(statistic_id):
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) 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) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units)
else: else:
convert = no_conversion 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_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity 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 homeassistant.util import dt as dt_util
from .const import ( # noqa: F401 from .const import ( # noqa: F401
@ -155,7 +155,7 @@ class SensorEntity(Entity):
) )
_invalid_unit_of_measurement_reported = False _invalid_unit_of_measurement_reported = False
_last_reset_reported = False _last_reset_reported = False
_sensor_option_unit_of_measurement: str | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
@callback @callback
def add_to_platform_start( def add_to_platform_start(
@ -371,7 +371,7 @@ class SensorEntity(Entity):
"""Return the unit of measurement of the entity, after unit conversion.""" """Return the unit of measurement of the entity, after unit conversion."""
# Highest priority, for registered entities: unit set by user,with fallback to # Highest priority, for registered entities: unit set by user,with fallback to
# unit suggested by integration or secondary fallback to unit conversion rules # 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 return self._sensor_option_unit_of_measurement
# Second priority, for non registered entities: unit suggested by integration # Second priority, for non registered entities: unit suggested by integration
@ -481,8 +481,6 @@ class SensorEntity(Entity):
native_unit_of_measurement != unit_of_measurement native_unit_of_measurement != unit_of_measurement
and device_class in UNIT_CONVERTERS and device_class in UNIT_CONVERTERS
): ):
assert unit_of_measurement
assert native_unit_of_measurement
converter = UNIT_CONVERTERS[device_class] converter = UNIT_CONVERTERS[device_class]
value_s = str(value) value_s = str(value)
@ -550,28 +548,31 @@ class SensorEntity(Entity):
return super().__repr__() return super().__repr__()
def _custom_unit_or_none(self, primary_key: str, secondary_key: str) -> str | None: def _custom_unit_or_undef(
"""Return a custom unit, or None if it's not compatible with the native unit.""" 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 assert self.registry_entry
if ( if (
(sensor_options := self.registry_entry.options.get(primary_key)) (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 (device_class := self.device_class) in UNIT_CONVERTERS
and self.native_unit_of_measurement and self.native_unit_of_measurement
in UNIT_CONVERTERS[device_class].VALID_UNITS 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 cast(str, custom_unit)
return None return UNDEFINED
@callback @callback
def async_registry_entry_updated(self) -> None: def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated.""" """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 DOMAIN, CONF_UNIT_OF_MEASUREMENT
) )
if not self._sensor_option_unit_of_measurement: if self._sensor_option_unit_of_measurement is UNDEFINED:
self._sensor_option_unit_of_measurement = self._custom_unit_or_none( self._sensor_option_unit_of_measurement = self._custom_unit_or_undef(
f"{DOMAIN}.private", "suggested_unit_of_measurement" f"{DOMAIN}.private", "suggested_unit_of_measurement"
) )

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.frame import report
from .unit_conversion import PressureConverter from .unit_conversion import PressureConverter
# pylint: disable-next=protected-access # 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 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 # 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 VALID_UNITS = SpeedConverter.VALID_UNITS

View File

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

View File

@ -17,6 +17,7 @@ from homeassistant.const import (
LENGTH_YARD, LENGTH_YARD,
MASS_GRAMS, MASS_GRAMS,
MASS_OUNCES, MASS_OUNCES,
PERCENTAGE,
PRESSURE_HPA, PRESSURE_HPA,
PRESSURE_INHG, PRESSURE_INHG,
PRESSURE_KPA, PRESSURE_KPA,
@ -546,6 +547,31 @@ async def test_custom_unit(
1000, 1000,
SensorDeviceClass.ENERGY, 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 # Pressure
# Smaller to larger unit, InHg is ~33x larger than hPa -> 1 more decimal # 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) state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(native_value)) 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( entity_registry.async_update_entity_options(
"sensor.test", "sensor", {"unit_of_measurement": custom_unit} "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) state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(custom_value)) 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( entity_registry.async_update_entity_options(
"sensor.test", "sensor", {"unit_of_measurement": native_unit} "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) state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(native_value)) 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) entity_registry.async_update_entity_options("sensor.test", "sensor", None)
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(entity0.entity_id) state = hass.states.get(entity0.entity_id)
assert float(state.state) == approx(float(native_value)) 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( @pytest.mark.parametrize(

View File

@ -94,13 +94,13 @@ def set_time_zone():
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[ [
(None, "%", "%", "%", None, 13.050847, -10, 30), (None, "%", "%", "%", "unitless", 13.050847, -10, 30),
("battery", "%", "%", "%", None, 13.050847, -10, 30), ("battery", "%", "%", "%", "unitless", 13.050847, -10, 30),
("battery", None, None, None, None, 13.050847, -10, 30), ("battery", None, None, None, "unitless", 13.050847, -10, 30),
("distance", "m", "m", "m", "distance", 13.050847, -10, 30), ("distance", "m", "m", "m", "distance", 13.050847, -10, 30),
("distance", "mi", "mi", "mi", "distance", 13.050847, -10, 30), ("distance", "mi", "mi", "mi", "distance", 13.050847, -10, 30),
("humidity", "%", "%", "%", None, 13.050847, -10, 30), ("humidity", "%", "%", "%", "unitless", 13.050847, -10, 30),
("humidity", None, None, None, None, 13.050847, -10, 30), ("humidity", None, None, None, "unitless", 13.050847, -10, 30),
("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30), ("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30),
("pressure", "hPa", "hPa", "hPa", "pressure", 13.050847, -10, 30), ("pressure", "hPa", "hPa", "hPa", "pressure", 13.050847, -10, 30),
("pressure", "mbar", "mbar", "mbar", "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( @pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class", "device_class, state_unit, display_unit, statistics_unit, unit_class",
[ [
(None, "%", "%", "%", None), (None, "%", "%", "%", "unitless"),
], ],
) )
def test_compile_hourly_statistics_purged_state_changes( 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", "source": "recorder",
"statistic_id": "sensor.test3", "statistic_id": "sensor.test3",
"statistics_unit_of_measurement": None, "statistics_unit_of_measurement": None,
"unit_class": None, "unit_class": "unitless",
}, },
{ {
"statistic_id": "sensor.test6", "statistic_id": "sensor.test6",
@ -1775,8 +1775,8 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"state_class, device_class, state_unit, display_unit, statistics_unit, unit_class, statistic_type", "state_class, device_class, state_unit, display_unit, statistics_unit, unit_class, statistic_type",
[ [
("measurement", "battery", "%", "%", "%", None, "mean"), ("measurement", "battery", "%", "%", "%", "unitless", "mean"),
("measurement", "battery", None, None, None, None, "mean"), ("measurement", "battery", None, None, None, "unitless", "mean"),
("measurement", "distance", "m", "m", "m", "distance", "mean"), ("measurement", "distance", "m", "m", "m", "distance", "mean"),
("measurement", "distance", "mi", "mi", "mi", "distance", "mean"), ("measurement", "distance", "mi", "mi", "mi", "distance", "mean"),
("total", "distance", "m", "m", "m", "distance", "sum"), ("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"), ("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"),
("measurement", "energy", "Wh", "Wh", "Wh", "energy", "mean"), ("measurement", "energy", "Wh", "Wh", "Wh", "energy", "mean"),
("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"), ("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"),
("measurement", "humidity", "%", "%", "%", None, "mean"), ("measurement", "humidity", "%", "%", "%", "unitless", "mean"),
("measurement", "humidity", None, None, None, None, "mean"), ("measurement", "humidity", None, None, None, "unitless", "mean"),
("total", "monetary", "USD", "USD", "USD", None, "sum"), ("total", "monetary", "USD", "USD", "USD", None, "sum"),
("total", "monetary", "None", "None", "None", None, "sum"), ("total", "monetary", "None", "None", "None", None, "sum"),
("total", "gas", "", "", "", "volume", "sum"), ("total", "gas", "", "", "", "volume", "sum"),
@ -1898,10 +1898,10 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class, state_unit, state_unit2, unit_class, mean, min, max", "device_class, state_unit, state_unit2, unit_class, mean, min, max",
[ [
(None, None, "cats", None, 13.050847, -10, 30), (None, None, "cats", "unitless", 13.050847, -10, 30),
(None, "%", "cats", None, 13.050847, -10, 30), (None, "%", "cats", "unitless", 13.050847, -10, 30),
("battery", "%", "cats", None, 13.050847, -10, 30), ("battery", "%", "cats", "unitless", 13.050847, -10, 30),
("battery", None, "cats", None, 13.050847, -10, 30), ("battery", None, "cats", "unitless", 13.050847, -10, 30),
(None, "kW", "Wh", "power", 13.050847, -10, 30), (None, "kW", "Wh", "power", 13.050847, -10, 30),
# Can't downgrade from ft³ to ft3 or from m³ to m3 # Can't downgrade from ft³ to ft3 or from m³ to m3
(None, "ft³", "ft3", "volume", 13.050847, -10, 30), (None, "ft³", "ft3", "volume", 13.050847, -10, 30),
@ -1919,7 +1919,10 @@ def test_compile_hourly_statistics_changing_units_1(
min, min,
max, 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() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
@ -2014,10 +2017,7 @@ def test_compile_hourly_statistics_changing_units_1(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[ [
(None, None, None, None, None, 13.050847, -10, 30), (None, "dogs", "dogs", "dogs", 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),
], ],
) )
def test_compile_hourly_statistics_changing_units_2( def test_compile_hourly_statistics_changing_units_2(
@ -2032,7 +2032,11 @@ def test_compile_hourly_statistics_changing_units_2(
min, min,
max, 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() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
@ -2077,10 +2081,7 @@ def test_compile_hourly_statistics_changing_units_2(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max",
[ [
(None, None, None, None, None, 13.050847, -10, 30), (None, "dogs", "dogs", "dogs", 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),
], ],
) )
def test_compile_hourly_statistics_changing_units_3( def test_compile_hourly_statistics_changing_units_3(
@ -2095,7 +2096,11 @@ def test_compile_hourly_statistics_changing_units_3(
min, min,
max, 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() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) 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 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( @pytest.mark.parametrize(
"device_class, state_unit, state_unit2, unit_class, unit_class2, mean, mean2, min, max", "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, min,
max, 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() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
@ -2586,7 +2720,10 @@ def test_compile_hourly_statistics_changing_device_class_2(
min, min,
max, 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() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
@ -2692,10 +2829,10 @@ def test_compile_hourly_statistics_changing_device_class_2(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", "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, hass_recorder,
caplog, caplog,
device_class, device_class,
@ -2707,7 +2844,7 @@ def test_compile_hourly_statistics_changing_statistics(
min, min,
max, max,
): ):
"""Test compiling hourly statistics where units change during an hour.""" """Test compiling hourly statistics where state class changes."""
period0 = dt_util.utcnow() period0 = dt_util.utcnow()
period0_end = period1 = period0 + timedelta(minutes=5) period0_end = period1 = period0 + timedelta(minutes=5)
period1_end = period0 + timedelta(minutes=10) period1_end = period0 + timedelta(minutes=10)
@ -2737,7 +2874,7 @@ def test_compile_hourly_statistics_changing_statistics(
"name": None, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": None, "statistics_unit_of_measurement": None,
"unit_class": None, "unit_class": unit_class,
}, },
] ]
metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) metadata = get_metadata(hass, statistic_ids=("sensor.test1",))
@ -2773,7 +2910,7 @@ def test_compile_hourly_statistics_changing_statistics(
"name": None, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": None, "statistics_unit_of_measurement": None,
"unit_class": None, "unit_class": unit_class,
}, },
] ]
metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) 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, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": "%", "statistics_unit_of_measurement": "%",
"unit_class": None, "unit_class": "unitless",
}, },
{ {
"statistic_id": "sensor.test2", "statistic_id": "sensor.test2",
@ -2975,7 +3112,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog):
"name": None, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": "%", "statistics_unit_of_measurement": "%",
"unit_class": None, "unit_class": "unitless",
}, },
{ {
"statistic_id": "sensor.test3", "statistic_id": "sensor.test3",
@ -2985,7 +3122,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog):
"name": None, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": "%", "statistics_unit_of_measurement": "%",
"unit_class": None, "unit_class": "unitless",
}, },
{ {
"statistic_id": "sensor.test4", "statistic_id": "sensor.test4",
@ -3496,6 +3633,13 @@ async def test_validate_statistics_unit_ignore_device_class(
"bar", "bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi", "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( async def test_validate_statistics_unit_change_no_device_class(
@ -3851,8 +3995,8 @@ async def test_validate_statistics_sensor_removed(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"attributes, unit1, unit2", "attributes, unit1, unit2",
[ [
(BATTERY_SENSOR_ATTRIBUTES, "%", "dogs"), (BATTERY_SENSOR_ATTRIBUTES, "cats", "dogs"),
(NONE_SENSOR_ATTRIBUTES, None, "dogs"), (NONE_SENSOR_ATTRIBUTES, "cats", "dogs"),
], ],
) )
async def test_validate_statistics_unit_change_no_conversion( async def test_validate_statistics_unit_change_no_conversion(