Add quantiles to Statistics integration (#52189)

* Add quantiles as another Statistics attribute

Quantiles divide states into intervals of equal probability. The
statistics.quantiles() function was added in Python 3.8 and can now
be included in the Statistics integration without new dependencies.

Quantiles can be used in conjunction with other distribution metrics to
create box plots (quartiles) and other graphical resources for
visualizing the distribution of states.

* Add quantiles reference to basic tests
This commit is contained in:
Carlos Gomes 2021-06-30 03:31:33 -03:00 committed by GitHub
parent 9f16e390f5
commit f2906d0fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 54 additions and 4 deletions

View File

@ -39,6 +39,7 @@ ATTR_MEAN = "mean"
ATTR_MEDIAN = "median" ATTR_MEDIAN = "median"
ATTR_MIN_AGE = "min_age" ATTR_MIN_AGE = "min_age"
ATTR_MIN_VALUE = "min_value" ATTR_MIN_VALUE = "min_value"
ATTR_QUANTILES = "quantiles"
ATTR_SAMPLING_SIZE = "sampling_size" ATTR_SAMPLING_SIZE = "sampling_size"
ATTR_STANDARD_DEVIATION = "standard_deviation" ATTR_STANDARD_DEVIATION = "standard_deviation"
ATTR_TOTAL = "total" ATTR_TOTAL = "total"
@ -47,10 +48,14 @@ 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" CONF_PRECISION = "precision"
CONF_QUANTILE_INTERVALS = "quantile_intervals"
CONF_QUANTILE_METHOD = "quantile_method"
DEFAULT_NAME = "Stats" DEFAULT_NAME = "Stats"
DEFAULT_SIZE = 20 DEFAULT_SIZE = 20
DEFAULT_PRECISION = 2 DEFAULT_PRECISION = 2
DEFAULT_QUANTILE_INTERVALS = 4
DEFAULT_QUANTILE_METHOD = "exclusive"
ICON = "mdi:calculator" ICON = "mdi:calculator"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@ -62,6 +67,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
), ),
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), vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int),
vol.Optional(
CONF_QUANTILE_INTERVALS, default=DEFAULT_QUANTILE_INTERVALS
): vol.All(vol.Coerce(int), vol.Range(min=2)),
vol.Optional(CONF_QUANTILE_METHOD, default=DEFAULT_QUANTILE_METHOD): vol.In(
["exclusive", "inclusive"]
),
} }
) )
@ -76,9 +87,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
sampling_size = config.get(CONF_SAMPLING_SIZE) sampling_size = config.get(CONF_SAMPLING_SIZE)
max_age = config.get(CONF_MAX_AGE) max_age = config.get(CONF_MAX_AGE)
precision = config.get(CONF_PRECISION) precision = config.get(CONF_PRECISION)
quantile_intervals = config.get(CONF_QUANTILE_INTERVALS)
quantile_method = config.get(CONF_QUANTILE_METHOD)
async_add_entities( async_add_entities(
[StatisticsSensor(entity_id, name, sampling_size, max_age, precision)], True [
StatisticsSensor(
entity_id,
name,
sampling_size,
max_age,
precision,
quantile_intervals,
quantile_method,
)
],
True,
) )
return True return True
@ -87,7 +111,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
class StatisticsSensor(SensorEntity): class StatisticsSensor(SensorEntity):
"""Representation of a Statistics sensor.""" """Representation of a Statistics sensor."""
def __init__(self, entity_id, name, sampling_size, max_age, precision): def __init__(
self,
entity_id,
name,
sampling_size,
max_age,
precision,
quantile_intervals,
quantile_method,
):
"""Initialize the Statistics sensor.""" """Initialize the Statistics sensor."""
self._entity_id = entity_id self._entity_id = entity_id
self.is_binary = self._entity_id.split(".")[0] == "binary_sensor" self.is_binary = self._entity_id.split(".")[0] == "binary_sensor"
@ -95,12 +128,14 @@ class StatisticsSensor(SensorEntity):
self._sampling_size = sampling_size self._sampling_size = sampling_size
self._max_age = max_age self._max_age = max_age
self._precision = precision self._precision = precision
self._quantile_intervals = quantile_intervals
self._quantile_method = quantile_method
self._unit_of_measurement = None self._unit_of_measurement = None
self.states = deque(maxlen=self._sampling_size) self.states = deque(maxlen=self._sampling_size)
self.ages = deque(maxlen=self._sampling_size) self.ages = deque(maxlen=self._sampling_size)
self.count = 0 self.count = 0
self.mean = self.median = self.stdev = self.variance = None self.mean = self.median = self.quantiles = self.stdev = self.variance = None
self.total = self.min = self.max = None self.total = self.min = self.max = None
self.min_age = self.max_age = None self.min_age = self.max_age = None
self.change = self.average_change = self.change_rate = None self.change = self.average_change = self.change_rate = None
@ -191,6 +226,7 @@ class StatisticsSensor(SensorEntity):
ATTR_COUNT: self.count, ATTR_COUNT: self.count,
ATTR_MEAN: self.mean, ATTR_MEAN: self.mean,
ATTR_MEDIAN: self.median, ATTR_MEDIAN: self.median,
ATTR_QUANTILES: self.quantiles,
ATTR_STANDARD_DEVIATION: self.stdev, ATTR_STANDARD_DEVIATION: self.stdev,
ATTR_VARIANCE: self.variance, ATTR_VARIANCE: self.variance,
ATTR_TOTAL: self.total, ATTR_TOTAL: self.total,
@ -257,9 +293,18 @@ class StatisticsSensor(SensorEntity):
try: # require at least two data points try: # require at least two data points
self.stdev = round(statistics.stdev(self.states), self._precision) self.stdev = round(statistics.stdev(self.states), self._precision)
self.variance = round(statistics.variance(self.states), self._precision) self.variance = round(statistics.variance(self.states), self._precision)
if self._quantile_intervals < self.count:
self.quantiles = [
round(quantile, self._precision)
for quantile in statistics.quantiles(
self.states,
n=self._quantile_intervals,
method=self._quantile_method,
)
]
except statistics.StatisticsError as err: except statistics.StatisticsError as err:
_LOGGER.debug("%s: %s", self.entity_id, err) _LOGGER.debug("%s: %s", self.entity_id, err)
self.stdev = self.variance = STATE_UNKNOWN self.stdev = self.variance = self.quantiles = STATE_UNKNOWN
if self.states: if self.states:
self.total = round(sum(self.states), self._precision) self.total = round(sum(self.states), self._precision)

