Skip to content

Commit f053b87

Browse files
authored
Drop empty Matryoshka buckets (#1155)
If buckets remain for ever in the power manager's Matryoshka instances, proposals with overlapping component IDs will never get accepted. But if empty buckets go away as soon as they become empty, then it becomes possible to propose requests for components without conflicting with old unused component ID buckets.
2 parents 46774c3 + 370d61b commit f053b87

File tree

3 files changed

+139
-8
lines changed

3 files changed

+139
-8
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414

1515
## Bug Fixes
1616

17-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
17+
- Fixed a bug that was preventing power proposals to go through if there once existed some proposals with overlapping component IDs, even if the old proposals have expired.

src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,15 @@ def calculate_target_power(
199199
bucket = self._component_buckets.setdefault(component_ids, set())
200200
if proposal in bucket:
201201
bucket.remove(proposal)
202-
bucket.add(proposal)
202+
if (
203+
proposal.preferred_power is not None
204+
or proposal.bounds.lower is not None
205+
or proposal.bounds.upper is not None
206+
):
207+
bucket.add(proposal)
208+
elif not bucket:
209+
del self._component_buckets[component_ids]
210+
_ = self._target_power.pop(component_ids, None)
203211

204212
# If there has not been any proposal for the given components, don't calculate a
205213
# target power and just return `None`.
@@ -292,10 +300,17 @@ def drop_old_proposals(self, loop_time: float) -> None:
292300
Args:
293301
loop_time: The current loop time.
294302
"""
295-
for bucket in self._component_buckets.values():
296-
to_delete = []
297-
for proposal in bucket:
303+
buckets_to_delete: list[frozenset[int]] = []
304+
for component_ids, proposals in self._component_buckets.items():
305+
to_delete: list[Proposal] = []
306+
for proposal in proposals:
298307
if (loop_time - proposal.creation_time) > self._max_proposal_age_sec:
299308
to_delete.append(proposal)
300309
for proposal in to_delete:
301-
bucket.remove(proposal)
310+
proposals.remove(proposal)
311+
if not proposals:
312+
buckets_to_delete.append(component_ids)
313+
314+
for component_ids in buckets_to_delete:
315+
del self._component_buckets[component_ids]
316+
_ = self._target_power.pop(component_ids, None)

tests/actor/_power_managing/test_matryoshka.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
"""Tests for the Matryoshka power manager algorithm."""
55

66
import asyncio
7+
import re
78
from datetime import datetime, timedelta, timezone
89

10+
import pytest
911
from frequenz.quantities import Power
1012

1113
from frequenz.sdk import timeseries
@@ -36,13 +38,14 @@ def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-argumen
3638
expected: float | None,
3739
creation_time: float | None = None,
3840
must_send: bool = False,
41+
batteries: frozenset[int] | None = None,
3942
) -> None:
4043
"""Test the target power calculation."""
4144
self._call_count += 1
4245
tgt_power = self.algorithm.calculate_target_power(
43-
self._batteries,
46+
self._batteries if batteries is None else batteries,
4447
Proposal(
45-
component_ids=self._batteries,
48+
component_ids=self._batteries if batteries is None else batteries,
4649
source_id=f"actor-{priority}",
4750
preferred_power=None if power is None else Power.from_watts(power),
4851
bounds=timeseries.Bounds(
@@ -368,6 +371,7 @@ async def test_matryoshka_drop_old_proposals() -> None:
368371
With inclusion bounds, and exclusion bounds -30.0 to 30.0.
369372
"""
370373
batteries = frozenset({2, 5})
374+
overlapping_batteries = frozenset({5, 8})
371375

372376
system_bounds = _base_types.SystemBounds(
373377
timestamp=datetime.now(tz=timezone.utc),
@@ -424,3 +428,115 @@ async def test_matryoshka_drop_old_proposals() -> None:
424428
tester.tgt_power(
425429
priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0, must_send=True
426430
)
431+
432+
# When all proposals are too old, they are dropped, and the buckets are dropped as
433+
# well. After that, sending a request for a different but overlapping bucket will
434+
# succeed. And it will fail until then.
435+
with pytest.raises(
436+
NotImplementedError,
437+
match=re.escape(
438+
"PowerManagingActor: component IDs frozenset({8, 5}) are already "
439+
+ "part of another bucket. Overlapping buckets are not yet supported."
440+
),
441+
):
442+
tester.tgt_power(
443+
priority=1,
444+
power=25.0,
445+
bounds=(25.0, 50.0),
446+
expected=25.0,
447+
must_send=True,
448+
batteries=overlapping_batteries,
449+
)
450+
451+
tester.tgt_power(
452+
priority=1,
453+
power=25.0,
454+
bounds=(25.0, 50.0),
455+
creation_time=now - 70.0,
456+
expected=25.0,
457+
must_send=True,
458+
)
459+
tester.tgt_power(
460+
priority=2,
461+
power=25.0,
462+
bounds=(25.0, 50.0),
463+
creation_time=now - 70.0,
464+
expected=25.0,
465+
must_send=True,
466+
)
467+
tester.tgt_power(
468+
priority=3,
469+
power=25.0,
470+
bounds=(25.0, 50.0),
471+
creation_time=now - 70.0,
472+
expected=25.0,
473+
must_send=True,
474+
)
475+
476+
tester.algorithm.drop_old_proposals(now)
477+
478+
tester.tgt_power(
479+
priority=1,
480+
power=25.0,
481+
bounds=(25.0, 50.0),
482+
expected=25.0,
483+
must_send=True,
484+
batteries=overlapping_batteries,
485+
)
486+
487+
488+
async def test_matryoshka_none_proposals() -> None:
489+
"""Tests for the power managing actor.
490+
491+
When a `None` proposal is received, is source id should be dropped from the bucket.
492+
Then if the bucket becomes empty, it should be dropped as well.
493+
"""
494+
batteries = frozenset({2, 5})
495+
overlapping_batteries = frozenset({5, 8})
496+
497+
system_bounds = _base_types.SystemBounds(
498+
timestamp=datetime.now(tz=timezone.utc),
499+
inclusion_bounds=timeseries.Bounds(
500+
lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0)
501+
),
502+
exclusion_bounds=timeseries.Bounds(lower=Power.zero(), upper=Power.zero()),
503+
)
504+
505+
def ensure_overlapping_bucket_request_fails() -> None:
506+
with pytest.raises(
507+
NotImplementedError,
508+
match=re.escape(
509+
"PowerManagingActor: component IDs frozenset({8, 5}) are already "
510+
+ "part of another bucket. Overlapping buckets are not yet supported."
511+
),
512+
):
513+
tester.tgt_power(
514+
priority=1,
515+
power=None,
516+
bounds=(20.0, 50.0),
517+
expected=None,
518+
must_send=True,
519+
batteries=overlapping_batteries,
520+
)
521+
522+
tester = StatefulTester(batteries, system_bounds)
523+
524+
tester.tgt_power(priority=3, power=22.0, bounds=(22.0, 30.0), expected=22.0)
525+
tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=25.0)
526+
tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None)
527+
528+
ensure_overlapping_bucket_request_fails()
529+
tester.tgt_power(priority=1, power=None, bounds=(None, None), expected=None)
530+
ensure_overlapping_bucket_request_fails()
531+
tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=None)
532+
ensure_overlapping_bucket_request_fails()
533+
tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=None)
534+
535+
# Overlapping battery bucket is dropped.
536+
tester.tgt_power(
537+
priority=1,
538+
power=20.0,
539+
bounds=(20.0, 50.0),
540+
expected=20.0,
541+
batteries=overlapping_batteries,
542+
)

0 commit comments

Comments
 (0)