Skip to content

Commit 29d573e

Browse files
Filter low outliers on battery change (#3082)
* WIP * Add outlier filter and config flow * MyPy * Work on outlier filtering * Fix battery low state for outliers * Remove unnecessary log * Tidy up unused code * Lint * Outlier defaults * Wording * Change to 80% outlier * Only filter outliers if lower * Rename outlier * Fix low outlier filter * Return earlier * Work on increased outlier
1 parent c31b02a commit 29d573e

File tree

6 files changed

+192
-6
lines changed

6 files changed

+192
-6
lines changed

custom_components/battery_notes/config_flow.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
CONF_BATTERY_QUANTITY,
3434
CONF_BATTERY_TYPE,
3535
CONF_DEVICE_NAME,
36+
CONF_FILTER_OUTLIERS,
3637
CONF_MANUFACTURER,
3738
CONF_MODEL,
3839
CONF_MODEL_ID,
@@ -356,6 +357,7 @@ async def async_step_battery(
356357
self.data[CONF_BATTERY_LOW_TEMPLATE] = user_input.get(
357358
CONF_BATTERY_LOW_TEMPLATE, None
358359
)
360+
self.data[CONF_FILTER_OUTLIERS] = user_input.get(CONF_FILTER_OUTLIERS, False)
359361

360362
source_entity_id = self.data.get(CONF_SOURCE_ENTITY_ID, None)
361363
device_id = self.data.get(CONF_DEVICE_ID, None)
@@ -433,6 +435,9 @@ async def async_step_battery(
433435
vol.Optional(
434436
CONF_BATTERY_LOW_TEMPLATE
435437
): selector.TemplateSelector(),
438+
vol.Optional(
439+
CONF_FILTER_OUTLIERS,
440+
default=False): selector.BooleanSelector(),
436441
}
437442
),
438443
errors=errors,
@@ -452,6 +457,7 @@ def __init__(self) -> None:
452457
self.battery_type: str
453458
self.battery_quantity: int
454459
self.battery_low_template: str
460+
self.filter_outliers: bool
455461

456462
async def async_step_init(
457463
self,
@@ -467,6 +473,9 @@ async def async_step_init(
467473
self.battery_low_template = str(
468474
self.current_config.get(CONF_BATTERY_LOW_TEMPLATE) or ""
469475
)
476+
self.filter_outliers = bool(
477+
self.current_config.get(CONF_FILTER_OUTLIERS) or False
478+
)
470479

471480
if self.source_device_id:
472481
device_registry = dr.async_get(self.hass)
@@ -595,6 +604,7 @@ def build_options_schema(self) -> vol.Schema:
595604
),
596605
),
597606
vol.Optional(CONF_BATTERY_LOW_TEMPLATE): selector.TemplateSelector(),
607+
vol.Optional(CONF_FILTER_OUTLIERS): selector.BooleanSelector(),
598608
}
599609
)
600610

custom_components/battery_notes/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
CONF_HIDE_BATTERY = "hide_battery"
5252
CONF_ROUND_BATTERY = "round_battery"
5353
CONF_BATTERY_LOW_TEMPLATE = "battery_low_template"
54+
CONF_FILTER_OUTLIERS = "filter_outliers"
5455

5556
DATA_CONFIGURED_ENTITIES = "configured_entities"
5657
DATA_DISCOVERED_ENTITIES = "discovered_entities"
@@ -91,6 +92,9 @@
9192
ATTR_PREVIOUS_BATTERY_LEVEL = "previous_battery_level"
9293
ATTR_BATTERY_THRESHOLD_REMINDER = "reminder"
9394

