Skip to content

Commit e1a90a4

Browse files
authored
Merge pull request #3620 from bramstroker/feat/outlier-filter
Implement outlier filter for energy sensors
2 parents 6d7010a + 5955d3a commit e1a90a4

32 files changed

+618
-20
lines changed

custom_components/powercalc/config_flow.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
)
6868
from .flow_helper.schema import (
6969
SCHEMA_ENERGY_SENSOR_TOGGLE,
70+
SCHEMA_SENSOR_ENERGY_OPTIONS,
7071
SCHEMA_UTILITY_METER_OPTIONS,
7172
SCHEMA_UTILITY_METER_TOGGLE,
7273
)
@@ -300,6 +301,17 @@ async def async_step_utility_meter_options(self, user_input: dict[str, Any] | No
300301
user_input,
301302
)
302303

304+
async def async_step_energy_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
305+
"""Handle the flow for utility meter options."""
306+
return await self.handle_form_step(
307+
PowercalcFormStep(
308+
step=Step.ENERGY_OPTIONS,
309+
schema=SCHEMA_SENSOR_ENERGY_OPTIONS,
310+
continue_utility_meter_options_step=not self.is_options_flow,
311+
),
312+
user_input,
313+
)
314+
303315

304316
class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
305317
"""Handle a config flow for PowerCalc."""
@@ -486,7 +498,7 @@ def build_menu(self) -> list[Step]:
486498
if self.selected_sensor_type == SensorType.DAILY_ENERGY:
487499
menu.append(Step.DAILY_ENERGY)
488500
if self.selected_sensor_type == SensorType.REAL_POWER:
489-
menu.append(Step.REAL_POWER)
501+
menu.extend([Step.REAL_POWER, Step.ENERGY_OPTIONS])
490502
if self.selected_sensor_type == SensorType.GROUP:
491503
menu.extend(self.flow_handlers[FlowType.GROUP].build_group_menu())
492504

custom_components/powercalc/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272
CONF_ENERGY_SENSOR_PRECISION = "energy_sensor_precision"
7373
CONF_ENERGY_SENSOR_UNIT_PREFIX = "energy_sensor_unit_prefix"
7474
CONF_ENERGY_UPDATE_INTERVAL = "energy_update_interval"
75+
CONF_ENERGY_FILTER_OUTLIER_ENABLED = "energy_filter_outlier_enabled"
76+
CONF_ENERGY_FILTER_OUTLIER_MAX = "energy_filter_outlier_max_step"
7577
CONF_EXCLUDE_ENTITIES = "exclude_entities"
7678
CONF_FILTER = "filter"
7779
CONF_FIXED = "fixed"

custom_components/powercalc/filter/__init__.py

Whitespace-only changes.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from collections import deque
4+
import statistics
5+
6+
7+
class OutlierFilter:
8+
"""Simple rolling-window outlier filter using median + MAD.
9+
10+
- Warm-up: accepts the first `min_samples` values unconditionally.
11+
- After that: rejects values whose modified Z-score > `max_z_score`.
12+
"""
13+
14+
def __init__(
15+
self,
16+
window_size: int = 30,
17+
min_samples: int = 10,
18+
max_z_score: float = 5.0,
19+
max_expected_step: int = 1000,
20+
) -> None:
21+
self._window_size = window_size
22+
self._min_samples = min_samples
23+
self._max_z_score = max_z_score
24+
self._values: deque[float] = deque(maxlen=window_size)
25+
self._max_expected_step = max_expected_step
26+
27+
@property
28+
def values(self) -> list[float]:
29+
return list(self._values)
30+
31+
def _is_outlier(self, value: float) -> bool:
32+
"""Return True if value is considered an outlier."""
33+
if len(self._values) < self._min_samples:
34+
return False
35+
36+
median = statistics.median(self._values)
37+
38+
# 1) Always allow downward transitions (light turning OFF)
39+
if value <= median:
40+
return False
41+
42+
# 2) Allow reasonable upward transitions (light turning ON)
43+
# e.g. below 200 W difference - always accept
44+
if value - median < self._max_expected_step:
45+
return False
46+
47+
# 3) For larger jumps, use proper outlier detection (MAD)
48+
abs_devs = [abs(x - median) for x in self._values]
49+
mad = statistics.median(abs_devs) or 0
50+
51+
if mad == 0:
52+
return False # pragma: no cover
53+
54+
z = 0.6745 * (value - median) / mad
55+
return abs(z) > self._max_z_score
56+
57+
def accept(self, value: float) -> bool:
58+
"""Return True if value should be accepted (not an outlier).
59+
60+
Also updates the internal window if the value is accepted.
61+
"""
62+
if self._is_outlier(value):
63+
return False
64+
65+
self._values.append(value)
66+
return True

custom_components/powercalc/flow_helper/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Step(StrEnum):
4040
USER = "user"
4141
SMART_SWITCH = "smart_switch"
4242
INIT = "init"
43+
ENERGY_OPTIONS = "energy_options"
4344
UTILITY_METER_OPTIONS = "utility_meter_options"
4445
GLOBAL_CONFIGURATION = "global_configuration"
4546
GLOBAL_CONFIGURATION_DISCOVERY = "global_configuration_discovery"

custom_components/powercalc/flow_helper/flows/real_power.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async def async_step_real_power(self, user_input: dict[str, Any] | None = None)
4747
PowercalcFormStep(
4848
step=Step.REAL_POWER,
4949
schema=SCHEMA_REAL_POWER,
50-
continue_utility_meter_options_step=True,
50+
next_step=Step.ENERGY_OPTIONS,
5151
),
5252
user_input,
5353
)

custom_components/powercalc/flow_helper/flows/virtual_power.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@
4040
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
4141
from custom_components.powercalc.flow_helper.flows.global_configuration import get_global_powercalc_config
4242
from custom_components.powercalc.flow_helper.flows.library import SCHEMA_POWER_OPTIONS_LIBRARY, SCHEMA_POWER_SMART_SWITCH
43-
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_OPTIONS, SCHEMA_ENERGY_SENSOR_TOGGLE, SCHEMA_UTILITY_METER_TOGGLE
43+
from custom_components.powercalc.flow_helper.schema import (
44+
SCHEMA_ENERGY_SENSOR_TOGGLE,
45+
SCHEMA_SENSOR_ENERGY_OPTIONS,
46+
SCHEMA_UTILITY_METER_TOGGLE,
47+
)
4448
from custom_components.powercalc.power_profile.power_profile import DeviceType
4549
from custom_components.powercalc.strategy.wled import CONFIG_SCHEMA as SCHEMA_POWER_WLED
4650

@@ -243,7 +247,7 @@ async def async_step_power_advanced(self, user_input: dict[str, Any] | None = No
243247

244248
schema = SCHEMA_POWER_ADVANCED
245249
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR):
246-
schema = schema.extend(SCHEMA_ENERGY_OPTIONS.schema)
250+
schema = schema.extend(SCHEMA_SENSOR_ENERGY_OPTIONS.schema)
247251

248252
return await self.flow.handle_form_step(
249253
PowercalcFormStep(

custom_components/powercalc/flow_helper/schema.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from custom_components.powercalc.const import (
66
CONF_CREATE_ENERGY_SENSOR,
77
CONF_CREATE_UTILITY_METERS,
8+
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
9+
CONF_ENERGY_FILTER_OUTLIER_MAX,
810
CONF_ENERGY_INTEGRATION_METHOD,
911
CONF_ENERGY_SENSOR_UNIT_PREFIX,
1012
CONF_SUB_PROFILE,
@@ -55,6 +57,15 @@
5557
},
5658
)
5759

60+
SCHEMA_SENSOR_ENERGY_OPTIONS = SCHEMA_ENERGY_OPTIONS.extend(
61+
vol.Schema(
62+
{
63+
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED, default=False): selector.BooleanSelector(),
64+
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): selector.NumberSelector(),
65+
},
66+
).schema,
67+
)
68+
5869

