Skip to content

Filter Sensor#12650

Merged
balloob merged 22 commits intohome-assistant:devfrom
dgomes:filter_sensor
Mar 1, 2018
Merged

Filter Sensor#12650
balloob merged 22 commits intohome-assistant:devfrom
dgomes:filter_sensor

Conversation

@dgomes
Copy link
Copy Markdown
Contributor

@dgomes dgomes commented Feb 25, 2018

Description:

Introduces the Filter Sensor platform (This PR comes in the aftermath of #12506)

This is a full rewrite using the Template Sensor pattern, and the configuration suggestion of @OttoWinter (thus supporting chaining).

Related issue (if applicable): fixes #12506

Pull request in home-assistant.github.io with documentation (if applicable): home-assistant/home-assistant.io#4725

Example entry for configuration.yaml (if applicable):

  - platform: filter
    name: "filtered realistic humidity"
    entity_id: sensor.realistic_humidity
    filters:
      - filter: outlier
        window_size: 4
        radius: 4.0
      - filter: lowpass
        time_constant: 10
        precision: 2

Checklist:

  • The code change is tested and works locally.

If user exposed functionality or configuration variables are added/changed:

If the code does not interact with devices:

  • Local tests with tox run successfully. Your PR cannot be merged unless tests pass
  • Tests have been added to verify that the new code works.

@robmarkcole
Copy link
Copy Markdown
Contributor

Looking great!
image

Couple of comments:

  1. Are the outlier warnings useful? Can I use them to fine tune my filter (appears the radius variable could be tweaked)? If not, perhaps they shouldn't be logged as this will definitely result in forum threads titled 'filter spamming my logs'. Perhaps an attribute with the % of outliers (in addition to erasures) would be instructive.
  2. self.states is a deque of previous sensor values, these aren't timestamped, but would it impact the functionality to capture the timestamp (e.g. using a dict)? Time info may be useful for other filters. This is a 'nice to have'.

Cheers

})

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be wrong interpreted from user. Maybe 'entity', 'state_from' is better?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be for entity: since the plural form of that (entities:) is already used for group: and the upcoming group platforms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used entity_id (singular) since that is what is used in template_sensor, min_max, history_stats (and I guess couple of other...)

So we either change all the others, or this is the common interpretation by users already...

@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Feb 25, 2018

@robmarkcole can also move outliers into debug()

erasures -> numbers of outliers removed

@robmarkcole
Copy link
Copy Markdown
Contributor

@dgomes numbers of outliers removed is just a number so not as helpful as a % IMO?

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall.

I haven't looked at the tests.

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ATTR_* constants should not be used in config schemas, but in service schemas. All constants in config schemas that represent keys should start with CONF_.

vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
vol.Required(CONF_FILTERS): [vol.Any(FILTER_OUTLIER_SCHEMA,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add cv.ensure_list.



@asyncio.coroutine
# pylint: disable=unused-argument
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already globally disabled.

entity_id = config.get(CONF_ENTITY_ID)
filters = []

for _filter in config.get(CONF_FILTERS):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use dict.get on required config keys. Just do dict[key].

window_size = _filter.get(CONF_FILTER_WINDOW_SIZE)
precision = _filter.get(CONF_FILTER_PRECISION)

if _filter['filter'] == FILTER_NAME_OUTLIER:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use CONF_FILTER_NAME.

self._state = filtered_state
filt.states.append(filtered_state)
except ValueError:
_LOGGER.warning("Could not convert state to number")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please log what state failed the conversion.

"""

def __init__(self, name, window_size=1, precision=None):
"""Initialize common properties."""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's initializing attributes.

"""Initialize common properties."""
self.states = deque(maxlen=window_size)
self.precision = precision
self._stats = dict()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use {}.

class OutlierFilter(Filter):
"""BASIC outlier filter.

Determines if new state is in a band around the median
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add period at the end.

}
for filt in self._filters:
state_attr.update({
slugify("{} stats".format(filt.name)): filt.stats
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will the nested dict that we create for state attributes look in the frontend?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this information is for fine tuning the filter options only. Should not be presented in the frontend.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State attributes are shown in a table under the history graph in the more info card of sensors.

Copy link
Copy Markdown
Contributor Author

@dgomes dgomes Feb 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only alternative I see is to slugify the nested dict():

outlier_erasures: 123
lowpass_median: 50

comments ?

Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare Feb 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably easiest to flatten the dicts into one dict, by concatenating + slugify the key names for the inner dict with the holding key name. I think that's what you mean too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes :)

@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Feb 25, 2018

@robmarkcole you are right, changed! :)

Comment thread tests/components/sensor/test_filter.py Outdated
with assert_setup_component(0):
assert setup_component(self.hass, 'sensor', config)

self.hass.start()
Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare Feb 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need to start home assistant in any of the tests.

Comment thread tests/components/sensor/test_filter.py Outdated
assert setup_component(self.hass, 'sensor', config)

self.hass.start()
self.hass.block_till_done()
Copy link
Copy Markdown
Member

@MartinHjelmare MartinHjelmare Feb 25, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since nothing is tested after this, this is not needed.

DEFAULT_FILTER_TIME_CONSTANT = 10

NAME_TEMPLATE = "{} filter"
ICON = 'mdi: chart-line-variant'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the space belongs here (between mdi: and chart-[...]) :)

Also: Wouldn't it be better if we copied the icon from the base entity? I mean for me, the use case for this platform would be to improve some shitty raw sensor values that deviate way too much. For example, for the following sensor I'd like to have some smoothing. It would therefore be nice not to have to manually set the icon for every single filter platform.

screen shot 2018-02-25 at 21 20 18

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, will get the icon from the "parent"


sensors.append(SensorFilter(name, entity_id, filters))

async_add_devices(sensors)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a style suggestion: Why not just async_add_devices([SensorFilter(name, entity_id, filters)])?

Comment thread tests/components/sensor/test_filter.py Outdated
with assert_setup_component(1):
assert setup_component(self.hass, 'sensor', config)

self.hass.start()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as Martin's comment: you don't need to call hass.start()

Comment thread tests/components/sensor/test_filter.py Outdated
with assert_setup_component(1):
assert setup_component(self.hass, 'sensor', config)

self.hass.start()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

@OttoWinter
Copy link
Copy Markdown
Member

Sorry for bothering you this much :) Just another quick suggestion/use-case: I think another brilliant use of this sensor platform would be to have a filter that only emits a new state on every xth source state.

For example I have a simple temperature sensor outside that tracks the outside temperature, but it emits waaay too many values and makes the showing of the graph in the front-end really slow. If I understand correctly the current design for filters expects a new value to be spit out by the filter for every new state it receives (short off raising ValueError, but that spams the logs).

screen shot 2018-02-25 at 21 58 20

Maybe a bit out of scope for this PR, but it would be good to have a way for a filter to signal that it doesn't want to emit a new state. What do you think?

@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Feb 25, 2018

Sounds simple enough, a SampleFilter, but let me get this PR in first 😁

BTW: several other filters are possible and easy to add, especially if we introduce scipy as a dependency :)

else:
self._skip = True

return new_state
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trailing whitespace

elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE:
filters.append(ThrottleFilter(window_size=window_size,
precision=precision,
entity=entity_id))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continuation line under-indented for visual indent

