Add Compensation Integration (#41675)

* Add Compensation Integration

Adds the Compensation Integration

* Add Requirements

add missing requirements to compensation integration

* Fix for tests

Fix files after tests

* Fix isort

ran isort

* Handle ADR-0007

Change the configuration to deal with ADR-0007

* fix flake8

Fix flake8

* Added Error Trapping

Catch errors.
Raise Rank Warnings but continue.
Fixed bad imports

* fix flake8 & pylint

* fix isort.... again

* fix tests & comments

fix tests and comments

* fix flake8

* remove discovery message

* Fixed Review changes

* Fixed review requests.
* Added test to test get more coverage.

* Roll back numpy requirement

Roll back numpy requirement to match other integrations.

* Fix flake8

* Fix requested changes

Removed some necessary comments.
Changed a test case to be more readable.

* Fix doc strings and continue

* Fixed a few test case doc strings
* Removed a continue/else

* Remove periods from logger

Removed periods from _LOGGER errors.

* Fixes

changed name to unqiue_id.
implemented suggested changes.

* Add name and fix unique_id

* removed conf name and auto construct it
This commit is contained in:
Petro31 2021-04-03 16:42:09 -04:00 committed by GitHub
parent 23fae255ff
commit 545fe7a7be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 537 additions and 0 deletions

View file

@ -89,6 +89,7 @@ homeassistant/components/cloud/* @home-assistant/cloud
homeassistant/components/cloudflare/* @ludeeus @ctalkington homeassistant/components/cloudflare/* @ludeeus @ctalkington
homeassistant/components/color_extractor/* @GenericStudent homeassistant/components/color_extractor/* @GenericStudent
homeassistant/components/comfoconnect/* @michaelarnauts homeassistant/components/comfoconnect/* @michaelarnauts
homeassistant/components/compensation/* @Petro31
homeassistant/components/config/* @home-assistant/core homeassistant/components/config/* @home-assistant/core
homeassistant/components/configurator/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core
homeassistant/components/control4/* @lawtancool homeassistant/components/control4/* @lawtancool

View file

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

View file

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

View file

@ -0,0 +1,7 @@
{
"domain": "compensation",
"name": "Compensation",
"documentation": "https://www.home-assistant.io/integrations/compensation",
"requirements": ["numpy==1.20.2"],
"codeowners": ["@Petro31"]
}

View file

@ -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()

View file

@ -1011,6 +1011,7 @@ nuheat==0.3.0
# homeassistant.components.numato # homeassistant.components.numato
numato-gpio==0.10.0 numato-gpio==0.10.0
# homeassistant.components.compensation
# homeassistant.components.iqvia # homeassistant.components.iqvia
# homeassistant.components.opencv # homeassistant.components.opencv
# homeassistant.components.tensorflow # homeassistant.components.tensorflow

View file

@ -529,6 +529,7 @@ nuheat==0.3.0
# homeassistant.components.numato # homeassistant.components.numato
numato-gpio==0.10.0 numato-gpio==0.10.0
# homeassistant.components.compensation
# homeassistant.components.iqvia # homeassistant.components.iqvia
# homeassistant.components.opencv # homeassistant.components.opencv
# homeassistant.components.tensorflow # homeassistant.components.tensorflow

View file

@ -0,0 +1 @@
"""Tests for the compensation component."""

View file

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