diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6b381b73ac20..c57ac16012fa 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -696,12 +696,19 @@ class TimeSMAFilter(Filter, SensorEntity): self._time_window = window_size self.last_leak: FilterState | None = None self.queue = deque[FilterState]() + # running time weighted sum of the queue, so a sample costs no pass over it + self._queue_sum: float = 0 def _leak(self, left_boundary: datetime) -> None: """Remove timeouted elements.""" while self.queue: if self.queue[0].timestamp + self._time_window <= left_boundary: self.last_leak = self.queue.popleft() + if self.queue: + self._queue_sum -= self._weighted(self.last_leak, self.queue[0]) + else: + # nothing left to weigh, so drop any rounding drift as well + self._queue_sum = 0 else: return @@ -710,22 +717,31 @@ class TimeSMAFilter(Filter, SensorEntity): """Implement the Simple Moving Average filter.""" self._leak(new_state.timestamp) + if self.queue: + self._queue_sum += self._weighted(self.queue[-1], new_state) self.queue.append(copy(new_state)) - moving_sum: float = 0 + # the stretch before the oldest queued sample is covered by the value that + # left the window last, or by that oldest sample while nothing has left yet start = new_state.timestamp - self._time_window - prev_state = self.last_leak if self.last_leak is not None else self.queue[0] - for state in self.queue: - # We can cast safely here thanks to self._only_numbers = True - prev_state_value = cast(float, prev_state.state) - moving_sum += (state.timestamp - start).total_seconds() * prev_state_value - start = state.timestamp - prev_state = state + lead_state = self.last_leak if self.last_leak is not None else self.queue[0] + # We can cast safely here thanks to self._only_numbers = True + lead_value = cast(float, lead_state.state) + lead_seconds = (self.queue[0].timestamp - start).total_seconds() + moving_sum = lead_seconds * lead_value + self._queue_sum new_state.state = moving_sum / self._time_window.total_seconds() return new_state + @staticmethod + def _weighted(state: FilterState, until: FilterState) -> float: + """Return the value of a state weighted by the time until the next one.""" + # The cast is safe, the base filter only lets numbers through + return (until.timestamp - state.timestamp).total_seconds() * cast( + float, state.state + ) + @FILTERS.register(FILTER_NAME_THROTTLE) class ThrottleFilter(Filter, SensorEntity): diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index baff4bc78a64..1f3117746bfa 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -547,6 +547,30 @@ def test_time_sma(values: list[State]) -> None: assert filtered.state == 21.5 +def test_time_sma_window() -> None: + """Test the time SMA as samples leave the window one by one, then all at once.""" + filt = TimeSMAFilter( + window_size=timedelta(minutes=2), precision=2, entity=None, type="last" + ) + start = dt_util.utcnow() + samples = [(0, 10), (60, 20), (120, 30), (180, 40), (240, 50), (600, 60), (630, 70)] + + filtered = [ + filt.filter_state( + State( + "sensor.test_monitored", + str(value), + last_updated=start + timedelta(seconds=offset), + ) + ).state + for offset, value in samples + ] + + # a value counts from its own timestamp until the next sample, and the value + # that left the window last covers the stretch before the oldest sample in it + assert filtered == [10, 10, 15, 25, 35, 50, 52.5] + + async def test_reload(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Verify we can reload filter sensors.""" hass.states.async_set("sensor.test_monitored", 12345)