mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +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,
|
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user