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
This commit is contained in:
parent
50266e9b91
commit
8a2bc99f63
2 changed files with 112 additions and 52 deletions
|
@ -25,23 +25,26 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
ATTR_AVERAGE_CHANGE = 'average_change'
|
ATTR_AVERAGE_CHANGE = 'average_change'
|
||||||
ATTR_CHANGE = 'change'
|
ATTR_CHANGE = 'change'
|
||||||
|
ATTR_CHANGE_RATE = 'change_rate'
|
||||||
ATTR_COUNT = 'count'
|
ATTR_COUNT = 'count'
|
||||||
|
ATTR_MAX_AGE = 'max_age'
|
||||||
ATTR_MAX_VALUE = 'max_value'
|
ATTR_MAX_VALUE = 'max_value'
|
||||||
ATTR_MIN_VALUE = 'min_value'
|
|
||||||
ATTR_MEAN = 'mean'
|
ATTR_MEAN = 'mean'
|
||||||
ATTR_MEDIAN = 'median'
|
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_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_SAMPLING_SIZE = 'sampling_size'
|
||||||
CONF_MAX_AGE = 'max_age'
|
CONF_MAX_AGE = 'max_age'
|
||||||
|
CONF_PRECISION = 'precision'
|
||||||
|
|
||||||
DEFAULT_NAME = 'Stats'
|
DEFAULT_NAME = 'Stats'
|
||||||
DEFAULT_SIZE = 20
|
DEFAULT_SIZE = 20
|
||||||
|
DEFAULT_PRECISION = 2
|
||||||
ICON = 'mdi:calculator'
|
ICON = 'mdi:calculator'
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
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_NAME, default=DEFAULT_NAME): cv.string,
|
||||||
vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE):
|
vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE):
|
||||||
vol.All(vol.Coerce(int), vol.Range(min=1)),
|
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)
|
name = config.get(CONF_NAME)
|
||||||
sampling_size = config.get(CONF_SAMPLING_SIZE)
|
sampling_size = config.get(CONF_SAMPLING_SIZE)
|
||||||
max_age = config.get(CONF_MAX_AGE, None)
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
class StatisticsSensor(Entity):
|
class StatisticsSensor(Entity):
|
||||||
"""Representation of a Statistics sensor."""
|
"""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."""
|
"""Initialize the Statistics sensor."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._entity_id = entity_id
|
self._entity_id = entity_id
|
||||||
|
@ -83,15 +90,16 @@ class StatisticsSensor(Entity):
|
||||||
self._name = '{} {}'.format(name, ATTR_COUNT)
|
self._name = '{} {}'.format(name, ATTR_COUNT)
|
||||||
self._sampling_size = sampling_size
|
self._sampling_size = sampling_size
|
||||||
self._max_age = max_age
|
self._max_age = max_age
|
||||||
|
self._precision = precision
|
||||||
self._unit_of_measurement = None
|
self._unit_of_measurement = None
|
||||||
self.states = deque(maxlen=self._sampling_size)
|
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.count = 0
|
||||||
self.min = self.max = self.total = self.count = 0
|
self.mean = self.median = self.stdev = self.variance = None
|
||||||
self.average_change = self.change = 0
|
self.total = self.min = self.max = None
|
||||||
self.max_age = self.min_age = 0
|
self.min_age = self.max_age = None
|
||||||
|
self.change = self.average_change = self.change_rate = None
|
||||||
|
|
||||||
if 'recorder' in self._hass.config.components:
|
if 'recorder' in self._hass.config.components:
|
||||||
# only use the database if it's configured
|
# only use the database if it's configured
|
||||||
|
@ -113,11 +121,9 @@ class StatisticsSensor(Entity):
|
||||||
def _add_state_to_queue(self, new_state):
|
def _add_state_to_queue(self, new_state):
|
||||||
try:
|
try:
|
||||||
self.states.append(float(new_state.state))
|
self.states.append(float(new_state.state))
|
||||||
if self._max_age is not None:
|
self.ages.append(new_state.last_updated)
|
||||||
self.ages.append(new_state.last_updated)
|
|
||||||
self.count = self.count + 1
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.count = self.count + 1
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
@ -143,26 +149,22 @@ class StatisticsSensor(Entity):
|
||||||
def device_state_attributes(self):
|
def device_state_attributes(self):
|
||||||
"""Return the state attributes of the sensor."""
|
"""Return the state attributes of the sensor."""
|
||||||
if not self.is_binary:
|
if not self.is_binary:
|
||||||
state = {
|
return {
|
||||||
ATTR_MEAN: self.mean,
|
|
||||||
ATTR_COUNT: self.count,
|
|
||||||
ATTR_MAX_VALUE: self.max,
|
|
||||||
ATTR_MEDIAN: self.median,
|
|
||||||
ATTR_MIN_VALUE: self.min,
|
|
||||||
ATTR_SAMPLING_SIZE: self._sampling_size,
|
ATTR_SAMPLING_SIZE: self._sampling_size,
|
||||||
|
ATTR_COUNT: self.count,
|
||||||
|
ATTR_MEAN: self.mean,
|
||||||
|
ATTR_MEDIAN: self.median,
|
||||||
ATTR_STANDARD_DEVIATION: self.stdev,
|
ATTR_STANDARD_DEVIATION: self.stdev,
|
||||||
ATTR_TOTAL: self.total,
|
|
||||||
ATTR_VARIANCE: self.variance,
|
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_CHANGE: self.change,
|
||||||
ATTR_AVERAGE_CHANGE: self.average_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
|
@property
|
||||||
def icon(self):
|
def icon(self):
|
||||||
|
@ -183,36 +185,56 @@ class StatisticsSensor(Entity):
|
||||||
if self._max_age is not None:
|
if self._max_age is not None:
|
||||||
self._purge_old()
|
self._purge_old()
|
||||||
|
|
||||||
|
self.count = len(self.states)
|
||||||
|
|
||||||
if not self.is_binary:
|
if not self.is_binary:
|
||||||
try: # require only one data point
|
try: # require only one data point
|
||||||
self.mean = round(statistics.mean(self.states), 2)
|
self.mean = round(statistics.mean(self.states),
|
||||||
self.median = round(statistics.median(self.states), 2)
|
self._precision)
|
||||||
|
self.median = round(statistics.median(self.states),
|
||||||
|
self._precision)
|
||||||
except statistics.StatisticsError as err:
|
except statistics.StatisticsError as err:
|
||||||
_LOGGER.error(err)
|
_LOGGER.debug(err)
|
||||||
self.mean = self.median = STATE_UNKNOWN
|
self.mean = self.median = STATE_UNKNOWN
|
||||||
|
|
||||||
try: # require at least two data points
|
try: # require at least two data points
|
||||||
self.stdev = round(statistics.stdev(self.states), 2)
|
self.stdev = round(statistics.stdev(self.states),
|
||||||
self.variance = round(statistics.variance(self.states), 2)
|
self._precision)
|
||||||
|
self.variance = round(statistics.variance(self.states),
|
||||||
|
self._precision)
|
||||||
except statistics.StatisticsError as err:
|
except statistics.StatisticsError as err:
|
||||||
_LOGGER.error(err)
|
_LOGGER.debug(err)
|
||||||
self.stdev = self.variance = STATE_UNKNOWN
|
self.stdev = self.variance = STATE_UNKNOWN
|
||||||
|
|
||||||
if self.states:
|
if self.states:
|
||||||
self.count = len(self.states)
|
self.total = round(sum(self.states), self._precision)
|
||||||
self.total = round(sum(self.states), 2)
|
self.min = round(min(self.states), self._precision)
|
||||||
self.min = min(self.states)
|
self.max = round(max(self.states), self._precision)
|
||||||
self.max = max(self.states)
|
|
||||||
|
self.min_age = self.ages[0]
|
||||||
|
self.max_age = self.ages[-1]
|
||||||
|
|
||||||
self.change = self.states[-1] - self.states[0]
|
self.change = self.states[-1] - self.states[0]
|
||||||
self.average_change = self.change
|
self.average_change = self.change
|
||||||
|
self.change_rate = 0
|
||||||
|
|
||||||
if len(self.states) > 1:
|
if len(self.states) > 1:
|
||||||
self.average_change /= len(self.states) - 1
|
self.average_change /= len(self.states) - 1
|
||||||
if self._max_age is not None:
|
|
||||||
self.max_age = max(self.ages)
|
time_diff = (self.max_age - self.min_age).total_seconds()
|
||||||
self.min_age = min(self.ages)
|
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:
|
else:
|
||||||
self.min = self.max = self.total = STATE_UNKNOWN
|
self.total = self.min = self.max = STATE_UNKNOWN
|
||||||
self.average_change = self.change = 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
|
@asyncio.coroutine
|
||||||
def _initialize_from_database(self):
|
def _initialize_from_database(self):
|
||||||
|
|
|
@ -28,8 +28,10 @@ class TestStatisticsSensor(unittest.TestCase):
|
||||||
self.median = round(statistics.median(self.values), 2)
|
self.median = round(statistics.median(self.values), 2)
|
||||||
self.deviation = round(statistics.stdev(self.values), 2)
|
self.deviation = round(statistics.stdev(self.values), 2)
|
||||||
self.variance = round(statistics.variance(self.values), 2)
|
self.variance = round(statistics.variance(self.values), 2)
|
||||||
self.change = self.values[-1] - self.values[0]
|
self.change = round(self.values[-1] - self.values[0], 2)
|
||||||
self.average_change = self.change / (len(self.values) - 1)
|
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):
|
def teardown_method(self, method):
|
||||||
"""Stop everything that was started."""
|
"""Stop everything that was started."""
|
||||||
|
@ -171,6 +173,42 @@ class TestStatisticsSensor(unittest.TestCase):
|
||||||
self.assertEqual(6, state.attributes.get('min_value'))
|
self.assertEqual(6, state.attributes.get('min_value'))
|
||||||
self.assertEqual(14, state.attributes.get('max_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):
|
def test_initialize_from_database(self):
|
||||||
"""Test initializing the statistics from the database."""
|
"""Test initializing the statistics from the database."""
|
||||||
# enable the recorder
|
# enable the recorder
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue