Skip to content

Commit 5d80a01

Browse files
authored
Fix off-by-one in OrderedRingBuffer.count_covered and add consistency test (#1343)
### Summary This PR fixes a subtle off-by-one issue in the `OrderedRingBuffer.count_covered` method and adds a test to ensure window count consistency in `MovingWindow`. ### Changes 1. **Fix `count_covered` calculation** - Switched from floating-point division to integer `timedelta` division (`timedelta // timedelta`). - This eliminates rounding errors in sample count calculations and ensures accurate coverage for all window sizes and sampling periods. 2. **Add `test_moving_window_length`** - New test verifies that the moving window length is consistent without resampling. - Confirms that the count of covered samples matches the expected window capacity. - Helps prevent regressions related to off-by-one errors in the future. ### Motivation Floating-point division could produce off-by-one errors for certain window sizes and sampling periods, causing `count_covered` to be inaccurate. Switching to integer-based `timedelta` division ensures deterministic and exact counts. The new test provides coverage to catch similar issues.
2 parents bcc902e + 039de13 commit 5d80a01

6 files changed

Lines changed: 45 additions & 12 deletions

File tree

RELEASE_NOTES.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Frequenz Python SDK Release Notes
22

3-
## Bug fixes
3+
## Summary
44

5-
- `FormulaEngine` and `FormulaEngine3Phase` are now type aliases to `Formula` and `Formula3Phase`, fixing a typing issue introduced in `v1.0.0-rc2202`.
5+
<!-- Here goes a general summary of what this release is about -->
6+
7+
## Upgrading
8+
9+
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
10+
11+
## New Features
12+
13+
<!-- Here goes the main new features and examples or instructions on how to use them -->
14+
15+
## Bug Fixes
16+
17+
- Fixed an off-by-one calculation in `OrderedRingBuffer.count_covered` by switching to integer timedelta division, ensuring accurate sample counting for all window sizes and sampling periods.

benchmarks/timeseries/periodic_feature_extractor.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ def _calculate_avg_window(
7171
reshaped = feature_extractor._reshape_np_array( # pylint: disable=protected-access
7272
window, window_size
7373
)
74-
# ignoring the type because np.average returns Any
75-
return np.average(reshaped[:, :window_size], axis=0) # type: ignore[no-any-return]
74+
return np.average(reshaped[:, :window_size], axis=0)
7675

7776

7877
def _calculate_avg_window_py(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ dependencies = [
3030
# changing the version
3131
# (plugins.mkdocstrings.handlers.python.import)
3232
"frequenz-client-microgrid >= 0.18.1, < 0.19.0",
33-
"frequenz-microgrid-component-graph >= 0.3.2, < 0.4",
33+
"frequenz-microgrid-component-graph >= 0.3.2, < 0.3.4",
3434
"frequenz-client-common >= 0.3.6, < 0.4.0",
3535
"frequenz-channels >= 1.6.1, < 2.0.0",
3636
"frequenz-quantities[marshmallow] >= 1.0.0, < 2.0.0",

src/frequenz/sdk/timeseries/_periodic_feature_extractor.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,4 @@ def avg(
409409
The averaged timeseries window.
410410
"""
411411
(reshaped, window_size) = self._get_reshaped_np_array(start, end)
412-
return np.average( # type: ignore[no-any-return]
413-
reshaped[:, :window_size], axis=0, weights=weights
414-
)
412+
return np.average(reshaped[:, :window_size], axis=0, weights=weights)

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -705,10 +705,7 @@ def count_covered(
705705
The count of samples between the oldest and newest (inclusive) valid samples
706706
or 0 if there are is no time range covered.
707707
"""
708-
return int(
709-
self._covered_time_range(since, until).total_seconds()
710-
// self._sampling_period.total_seconds()
711-
)
708+
return self._covered_time_range(since, until) // self._sampling_period
712709

713710
def count_valid(
714711
self, *, since: datetime | None = None, until: datetime | None = None

tests/timeseries/test_moving_window.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,33 @@ async def test_resampling_window(fake_time: time_machine.Coordinates) -> None:
560560
assert 4.9 < value < 5.1
561561

562562

563+
async def test_moving_window_length(fake_time: time_machine.Coordinates) -> None:
564+
"""Test moving window length without resampling."""
565+
channel = Broadcast[Sample[Quantity]](name="net_power")
566+
sender = channel.new_sender()
567+
568+
window_size = timedelta(seconds=1)
569+
input_sampling = timedelta(seconds=0.1)
570+
571+
async with MovingWindow(
572+
size=window_size,
573+
resampled_data_recv=channel.new_receiver(),
574+
input_sampling_period=input_sampling,
575+
) as window:
576+
assert window.capacity == window_size / input_sampling, "Wrong window capacity"
577+
assert window.count_valid() == 0, "Window should be empty at the beginning"
578+
stream_values = [4.0, 8.0, 2.0, 6.0, 5.0] * 100
579+
for value in stream_values:
580+
timestamp = datetime.now(tz=timezone.utc)
581+
sample = Sample(timestamp, Quantity(float(value)))
582+
await sender.send(sample)
583+
await asyncio.sleep(0.1)
584+
fake_time.shift(0.1)
585+
assert window.count_valid() <= window.count_covered()
586+
587+
assert window.count_valid() == window_size / input_sampling
588+
589+
563590
async def test_timestamps() -> None:
564591
"""Test indexing a window by timestamp."""
565592
window, sender = init_moving_window(timedelta(seconds=5))

0 commit comments

Comments
 (0)