Skip to content

Commit 87c2e35

Browse files
authored
Implement the ShiftingMatryoshka algorithm for the PowerManager (#1146)
This PR removes the `set_operating_point` feature and replaces the power manager's original `Matryoshka` algorithm with the new `ShiftingMatryoshka` algorithm. With the new algorithm, power proposals from actors are added to get the target power for the components, and higher-priority actors can limit the bounds available to lower-priority actors.
2 parents f5e1ad3 + 95e326c commit 87c2e35

File tree

13 files changed

+1127
-579
lines changed

13 files changed

+1127
-579
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
- The `microgrid.new_*_pool` methods no longer accept a `set_operating_point` parameter.
10+
- The power manager now uses a new algorithm described [here](https://frequenz-floss.github.io/frequenz-sdk-python/v1.0-dev/user-guide/microgrid-concepts/#frequenz.sdk.microgrid--setting-power).
1011

1112
## New Features
1213

src/frequenz/sdk/microgrid/__init__.py

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -174,53 +174,128 @@
174174
controlling batteries, power could be distributed based on the `SoC` of the
175175
individual batteries, to keep the batteries in balance.
176176
177-
### Resolving conflicting power proposals
177+
### How to work with other actors
178178
179-
When there are multiple actors trying to control the same set of batteries, a
180-
target power is calculated based on the priorities of the actors making the
181-
requests. Actors need to specify their priorities as parameters when creating
182-
the `*Pool` instances using the constructors mentioned above.
179+
If multiple actors are trying to control (by proposing power values) the same
180+
set of components, the power manager will aggregate their desired power values,
181+
while considering the priority of the actors and the bounds they set, to
182+
calculate the target power for the components.
183183
184-
The algorithm used for resolving power conflicts based on actor priority can be
185-
found in the documentation for any of the
186-
[`propose_power`][frequenz.sdk.timeseries.battery_pool.BatteryPool.propose_power]
187-
methods.
184+
The final target power can be accessed using the receiver returned from the
185+
[`power_status`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power_status]
186+
method available for all pools, which also streams the bounds that an actor
187+
should comply with, based on its priority.
188188
189-
### Shifting the target power by an Operating Point power
189+
#### Adding the power proposals of individual actors
190190
191-
There are cases where the target power needs to be shifted by an operating point. This
192-
can be done by designating some actors to be able to set only the operating point power.
191+
When an actor A calls the `propose_power` method with a power, the proposed
192+
power of the lower priority actor will get added to actor A's power. This works
193+
as follows:
193194
194-
When creating a `*Pool` instance using the above-mentioned constructors, an optional
195-
`set_operating_point` parameter can be passed to specify that this actor is special, and
196-
the target power of the regular actors will be shifted by the target power of all actors
197-
with `set_operating_point` together.
195+
- the lower priority actor would see bounds shifted by the power proposed by
196+
actor A.
197+
- After lower priority actor B sets a power in its shifted bounds, it will get
198+
shifted back by the power set by actor A.
198199
199-
In a location with 2 regular actors and 1 `set_operating_point` actor, here's how things
200-
would play out:
200+
This has the effect of adding the powers set by actors A and B.
201201
202-
1. When only regular actors have made proposals, the power bounds available from the
203-
batteries are available to them exactly.
202+
*Example 1*: Battery bounds available for use: -100kW to 100kW
204203
205-
| actor priority | in op group? | proposed power/bounds | available bounds |
206-
|----------------|--------------|-----------------------|------------------|
207-
| 3 | No | 1000, -4000..2500 | -3000..3000 |
208-
| 2 | No | 2500 | -3000..2500 |
209-
| 1 | Yes | None | -3000..3000 |
204+
| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate |
205+
| | | | | Power | Power | Power |
206+
|-------|----------|-----------------|------------------|-----------|--------------|-----------|
207+
| A | 3 | -100kW .. 100kW | None | 20kW | 20kW | 20kW |
208+
| B | 2 | -120kW .. 80kW | None | 50kW | 50kW | 70kW |
209+
| C | 1 | -170kW .. 30kW | None | 50kW | 30kW | 100kW |
210+
| | | | | | target power | 100kW |
210211
211-
Power actually distributed to the batteries: 2500W
212+
Actor A proposes a power of `20kW`, but no bounds. In this case, actor B sees
213+
bounds shifted by A's proposal. Actor B proposes a power of `50kW` on this
214+
shifted range, and if this is applied on to the original bounds (aka shift the
215+
bounds back to the original range), it would be `20kW + 50kW = 70kW`.
212216
213-
2. When the `set_operating_point` actor has made proposals, the bounds available to the
214-
regular actors gets shifted, and the final power that actually gets distributed to
215-
the batteries is also shifted.
217+
So Actor C sees bounds shifted by `70kW` from the original bounds, and sets
218+
`50kW` on this shifted range, but it can't exceed `30kW`, so its request gets
219+
limited to 30kW. Shifting this back by `70kW`, the target power is calculated
220+
to be `100kW`.
216221
217-
| actor priority | in op group? | proposed power/bounds | available bounds |
218-
|----------------|--------------|-----------------------|------------------|
219-
| 3 | No | 1000, -4000..2500 | -2000..4000 |
220-
| 2 | No | 2500 | -2000..2500 |
221-
| 1 | Yes | -1000 | -3000..3000 |
222+
Irrespective of what any actor sets, the final power won't exceed the available
223+
battery bounds.
224+
225+
*Example 2*:
226+
227+
| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate |
228+
| | | | | Power | Power | Power |
229+
|-------|----------|-----------------|------------------|-----------|--------------|-----------|
230+
| A | 3 | -100kW .. 100kW | None | 20kW | 20kW | 20kW |
231+
| B | 2 | -120kW .. 80kW | None | -20kW | -20kW | 0kW |
232+
| | | | | | target power | 0kW |
233+
234+
Actors with exactly opposite requests cancel each other out.
235+
236+
#### Limiting bounds for lower priority actors
237+
238+
When an actor A calls the `propose_power` method with bounds (either both lower
239+
and upper bounds or at least one of them), lower priority actors will see their
240+
(shifted) bounds restricted and can only propose power values within that range.
241+
242+
*Example 1*: Battery bounds available for use: -100kW to 100kW
243+
244+
| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate |
245+
| | | | | Power | Power | Power |
246+
|-------|----------|-----------------|------------------|-----------|--------------|-----------|
247+
| A | 3 | -100kW .. 100kW | -20kW .. 100kW | 50kW | 40kW | 50kW |
248+
| B | 2 | -70kW .. 50kW | -90kW .. 0kW | -10kW | -10kW | 40kW |
249+
| C | 1 | -60kW .. 10kW | None | -20kW | -20kW | 20kW |
250+
| | | | | | target power | 20kW |
251+
252+
Actor A with the highest priority has the entire battery bounds available to it.
253+
It sets limited bounds of -20kW .. 100kW, and proposes a power of 50kW.
254+
255+
Actor B sees Actor A's limit of -20kW..100kW shifted by 50kW as -70kW..50kW, and
256+
can only propose powers within this range, which will get added (shifted back)
257+
to Actor A's proposed power.
258+
259+
Actor B tries to limit the bounds of actor C to -90kW .. 0kW, but it can only
260+
operate in the -70kW .. 50kW range because of bounds set by actor A, so its
261+
requested bounds get restricted to -70kW .. 0kW.
262+
263+
Actor C sees this as -60kW .. 10kW, because it gets shifted by Actor B's
264+
proposed power of -10kW.
265+
266+
Actor C proposes a power within its bounds and the proposals of all the actors
267+
are added to get the target power.
268+
269+
*Example 2*:
270+
271+
| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate |
272+
| | | | | Power | Power | Power |
273+
|-------|----------|-----------------|------------------|-----------|--------------|-----------|
274+
| A | 3 | -100kW .. 100kW | -20kW .. 100kW | 50kW | 50kW | 50kW |
275+
| B | 2 | -70kW .. 50kW | -90kW .. 0kW | -90kW | -70kW | -20kW |
276+
| | | | | | target power | -20kW |
277+
278+
When an actor requests a power that's outside its available bounds, the closest
279+
available power is used.
280+
281+
#### Comprehensive example
282+
283+
Battery bounds available for use: -100kW to 100kW
284+
285+
| Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate |
286+
| | | | Power | Power | Power |
287+
|----------|-------------------|------------------|-----------|--------------|-----------|
288+
| 7 | -100 kW .. 100 kW | None | 10 kW | 10 kW | 10 kW |
289+
| 6 | -110 kW .. 90 kW | -110 kW .. 80 kW | 10 kW | 10 kW | 20 kW |
290+
| 5 | -120 kW .. 70 kW | -100 kW .. 80 kW | 80 kW | 70 kW | 90 kW |
291+
| 4 | -170 kW .. 0 kW | None | -120 kW | -120 kW | -30 kW |
292+
| 3 | -50 kW .. 120 kW | None | 60 kW | 60 kW | 30 kW |
293+
| 2 | -110 kW .. 60 kW | -40 kW .. 30 kW | 20 kW | 20 kW | 50 kW |
294+
| 1 | -60 kW .. 10 kW | -50 kW .. 40 kW | 25 kW | 10 kW | 60 kW |
295+
| 0 | -60 kW .. 0 kW | None | 12 kW | 0 kW | 60 kW |
296+
| -1 | -60 kW .. 0 kW | -40 kW .. -10 kW | -10 kW | -10 kW | 50 kW |
297+
| | | | | Target Power | 50 kW |
222298
223-
Power actually distributed to the batteries: 1500W
224299
""" # noqa: D205, D400
225300

226301
from datetime import timedelta

src/frequenz/sdk/microgrid/_data_pipeline.py

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,6 @@ def new_ev_charger_pool(
210210
priority: int,
211211
component_ids: abc.Set[int] | None = None,
212212
name: str | None = None,
213-
set_operating_point: bool = False,
214213
) -> EVChargerPool:
215214
"""Return the corresponding EVChargerPool instance for the given ids.
216215
@@ -223,8 +222,6 @@ def new_ev_charger_pool(
223222
EVChargerPool.
224223
name: An optional name used to identify this instance of the pool or a
225224
corresponding actor in the logs.
226-
set_operating_point: Whether this instance sets the operating point power or
227-
the normal power for the components.
228225
229226
Returns:
230227
An EVChargerPool instance.
@@ -281,7 +278,6 @@ def new_ev_charger_pool(
281278
pool_ref_store=self._ev_charger_pool_reference_stores[ref_store_key],
282279
name=name,
283280
priority=priority,
284-
set_operating_point=set_operating_point,
285281
)
286282

287283
def new_pv_pool(
@@ -290,7 +286,6 @@ def new_pv_pool(
290286
priority: int,
291287
component_ids: abc.Set[int] | None = None,
292288
name: str | None = None,
293-
set_operating_point: bool = False,
294289
) -> PVPool:
295290
"""Return a new `PVPool` instance for the given ids.
296291
@@ -303,8 +298,6 @@ def new_pv_pool(
303298
`PVPool`.
304299
name: An optional name used to identify this instance of the pool or a
305300
corresponding actor in the logs.
306-
set_operating_point: Whether this instance sets the operating point power or
307-
the normal power for the components.
308301
309302
Returns:
310303
A `PVPool` instance.
@@ -358,7 +351,6 @@ def new_pv_pool(
358351
pool_ref_store=self._pv_pool_reference_stores[ref_store_key],
359352
name=name,
360353
priority=priority,
361-
set_operating_point=set_operating_point,
362354
)
363355

364356
def new_battery_pool(
@@ -367,7 +359,6 @@ def new_battery_pool(
367359
priority: int,
368360
component_ids: abc.Set[int] | None = None,
369361
name: str | None = None,
370-
set_operating_point: bool = False,
371362
) -> BatteryPool:
372363
"""Return a new `BatteryPool` instance for the given ids.
373364
@@ -380,8 +371,6 @@ def new_battery_pool(
380371
`BatteryPool`.
381372
name: An optional name used to identify this instance of the pool or a
382373
corresponding actor in the logs.
383-
set_operating_point: Whether this instance sets the operating point power or
384-
the normal power for the components.
385374
386375
Returns:
387376
A `BatteryPool` instance.
@@ -440,7 +429,6 @@ def new_battery_pool(
440429
pool_ref_store=self._battery_pool_reference_stores[ref_store_key],
441430
name=name,
442431
priority=priority,
443-
set_operating_point=set_operating_point,
444432
)
445433

446434
def _data_sourcing_request_sender(self) -> Sender[ComponentMetricRequest]:
@@ -557,7 +545,6 @@ def new_ev_charger_pool(
557545
priority: int,
558546
component_ids: abc.Set[int] | None = None,
559547
name: str | None = None,
560-
set_operating_point: bool = False,
561548
) -> EVChargerPool:
562549
"""Return a new `EVChargerPool` instance for the given parameters.
563550
@@ -583,17 +570,12 @@ def new_ev_charger_pool(
583570
component graph are used.
584571
name: An optional name used to identify this instance of the pool or a
585572
corresponding actor in the logs.
586-
set_operating_point: Whether this instance sets the operating point power or the
587-
normal power for the components.
588573
589574
Returns:
590575
An `EVChargerPool` instance.
591576
"""
592577
return _get().new_ev_charger_pool(
593-
priority=priority,
594-
component_ids=component_ids,
595-
name=name,
596-
set_operating_point=set_operating_point,
578+
priority=priority, component_ids=component_ids, name=name
597579
)
598580

599581

@@ -602,7 +584,6 @@ def new_battery_pool(
602584
priority: int,
603585
component_ids: abc.Set[int] | None = None,
604586
name: str | None = None,
605-
set_operating_point: bool = False,
606587
) -> BatteryPool:
607588
"""Return a new `BatteryPool` instance for the given parameters.
608589
@@ -628,17 +609,12 @@ def new_battery_pool(
628609
graph are used.
629610
name: An optional name used to identify this instance of the pool or a
630611
corresponding actor in the logs.
631-
set_operating_point: Whether this instance sets the operating point power or the
632-
normal power for the components.
633612
634613
Returns:
635614
A `BatteryPool` instance.
636615
"""
637616
return _get().new_battery_pool(
638-
priority=priority,
639-
component_ids=component_ids,
640-
name=name,
641-
set_operating_point=set_operating_point,
617+
priority=priority, component_ids=component_ids, name=name
642618
)
643619

644620

@@ -647,7 +623,6 @@ def new_pv_pool(
647623
priority: int,
648624
component_ids: abc.Set[int] | None = None,
649625
name: str | None = None,
650-
set_operating_point: bool = False,
651626
) -> PVPool:
652627
"""Return a new `PVPool` instance for the given parameters.
653628
@@ -673,18 +648,11 @@ def new_pv_pool(
673648
graph are used.
674649
name: An optional name used to identify this instance of the pool or a
675650
corresponding actor in the logs.
676-
set_operating_point: Whether this instance sets the operating point power or the
677-
normal power for the components.
678651
679652
Returns:
680653
A `PVPool` instance.
681654
"""
682-
return _get().new_pv_pool(
683-
priority=priority,
684-
component_ids=component_ids,
685-
name=name,
686-
set_operating_point=set_operating_point,
687-
)
655+
return _get().new_pv_pool(priority=priority, component_ids=component_ids, name=name)
688656

689657

690658
def grid() -> Grid:

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

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ class ReportRequest:
3232
priority: int
3333
"""The priority of the actor ."""
3434

35-
set_operating_point: bool
36-
"""Whether this proposal sets the operating point power or the normal power."""
37-
3835
def get_channel_name(self) -> str:
3936
"""Get the channel name for the report request.
4037
@@ -157,9 +154,6 @@ class Proposal:
157154
This is used by the power manager to determine the age of the proposal.
158155
"""
159156

160-
set_operating_point: bool
161-
"""Whether this proposal sets the operating point power or the normal power."""
162-
163157
def __lt__(self, other: Proposal) -> bool:
164158
"""Compare two proposals by their priority.
165159
@@ -209,6 +203,7 @@ class Algorithm(enum.Enum):
209203
"""The available algorithms for the power manager."""
210204

211205
MATRYOSHKA = "matryoshka"
206+
SHIFTING_MATRYOSHKA = "shifting_matryoshka"
212207

213208

214209
class BaseAlgorithm(abc.ABC):
@@ -237,21 +232,6 @@ def calculate_target_power(
237232
didn't change.
238233
"""
239234

240-
@abc.abstractmethod
241-
def get_target_power(
242-
self,
243-
component_ids: frozenset[int],
244-
) -> Power | None:
245-
"""Get the target power for the given components.
246-
247-
Args:
248-
component_ids: The component IDs to get the target power for.
249-
250-
Returns:
251-
The target power for the given components, or `None` if there is no target
252-
power.
253-
"""
254-
255235
# The arguments for this method are tightly coupled to the `Matryoshka` algorithm.
256236
# It can be loosened up when more algorithms are added.
257237
@abc.abstractmethod

0 commit comments

Comments
 (0)