time_constant=time_constant))
elif _filter[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE:
filters.append(ThrottleFilter(window_size=window_size,
precision=precision,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continuation line under-indented for visual indent

@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Feb 25, 2018

Added extra Throttle Filter (very simple, but required some foundation work)

@OttoWinter figured I could use the filter in my setup as well, so here goes an extra filter ;)

      - filter: throttle
        window_size: 10

It throttles to 1 sample per window, so you only need to define the window_size.

@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Feb 27, 2018

I reverted... I just want the basic filter code through :)

@dgomes dgomes closed this Feb 28, 2018
@dgomes dgomes reopened this Feb 28, 2018
filt_stat = "{}_{}".format(filt.name, filt_stat_key)
_LOGGER.debug("stats(%s): %s: %s", self._entity,
filt_stat, filt_stat_value)
state_attr.update({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so expensive and inefficient. Create a dict with 1 value to then pass to the update method after which it is discarded? 🤔 You can do state_atr[filt_stat] = filt_stat_value but really, just go for the define it at once declaration I suggested earlier or explain to me why it is so important to log every attribute individually?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@robmarkcole and @OttoWinter in the previous PR made a case for having stats of the filters.

Personally I think logging is enough to tune the filters, logging can even be done in the filter itself saving the dict.

If I don't get feedback from you guys, I'm taking the stats off from this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I say leave the stats of for now, since I coudn't even say what stats I'd request. Once I've been using the component a while then will be in a position to do some analysis and make a suggestion, cheers

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should do stat reporting through attributes (if at all). I personally hate it when entities put completely unnecessary stuff in their attributes that just clutter the front end (and state machine)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then it's settled! :) uff ... one less feature to creep this PR

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Databases all over the world are cheering 😉

@callback
def filter_sensor_state_listener(entity, old_state, new_state):
"""Handle device state changes."""
self._unit_of_measurement = new_state.attributes.get(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this one fall back to the old unit if there is none in the current state? (just like icon)

Copy link
Copy Markdown
Contributor Author

@dgomes dgomes Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filters have no units... they are pure math :)

while pure math might have an icon, makes no sense to have a default unit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With old unit I meant the unit of measurement of the previous state that was processed. For history it's important that all states of an entity have the same unit.

What happens now is that a state will have unit set to None if the source state becomes unavailable/unknown. We should probably move setting this instance variable when we know we're going to adopt a filtered version of the state.

if filt.skip_processing:
return
self._state = filtered_state
except ValueError:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the conversion happening? Also, wouldn't it make more sense to try convert it once, before running the filters?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conversion happens inside filters that process numbers, moving the try catch there would replicate the same code several times...

_LOGGER.error("Could not convert state: %s to number",
self._state)
self._state = STATE_UNAVAILABLE
return
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we still update the state when this happens? (and thus not return)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#12650 (comment)

" just make sure that nothing is updated."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're not going to change anything, you don't have to assign self._state either.

if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
return

self._state = new_state.state
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this a very weird assignment as it is never used. I would prefer a local variable that is assigned to self._state at the end. That way if something unexpected blows up, you are not left with a temp variable as state.

Copy link
Copy Markdown
Contributor Author

@dgomes dgomes Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its not temp, its the state of the previous filter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new_state.state is the state that triggered the state change. It has not been filtered yet. Since we have at least 1 filter, this assignment will never last when we update Home Assistant.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect only to write values to self._state that you are intending to publish to Home Assistant. Class instance variables should not be used for temporary variables inside the scope.

window_size = _filter.get(CONF_FILTER_WINDOW_SIZE)
precision = _filter.get(CONF_FILTER_PRECISION)

if _filter[CONF_FILTER_NAME] == FILTER_NAME_OUTLIER:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do this easier. If you use the Registry decorator, you would have all classes by type in the registry. Then the config is already valid from the schema and maps to the keywords.

FILTERS = Registry()

@FILTERS.register(FILTER_NAME_LOWPASS)
class LowPassFilter(Filter):
    …

for filter_args in filters:
    filter_args = filter_args.copy()
    filter_args['entity'] = entity_id
    filter_class = FILTERS[filter_args.pop(CONF_FILTER_NAME)]
    filters.append(filter_class(**filter_args))

Copy link
Copy Markdown
Member

@balloob balloob left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉 🎉

Thank you for working through all my comments 🥇

@balloob balloob merged commit de3c769 into home-assistant:dev Mar 1, 2018
@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Mar 1, 2018

🎊 Was starting to loose hope!

@B1tMaster
Copy link
Copy Markdown
Contributor

B1tMaster commented Mar 5, 2018

This is awesome. Really need filtering.. Will be great for Weather related sensors with Wind , temp etc..
currently charts from my Weather station looks really bad and wind gusts and direction shifts make data unusable.

Is it possible to add ability to exclude certain values (like ZERO ) from time series during calculations.
For example, if a sensor often dropping to zero and those are not really valid numbers that we want to be included in the calculations of the filter, would be great to have a flag to say: ignore all zeros.. or maybe values below certain threshold , or values that evaluate true with certain boolean condition.

@dgomes
Copy link
Copy Markdown
Contributor Author

dgomes commented Mar 5, 2018

@B1tMaster this PR is already closed :)

I think your use cases are already covered by the basic filters, but feel free to add new ones ;)

@home-assistant home-assistant locked as resolved and limited conversation to collaborators Mar 5, 2018
@dgomes dgomes deleted the filter_sensor branch March 18, 2018 19:41
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants