Skip to content

Commit 1c75435

Browse files
committed
Reset components to default power when all proposals are withdrawn
Signed-off-by: Sahas Subramanian <[email protected]>
1 parent b16e2df commit 1c75435

File tree

10 files changed

+139
-19
lines changed

10 files changed

+139
-19
lines changed

src/frequenz/sdk/microgrid/_data_pipeline.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from frequenz.channels import Broadcast, Sender
2020
from frequenz.client.microgrid import ComponentCategory, InverterType
2121

22-
from frequenz.sdk.microgrid._power_managing._base_classes import Algorithm
22+
from frequenz.sdk.microgrid._power_managing._base_classes import Algorithm, DefaultPower
2323

2424
from .._internal._channels import ChannelRegistry
2525
from ..actor._actor import Actor
@@ -107,19 +107,22 @@ def __init__(
107107
api_power_request_timeout=api_power_request_timeout,
108108
power_manager_algorithm=Algorithm.SHIFTING_MATRYOSHKA,
109109
component_category=ComponentCategory.BATTERY,
110+
default_power=DefaultPower.ZERO,
110111
)
111112
self._ev_power_wrapper = PowerWrapper(
112113
self._channel_registry,
113114
api_power_request_timeout=api_power_request_timeout,
114115
power_manager_algorithm=Algorithm.MATRYOSHKA,
115116
component_category=ComponentCategory.EV_CHARGER,
117+
default_power=DefaultPower.MAX,
116118
)
117119
self._pv_power_wrapper = PowerWrapper(
118120
self._channel_registry,
119121
api_power_request_timeout=api_power_request_timeout,
120122
power_manager_algorithm=Algorithm.MATRYOSHKA,
121123
component_category=ComponentCategory.INVERTER,
122124
component_type=InverterType.SOLAR,
125+
default_power=DefaultPower.MIN,
123126
)
124127

125128
self._logical_meter: LogicalMeter | None = None

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ def __hash__(self) -> int:
199199
return hash((self.priority, self.source_id))
200200

201201

202+
class DefaultPower(enum.Enum):
203+
"""The default power for a component category."""
204+
205+
ZERO = "zero"
206+
"""The default power is 0 W."""
207+
208+
MIN = "min"
209+
"""The default power is the minimum power of the component."""
210+
211+
MAX = "max"
212+
"""The default power is the maximum power of the component."""
213+
214+
202215
class Algorithm(enum.Enum):
203216
"""The available algorithms for the power manager."""
204217

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
from ... import timeseries
3030
from . import _bounds
31-
from ._base_classes import BaseAlgorithm, Proposal, _Report
31+
from ._base_classes import BaseAlgorithm, DefaultPower, Proposal, _Report
3232

3333
if typing.TYPE_CHECKING:
3434
from ...timeseries._base_types import SystemBounds
@@ -39,9 +39,12 @@
3939
class Matryoshka(BaseAlgorithm):
4040
"""The matryoshka algorithm."""
4141

42-
def __init__(self, max_proposal_age: timedelta) -> None:
42+
def __init__(
43+
self, max_proposal_age: timedelta, default_power: DefaultPower
44+
) -> None:
4345
"""Create a new instance of the matryoshka algorithm."""
4446
self._max_proposal_age_sec = max_proposal_age.total_seconds()
47+
self._default_power = default_power
4548
self._component_buckets: dict[frozenset[int], set[Proposal]] = {}
4649
self._target_power: dict[frozenset[int], Power] = {}
4750

@@ -190,18 +193,33 @@ def calculate_target_power(
190193
bucket.add(proposal)
191194
elif not bucket:
192195
del self._component_buckets[component_ids]
193-
_ = self._target_power.pop(component_ids, None)
194196

195197
# If there has not been any proposal for the given components, don't calculate a
196198
# target power and just return `None`.
197199
proposals = self._component_buckets.get(component_ids)
198-
if proposals is None:
199-
return None
200200

201-
target_power = self._calc_target_power(proposals, system_bounds)
201+
target_power = None
202+
if proposals is not None:
203+
target_power = self._calc_target_power(proposals, system_bounds)
202204

203205
if target_power is not None:
204206
self._target_power[component_ids] = target_power
207+
elif self._target_power.get(component_ids) is not None:
208+
# If the target power was previously set, but is now `None`, then we send
209+
# the default power of the component category, to reset it immediately.
210+
del self._target_power[component_ids]
211+
bounds = system_bounds.inclusion_bounds
212+
if bounds is None:
213+
return None
214+
match self._default_power:
215+
case DefaultPower.MIN:
216+
return bounds.lower
217+
case DefaultPower.MAX:
218+
return bounds.upper
219+
case DefaultPower.ZERO:
220+
return Power.zero()
221+
case other:
222+
typing.assert_never(other)
205223

206224
return target_power
207225

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121
from ...actor import Actor
2222
from ...timeseries._base_types import SystemBounds
2323
from .. import _data_pipeline, _power_distributing
24-
from ._base_classes import Algorithm, BaseAlgorithm, Proposal, ReportRequest, _Report
24+
from ._base_classes import (
25+
Algorithm,
26+
BaseAlgorithm,
27+
DefaultPower,
28+
Proposal,
29+
ReportRequest,
30+
_Report,
31+
)
2532
from ._matryoshka import Matryoshka
2633
from ._shifting_matryoshka import ShiftingMatryoshka
2734

@@ -40,6 +47,7 @@ def __init__( # pylint: disable=too-many-arguments
4047
power_distributing_results_receiver: Receiver[_power_distributing.Result],
4148
channel_registry: ChannelRegistry,
4249
algorithm: Algorithm,
50+
default_power: DefaultPower,
4351
component_category: ComponentCategory,
4452
component_type: ComponentType | None = None,
4553
):
@@ -54,6 +62,7 @@ def __init__( # pylint: disable=too-many-arguments
5462
results.
5563
channel_registry: The channel registry.
5664
algorithm: The power management algorithm to use.
65+
default_power: The default power to use for the components.
5766
component_category: The category of the component this power manager
5867
instance is going to support.
5968
component_type: The type of the component of the given category that this
@@ -66,6 +75,7 @@ def __init__( # pylint: disable=too-many-arguments
6675
"""
6776
self._component_category = component_category
6877
self._component_type = component_type
78+
self._default_power = default_power
6979
self._bounds_subscription_receiver = bounds_subscription_receiver
7080
self._power_distributing_requests_sender = power_distributing_requests_sender
7181
self._power_distributing_results_receiver = power_distributing_results_receiver
@@ -79,11 +89,13 @@ def __init__( # pylint: disable=too-many-arguments
7989
match algorithm:
8090
case Algorithm.MATRYOSHKA:
8191
self._algorithm: BaseAlgorithm = Matryoshka(
82-
max_proposal_age=timedelta(seconds=60.0)
92+
max_proposal_age=timedelta(seconds=60.0),
93+
default_power=default_power,
8394
)
8495
case Algorithm.SHIFTING_MATRYOSHKA:
8596
self._algorithm = ShiftingMatryoshka(
86-
max_proposal_age=timedelta(seconds=60.0)
97+
max_proposal_age=timedelta(seconds=60.0),
98+
default_power=default_power,
8799
)
88100
case _:
89101
assert_never(algorithm)

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from ... import timeseries
1818
from . import _bounds
19-
from ._base_classes import BaseAlgorithm, Proposal, _Report
19+
from ._base_classes import BaseAlgorithm, DefaultPower, Proposal, _Report
2020

2121
if typing.TYPE_CHECKING:
2222
from ...timeseries._base_types import SystemBounds
@@ -55,8 +55,13 @@ class ShiftingMatryoshka(BaseAlgorithm):
5555
Details about the algorithm can be found in the [microgrid module documentation](https://frequenz-floss.github.io/frequenz-sdk-python/v1.0-dev/user-guide/microgrid-concepts/#frequenz.sdk.microgrid--setting-power).
5656
""" # noqa: E501 (line too long)
5757

58-
def __init__(self, max_proposal_age: timedelta) -> None:
58+
def __init__(
59+
self,
60+
max_proposal_age: timedelta,
61+
default_power: DefaultPower,
62+
) -> None:
5963
"""Create a new instance of the matryoshka algorithm."""
64+
self._default_power = default_power
6065
self._max_proposal_age_sec = max_proposal_age.total_seconds()
6166
self._component_buckets: dict[frozenset[int], set[Proposal]] = {}
6267
self._target_power: dict[frozenset[int], Power] = {}
@@ -250,12 +255,28 @@ def calculate_target_power(
250255
bucket.add(proposal)
251256
elif not bucket:
252257
del self._component_buckets[component_ids]
253-
_ = self._target_power.pop(component_ids, None)
254258

255259
target_power, _ = self._calc_targets(component_ids, system_bounds)
256260

257261
if target_power is not None:
258262
self._target_power[component_ids] = target_power
263+
elif self._target_power.get(component_ids) is not None:
264+
# If the target power was previously set, but is now `None`, then we send
265+
# the default power of the component category, to reset it immediately.
266+
del self._target_power[component_ids]
267+
bounds = system_bounds.inclusion_bounds
268+
if bounds is None:
269+
return None
270+
match self._default_power:
271+
case DefaultPower.MIN:
272+
return bounds.lower
273+
case DefaultPower.MAX:
274+
return bounds.upper
275+
case DefaultPower.ZERO:
276+
return Power.zero()
277+
case other:
278+
typing.assert_never(other)
279+
259280
return target_power
260281

261282
@override

src/frequenz/sdk/microgrid/_power_wrapper.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
Request,
2424
Result,
2525
)
26-
from ._power_managing._base_classes import Algorithm
26+
from ._power_managing._base_classes import Algorithm, DefaultPower
2727

2828
_logger = logging.getLogger(__name__)
2929

3030

31-
class PowerWrapper:
31+
class PowerWrapper: # pylint: disable=too-many-instance-attributes
3232
"""Wrapper around the power managing and power distributing actors."""
3333

3434
def __init__( # pylint: disable=too-many-arguments
@@ -37,6 +37,7 @@ def __init__( # pylint: disable=too-many-arguments
3737
*,
3838
api_power_request_timeout: timedelta,
3939
power_manager_algorithm: Algorithm,
40+
default_power: DefaultPower,
4041
component_category: ComponentCategory,
4142
component_type: ComponentType | None = None,
4243
):
@@ -47,6 +48,7 @@ def __init__( # pylint: disable=too-many-arguments
4748
api_power_request_timeout: Timeout to use when making power requests to
4849
the microgrid API.
4950
power_manager_algorithm: The power management algorithm to use.
51+
default_power: The default power to use for the components.
5052
component_category: The category of the components that actors started by
5153
this instance of the PowerWrapper will be responsible for.
5254
component_type: The type of the component of the given category that this
@@ -57,6 +59,7 @@ def __init__( # pylint: disable=too-many-arguments
5759
`None` when the component category is enough to uniquely identify the
5860
component.
5961
"""
62+
self._default_power = default_power
6063
self._component_category = component_category
6164
self._component_type = component_type
6265
self._power_manager_algorithm = power_manager_algorithm
@@ -103,6 +106,7 @@ def _start_power_managing_actor(self) -> None:
103106
return
104107

105108
self._power_managing_actor = _power_managing.PowerManagingActor(
109+
default_power=self._default_power,
106110
component_category=self._component_category,
107111
component_type=self._component_type,
108112
algorithm=self._power_manager_algorithm,

tests/actor/_power_managing/test_matryoshka.py

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

1313
from frequenz.sdk import timeseries
1414
from frequenz.sdk.microgrid._power_managing import Proposal
15+
from frequenz.sdk.microgrid._power_managing._base_classes import DefaultPower
1516
from frequenz.sdk.microgrid._power_managing._matryoshka import Matryoshka
1617
from frequenz.sdk.timeseries import _base_types
1718

@@ -28,7 +29,9 @@ def __init__(
2829
self._call_count = 0
2930
self._batteries = batteries
3031
self._system_bounds = system_bounds
31-
self.algorithm = Matryoshka(max_proposal_age=timedelta(seconds=60.0))
32+
self.algorithm = Matryoshka(
33+
max_proposal_age=timedelta(seconds=60.0), default_power=DefaultPower.ZERO
34+
)
3235

3336
def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-arguments
3437
self,
@@ -510,7 +513,7 @@ def ensure_overlapping_bucket_request_fails() -> None:
510513
ensure_overlapping_bucket_request_fails()
511514
tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=25.0)
512515
ensure_overlapping_bucket_request_fails()
513-
tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=None)
516+
tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=0.0)
514517

515518
# Overlapping battery bucket is dropped.
516519
tester.tgt_power(

tests/actor/_power_managing/test_shifting_matryoshka.py

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

1515
from frequenz.sdk import timeseries
1616
from frequenz.sdk.microgrid._power_managing import Proposal
17+
from frequenz.sdk.microgrid._power_managing._base_classes import DefaultPower
1718
from frequenz.sdk.microgrid._power_managing._shifting_matryoshka import (
1819
ShiftingMatryoshka,
1920
)
@@ -32,7 +33,9 @@ def __init__(
3233
self._call_count = 0
3334
self._batteries = batteries
3435
self._system_bounds = system_bounds
35-
self.algorithm = ShiftingMatryoshka(max_proposal_age=timedelta(seconds=60.0))
36+
self.algorithm = ShiftingMatryoshka(
37+
max_proposal_age=timedelta(seconds=60.0), default_power=DefaultPower.ZERO
38+
)
3639

3740
def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-arguments
3841
self,
@@ -543,7 +546,7 @@ def ensure_overlapping_bucket_request_fails() -> None:
543546
ensure_overlapping_bucket_request_fails()
544547
tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=25.0)
545548
ensure_overlapping_bucket_request_fails()
546-
tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=None)
549+
tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=0.0)
547550

548551
# Overlapping battery bucket is dropped.
549552
tester.tgt_power(

tests/timeseries/_battery_pool/test_battery_pool_control_methods.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,3 +527,26 @@ async def test_case_4(self, mocks: Mocks, mocker: MockerFixture) -> None:
527527
result, _power_distributing.Success
528528
),
529529
)
530+
531+
# Resetting the power should lead to default (zero) power getting set for all
532+
# the batteries.
533+
set_power.reset_mock()
534+
await battery_pool.propose_power(None)
535+
self._assert_report(
536+
await bounds_rx.receive(), power=None, lower=-4000.0, upper=4000.0
537+
)
538+
await asyncio.sleep(0.0)
539+
assert set_power.call_count == 4
540+
assert sorted(set_power.call_args_list) == [
541+
mocker.call(inv_id, 0.0) for inv_id in mocks.microgrid.battery_inverter_ids
542+
]
543+
self._assert_report(
544+
await bounds_rx.receive(),
545+
power=None,
546+
lower=-4000.0,
547+
upper=4000.0,
548+
dist_result=latest_dist_result.get(),
549+
expected_result_pred=lambda result: isinstance(
550+
result, _power_distributing.Success
551+
),
552+
)

tests/timeseries/_pv_pool/test_pv_pool_control_methods.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,23 @@ async def test_setting_power( # pylint: disable=too-many-statements
279279
mocker.call(inv_ids[2], 0.0),
280280
mocker.call(inv_ids[3], 0.0),
281281
]
282+
283+
# Resetting the power should lead to default (full) power getting set for all
284+
# inverters.
285+
set_power.reset_mock()
286+
await pv_pool.propose_power(None)
287+
report = await self._recv_reports_until(
288+
bounds_rx,
289+
lambda x: x.target_power is None,
290+
)
291+
self._assert_report(report, power=None, lower=-100000.0, upper=0.0)
292+
await asyncio.sleep(0.0)
293+
294+
assert set_power.call_count == 4
295+
inv_ids = mocks.microgrid.pv_inverter_ids
296+
assert sorted(set_power.call_args_list, key=lambda x: x.args[0]) == [
297+
mocker.call(inv_ids[0], -10_000.0),
298+
mocker.call(inv_ids[1], -20_000.0),
299+
mocker.call(inv_ids[2], -30_000.0),
300+
mocker.call(inv_ids[3], -40_000.0),
301+
]

0 commit comments

Comments
 (0)