From 49eeeae51da329284070eb7b91ed6cc8078d2f19 Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Mon, 26 Sep 2022 03:02:35 +0100 Subject: [PATCH] Fix Bayesian sensor to use negative observations (#67631) Co-authored-by: Diogo Gomes --- CODEOWNERS | 2 + .../components/bayesian/binary_sensor.py | 105 ++++--- .../components/bayesian/manifest.json | 2 +- .../components/bayesian/test_binary_sensor.py | 275 +++++++++++++----- 4 files changed, 278 insertions(+), 106 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 917a279228a..101c40370ef 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -127,6 +127,8 @@ build.json @home-assistant/supervisor /tests/components/baf/ @bdraco @jfroy /homeassistant/components/balboa/ @garbled1 /tests/components/balboa/ @garbled1 +/homeassistant/components/bayesian/ @HarvsG +/tests/components/bayesian/ @HarvsG /homeassistant/components/beewi_smartclim/ @alemuro /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 5641480ba98..73ebcc8b37e 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_STATE, CONF_VALUE_TEMPLATE, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback @@ -60,7 +61,7 @@ NUMERIC_STATE_SCHEMA = vol.Schema( 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), + vol.Required(CONF_P_GIVEN_F): vol.Coerce(float), }, required=True, ) @@ -71,7 +72,7 @@ STATE_SCHEMA = vol.Schema( vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TO_STATE): cv.string, vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), - vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_F): vol.Coerce(float), }, required=True, ) @@ -81,7 +82,7 @@ TEMPLATE_SCHEMA = vol.Schema( CONF_PLATFORM: CONF_TEMPLATE, vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), - vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_F): vol.Coerce(float), }, required=True, ) @@ -160,6 +161,7 @@ class BayesianBinarySensor(BinarySensorEntity): self.observation_handlers = { "numeric_state": self._process_numeric_state, "state": self._process_state, + "multi_state": self._process_multi_state, } async def async_added_to_hass(self) -> None: @@ -185,10 +187,6 @@ class BayesianBinarySensor(BinarySensorEntity): When a state changes, we must update our list of current observations, then calculate the new probability. """ - new_state = event.data.get("new_state") - - if new_state is None or new_state.state == STATE_UNKNOWN: - return entity = event.data.get("entity_id") @@ -210,7 +208,6 @@ class BayesianBinarySensor(BinarySensorEntity): template = track_template_result.template result = track_template_result.result entity = event and event.data.get("entity_id") - if isinstance(result, TemplateError): _LOGGER.error( "TemplateError('%s') " @@ -221,15 +218,12 @@ class BayesianBinarySensor(BinarySensorEntity): self.entity_id, ) - should_trigger = False + observation = None else: - should_trigger = result_as_boolean(result) + observation = result_as_boolean(result) for obs in self.observations_by_template[template]: - if should_trigger: - obs_entry = {"entity_id": entity, **obs} - else: - obs_entry = None + obs_entry = {"entity_id": entity, "observation": observation, **obs} self.current_observations[obs["id"]] = obs_entry if event: @@ -259,6 +253,7 @@ class BayesianBinarySensor(BinarySensorEntity): def _initialize_current_observations(self): local_observations = OrderedDict({}) + for entity in self.observations_by_entity: local_observations.update(self._record_entity_observations(entity)) return local_observations @@ -269,13 +264,13 @@ class BayesianBinarySensor(BinarySensorEntity): for entity_obs in self.observations_by_entity[entity]: platform = entity_obs["platform"] - should_trigger = self.observation_handlers[platform](entity_obs) - - if should_trigger: - obs_entry = {"entity_id": entity, **entity_obs} - else: - obs_entry = None + observation = self.observation_handlers[platform](entity_obs) + obs_entry = { + "entity_id": entity, + "observation": observation, + **entity_obs, + } local_observations[entity_obs["id"]] = obs_entry return local_observations @@ -285,11 +280,28 @@ class BayesianBinarySensor(BinarySensorEntity): for obs in self.current_observations.values(): if obs is not None: - prior = update_probability( - prior, - obs["prob_given_true"], - obs.get("prob_given_false", 1 - obs["prob_given_true"]), - ) + if obs["observation"] is True: + prior = update_probability( + prior, + obs["prob_given_true"], + obs["prob_given_false"], + ) + elif obs["observation"] is False: + prior = update_probability( + prior, + 1 - obs["prob_given_true"], + 1 - obs["prob_given_false"], + ) + elif obs["observation"] is None: + if obs["entity_id"] is not None: + _LOGGER.debug( + "Observation for entity '%s' returned None, it will not be used for Bayesian updating", + obs["entity_id"], + ) + else: + _LOGGER.debug( + "Observation for template entity returned None rather than a valid boolean, it will not be used for Bayesian updating", + ) return prior @@ -307,17 +319,21 @@ class BayesianBinarySensor(BinarySensorEntity): for all relevant observations to be looked up via their `entity_id`. """ - observations_by_entity = {} - for ind, obs in enumerate(self._observations): - obs["id"] = ind + observations_by_entity: dict[str, list[OrderedDict]] = {} + for i, obs in enumerate(self._observations): + obs["id"] = i if "entity_id" not in obs: continue + observations_by_entity.setdefault(obs["entity_id"], []).append(obs) - entity_ids = [obs["entity_id"]] - - for e_id in entity_ids: - observations_by_entity.setdefault(e_id, []).append(obs) + for li_of_dicts in observations_by_entity.values(): + if len(li_of_dicts) == 1: + continue + for ord_dict in li_of_dicts: + if ord_dict["platform"] != "state": + continue + ord_dict["platform"] = "multi_state" return observations_by_entity @@ -348,10 +364,12 @@ class BayesianBinarySensor(BinarySensorEntity): return observations_by_template def _process_numeric_state(self, entity_observation): - """Return True if numeric condition is met.""" + """Return True if numeric condition is met, return False if not, return None otherwise.""" entity = entity_observation["entity_id"] try: + if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): + return None return condition.async_numeric_state( self.hass, entity, @@ -361,18 +379,31 @@ class BayesianBinarySensor(BinarySensorEntity): entity_observation, ) except ConditionError: - return False + return None def _process_state(self, entity_observation): """Return True if state conditions are met.""" entity = entity_observation["entity_id"] try: + if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): + return None + return condition.state( self.hass, entity, entity_observation.get("to_state") ) except ConditionError: - return False + return None + + def _process_multi_state(self, entity_observation): + """Return True if state conditions are met.""" + entity = entity_observation["entity_id"] + + try: + if condition.state(self.hass, entity, entity_observation.get("to_state")): + return True + except ConditionError: + return None @property def extra_state_attributes(self): @@ -390,7 +421,9 @@ class BayesianBinarySensor(BinarySensorEntity): { obs.get("entity_id") for obs in self.current_observations.values() - if obs is not None and obs.get("entity_id") is not None + if obs is not None + and obs.get("entity_id") is not None + and obs.get("observation") is not None } ), ATTR_PROBABILITY: round(self.probability, 2), diff --git a/homeassistant/components/bayesian/manifest.json b/homeassistant/components/bayesian/manifest.json index 6a84beb1df6..1b5a466f0a2 100644 --- a/homeassistant/components/bayesian/manifest.json +++ b/homeassistant/components/bayesian/manifest.json @@ -2,7 +2,7 @@ "domain": "bayesian", "name": "Bayesian", "documentation": "https://www.home-assistant.io/integrations/bayesian", - "codeowners": [], + "codeowners": ["@HarvsG"], "quality_scale": "internal", "iot_class": "local_polling" } diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index 2f45e0e475e..357cacb4214 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, callback @@ -56,7 +57,7 @@ async def test_load_values_when_added_to_hass(hass): async def test_unknown_state_does_not_influence_probability(hass): """Test that an unknown state does not change the output probability.""" - + prior = 0.2 config = { "binary_sensor": { "name": "Test_Binary", @@ -70,11 +71,12 @@ async def test_unknown_state_does_not_influence_probability(hass): "prob_given_false": 0.4, } ], - "prior": 0.2, + "prior": prior, "probability_threshold": 0.32, } } - + hass.states.async_set("sensor.test_monitored", "on") + await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) await hass.async_block_till_done() @@ -82,7 +84,8 @@ async def test_unknown_state_does_not_influence_probability(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert state.attributes.get("observations") == [] + assert state.attributes.get("occurred_observation_entities") == [] + assert state.attributes.get("probability") == prior async def test_sensor_numeric_state(hass): @@ -97,7 +100,8 @@ async def test_sensor_numeric_state(hass): "entity_id": "sensor.test_monitored", "below": 10, "above": 5, - "prob_given_true": 0.6, + "prob_given_true": 0.7, + "prob_given_false": 0.4, }, { "platform": "numeric_state", @@ -105,7 +109,7 @@ async def test_sensor_numeric_state(hass): "below": 7, "above": 5, "prob_given_true": 0.9, - "prob_given_false": 0.1, + "prob_given_false": 0.2, }, ], "prior": 0.2, @@ -115,40 +119,61 @@ async def test_sensor_numeric_state(hass): assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + hass.states.async_set("sensor.test_monitored", 6) + 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(state.attributes.get("probability") - 0.304) < 0.01 + # A = sensor.test_binary being ON + # B = sensor.test_monitored in the range [5, 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.2, P(B|A) = 0.7, P(B|~A) = 0.4 -> 0.30 + hass.states.async_set("sensor.test_monitored", 4) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert [] == state.attributes.get("observations") - assert state.attributes.get("probability") == 0.2 + assert state.attributes.get("occurred_observation_entities") == [ + "sensor.test_monitored" + ] + assert abs(state.attributes.get("probability") - 0.111) < 0.01 + # As abve but since the value is equal to 4 then this is a negative observation (~B) where P(~B) == 1 - P(B) because B is binary + # We therefore want to calculate P(A|~B) so we use P(~B|A) (1-0.7) and P(~B|~A) (1-0.4) + # Calculated using bayes theorum where P(A) = 0.2, P(~B|A) = 1-0.7 (as negative observation), P(~B|notA) = 1-0.4 -> 0.11 assert state.state == "off" hass.states.async_set("sensor.test_monitored", 6) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", 4) - await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", 6) hass.states.async_set("sensor.test_monitored1", 6) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert state.attributes.get("observations")[0]["prob_given_true"] == 0.6 + assert state.attributes.get("observations")[0]["prob_given_true"] == 0.7 assert state.attributes.get("observations")[1]["prob_given_true"] == 0.9 - assert state.attributes.get("observations")[1]["prob_given_false"] == 0.1 - assert round(abs(0.77 - state.attributes.get("probability")), 7) == 0 + assert state.attributes.get("observations")[1]["prob_given_false"] == 0.2 + assert abs(state.attributes.get("probability") - 0.663) < 0.01 + # Here we have two positive observations as both are in range. We do a 2-step bayes. The output of the first is used as the (updated) prior in the second. + # 1st step P(A) = 0.2, P(B|A) = 0.7, P(B|notA) = 0.4 -> 0.304 + # 2nd update: P(A) = 0.304, P(B|A) = 0.9, P(B|notA) = 0.2 -> 0.663 assert state.state == "on" - hass.states.async_set("sensor.test_monitored", 6) hass.states.async_set("sensor.test_monitored1", 0) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", 4) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert state.attributes.get("probability") == 0.2 + assert abs(state.attributes.get("probability") - 0.0153) < 0.01 + # Calculated using bayes theorum where P(A) = 0.2, P(~B|A) = 0.3, P(~B|notA) = 0.6 -> 0.11 + # 2nd update: P(A) = 0.111, P(~B|A) = 0.1, P(~B|notA) = 0.8 assert state.state == "off" @@ -162,6 +187,7 @@ async def test_sensor_numeric_state(hass): async def test_sensor_state(hass): """Test sensor on state platform observations.""" + prior = 0.2 config = { "binary_sensor": { "name": "Test_Binary", @@ -175,7 +201,7 @@ async def test_sensor_state(hass): "prob_given_false": 0.4, } ], - "prior": 0.2, + "prior": prior, "probability_threshold": 0.32, } } @@ -184,36 +210,51 @@ async def test_sensor_state(hass): await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", "on") - + await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert [] == state.attributes.get("observations") - assert state.attributes.get("probability") == 0.2 - + assert state.attributes.get("occurred_observation_entities") == [ + "sensor.test_monitored" + ] + assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8 + assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4 + assert abs(0.0769 - state.attributes.get("probability")) < 0.01 + # Calculated using bayes theorum where P(A) = 0.2, P(~B|A) = 0.2 (as negative observation), P(~B|notA) = 0.6 assert state.state == "off" hass.states.async_set("sensor.test_monitored", "off") await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", "on") - await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", "off") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") - assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8 - assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4 - assert round(abs(0.33 - state.attributes.get("probability")), 7) == 0 + assert state.attributes.get("occurred_observation_entities") == [ + "sensor.test_monitored" + ] + assert abs(0.33 - state.attributes.get("probability")) < 0.01 + # Calculated using bayes theorum where P(A) = 0.2, P(~B|A) = 0.8 (as negative observation), P(~B|notA) = 0.4 assert state.state == "on" - hass.states.async_set("sensor.test_monitored", "off") + hass.states.async_remove("sensor.test_monitored") await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", "on") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") - assert round(abs(0.2 - state.attributes.get("probability")), 7) == 0 + assert state.attributes.get("occurred_observation_entities") == [] + assert abs(prior - state.attributes.get("probability")) < 0.01 + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_binary") + + assert state.attributes.get("occurred_observation_entities") == [] + assert abs(prior - state.attributes.get("probability")) < 0.01 + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_binary") + + assert state.attributes.get("occurred_observation_entities") == [] + assert abs(prior - state.attributes.get("probability")) < 0.01 assert state.state == "off" @@ -243,32 +284,29 @@ async def test_sensor_value_template(hass): state = hass.states.get("binary_sensor.test_binary") - assert [] == state.attributes.get("observations") - assert state.attributes.get("probability") == 0.2 + assert state.attributes.get("occurred_observation_entities") == [] + assert abs(0.0769 - state.attributes.get("probability")) < 0.01 + # Calculated using bayes theorum where P(A) = 0.2, P(~B|A) = 0.2 (as negative observation), P(~B|notA) = 0.6 assert state.state == "off" - hass.states.async_set("sensor.test_monitored", "off") - await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", "on") - await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", "off") await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8 assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4 - assert round(abs(0.33 - state.attributes.get("probability")), 7) == 0 + assert abs(0.33333 - state.attributes.get("probability")) < 0.01 + # Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.8, P(B|notA) = 0.4 assert state.state == "on" - hass.states.async_set("sensor.test_monitored", "off") - await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", "on") await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert round(abs(0.2 - state.attributes.get("probability")), 7) == 0 + assert abs(0.076923 - state.attributes.get("probability")) < 0.01 + # Calculated using bayes theorum where P(A) = 0.2, P(~B|A) = 0.2 (as negative observation), P(~B|notA) = 0.6 assert state.state == "off" @@ -285,6 +323,7 @@ async def test_threshold(hass): "entity_id": "sensor.test_monitored", "to_state": "on", "prob_given_true": 1.0, + "prob_given_false": 0.0, } ], "prior": 0.5, @@ -305,7 +344,14 @@ async def test_threshold(hass): async def test_multiple_observations(hass): - """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. + Before the merge of #67631 this practice was a common work-around for bayesian's ignoring of negative observations, + this also preserves that function + """ + config = { "binary_sensor": { "name": "Test_Binary", @@ -323,7 +369,7 @@ async def test_multiple_observations(hass): "entity_id": "sensor.test_monitored", "to_state": "red", "prob_given_true": 0.2, - "prob_given_false": 0.4, + "prob_given_false": 0.6, }, ], "prior": 0.2, @@ -335,40 +381,118 @@ async def test_multiple_observations(hass): await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", "off") + await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - for key, attrs in state.attributes.items(): + for _, attrs in state.attributes.items(): json.dumps(attrs) - assert [] == state.attributes.get("observations") + assert state.attributes.get("occurred_observation_entities") == [] assert state.attributes.get("probability") == 0.2 + # probability should be the same as the prior as negative observations are ignored in multi-state assert state.state == "off" - hass.states.async_set("sensor.test_monitored", "blue") - await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", "off") - await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", "blue") 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 state.attributes.get("observations")[0]["prob_given_true"] == 0.8 assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4 assert round(abs(0.33 - state.attributes.get("probability")), 7) == 0 + # Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.8, P(B|notA) = 0.4 assert state.state == "on" - hass.states.async_set("sensor.test_monitored", "blue") - await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", "red") await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert round(abs(0.11 - state.attributes.get("probability")), 7) == 0 + assert abs(0.076923 - state.attributes.get("probability")) < 0.01 + # 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" + + +async def test_multiple_numeric_observations(hass): + """Test sensor with multiple numeric observations of same entity.""" + + config = { + "binary_sensor": { + "platform": "bayesian", + "name": "Test_Binary", + "observations": [ + { + "platform": "numeric_state", + "entity_id": "sensor.test_monitored", + "below": 10, + "above": 0, + "prob_given_true": 0.4, + "prob_given_false": 0.0001, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.test_monitored", + "below": 100, + "above": 30, + "prob_given_true": 0.6, + "prob_given_false": 0.0001, + }, + ], + "prior": 0.1, + } + } + + assert await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_binary") + + for _, attrs in state.attributes.items(): + json.dumps(attrs) + assert state.attributes.get("occurred_observation_entities") == [] + assert state.attributes.get("probability") == 0.1 + # No observations made so probability should be the prior + + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 20) + 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 + + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 35) + 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 + + assert state.state == "on" + assert state.attributes.get("observations")[0]["platform"] == "numeric_state" + assert state.attributes.get("observations")[1]["platform"] == "numeric_state" async def test_probability_updates(hass): @@ -377,8 +501,8 @@ async def test_probability_updates(hass): prob_given_false = [0.7, 0.4, 0.2] prior = 0.5 - for pt, pf in zip(prob_given_true, prob_given_false): - prior = bayesian.update_probability(prior, pt, pf) + for p_t, p_f in zip(prob_given_true, prob_given_false): + prior = bayesian.update_probability(prior, p_t, p_f) assert round(abs(0.720000 - prior), 7) == 0 @@ -386,8 +510,8 @@ async def test_probability_updates(hass): prob_given_false = [0.6, 0.4, 0.2] prior = 0.7 - for pt, pf in zip(prob_given_true, prob_given_false): - prior = bayesian.update_probability(prior, pt, pf) + for p_t, p_f in zip(prob_given_true, prob_given_false): + prior = bayesian.update_probability(prior, p_t, p_f) assert round(abs(0.9130434782608695 - prior), 7) == 0 @@ -410,6 +534,7 @@ async def test_observed_entities(hass): "platform": "template", "value_template": "{{is_state('sensor.test_monitored1','on') and is_state('sensor.test_monitored','off')}}", "prob_given_true": 0.9, + "prob_given_false": 0.1, }, ], "prior": 0.2, @@ -426,7 +551,9 @@ async def test_observed_entities(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert [] == state.attributes.get("occurred_observation_entities") + assert state.attributes.get("occurred_observation_entities") == [ + "sensor.test_monitored" + ] hass.states.async_set("sensor.test_monitored", "off") await hass.async_block_till_done() @@ -463,6 +590,7 @@ async def test_state_attributes_are_serializable(hass): "platform": "template", "value_template": "{{is_state('sensor.test_monitored1','on') and is_state('sensor.test_monitored','off')}}", "prob_given_true": 0.9, + "prob_given_false": 0.1, }, ], "prior": 0.2, @@ -479,15 +607,17 @@ async def test_state_attributes_are_serializable(hass): await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert [] == state.attributes.get("occurred_observation_entities") + assert state.attributes.get("occurred_observation_entities") == [ + "sensor.test_monitored" + ] hass.states.async_set("sensor.test_monitored", "off") await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_binary") - assert ["sensor.test_monitored"] == state.attributes.get( - "occurred_observation_entities" - ) + assert state.attributes.get("occurred_observation_entities") == [ + "sensor.test_monitored" + ] hass.states.async_set("sensor.test_monitored1", "on") await hass.async_block_till_done() @@ -497,7 +627,7 @@ async def test_state_attributes_are_serializable(hass): state.attributes.get("occurred_observation_entities") ) - for key, attrs in state.attributes.items(): + for _, attrs in state.attributes.items(): json.dumps(attrs) @@ -512,6 +642,7 @@ async def test_template_error(hass, caplog): "platform": "template", "value_template": "{{ xyz + 1 }}", "prob_given_true": 0.9, + "prob_given_false": 0.1, }, ], "prior": 0.2, @@ -633,11 +764,16 @@ async def test_monitored_sensor_goes_away(hass): await hass.async_block_till_done() assert hass.states.get("binary_sensor.test_binary").state == "on" + # Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.9, P(B|notA) = 0.4 -> 0.36 (>0.32) hass.states.async_remove("sensor.test_monitored") await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_binary").state == "on" + assert ( + hass.states.get("binary_sensor.test_binary").attributes.get("probability") + == 0.2 + ) + assert hass.states.get("binary_sensor.test_binary").state == "off" async def test_reload(hass): @@ -696,7 +832,8 @@ async def test_template_triggers(hass): { "platform": "template", "value_template": "{{ states.input_boolean.test.state }}", - "prob_given_true": 1999.9, + "prob_given_true": 1.0, + "prob_given_false": 0.0, }, ], "prior": 0.2, @@ -735,8 +872,8 @@ async def test_state_triggers(hass): "platform": "state", "entity_id": "sensor.test_monitored", "to_state": "off", - "prob_given_true": 999.9, - "prob_given_false": 999.4, + "prob_given_true": 0.9999, + "prob_given_false": 0.9994, }, ], "prior": 0.2,