Skip to content

Commit 92a268f

Browse files
authored
Reset components to default power when all proposals are withdrawn (frequenz-floss#1212)
Previously, when all proposals for a set of components were withdrawn with `pool.propose_power(None)`, the power manager would just stop setting powers for those components, and the last set powers remain until they get reset by the API service or some other process. This is not desirable, and instead the components should get set back to their respective default powers, according to the table below. This is implemented in this PR. | component category | default power | |--------------------|-------------------------------------------| | Battery | 0.0 | | PV | Minimum power (aka max production power) | | EV Chargers | Maximum power (aka max consumption power) |
2 parents 1c5332f + 9f14a0d commit 92a268f

File tree

13 files changed

+271
-181
lines changed

13 files changed

+271
-181
lines changed

RELEASE_NOTES.md

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

1515
## Bug Fixes
1616

17-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
17+
- Components used to be just forgotten by the power manager when all proposals are withdrawn, leaving them at their last set power values. This has been fixed by getting the power manager to set the components to their default powers, based on the component category (according to the table below), as the last step.
18+
19+
20+
| component category | default power |
21+
|--------------------|-------------------------------------------|
22+
| Battery | 0.0 |
23+
| PV | Minimum power (aka max production power) |
24+
| EV Chargers | Maximum power (aka max consumption power) |

src/frequenz/sdk/microgrid/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,21 @@
296296
| -1 | -60 kW .. 0 kW | -40 kW .. -10 kW | -10 kW | -10 kW | 50 kW |
297297
| | | | | Target Power | 50 kW |
298298
299+
## Withdrawing power proposals
300+
301+
An actor can withdraw its power proposal by calling `propose_power` with `None`
302+
target_power and `None` bounds (which are the default anyway). As soon as an actor
303+
calls `pool.propose_power(None)`, its proposal is dropped and the target power is
304+
recalculated and the component powers are updated.
305+
306+
When all the proposals for a pool are withdrawn, the components get reset to their
307+
default powers immediately. These are:
308+
309+
| component category | default power (according to Passive Sign Convention) |
310+
|--------------------|------------------------------------------------------|
311+
| Batteries | Zero |
312+
| PV | Max production (Min power according to PSC) |
313+
| EV Chargers | Max consumption (Max power according to PSC) |
299314
""" # noqa: D205, D400
300315

301316
from datetime import timedelta

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: 14 additions & 4 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

@@ -215,7 +228,6 @@ def calculate_target_power(
215228
component_ids: frozenset[int],
216229
proposal: Proposal | None,
217230
system_bounds: SystemBounds,
218-
must_return_power: bool = False,
219231
) -> Power | None:
220232
"""Calculate and return the target power for the given components.
221233
@@ -224,12 +236,10 @@ def calculate_target_power(
224236
proposal: If given, the proposal to added to the bucket, before the target
225237
power is calculated.
226238
system_bounds: The system bounds for the components in the proposal.
227-
must_return_power: If `True`, the algorithm must return a target power,
228-
even if it hasn't changed since the last call.
229239
230240
Returns:
231241
The new target power for the components, or `None` if the target power
232-
didn't change.
242+
couldn't be calculated.
233243
"""
234244

235245
# The arguments for this method are tightly coupled to the `Matryoshka` algorithm.

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

Lines changed: 30 additions & 19 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,17 +39,20 @@
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

4851
def _calc_target_power(
4952
self,
5053
proposals: set[Proposal],
5154
system_bounds: SystemBounds,
52-
) -> Power:
55+
) -> Power | None:
5356
"""Calculate the target power for the given components.
5457
5558
Args:
@@ -80,7 +83,7 @@ def _calc_target_power(
8083
):
8184
exclusion_bounds = system_bounds.exclusion_bounds
8285

83-
target_power = Power.zero()
86+
target_power = None
8487
for next_proposal in sorted(proposals, reverse=True):
8588
if upper_bound < lower_bound:
8689
break
@@ -158,7 +161,6 @@ def calculate_target_power(
158161
component_ids: frozenset[int],
159162
proposal: Proposal | None,
160163
system_bounds: SystemBounds,
161-
must_return_power: bool = False,
162164
) -> Power | None:
163165
"""Calculate and return the target power for the given components.
164166
@@ -167,12 +169,10 @@ def calculate_target_power(
167169
proposal: If given, the proposal to added to the bucket, before the target
168170
power is calculated.
169171
system_bounds: The system bounds for the components in the proposal.
170-
must_return_power: If `True`, the algorithm must return a target power,
171-
even if it hasn't changed since the last call.
172172
173173
Returns:
174174
The new target power for the components, or `None` if the target power
175-
didn't change.
175+
couldn't be calculated.
176176
177177
Raises: # noqa: DOC502
178178
NotImplementedError: When the proposal contains component IDs that are
@@ -193,24 +193,35 @@ def calculate_target_power(
193193
bucket.add(proposal)
194194
elif not bucket:
195195
del self._component_buckets[component_ids]
196-
_ = self._target_power.pop(component_ids, None)
197196

198197
# If there has not been any proposal for the given components, don't calculate a
199198
# target power and just return `None`.
200199
proposals = self._component_buckets.get(component_ids)
201-
if proposals is None:
202-
return None
203200

204-
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)
205204

206-
if (
207-
must_return_power
208-
or component_ids not in self._target_power
209-
or self._target_power[component_ids] != target_power
210-
):
205+
if target_power is not None:
211206
self._target_power[component_ids] = target_power
212-
return target_power
213-
return None
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)
223+
224+
return target_power
214225

215226
@override
216227
def get_status(

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

Lines changed: 24 additions & 9 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)
@@ -121,7 +133,14 @@ async def _bounds_tracker(
121133
collective bounds of.
122134
bounds_receiver: The receiver for power bounds.
123135
"""
136+
last_bounds: SystemBounds | None = None
124137
async for bounds in bounds_receiver:
138+
if (
139+
last_bounds is not None
140+
and bounds.inclusion_bounds == last_bounds.inclusion_bounds
141+
):
142+
continue
143+
last_bounds = bounds
125144
self._system_bounds[component_ids] = bounds
126145
await self._send_updated_target_power(component_ids, None)
127146
await self._send_reports(component_ids)
@@ -179,13 +198,11 @@ async def _send_updated_target_power(
179198
self,
180199
component_ids: frozenset[int],
181200
proposal: Proposal | None,
182-
must_send: bool = False,
183201
) -> None:
184202
target_power = self._algorithm.calculate_target_power(
185203
component_ids,
186204
proposal,
187205
self._system_bounds[component_ids],
188-
must_send,
189206
)
190207
if target_power is not None:
191208
await self._power_distributing_requests_sender.send(
@@ -221,9 +238,7 @@ async def _run(self) -> None:
221238
# This can be removed as soon as
222239
# https://github.com/frequenz-floss/frequenz-sdk-python/issues/293 is
223240
# implemented.
224-
await self._send_updated_target_power(
225-
proposal.component_ids, proposal, must_send=True
226-
)
241+
await self._send_updated_target_power(proposal.component_ids, proposal)
227242
await self._send_reports(proposal.component_ids)
228243

229244
elif selected_from(selected, self._bounds_subscription_receiver):
@@ -258,7 +273,7 @@ async def _run(self) -> None:
258273
if not last_result_partial_failure:
259274
last_result_partial_failure = True
260275
await self._send_updated_target_power(
261-
frozenset(request.component_ids), None, must_send=True
276+
frozenset(request.component_ids), None
262277
)
263278
case _power_distributing.Success():
264279
last_result_partial_failure = False

0 commit comments

Comments
 (0)