5970
SCHEMA_UTILITY_METER_OPTIONS = vol.Schema(
6071
{

custom_components/powercalc/sensor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
CONF_DAILY_FIXED_ENERGY,
5858
CONF_DELAY,
5959
CONF_DISABLE_STANDBY_POWER,
60+
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
61+
CONF_ENERGY_FILTER_OUTLIER_MAX,
6062
CONF_ENERGY_INTEGRATION_METHOD,
6163
CONF_ENERGY_SENSOR_CATEGORY,
6264
CONF_ENERGY_SENSOR_ID,
@@ -195,6 +197,8 @@
195197
vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern,
196198
vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
197199
vol.Optional(CONF_ENERGY_INTEGRATION_METHOD): vol.In(ENERGY_INTEGRATION_METHODS),
200+
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED): cv.boolean,
201+
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): cv.positive_int,
198202
vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): vol.In([cls.value for cls in UnitPrefix]),
199203
vol.Optional(CONF_CREATE_GROUP): cv.string,
200204
vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO): cv.boolean,

custom_components/powercalc/sensors/energy.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from datetime import timedelta
3+
from datetime import datetime, timedelta
44
from decimal import Decimal
55
import inspect
66
import logging
@@ -10,11 +10,13 @@
1010
from homeassistant.const import (
1111
ATTR_UNIT_OF_MEASUREMENT,
1212
CONF_NAME,
13+
STATE_UNAVAILABLE,
14+
STATE_UNKNOWN,
1315
UnitOfEnergy,
1416
UnitOfPower,
1517
UnitOfTime,
1618
)
17-
from homeassistant.core import HomeAssistant, callback
19+
from homeassistant.core import HomeAssistant, State, callback
1820
from homeassistant.helpers.device_registry import DeviceInfo
1921
from homeassistant.helpers.entity import EntityCategory
2022
import homeassistant.helpers.entity_registry as er
@@ -25,6 +27,8 @@
2527
ATTR_SOURCE_DOMAIN,
2628
ATTR_SOURCE_ENTITY,
2729
CONF_DISABLE_EXTENDED_ATTRIBUTES,
30+
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
31+
CONF_ENERGY_FILTER_OUTLIER_MAX,
2832
CONF_ENERGY_INTEGRATION_METHOD,
2933
CONF_ENERGY_SENSOR_CATEGORY,
3034
CONF_ENERGY_SENSOR_ID,
@@ -40,6 +44,7 @@
4044
)
4145
from custom_components.powercalc.device_binding import get_device_info
4246
from custom_components.powercalc.errors import SensorConfigurationError
47+
from custom_components.powercalc.filter.outlier import OutlierFilter
4348

4449
from .abstract import (
4550
BaseEntity,
@@ -280,6 +285,34 @@ def __init__(
280285
self._attr_suggested_display_precision = round_digits
281286
if entity_category:
282287
self._attr_entity_category = EntityCategory(entity_category)
288+
self._filter_outliers = bool(sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_ENABLED, False))
289+
self._outlier_filter = OutlierFilter(
290+
window_size=30,
291+
min_samples=5,
292+
max_z_score=3.5,
293+
max_expected_step=sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_MAX, 1000),
294+
)
295+
296+
def _integrate_on_state_change(
297+
self,
298+
old_timestamp: datetime | None,
299+
new_timestamp: datetime | None,
300+
old_state: State | None,
301+
new_state: State | None,
302+
) -> None:
303+
"""Override to add outlier filtering."""
304+
305+
if self._filter_outliers and new_state is not None:
306+
valid_state = new_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
307+
if valid_state and not self._outlier_filter.accept(float(new_state.state)):
308+
_LOGGER.debug(
309+
"%s: Rejecting power value %s as outlier for energy integration",
310+
self.entity_id,
311+
new_state.state,
312+
)
313+
return
314+
315+
super()._integrate_on_state_change(old_timestamp, new_timestamp, old_state, new_state)
283316

284317
@property
285318
def extra_state_attributes(self) -> dict[str, str] | None:

0 commit comments

Comments
 (0)