|
58 | 58 | _LOGGER = logging.getLogger(__name__) |
59 | 59 |
|
60 | 60 |
|
| 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 | + |
61 | 71 | def create_energy_sensor( |
62 | 72 | hass: HomeAssistant, |
63 | 73 | sensor_config: ConfigType, |
@@ -290,27 +300,86 @@ def __init__( |
290 | 300 | max_z_score=3.5, |
291 | 301 | max_expected_step=sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_MAX, 1000), |
292 | 302 | ) |
| 303 | + self._last_accepted_value: float | None = None |
| 304 | + self._last_rejected_value: float | None = None |
293 | 305 |
|
294 | 306 | 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 | + ) |
314 | 383 |
|
315 | 384 | @property |
316 | 385 | def extra_state_attributes(self) -> dict[str, str] | None: |
|
0 commit comments