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:
HarvsG 2024-09-12 11:06:18 +01:00 committed by GitHub
parent c21ea6b8da
commit 70ebf2f5d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 464 additions and 87 deletions

View File

@ -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]:

View File

@ -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"

View File

@ -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]:

View File

@ -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]