mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
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:
parent
a0668e5a5b
commit
61a3cc37e0
@ -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
|
||||
)
|
||||
)
|
||||
|
@ -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()
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user