diff --git a/CODEOWNERS b/CODEOWNERS index 70ea2385da8fe..62e1871192cc8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -89,6 +89,7 @@ homeassistant/components/cloud/* @home-assistant/cloud homeassistant/components/cloudflare/* @ludeeus @ctalkington homeassistant/components/color_extractor/* @GenericStudent homeassistant/components/comfoconnect/* @michaelarnauts +homeassistant/components/compensation/* @Petro31 homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core homeassistant/components/control4/* @lawtancool diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py new file mode 100644 index 0000000000000..7d96905efa0ae --- /dev/null +++ b/homeassistant/components/compensation/__init__.py @@ -0,0 +1,120 @@ +"""The Compensation integration.""" +import logging +import warnings + +import numpy as np +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_SOURCE, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +from .const import ( + CONF_COMPENSATION, + CONF_DATAPOINTS, + CONF_DEGREE, + CONF_POLYNOMIAL, + CONF_PRECISION, + DATA_COMPENSATION, + DEFAULT_DEGREE, + DEFAULT_PRECISION, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def datapoints_greater_than_degree(value: dict) -> dict: + """Validate data point list is greater than polynomial degrees.""" + if len(value[CONF_DATAPOINTS]) <= value[CONF_DEGREE]: + raise vol.Invalid( + f"{CONF_DATAPOINTS} must have at least {value[CONF_DEGREE]+1} {CONF_DATAPOINTS}" + ) + + return value + + +COMPENSATION_SCHEMA = vol.Schema( + { + vol.Required(CONF_SOURCE): cv.entity_id, + vol.Required(CONF_DATAPOINTS): [ + vol.ExactSequence([vol.Coerce(float), vol.Coerce(float)]) + ], + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, + vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=7), + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {cv.slug: vol.All(COMPENSATION_SCHEMA, datapoints_greater_than_degree)} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Compensation sensor.""" + hass.data[DATA_COMPENSATION] = {} + + for compensation, conf in config.get(DOMAIN).items(): + _LOGGER.debug("Setup %s.%s", DOMAIN, compensation) + + degree = conf[CONF_DEGREE] + + # get x values and y values from the x,y point pairs + x_values, y_values = zip(*conf[CONF_DATAPOINTS]) + + # try to get valid coefficients for a polynomial + coefficients = None + with np.errstate(all="raise"): + with warnings.catch_warnings(record=True) as all_warnings: + warnings.simplefilter("always") + try: + coefficients = np.polyfit(x_values, y_values, degree) + except FloatingPointError as error: + _LOGGER.error( + "Setup of %s encountered an error, %s", + compensation, + error, + ) + for warning in all_warnings: + _LOGGER.warning( + "Setup of %s encountered a warning, %s", + compensation, + str(warning.message).lower(), + ) + + if coefficients is not None: + data = { + k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS] + } + data[CONF_POLYNOMIAL] = np.poly1d(coefficients) + + hass.data[DATA_COMPENSATION][compensation] = data + + hass.async_create_task( + async_load_platform( + hass, + SENSOR_DOMAIN, + DOMAIN, + {CONF_COMPENSATION: compensation}, + config, + ) + ) + + return True diff --git a/homeassistant/components/compensation/const.py b/homeassistant/components/compensation/const.py new file mode 100644 index 0000000000000..f116725883ecd --- /dev/null +++ b/homeassistant/components/compensation/const.py @@ -0,0 +1,16 @@ +"""Compensation constants.""" +DOMAIN = "compensation" + +SENSOR = "compensation" + +CONF_COMPENSATION = "compensation" +CONF_DATAPOINTS = "data_points" +CONF_DEGREE = "degree" +CONF_PRECISION = "precision" +CONF_POLYNOMIAL = "polynomial" + +DATA_COMPENSATION = "compensation_data" + +DEFAULT_DEGREE = 1 +DEFAULT_NAME = "Compensation" +DEFAULT_PRECISION = 2 diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json new file mode 100644 index 0000000000000..86efbce72c87b --- /dev/null +++ b/homeassistant/components/compensation/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "compensation", + "name": "Compensation", + "documentation": "https://www.home-assistant.io/integrations/compensation", + "requirements": ["numpy==1.20.2"], + "codeowners": ["@Petro31"] +} diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py new file mode 100644 index 0000000000000..35ca07ce52215 --- /dev/null +++ b/homeassistant/components/compensation/sensor.py @@ -0,0 +1,162 @@ +"""Support for compensation sensor.""" +import logging + +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_ATTRIBUTE, + CONF_SOURCE, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change_event + +from .const import ( + CONF_COMPENSATION, + CONF_POLYNOMIAL, + CONF_PRECISION, + DATA_COMPENSATION, + DEFAULT_NAME, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_COEFFICIENTS = "coefficients" +ATTR_SOURCE = "source" +ATTR_SOURCE_ATTRIBUTE = "source_attribute" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Compensation sensor.""" + if discovery_info is None: + return + + compensation = discovery_info[CONF_COMPENSATION] + conf = hass.data[DATA_COMPENSATION][compensation] + + source = conf[CONF_SOURCE] + attribute = conf.get(CONF_ATTRIBUTE) + name = f"{DEFAULT_NAME} {source}" + if attribute is not None: + name = f"{name} {attribute}" + + async_add_entities( + [ + CompensationSensor( + conf.get(CONF_UNIQUE_ID), + name, + source, + attribute, + conf[CONF_PRECISION], + conf[CONF_POLYNOMIAL], + conf.get(CONF_UNIT_OF_MEASUREMENT), + ) + ] + ) + + +class CompensationSensor(SensorEntity): + """Representation of a Compensation sensor.""" + + def __init__( + self, + unique_id, + name, + source, + attribute, + precision, + polynomial, + unit_of_measurement, + ): + """Initialize the Compensation sensor.""" + self._source_entity_id = source + self._precision = precision + self._source_attribute = attribute + self._unit_of_measurement = unit_of_measurement + self._poly = polynomial + self._coefficients = polynomial.coefficients.tolist() + self._state = None + self._unique_id = unique_id + self._name = name + + async def async_added_to_hass(self): + """Handle added to Hass.""" + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._source_entity_id], + self._async_compensation_sensor_state_listener, + ) + ) + + @property + def unique_id(self): + """Return the unique id of this sensor.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + ret = { + ATTR_SOURCE: self._source_entity_id, + ATTR_COEFFICIENTS: self._coefficients, + } + if self._source_attribute: + ret[ATTR_SOURCE_ATTRIBUTE] = self._source_attribute + return ret + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @callback + def _async_compensation_sensor_state_listener(self, event): + """Handle sensor state changes.""" + new_state = event.data.get("new_state") + if new_state is None: + return + + if self._unit_of_measurement is None and self._source_attribute is None: + self._unit_of_measurement = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) + + try: + if self._source_attribute: + value = float(new_state.attributes.get(self._source_attribute)) + else: + value = ( + None if new_state.state == STATE_UNKNOWN else float(new_state.state) + ) + self._state = round(self._poly(value), self._precision) + + except (ValueError, TypeError): + self._state = None + if self._source_attribute: + _LOGGER.warning( + "%s attribute %s is not numerical", + self._source_entity_id, + self._source_attribute, + ) + else: + _LOGGER.warning("%s state is not numerical", self._source_entity_id) + + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 7b60bcd4ee912..c368183080342 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1011,6 +1011,7 @@ nuheat==0.3.0 # homeassistant.components.numato numato-gpio==0.10.0 +# homeassistant.components.compensation # homeassistant.components.iqvia # homeassistant.components.opencv # homeassistant.components.tensorflow diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc7367159562d..a4b98afbd198c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,6 +529,7 @@ nuheat==0.3.0 # homeassistant.components.numato numato-gpio==0.10.0 +# homeassistant.components.compensation # homeassistant.components.iqvia # homeassistant.components.opencv # homeassistant.components.tensorflow diff --git a/tests/components/compensation/__init__.py b/tests/components/compensation/__init__.py new file mode 100644 index 0000000000000..55d365adc0e58 --- /dev/null +++ b/tests/components/compensation/__init__.py @@ -0,0 +1 @@ +"""Tests for the compensation component.""" diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py new file mode 100644 index 0000000000000..3bd86280750ff --- /dev/null +++ b/tests/components/compensation/test_sensor.py @@ -0,0 +1,228 @@ +"""The tests for the integration sensor platform.""" + +from homeassistant.components.compensation.const import CONF_PRECISION, DOMAIN +from homeassistant.components.compensation.sensor import ATTR_COEFFICIENTS +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_STATE_CHANGED, + STATE_UNKNOWN, +) +from homeassistant.setup import async_setup_component + + +async def test_linear_state(hass): + """Test compensation sensor state.""" + config = { + "compensation": { + "test": { + "source": "sensor.uncompensated", + "data_points": [ + [1.0, 2.0], + [2.0, 3.0], + ], + "precision": 2, + "unit_of_measurement": "a", + } + } + } + expected_entity_id = "sensor.compensation_sensor_uncompensated" + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entity_id = config[DOMAIN]["test"]["source"] + hass.states.async_set(entity_id, 4, {}) + await hass.async_block_till_done() + + state = hass.states.get(expected_entity_id) + assert state is not None + + assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 5.0 + + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "a" + + coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] + assert coefs == [1.0, 1.0] + + hass.states.async_set(entity_id, "foo", {}) + await hass.async_block_till_done() + + state = hass.states.get(expected_entity_id) + assert state is not None + + assert state.state == STATE_UNKNOWN + + +async def test_linear_state_from_attribute(hass): + """Test compensation sensor state that pulls from attribute.""" + config = { + "compensation": { + "test": { + "source": "sensor.uncompensated", + "attribute": "value", + "data_points": [ + [1.0, 2.0], + [2.0, 3.0], + ], + "precision": 2, + } + } + } + expected_entity_id = "sensor.compensation_sensor_uncompensated_value" + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + entity_id = config[DOMAIN]["test"]["source"] + hass.states.async_set(entity_id, 3, {"value": 4}) + await hass.async_block_till_done() + + state = hass.states.get(expected_entity_id) + assert state is not None + + assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 5.0 + + coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] + assert coefs == [1.0, 1.0] + + hass.states.async_set(entity_id, 3, {"value": "bar"}) + await hass.async_block_till_done() + + state = hass.states.get(expected_entity_id) + assert state is not None + + assert state.state == STATE_UNKNOWN + + +async def test_quadratic_state(hass): + """Test 3 degree polynominial compensation sensor.""" + config = { + "compensation": { + "test": { + "source": "sensor.temperature", + "data_points": [ + [50, 3.3], + [50, 2.8], + [50, 2.9], + [70, 2.3], + [70, 2.6], + [70, 2.1], + [80, 2.5], + [80, 2.9], + [80, 2.4], + [90, 3.0], + [90, 3.1], + [90, 2.8], + [100, 3.3], + [100, 3.5], + [100, 3.0], + ], + "degree": 2, + "precision": 3, + } + } + } + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + entity_id = config[DOMAIN]["test"]["source"] + hass.states.async_set(entity_id, 43.2, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.compensation_sensor_temperature") + + assert state is not None + + assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 3.327 + + +async def test_numpy_errors(hass, caplog): + """Tests bad polyfits.""" + config = { + "compensation": { + "test": { + "source": "sensor.uncompensated", + "data_points": [ + [1.0, 1.0], + [1.0, 1.0], + ], + }, + "test2": { + "source": "sensor.uncompensated2", + "data_points": [ + [0.0, 1.0], + [0.0, 1.0], + ], + }, + } + } + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert "polyfit may be poorly conditioned" in caplog.text + + assert "invalid value encountered in true_divide" in caplog.text + + +async def test_datapoints_greater_than_degree(hass, caplog): + """Tests 3 bad data points.""" + config = { + "compensation": { + "test": { + "source": "sensor.uncompensated", + "data_points": [ + [1.0, 2.0], + [2.0, 3.0], + ], + "degree": 2, + }, + } + } + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert "data_points must have at least 3 data_points" in caplog.text + + +async def test_new_state_is_none(hass): + """Tests catch for empty new states.""" + config = { + "compensation": { + "test": { + "source": "sensor.uncompensated", + "data_points": [ + [1.0, 2.0], + [2.0, 3.0], + ], + "precision": 2, + "unit_of_measurement": "a", + } + } + } + expected_entity_id = "sensor.compensation_sensor_uncompensated" + + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + last_changed = hass.states.get(expected_entity_id).last_changed + + hass.bus.async_fire( + EVENT_STATE_CHANGED, event_data={"entity_id": "sensor.uncompensated"} + ) + + assert last_changed == hass.states.get(expected_entity_id).last_changed