Skip to content

Commit caa16e5

Browse files
committed
fix: outlier filter not working properly
1 parent 6812695 commit caa16e5

2 files changed

Lines changed: 134 additions & 19 deletions

File tree

custom_components/powercalc/sensors/energy.py

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@
5858
_LOGGER = logging.getLogger(__name__)
5959

6060

61+
def _numeric_state_value(state: State | None) -> float | None:
62+
"""Return the numeric value of a state, or None when it is not a usable number."""
63+
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
64+
return None
65+
try:
66+
return float(state.state)
67+
except TypeError, ValueError:
68+
return None
69+
70+
6171
def create_energy_sensor(
6272
hass: HomeAssistant,
6373
sensor_config: ConfigType,
@@ -290,27 +300,86 @@ def __init__(
290300
max_z_score=3.5,
291301
max_expected_step=sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_MAX, 1000),
292302
)
303+
self._last_accepted_value: float | None = None
304+
self._last_rejected_value: float | None = None
293305

294306
def _integrate_on_state_change(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
295-
"""Override to add outlier filtering."""
296-
297-
new_state: State | None = kwargs.get("new_state")
298-
if new_state is None and args:
299-
last_arg = args[-1]
300-
if isinstance(last_arg, State):
301-
new_state = last_arg
302-
303-
if self._filter_outliers and new_state is not None:
304-
valid_state = new_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
305-
if valid_state and not self._outlier_filter.accept(float(new_state.state)):
306-
_LOGGER.debug(
307-
"%s: Rejecting power value %s as outlier for energy integration",
308-
self.entity_id,
309-
new_state.state,
310-
)
311-
return
312-
313-
super()._integrate_on_state_change(*args, **kwargs)
307+
"""Override to add outlier filtering.
308+
309+
Simply skipping integration when an outlier arrives as the ``new_state`` is not
310+
enough: the energy sensor integrates over consecutive states, and depending on the
311+
integration method the outlier also contributes when it is the *old* state of the
312+
following event. With the default ``left`` Riemann method the contribution of a
313+
state change is ``old_state * elapsed_time``, so a rejected spike still leaks into
314+
the total on the next update. Instead of skipping, we substitute any outlier reading
315+
with the last accepted value wherever it appears (as old and as new state), so it can
316+
never affect the energy total regardless of the integration method.
317+
"""
318+
if not self._filter_outliers:
319+
super()._integrate_on_state_change(*args, **kwargs)
320+
return
321+
322+
arg_list = list(args)
323+
state_positions = [index for index, value in enumerate(arg_list) if isinstance(value, State)]
324+
325+
# The integration sensor passes the states positionally (old_state, new_state being
326+
# the last two). Sanitize old_state first, then new_state, since processing new_state
327+
# updates the tracking used to detect the outlier as a subsequent old_state.
328+
if len(state_positions) >= 2:
329+
old_index = state_positions[-2]
330+
arg_list[old_index] = self._replace_outlier_state(arg_list[old_index])
331+
if state_positions:
332+
new_index = state_positions[-1]
333+
arg_list[new_index] = self._sanitize_new_state(arg_list[new_index])
334+
335+
super()._integrate_on_state_change(*arg_list, **kwargs)
336+
337+
def _schedule_max_sub_interval_exceeded_if_state_is_numeric(self, source_state: State | None) -> None:
338+
"""Prevent a rejected outlier from being integrated as the assumed constant value.
339+
340+
When ``max_sub_interval`` is configured (always the case for powercalc energy sensors)
341+
the integration sensor keeps integrating the last known source state until a new state
342+
change arrives. Substitute the outlier with the last accepted value so this fallback
343+
does not leak the spike either.
344+
"""
345+
if self._filter_outliers:
346+
source_state = self._replace_outlier_state(source_state)
347+
super()._schedule_max_sub_interval_exceeded_if_state_is_numeric(source_state)
348+
349+
def _sanitize_new_state(self, state: State | None) -> State | None:
350+
"""Feed a new state through the outlier filter, substituting rejected outliers."""
351+
value = _numeric_state_value(state)
352+
if value is None:
353+
return state
354+
355+
if self._outlier_filter.accept(value):
356+
self._last_accepted_value = value
357+
self._last_rejected_value = None
358+
return state
359+
360+
self._last_rejected_value = value
361+
_LOGGER.debug(
362+
"%s: Rejecting power value %s as outlier for energy integration",
363+
self.entity_id,
364+
state.state if state else value,
365+
)
366+
return self._replace_outlier_state(state)
367+
368+
def _replace_outlier_state(self, state: State | None) -> State | None:
369+
"""Replace a state holding the last rejected outlier value with the last accepted value."""
370+
if state is None or self._last_rejected_value is None or self._last_accepted_value is None:
371+
return state
372+
if _numeric_state_value(state) != self._last_rejected_value:
373+
return state
374+
return State(
375+
state.entity_id,
376+
str(self._last_accepted_value),
377+
state.attributes,
378+
last_changed=state.last_changed,
379+
last_reported=state.last_reported,
380+
last_updated=state.last_updated,
381+
context=state.context,
382+
)
314383

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

tests/sensors/test_energy.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
from unittest.mock import patch
44

5+
from freezegun.api import FrozenDateTimeFactory
56
from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass
67
from homeassistant.components.utility_meter.sensor import SensorDeviceClass
78
from homeassistant.const import (
@@ -528,3 +529,48 @@ async def test_outlier_filtering(hass: HomeAssistant, caplog: pytest.LogCaptureF
528529
],
529530
)
530531
assert "Rejecting power value 7000000 as outlier for energy integration" in caplog.text
532+
533+
534+
async def test_outlier_filtering_does_not_leak_energy(
535+
hass: HomeAssistant,
536+
caplog: pytest.LogCaptureFixture,
537+
freezer: FrozenDateTimeFactory,
538+
) -> None:
539+
"""Regression test for https://github.com/bramstroker/homeassistant-powercalc/issues/4279.
540+
541+
With the default `left` Riemann integration method the harmful contribution of an outlier
542+
is not on its own state change (it becomes the new_state), but on the *following* state
543+
change when it acts as the old_state. Ensure the spike never leaks into the energy total.
544+
"""
545+
caplog.set_level(logging.DEBUG)
546+
547+
power_sensor_id = "sensor.test_power"
548+
await run_powercalc_setup(
549+
hass,
550+
{
551+
CONF_NAME: "Test",
552+
CONF_POWER_SENSOR_ID: power_sensor_id,
553+
CONF_ENERGY_FILTER_OUTLIER_ENABLED: True,
554+
},
555+
)
556+
557+
async def advance_and_set(value: str) -> None:
558+
freezer.tick(timedelta(seconds=15))
559+
await set_states(hass, [(power_sensor_id, value, {ATTR_UNIT_OF_MEASUREMENT: "W"})])
560+
561+
# Warm up the filter with a stable but slightly varied ~4W baseline (non-zero MAD)
562+
for value in ["4.4", "4.1", "4.6", "4.2", "4.5", "4.4", "4.5", "4.1", "4.0", "4.2"]:
563+
await advance_and_set(value)
564+
565+
energy_before = float(hass.states.get("sensor.test_energy").state)
566+
567+
# Spike to an unrealistic value and back to the baseline
568+
await advance_and_set("6553.5")
569+
await advance_and_set("4.0")
570+
571+
energy_after = float(hass.states.get("sensor.test_energy").state)
572+
573+
assert "Rejecting power value 6553.5 as outlier for energy integration" in caplog.text
574+
# The spike (6553.5W integrated over 15s ~= 0.027 kWh) must not have leaked into the total.
575+
# Only the ~4W baseline over the two intervals (~0.00003 kWh) may have accrued.
576+
assert energy_after - energy_before < 0.001

0 commit comments

Comments
 (0)