From 1e17b2fd63c9bcc3355015afb7127a59b1692c7b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 18 Mar 2018 15:58:52 +0000 Subject: [PATCH] Added Time based SMA to Filter Sensor (#13104) * Added Time based SMA * move "now" to _filter_state() * Addressed comments * fix long line * type and name * # pylint: disable=redefined-builtin * added test --- homeassistant/components/sensor/filter.py | 66 ++++++++++++++++++++++- tests/components/sensor/test_filter.py | 18 ++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index cde50699b29..aad7fec26a0 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -20,12 +20,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.decorator import Registry from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' +FILTER_NAME_TIME_SMA = 'time_simple_moving_average' FILTERS = Registry() CONF_FILTERS = 'filters' @@ -34,6 +36,9 @@ CONF_FILTER_WINDOW_SIZE = 'window_size' CONF_FILTER_PRECISION = 'precision' CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +CONF_TIME_SMA_TYPE = 'type' + +TIME_SMA_LAST = 'last' DEFAULT_WINDOW_SIZE = 1 DEFAULT_PRECISION = 2 @@ -44,24 +49,37 @@ NAME_TEMPLATE = "{} filter" ICON = 'mdi:chart-line-variant' FILTER_SCHEMA = vol.Schema({ - vol.Optional(CONF_FILTER_WINDOW_SIZE, - default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), }) +# pylint: disable=redefined-builtin FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): vol.Coerce(float), }) FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS, + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) +FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA, + vol.Optional(CONF_TIME_SMA_TYPE, + default=TIME_SMA_LAST): vol.In( + [None, TIME_SMA_LAST]), + + vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All(cv.time_period, + cv.positive_timedelta) +}) + FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE, }) @@ -72,6 +90,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FILTERS): vol.All(cv.ensure_list, [vol.Any(FILTER_OUTLIER_SCHEMA, FILTER_LOWPASS_SCHEMA, + FILTER_TIME_SMA_SCHEMA, FILTER_THROTTLE_SCHEMA)]) }) @@ -277,6 +296,49 @@ class LowPassFilter(Filter): return filtered +@FILTERS.register(FILTER_NAME_TIME_SMA) +class TimeSMAFilter(Filter): + """Simple Moving Average (SMA) Filter. + + The window_size is determined by time, and SMA is time weighted. + + Args: + variant (enum): type of argorithm used to connect discrete values + """ + + def __init__(self, window_size, precision, entity, type): + """Initialize Filter.""" + super().__init__(FILTER_NAME_TIME_SMA, 0, precision, entity) + self._time_window = int(window_size.total_seconds()) + self.last_leak = None + self.queue = deque() + + def _leak(self, now): + """Remove timeouted elements.""" + while self.queue: + timestamp, _ = self.queue[0] + if timestamp + self._time_window <= now: + self.last_leak = self.queue.popleft() + else: + return + + def _filter_state(self, new_state): + now = int(dt_util.utcnow().timestamp()) + + self._leak(now) + self.queue.append((now, float(new_state))) + moving_sum = 0 + start = now - self._time_window + _, prev_val = self.last_leak or (0, float(new_state)) + + for timestamp, val in self.queue: + moving_sum += (timestamp-start)*prev_val + start, prev_val = timestamp, val + moving_sum += (now-start)*prev_val + + return moving_sum/self._time_window + + @FILTERS.register(FILTER_NAME_THROTTLE) class ThrottleFilter(Filter): """Throttle Filter. diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index dd1112d65f8..0d4082731ab 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -1,8 +1,11 @@ """The test for the data filter sensor platform.""" +from datetime import timedelta import unittest +from unittest.mock import patch from homeassistant.components.sensor.filter import ( - LowPassFilter, OutlierFilter, ThrottleFilter) + LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) +import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component from tests.common import get_test_home_assistant, assert_setup_component @@ -90,3 +93,16 @@ class TestFilterSensor(unittest.TestCase): if not filt.skip_processing: filtered.append(new_state) self.assertEqual([20, 21], filtered) + + def test_time_sma(self): + """Test if time_sma filter works.""" + filt = TimeSMAFilter(window_size=timedelta(minutes=2), + precision=2, + entity=None, + type='last') + past = dt_util.utcnow() - timedelta(minutes=5) + for state in self.values: + with patch('homeassistant.util.dt.utcnow', return_value=past): + filtered = filt.filter_state(state) + past += timedelta(minutes=1) + self.assertEqual(21.5, filtered)