From 8a2bc99f6337b61923dea095636a3e2857ccb6c5 Mon Sep 17 00:00:00 2001 From: Florian Werner Date: Sat, 8 Sep 2018 01:10:08 +0200 Subject: [PATCH] Add rate of change to statistics sensor (#15632) * always export max_age/min_age * downgrade errors of missing data on start with empty recorder database these errors are logged multiple times: ERROR (MainThread) [homeassistant.components.sensor.statistics] mean requires at least one data point ERROR (MainThread) [homeassistant.components.sensor.statistics] variance requires at least two data points downgrade them to debug as they are not meaningful to end users * add change_rate attribute this calculates the average change rate of all data points * simplify count, reorder attribute calculation * reorder initialization * reorder attribute names * don't use min/max for min_age/max_age * add test case * style * style * sort constants * init variables with None * add precision config setting * round to precision * test round --- homeassistant/components/sensor/statistics.py | 122 +++++++++++------- tests/components/sensor/test_statistics.py | 42 +++++- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index b1cfd447f0e..e7692001ffa 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -25,23 +25,26 @@ _LOGGER = logging.getLogger(__name__) ATTR_AVERAGE_CHANGE = 'average_change' ATTR_CHANGE = 'change' +ATTR_CHANGE_RATE = 'change_rate' ATTR_COUNT = 'count' +ATTR_MAX_AGE = 'max_age' ATTR_MAX_VALUE = 'max_value' -ATTR_MIN_VALUE = 'min_value' ATTR_MEAN = 'mean' ATTR_MEDIAN = 'median' -ATTR_VARIANCE = 'variance' -ATTR_STANDARD_DEVIATION = 'standard_deviation' -ATTR_SAMPLING_SIZE = 'sampling_size' -ATTR_TOTAL = 'total' -ATTR_MAX_AGE = 'max_age' ATTR_MIN_AGE = 'min_age' +ATTR_MIN_VALUE = 'min_value' +ATTR_SAMPLING_SIZE = 'sampling_size' +ATTR_STANDARD_DEVIATION = 'standard_deviation' +ATTR_TOTAL = 'total' +ATTR_VARIANCE = 'variance' CONF_SAMPLING_SIZE = 'sampling_size' CONF_MAX_AGE = 'max_age' +CONF_PRECISION = 'precision' DEFAULT_NAME = 'Stats' DEFAULT_SIZE = 20 +DEFAULT_PRECISION = 2 ICON = 'mdi:calculator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -49,7 +52,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_MAX_AGE): cv.time_period + vol.Optional(CONF_MAX_AGE): cv.time_period, + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): + vol.Coerce(int) }) @@ -61,17 +66,19 @@ def async_setup_platform(hass, config, async_add_entities, name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) max_age = config.get(CONF_MAX_AGE, None) + precision = config.get(CONF_PRECISION) + + async_add_entities([StatisticsSensor(hass, entity_id, name, sampling_size, + max_age, precision)], True) - async_add_entities( - [StatisticsSensor(hass, entity_id, name, sampling_size, max_age)], - True) return True class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size, max_age): + def __init__(self, hass, entity_id, name, sampling_size, max_age, + precision): """Initialize the Statistics sensor.""" self._hass = hass self._entity_id = entity_id @@ -83,15 +90,16 @@ class StatisticsSensor(Entity): self._name = '{} {}'.format(name, ATTR_COUNT) self._sampling_size = sampling_size self._max_age = max_age + self._precision = precision self._unit_of_measurement = None self.states = deque(maxlen=self._sampling_size) - if self._max_age is not None: - self.ages = deque(maxlen=self._sampling_size) + self.ages = deque(maxlen=self._sampling_size) - self.median = self.mean = self.variance = self.stdev = 0 - self.min = self.max = self.total = self.count = 0 - self.average_change = self.change = 0 - self.max_age = self.min_age = 0 + self.count = 0 + self.mean = self.median = self.stdev = self.variance = None + self.total = self.min = self.max = None + self.min_age = self.max_age = None + self.change = self.average_change = self.change_rate = None if 'recorder' in self._hass.config.components: # only use the database if it's configured @@ -113,11 +121,9 @@ class StatisticsSensor(Entity): def _add_state_to_queue(self, new_state): try: self.states.append(float(new_state.state)) - if self._max_age is not None: - self.ages.append(new_state.last_updated) - self.count = self.count + 1 + self.ages.append(new_state.last_updated) except ValueError: - self.count = self.count + 1 + pass @property def name(self): @@ -143,26 +149,22 @@ class StatisticsSensor(Entity): def device_state_attributes(self): """Return the state attributes of the sensor.""" if not self.is_binary: - state = { - ATTR_MEAN: self.mean, - ATTR_COUNT: self.count, - ATTR_MAX_VALUE: self.max, - ATTR_MEDIAN: self.median, - ATTR_MIN_VALUE: self.min, + return { ATTR_SAMPLING_SIZE: self._sampling_size, + ATTR_COUNT: self.count, + ATTR_MEAN: self.mean, + ATTR_MEDIAN: self.median, ATTR_STANDARD_DEVIATION: self.stdev, - ATTR_TOTAL: self.total, ATTR_VARIANCE: self.variance, + ATTR_TOTAL: self.total, + ATTR_MIN_VALUE: self.min, + ATTR_MAX_VALUE: self.max, + ATTR_MIN_AGE: self.min_age, + ATTR_MAX_AGE: self.max_age, ATTR_CHANGE: self.change, ATTR_AVERAGE_CHANGE: self.average_change, + ATTR_CHANGE_RATE: self.change_rate, } - # Only return min/max age if we have an age span - if self._max_age: - state.update({ - ATTR_MAX_AGE: self.max_age, - ATTR_MIN_AGE: self.min_age, - }) - return state @property def icon(self): @@ -183,36 +185,56 @@ class StatisticsSensor(Entity): if self._max_age is not None: self._purge_old() + self.count = len(self.states) + if not self.is_binary: try: # require only one data point - self.mean = round(statistics.mean(self.states), 2) - self.median = round(statistics.median(self.states), 2) + self.mean = round(statistics.mean(self.states), + self._precision) + self.median = round(statistics.median(self.states), + self._precision) except statistics.StatisticsError as err: - _LOGGER.error(err) + _LOGGER.debug(err) self.mean = self.median = STATE_UNKNOWN try: # require at least two data points - self.stdev = round(statistics.stdev(self.states), 2) - self.variance = round(statistics.variance(self.states), 2) + self.stdev = round(statistics.stdev(self.states), + self._precision) + self.variance = round(statistics.variance(self.states), + self._precision) except statistics.StatisticsError as err: - _LOGGER.error(err) + _LOGGER.debug(err) self.stdev = self.variance = STATE_UNKNOWN if self.states: - self.count = len(self.states) - self.total = round(sum(self.states), 2) - self.min = min(self.states) - self.max = max(self.states) + self.total = round(sum(self.states), self._precision) + self.min = round(min(self.states), self._precision) + self.max = round(max(self.states), self._precision) + + self.min_age = self.ages[0] + self.max_age = self.ages[-1] + self.change = self.states[-1] - self.states[0] self.average_change = self.change + self.change_rate = 0 + if len(self.states) > 1: self.average_change /= len(self.states) - 1 - if self._max_age is not None: - self.max_age = max(self.ages) - self.min_age = min(self.ages) + + time_diff = (self.max_age - self.min_age).total_seconds() + if time_diff > 0: + self.change_rate = self.average_change / time_diff + + self.change = round(self.change, self._precision) + self.average_change = round(self.average_change, + self._precision) + self.change_rate = round(self.change_rate, self._precision) + else: - self.min = self.max = self.total = STATE_UNKNOWN - self.average_change = self.change = STATE_UNKNOWN + self.total = self.min = self.max = STATE_UNKNOWN + self.min_age = self.max_age = dt_util.utcnow() + self.change = self.average_change = STATE_UNKNOWN + self.change_rate = STATE_UNKNOWN @asyncio.coroutine def _initialize_from_database(self): diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 466b89cc0d1..e7cfec4d825 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -28,8 +28,10 @@ class TestStatisticsSensor(unittest.TestCase): self.median = round(statistics.median(self.values), 2) self.deviation = round(statistics.stdev(self.values), 2) self.variance = round(statistics.variance(self.values), 2) - self.change = self.values[-1] - self.values[0] - self.average_change = self.change / (len(self.values) - 1) + self.change = round(self.values[-1] - self.values[0], 2) + self.average_change = round(self.change / (len(self.values) - 1), 2) + self.change_rate = round(self.average_change / (60 * (self.count - 1)), + 2) def teardown_method(self, method): """Stop everything that was started.""" @@ -171,6 +173,42 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(6, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + def test_change_rate(self): + """Test min_age/max_age and change_rate.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, 42, + tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored' + } + }) + + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value one minute later + mock_data['return_time'] += timedelta(minutes=1) + + state = self.hass.states.get('sensor.test_mean') + + self.assertEqual(datetime(2017, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC), + state.attributes.get('min_age')) + self.assertEqual(datetime(2017, 8, 2, 12, 23 + self.count - 1, 42, + tzinfo=dt_util.UTC), + state.attributes.get('max_age')) + self.assertEqual(self.change_rate, state.attributes.get('change_rate')) + def test_initialize_from_database(self): """Test initializing the statistics from the database.""" # enable the recorder