95+
WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1
96+
WINDOW_SIZE_UNIT_TIME = 2
97+
9498
SERVICE_BATTERY_REPLACED_SCHEMA = vol.Schema(
9599
{
96100
vol.Optional(ATTR_DEVICE_ID): cv.string,

custom_components/battery_notes/coordinator.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
LAST_REPORTED,
4242
LAST_REPORTED_LEVEL,
4343
)
44+
from .filters import LowOutlierFilter
4445
from .store import BatteryNotesStorage
4546

4647
_LOGGER = logging.getLogger(__name__)
@@ -68,13 +69,15 @@ class BatteryNotesCoordinator(DataUpdateCoordinator[None]):
6869
_battery_low_binary_state: bool = False
6970
_previous_battery_low_binary_state: bool | None = None
7071
_source_entity_name: str | None = None
72+
_outlier_filter: LowOutlierFilter | None = None
7173

7274
def __init__(
7375
self,
7476
hass,
7577
store: BatteryNotesStorage,
7678
wrapped_battery: RegistryEntry | None,
7779
wrapped_battery_low: RegistryEntry | None,
80+
filter_outliers: bool,
7881
):
7982
"""Initialize."""
8083
self.store = store
@@ -88,6 +91,10 @@ def __init__(
8891

8992
super().__init__(hass, _LOGGER, name=DOMAIN)
9093

94+
if filter_outliers:
95+
self._outlier_filter = LowOutlierFilter(window_size=3, radius=80)
96+
_LOGGER.debug("Outlier filter enabled")
97+
9198
@property
9299
def source_entity_name(self):
93100
"""Get the current name of the source_entity_id."""
@@ -222,8 +229,21 @@ def current_battery_level(self):
222229
@current_battery_level.setter
223230
def current_battery_level(self, value):
224231
"""Set the current battery level and fire events if valid."""
225-
self._current_battery_level = value
226232

233+
if self._outlier_filter:
234+
if value not in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
235+
self._outlier_filter.filter_state(float(value))
236+
237+
_LOGGER.debug(
238+
"Checking outlier (%s=%s) -> %s",
239+
self.device_id or self.source_entity_id or "",
240+
value,
241+
"skip" if self._outlier_filter.skip_processing else self._outlier_filter.filter_state(value),
242+
)
243+
if self._outlier_filter.skip_processing:
244+
return
245+
246+
self._current_battery_level = value
227247
if (
228248
self._previous_battery_level is not None
229249
and self.battery_low_template is None

custom_components/battery_notes/device.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
CONF_BATTERY_QUANTITY,
2626
CONF_BATTERY_TYPE,
2727
CONF_DEFAULT_BATTERY_LOW_THRESHOLD,
28+
CONF_FILTER_OUTLIERS,
2829
CONF_SOURCE_ENTITY_ID,
2930
DATA,
3031
DATA_STORE,
@@ -214,7 +215,8 @@ async def async_setup(self) -> bool:
214215

215216
self.store = self.hass.data[DOMAIN][DATA_STORE]
216217
self.coordinator = BatteryNotesCoordinator(
217-
self.hass, self.store, self.wrapped_battery, self.wrapped_battery_low
218+
self.hass, self.store, self.wrapped_battery, self.wrapped_battery_low,
219+
cast(bool, self.config.data.get(CONF_FILTER_OUTLIERS, False))
218220
)
219221

220222
assert(self.device_name)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Filters for battery_notes."""
2+
3+
import logging
4+
import statistics
5+
from collections import Counter, deque
6+
from datetime import datetime, timedelta
7+
from numbers import Number
8+
from typing import cast
9+
10+
from .const import WINDOW_SIZE_UNIT_NUMBER_EVENTS, WINDOW_SIZE_UNIT_TIME
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
15+
class FilterState:
16+
"""State abstraction for filter usage."""
17+
18+
state: str | float | int
19+
20+
def __init__(self, state: str | float | int) -> None:
21+
"""Initialize with HA State object."""
22+
self.timestamp = datetime.utcnow()
23+
try:
24+
self.state = float(state)
25+
except ValueError:
26+
self.state = state
27+
28+
def __str__(self) -> str:
29+
"""Return state as the string representation of FilterState."""
30+
return str(self.state)
31+
32+
def __repr__(self) -> str:
33+
"""Return timestamp and state as the representation of FilterState."""
34+
return f"{self.timestamp} : {self.state}"
35+
36+
37+
class Filter():
38+
"""Base filter class."""
39+
40+
def __init__(
41+
self,
42+
window_size: int | timedelta,
43+
) -> None:
44+
"""Initialize common attributes.
45+
46+
:param window_size: size of the sliding window that holds previous values
47+
:param entity: used for debugging only
48+
"""
49+
if isinstance(window_size, int):
50+
self.states: deque[FilterState] = deque(maxlen=window_size)
51+
self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS
52+
else:
53+
self.states = deque(maxlen=0)
54+
self.window_unit = WINDOW_SIZE_UNIT_TIME
55+
self._skip_processing = False
56+
self._window_size = window_size
57+
self._store_raw = False
58+
self._only_numbers = True
59+
60+
@property
61+
def window_size(self) -> int | timedelta:
62+
"""Return window size."""
63+
return self._window_size
64+
65+
@property
66+
def skip_processing(self) -> bool:
67+
"""Return whether the current filter_state should be skipped."""
68+
return self._skip_processing
69+
70+
def reset(self) -> None:
71+
"""Reset filter."""
72+
self.states.clear()
73+
74+
def _filter_state(self, new_state: FilterState) -> FilterState:
75+
"""Implement filter."""
76+
raise NotImplementedError
77+
78+
def filter_state(self, new_state: int | float | str) -> int | float | str:
79+
"""Implement a common interface for filters."""
80+
fstate = FilterState(new_state)
81+
if not isinstance(fstate.state, Number):
82+
raise ValueError(f"State <{fstate.state}> is not a Number")
83+
84+
filtered = self._filter_state(fstate)
85+
86+
if self._store_raw:
87+
self.states.append(FilterState(new_state))
88+
else:
89+
self.states.append(filtered)
90+
new_state = filtered.state
91+
return new_state
92+
93+
class LowOutlierFilter(Filter):
94+
"""Low Outlier filter.
95+
96+
Determines if new state is in a band around the median, or higher.
97+
"""
98+
99+
def __init__(
100+
self,
101+
window_size: int,
102+
radius: float,
103+
) -> None:
104+
"""Initialize Filter.
105+
106+
:param radius: band radius
107+
"""
108+
super().__init__(
109+
window_size
110+
)
111+
self._radius = radius
112+
self._stats_internal: Counter = Counter()
113+
self._store_raw = True
114+
115+
def _filter_state(self, new_state: FilterState) -> FilterState:
116+
"""Implement the outlier filter."""
117+
118+
previous_state_values = [cast(float, s.state) for s in self.states]
119+
new_state_value = cast(float, new_state.state)
120+
self._skip_processing = False
121+
122+
if previous_state_values and new_state_value >= previous_state_values[-1]:
123+
_LOGGER.debug(
124+
"New value higher than last previous state, allowing. %s >= %s",
125+
new_state,
126+
previous_state_values[-1]
127+
)
128+
return new_state
129+
130+
median = statistics.median(previous_state_values) if self.states else 0
131+
132+
if (
133+
len(self.states) == self.states.maxlen
134+
and abs(new_state_value - median) > self._radius
135+
):
136+
self._skip_processing = True
137+
138+
if len(self.states) == self.states.maxlen:
139+
self._stats_internal["erasures"] += 1
140+
_LOGGER.debug(
141+
"Outlier nr. %s: %s",
142+
self._stats_internal["erasures"],
143+
new_state,
144+
)
145+
146+
return new_state

custom_components/battery_notes/translations/en.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@
3636
"battery_type": "Battery type",
3737
"battery_quantity": "Battery quantity",
3838
"battery_low_threshold": "Battery low threshold",
39-
"battery_low_template": "Battery low template"
39+
"battery_low_template": "Battery low template",
40+
"filter_outliers": "Filter outliers"
4041
},
4142
"data_description": {
4243
"battery_low_threshold": "0 will use the global default threshold",
43-
"battery_low_template": "Template to determine a battery is low, should return true if low\nOnly needed for non-standard battery levels"
44+
"battery_low_template": "Template to determine a battery is low, should return true if low\nOnly needed for non-standard battery levels",
45+
"filter_outliers": "Filter out large battery level changes, reducing falsely firing events on devices that erronously report levels occasionally"
4446
}
4547
},
4648
"manual": {
@@ -65,12 +67,14 @@
6567
"battery_type": "Battery type",
6668
"battery_quantity": "Battery quantity",
6769
"battery_low_threshold": "Battery low threshold",
68-
"battery_low_template": "Battery low template"
70+
"battery_low_template": "Battery low template",
71+
"filter_outliers": "Filter outliers"
6972
},
7073
"data_description": {
7174
"name": "Leaving blank will take the name from the source device",
7275
"battery_low_threshold": "0 will use the global default threshold",
73-
"battery_low_template": "Template to determine a battery is low, should return true if low\nOnly needed for non-standard battery levels"
76+
"battery_low_template": "Template to determine a battery is low, should return true if low\nOnly needed for non-standard battery levels",
77+
"filter_outliers": "Filter out large battery level changes, reducing falsely firing events on devices that erronously report levels occasionally"
7478
}
7579
}
7680
},

0 commit comments

Comments
 (0)