Skip to content

Commit 18c1b56

Browse files
authored
Fix ordering issues with sets of ints (frequenz-floss#1177)
Sets of `int` have a special behavior in Python (at least CPython), as it seems the hashing function is the `int` itself, resulting in `set` being ordered when iterated. But this is not guaranteed and could change at any time. The code is unfortunately sometimes relaying in this `set` order, and this PR fixes it by using `sorted` when needed or making tests more robust and work reliable when the order is not guaranteed. This is necessary to transition to using the new microgrid client `ComponentId` to represent component IDs.
2 parents 83b8654 + ec77bad commit 18c1b56

File tree

7 files changed

+115
-69
lines changed

7 files changed

+115
-69
lines changed

src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,8 +609,12 @@ def _distribute_multi_inverter_pairs(
609609
else:
610610
remaining_power = power.power
611611

612-
# Inverters are sorted by largest excl bound first
613-
for inverter_id in inverter_ids:
612+
# Sort inverters to have the largest exclusion bounds first
613+
sorted_inverter_ids = sorted(
614+
inverter_ids, key=lambda inv_id: excl_bounds[inv_id], reverse=True
615+
)
616+
617+
for inverter_id in sorted_inverter_ids:
614618
if (
615619
not is_close_to_zero(remaining_power)
616620
and excl_bounds[inverter_id] <= remaining_power

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,9 @@ def _validate_component_ids(
144144

145145
for bucket in self._component_buckets:
146146
if any(component_id in bucket for component_id in component_ids):
147+
comp_ids = ", ".join(map(str, sorted(component_ids)))
147148
raise NotImplementedError(
148-
f"PowerManagingActor: component IDs {component_ids} are already"
149+
f"PowerManagingActor: component IDs {comp_ids} are already"
149150
" part of another bucket. Overlapping buckets are not"
150151
" yet supported."
151152
)

src/frequenz/sdk/microgrid/component_graph.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -915,14 +915,14 @@ def _validate_graph(self) -> None:
915915
# This check doesn't seem to have much sense, it only search for nodes without
916916
# data associated with them. We leave it here for now, but we should consider
917917
# removing it in the future.
918-
undefined: list[int] = [
918+
if undefined := [
919919
node[0] for node in self._graph.nodes(data=True) if len(node[1]) == 0
920-
]
921-
922-
if len(undefined) > 0:
920+
]:
921+
undefined_str = ", ".join(map(str, sorted(undefined)))
923922
raise InvalidGraphError(
924-
f"Missing definition for graph components: {undefined}"
923+
f"Missing definition for graph components: {undefined_str}"
925924
)
925+
926926
# should be true as a consequence of checks above
927927
if sum(1 for _ in self.components()) <= 0:
928928
raise InvalidGraphError("Graph must have a least one component!")

tests/actor/_power_managing/test_matryoshka.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,8 @@ async def test_matryoshka_drop_old_proposals() -> None:
435435
with pytest.raises(
436436
NotImplementedError,
437437
match=re.escape(
438-
"PowerManagingActor: component IDs frozenset({8, 5}) are already "
439-
+ "part of another bucket. Overlapping buckets are not yet supported."
438+
"PowerManagingActor: component IDs 5, 8 are already part of "
439+
"another bucket. Overlapping buckets are not yet supported."
440440
),
441441
):
442442
tester.tgt_power(
@@ -506,8 +506,8 @@ def ensure_overlapping_bucket_request_fails() -> None:
506506
with pytest.raises(
507507
NotImplementedError,
508508
match=re.escape(
509-
"PowerManagingActor: component IDs frozenset({8, 5}) are already "
510-
+ "part of another bucket. Overlapping buckets are not yet supported."
509+
"PowerManagingActor: component IDs 5, 8 are already part of "
510+
"another bucket. Overlapping buckets are not yet supported."
511511
),
512512
):
513513
tester.tgt_power(

tests/actor/power_distributing/test_battery_distribution_algorithm.py

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# pylint: disable=too-many-lines
55
"""Tests for distribution algorithm."""
66
import math
7+
from collections.abc import Sequence, Set
78
from dataclasses import dataclass
89
from datetime import datetime, timezone
910

@@ -1023,6 +1024,20 @@ def assert_result(result: DistributionResult, expected: DistributionResult) -> N
10231024
assert result.distribution == approx(expected.distribution, abs=0.01)
10241025
assert result.remaining_power == approx(expected.remaining_power, abs=0.01)
10251026

1027+
@staticmethod
1028+
def assert_any_result(
1029+
result: DistributionResult,
1030+
expected_distribution_ids: Set[int],
1031+
expected_distribution_powers: Sequence[float],
1032+
expected_remaining_power: float,
1033+
) -> None:
1034+
"""Assert the result is as expected, disregarding which power goes to which component."""
1035+
assert result.remaining_power == expected_remaining_power
1036+
assert {*result.distribution.keys()} == expected_distribution_ids
1037+
assert list(sorted(result.distribution.values())) == list(
1038+
sorted(expected_distribution_powers)
1039+
)
1040+
10261041
def test_scenario_1(self) -> None:
10271042
"""Test scenario 1.
10281043
@@ -1349,19 +1364,27 @@ def test_scenario_4(self) -> None:
13491364

13501365
algorithm = BatteryDistributionAlgorithm()
13511366

1352-
self.assert_result(
1367+
# The assignment of power to each inverter can be swapped, as they both have the
1368+
# same bounds, none will be preferred
1369+
self.assert_any_result(
13531370
algorithm.distribute_power(-300, components),
1354-
DistributionResult({2: -300, 3: 0}, remaining_power=0.0),
1371+
expected_distribution_ids={2, 3},
1372+
expected_distribution_powers=[-300, 0],
1373+
expected_remaining_power=0,
13551374
)
13561375

1357-
self.assert_result(
1376+
self.assert_any_result(
13581377
algorithm.distribute_power(300, components),
1359-
DistributionResult({2: 300, 3: 0}, remaining_power=0.0),
1378+
expected_distribution_ids={2, 3},
1379+
expected_distribution_powers=[300, 0],
1380+
expected_remaining_power=0,
13601381
)
13611382

1362-
self.assert_result(
1383+
self.assert_any_result(
13631384
algorithm.distribute_power(-1800, components),
1364-
DistributionResult({2: -1000, 3: -500}, remaining_power=-300.0),
1385+
expected_distribution_ids={2, 3},
1386+
expected_distribution_powers=[-1000, -500],
1387+
expected_remaining_power=-300,
13651388
)
13661389

13671390
def test_scenario_5(self) -> None:
@@ -1431,33 +1454,37 @@ def test_scenario_5(self) -> None:
14311454

14321455
algorithm = BatteryDistributionAlgorithm()
14331456

1434-
self.assert_result(
1457+
self.assert_any_result(
14351458
algorithm.distribute_power(-300, components),
1436-
DistributionResult({10: -200, 11: 0, 20: -100, 21: 0}, remaining_power=0.0),
1459+
expected_distribution_ids={10, 11, 20, 21},
1460+
expected_distribution_powers=[-200, 0, -100, 0],
1461+
expected_remaining_power=0.0,
14371462
)
14381463

1439-
self.assert_result(
1464+
self.assert_any_result(
14401465
algorithm.distribute_power(300, components),
1441-
DistributionResult({10: 200, 11: 0, 20: 100, 21: 0}, remaining_power=0.0),
1466+
expected_distribution_ids={10, 11, 20, 21},
1467+
expected_distribution_powers=[200, 0, 100, 0],
1468+
expected_remaining_power=0.0,
14421469
)
14431470

1444-
self.assert_result(
1471+
self.assert_any_result(
14451472
algorithm.distribute_power(-1800, components),
1446-
DistributionResult(
1447-
{10: -300, 11: 0, 20: -1000, 21: -500}, remaining_power=0.0
1448-
),
1473+
expected_distribution_ids={10, 11, 20, 21},
1474+
expected_distribution_powers=[-300, 0, -1000, -500],
1475+
expected_remaining_power=0.0,
14491476
)
14501477

1451-
self.assert_result(
1478+
self.assert_any_result(
14521479
algorithm.distribute_power(3000, components),
1453-
DistributionResult(
1454-
{10: 1000, 11: 500, 20: 1000, 21: 500}, remaining_power=0.0
1455-
),
1480+
expected_distribution_ids={10, 11, 20, 21},
1481+
expected_distribution_powers=[1000, 500, 1000, 500],
1482+
expected_remaining_power=0.0,
14561483
)
14571484

1458-
self.assert_result(
1485+
self.assert_any_result(
14591486
algorithm.distribute_power(3500, components),
1460-
DistributionResult(
1461-
{10: 1000, 11: 500, 20: 1000, 21: 500}, remaining_power=500.0
1462-
),
1487+
expected_distribution_ids={10, 11, 20, 21},
1488+
expected_distribution_powers=[1000, 500, 1000, 500],
1489+
expected_remaining_power=500.0,
14631490
)

tests/timeseries/_battery_pool/test_battery_pool.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,21 @@ async def setup_batteries_pool(mocker: MockerFixture) -> AsyncIterator[SetupArgs
198198
# the scope of this tests. This tests should cover BatteryPool only.
199199
# We use our own battery status channel, where we easily control set of working
200200
# batteries.
201-
all_batteries = list(get_components(mock_microgrid, ComponentCategory.BATTERY))
201+
all_batteries = get_components(mock_microgrid, ComponentCategory.BATTERY)
202202

203-
battery_pool = microgrid.new_battery_pool(
204-
priority=5, component_ids=set(all_batteries[:2])
205-
)
203+
# This is a hack because these tests used to rely on the order in which components
204+
# are returned from the component graph using `all_batteries[:2]` to get the first 2
205+
# batteries. But the component graph returns a set, which doesn't have any ordering
206+
# guarantees, so if the Python implementation changes, the hashing can change and
207+
# this resulting order could be different, breaking the tests. This is why we are
208+
# now specifying IDs to use explicitly (these are the IDs that were used when the
209+
# tests were written). This can also change in the future if generator of mock
210+
# components changes, as it might produce different IDs, so this solution is also
211+
# not bullet-proof.
212+
assert 8 in all_batteries
213+
assert 11 in all_batteries
214+
215+
battery_pool = microgrid.new_battery_pool(priority=5, component_ids=set([8, 11]))
206216

207217
dp = microgrid._data_pipeline._DATA_PIPELINE
208218
assert dp is not None
@@ -1125,7 +1135,7 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals
11251135
)
11261136
compare_messages(msg, expected)
11271137

1128-
batteries_in_pool = list(battery_pool.component_ids)
1138+
batteries_in_pool = list(sorted(battery_pool.component_ids))
11291139
scenarios: list[Scenario[SystemBounds]] = [
11301140
Scenario(
11311141
next(iter(bat_invs_map[batteries_in_pool[0]])),

tests/timeseries/test_formula_formatter.py

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@
44
"""Tests for the FormulaFormatter."""
55

66

7-
from contextlib import AsyncExitStack
8-
97
from frequenz.channels import Broadcast
108
from frequenz.quantities import Quantity
11-
from pytest_mock import MockerFixture
129

13-
from frequenz.sdk import microgrid
1410
from frequenz.sdk.timeseries import Sample
15-
from frequenz.sdk.timeseries.formula_engine._formula_engine import FormulaBuilder
11+
from frequenz.sdk.timeseries.formula_engine._formula_engine import (
12+
FormulaBuilder,
13+
)
1614
from frequenz.sdk.timeseries.formula_engine._formula_formatter import format_formula
1715
from frequenz.sdk.timeseries.formula_engine._formula_steps import (
1816
Clipper,
@@ -22,7 +20,6 @@
2220
Minimizer,
2321
)
2422
from frequenz.sdk.timeseries.formula_engine._tokenizer import Tokenizer, TokenType
25-
from tests.timeseries.mock_microgrid import MockMicrogrid
2623

2724

2825
def build_formula(formula: str) -> list[FormulaStep]:
@@ -112,27 +109,34 @@ def test_functions(self) -> None:
112109
# flake8: enable
113110
# fmt: on
114111

115-
async def test_higher_order_formula(self, mocker: MockerFixture) -> None:
116-
"""Test that the formula is formatted correctly for a higher-order formula."""
117-
mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker)
118-
mockgrid.add_batteries(3)
119-
mockgrid.add_ev_chargers(1)
120-
mockgrid.add_solar_inverters(2)
121-
122-
async with mockgrid, AsyncExitStack() as stack:
123-
logical_meter = microgrid.logical_meter()
124-
stack.push_async_callback(logical_meter.stop)
125-
126-
pv_pool = microgrid.new_pv_pool(priority=5)
127-
stack.push_async_callback(pv_pool.stop)
128-
129-
grid = microgrid.grid()
130-
stack.push_async_callback(grid.stop)
131-
132-
assert str(grid.power) == "#36 + #7 + #47 + #17 + #57 + #27"
133-
134-
composed_formula = (grid.power - pv_pool.power).build("grid_minus_pv")
135-
assert (
136-
str(composed_formula)
137-
== "[grid-power](#36 + #7 + #47 + #17 + #57 + #27) - [pv-power](#47 + #57)"
138-
)
112+
async def test_higher_order_formula(self) -> None:
113+
"""Test that higher-order formulas (formulas combining other formulas) are formatted correctly."""
114+
# Create two base formulas
115+
builder1 = FormulaBuilder("test_formula1", Quantity)
116+
builder2 = FormulaBuilder("test_formula2", Quantity)
117+
118+
# Push metrics directly to the builders
119+
channel1 = Broadcast[Sample[Quantity]](name="channel1")
120+
channel2 = Broadcast[Sample[Quantity]](name="channel2")
121+
builder1.push_metric("#1", channel1.new_receiver(), nones_are_zeros=True)
122+
builder1.push_oper("+")
123+
builder1.push_metric("#2", channel2.new_receiver(), nones_are_zeros=True)
124+
125+
channel3 = Broadcast[Sample[Quantity]](name="channel3")
126+
channel4 = Broadcast[Sample[Quantity]](name="channel4")
127+
builder2.push_metric("#3", channel3.new_receiver(), nones_are_zeros=True)
128+
builder2.push_oper("+")
129+
builder2.push_metric("#4", channel4.new_receiver(), nones_are_zeros=True)
130+
131+
# Build individual formula engines first
132+
engine1 = builder1.build()
133+
engine2 = builder2.build()
134+
135+
# Combine them into a higher-order formula
136+
composed_formula = (engine1 - engine2).build("higher_order_formula")
137+
138+
# Check the string representation
139+
assert (
140+
str(composed_formula)
141+
== "[test_formula1](#1 + #2) - [test_formula2](#3 + #4)"
142+
)

0 commit comments

Comments
 (0)