mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Fix calculation of attributes in statistics (#128475)
* Fix calculation of attributes in statistics * Cleanup * Mods * Fix device class * Typing * Mod uom calc * Fix UoM * Fix docstrings * state class docstring
This commit is contained in:
parent
21f23f67f4
commit
7d699c6c35
@ -17,6 +17,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
|
|||||||
from homeassistant.components.recorder import get_instance, history
|
from homeassistant.components.recorder import get_instance, history
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
DEVICE_CLASS_STATE_CLASSES,
|
DEVICE_CLASS_STATE_CLASSES,
|
||||||
|
DEVICE_CLASS_UNITS,
|
||||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
@ -359,15 +360,14 @@ class StatisticsSensor(SensorEntity):
|
|||||||
self.samples_keep_last: bool = samples_keep_last
|
self.samples_keep_last: bool = samples_keep_last
|
||||||
self._precision: int = precision
|
self._precision: int = precision
|
||||||
self._percentile: int = percentile
|
self._percentile: int = percentile
|
||||||
self._value: StateType | datetime = None
|
self._value: float | int | datetime | None = None
|
||||||
self._unit_of_measurement: str | None = None
|
|
||||||
self._available: bool = False
|
self._available: bool = False
|
||||||
|
|
||||||
self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size)
|
self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size)
|
||||||
self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size)
|
self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size)
|
||||||
self.attributes: dict[str, StateType] = {}
|
self.attributes: dict[str, StateType] = {}
|
||||||
|
|
||||||
self._state_characteristic_fn: Callable[[], StateType | datetime] = (
|
self._state_characteristic_fn: Callable[[], float | int | datetime | None] = (
|
||||||
self._callable_characteristic_fn(self._state_characteristic)
|
self._callable_characteristic_fn(self._state_characteristic)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -486,11 +486,28 @@ class StatisticsSensor(SensorEntity):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._unit_of_measurement = self._derive_unit_of_measurement(new_state)
|
self._calculate_state_attributes(new_state)
|
||||||
|
|
||||||
|
def _calculate_state_attributes(self, new_state: State) -> None:
|
||||||
|
"""Set the entity state attributes."""
|
||||||
|
|
||||||
|
self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement(
|
||||||
|
new_state
|
||||||
|
)
|
||||||
|
self._attr_device_class = self._calculate_device_class(
|
||||||
|
new_state, self._attr_native_unit_of_measurement
|
||||||
|
)
|
||||||
|
self._attr_state_class = self._calculate_state_class(new_state)
|
||||||
|
|
||||||
|
def _calculate_unit_of_measurement(self, new_state: State) -> str | None:
|
||||||
|
"""Return the calculated unit of measurement.
|
||||||
|
|
||||||
|
The unit of measurement is that of the source sensor, adjusted based on the
|
||||||
|
state characteristics.
|
||||||
|
"""
|
||||||
|
|
||||||
def _derive_unit_of_measurement(self, new_state: State) -> str | None:
|
|
||||||
base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||||
unit: str | None
|
unit: str | None = None
|
||||||
if self.is_binary and self._state_characteristic in STATS_BINARY_PERCENTAGE:
|
if self.is_binary and self._state_characteristic in STATS_BINARY_PERCENTAGE:
|
||||||
unit = PERCENTAGE
|
unit = PERCENTAGE
|
||||||
elif not base_unit:
|
elif not base_unit:
|
||||||
@ -513,48 +530,66 @@ class StatisticsSensor(SensorEntity):
|
|||||||
unit = base_unit + "/sample"
|
unit = base_unit + "/sample"
|
||||||
elif self._state_characteristic == STAT_CHANGE_SECOND:
|
elif self._state_characteristic == STAT_CHANGE_SECOND:
|
||||||
unit = base_unit + "/s"
|
unit = base_unit + "/s"
|
||||||
|
|
||||||
return unit
|
return unit
|
||||||
|
|
||||||
@property
|
def _calculate_device_class(
|
||||||
def device_class(self) -> SensorDeviceClass | None:
|
self, new_state: State, unit: str | None
|
||||||
"""Return the class of this device."""
|
) -> SensorDeviceClass | None:
|
||||||
|
"""Return the calculated device class.
|
||||||
|
|
||||||
|
The device class is calculated based on the state characteristics,
|
||||||
|
the source device class and the unit of measurement is
|
||||||
|
in the device class units list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_class: SensorDeviceClass | None = None
|
||||||
if self._state_characteristic in STATS_DATETIME:
|
if self._state_characteristic in STATS_DATETIME:
|
||||||
return SensorDeviceClass.TIMESTAMP
|
return SensorDeviceClass.TIMESTAMP
|
||||||
if self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT:
|
if self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT:
|
||||||
source_state = self.hass.states.get(self._source_entity_id)
|
device_class = new_state.attributes.get(ATTR_DEVICE_CLASS)
|
||||||
if source_state is None:
|
if device_class is None:
|
||||||
return None
|
return None
|
||||||
source_device_class = source_state.attributes.get(ATTR_DEVICE_CLASS)
|
if (
|
||||||
if source_device_class is None:
|
sensor_device_class := try_parse_enum(SensorDeviceClass, device_class)
|
||||||
|
) is None:
|
||||||
return None
|
return None
|
||||||
sensor_device_class = try_parse_enum(SensorDeviceClass, source_device_class)
|
if (
|
||||||
if sensor_device_class is None:
|
sensor_device_class
|
||||||
return None
|
and (
|
||||||
sensor_state_classes = DEVICE_CLASS_STATE_CLASSES.get(
|
sensor_state_classes := DEVICE_CLASS_STATE_CLASSES.get(
|
||||||
sensor_device_class, set()
|
sensor_device_class
|
||||||
)
|
)
|
||||||
if SensorStateClass.MEASUREMENT not in sensor_state_classes:
|
)
|
||||||
|
and sensor_state_classes
|
||||||
|
and SensorStateClass.MEASUREMENT not in sensor_state_classes
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
return sensor_device_class
|
if device_class not in DEVICE_CLASS_UNITS:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
device_class in DEVICE_CLASS_UNITS
|
||||||
|
and unit not in DEVICE_CLASS_UNITS[device_class]
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
return device_class
|
||||||
def state_class(self) -> SensorStateClass | None:
|
|
||||||
"""Return the state class of this entity."""
|
def _calculate_state_class(self, new_state: State) -> SensorStateClass | None:
|
||||||
|
"""Return the calculated state class.
|
||||||
|
|
||||||
|
Will be None if the characteristics is not numerical, otherwise
|
||||||
|
SensorStateClass.MEASUREMENT.
|
||||||
|
"""
|
||||||
if self._state_characteristic in STATS_NOT_A_NUMBER:
|
if self._state_characteristic in STATS_NOT_A_NUMBER:
|
||||||
return None
|
return None
|
||||||
return SensorStateClass.MEASUREMENT
|
return SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType | datetime:
|
def native_value(self) -> float | int | datetime | None:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
return self._value
|
return self._value
|
||||||
|
|
||||||
@property
|
|
||||||
def native_unit_of_measurement(self) -> str | None:
|
|
||||||
"""Return the unit the value is expressed in."""
|
|
||||||
return self._unit_of_measurement
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return the availability of the sensor linked to the source sensor."""
|
"""Return the availability of the sensor linked to the source sensor."""
|
||||||
@ -703,7 +738,7 @@ class StatisticsSensor(SensorEntity):
|
|||||||
):
|
):
|
||||||
for state in reversed(states):
|
for state in reversed(states):
|
||||||
self._add_state_to_queue(state)
|
self._add_state_to_queue(state)
|
||||||
|
self._calculate_state_attributes(state)
|
||||||
self._async_purge_update_and_schedule()
|
self._async_purge_update_and_schedule()
|
||||||
|
|
||||||
# only write state to the state machine if we are not in preview mode
|
# only write state to the state machine if we are not in preview mode
|
||||||
@ -750,9 +785,9 @@ class StatisticsSensor(SensorEntity):
|
|||||||
|
|
||||||
def _callable_characteristic_fn(
|
def _callable_characteristic_fn(
|
||||||
self, characteristic: str
|
self, characteristic: str
|
||||||
) -> Callable[[], StateType | datetime]:
|
) -> Callable[[], float | int | datetime | None]:
|
||||||
"""Return the function callable of one characteristic function."""
|
"""Return the function callable of one characteristic function."""
|
||||||
function: Callable[[], StateType | datetime] = getattr(
|
function: Callable[[], float | int | datetime | None] = getattr(
|
||||||
self,
|
self,
|
||||||
f"_stat_binary_{characteristic}"
|
f"_stat_binary_{characteristic}"
|
||||||
if self.is_binary
|
if self.is_binary
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
'attributes': dict({
|
'attributes': dict({
|
||||||
'friendly_name': 'Statistical characteristic',
|
'friendly_name': 'Statistical characteristic',
|
||||||
'icon': 'mdi:calculator',
|
'icon': 'mdi:calculator',
|
||||||
'state_class': 'measurement',
|
|
||||||
}),
|
}),
|
||||||
'state': 'unavailable',
|
'state': 'unavailable',
|
||||||
})
|
})
|
||||||
|
@ -1832,3 +1832,203 @@ async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None:
|
|||||||
"value mismatch for characteristic 'sensor/average_linear' - "
|
"value mismatch for characteristic 'sensor/average_linear' - "
|
||||||
f"assert {state.state} == 8.33"
|
f"assert {state.state} == 8.33"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_unit_gets_removed(hass: HomeAssistant) -> None:
|
||||||
|
"""Test when input lose its unit of measurement."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"sensor",
|
||||||
|
{
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"platform": "statistics",
|
||||||
|
"name": "test",
|
||||||
|
"entity_id": "sensor.test_monitored",
|
||||||
|
"state_characteristic": "mean",
|
||||||
|
"sampling_size": 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
input_attributes = {
|
||||||
|
ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT,
|
||||||
|
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in VALUES_NUMERIC:
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(value),
|
||||||
|
input_attributes,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2))
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(VALUES_NUMERIC[0]),
|
||||||
|
{
|
||||||
|
ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT,
|
||||||
|
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "11.39"
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
|
||||||
|
# Temperature device class is not valid with no unit of measurement
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) is None
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
for value in VALUES_NUMERIC:
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(value),
|
||||||
|
input_attributes,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "11.39"
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_device_class_gets_removed(hass: HomeAssistant) -> None:
|
||||||
|
"""Test when device class gets removed."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"sensor",
|
||||||
|
{
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"platform": "statistics",
|
||||||
|
"name": "test",
|
||||||
|
"entity_id": "sensor.test_monitored",
|
||||||
|
"state_characteristic": "mean",
|
||||||
|
"sampling_size": 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
input_attributes = {
|
||||||
|
ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT,
|
||||||
|
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in VALUES_NUMERIC:
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(value),
|
||||||
|
input_attributes,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2))
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(VALUES_NUMERIC[0]),
|
||||||
|
{
|
||||||
|
ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT,
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "11.39"
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) is None
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
for value in VALUES_NUMERIC:
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(value),
|
||||||
|
input_attributes,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "11.39"
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_not_valid_device_class(hass: HomeAssistant) -> None:
|
||||||
|
"""Test when not valid device class."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"sensor",
|
||||||
|
{
|
||||||
|
"sensor": [
|
||||||
|
{
|
||||||
|
"platform": "statistics",
|
||||||
|
"name": "test",
|
||||||
|
"entity_id": "sensor.test_monitored",
|
||||||
|
"state_characteristic": "mean",
|
||||||
|
"sampling_size": 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
for value in VALUES_NUMERIC:
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(value),
|
||||||
|
{
|
||||||
|
ATTR_DEVICE_CLASS: SensorDeviceClass.DATE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2))
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) is None
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.test_monitored",
|
||||||
|
str(10),
|
||||||
|
{
|
||||||
|
ATTR_DEVICE_CLASS: "not_exist",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test")
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "10.69"
|
||||||
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
|
||||||
|
assert state.attributes.get(ATTR_DEVICE_CLASS) is None
|
||||||
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
||||||
|
Loading…
x
Reference in New Issue
Block a user