diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 192d7987311..6d203c344f2 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -5,7 +5,8 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Callable import logging -from typing import Any +import math +from typing import TYPE_CHECKING, Any, NamedTuple from uuid import UUID import voluptuous as vol @@ -50,6 +51,7 @@ from .const import ( ATTR_OCCURRED_OBSERVATION_ENTITIES, ATTR_PROBABILITY, ATTR_PROBABILITY_THRESHOLD, + CONF_NUMERIC_STATE, CONF_OBSERVATIONS, CONF_P_GIVEN_F, CONF_P_GIVEN_T, @@ -66,18 +68,74 @@ from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) -NUMERIC_STATE_SCHEMA = vol.Schema( - { - CONF_PLATFORM: "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, +def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: + if config[CONF_PLATFORM] == CONF_NUMERIC_STATE: + above = config.get(CONF_ABOVE) + below = config.get(CONF_BELOW) + if above is None and below is None: + _LOGGER.error( + "For bayesian numeric state for entity: %s at least one of 'above' or 'below' must be specified", + config[CONF_ENTITY_ID], + ) + 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( { CONF_PLATFORM: CONF_STATE, @@ -107,7 +165,8 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( vol.Required(CONF_OBSERVATIONS): vol.Schema( vol.All( 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), @@ -211,10 +270,11 @@ class BayesianBinarySensor(BinarySensorEntity): self.observations_by_entity = self._build_observations_by_entity() 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, "state": self._process_state, - "multi_state": self._process_multi_state, } async def async_added_to_hass(self) -> None: @@ -342,8 +402,9 @@ class BayesianBinarySensor(BinarySensorEntity): for observation in self.observations_by_entity[entity]: platform = observation.platform - observation.observed = self.observation_handlers[platform](observation) - + observation.observed = self.observation_handlers[platform]( + observation, observation.multi + ) local_observations[observation.id] = observation return local_observations @@ -408,9 +469,7 @@ class BayesianBinarySensor(BinarySensorEntity): if len(entity_observations) == 1: continue for observation in entity_observations: - if observation.platform != "state": - continue - observation.platform = "multi_state" + observation.multi = True return observations_by_entity @@ -437,14 +496,23 @@ class BayesianBinarySensor(BinarySensorEntity): 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.""" - 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: if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): return None - return condition.async_numeric_state( + result = condition.async_numeric_state( self.hass, entity, entity_observation.below, @@ -452,10 +520,24 @@ class BayesianBinarySensor(BinarySensorEntity): None, 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: 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. Returns None if the state is unavailable. @@ -467,24 +549,13 @@ class BayesianBinarySensor(BinarySensorEntity): if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): 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: return None - - def _process_multi_state(self, entity_observation: Observation) -> bool | None: - """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 + else: + return result @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/bayesian/const.py b/homeassistant/components/bayesian/const.py index 5d3f978cedc..cac4237b4ec 100644 --- a/homeassistant/components/bayesian/const.py +++ b/homeassistant/components/bayesian/const.py @@ -8,6 +8,7 @@ ATTR_PROBABILITY_THRESHOLD = "probability_threshold" CONF_OBSERVATIONS = "observations" CONF_PRIOR = "prior" CONF_TEMPLATE = "template" +CONF_NUMERIC_STATE = "numeric_state" CONF_PROBABILITY_THRESHOLD = "probability_threshold" CONF_P_GIVEN_F = "prob_given_false" CONF_P_GIVEN_T = "prob_given_true" diff --git a/homeassistant/components/bayesian/helpers.py b/homeassistant/components/bayesian/helpers.py index cc8966a90b6..2af3a331775 100644 --- a/homeassistant/components/bayesian/helpers.py +++ b/homeassistant/components/bayesian/helpers.py @@ -33,6 +33,7 @@ class Observation: below: float | None value_template: Template | None observed: bool | None = None + multi: bool = False id: uuid.UUID = field(default_factory=uuid.uuid4) def to_dict(self) -> dict[str, str | float | bool | None]: diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index 818e9bed909..a8723ae5d30 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -1,6 +1,7 @@ """The test for the bayesian sensor platform.""" import json +from logging import WARNING from unittest.mock import patch import pytest @@ -20,16 +21,14 @@ from homeassistant.const import ( STATE_UNKNOWN, ) 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.setup import async_setup_component from tests.common import get_fixture_path -async def test_load_values_when_added_to_hass( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: +async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None: """Test that sensor initializes with observations of relevant entities.""" config = { @@ -58,11 +57,6 @@ async def test_load_values_when_added_to_hass( assert await async_setup_component(hass, "binary_sensor", config) 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") assert state.attributes.get("device_class") == "connectivity" 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" +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: """Test sensor on probability threshold limits.""" config = { @@ -367,7 +430,7 @@ async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry) async def test_multiple_observations(hass: HomeAssistant) -> None: """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, 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 assert state.state == "off" - assert state.attributes.get("observations")[0]["platform"] == "multi_state" - assert state.attributes.get("observations")[1]["platform"] == "multi_state" + assert state.attributes.get("observations")[0]["platform"] == "state" + assert state.attributes.get("observations")[1]["platform"] == "state" -async def test_multiple_numeric_observations(hass: HomeAssistant) -> None: - """Test sensor with multiple numeric observations of same entity.""" +async def test_multiple_numeric_observations( + 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 = { "binary_sensor": { "platform": "bayesian", - "name": "Test_Binary", + "name": "nice_day", "observations": [ { "platform": "numeric_state", - "entity_id": "sensor.test_monitored", - "below": 10, - "above": 0, - "prob_given_true": 0.4, - "prob_given_false": 0.0001, + "entity_id": "sensor.test_temp", + "below": 0, + "prob_given_true": 0.05, + "prob_given_false": 0.2, }, { "platform": "numeric_state", - "entity_id": "sensor.test_monitored", - "below": 100, - "above": 30, - "prob_given_true": 0.6, - "prob_given_false": 0.0001, + "entity_id": "sensor.test_temp", + "below": 10, + "above": 0, + "prob_given_true": 0.1, + "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) 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() - state = hass.states.get("binary_sensor.test_binary") + state = hass.states.get("binary_sensor.nice_day") for attrs in state.attributes.values(): 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 + # 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" - hass.states.async_set("sensor.test_monitored", 20) + hass.states.async_set("sensor.test_temp", 12) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") - - assert state.attributes.get("occurred_observation_entities") == [ - "sensor.test_monitored" - ] - assert round(abs(0.026 - state.attributes.get("probability")), 7) < 0.01 - # Step 1 Calculated where P(A) = 0.1, P(~B|A) = 0.6 (negative obs), P(~B|notA) = 0.9999 -> 0.0625 - # Step 2 P(A) = 0.0625, P(B|A) = 0.4 (negative obs), P(B|notA) = 0.9999 -> 0.26 + state = hass.states.get("binary_sensor.nice_day") + assert abs(state.attributes.get("probability") - 0.19672131) < 0.01 + # 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" - hass.states.async_set("sensor.test_monitored", 35) + hass.states.async_set("sensor.test_temp", 22) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") - assert state.attributes.get("occurred_observation_entities") == [ - "sensor.test_monitored" - ] - assert abs(1 - state.attributes.get("probability")) < 0.01 - # Step 1 Calculated where P(A) = 0.1, P(~B|A) = 0.6 (negative obs), P(~B|notA) = 0.9999 -> 0.0625 - # Step 2 P(A) = 0.0625, P(B|A) = 0.6, P(B|notA) = 0.0001 -> 0.9975 + state = hass.states.get("binary_sensor.nice_day") + assert abs(state.attributes.get("probability") - 0.58823529) < 0.01 + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (15, 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.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" + + 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")[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( @@ -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: """Test probability update function.""" prob_given_true = [0.3, 0.6, 0.8]