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,
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.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
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 .const import (
@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("Could not restore last state: %s", err)
@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."""
if (
(old_state := event.data["old_state"]) is None
or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
or (new_state := event.data["new_state"]) is None
or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in (
STATE_UNKNOWN,
STATE_UNAVAILABLE,
):
return
@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
self._state_list = [
(time_start, time_end, state)
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
]
try:
elapsed_time = (
new_state.last_updated - old_state.last_updated
new_state.last_reported - old_last_reported
).total_seconds()
delta_value = Decimal(new_state.state) - Decimal(old_state.state)
delta_value = Decimal(new_state.state) - Decimal(old_value)
new_derivative = (
delta_value
/ Decimal(elapsed_time)
@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("While calculating derivative: %s", err)
except DecimalException as err:
_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:
_LOGGER.error("Could not calculate derivative: %s", err)
@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
# add latest derivative to the window list
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(
@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
else:
derivative = Decimal("0.00")
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))
self._attr_native_value = round(derivative, self._round_digits)
self.async_write_ha_state()
self.async_on_remove(
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()
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()
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"
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(
hass: HomeAssistant, config: dict[str, Any]
) -> tuple[dict[str, Any], str]:
@ -86,7 +129,7 @@ async def setup_tests(
with freeze_time(base) as freezer:
for time, value in zip(times, values, strict=False):
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()
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)
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:
"""Test derivative sensor state."""
# 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):
now = base + timedelta(seconds=time)
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()
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):
now = base + timedelta(seconds=time)
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()
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):
now = base + timedelta(seconds=time)
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()
state = hass.states.get("sensor.power")
derivative = round(float(state.state), config["sensor"]["round"])
@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None:
entity_id,
1000,
{"unit_of_measurement": UnitOfPower.WATT},
force_update=True,
)
await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(
entity_id,
1000,
2000,
{"unit_of_measurement": UnitOfPower.WATT},
force_update=True,
)
await hass.async_block_till_done()
state = hass.states.get("sensor.derivative")
assert state is not None
# Testing a power sensor at 1000 Watts for 1hour = 0kW/h
assert round(float(state.state), config["sensor"]["round"]) == 0.0
# Testing a power sensor increasing by 1000 Watts per hour = 1kW/h
assert round(float(state.state), config["sensor"]["round"]) == 1.0
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()
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()
state = hass.states.get("sensor.derivative")
@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None:
entity_id,
value,
{ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING},
force_update=True,
)
await hass.async_block_till_done()