Add circular mean to statistics integration (#98930)

* Add circular mean

Add support for circular mean for sensors in units of degrees, e.g. direction data.

* Update test_sensor.py

* Update sensor.py

* Remove whitespace

* Revert to degC

* Fix: shift atan2 output to positive degrees

* Add new dedicated test

* Simplify test
This commit is contained in:
enzo2 2023-10-07 07:51:27 -04:00 committed by GitHub
parent 3018d4edb9
commit 35be5957c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 57 additions and 0 deletions

View File

@ -6,6 +6,7 @@ from collections.abc import Callable
import contextlib import contextlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import math
import statistics import statistics
from typing import Any, cast from typing import Any, cast
@ -82,6 +83,7 @@ STAT_DISTANCE_95P = "distance_95_percent_of_values"
STAT_DISTANCE_99P = "distance_99_percent_of_values" STAT_DISTANCE_99P = "distance_99_percent_of_values"
STAT_DISTANCE_ABSOLUTE = "distance_absolute" STAT_DISTANCE_ABSOLUTE = "distance_absolute"
STAT_MEAN = "mean" STAT_MEAN = "mean"
STAT_MEAN_CIRCULAR = "mean_circular"
STAT_MEDIAN = "median" STAT_MEDIAN = "median"
STAT_NOISINESS = "noisiness" STAT_NOISINESS = "noisiness"
STAT_PERCENTILE = "percentile" STAT_PERCENTILE = "percentile"
@ -111,6 +113,7 @@ STATS_NUMERIC_SUPPORT = {
STAT_DISTANCE_99P, STAT_DISTANCE_99P,
STAT_DISTANCE_ABSOLUTE, STAT_DISTANCE_ABSOLUTE,
STAT_MEAN, STAT_MEAN,
STAT_MEAN_CIRCULAR,
STAT_MEDIAN, STAT_MEDIAN,
STAT_NOISINESS, STAT_NOISINESS,
STAT_PERCENTILE, STAT_PERCENTILE,
@ -160,6 +163,7 @@ STATS_NUMERIC_RETAIN_UNIT = {
STAT_DISTANCE_99P, STAT_DISTANCE_99P,
STAT_DISTANCE_ABSOLUTE, STAT_DISTANCE_ABSOLUTE,
STAT_MEAN, STAT_MEAN,
STAT_MEAN_CIRCULAR,
STAT_MEDIAN, STAT_MEDIAN,
STAT_NOISINESS, STAT_NOISINESS,
STAT_PERCENTILE, STAT_PERCENTILE,
@ -681,6 +685,13 @@ class StatisticsSensor(SensorEntity):
return statistics.mean(self.states) return statistics.mean(self.states)
return None return None
def _stat_mean_circular(self) -> StateType:
if len(self.states) > 0:
sin_sum = sum(math.sin(math.radians(x)) for x in self.states)
cos_sum = sum(math.cos(math.radians(x)) for x in self.states)
return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360
return None
def _stat_median(self) -> StateType: def _stat_median(self) -> StateType:
if len(self.states) > 0: if len(self.states) > 0:
return statistics.median(self.states) return statistics.median(self.states)

View File

@ -22,6 +22,7 @@ from homeassistant.components.statistics.sensor import StatisticsSensor
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
DEGREE,
SERVICE_RELOAD, SERVICE_RELOAD,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
@ -920,6 +921,14 @@ async def test_state_characteristics(hass: HomeAssistant) -> None:
"value_9": float(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)), "value_9": float(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)),
"unit": "°C", "unit": "°C",
}, },
{
"source_sensor_domain": "sensor",
"name": "mean_circular",
"value_0": STATE_UNKNOWN,
"value_1": float(VALUES_NUMERIC[-1]),
"value_9": 10.76,
"unit": "°C",
},
{ {
"source_sensor_domain": "sensor", "source_sensor_domain": "sensor",
"name": "median", "name": "median",
@ -1207,6 +1216,43 @@ async def test_state_characteristics(hass: HomeAssistant) -> None:
) )
async def test_state_characteristic_mean_circular(hass: HomeAssistant) -> None:
"""Test the mean_circular state characteristic using angle data."""
values_angular = [0, 10, 90.5, 180, 269.5, 350]
assert await async_setup_component(
hass,
"sensor",
{
"sensor": [
{
"platform": "statistics",
"name": "test_sensor_mean_circular",
"entity_id": "sensor.test_monitored",
"state_characteristic": "mean_circular",
"sampling_size": 6,
},
]
},
)
await hass.async_block_till_done()
for angle in values_angular:
hass.states.async_set(
"sensor.test_monitored",
str(angle),
{ATTR_UNIT_OF_MEASUREMENT: DEGREE},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_sensor_mean_circular")
assert state is not None
assert state.state == "0.0", (
"value mismatch for characteristic 'sensor/mean_circular' - "
f"assert {state.state} == 0.0"
)
async def test_invalid_state_characteristic(hass: HomeAssistant) -> None: async def test_invalid_state_characteristic(hass: HomeAssistant) -> None:
"""Test the detection of wrong state_characteristics selected.""" """Test the detection of wrong state_characteristics selected."""
assert await async_setup_component( assert await async_setup_component(