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
from datetime import datetime, timedelta
import logging
import math
import statistics
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_ABSOLUTE = "distance_absolute"
STAT_MEAN = "mean"
STAT_MEAN_CIRCULAR = "mean_circular"
STAT_MEDIAN = "median"
STAT_NOISINESS = "noisiness"
STAT_PERCENTILE = "percentile"
@ -111,6 +113,7 @@ STATS_NUMERIC_SUPPORT = {
STAT_DISTANCE_99P,
STAT_DISTANCE_ABSOLUTE,
STAT_MEAN,
STAT_MEAN_CIRCULAR,
STAT_MEDIAN,
STAT_NOISINESS,
STAT_PERCENTILE,
@ -160,6 +163,7 @@ STATS_NUMERIC_RETAIN_UNIT = {
STAT_DISTANCE_99P,
STAT_DISTANCE_ABSOLUTE,
STAT_MEAN,
STAT_MEAN_CIRCULAR,
STAT_MEDIAN,
STAT_NOISINESS,
STAT_PERCENTILE,
@ -681,6 +685,13 @@ class StatisticsSensor(SensorEntity):
return statistics.mean(self.states)
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:
if len(self.states) > 0:
return statistics.median(self.states)

View file

@ -22,6 +22,7 @@ from homeassistant.components.statistics.sensor import StatisticsSensor
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
DEGREE,
SERVICE_RELOAD,
STATE_UNAVAILABLE,
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)),
"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",
"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:
"""Test the detection of wrong state_characteristics selected."""
assert await async_setup_component(