Fix bug in derivative sensor when source sensor's state is constant (#139230)

Previously, when the source sensor's state remains constant, the derivative
sensor repeats its latest value indefinitely.

This patch fixes this bug by consuming the state_reported event and updating
the sensor's output even when the source sensor doesn't change its state.
This commit is contained in:
Juan Grande 2025-03-01 00:10:35 -08:00 committed by Bram Kragten
parent a0668e5a5b
commit 61a3cc37e0
2 changed files with 150 additions and 27 deletions

View File

@ -24,7 +24,14 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
UnitOfTime, UnitOfTime,
) )
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.core import (
Event,
EventStateChangedData,
EventStateReportedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback, AddConfigEntryEntitiesCallback,
AddEntitiesCallback, AddEntitiesCallback,
) )
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_state_report_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import ( from .const import (
@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("Could not restore last state: %s", err) _LOGGER.warning("Could not restore last state: %s", err)
@callback @callback
def calc_derivative(event: Event[EventStateChangedData]) -> None: def on_state_reported(event: Event[EventStateReportedData]) -> None:
"""Handle constant sensor state."""
if self._attr_native_value == Decimal(0):
# If the derivative is zero, and the source sensor hasn't
# changed state, then we know it will still be zero.
return
new_state = event.data["new_state"]
if new_state is not None:
calc_derivative(
new_state, new_state.state, event.data["old_last_reported"]
)
@callback
def on_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle changed sensor state."""
new_state = event.data["new_state"]
old_state = event.data["old_state"]
if new_state is not None and old_state is not None:
calc_derivative(new_state, old_state.state, old_state.last_reported)
def calc_derivative(
new_state: State, old_value: str, old_last_reported: datetime
) -> None:
"""Handle the sensor state changes.""" """Handle the sensor state changes."""
if ( if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in (
(old_state := event.data["old_state"]) is None STATE_UNKNOWN,
or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) STATE_UNAVAILABLE,
or (new_state := event.data["new_state"]) is None
or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
): ):
return return
@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
self._state_list = [ self._state_list = [
(time_start, time_end, state) (time_start, time_end, state)
for time_start, time_end, state in self._state_list for time_start, time_end, state in self._state_list
if (new_state.last_updated - time_end).total_seconds() if (new_state.last_reported - time_end).total_seconds()
< self._time_window < self._time_window
] ]
try: try:
elapsed_time = ( elapsed_time = (
new_state.last_updated - old_state.last_updated new_state.last_reported - old_last_reported
).total_seconds() ).total_seconds()
delta_value = Decimal(new_state.state) - Decimal(old_state.state) delta_value = Decimal(new_state.state) - Decimal(old_value)
new_derivative = ( new_derivative = (
delta_value delta_value
/ Decimal(elapsed_time) / Decimal(elapsed_time)
@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("While calculating derivative: %s", err) _LOGGER.warning("While calculating derivative: %s", err)
except DecimalException as err: except DecimalException as err:
_LOGGER.warning( _LOGGER.warning(
"Invalid state (%s > %s): %s", old_state.state, new_state.state, err "Invalid state (%s > %s): %s", old_value, new_state.state, err
) )
except AssertionError as err: except AssertionError as err:
_LOGGER.error("Could not calculate derivative: %s", err) _LOGGER.error("Could not calculate derivative: %s", err)
@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
# add latest derivative to the window list # add latest derivative to the window list
self._state_list.append( self._state_list.append(
(old_state.last_updated, new_state.last_updated, new_derivative) (old_last_reported, new_state.last_reported, new_derivative)
) )
def calculate_weight( def calculate_weight(
@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
else: else:
derivative = Decimal("0.00") derivative = Decimal("0.00")
for start, end, value in self._state_list: for start, end, value in self._state_list:
weight = calculate_weight(start, end, new_state.last_updated) weight = calculate_weight(start, end, new_state.last_reported)
derivative = derivative + (value * Decimal(weight)) derivative = derivative + (value * Decimal(weight))
self._attr_native_value = round(derivative, self._round_digits) self._attr_native_value = round(derivative, self._round_digits)
self.async_write_ha_state() self.async_write_ha_state()
self.async_on_remove( self.async_on_remove(
async_track_state_change_event( async_track_state_change_event(
self.hass, self._sensor_source_id, calc_derivative self.hass, self._sensor_source_id, on_state_changed
)
)
self.async_on_remove(
async_track_state_report_event(
self.hass, self._sensor_source_id, on_state_reported
) )
) )

View File

@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(entity_id, 1, {}, force_update=True) hass.states.async_set(entity_id, 1, {})
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get("sensor.derivative") state = hass.states.get("sensor.derivative")
@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None:
assert state.attributes.get("unit_of_measurement") == "kW" assert state.attributes.get("unit_of_measurement") == "kW"
async def test_no_change(hass: HomeAssistant) -> None:
"""Test derivative sensor state updated when source sensor doesn't change."""
config = {
"sensor": {
"platform": "derivative",
"name": "derivative",
"source": "sensor.energy",
"unit": "kW",
"round": 2,
}
}
assert await async_setup_component(hass, "sensor", config)
entity_id = config["sensor"]["source"]
base = dt_util.utcnow()
with freeze_time(base) as freezer:
hass.states.async_set(entity_id, 0, {})
await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(entity_id, 1, {})
await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(entity_id, 1, {})
await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(entity_id, 1, {})
await hass.async_block_till_done()
state = hass.states.get("sensor.derivative")
assert state is not None
# Testing a energy sensor at 1 kWh for 1hour = 0kW
assert round(float(state.state), config["sensor"]["round"]) == 0.0
assert state.attributes.get("unit_of_measurement") == "kW"
assert state.last_changed == base + timedelta(seconds=2 * 3600)
async def _setup_sensor( async def _setup_sensor(
hass: HomeAssistant, config: dict[str, Any] hass: HomeAssistant, config: dict[str, Any]
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
@ -86,7 +129,7 @@ async def setup_tests(
with freeze_time(base) as freezer: with freeze_time(base) as freezer:
for time, value in zip(times, values, strict=False): for time, value in zip(times, values, strict=False):
freezer.move_to(base + timedelta(seconds=time)) freezer.move_to(base + timedelta(seconds=time))
hass.states.async_set(entity_id, value, {}, force_update=True) hass.states.async_set(entity_id, value, {})
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get("sensor.power") state = hass.states.get("sensor.power")
@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None:
await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1)
async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None:
"""Test that zeroes are properly handled within the time window."""
# We simulate the following situation:
# The temperature rises 1 °C per minute for 10 minutes long. Then, it
# stays constant for another 10 minutes. There is a data point every
# minute and we use a time window of 10 minutes.
# Therefore, we can expect the derivative to peak at 1 after 10 minutes
# and then fall down to 0 in steps of 10%.
temperature_values = []
for temperature in range(10):
temperature_values += [temperature]
temperature_values += [10] * 11
time_window = 600
times = list(range(0, 1200 + 60, 60))
config, entity_id = await _setup_sensor(
hass,
{
"time_window": {"seconds": time_window},
"unit_time": UnitOfTime.MINUTES,
"round": 1,
},
)
base = dt_util.utcnow()
with freeze_time(base) as freezer:
last_derivative = 0
for time, value in zip(times, temperature_values, strict=True):
now = base + timedelta(seconds=time)
freezer.move_to(now)
hass.states.async_set(entity_id, value, {})
await hass.async_block_till_done()
state = hass.states.get("sensor.power")
derivative = round(float(state.state), config["sensor"]["round"])
if time_window == time:
assert derivative == 1.0
elif time_window < time < time_window * 2:
assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6)
elif time == time_window * 2:
assert derivative == 0
last_derivative = derivative
async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None:
"""Test derivative sensor state.""" """Test derivative sensor state."""
# We simulate the following situation: # We simulate the following situation:
@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N
for time, value in zip(times, temperature_values, strict=False): for time, value in zip(times, temperature_values, strict=False):
now = base + timedelta(seconds=time) now = base + timedelta(seconds=time)
freezer.move_to(now) freezer.move_to(now)
hass.states.async_set(entity_id, value, {}, force_update=True) hass.states.async_set(entity_id, value, {})
await hass.async_block_till_done() await hass.async_block_till_done()
if time_window < time < times[-1] - time_window: if time_window < time < times[-1] - time_window:
@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N
for time, value in zip(times, temperature_values, strict=False): for time, value in zip(times, temperature_values, strict=False):
now = base + timedelta(seconds=time) now = base + timedelta(seconds=time)
freezer.move_to(now) freezer.move_to(now)
hass.states.async_set(entity_id, value, {}, force_update=True) hass.states.async_set(entity_id, value, {})
await hass.async_block_till_done() await hass.async_block_till_done()
if time_window < time and time > times[3]: if time_window < time and time > times[3]:
@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None:
for time, value in zip(times, temperature_values, strict=False): for time, value in zip(times, temperature_values, strict=False):
now = base + timedelta(seconds=time) now = base + timedelta(seconds=time)
freezer.move_to(now) freezer.move_to(now)
hass.states.async_set(entity_id, value, {}, force_update=True) hass.states.async_set(entity_id, value, {})
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get("sensor.power") state = hass.states.get("sensor.power")
derivative = round(float(state.state), config["sensor"]["round"]) derivative = round(float(state.state), config["sensor"]["round"])
@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None:
entity_id, entity_id,
1000, 1000,
{"unit_of_measurement": UnitOfPower.WATT}, {"unit_of_measurement": UnitOfPower.WATT},
force_update=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set( hass.states.async_set(
entity_id, entity_id,
1000, 2000,
{"unit_of_measurement": UnitOfPower.WATT}, {"unit_of_measurement": UnitOfPower.WATT},
force_update=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get("sensor.derivative") state = hass.states.get("sensor.derivative")
assert state is not None assert state is not None
# Testing a power sensor at 1000 Watts for 1hour = 0kW/h # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h
assert round(float(state.state), config["sensor"]["round"]) == 0.0 assert round(float(state.state), config["sensor"]["round"]) == 1.0
assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}"
@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(entity_id, 1000, {}, force_update=True) hass.states.async_set(entity_id, 1000, {})
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get("sensor.derivative") state = hass.states.get("sensor.derivative")
@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None:
entity_id, entity_id,
value, value,
{ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING},
force_update=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()