mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 22:57:17 +00:00
Accept more than 1 state for numeric entities in Bayesian (#119281)
* test driven delevopment * test driven development - multi numeric state * better multi-state processing * when state==below return true * adds test for a bad state * improve codecov * value error already handled in async_numeric_state * remove whitespace * remove async_get * linting * test_driven dev for error handling * make tests fail correctly * ensure tests fail correctly * prevent bad numeric entries * ensure no overlapping ranges * fix tests, as error caught in validation * remove redundant er call * remove reddundant arg * improves code coverage * filter for numeric states before testing overlap * adress code review * skip non numeric configs but continue * wait to avoid race condition * Better tuples name and better guard clause * better test description * more accurate description * Add comments to calculations * using typing not collections as per ruff * Apply suggestions from code review Co-authored-by: Erik Montnemery <erik@montnemery.com> * follow on from suggestions * Lazy evaluation Co-authored-by: Erik Montnemery <erik@montnemery.com> * update error text in tests * fix broken tests * move validation function call * fixes return type of above_greater_than_below. * improves codecov * fixes validation --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
c21ea6b8da
commit
70ebf2f5d8
@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
import math
|
||||||
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -50,6 +51,7 @@ from .const import (
|
|||||||
ATTR_OCCURRED_OBSERVATION_ENTITIES,
|
ATTR_OCCURRED_OBSERVATION_ENTITIES,
|
||||||
ATTR_PROBABILITY,
|
ATTR_PROBABILITY,
|
||||||
ATTR_PROBABILITY_THRESHOLD,
|
ATTR_PROBABILITY_THRESHOLD,
|
||||||
|
CONF_NUMERIC_STATE,
|
||||||
CONF_OBSERVATIONS,
|
CONF_OBSERVATIONS,
|
||||||
CONF_P_GIVEN_F,
|
CONF_P_GIVEN_F,
|
||||||
CONF_P_GIVEN_T,
|
CONF_P_GIVEN_T,
|
||||||
@ -66,18 +68,74 @@ from .issues import raise_mirrored_entries, raise_no_prob_given_false
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
NUMERIC_STATE_SCHEMA = vol.Schema(
|
def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
{
|
if config[CONF_PLATFORM] == CONF_NUMERIC_STATE:
|
||||||
CONF_PLATFORM: "numeric_state",
|
above = config.get(CONF_ABOVE)
|
||||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
below = config.get(CONF_BELOW)
|
||||||
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
if above is None and below is None:
|
||||||
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
_LOGGER.error(
|
||||||
vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
|
"For bayesian numeric state for entity: %s at least one of 'above' or 'below' must be specified",
|
||||||
vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float),
|
config[CONF_ENTITY_ID],
|
||||||
},
|
)
|
||||||
required=True,
|
raise vol.Invalid(
|
||||||
|
"For bayesian numeric state at least one of 'above' or 'below' must be specified."
|
||||||
|
)
|
||||||
|
if above is not None and below is not None:
|
||||||
|
if above > below:
|
||||||
|
_LOGGER.error(
|
||||||
|
"For bayesian numeric state 'above' (%s) must be less than 'below' (%s)",
|
||||||
|
above,
|
||||||
|
below,
|
||||||
|
)
|
||||||
|
raise vol.Invalid("'above' is greater than 'below'")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
NUMERIC_STATE_SCHEMA = vol.All(
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
CONF_PLATFORM: CONF_NUMERIC_STATE,
|
||||||
|
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||||
|
vol.Optional(CONF_ABOVE): vol.Coerce(float),
|
||||||
|
vol.Optional(CONF_BELOW): vol.Coerce(float),
|
||||||
|
vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
|
||||||
|
vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float),
|
||||||
|
},
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
_above_greater_than_below,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_overlapping(configs: list[dict]) -> list[dict]:
|
||||||
|
numeric_configs = [
|
||||||
|
config for config in configs if config[CONF_PLATFORM] == CONF_NUMERIC_STATE
|
||||||
|
]
|
||||||
|
if len(numeric_configs) < 2:
|
||||||
|
return configs
|
||||||
|
|
||||||
|
class NumericConfig(NamedTuple):
|
||||||
|
above: float
|
||||||
|
below: float
|
||||||
|
|
||||||
|
d: dict[str, list[NumericConfig]] = {}
|
||||||
|
for _, config in enumerate(numeric_configs):
|
||||||
|
above = config.get(CONF_ABOVE, -math.inf)
|
||||||
|
below = config.get(CONF_BELOW, math.inf)
|
||||||
|
entity_id: str = str(config[CONF_ENTITY_ID])
|
||||||
|
d.setdefault(entity_id, []).append(NumericConfig(above, below))
|
||||||
|
|
||||||
|
for ent_id, intervals in d.items():
|
||||||
|
intervals = sorted(intervals, key=lambda tup: tup.above)
|
||||||
|
|
||||||
|
for i, tup in enumerate(intervals):
|
||||||
|
if len(intervals) > i + 1 and tup.below > intervals[i + 1].above:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}."
|
||||||
|
)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
STATE_SCHEMA = vol.Schema(
|
STATE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
CONF_PLATFORM: CONF_STATE,
|
CONF_PLATFORM: CONF_STATE,
|
||||||
@ -107,7 +165,8 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
|
|||||||
vol.Required(CONF_OBSERVATIONS): vol.Schema(
|
vol.Required(CONF_OBSERVATIONS): vol.Schema(
|
||||||
vol.All(
|
vol.All(
|
||||||
cv.ensure_list,
|
cv.ensure_list,
|
||||||
[vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA, TEMPLATE_SCHEMA)],
|
[vol.Any(TEMPLATE_SCHEMA, STATE_SCHEMA, NUMERIC_STATE_SCHEMA)],
|
||||||
|
_no_overlapping,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_PRIOR): vol.Coerce(float),
|
vol.Required(CONF_PRIOR): vol.Coerce(float),
|
||||||
@ -211,10 +270,11 @@ class BayesianBinarySensor(BinarySensorEntity):
|
|||||||
self.observations_by_entity = self._build_observations_by_entity()
|
self.observations_by_entity = self._build_observations_by_entity()
|
||||||
self.observations_by_template = self._build_observations_by_template()
|
self.observations_by_template = self._build_observations_by_template()
|
||||||
|
|
||||||
self.observation_handlers: dict[str, Callable[[Observation], bool | None]] = {
|
self.observation_handlers: dict[
|
||||||
|
str, Callable[[Observation, bool], bool | None]
|
||||||
|
] = {
|
||||||
"numeric_state": self._process_numeric_state,
|
"numeric_state": self._process_numeric_state,
|
||||||
"state": self._process_state,
|
"state": self._process_state,
|
||||||
"multi_state": self._process_multi_state,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
@ -342,8 +402,9 @@ class BayesianBinarySensor(BinarySensorEntity):
|
|||||||
for observation in self.observations_by_entity[entity]:
|
for observation in self.observations_by_entity[entity]:
|
||||||
platform = observation.platform
|
platform = observation.platform
|
||||||
|
|
||||||
observation.observed = self.observation_handlers[platform](observation)
|
observation.observed = self.observation_handlers[platform](
|
||||||
|
observation, observation.multi
|
||||||
|
)
|
||||||
local_observations[observation.id] = observation
|
local_observations[observation.id] = observation
|
||||||
|
|
||||||
return local_observations
|
return local_observations
|
||||||
@ -408,9 +469,7 @@ class BayesianBinarySensor(BinarySensorEntity):
|
|||||||
if len(entity_observations) == 1:
|
if len(entity_observations) == 1:
|
||||||
continue
|
continue
|
||||||
for observation in entity_observations:
|
for observation in entity_observations:
|
||||||
if observation.platform != "state":
|
observation.multi = True
|
||||||
continue
|
|
||||||
observation.platform = "multi_state"
|
|
||||||
|
|
||||||
return observations_by_entity
|
return observations_by_entity
|
||||||
|
|
||||||
@ -437,14 +496,23 @@ class BayesianBinarySensor(BinarySensorEntity):
|
|||||||
|
|
||||||
return observations_by_template
|
return observations_by_template
|
||||||
|
|
||||||
def _process_numeric_state(self, entity_observation: Observation) -> bool | None:
|
def _process_numeric_state(
|
||||||
|
self, entity_observation: Observation, multi: bool = False
|
||||||
|
) -> bool | None:
|
||||||
"""Return True if numeric condition is met, return False if not, return None otherwise."""
|
"""Return True if numeric condition is met, return False if not, return None otherwise."""
|
||||||
entity = entity_observation.entity_id
|
entity_id = entity_observation.entity_id
|
||||||
|
# if we are dealing with numeric_state observations entity_id cannot be None
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert entity_id is not None
|
||||||
|
|
||||||
|
entity = self.hass.states.get(entity_id)
|
||||||
|
if entity is None:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]):
|
if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]):
|
||||||
return None
|
return None
|
||||||
return condition.async_numeric_state(
|
result = condition.async_numeric_state(
|
||||||
self.hass,
|
self.hass,
|
||||||
entity,
|
entity,
|
||||||
entity_observation.below,
|
entity_observation.below,
|
||||||
@ -452,10 +520,24 @@ class BayesianBinarySensor(BinarySensorEntity):
|
|||||||
None,
|
None,
|
||||||
entity_observation.to_dict(),
|
entity_observation.to_dict(),
|
||||||
)
|
)
|
||||||
|
if result:
|
||||||
|
return True
|
||||||
|
if multi:
|
||||||
|
state = float(entity.state)
|
||||||
|
if (
|
||||||
|
entity_observation.below is not None
|
||||||
|
and state == entity_observation.below
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return None
|
||||||
except ConditionError:
|
except ConditionError:
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def _process_state(self, entity_observation: Observation) -> bool | None:
|
def _process_state(
|
||||||
|
self, entity_observation: Observation, multi: bool = False
|
||||||
|
) -> bool | None:
|
||||||
"""Return True if state conditions are met, return False if they are not.
|
"""Return True if state conditions are met, return False if they are not.
|
||||||
|
|
||||||
Returns None if the state is unavailable.
|
Returns None if the state is unavailable.
|
||||||
@ -467,24 +549,13 @@ class BayesianBinarySensor(BinarySensorEntity):
|
|||||||
if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]):
|
if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return condition.state(self.hass, entity, entity_observation.to_state)
|
result = condition.state(self.hass, entity, entity_observation.to_state)
|
||||||
|
if multi and not result:
|
||||||
|
return None
|
||||||
except ConditionError:
|
except ConditionError:
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
def _process_multi_state(self, entity_observation: Observation) -> bool | None:
|
return result
|
||||||
"""Return True if state conditions are met, otherwise return None.
|
|
||||||
|
|
||||||
Never return False as all other states should have their own probabilities configured.
|
|
||||||
"""
|
|
||||||
|
|
||||||
entity = entity_observation.entity_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
if condition.state(self.hass, entity, entity_observation.to_state):
|
|
||||||
return True
|
|
||||||
except ConditionError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
|
@ -8,6 +8,7 @@ ATTR_PROBABILITY_THRESHOLD = "probability_threshold"
|
|||||||
CONF_OBSERVATIONS = "observations"
|
CONF_OBSERVATIONS = "observations"
|
||||||
CONF_PRIOR = "prior"
|
CONF_PRIOR = "prior"
|
||||||
CONF_TEMPLATE = "template"
|
CONF_TEMPLATE = "template"
|
||||||
|
CONF_NUMERIC_STATE = "numeric_state"
|
||||||
CONF_PROBABILITY_THRESHOLD = "probability_threshold"
|
CONF_PROBABILITY_THRESHOLD = "probability_threshold"
|
||||||
CONF_P_GIVEN_F = "prob_given_false"
|
CONF_P_GIVEN_F = "prob_given_false"
|
||||||
CONF_P_GIVEN_T = "prob_given_true"
|
CONF_P_GIVEN_T = "prob_given_true"
|
||||||
|
@ -33,6 +33,7 @@ class Observation:
|
|||||||
below: float | None
|
below: float | None
|
||||||
value_template: Template | None
|
value_template: Template | None
|
||||||
observed: bool | None = None
|
observed: bool | None = None
|
||||||
|
multi: bool = False
|
||||||
id: uuid.UUID = field(default_factory=uuid.uuid4)
|
id: uuid.UUID = field(default_factory=uuid.uuid4)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, str | float | bool | None]:
|
def to_dict(self) -> dict[str, str | float | bool | None]:
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""The test for the bayesian sensor platform."""
|
"""The test for the bayesian sensor platform."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from logging import WARNING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -20,16 +21,14 @@ from homeassistant.const import (
|
|||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Context, HomeAssistant, callback
|
from homeassistant.core import Context, HomeAssistant, callback
|
||||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import get_fixture_path
|
from tests.common import get_fixture_path
|
||||||
|
|
||||||
|
|
||||||
async def test_load_values_when_added_to_hass(
|
async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None:
|
||||||
hass: HomeAssistant, entity_registry: er.EntityRegistry
|
|
||||||
) -> None:
|
|
||||||
"""Test that sensor initializes with observations of relevant entities."""
|
"""Test that sensor initializes with observations of relevant entities."""
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
@ -58,11 +57,6 @@ async def test_load_values_when_added_to_hass(
|
|||||||
assert await async_setup_component(hass, "binary_sensor", config)
|
assert await async_setup_component(hass, "binary_sensor", config)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert (
|
|
||||||
entity_registry.entities["binary_sensor.test_binary"].unique_id
|
|
||||||
== "bayesian-3b4c9563-5e84-4167-8fe7-8f507e796d72"
|
|
||||||
)
|
|
||||||
|
|
||||||
state = hass.states.get("binary_sensor.test_binary")
|
state = hass.states.get("binary_sensor.test_binary")
|
||||||
assert state.attributes.get("device_class") == "connectivity"
|
assert state.attributes.get("device_class") == "connectivity"
|
||||||
assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8
|
assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8
|
||||||
@ -331,6 +325,75 @@ async def test_sensor_value_template(hass: HomeAssistant) -> None:
|
|||||||
assert state.state == "off"
|
assert state.state == "off"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mixed_states(hass: HomeAssistant) -> None:
|
||||||
|
"""Test sensor on probability threshold limits."""
|
||||||
|
config = {
|
||||||
|
"binary_sensor": {
|
||||||
|
"name": "should_HVAC",
|
||||||
|
"platform": "bayesian",
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"platform": "template",
|
||||||
|
"value_template": "{{states('sensor.guest_sensor') != 'off'}}",
|
||||||
|
"prob_given_true": 0.3,
|
||||||
|
"prob_given_false": 0.15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "state",
|
||||||
|
"entity_id": "sensor.anyone_home",
|
||||||
|
"to_state": "on",
|
||||||
|
"prob_given_true": 0.6,
|
||||||
|
"prob_given_false": 0.05,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.temperature",
|
||||||
|
"below": 24,
|
||||||
|
"above": 19,
|
||||||
|
"prob_given_true": 0.1,
|
||||||
|
"prob_given_false": 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prior": 0.3,
|
||||||
|
"probability_threshold": 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert await async_setup_component(hass, "binary_sensor", config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
hass.states.async_set("sensor.guest_sensor", "UNKNOWN")
|
||||||
|
hass.states.async_set("sensor.anyone_home", "on")
|
||||||
|
hass.states.async_set("sensor.temperature", 15)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.should_HVAC")
|
||||||
|
|
||||||
|
assert set(state.attributes.get("occurred_observation_entities")) == {
|
||||||
|
"sensor.anyone_home",
|
||||||
|
"sensor.temperature",
|
||||||
|
}
|
||||||
|
template_obs = {
|
||||||
|
"platform": "template",
|
||||||
|
"value_template": "{{states('sensor.guest_sensor') != 'off'}}",
|
||||||
|
"prob_given_true": 0.3,
|
||||||
|
"prob_given_false": 0.15,
|
||||||
|
"observed": True,
|
||||||
|
}
|
||||||
|
assert template_obs in state.attributes.get("observations")
|
||||||
|
|
||||||
|
assert abs(0.95857988 - state.attributes.get("probability")) < 0.01
|
||||||
|
# A = binary_sensor.should_HVAC being TRUE, P(A) being the prior
|
||||||
|
# B = value_template evaluating to TRUE
|
||||||
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
|
# Calculated where P(A) = 0.3, P(B|A) = 0.3 , P(B|notA) = 0.15 = 0.46153846
|
||||||
|
# Step 2, prior is now 0.46153846, B now refers to sensor.anyone_home=='on'
|
||||||
|
# P(A) = 0.46153846, P(B|A) = 0.6 , P(B|notA) = 0.05, result = 0.91139240
|
||||||
|
# Step 3, prior is now 0.91139240, B now refers to sensor.temperature in range [19,24]
|
||||||
|
# However since the temp is 15 we take the inverse probability for this negative observation
|
||||||
|
# P(A) = 0.91139240, P(B|A) = (1-0.1) , P(B|notA) = (1-0.6), result = 0.95857988
|
||||||
|
|
||||||
|
|
||||||
async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None:
|
async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None:
|
||||||
"""Test sensor on probability threshold limits."""
|
"""Test sensor on probability threshold limits."""
|
||||||
config = {
|
config = {
|
||||||
@ -367,7 +430,7 @@ async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry)
|
|||||||
async def test_multiple_observations(hass: HomeAssistant) -> None:
|
async def test_multiple_observations(hass: HomeAssistant) -> None:
|
||||||
"""Test sensor with multiple observations of same entity.
|
"""Test sensor with multiple observations of same entity.
|
||||||
|
|
||||||
these entries should be labelled as 'multi_state' and negative observations ignored - as the outcome is not known to be binary.
|
these entries should be labelled as 'state' and negative observations ignored - as the outcome is not known to be binary.
|
||||||
Before the merge of #67631 this practice was a common work-around for bayesian's ignoring of negative observations,
|
Before the merge of #67631 this practice was a common work-around for bayesian's ignoring of negative observations,
|
||||||
this also preserves that function
|
this also preserves that function
|
||||||
"""
|
"""
|
||||||
@ -436,83 +499,203 @@ async def test_multiple_observations(hass: HomeAssistant) -> None:
|
|||||||
# Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.2, P(B|notA) = 0.6
|
# Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.2, P(B|notA) = 0.6
|
||||||
|
|
||||||
assert state.state == "off"
|
assert state.state == "off"
|
||||||
assert state.attributes.get("observations")[0]["platform"] == "multi_state"
|
assert state.attributes.get("observations")[0]["platform"] == "state"
|
||||||
assert state.attributes.get("observations")[1]["platform"] == "multi_state"
|
assert state.attributes.get("observations")[1]["platform"] == "state"
|
||||||
|
|
||||||
|
|
||||||
async def test_multiple_numeric_observations(hass: HomeAssistant) -> None:
|
async def test_multiple_numeric_observations(
|
||||||
"""Test sensor with multiple numeric observations of same entity."""
|
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
||||||
|
) -> None:
|
||||||
|
"""Test sensor on numeric state platform observations with more than one range.
|
||||||
|
|
||||||
|
This tests an example where the probability of it being a 'nice day' varies over
|
||||||
|
a series of temperatures. Since this is a multi-state, all the non-observed ranges
|
||||||
|
should be ignored and only the range including the observed value should update
|
||||||
|
the prior. When a value lands on above or below (15 is tested) it is included if it
|
||||||
|
equals `below`, and ignored if it equals `above`.
|
||||||
|
"""
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"platform": "bayesian",
|
"platform": "bayesian",
|
||||||
"name": "Test_Binary",
|
"name": "nice_day",
|
||||||
"observations": [
|
"observations": [
|
||||||
{
|
{
|
||||||
"platform": "numeric_state",
|
"platform": "numeric_state",
|
||||||
"entity_id": "sensor.test_monitored",
|
"entity_id": "sensor.test_temp",
|
||||||
"below": 10,
|
"below": 0,
|
||||||
"above": 0,
|
"prob_given_true": 0.05,
|
||||||
"prob_given_true": 0.4,
|
"prob_given_false": 0.2,
|
||||||
"prob_given_false": 0.0001,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"platform": "numeric_state",
|
"platform": "numeric_state",
|
||||||
"entity_id": "sensor.test_monitored",
|
"entity_id": "sensor.test_temp",
|
||||||
"below": 100,
|
"below": 10,
|
||||||
"above": 30,
|
"above": 0,
|
||||||
"prob_given_true": 0.6,
|
"prob_given_true": 0.1,
|
||||||
"prob_given_false": 0.0001,
|
"prob_given_false": 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.test_temp",
|
||||||
|
"below": 15,
|
||||||
|
"above": 10,
|
||||||
|
"prob_given_true": 0.2,
|
||||||
|
"prob_given_false": 0.35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.test_temp",
|
||||||
|
"below": 25,
|
||||||
|
"above": 15,
|
||||||
|
"prob_given_true": 0.5,
|
||||||
|
"prob_given_false": 0.15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.test_temp",
|
||||||
|
"above": 25,
|
||||||
|
"prob_given_true": 0.15,
|
||||||
|
"prob_given_false": 0.05,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"prior": 0.1,
|
"prior": 0.3,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert await async_setup_component(hass, "binary_sensor", config)
|
assert await async_setup_component(hass, "binary_sensor", config)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN)
|
hass.states.async_set("sensor.test_temp", -5)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("binary_sensor.test_binary")
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
|
||||||
for attrs in state.attributes.values():
|
for attrs in state.attributes.values():
|
||||||
json.dumps(attrs)
|
json.dumps(attrs)
|
||||||
assert state.attributes.get("occurred_observation_entities") == []
|
assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"]
|
||||||
assert state.attributes.get("probability") == 0.1
|
assert state.attributes.get("probability") == 0.1
|
||||||
|
|
||||||
# No observations made so probability should be the prior
|
# No observations made so probability should be the prior
|
||||||
|
assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"]
|
||||||
|
assert abs(state.attributes.get("probability") - 0.09677) < 0.01
|
||||||
|
# A = binary_sensor.nice_day being TRUE
|
||||||
|
# B = sensor.test_temp in the range (, 0]
|
||||||
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
|
# Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false
|
||||||
|
# Calculated using P(A) = 0.3, P(B|A) = 0.05, P(B|~A) = 0.2 -> 0.09677
|
||||||
|
# Because >1 range is defined for sensor.test_temp we should not infer anything from the
|
||||||
|
# ranges not observed
|
||||||
|
assert state.state == "off"
|
||||||
|
|
||||||
|
hass.states.async_set("sensor.test_temp", 5)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
|
||||||
|
assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"]
|
||||||
|
assert abs(state.attributes.get("probability") - 0.14634146) < 0.01
|
||||||
|
# A = binary_sensor.nice_day being TRUE
|
||||||
|
# B = sensor.test_temp in the range (0, 10]
|
||||||
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
|
# Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false
|
||||||
|
# Calculated using P(A) = 0.3, P(B|A) = 0.1, P(B|~A) = 0.25 -> 0.14634146
|
||||||
|
# Because >1 range is defined for sensor.test_temp we should not infer anything from the
|
||||||
|
# ranges not observed
|
||||||
|
|
||||||
assert state.state == "off"
|
assert state.state == "off"
|
||||||
|
|
||||||
hass.states.async_set("sensor.test_monitored", 20)
|
hass.states.async_set("sensor.test_temp", 12)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("binary_sensor.test_binary")
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
assert abs(state.attributes.get("probability") - 0.19672131) < 0.01
|
||||||
assert state.attributes.get("occurred_observation_entities") == [
|
# A = binary_sensor.nice_day being TRUE
|
||||||
"sensor.test_monitored"
|
# B = sensor.test_temp in the range (10, 15]
|
||||||
]
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
assert round(abs(0.026 - state.attributes.get("probability")), 7) < 0.01
|
# Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false
|
||||||
# Step 1 Calculated where P(A) = 0.1, P(~B|A) = 0.6 (negative obs), P(~B|notA) = 0.9999 -> 0.0625
|
# Calculated using P(A) = 0.3, P(B|A) = 0.2, P(B|~A) = 0.35 -> 0.19672131
|
||||||
# Step 2 P(A) = 0.0625, P(B|A) = 0.4 (negative obs), P(B|notA) = 0.9999 -> 0.26
|
# Because >1 range is defined for sensor.test_temp we should not infer anything from the
|
||||||
|
# ranges not observed
|
||||||
|
|
||||||
assert state.state == "off"
|
assert state.state == "off"
|
||||||
|
|
||||||
hass.states.async_set("sensor.test_monitored", 35)
|
hass.states.async_set("sensor.test_temp", 22)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("binary_sensor.test_binary")
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
assert state.attributes.get("occurred_observation_entities") == [
|
assert abs(state.attributes.get("probability") - 0.58823529) < 0.01
|
||||||
"sensor.test_monitored"
|
# A = binary_sensor.nice_day being TRUE
|
||||||
]
|
# B = sensor.test_temp in the range (15, 25]
|
||||||
assert abs(1 - state.attributes.get("probability")) < 0.01
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
# Step 1 Calculated where P(A) = 0.1, P(~B|A) = 0.6 (negative obs), P(~B|notA) = 0.9999 -> 0.0625
|
# Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false
|
||||||
# Step 2 P(A) = 0.0625, P(B|A) = 0.6, P(B|notA) = 0.0001 -> 0.9975
|
# Calculated using P(A) = 0.3, P(B|A) = 0.5, P(B|~A) = 0.15 -> 0.58823529
|
||||||
|
# Because >1 range is defined for sensor.test_temp we should not infer anything from the
|
||||||
|
# ranges not observed
|
||||||
|
|
||||||
assert state.state == "on"
|
assert state.state == "on"
|
||||||
|
|
||||||
|
hass.states.async_set("sensor.test_temp", 30)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
assert abs(state.attributes.get("probability") - 0.562500) < 0.01
|
||||||
|
# A = binary_sensor.nice_day being TRUE
|
||||||
|
# B = sensor.test_temp in the range (25, ]
|
||||||
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
|
# Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false
|
||||||
|
# Calculated using P(A) = 0.3, P(B|A) = 0.15, P(B|~A) = 0.05 -> 0.562500
|
||||||
|
# Because >1 range is defined for sensor.test_temp we should not infer anything from the
|
||||||
|
# ranges not observed
|
||||||
|
|
||||||
|
assert state.state == "on"
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
# if on a threshold only one observation should be included and not both
|
||||||
|
hass.states.async_set("sensor.test_temp", 15)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
|
||||||
|
assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"]
|
||||||
|
|
||||||
|
assert abs(state.attributes.get("probability") - 0.19672131) < 0.01
|
||||||
|
# Where there are multi numeric ranges when on the threshold, use below
|
||||||
|
# A = binary_sensor.nice_day being TRUE
|
||||||
|
# B = sensor.test_temp in the range (10, 15]
|
||||||
|
# Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ).
|
||||||
|
# Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false
|
||||||
|
# Calculated using P(A) = 0.3, P(B|A) = 0.2, P(B|~A) = 0.35 -> 0.19672131
|
||||||
|
# Because >1 range is defined for sensor.test_temp we should not infer anything from the
|
||||||
|
# ranges not observed
|
||||||
|
|
||||||
|
assert state.state == "off"
|
||||||
|
|
||||||
|
assert len(issue_registry.issues) == 0
|
||||||
assert state.attributes.get("observations")[0]["platform"] == "numeric_state"
|
assert state.attributes.get("observations")[0]["platform"] == "numeric_state"
|
||||||
assert state.attributes.get("observations")[1]["platform"] == "numeric_state"
|
|
||||||
|
hass.states.async_set("sensor.test_temp", "badstate")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
|
||||||
|
assert state.attributes.get("occurred_observation_entities") == []
|
||||||
|
assert state.state == "off"
|
||||||
|
|
||||||
|
hass.states.async_set("sensor.test_temp", STATE_UNAVAILABLE)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
|
||||||
|
assert state.attributes.get("occurred_observation_entities") == []
|
||||||
|
assert state.state == "off"
|
||||||
|
|
||||||
|
hass.states.async_set("sensor.test_temp", STATE_UNKNOWN)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("binary_sensor.nice_day")
|
||||||
|
|
||||||
|
assert state.attributes.get("occurred_observation_entities") == []
|
||||||
|
assert state.state == "off"
|
||||||
|
|
||||||
|
|
||||||
async def test_mirrored_observations(
|
async def test_mirrored_observations(
|
||||||
@ -651,6 +834,127 @@ async def test_missing_prob_given_false(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bad_multi_numeric(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether missing prob_given_false are detected and appropriate issues are created."""
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"binary_sensor": {
|
||||||
|
"platform": "bayesian",
|
||||||
|
"name": "bins_out",
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.signal_strength",
|
||||||
|
"above": 10,
|
||||||
|
"prob_given_true": 0.01,
|
||||||
|
"prob_given_false": 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.signal_strength",
|
||||||
|
"above": 5,
|
||||||
|
"below": 10,
|
||||||
|
"prob_given_true": 0.02,
|
||||||
|
"prob_given_false": 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.signal_strength",
|
||||||
|
"above": 0,
|
||||||
|
"below": 6, # overlaps
|
||||||
|
"prob_given_true": 0.07,
|
||||||
|
"prob_given_false": 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.signal_strength",
|
||||||
|
"above": -10,
|
||||||
|
"below": 0,
|
||||||
|
"prob_given_true": 0.3,
|
||||||
|
"prob_given_false": 0.07,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.signal_strength",
|
||||||
|
"below": -10,
|
||||||
|
"prob_given_true": 0.6,
|
||||||
|
"prob_given_false": 0.03,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prior": 0.2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caplog.clear()
|
||||||
|
caplog.set_level(WARNING)
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "binary_sensor", config)
|
||||||
|
|
||||||
|
assert "entities must not overlap" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inverted_numeric(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether missing prob_given_false are detected and appropriate logs are created."""
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"binary_sensor": {
|
||||||
|
"platform": "bayesian",
|
||||||
|
"name": "goldilocks_zone",
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.temp",
|
||||||
|
"above": 23,
|
||||||
|
"below": 20,
|
||||||
|
"prob_given_true": 0.9,
|
||||||
|
"prob_given_false": 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prior": 0.4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "binary_sensor", config)
|
||||||
|
assert (
|
||||||
|
"bayesian numeric state 'above' (23.0) must be less than 'below' (20.0)"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_value_numeric(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test whether missing prob_given_false are detected and appropriate logs are created."""
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"binary_sensor": {
|
||||||
|
"platform": "bayesian",
|
||||||
|
"name": "goldilocks_zone",
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"platform": "numeric_state",
|
||||||
|
"entity_id": "sensor.temp",
|
||||||
|
"prob_given_true": 0.9,
|
||||||
|
"prob_given_false": 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"prior": 0.4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "binary_sensor", config)
|
||||||
|
assert "at least one of 'above' or 'below' must be specified" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
async def test_probability_updates(hass: HomeAssistant) -> None:
|
async def test_probability_updates(hass: HomeAssistant) -> None:
|
||||||
"""Test probability update function."""
|
"""Test probability update function."""
|
||||||
prob_given_true = [0.3, 0.6, 0.8]
|
prob_given_true = [0.3, 0.6, 0.8]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user