Skip to content

Commit 7c531c9

Browse files
authored
Fix ring buffer len (#445)
- RingBuffer: Fix off-by-one error in gap calculation - RingBuffer: Rename variables and add comments for more clarity - RingBuffer: Decrease test samples to speedup a long test - RingBuffer: Fix gaps being ignored in __len__() fixes #442
2 parents 0e29d10 + 2d54b6f commit 7c531c9

File tree

4 files changed

+74
-22
lines changed

4 files changed

+74
-22
lines changed

RELEASE_NOTES.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
1010

1111
## New Features
1212

@@ -16,4 +16,8 @@
1616

1717
## Bug Fixes
1818

19-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
19+
- Two bugs in the ring buffer which is used by the `MovingWindow` class were fixed:
20+
- `len(buffer)` was not considering potentially existing gaps (areas without elements) in the buffer.
21+
- A off-by-one error in the gap calculation logic was fixed that recorded a
22+
gap when there was none if an element with a future timestamp was added that
23+
would create a gap of exactly 1.

src/frequenz/sdk/timeseries/_ringbuffer/buffer.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def __init__(
8282
self._gaps: list[Gap] = []
8383
self._datetime_newest: datetime = self._DATETIME_MIN
8484
self._datetime_oldest: datetime = self._DATETIME_MAX
85-
self._time_range: timedelta = (len(self._buffer) - 1) * sampling_period
85+
self._full_time_range: timedelta = len(self._buffer) * self._sampling_period
8686

8787
@property
8888
def sampling_period(self) -> timedelta:
@@ -145,7 +145,9 @@ def update(self, sample: Sample[QuantityT]) -> None:
145145
# Update timestamps
146146
prev_newest = self._datetime_newest
147147
self._datetime_newest = max(self._datetime_newest, timestamp)
148-
self._datetime_oldest = self._datetime_newest - self._time_range
148+
self._datetime_oldest = self._datetime_newest - (
149+
self._full_time_range - self._sampling_period
150+
)
149151

150152
# Update data
151153
value: float = np.nan if sample.value is None else sample.value.base_value
@@ -279,39 +281,39 @@ def is_missing(self, timestamp: datetime) -> bool:
279281
return any(map(lambda gap: gap.contains(timestamp), self._gaps))
280282

281283
def _update_gaps(
282-
self, timestamp: datetime, newest: datetime, new_missing: bool
284+
self, timestamp: datetime, newest: datetime, record_as_missing: bool
283285
) -> None:
284286
"""Update gap list with new timestamp.
285287
286288
Args:
287289
timestamp: Timestamp of the new value.
288290
newest: Timestamp of the newest value before the current update.
289-
new_missing: if true, the given timestamp will be recorded as missing.
290-
291+
record_as_missing: if `True`, the given timestamp will be recorded as missing.
291292
"""
292-
currently_missing = self.is_missing(timestamp)
293+
found_in_gaps = self.is_missing(timestamp)
293294

294-
if not new_missing:
295+
if not record_as_missing:
295296
# Replace all gaps with one if we went far into then future
296-
if self._datetime_newest - newest >= self._time_range:
297+
if self._datetime_newest - newest >= self._full_time_range:
297298
self._gaps = [
298299
Gap(start=self._datetime_oldest, end=self._datetime_newest)
299300
]
300301
return
301302

302-
if not currently_missing and timestamp > newest + self._sampling_period:
303+
# Check if we created a gap with the addition of the new value
304+
if not found_in_gaps and timestamp > newest + self._sampling_period:
303305
self._gaps.append(
304306
Gap(start=newest + self._sampling_period, end=timestamp)
305307
)
306308

307309
# New missing entry that is not already in a gap?
308-
if new_missing:
309-
if not currently_missing:
310+
if record_as_missing:
311+
if not found_in_gaps:
310312
self._gaps.append(
311313
Gap(start=timestamp, end=timestamp + self._sampling_period)
312314
)
313315
elif len(self._gaps) > 0:
314-
if currently_missing:
316+
if found_in_gaps:
315317
self._remove_gap(timestamp)
316318

317319
self._cleanup_gaps()
@@ -515,10 +517,24 @@ def __len__(self) -> int:
515517
if self._datetime_newest == self._DATETIME_MIN:
516518
return 0
517519

520+
# Sum of all elements in the gap ranges
521+
sum_missing_entries = max(
522+
0,
523+
sum(
524+
(
525+
gap.end
526+
# Don't look further back than oldest timestamp
527+
- max(gap.start, self._datetime_oldest)
528+
)
529+
// self._sampling_period
530+
for gap in self._gaps
531+
),
532+
)
533+
518534
start_index = self.datetime_to_index(self._datetime_oldest)
519535
end_index = self.datetime_to_index(self._datetime_newest)
520536

521537
if end_index < start_index:
522-
return len(self._buffer) - start_index + end_index + 1
538+
return len(self._buffer) - start_index + end_index + 1 - sum_missing_entries
523539

524-
return end_index + 1 - start_index
540+
return end_index + 1 - start_index - sum_missing_entries

tests/timeseries/test_ringbuffer.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,14 @@ def test_timestamp_ringbuffer_missing_parameter(
195195
@pytest.mark.parametrize(
196196
"buffer",
197197
[
198-
OrderedRingBuffer([0.0] * 24 * int(ONE_MINUTE.total_seconds()), ONE_MINUTE),
198+
OrderedRingBuffer([0.0] * 10 * int(ONE_MINUTE.total_seconds()), ONE_MINUTE),
199199
OrderedRingBuffer(
200-
np.empty(shape=(24 * int(ONE_MINUTE.total_seconds()),), dtype=np.float64),
200+
np.empty(shape=(12 * int(ONE_MINUTE.total_seconds()),), dtype=np.float64),
201201
ONE_MINUTE,
202202
),
203-
OrderedRingBuffer([0.0] * 24 * int(FIVE_MINUTES.total_seconds()), FIVE_MINUTES),
203+
OrderedRingBuffer([0.0] * 5 * int(FIVE_MINUTES.total_seconds()), FIVE_MINUTES),
204204
OrderedRingBuffer(
205-
np.empty(shape=(24 * int(FIVE_MINUTES.total_seconds())), dtype=np.float64),
205+
np.empty(shape=(5 * int(FIVE_MINUTES.total_seconds())), dtype=np.float64),
206206
FIVE_MINUTES,
207207
),
208208
],
@@ -289,6 +289,21 @@ def test_len_ringbuffer_samples_fit_buffer_size() -> None:
289289
assert len(buffer) == len(test_samples)
290290

291291

292+
def test_len_with_gaps() -> None:
293+
"""Test the length when there are gaps in the buffer."""
294+
buffer = OrderedRingBuffer(
295+
np.empty(shape=10, dtype=float),
296+
sampling_period=timedelta(seconds=1),
297+
align_to=datetime(1, 1, 1, tzinfo=timezone.utc),
298+
)
299+
300+
for i in range(10):
301+
buffer.update(
302+
Sample(datetime(2, 2, 2, 0, 0, i, tzinfo=timezone.utc), Quantity(float(i)))
303+
)
304+
assert len(buffer) == i + 1
305+
306+
292307
def test_len_ringbuffer_samples_overwrite_buffer() -> None:
293308
"""Test the length of ordered ring buffer.
294309
@@ -335,3 +350,22 @@ def test_ringbuffer_empty_buffer() -> None:
335350
sampling_period=timedelta(seconds=1),
336351
align_to=datetime(1, 1, 1),
337352
)
353+
354+
355+
def test_off_by_one_gap_logic_bug() -> None:
356+
"""Test off by one bug in the gap calculation."""
357+
buffer = OrderedRingBuffer(
358+
np.empty(shape=2, dtype=float),
359+
sampling_period=timedelta(seconds=1),
360+
align_to=datetime(1, 1, 1, tzinfo=timezone.utc),
361+
)
362+
363+
base_time = datetime(2023, 1, 1, tzinfo=timezone.utc)
364+
365+
times = [base_time, base_time + timedelta(seconds=1)]
366+
367+
buffer.update(Sample(times[0], Quantity(1.0)))
368+
buffer.update(Sample(times[1], Quantity(2.0)))
369+
370+
assert buffer.is_missing(times[0]) is False
371+
assert buffer.is_missing(times[1]) is False

tests/timeseries/test_ringbuffer_serialization.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ def load_dump_test(dumped: rb.OrderedRingBuffer[Any], path: str) -> None:
6161
# pylint: disable=protected-access
6262
assert dumped._gaps == loaded._gaps
6363
# pylint: disable=protected-access
64-
assert dumped._time_range == loaded._time_range
65-
# pylint: disable=protected-access
6664
assert dumped._sampling_period == loaded._sampling_period
6765
# pylint: disable=protected-access
6866
assert dumped._time_index_alignment == loaded._time_index_alignment

0 commit comments

Comments
 (0)