From 35be5957c39f6937b8e76bbf5805e50bf7b2e9f8 Mon Sep 17 00:00:00 2001 From: enzo2 <542271+enzo2@users.noreply.github.com> Date: Sat, 7 Oct 2023 07:51:27 -0400 Subject: [PATCH] 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 --- homeassistant/components/statistics/sensor.py | 11 +++++ tests/components/statistics/test_sensor.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index e86a4741080..90cb80a9642 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -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) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 780e550f224..13330770978 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -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(