From afc848bf22f93a650ea1641179de205b0ac5f3cb Mon Sep 17 00:00:00 2001 From: kahrendt Date: Wed, 12 Apr 2023 21:48:29 -0400 Subject: [PATCH] Add Bayesian type for binary_sensor_map component (#4640) * initial support for Bayesian type * Cast bool state of binary_sensor to uint64_t * Rename channels to observations with Bayesian * Improve/standardize comments for all types * Use black to correct sensor.py formatting * Add SUM and BAYESIAN binary sensor map tests * Remove unused variable * Update esphome/components/binary_sensor_map/binary_sensor_map.cpp Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --------- Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../binary_sensor_map/binary_sensor_map.cpp | 82 ++++++++++++++++--- .../binary_sensor_map/binary_sensor_map.h | 69 ++++++++++++---- .../components/binary_sensor_map/sensor.py | 47 +++++++++-- tests/test3.yaml | 26 ++++++ 4 files changed, 190 insertions(+), 34 deletions(-) diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 3934e0a99c..0bf6202893 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -16,6 +16,9 @@ void BinarySensorMap::loop() { case BINARY_SENSOR_MAP_TYPE_SUM: this->process_sum_(); break; + case BINARY_SENSOR_MAP_TYPE_BAYESIAN: + this->process_bayesian_(); + break; } } @@ -23,46 +26,51 @@ void BinarySensorMap::process_group_() { float total_current_value = 0.0; uint8_t num_active_sensors = 0; uint64_t mask = 0x00; - // check all binary_sensors for its state. when active add its value to total_current_value. - // create a bitmask for the binary_sensor status on all channels + + // - check all binary_sensors for its state + // - if active, add its value to total_current_value. + // - creates a bitmask for the binary_sensor states on all channels for (size_t i = 0; i < this->channels_.size(); i++) { auto bs = this->channels_[i]; if (bs.binary_sensor->state) { num_active_sensors++; - total_current_value += bs.sensor_value; + total_current_value += bs.parameters.sensor_value; mask |= 1ULL << i; } } - // check if the sensor map was touched + + // potentially update state only if a binary_sensor is active if (mask != 0ULL) { - // did the bit_mask change or is it a new sensor touch + // publish the average if the bitmask has changed if (this->last_mask_ != mask) { float publish_value = total_current_value / num_active_sensors; this->publish_state(publish_value); } } else if (this->last_mask_ != 0ULL) { - // is this a new sensor release + // no buttons are pressed and the states have changed since last run, so publish NAN ESP_LOGV(TAG, "'%s' - No binary sensor active, publishing NAN", this->name_.c_str()); this->publish_state(NAN); } + this->last_mask_ = mask; } void BinarySensorMap::process_sum_() { float total_current_value = 0.0; uint64_t mask = 0x00; + // - check all binary_sensor states // - if active, add its value to total_current_value - // - creates a bitmask for the binary_sensor status on all channels + // - creates a bitmask for the binary_sensor states on all channels for (size_t i = 0; i < this->channels_.size(); i++) { auto bs = this->channels_[i]; if (bs.binary_sensor->state) { - total_current_value += bs.sensor_value; + total_current_value += bs.parameters.sensor_value; mask |= 1ULL << i; } } - // update state only if the binary sensor states have changed or if no state has ever been sent on boot + // update state only if any binary_sensor states have changed or if no state has ever been sent on boot if ((this->last_mask_ != mask) || (!this->has_state())) { this->publish_state(total_current_value); } @@ -70,15 +78,65 @@ void BinarySensorMap::process_sum_() { this->last_mask_ = mask; } +void BinarySensorMap::process_bayesian_() { + float posterior_probability = this->bayesian_prior_; + uint64_t mask = 0x00; + + // - compute the posterior probability by taking the product of the predicate probablities for each observation + // - create a bitmask for the binary_sensor states on all channels/observations + for (size_t i = 0; i < this->channels_.size(); i++) { + auto bs = this->channels_[i]; + + posterior_probability *= + this->bayesian_predicate_(bs.binary_sensor->state, posterior_probability, + bs.parameters.probabilities.given_true, bs.parameters.probabilities.given_false); + + mask |= ((uint64_t) (bs.binary_sensor->state)) << i; + } + + // update state only if any binary_sensor states have changed or if no state has ever been sent on boot + if ((this->last_mask_ != mask) || (!this->has_state())) { + this->publish_state(posterior_probability); + } + + this->last_mask_ = mask; +} + +float BinarySensorMap::bayesian_predicate_(bool sensor_state, float prior, float prob_given_true, + float prob_given_false) { + float prob_state_source_true = prob_given_true; + float prob_state_source_false = prob_given_false; + + // if sensor is off, then we use the probabilities for the observation's complement + if (!sensor_state) { + prob_state_source_true = 1 - prob_given_true; + prob_state_source_false = 1 - prob_given_false; + } + + return prob_state_source_true / (prior * prob_state_source_true + (1.0 - prior) * prob_state_source_false); +} + void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float value) { BinarySensorMapChannel sensor_channel{ .binary_sensor = sensor, - .sensor_value = value, + .parameters{ + .sensor_value = value, + }, }; this->channels_.push_back(sensor_channel); } -void BinarySensorMap::set_sensor_type(BinarySensorMapType sensor_type) { this->sensor_type_ = sensor_type; } - +void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float prob_given_true, float prob_given_false) { + BinarySensorMapChannel sensor_channel{ + .binary_sensor = sensor, + .parameters{ + .probabilities{ + .given_true = prob_given_true, + .given_false = prob_given_false, + }, + }, + }; + this->channels_.push_back(sensor_channel); +} } // namespace binary_sensor_map } // namespace esphome diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index a1d6f95009..a07154c0e8 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -12,51 +12,88 @@ namespace binary_sensor_map { enum BinarySensorMapType { BINARY_SENSOR_MAP_TYPE_GROUP, BINARY_SENSOR_MAP_TYPE_SUM, + BINARY_SENSOR_MAP_TYPE_BAYESIAN, }; struct BinarySensorMapChannel { binary_sensor::BinarySensor *binary_sensor; - float sensor_value; + union { + float sensor_value; + struct { + float given_true; + float given_false; + } probabilities; + } parameters; }; -/** Class to group binary_sensors to one Sensor. +/** Class to map one or more binary_sensors to one Sensor. * - * Each binary sensor represents a float value in the group. + * Each binary sensor has configured parameters that each mapping type uses to compute the single numerical result */ class BinarySensorMap : public sensor::Sensor, public Component { public: void dump_config() override; + /** - * The loop checks all binary_sensor states - * When the binary_sensor reports a true value for its state, then the float value it represents is added to the - * total_current_value + * The loop calls the configured type processing method * - * Only when the total_current_value changed and at least one sensor reports an active state we publish the sensors - * average value. When the value changed and no sensors ar active we publish NAN. - * */ + * The processing method loops through all sensors and calculates the numerical result + * The result is only published if a binary sensor state has changed or, for some types, on initial boot + */ void loop() override; - float get_setup_priority() const override { return setup_priority::DATA; } - /** Add binary_sensors to the group. - * Each binary_sensor represents a float value when its state is true + + /** + * Add binary_sensors to the group when only one parameter is needed for the configured mapping type. * * @param *sensor The binary sensor. * @param value The value this binary_sensor represents */ void add_channel(binary_sensor::BinarySensor *sensor, float value); - void set_sensor_type(BinarySensorMapType sensor_type); + + /** + * Add binary_sensors to the group when two parameters are needed for the Bayesian mapping type. + * + * @param *sensor The binary sensor. + * @param prob_given_true Probability this observation is on when the Bayesian event is true + * @param prob_given_false Probability this observation is on when the Bayesian event is false + */ + void add_channel(binary_sensor::BinarySensor *sensor, float prob_given_true, float prob_given_false); + + void set_sensor_type(BinarySensorMapType sensor_type) { this->sensor_type_ = sensor_type; } + + void set_bayesian_prior(float prior) { this->bayesian_prior_ = prior; }; protected: std::vector channels_{}; BinarySensorMapType sensor_type_{BINARY_SENSOR_MAP_TYPE_GROUP}; - // this gives max 64 channels per binary_sensor_map + + // this allows a max of 64 channels/observations in order to keep track of binary_sensor states uint64_t last_mask_{0x00}; + + // Bayesian event prior probability before taking into account any observations + float bayesian_prior_{}; + /** - * methods to process the types of binary_sensor_maps - * GROUP: process_group_() just map to a value + * Methods to process the binary_sensor_maps types + * + * GROUP: process_group_() averages all the values * ADD: process_add_() adds all the values + * BAYESIAN: process_bayesian_() computes the predicate probability * */ void process_group_(); void process_sum_(); + void process_bayesian_(); + + /** + * Computes the Bayesian predicate for a specific observation + * If the sensor state is false, then we use the parameters' probabilities for the observatiosn complement + * + * @param sensor_state State of observation + * @param prior Prior probability before accounting for this observation + * @param prob_given_true Probability this observation is on when the Bayesian event is true + * @param prob_given_false Probability this observation is on when the Bayesian event is false + * */ + float bayesian_predicate_(bool sensor_state, float prior, float prob_given_true, float prob_given_false); }; } // namespace binary_sensor_map diff --git a/esphome/components/binary_sensor_map/sensor.py b/esphome/components/binary_sensor_map/sensor.py index 573cce9223..1181905f30 100644 --- a/esphome/components/binary_sensor_map/sensor.py +++ b/esphome/components/binary_sensor_map/sensor.py @@ -20,16 +20,29 @@ BinarySensorMap = binary_sensor_map_ns.class_( ) SensorMapType = binary_sensor_map_ns.enum("SensorMapType") +CONF_BAYESIAN = "bayesian" +CONF_PRIOR = "prior" +CONF_PROB_GIVEN_TRUE = "prob_given_true" +CONF_PROB_GIVEN_FALSE = "prob_given_false" +CONF_OBSERVATIONS = "observations" + SENSOR_MAP_TYPES = { CONF_GROUP: SensorMapType.BINARY_SENSOR_MAP_TYPE_GROUP, CONF_SUM: SensorMapType.BINARY_SENSOR_MAP_TYPE_SUM, + CONF_BAYESIAN: SensorMapType.BINARY_SENSOR_MAP_TYPE_BAYESIAN, } -entry = { +entry_one_parameter = { cv.Required(CONF_BINARY_SENSOR): cv.use_id(binary_sensor.BinarySensor), cv.Required(CONF_VALUE): cv.float_, } +entry_bayesian_parameters = { + cv.Required(CONF_BINARY_SENSOR): cv.use_id(binary_sensor.BinarySensor), + cv.Required(CONF_PROB_GIVEN_TRUE): cv.float_range(min=0, max=1), + cv.Required(CONF_PROB_GIVEN_FALSE): cv.float_range(min=0, max=1), +} + CONFIG_SCHEMA = cv.typed_schema( { CONF_GROUP: sensor.sensor_schema( @@ -39,7 +52,7 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend( { cv.Required(CONF_CHANNELS): cv.All( - cv.ensure_list(entry), cv.Length(min=1, max=64) + cv.ensure_list(entry_one_parameter), cv.Length(min=1, max=64) ), } ), @@ -50,7 +63,18 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend( { cv.Required(CONF_CHANNELS): cv.All( - cv.ensure_list(entry), cv.Length(min=1, max=64) + cv.ensure_list(entry_one_parameter), cv.Length(min=1, max=64) + ), + } + ), + CONF_BAYESIAN: sensor.sensor_schema( + BinarySensorMap, + accuracy_decimals=2, + ).extend( + { + cv.Required(CONF_PRIOR): cv.float_range(min=0, max=1), + cv.Required(CONF_OBSERVATIONS): cv.All( + cv.ensure_list(entry_bayesian_parameters), cv.Length(min=1, max=64) ), } ), @@ -66,6 +90,17 @@ async def to_code(config): constant = SENSOR_MAP_TYPES[config[CONF_TYPE]] cg.add(var.set_sensor_type(constant)) - for ch in config[CONF_CHANNELS]: - input_var = await cg.get_variable(ch[CONF_BINARY_SENSOR]) - cg.add(var.add_channel(input_var, ch[CONF_VALUE])) + if config[CONF_TYPE] == CONF_BAYESIAN: + cg.add(var.set_bayesian_prior(config[CONF_PRIOR])) + + for obs in config[CONF_OBSERVATIONS]: + input_var = await cg.get_variable(obs[CONF_BINARY_SENSOR]) + cg.add( + var.add_channel( + input_var, obs[CONF_PROB_GIVEN_TRUE], obs[CONF_PROB_GIVEN_FALSE] + ) + ) + else: + for ch in config[CONF_CHANNELS]: + input_var = await cg.get_variable(ch[CONF_BINARY_SENSOR]) + cg.add(var.add_channel(input_var, ch[CONF_VALUE])) diff --git a/tests/test3.yaml b/tests/test3.yaml index ceb9047d17..c4847725e8 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -368,6 +368,32 @@ sensor: - binary_sensor: bin3 value: 100.0 + - platform: binary_sensor_map + name: Binary Sensor Map + type: sum + channels: + - binary_sensor: bin1 + value: 10.0 + - binary_sensor: bin2 + value: 15.0 + - binary_sensor: bin3 + value: 100.0 + + - platform: binary_sensor_map + name: Binary Sensor Map + type: bayesian + prior: 0.4 + observations: + - binary_sensor: bin1 + prob_given_true: 0.9 + prob_given_false: 0.4 + - binary_sensor: bin2 + prob_given_true: 0.7 + prob_given_false: 0.05 + - binary_sensor: bin3 + prob_given_true: 0.8 + prob_given_false: 0.2 + - platform: bl0939 uart_id: uart_8 voltage: