Skip to content

Commit 5c98c35

Browse files
authored
Fix single element access for moving window (#672)
This brings full support for single element access in moving windows either via integer or datetime indices including bug fixes.
2 parents 9c65e24 + 7e87989 commit 5c98c35

File tree

3 files changed

+75
-14
lines changed

3 files changed

+75
-14
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@
2828
- In `OrderedRingBuffer` and `MovingWindow`:
2929
- Support for integer indices is added.
3030
- Add `count_covered` method to count the number of elements covered by the used time range.
31-
31+
- Add `at` method to `MovingWindow` to access a single element and use it in `__getitem__` magic to fully support single element access.
3232

3333

3434

3535
## Bug Fixes
3636

3737
- Fix rendering of diagrams in the documentation.
3838
- The `__getitem__` magic of the `MovingWindow` is fixed to support the same functionality that the `window` method provides.
39+
- Fixes incorrect implementation of single element access in `__getitem__` magic of `MovingWindow`.

src/frequenz/sdk/timeseries/_moving_window.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,48 @@ def capacity(self) -> int:
241241
"""
242242
return self._buffer.maxlen
243243

244+
# pylint before 3.0 only accepts names with 3 or more chars
245+
def at(self, key: int | datetime) -> float: # pylint: disable=invalid-name
246+
"""
247+
Return the sample at the given index or timestamp.
248+
249+
In contrast to the [`window`][frequenz.sdk.timeseries.MovingWindow.window] method,
250+
which expects a slice as argument, this method expects a single index as argument
251+
and returns a single value.
252+
253+
Args:
254+
key: The index or timestamp of the sample to return.
255+
256+
Returns:
257+
The sample at the given index or timestamp.
258+
259+
Raises:
260+
IndexError: If the buffer is empty or the index is out of bounds.
261+
"""
262+
if self._buffer.count_valid() == 0:
263+
raise IndexError("The buffer is empty.")
264+
265+
if isinstance(key, datetime):
266+
assert self._buffer.oldest_timestamp is not None
267+
assert self._buffer.newest_timestamp is not None
268+
if (
269+
key < self._buffer.oldest_timestamp
270+
or key > self._buffer.newest_timestamp
271+
):
272+
raise IndexError(
273+
f"Timestamp {key} is out of range [{self._buffer.oldest_timestamp}, "
274+
f"{self._buffer.newest_timestamp}]"
275+
)
276+
return self._buffer[self._buffer.to_internal_index(key)]
277+
278+
if isinstance(key, int):
279+
_logger.debug("Returning value at index %s ", key)
280+
timestamp = self._buffer.get_timestamp(key)
281+
assert timestamp is not None
282+
return self._buffer[self._buffer.to_internal_index(timestamp)]
283+
284+
raise TypeError("Key has to be either a timestamp or an integer.")
285+
244286
def window(
245287
self,
246288
start: datetime | int | None,
@@ -251,6 +293,10 @@ def window(
251293
"""
252294
Return an array containing the samples in the given time interval.
253295
296+
In contrast to the [`at`][frequenz.sdk.timeseries.MovingWindow.at] method,
297+
which expects a single index as argument, this method expects a slice as argument
298+
and returns an array.
299+
254300
Args:
255301
start: The start of the time interval. If `None`, the start of the
256302
window is used.
@@ -372,15 +418,11 @@ def __getitem__(self, key: SupportsIndex | datetime | slice) -> float | ArrayLik
372418
raise ValueError("Slicing with a step other than 1 is not supported.")
373419
return self.window(key.start, key.stop)
374420

375-
if self._buffer.count_valid() == 0:
376-
raise IndexError("The buffer is empty.")
377-
378421
if isinstance(key, datetime):
379-
_logger.debug("Returning value at time %s ", key)
380-
return self._buffer[self._buffer.to_internal_index(key)]
422+
return self.at(key)
423+
381424
if isinstance(key, SupportsIndex):
382-
_logger.debug("Returning value at index %s ", key)
383-
return self._buffer[key]
425+
return self.at(key.__index__())
384426

385427
raise TypeError(
386428
"Key has to be either a timestamp or an integer "

tests/timeseries/test_moving_window.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,36 @@ def dt(i: int) -> datetime: # pylint: disable=invalid-name
8080

8181
async def test_access_window_by_index() -> None:
8282
"""Test indexing a window by integer index."""
83-
window, sender = init_moving_window(timedelta(seconds=1))
83+
window, sender = init_moving_window(timedelta(seconds=2))
8484
async with window:
85-
await push_logical_meter_data(sender, [1])
86-
assert np.array_equal(window[0], 1.0)
85+
await push_logical_meter_data(sender, [1, 2, 3])
86+
assert np.array_equal(window[0], 2.0)
87+
assert np.array_equal(window[1], 3.0)
88+
assert np.array_equal(window[-1], 3.0)
89+
assert np.array_equal(window[-2], 2.0)
90+
with pytest.raises(IndexError):
91+
_ = window[3]
92+
with pytest.raises(IndexError):
93+
_ = window[-3]
8794

8895

8996
async def test_access_window_by_timestamp() -> None:
9097
"""Test indexing a window by timestamp."""
91-
window, sender = init_moving_window(timedelta(seconds=1))
98+
window, sender = init_moving_window(timedelta(seconds=2))
9299
async with window:
93-
await push_logical_meter_data(sender, [1])
94-
assert np.array_equal(window[UNIX_EPOCH], 1.0)
100+
await push_logical_meter_data(sender, [0, 1, 2])
101+
assert np.array_equal(window[dt(1)], 1.0)
102+
assert np.array_equal(window.at(dt(1)), 1.0)
103+
assert np.array_equal(window[dt(2)], 2.0)
104+
assert np.array_equal(window.at(dt(2)), 2.0)
105+
with pytest.raises(IndexError):
106+
_ = window[dt(0)]
107+
with pytest.raises(IndexError):
108+
_ = window.at(dt(0))
109+
with pytest.raises(IndexError):
110+
_ = window[dt(3)]
111+
with pytest.raises(IndexError):
112+
_ = window.at(dt(3))
95113

96114

97115
async def test_access_window_by_int_slice() -> None:

0 commit comments

Comments
 (0)