View File

@ -48,6 +48,9 @@ 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.quantiles = [
round(quantile, 2) for quantile in statistics.quantiles(self.values)
]
self.change = round(self.values[-1] - self.values[0], 2) self.change = round(self.values[-1] - self.values[0], 2)
self.average_change = round(self.change / (len(self.values) - 1), 2) self.average_change = round(self.change / (len(self.values) - 1), 2)
self.change_rate = round(self.change / (60 * (self.count - 1)), 2) self.change_rate = round(self.change / (60 * (self.count - 1)), 2)
@ -112,6 +115,7 @@ class TestStatisticsSensor(unittest.TestCase):
assert self.variance == state.attributes.get("variance") assert self.variance == state.attributes.get("variance")
assert self.median == state.attributes.get("median") assert self.median == state.attributes.get("median")
assert self.deviation == state.attributes.get("standard_deviation") assert self.deviation == state.attributes.get("standard_deviation")
assert self.quantiles == state.attributes.get("quantiles")
assert self.mean == state.attributes.get("mean") assert self.mean == state.attributes.get("mean")
assert self.count == state.attributes.get("count") assert self.count == state.attributes.get("count")
assert self.total == state.attributes.get("total") assert self.total == state.attributes.get("total")
@ -188,6 +192,7 @@ class TestStatisticsSensor(unittest.TestCase):
# require at least two data points # require at least two data points
assert state.attributes.get("variance") == STATE_UNKNOWN assert state.attributes.get("variance") == STATE_UNKNOWN
assert state.attributes.get("standard_deviation") == STATE_UNKNOWN assert state.attributes.get("standard_deviation") == STATE_UNKNOWN
assert state.attributes.get("quantiles") == STATE_UNKNOWN
def test_max_age(self): def test_max_age(self):
"""Test value deprecation.""" """Test value deprecation."""