diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58b0e962b..13cecc720 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,7 +6,8 @@ ## Upgrading - +- The `microgrid.new_*_pool` methods no longer accept a `set_operating_point` parameter. +- 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). ## New Features diff --git a/src/frequenz/sdk/microgrid/__init__.py b/src/frequenz/sdk/microgrid/__init__.py index 213842e8f..633b89967 100644 --- a/src/frequenz/sdk/microgrid/__init__.py +++ b/src/frequenz/sdk/microgrid/__init__.py @@ -174,53 +174,128 @@ controlling batteries, power could be distributed based on the `SoC` of the individual batteries, to keep the batteries in balance. -### Resolving conflicting power proposals +### How to work with other actors -When there are multiple actors trying to control the same set of batteries, a -target power is calculated based on the priorities of the actors making the -requests. Actors need to specify their priorities as parameters when creating -the `*Pool` instances using the constructors mentioned above. +If multiple actors are trying to control (by proposing power values) the same +set of components, the power manager will aggregate their desired power values, +while considering the priority of the actors and the bounds they set, to +calculate the target power for the components. -The algorithm used for resolving power conflicts based on actor priority can be -found in the documentation for any of the -[`propose_power`][frequenz.sdk.timeseries.battery_pool.BatteryPool.propose_power] -methods. +The final target power can be accessed using the receiver returned from the +[`power_status`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power_status] +method available for all pools, which also streams the bounds that an actor +should comply with, based on its priority. -### Shifting the target power by an Operating Point power +#### Adding the power proposals of individual actors -There are cases where the target power needs to be shifted by an operating point. This -can be done by designating some actors to be able to set only the operating point power. +When an actor A calls the `propose_power` method with a power, the proposed +power of the lower priority actor will get added to actor A's power. This works +as follows: -When creating a `*Pool` instance using the above-mentioned constructors, an optional -`set_operating_point` parameter can be passed to specify that this actor is special, and -the target power of the regular actors will be shifted by the target power of all actors -with `set_operating_point` together. + - the lower priority actor would see bounds shifted by the power proposed by + actor A. + - After lower priority actor B sets a power in its shifted bounds, it will get + shifted back by the power set by actor A. -In a location with 2 regular actors and 1 `set_operating_point` actor, here's how things -would play out: +This has the effect of adding the powers set by actors A and B. -1. When only regular actors have made proposals, the power bounds available from the - batteries are available to them exactly. +*Example 1*: Battery bounds available for use: -100kW to 100kW - | actor priority | in op group? | proposed power/bounds | available bounds | - |----------------|--------------|-----------------------|------------------| - | 3 | No | 1000, -4000..2500 | -3000..3000 | - | 2 | No | 2500 | -3000..2500 | - | 1 | Yes | None | -3000..3000 | +| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate | +| | | | | Power | Power | Power | +|-------|----------|-----------------|------------------|-----------|--------------|-----------| +| A | 3 | -100kW .. 100kW | None | 20kW | 20kW | 20kW | +| B | 2 | -120kW .. 80kW | None | 50kW | 50kW | 70kW | +| C | 1 | -170kW .. 30kW | None | 50kW | 30kW | 100kW | +| | | | | | target power | 100kW | - Power actually distributed to the batteries: 2500W +Actor A proposes a power of `20kW`, but no bounds. In this case, actor B sees +bounds shifted by A's proposal. Actor B proposes a power of `50kW` on this +shifted range, and if this is applied on to the original bounds (aka shift the +bounds back to the original range), it would be `20kW + 50kW = 70kW`. -2. When the `set_operating_point` actor has made proposals, the bounds available to the - regular actors gets shifted, and the final power that actually gets distributed to - the batteries is also shifted. +So Actor C sees bounds shifted by `70kW` from the original bounds, and sets +`50kW` on this shifted range, but it can't exceed `30kW`, so its request gets +limited to 30kW. Shifting this back by `70kW`, the target power is calculated +to be `100kW`. - | actor priority | in op group? | proposed power/bounds | available bounds | - |----------------|--------------|-----------------------|------------------| - | 3 | No | 1000, -4000..2500 | -2000..4000 | - | 2 | No | 2500 | -2000..2500 | - | 1 | Yes | -1000 | -3000..3000 | +Irrespective of what any actor sets, the final power won't exceed the available +battery bounds. + +*Example 2*: + +| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate | +| | | | | Power | Power | Power | +|-------|----------|-----------------|------------------|-----------|--------------|-----------| +| A | 3 | -100kW .. 100kW | None | 20kW | 20kW | 20kW | +| B | 2 | -120kW .. 80kW | None | -20kW | -20kW | 0kW | +| | | | | | target power | 0kW | + +Actors with exactly opposite requests cancel each other out. + +#### Limiting bounds for lower priority actors + +When an actor A calls the `propose_power` method with bounds (either both lower +and upper bounds or at least one of them), lower priority actors will see their +(shifted) bounds restricted and can only propose power values within that range. + +*Example 1*: Battery bounds available for use: -100kW to 100kW + +| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate | +| | | | | Power | Power | Power | +|-------|----------|-----------------|------------------|-----------|--------------|-----------| +| A | 3 | -100kW .. 100kW | -20kW .. 100kW | 50kW | 40kW | 50kW | +| B | 2 | -70kW .. 50kW | -90kW .. 0kW | -10kW | -10kW | 40kW | +| C | 1 | -60kW .. 10kW | None | -20kW | -20kW | 20kW | +| | | | | | target power | 20kW | + +Actor A with the highest priority has the entire battery bounds available to it. +It sets limited bounds of -20kW .. 100kW, and proposes a power of 50kW. + +Actor B sees Actor A's limit of -20kW..100kW shifted by 50kW as -70kW..50kW, and +can only propose powers within this range, which will get added (shifted back) +to Actor A's proposed power. + +Actor B tries to limit the bounds of actor C to -90kW .. 0kW, but it can only +operate in the -70kW .. 50kW range because of bounds set by actor A, so its +requested bounds get restricted to -70kW .. 0kW. + +Actor C sees this as -60kW .. 10kW, because it gets shifted by Actor B's +proposed power of -10kW. + +Actor C proposes a power within its bounds and the proposals of all the actors +are added to get the target power. + +*Example 2*: + +| Actor | Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate | +| | | | | Power | Power | Power | +|-------|----------|-----------------|------------------|-----------|--------------|-----------| +| A | 3 | -100kW .. 100kW | -20kW .. 100kW | 50kW | 50kW | 50kW | +| B | 2 | -70kW .. 50kW | -90kW .. 0kW | -90kW | -70kW | -20kW | +| | | | | | target power | -20kW | + +When an actor requests a power that's outside its available bounds, the closest +available power is used. + +#### Comprehensive example + +Battery bounds available for use: -100kW to 100kW + +| Priority | System Bounds | Requested Bounds | Requested | Adjusted | Aggregate | +| | | | Power | Power | Power | +|----------|-------------------|------------------|-----------|--------------|-----------| +| 7 | -100 kW .. 100 kW | None | 10 kW | 10 kW | 10 kW | +| 6 | -110 kW .. 90 kW | -110 kW .. 80 kW | 10 kW | 10 kW | 20 kW | +| 5 | -120 kW .. 70 kW | -100 kW .. 80 kW | 80 kW | 70 kW | 90 kW | +| 4 | -170 kW .. 0 kW | None | -120 kW | -120 kW | -30 kW | +| 3 | -50 kW .. 120 kW | None | 60 kW | 60 kW | 30 kW | +| 2 | -110 kW .. 60 kW | -40 kW .. 30 kW | 20 kW | 20 kW | 50 kW | +| 1 | -60 kW .. 10 kW | -50 kW .. 40 kW | 25 kW | 10 kW | 60 kW | +| 0 | -60 kW .. 0 kW | None | 12 kW | 0 kW | 60 kW | +| -1 | -60 kW .. 0 kW | -40 kW .. -10 kW | -10 kW | -10 kW | 50 kW | +| | | | | Target Power | 50 kW | - Power actually distributed to the batteries: 1500W """ # noqa: D205, D400 from datetime import timedelta diff --git a/src/frequenz/sdk/microgrid/_data_pipeline.py b/src/frequenz/sdk/microgrid/_data_pipeline.py index 051fdde2c..b1714398c 100644 --- a/src/frequenz/sdk/microgrid/_data_pipeline.py +++ b/src/frequenz/sdk/microgrid/_data_pipeline.py @@ -210,7 +210,6 @@ def new_ev_charger_pool( priority: int, component_ids: abc.Set[int] | None = None, name: str | None = None, - set_operating_point: bool = False, ) -> EVChargerPool: """Return the corresponding EVChargerPool instance for the given ids. @@ -223,8 +222,6 @@ def new_ev_charger_pool( EVChargerPool. name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. - set_operating_point: Whether this instance sets the operating point power or - the normal power for the components. Returns: An EVChargerPool instance. @@ -281,7 +278,6 @@ def new_ev_charger_pool( pool_ref_store=self._ev_charger_pool_reference_stores[ref_store_key], name=name, priority=priority, - set_operating_point=set_operating_point, ) def new_pv_pool( @@ -290,7 +286,6 @@ def new_pv_pool( priority: int, component_ids: abc.Set[int] | None = None, name: str | None = None, - set_operating_point: bool = False, ) -> PVPool: """Return a new `PVPool` instance for the given ids. @@ -303,8 +298,6 @@ def new_pv_pool( `PVPool`. name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. - set_operating_point: Whether this instance sets the operating point power or - the normal power for the components. Returns: A `PVPool` instance. @@ -358,7 +351,6 @@ def new_pv_pool( pool_ref_store=self._pv_pool_reference_stores[ref_store_key], name=name, priority=priority, - set_operating_point=set_operating_point, ) def new_battery_pool( @@ -367,7 +359,6 @@ def new_battery_pool( priority: int, component_ids: abc.Set[int] | None = None, name: str | None = None, - set_operating_point: bool = False, ) -> BatteryPool: """Return a new `BatteryPool` instance for the given ids. @@ -380,8 +371,6 @@ def new_battery_pool( `BatteryPool`. name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. - set_operating_point: Whether this instance sets the operating point power or - the normal power for the components. Returns: A `BatteryPool` instance. @@ -440,7 +429,6 @@ def new_battery_pool( pool_ref_store=self._battery_pool_reference_stores[ref_store_key], name=name, priority=priority, - set_operating_point=set_operating_point, ) def _data_sourcing_request_sender(self) -> Sender[ComponentMetricRequest]: @@ -557,7 +545,6 @@ def new_ev_charger_pool( priority: int, component_ids: abc.Set[int] | None = None, name: str | None = None, - set_operating_point: bool = False, ) -> EVChargerPool: """Return a new `EVChargerPool` instance for the given parameters. @@ -583,17 +570,12 @@ def new_ev_charger_pool( component graph are used. name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. - set_operating_point: Whether this instance sets the operating point power or the - normal power for the components. Returns: An `EVChargerPool` instance. """ return _get().new_ev_charger_pool( - priority=priority, - component_ids=component_ids, - name=name, - set_operating_point=set_operating_point, + priority=priority, component_ids=component_ids, name=name ) @@ -602,7 +584,6 @@ def new_battery_pool( priority: int, component_ids: abc.Set[int] | None = None, name: str | None = None, - set_operating_point: bool = False, ) -> BatteryPool: """Return a new `BatteryPool` instance for the given parameters. @@ -628,17 +609,12 @@ def new_battery_pool( graph are used. name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. - set_operating_point: Whether this instance sets the operating point power or the - normal power for the components. Returns: A `BatteryPool` instance. """ return _get().new_battery_pool( - priority=priority, - component_ids=component_ids, - name=name, - set_operating_point=set_operating_point, + priority=priority, component_ids=component_ids, name=name ) @@ -647,7 +623,6 @@ def new_pv_pool( priority: int, component_ids: abc.Set[int] | None = None, name: str | None = None, - set_operating_point: bool = False, ) -> PVPool: """Return a new `PVPool` instance for the given parameters. @@ -673,18 +648,11 @@ def new_pv_pool( graph are used. name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. - set_operating_point: Whether this instance sets the operating point power or the - normal power for the components. Returns: A `PVPool` instance. """ - return _get().new_pv_pool( - priority=priority, - component_ids=component_ids, - name=name, - set_operating_point=set_operating_point, - ) + return _get().new_pv_pool(priority=priority, component_ids=component_ids, name=name) def grid() -> Grid: diff --git a/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py b/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py index f4f2ed5ae..7783a8c3e 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py @@ -32,9 +32,6 @@ class ReportRequest: priority: int """The priority of the actor .""" - set_operating_point: bool - """Whether this proposal sets the operating point power or the normal power.""" - def get_channel_name(self) -> str: """Get the channel name for the report request. @@ -157,9 +154,6 @@ class Proposal: This is used by the power manager to determine the age of the proposal. """ - set_operating_point: bool - """Whether this proposal sets the operating point power or the normal power.""" - def __lt__(self, other: Proposal) -> bool: """Compare two proposals by their priority. @@ -209,6 +203,7 @@ class Algorithm(enum.Enum): """The available algorithms for the power manager.""" MATRYOSHKA = "matryoshka" + SHIFTING_MATRYOSHKA = "shifting_matryoshka" class BaseAlgorithm(abc.ABC): @@ -237,21 +232,6 @@ def calculate_target_power( didn't change. """ - @abc.abstractmethod - def get_target_power( - self, - component_ids: frozenset[int], - ) -> Power | None: - """Get the target power for the given components. - - Args: - component_ids: The component IDs to get the target power for. - - Returns: - The target power for the given components, or `None` if there is no target - power. - """ - # The arguments for this method are tightly coupled to the `Matryoshka` algorithm. # It can be loosened up when more algorithms are added. @abc.abstractmethod diff --git a/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py b/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py index 58b037f93..14b55efb6 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py @@ -152,21 +152,6 @@ def _validate_component_ids( ) return True - def get_target_power( - self, - component_ids: frozenset[int], - ) -> Power | None: - """Get the target power for the given components. - - Args: - component_ids: The component IDs to get the target power for. - - Returns: - The target power for the given components, or `None` if there is no target - power. - """ - return self._target_power.get(component_ids) - @override def calculate_target_power( self, diff --git a/src/frequenz/sdk/microgrid/_power_managing/_power_managing_actor.py b/src/frequenz/sdk/microgrid/_power_managing/_power_managing_actor.py index 590ec2b6c..451657672 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_power_managing_actor.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_power_managing_actor.py @@ -9,25 +9,26 @@ import logging import sys from datetime import datetime, timedelta, timezone +from typing import assert_never from frequenz.channels import Receiver, Sender, select, selected_from from frequenz.channels.timer import SkipMissedAndDrift, Timer from frequenz.client.microgrid import ComponentCategory, ComponentType, InverterType -from frequenz.quantities import Power from typing_extensions import override from ..._internal._asyncio import run_forever from ..._internal._channels import ChannelRegistry from ...actor import Actor -from ...timeseries._base_types import Bounds, SystemBounds +from ...timeseries._base_types import SystemBounds from .. import _data_pipeline, _power_distributing from ._base_classes import Algorithm, BaseAlgorithm, Proposal, ReportRequest, _Report from ._matryoshka import Matryoshka +from ._shifting_matryoshka import ShiftingMatryoshka _logger = logging.getLogger(__name__) -class PowerManagingActor(Actor): # pylint: disable=too-many-instance-attributes +class PowerManagingActor(Actor): """The power manager.""" def __init__( # pylint: disable=too-many-arguments @@ -42,7 +43,7 @@ def __init__( # pylint: disable=too-many-arguments component_type: ComponentType | None = None, # arguments to actors need to serializable, so we pass an enum for the algorithm # instead of an instance of the algorithm. - algorithm: Algorithm = Algorithm.MATRYOSHKA, + algorithm: Algorithm = Algorithm.SHIFTING_MATRYOSHKA, ): """Create a new instance of the power manager. @@ -64,15 +65,7 @@ def __init__( # pylint: disable=too-many-arguments `None` when the component category is enough to uniquely identify the component. algorithm: The power management algorithm to use. - - Raises: - NotImplementedError: When an unknown algorithm is given. """ - if algorithm is not Algorithm.MATRYOSHKA: - raise NotImplementedError( - f"PowerManagingActor: Unknown algorithm: {algorithm}" - ) - self._component_category = component_category self._component_type = component_type self._bounds_subscription_receiver = bounds_subscription_receiver @@ -83,19 +76,19 @@ def __init__( # pylint: disable=too-many-arguments self._system_bounds: dict[frozenset[int], SystemBounds] = {} self._bound_tracker_tasks: dict[frozenset[int], asyncio.Task[None]] = {} - self._set_power_subscriptions: dict[ - frozenset[int], dict[int, Sender[_Report]] - ] = {} - self._set_op_power_subscriptions: dict[ - frozenset[int], dict[int, Sender[_Report]] - ] = {} + self._subscriptions: dict[frozenset[int], dict[int, Sender[_Report]]] = {} - self._set_power_group: BaseAlgorithm = Matryoshka( - max_proposal_age=timedelta(seconds=60.0) - ) - self._set_op_power_group: BaseAlgorithm = Matryoshka( - max_proposal_age=timedelta(seconds=60.0) - ) + match algorithm: + case Algorithm.MATRYOSHKA: + self._algorithm: BaseAlgorithm = Matryoshka( + max_proposal_age=timedelta(seconds=60.0) + ) + case Algorithm.SHIFTING_MATRYOSHKA: + self._algorithm = ShiftingMatryoshka( + max_proposal_age=timedelta(seconds=60.0) + ) + case _: + assert_never(algorithm) super().__init__() @@ -110,27 +103,13 @@ async def _send_reports(self, component_ids: frozenset[int]) -> None: if bounds is None: _logger.warning("PowerManagingActor: No bounds for %s", component_ids) return - for priority, sender in self._set_op_power_subscriptions.get( - component_ids, {} - ).items(): - status = self._set_op_power_group.get_status( + for priority, sender in self._subscriptions.get(component_ids, {}).items(): + status = self._algorithm.get_status( component_ids, priority, bounds, ) await sender.send(status) - for priority, sender in self._set_power_subscriptions.get( - component_ids, {} - ).items(): - status = self._set_power_group.get_status( - component_ids, - priority, - self._calculate_shifted_bounds( - bounds, - self._set_op_power_group.get_target_power(component_ids), - ), - ) - await sender.send(status) async def _bounds_tracker( self, @@ -150,7 +129,7 @@ async def _bounds_tracker( await self._send_reports(component_ids) def _add_system_bounds_tracker(self, component_ids: frozenset[int]) -> None: - """Add a bounds tracker. + """Add a system bounds tracker for the given components. Args: component_ids: The component IDs for which to add a bounds tracker. @@ -198,124 +177,16 @@ def _add_system_bounds_tracker(self, component_ids: frozenset[int]) -> None: run_forever(lambda: self._bounds_tracker(component_ids, bounds_receiver)) ) - def _calculate_shifted_bounds( - self, bounds: SystemBounds, op_power: Power | None - ) -> SystemBounds: - """Calculate the shifted bounds shifted by the operating point power. - - Any value regular actors choose within these bounds can be shifted by the - operating point power and still remain within the actual system bounds. - - | system bounds | operating | shifted | - | | point power | bounds | - |---------------+-------------+------------| - | -100 to 100 | 70 | -170 to 30 | - | -100 to 100 | -50 | -50 to 150 | - - Args: - bounds: The bounds to calculate the remaining bounds from. - op_power: The operating point power to shift by. - - Returns: - The remaining bounds. - """ - if op_power is None: - return bounds - - inclusion_bounds: Bounds[Power] | None = None - if bounds.inclusion_bounds is not None: - inclusion_bounds = Bounds( - bounds.inclusion_bounds.lower - op_power, - bounds.inclusion_bounds.upper - op_power, - ) - return SystemBounds( - timestamp=bounds.timestamp, - inclusion_bounds=inclusion_bounds, - exclusion_bounds=bounds.exclusion_bounds, - ) - - def _calculate_target_power( - self, - component_ids: frozenset[int], - proposal: Proposal | None, - must_send: bool = False, - ) -> Power | None: - """Calculate the target power for a set of components. - - This is the target power, shifted by the operating point power. - - Args: - component_ids: The component IDs for which to calculate the target power. - proposal: The proposal to calculate the target power for. - must_send: If `True`, a new request will be sent to the PowerDistributor, - even if there's no change in power. - - Returns: - The target power. - """ - tgt_power_shift: Power | None = None - tgt_power_no_shift: Power | None = None - if proposal is not None: - if proposal.set_operating_point: - tgt_power_shift = self._set_op_power_group.calculate_target_power( - component_ids, - proposal, - self._system_bounds[component_ids], - must_send, - ) - tgt_power_no_shift = self._set_power_group.calculate_target_power( - component_ids, - None, - self._calculate_shifted_bounds( - self._system_bounds[component_ids], tgt_power_shift - ), - must_send, - ) - else: - tgt_power_no_shift = self._set_power_group.calculate_target_power( - component_ids, - proposal, - self._system_bounds[component_ids], - must_send, - ) - tgt_power_shift = self._set_op_power_group.calculate_target_power( - component_ids, - None, - self._calculate_shifted_bounds( - self._system_bounds[component_ids], tgt_power_no_shift - ), - must_send, - ) - else: - tgt_power_no_shift = self._set_power_group.calculate_target_power( - component_ids, - None, - self._system_bounds[component_ids], - must_send, - ) - tgt_power_shift = self._set_op_power_group.calculate_target_power( - component_ids, - None, - self._calculate_shifted_bounds( - self._system_bounds[component_ids], tgt_power_no_shift - ), - must_send, - ) - if tgt_power_shift is not None and tgt_power_no_shift is not None: - return tgt_power_shift + tgt_power_no_shift - if tgt_power_shift is not None: - return tgt_power_shift - return tgt_power_no_shift - async def _send_updated_target_power( self, component_ids: frozenset[int], proposal: Proposal | None, must_send: bool = False, ) -> None: - target_power = self._calculate_target_power( + target_power = self._algorithm.calculate_target_power( component_ids, proposal, + self._system_bounds[component_ids], must_send, ) if target_power is not None: @@ -361,29 +232,22 @@ async def _run(self) -> None: sub = selected.message component_ids = sub.component_ids priority = sub.priority - set_operating_point = sub.set_operating_point - - subs_set = ( - self._set_op_power_subscriptions - if set_operating_point - else self._set_power_subscriptions - ) - if component_ids not in subs_set: - subs_set[component_ids] = { + if component_ids not in self._subscriptions: + self._subscriptions[component_ids] = { priority: self._channel_registry.get_or_create( _Report, sub.get_channel_name() ).new_sender() } - elif priority not in subs_set[component_ids]: - subs_set[component_ids][priority] = ( + elif priority not in self._subscriptions[component_ids]: + self._subscriptions[component_ids][priority] = ( self._channel_registry.get_or_create( _Report, sub.get_channel_name() ).new_sender() ) - if component_ids not in self._bound_tracker_tasks: - self._add_system_bounds_tracker(component_ids) + if sub.component_ids not in self._bound_tracker_tasks: + self._add_system_bounds_tracker(sub.component_ids) elif selected_from(selected, self._power_distributing_results_receiver): result = selected.message @@ -403,9 +267,4 @@ async def _run(self) -> None: await self._send_reports(frozenset(result.request.component_ids)) elif selected_from(selected, drop_old_proposals_timer): - self._set_power_group.drop_old_proposals( - asyncio.get_event_loop().time() - ) - self._set_op_power_group.drop_old_proposals( - asyncio.get_event_loop().time() - ) + self._algorithm.drop_old_proposals(asyncio.get_event_loop().time()) diff --git a/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py b/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py new file mode 100644 index 000000000..d76fe1eb4 --- /dev/null +++ b/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py @@ -0,0 +1,311 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A power manager implementation that uses the shifting matryoshka algorithm.""" + +from __future__ import annotations + +import logging +import typing +from datetime import timedelta + +from frequenz.quantities import Power +from typing_extensions import override + +from frequenz.sdk.timeseries._base_types import Bounds + +from ... import timeseries +from . import _bounds +from ._base_classes import BaseAlgorithm, Proposal, _Report + +if typing.TYPE_CHECKING: + from ...timeseries._base_types import SystemBounds + +_logger = logging.getLogger(__name__) + + +def _get_nearest_possible_power( + power: Power, + lower_bound: Power, + upper_bound: Power, + exclusion_bounds: Bounds[Power] | None, +) -> Power: + match _bounds.clamp_to_bounds( + power, + lower_bound, + upper_bound, + exclusion_bounds, + ): + case (None, p) | (p, None) if p: + return p + case (low, high) if low and high: + if high - power < power - low: + return high + return low + case _: + return Power.zero() + + +class ShiftingMatryoshka(BaseAlgorithm): + """The ShiftingMatryoshka algorithm. + + When there are multiple actors trying to control the same set of components, this + algorithm will reconcile the different proposals and calculate the target power. + + 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). + """ # noqa: E501 (line too long) + + def __init__(self, max_proposal_age: timedelta) -> None: + """Create a new instance of the matryoshka algorithm.""" + self._max_proposal_age_sec = max_proposal_age.total_seconds() + self._component_buckets: dict[frozenset[int], set[Proposal]] = {} + self._target_power: dict[frozenset[int], Power] = {} + + def _calc_targets( + self, + proposals: set[Proposal], + system_bounds: SystemBounds, + priority: int | None = None, + ) -> tuple[Power | None, Bounds[Power]]: + """Calculate the target power and bounds for the given components. + + Args: + proposals: The proposals for the given components. + system_bounds: The system bounds for the components in the proposal. + priority: The priority of the actor for which the target power is calculated. + + Returns: + The new target power and bounds for the components. + """ + lower_bound = ( + system_bounds.inclusion_bounds.lower + if system_bounds.inclusion_bounds + # if a target power exists from a previous proposal, and the system bounds + # have become unavailable, force the target power to be zero, by narrowing + # the bounds to zero. + else Power.zero() + ) + upper_bound = ( + system_bounds.inclusion_bounds.upper + if system_bounds.inclusion_bounds + else Power.zero() + ) + + if not proposals: + return None, Bounds[Power](lower=lower_bound, upper=upper_bound) + + available_bounds = Bounds[Power](lower=lower_bound, upper=upper_bound) + top_pri_bounds: Bounds[Power] | None = None + + target_power = Power.zero() + for next_proposal in sorted(proposals, reverse=True): + # if a priority is given, the bounds calculated until that priority is + # reached will be the bounds available to an actor with the given priority. + # + # This could mean that the calculated target power is incorrect and should + # not be used. + if priority is not None and next_proposal.priority <= priority: + break + + # When the upper bound is less than the lower bound, if means that there's + # no more room to process further proposals, so we break out of the loop. + if upper_bound < lower_bound: + break + + proposal_lower = next_proposal.bounds.lower or lower_bound + proposal_upper = next_proposal.bounds.upper or upper_bound + proposal_power = next_proposal.preferred_power + + # Make sure that if the proposal specified bounds, they make sense. + if proposal_upper < proposal_lower: + continue + + # If the proposal bounds are outside the available bounds, we need to + # adjust the proposal bounds to fit within the available bounds. + if proposal_lower >= upper_bound: + proposal_lower = upper_bound + proposal_upper = upper_bound + elif proposal_upper <= lower_bound: + proposal_lower = lower_bound + proposal_upper = lower_bound + + # Clamp the available bounds by the proposal bounds. + lower_bound = max(lower_bound, proposal_lower) + upper_bound = min(upper_bound, proposal_upper) + + if proposal_power is not None: + # If this is the first power setting proposal, then hold on to the + # bounds that were available at that time, for use when applying the + # exclusion bounds to the target power at the end. + if top_pri_bounds is None and proposal_power != Power.zero(): + top_pri_bounds = Bounds[Power](lower=lower_bound, upper=upper_bound) + # Clamp the proposal power to its available bounds. + proposal_power = _get_nearest_possible_power( + proposal_power, + lower_bound, + upper_bound, + None, + ) + # Shift the available bounds by the proposal power. + lower_bound = lower_bound - proposal_power + upper_bound = upper_bound - proposal_power + # Add the proposal power to the target power (aka shift in the opposite direction). + target_power += proposal_power + + # The `top_pri_bounds` is to ensure that when applying the exclusion bounds to + # the target power at the end, we respect the bounds that were set by the first + # power-proposing actor. + if top_pri_bounds is not None: + available_bounds = top_pri_bounds + + # Apply the exclusion bounds to the target power. + target_power = _get_nearest_possible_power( + target_power, + available_bounds.lower, + available_bounds.upper, + system_bounds.exclusion_bounds, + ) + + return target_power, Bounds[Power](lower=lower_bound, upper=upper_bound) + + def _validate_component_ids( + self, + component_ids: frozenset[int], + proposal: Proposal | None, + system_bounds: SystemBounds, + ) -> bool: + if component_ids not in self._component_buckets: + # if there are no previous proposals and there are no system bounds, then + # don't calculate a target power and fail the validation. + if ( + system_bounds.inclusion_bounds is None + and system_bounds.exclusion_bounds is None + ): + if proposal is not None: + _logger.warning( + "PowerManagingActor: No system bounds available for component " + + "IDs %s, but a proposal was given. The proposal will be " + + "ignored.", + component_ids, + ) + return False + + for bucket in self._component_buckets: + if any(component_id in bucket for component_id in component_ids): + raise NotImplementedError( + f"PowerManagingActor: component IDs {component_ids} are already" + + " part of another bucket. Overlapping buckets are not" + + " yet supported." + ) + return True + + @override + def calculate_target_power( + self, + component_ids: frozenset[int], + proposal: Proposal | None, + system_bounds: SystemBounds, + must_return_power: bool = False, + ) -> Power | None: + """Calculate and return the target power for the given components. + + Args: + component_ids: The component IDs to calculate the target power for. + proposal: If given, the proposal to added to the bucket, before the target + power is calculated. + system_bounds: The system bounds for the components in the proposal. + must_return_power: If `True`, the algorithm must return a target power, + even if it hasn't changed since the last call. + + Returns: + The new target power for the components, or `None` if the target power + didn't change. + + Raises: # noqa: DOC502 + NotImplementedError: When the proposal contains component IDs that are + already part of another bucket. + """ + if not self._validate_component_ids(component_ids, proposal, system_bounds): + return None + + if proposal is not None: + bucket = self._component_buckets.setdefault(component_ids, set()) + if proposal in bucket: + bucket.remove(proposal) + if ( + proposal.preferred_power is not None + or proposal.bounds.lower is not None + or proposal.bounds.upper is not None + ): + bucket.add(proposal) + elif not bucket: + del self._component_buckets[component_ids] + _ = self._target_power.pop(component_ids, None) + + target_power, _ = self._calc_targets( + self._component_buckets.get(component_ids, set()), system_bounds + ) + + if target_power is not None and ( + must_return_power + or component_ids not in self._target_power + or self._target_power[component_ids] != target_power + ): + self._target_power[component_ids] = target_power + return target_power + return None + + @override + def get_status( # pylint: disable=too-many-locals + self, + component_ids: frozenset[int], + priority: int, + system_bounds: SystemBounds, + ) -> _Report: + """Get the bounds for the algorithm. + + Args: + component_ids: The IDs of the components to get the bounds for. + priority: The priority of the actor for which the bounds are requested. + system_bounds: The system bounds for the components. + + Returns: + The target power and the available bounds for the given components, for + the given priority. + """ + target_power = self._target_power.get(component_ids) + _, bounds = self._calc_targets( + self._component_buckets.get(component_ids, set()), system_bounds, priority + ) + return _Report( + target_power=target_power, + _inclusion_bounds=timeseries.Bounds[Power]( + lower=bounds.lower, upper=bounds.upper + ), + _exclusion_bounds=system_bounds.exclusion_bounds, + ) + + @override + def drop_old_proposals(self, loop_time: float) -> None: + """Drop old proposals. + + This will remove all proposals that have not been updated for longer than + `max_proposal_age`. + + Args: + loop_time: The current loop time. + """ + buckets_to_delete: list[frozenset[int]] = [] + for component_ids, proposals in self._component_buckets.items(): + to_delete: list[Proposal] = [] + for proposal in proposals: + if (loop_time - proposal.creation_time) > self._max_proposal_age_sec: + to_delete.append(proposal) + for proposal in to_delete: + proposals.remove(proposal) + if not proposals: + buckets_to_delete.append(component_ids) + + for component_ids in buckets_to_delete: + del self._component_buckets[component_ids] + _ = self._target_power.pop(component_ids, None) diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py index e3bc76241..3b934182f 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py @@ -59,7 +59,6 @@ def __init__( pool_ref_store: BatteryPoolReferenceStore, name: str | None, priority: int, - set_operating_point: bool, ): """Create a BatteryPool instance. @@ -73,14 +72,11 @@ def __init__( name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. priority: The priority of the actor using this wrapper. - set_operating_point: Whether this instance sets the operating point power or - the normal power for the components. """ self._pool_ref_store = pool_ref_store unique_id = str(uuid.uuid4()) self._source_id = unique_id if name is None else f"{name}-{unique_id}" self._priority = priority - self._set_operating_point = set_operating_point async def propose_power( self, @@ -93,35 +89,17 @@ async def propose_power( Power values need to follow the Passive Sign Convention (PSC). That is, positive values indicate charge power and negative values indicate discharge power. - If the same batteries are shared by multiple actors, the power manager will - consider the priority of the actors, the bounds they set, and their preferred - power, when calculating the target power for the batteries. - - The preferred power of lower priority actors will take precedence as long as - they respect the bounds set by higher priority actors. If lower priority actors - request power values outside of the bounds set by higher priority actors, the - target power will be the closest value to the preferred power that is within the - bounds. - - When there are no other actors trying to use the same batteries, the actor's - preferred power would be set as the target power, as long as it falls within the - system power bounds for the batteries. - - The result of the request can be accessed using the receiver returned from the - [`power_status`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power_status] - method, which also streams the bounds that an actor should comply with, based on - its priority. + Details on how the power manager handles proposals can be found in the + [Microgrid][frequenz.sdk.microgrid--setting-power] documentation. Args: power: The power to propose for the batteries in the pool. If `None`, this proposal will not have any effect on the target power, unless bounds are - specified. If both are `None`, it is equivalent to not having a - proposal or withdrawing a previous one. - bounds: The power bounds for the proposal. These bounds will apply to - actors with a lower priority, and can be overridden by bounds from - actors with a higher priority. If None, the power bounds will be set - to the maximum power of the batteries in the pool. This is currently - and experimental feature. + specified. When specified without bounds, bounds for lower priority + actors will be shifted by this power. If both are `None`, it is + equivalent to not having a proposal or withdrawing a previous one. + bounds: The power bounds for the proposal. When specified, this will limit + the bounds for lower priority actors. """ await self._pool_ref_store._power_manager_requests_sender.send( _power_managing.Proposal( @@ -131,7 +109,6 @@ async def propose_power( component_ids=self._pool_ref_store._batteries, priority=self._priority, creation_time=asyncio.get_running_loop().time(), - set_operating_point=self._set_operating_point, ) ) @@ -145,13 +122,9 @@ async def propose_charge(self, power: Power | None) -> None: method might be more convenient. If the same batteries are shared by multiple actors, the behaviour is the same - as that of the `propose_power` method. The bounds for lower priority actors - can't be specified with this method. If that's required, use the - `propose_power` method instead. - - The result of the request can be accessed using the receiver returned from the - [`power_status`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power_status] - method. + as that of the `propose_power` method, when calling it with `None` bounds. The + bounds for lower priority actors can't be specified with this method. If that's + required, use the `propose_power` method instead. Args: power: The unsigned charge power to propose for the batteries in the pool. @@ -171,7 +144,6 @@ async def propose_charge(self, power: Power | None) -> None: component_ids=self._pool_ref_store._batteries, priority=self._priority, creation_time=asyncio.get_running_loop().time(), - set_operating_point=self._set_operating_point, ) ) @@ -185,13 +157,9 @@ async def propose_discharge(self, power: Power | None) -> None: method might be more convenient. If the same batteries are shared by multiple actors, the behaviour is the same - as that of the `propose_power` method. The bounds for lower priority actors - can't be specified with this method. If that's required, use the - `propose_power` method instead. - - The result of the request can be accessed using the receiver returned from the - [`power_status`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power_status] - method. + as that of the `propose_power` method, when calling it with `None` bounds. The + bounds for lower priority actors can't be specified with this method. If that's + required, use the `propose_power` method instead. Args: power: The unsigned discharge power to propose for the batteries in the @@ -213,7 +181,6 @@ async def propose_discharge(self, power: Power | None) -> None: component_ids=self._pool_ref_store._batteries, priority=self._priority, creation_time=asyncio.get_running_loop().time(), - set_operating_point=self._set_operating_point, ) ) @@ -373,7 +340,6 @@ def power_status(self) -> ReceiverFetcher[BatteryPoolReport]: source_id=self._source_id, priority=self._priority, component_ids=self._pool_ref_store._batteries, - set_operating_point=self._set_operating_point, ) self._pool_ref_store._power_bounds_subs[sub.get_channel_name()] = ( asyncio.create_task( diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py index 5026892a6..0349cbfb5 100644 --- a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py +++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py @@ -44,7 +44,6 @@ def __init__( # pylint: disable=too-many-arguments pool_ref_store: EVChargerPoolReferenceStore, name: str | None, priority: int, - set_operating_point: bool, ) -> None: """Create an `EVChargerPool` instance. @@ -60,14 +59,11 @@ def __init__( # pylint: disable=too-many-arguments name: An optional name used to identify this instance of the pool or a corresponding actor in the logs. priority: The priority of the actor using this wrapper. - set_operating_point: Whether this instance sets the operating point power or - the normal power for the components. """ self._pool_ref_store = pool_ref_store unique_id = str(uuid.uuid4()) self._source_id = unique_id if name is None else f"{name}-{unique_id}" self._priority = priority - self._set_operating_point = set_operating_point async def propose_power( self, @@ -81,39 +77,17 @@ async def propose_power( the pool. The actual consumption might be lower based on the number of phases an EV is drawing power from, and its current state of charge. - Power values need to follow the Passive Sign Convention (PSC). That is, positive - values indicate charge power and negative values indicate discharge power. - Discharging from EV chargers is currently not supported. - - If the same EV chargers are shared by multiple actors, the power manager will - consider the priority of the actors, the bounds they set, and their preferred - power, when calculating the target power for the EV chargers - - The preferred power of lower priority actors will take precedence as long as - they respect the bounds set by higher priority actors. If lower priority actors - request power values outside of the bounds set by higher priority actors, the - target power will be the closest value to the preferred power that is within the - bounds. - - When there are no other actors trying to use the same EV chargers, the actor's - preferred power would be set as the target power, as long as it falls within the - system power bounds for the EV chargers. - - The result of the request can be accessed using the receiver returned from the - [`power_status`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool.power_status] - method, which also streams the bounds that an actor should comply with, based on - its priority. + Details on how the power manager handles proposals can be found in the + [Microgrid][frequenz.sdk.microgrid--setting-power] documentation. Args: power: The power to propose for the EV chargers in the pool. If `None`, this proposal will not have any effect on the target power, unless - bounds are specified. If both are `None`, it is equivalent to not - having a proposal or withdrawing a previous one. - bounds: The power bounds for the proposal. These bounds will apply to - actors with a lower priority, and can be overridden by bounds from - actors with a higher priority. If None, the power bounds will be set to - the maximum power of the batteries in the pool. This is currently and - experimental feature. + bounds are specified. When specified without bounds, bounds for lower + priority actors will be shifted by this power. If both are `None`, it + is equivalent to not having a proposal or withdrawing a previous one. + bounds: The power bounds for the proposal. When specified, these bounds will + limit the bounds for lower priority actors. Raises: EVChargerPoolError: If a discharge power for EV chargers is requested. @@ -130,7 +104,6 @@ async def propose_power( component_ids=self._pool_ref_store.component_ids, priority=self._priority, creation_time=asyncio.get_running_loop().time(), - set_operating_point=self._set_operating_point, ) ) @@ -213,7 +186,6 @@ def power_status(self) -> ReceiverFetcher[EVChargerPoolReport]: source_id=self._source_id, priority=self._priority, component_ids=self._pool_ref_store.component_ids, - set_operating_point=self._set_operating_point, ) self._pool_ref_store.power_bounds_subs[sub.get_channel_name()] = ( asyncio.create_task( diff --git a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py index 3f7fb2c2c..a2c7d9a44 100644 --- a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py +++ b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py @@ -37,7 +37,6 @@ def __init__( # pylint: disable=too-many-arguments pool_ref_store: PVPoolReferenceStore, name: str | None, priority: int, - set_operating_point: bool, ) -> None: """Initialize the instance. @@ -50,19 +49,15 @@ def __init__( # pylint: disable=too-many-arguments pool_ref_store: The reference store for the PV pool. name: The name of the PV pool. priority: The priority of the PV pool. - set_operating_point: Whether this instance sets the operating point power or - the normal power for the components. """ self._pool_ref_store = pool_ref_store unique_id = uuid.uuid4() self._source_id = str(unique_id) if name is None else f"{name}-{unique_id}" self._priority = priority - self._set_operating_point = set_operating_point async def propose_power( self, power: Power | None, - *, bounds: Bounds[Power | None] = Bounds(None, None), ) -> None: """Send a proposal to the power manager for the pool's set of PV inverters. @@ -71,38 +66,20 @@ async def propose_power( the pool. The actual production might be lower. Power values need to follow the Passive Sign Convention (PSC). That is, positive - values indicate charge power and negative values indicate discharge power. - Only discharge powers are allowed for PV inverters. - - If the same PV inverters are shared by multiple actors, the power manager will - consider the priority of the actors, the bounds they set, and their preferred - power, when calculating the target power for the PV inverters. - - The preferred power of lower priority actors will take precedence as long as - they respect the bounds set by higher priority actors. If lower priority actors - request power values outside of the bounds set by higher priority actors, the - target power will be the closest value to the preferred power that is within the - bounds. - - When there are no other actors trying to use the same PV inverters, the actor's - preferred power would be set as the target power, as long as it falls within the - system power bounds for the PV inverters. + values indicate charge power and negative values indicate discharge power. Only + discharge powers are allowed for PV inverters. - The result of the request can be accessed using the receiver returned from the - [`power_status`][frequenz.sdk.timeseries.pv_pool.PVPool.power_status] - method, which also streams the bounds that an actor should comply with, based on - its priority. + Details on how the power manager handles proposals can be found in the + [Microgrid][frequenz.sdk.microgrid--setting-power] documentation. Args: power: The power to propose for the PV inverters in the pool. If `None`, this proposal will not have any effect on the target power, unless - bounds are specified. If both are `None`, it is equivalent to not - having a proposal or withdrawing a previous one. - bounds: The power bounds for the proposal. These bounds will apply to - actors with a lower priority, and can be overridden by bounds from - actors with a higher priority. If None, the power bounds will be set to - the maximum power of the batteries in the pool. This is currently and - experimental feature. + bounds are specified. When speficied without bounds, bounds for lower + priority actors will be shifted by this power. If both are `None`, it + is equivalent to not having a proposal or withdrawing a previous one. + bounds: The power bounds for the proposal. When specified, this will limit + the bounds for lower priority actors. Raises: PVPoolError: If a charge power for PV inverters is requested. @@ -117,7 +94,6 @@ async def propose_power( component_ids=self._pool_ref_store.component_ids, priority=self._priority, creation_time=asyncio.get_running_loop().time(), - set_operating_point=self._set_operating_point, ) ) @@ -172,7 +148,6 @@ def power_status(self) -> ReceiverFetcher[PVPoolReport]: source_id=self._source_id, priority=self._priority, component_ids=self._pool_ref_store.component_ids, - set_operating_point=self._set_operating_point, ) self._pool_ref_store.power_bounds_subs[sub.get_channel_name()] = ( asyncio.create_task( diff --git a/tests/actor/_power_managing/test_matryoshka.py b/tests/actor/_power_managing/test_matryoshka.py index 589e7b139..bd07b9ae9 100644 --- a/tests/actor/_power_managing/test_matryoshka.py +++ b/tests/actor/_power_managing/test_matryoshka.py @@ -58,7 +58,6 @@ def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-argumen if creation_time is not None else asyncio.get_event_loop().time() ), - set_operating_point=False, ), self._system_bounds, must_send, diff --git a/tests/actor/_power_managing/test_shifting_matryoshka.py b/tests/actor/_power_managing/test_shifting_matryoshka.py new file mode 100644 index 000000000..10cc72664 --- /dev/null +++ b/tests/actor/_power_managing/test_shifting_matryoshka.py @@ -0,0 +1,636 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Shifting Matryoshka power manager algorithm.""" + +# pylint: disable=duplicate-code + +import asyncio +import re +from datetime import datetime, timedelta, timezone + +import pytest +from frequenz.quantities import Power + +from frequenz.sdk import timeseries +from frequenz.sdk.microgrid._power_managing import Proposal +from frequenz.sdk.microgrid._power_managing._shifting_matryoshka import ( + ShiftingMatryoshka, +) +from frequenz.sdk.timeseries import _base_types + + +class StatefulTester: + """A stateful tester for the Matryoshka algorithm.""" + + def __init__( + self, + batteries: frozenset[int], + system_bounds: _base_types.SystemBounds, + ) -> None: + """Create a new instance of the stateful tester.""" + self._call_count = 0 + self._batteries = batteries + self._system_bounds = system_bounds + self.algorithm = ShiftingMatryoshka(max_proposal_age=timedelta(seconds=60.0)) + + def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + priority: int, + power: float | None, + bounds: tuple[float | None, float | None], + expected: float | None, + creation_time: float | None = None, + must_send: bool = False, + batteries: frozenset[int] | None = None, + ) -> None: + """Test the target power calculation.""" + self._call_count += 1 + tgt_power = self.algorithm.calculate_target_power( + self._batteries if batteries is None else batteries, + Proposal( + component_ids=self._batteries if batteries is None else batteries, + source_id=f"actor-{priority}", + preferred_power=None if power is None else Power.from_watts(power), + bounds=timeseries.Bounds( + None if bounds[0] is None else Power.from_watts(bounds[0]), + None if bounds[1] is None else Power.from_watts(bounds[1]), + ), + priority=priority, + creation_time=( + creation_time + if creation_time is not None + else asyncio.get_event_loop().time() + ), + ), + self._system_bounds, + must_send, + ) + assert tgt_power == ( + Power.from_watts(expected) if expected is not None else None + ) + + def bounds( + self, + priority: int, + expected_power: float | None, + expected_bounds: tuple[float, float], + ) -> None: + """Test the status report.""" + report = self.algorithm.get_status( + self._batteries, priority, self._system_bounds + ) + if expected_power is None: + assert report.target_power is None + else: + assert report.target_power is not None + assert report.target_power.as_watts() == expected_power + # pylint: disable=protected-access + assert report._inclusion_bounds is not None + assert report._inclusion_bounds.lower.as_watts() == expected_bounds[0] + assert report._inclusion_bounds.upper.as_watts() == expected_bounds[1] + # pylint: enable=protected-access + + +async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-statements + """Tests for the power managing actor. + + With just inclusion bounds, and no exclusion bounds. + """ + batteries = frozenset({2, 5}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds(lower=Power.zero(), upper=Power.zero()), + ) + + tester = StatefulTester(batteries, system_bounds) + + tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=25.0) + tester.bounds(priority=2, expected_power=25.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=25.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=45.0) + tester.tgt_power( + priority=1, power=20.0, bounds=(20.0, 50.0), expected=45.0, must_send=True + ) + tester.bounds(priority=1, expected_power=45.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=3, power=10.0, bounds=(10.0, 15.0), expected=15.0) + tester.bounds(priority=3, expected_power=15.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=15.0, expected_bounds=(0.0, 5.0)) + tester.bounds(priority=1, expected_power=15.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=3, power=10.0, bounds=(10.0, 22.0), expected=22.0) + tester.bounds(priority=3, expected_power=22.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=22.0, expected_bounds=(0.0, 12.0)) + tester.bounds(priority=1, expected_power=22.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=1, power=30.0, bounds=(20.0, 50.0), expected=None) + tester.bounds(priority=1, expected_power=22.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=3, power=10.0, bounds=(10.0, 50.0), expected=50.0) + tester.bounds(priority=3, expected_power=50.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=50.0, expected_bounds=(0.0, 40.0)) + tester.bounds(priority=1, expected_power=50.0, expected_bounds=(0.0, 15.0)) + + tester.tgt_power(priority=2, power=40.0, bounds=(40.0, None), expected=None) + tester.bounds(priority=3, expected_power=50.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=50.0, expected_bounds=(0.0, 40.0)) + tester.bounds(priority=1, expected_power=50.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=2, power=0.0, bounds=(-200.0, 200.0), expected=40.0) + tester.bounds(priority=4, expected_power=40.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=3, expected_power=40.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=40.0, expected_bounds=(0.0, 40.0)) + tester.bounds(priority=1, expected_power=40.0, expected_bounds=(0.0, 40.0)) + + tester.tgt_power(priority=4, power=-50.0, bounds=(None, -50.0), expected=-50.0) + tester.bounds(priority=4, expected_power=-50.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=3, expected_power=-50.0, expected_bounds=(-150.0, 0.0)) + tester.bounds(priority=2, expected_power=-50.0, expected_bounds=(0.0, 0.0)) + tester.bounds(priority=1, expected_power=-50.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=3, power=0.0, bounds=(-200.0, 200.0), expected=None) + tester.bounds(priority=1, expected_power=-50.0, expected_bounds=(-150.0, 0.0)) + + tester.tgt_power(priority=1, power=-150.0, bounds=(-200.0, -150.0), expected=-200.0) + tester.bounds(priority=2, expected_power=-200.0, expected_bounds=(-150.0, 0.0)) + tester.bounds(priority=1, expected_power=-200.0, expected_bounds=(-150.0, 0.0)) + + tester.tgt_power(priority=4, power=-180.0, bounds=(-200.0, -50.0), expected=None) + tester.bounds(priority=1, expected_power=-200.0, expected_bounds=(-20.0, 130.0)) + + tester.tgt_power(priority=4, power=50.0, bounds=(50.0, None), expected=50.0) + tester.bounds(priority=4, expected_power=50.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=3, expected_power=50.0, expected_bounds=(0.0, 150.0)) + tester.bounds(priority=2, expected_power=50.0, expected_bounds=(0.0, 150.0)) + tester.bounds(priority=1, expected_power=50.0, expected_bounds=(0.0, 150.0)) + + tester.tgt_power(priority=4, power=0.0, bounds=(-200.0, 200.0), expected=-150.0) + tester.bounds(priority=4, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=3, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + + tester.tgt_power(priority=3, power=0.0, bounds=(-200.0, 200.0), expected=None) + tester.bounds(priority=3, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=-150.0, expected_bounds=(-200.0, 200.0)) + + tester.tgt_power(priority=2, power=50.0, bounds=(-100, 100), expected=-100.0) + tester.bounds(priority=3, expected_power=-100.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=2, expected_power=-100.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=-100.0, expected_bounds=(-150.0, 50.0)) + + tester.tgt_power(priority=1, power=100.0, bounds=(100, 200), expected=100.0) + tester.bounds(priority=1, expected_power=100.0, expected_bounds=(-150.0, 50.0)) + + tester.tgt_power(priority=1, power=50.0, bounds=(50, 200), expected=None) + tester.bounds(priority=1, expected_power=100.0, expected_bounds=(-150.0, 50.0)) + + tester.tgt_power(priority=1, power=10.0, bounds=(10, 200), expected=60.0) + tester.bounds(priority=1, expected_power=60.0, expected_bounds=(-150.0, 50.0)) + + tester.tgt_power(priority=1, power=0.0, bounds=(-200, 200), expected=50.0) + tester.bounds(priority=1, expected_power=50.0, expected_bounds=(-150.0, 50.0)) + + tester.tgt_power(priority=1, power=None, bounds=(-200, 200), expected=None) + tester.bounds(priority=1, expected_power=50.0, expected_bounds=(-150.0, 50.0)) + + +async def test_matryoshka_simple() -> None: + """Tests for the power managing actor. + + With inclusion bounds, and exclusion bounds -30.0 to 0.0. + """ + batteries = frozenset({2, 5}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-30.0), upper=Power.from_watts(30.0) + ), + ) + + tester = StatefulTester(batteries, system_bounds) + tester.tgt_power(priority=3, power=None, bounds=(-200.0, 200.0), expected=0.0) + tester.bounds(priority=2, expected_power=0.0, expected_bounds=(-200.0, 200.0)) + tester.tgt_power(priority=1, power=None, bounds=(-200.0, 200.0), expected=None) + tester.bounds(priority=2, expected_power=0.0, expected_bounds=(-200.0, 200.0)) + tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=30.0) + # tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=-10.0) + # tester.bounds(priority=1, expected_power=-10.0, expected_bounds=(0.0, 60.0)) + # tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=None) + # tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(0.0, 20.0)) + + +async def test_matryoshka_with_excl_1() -> None: + """Tests for the power managing actor. + + With inclusion bounds, and exclusion bounds -30.0 to 0.0. + """ + batteries = frozenset({2, 5}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-30.0), upper=Power.zero() + ), + ) + + tester = StatefulTester(batteries, system_bounds) + + tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=25.0) + tester.bounds(priority=2, expected_power=25.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=25.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=45.0) + tester.bounds(priority=1, expected_power=45.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=10.0) + tester.bounds(priority=1, expected_power=10.0, expected_bounds=(0.0, 60.0)) + tester.bounds(priority=0, expected_power=10.0, expected_bounds=(0.0, 30.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 50.0), expected=0.0) + tester.bounds(priority=0, expected_power=0.0, expected_bounds=(0.0, 50.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=None) + tester.bounds(priority=0, expected_power=0.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=2, power=-10.0, bounds=(-200.0, -5.0), expected=-30.0) + tester.bounds(priority=1, expected_power=-30.0, expected_bounds=(-190.0, 5.0)) + tester.bounds(priority=0, expected_power=-30.0, expected_bounds=(0.0, 5.0)) + + tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=0.0) + tester.bounds(priority=2, expected_power=0.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=0.0, expected_bounds=(-200.0, 200.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=None) + tester.bounds(priority=0, expected_power=0.0, expected_bounds=(0.0, 5.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=-30.0) + tester.bounds(priority=0, expected_power=-30.0, expected_bounds=(-90.0, 5.0)) + + tester.tgt_power(priority=1, power=-40.0, bounds=(-100.0, -35.0), expected=-40.0) + tester.bounds(priority=0, expected_power=-40.0, expected_bounds=(-60.0, 5.0)) + + +async def test_matryoshka_with_excl_2() -> None: + """Tests for the power managing actor. + + With inclusion bounds, and exclusion bounds 0.0 to 30.0. + """ + batteries = frozenset({2, 5}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds( + lower=Power.zero(), upper=Power.from_watts(30.0) + ), + ) + + tester = StatefulTester(batteries, system_bounds) + + tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=30.0) + tester.bounds(priority=2, expected_power=30.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=30.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=45.0) + tester.bounds(priority=1, expected_power=45.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=1, power=10.0, bounds=(5.0, 10.0), expected=35.0) + tester.bounds(priority=0, expected_power=35.0, expected_bounds=(-5.0, 0.0)) + + tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=0.0) + tester.bounds(priority=1, expected_power=0.0, expected_bounds=(0.0, 60.0)) + tester.bounds(priority=0, expected_power=0.0, expected_bounds=(-5.0, 0.0)) + + tester.tgt_power(priority=0, power=40, bounds=(None, None), expected=None) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 50.0), expected=30.0) + tester.bounds(priority=0, expected_power=30.0, expected_bounds=(0.0, 50.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=0.0) + tester.bounds(priority=0, expected_power=0.0, expected_bounds=(0.0, 20.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=-10.0) + tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(0.0, 0.0)) + + tester.tgt_power(priority=2, power=-10.0, bounds=(-200.0, -5.0), expected=-15.0) + tester.bounds(priority=1, expected_power=-15.0, expected_bounds=(-190.0, 5.0)) + tester.bounds(priority=0, expected_power=-15.0, expected_bounds=(0.0, 5.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=None) + tester.bounds(priority=0, expected_power=-15.0, expected_bounds=(-90.0, 5.0)) + + tester.tgt_power(priority=1, power=-40.0, bounds=(-100.0, -35.0), expected=-45.0) + tester.bounds(priority=0, expected_power=-45.0, expected_bounds=(-60.0, 5.0)) + + +async def test_matryoshka_with_excl_3() -> None: + """Tests for the power managing actor. + + With inclusion bounds, and exclusion bounds -30.0 to 30.0. + """ + batteries = frozenset({2, 5}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-30.0), upper=Power.from_watts(30.0) + ), + ) + + tester = StatefulTester(batteries, system_bounds) + tester.tgt_power(priority=2, power=10.0, bounds=(-200.0, 200.0), expected=30.0) + tester.tgt_power(priority=2, power=-10.0, bounds=(-200.0, 200.0), expected=-30.0) + tester.tgt_power(priority=2, power=0.0, bounds=(-200.0, 200.0), expected=0.0) + tester.tgt_power(priority=3, power=20.0, bounds=(-200.0, 200.0), expected=30.0) + tester.tgt_power(priority=1, power=-20.0, bounds=(-200.0, 200.0), expected=0.0) + tester.tgt_power(priority=3, power=None, bounds=(-200.0, 200.0), expected=-30.0) + tester.tgt_power(priority=1, power=None, bounds=(-200.0, 200.0), expected=0.0) + + tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=30.0) + tester.bounds(priority=2, expected_power=30.0, expected_bounds=(-200.0, 200.0)) + tester.bounds(priority=1, expected_power=30.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=45.0) + tester.bounds(priority=1, expected_power=45.0, expected_bounds=(0.0, 25.0)) + + tester.tgt_power(priority=1, power=10.0, bounds=(5.0, 10.0), expected=35.0) + tester.bounds(priority=0, expected_power=35.0, expected_bounds=(-5.0, 0.0)) + + tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=30.0) + tester.bounds(priority=1, expected_power=30.0, expected_bounds=(0.0, 60.0)) + tester.bounds(priority=0, expected_power=30.0, expected_bounds=(-5.0, 0.0)) + + tester.tgt_power(priority=1, power=40.0, bounds=(-10.0, 50.0), expected=None) + tester.bounds(priority=0, expected_power=30.0, expected_bounds=(-40.0, 10.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=None) + tester.bounds(priority=0, expected_power=30.0, expected_bounds=(0.0, 20.0)) + + tester.tgt_power(priority=2, power=-10.0, bounds=(-200.0, -5.0), expected=-30.0) + tester.bounds(priority=1, expected_power=-30.0, expected_bounds=(-190.0, 5.0)) + tester.bounds(priority=0, expected_power=-30.0, expected_bounds=(0.0, 15.0)) + + tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=None) + tester.bounds(priority=0, expected_power=-30.0, expected_bounds=(-90.0, 5.0)) + + tester.tgt_power(priority=1, power=-40.0, bounds=(-100.0, -35.0), expected=-50.0) + tester.bounds(priority=0, expected_power=-50.0, expected_bounds=(-60.0, 5.0)) + + +async def test_matryoshka_drop_old_proposals() -> None: + """Tests for the power managing actor. + + With inclusion bounds, and exclusion bounds -30.0 to 30.0. + """ + batteries = frozenset({2, 5}) + overlapping_batteries = frozenset({5, 8}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds(lower=Power.zero(), upper=Power.zero()), + ) + + tester = StatefulTester(batteries, system_bounds) + + now = asyncio.get_event_loop().time() + + tester.tgt_power(priority=3, power=22.0, bounds=(22.0, 100.0), expected=22.0) + + # When a proposal is too old and hasn't been updated, it is dropped. + tester.tgt_power( + priority=2, + power=25.0, + bounds=(25.0, 50.0), + creation_time=now - 70.0, + expected=47.0, + ) + + tester.tgt_power( + priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0, must_send=True + ) + tester.algorithm.drop_old_proposals(now) + tester.tgt_power( + priority=1, power=20.0, bounds=(20.0, 50.0), expected=42.0, must_send=True + ) + + # When overwritten by a newer proposal, that proposal is not dropped. + tester.tgt_power( + priority=2, + power=25.0, + bounds=(25.0, 50.0), + creation_time=now - 70.0, + expected=67.0, + ) + tester.tgt_power( + priority=2, + power=25.0, + bounds=(25.0, 50.0), + creation_time=now - 30.0, + expected=67.0, + must_send=True, + ) + + tester.tgt_power( + priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0, must_send=True + ) + tester.algorithm.drop_old_proposals(now) + tester.tgt_power( + priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0, must_send=True + ) + + # When all proposals are too old, they are dropped, and the buckets are dropped as + # well. After that, sending a request for a different but overlapping bucket will + # succeed. And it will fail until then. + with pytest.raises( + NotImplementedError, + match=re.escape( + "PowerManagingActor: component IDs frozenset({8, 5}) are already " + + "part of another bucket. Overlapping buckets are not yet supported." + ), + ): + tester.tgt_power( + priority=1, + power=25.0, + bounds=(25.0, 50.0), + expected=25.0, + must_send=True, + batteries=overlapping_batteries, + ) + + tester.tgt_power( + priority=1, + power=25.0, + bounds=(25.0, 100.0), + creation_time=now - 70.0, + expected=72.0, + must_send=True, + ) + tester.tgt_power( + priority=2, + power=25.0, + bounds=(25.0, 100.0), + creation_time=now - 70.0, + expected=72.0, + must_send=True, + ) + tester.tgt_power( + priority=3, + power=25.0, + bounds=(25.0, 100.0), + creation_time=now - 70.0, + expected=75.0, + must_send=True, + ) + + tester.algorithm.drop_old_proposals(now) + + tester.tgt_power( + priority=1, + power=25.0, + bounds=(25.0, 50.0), + expected=25.0, + must_send=True, + batteries=overlapping_batteries, + ) + + +async def test_matryoshka_none_proposals() -> None: + """Tests for the power managing actor. + + When a `None` proposal is received, is source id should be dropped from the bucket. + Then if the bucket becomes empty, it should be dropped as well. + """ + batteries = frozenset({2, 5}) + overlapping_batteries = frozenset({5, 8}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds(lower=Power.zero(), upper=Power.zero()), + ) + + def ensure_overlapping_bucket_request_fails() -> None: + with pytest.raises( + NotImplementedError, + match=re.escape( + "PowerManagingActor: component IDs frozenset({8, 5}) are already " + + "part of another bucket. Overlapping buckets are not yet supported." + ), + ): + tester.tgt_power( + priority=1, + power=None, + bounds=(20.0, 50.0), + expected=None, + must_send=True, + batteries=overlapping_batteries, + ) + + tester = StatefulTester(batteries, system_bounds) + + tester.tgt_power(priority=3, power=22.0, bounds=(22.0, 30.0), expected=22.0) + tester.tgt_power(priority=2, power=25.0, bounds=(25.0, 50.0), expected=30.0) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None) + + ensure_overlapping_bucket_request_fails() + tester.tgt_power(priority=1, power=None, bounds=(None, None), expected=None) + ensure_overlapping_bucket_request_fails() + tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=25.0) + ensure_overlapping_bucket_request_fails() + tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=None) + + # Overlapping battery bucket is dropped. + tester.tgt_power( + priority=1, + power=20.0, + bounds=(20.0, 50.0), + expected=20.0, + batteries=overlapping_batteries, + ) + + +async def test_matryoshka_shifting_limiting() -> None: + """Tests for the power managing actor. + + With the following scenario: + + | Actor | System Limits | Specified Limits | Desired | Adjusted | Aggregate | + | Prio | | | Power | Power | Power | + |-------|-------------------|------------------|---------|----------|-----------| + | 7 | -100 kW .. 100 kW | None | 10 kW | 10 kW | 10 kW | + | 6 | -110 kW .. 90 kW | -110 kW .. 80 kW | 10 kW | 10 kW | 20 kW | + | 5 | -120 kW .. 70 kW | -100 kW .. 80 kW | 80 kW | 70 kW | 90 kW | + | 4 | -170 kW .. 0 kW | None | -120 kW | -120 kW | -30 kW | + | 3 | -50 kW .. 120 kW | None | 60 kW | 60 kW | 30 kW | + | 2 | -110 kW .. 60 kW | -40 kW .. 30 kW | 20 kW | 20 kW | 50 kW | + | 1 | -60 kW .. 10 kW | -50 kW .. 40 kW | 25 kW | 10 kW | 60 kW | + | 0 | -60 kW .. 0 kW | None | 12 kW | 0 kW | 60 kW | + | -1 | -60 kW .. 0 kW | -40 kW .. -10 kW | -10 kW | -10 kW | 50 kW | + |-------|-------------------|------------------|---------|----------|-----------| + | | | | | Power | | + | | | | | Setpoint | 50 kW | + """ + batteries = frozenset({2, 5}) + + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-100.0), upper=Power.from_watts(100.0) + ), + exclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-0.0), upper=Power.from_watts(0.0) + ), + ) + + tester = StatefulTester(batteries, system_bounds) + tester.tgt_power(priority=7, power=10.0, bounds=(None, None), expected=10.0) + tester.bounds(priority=7, expected_power=10.0, expected_bounds=(-100.0, 100.0)) + tester.bounds(priority=6, expected_power=10.0, expected_bounds=(-110.0, 90.0)) + + tester.tgt_power(priority=6, power=10.0, bounds=(-110.0, 80.0), expected=20.0) + tester.bounds(priority=5, expected_power=20.0, expected_bounds=(-120.0, 70.0)) + + tester.tgt_power(priority=5, power=80.0, bounds=(-100.0, 80.0), expected=90.0) + tester.bounds(priority=4, expected_power=90.0, expected_bounds=(-170.0, 0.0)) + + tester.tgt_power(priority=4, power=-120.0, bounds=(None, None), expected=-30.0) + tester.bounds(priority=3, expected_power=-30.0, expected_bounds=(-50.0, 120.0)) + + tester.tgt_power(priority=3, power=60.0, bounds=(None, None), expected=30.0) + tester.bounds(priority=2, expected_power=30.0, expected_bounds=(-110.0, 60.0)) + + tester.tgt_power(priority=2, power=20.0, bounds=(-40.0, 30.0), expected=50.0) + tester.bounds(priority=1, expected_power=50.0, expected_bounds=(-60.0, 10.0)) + + tester.tgt_power(priority=1, power=25.0, bounds=(-50.0, 40.0), expected=60.0) + tester.bounds(priority=0, expected_power=60.0, expected_bounds=(-60.0, 0.0)) + + tester.tgt_power(priority=0, power=12.0, bounds=(None, None), expected=None) + tester.bounds(priority=-1, expected_power=60.0, expected_bounds=(-60.0, 0.0)) + + tester.tgt_power(priority=-1, power=-10.0, bounds=(-40.0, -10.0), expected=50.0) diff --git a/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py b/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py index 9632b1fc7..340f585bb 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py +++ b/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py @@ -379,7 +379,7 @@ async def test_case_3(self, mocks: Mocks, mocker: MockerFixture) -> None: await bounds_1_rx.receive(), power=-1000.0, lower=-4000.0, upper=4000.0 ) self._assert_report( - await bounds_2_rx.receive(), power=-1000.0, lower=-1000.0, upper=0.0 + await bounds_2_rx.receive(), power=-1000.0, lower=0.0, upper=1000.0 ) await asyncio.sleep(0.0) # Wait for the power to be distributed. assert set_power.call_count == 4 @@ -390,20 +390,21 @@ async def test_case_3(self, mocks: Mocks, mocker: MockerFixture) -> None: set_power.reset_mock() await battery_pool_2.propose_power( - Power.from_watts(0.0), + Power.from_watts(200.0), bounds=timeseries.Bounds(Power.from_watts(0.0), Power.from_watts(1000.0)), ) self._assert_report( - await bounds_1_rx.receive(), power=0.0, lower=-4000.0, upper=4000.0 + await bounds_1_rx.receive(), power=-800.0, lower=-4000.0, upper=4000.0 ) bounds = await bounds_2_rx.receive() if not latest_dist_result_2.has_value(): bounds = await bounds_2_rx.receive() - self._assert_report(bounds, power=0.0, lower=-1000.0, upper=0.0) + self._assert_report(bounds, power=-800.0, lower=0.0, upper=1000.0) await asyncio.sleep(0.0) # Wait for the power to be distributed. assert set_power.call_count == 4 assert sorted(set_power.call_args_list) == [ - mocker.call(inv_id, 0.0) for inv_id in mocks.microgrid.battery_inverter_ids + mocker.call(inv_id, -200.0) + for inv_id in mocks.microgrid.battery_inverter_ids ] async def test_case_4(self, mocks: Mocks, mocker: MockerFixture) -> None: @@ -526,183 +527,3 @@ async def test_case_4(self, mocks: Mocks, mocker: MockerFixture) -> None: result, _power_distributing.Success ), ) - - async def test_case_5( # pylint: disable=too-many-statements,too-many-locals - self, - mocks: Mocks, - mocker: MockerFixture, - ) -> None: - """Test case 5. - - - four battery pools with same batteries, but different priorities. - - two battery pools are in the shifting group, two are not. - - all batteries are working. - """ - set_power = typing.cast( - AsyncMock, microgrid.connection_manager.get().api_client.set_power - ) - - await self._patch_battery_pool_status(mocks, mocker) - await self._init_data_for_batteries(mocks) - await self._init_data_for_inverters(mocks) - - battery_pool_4 = microgrid.new_battery_pool( - priority=4, set_operating_point=True - ) - bounds_4_rx = battery_pool_4.power_status.new_receiver() - battery_pool_3 = microgrid.new_battery_pool( - priority=3, set_operating_point=True - ) - bounds_3_rx = battery_pool_3.power_status.new_receiver() - battery_pool_2 = microgrid.new_battery_pool(priority=2) - bounds_2_rx = battery_pool_2.power_status.new_receiver() - battery_pool_1 = microgrid.new_battery_pool(priority=1) - bounds_1_rx = battery_pool_1.power_status.new_receiver() - - latest_dist_result_4 = LatestValueCache( - battery_pool_4.power_distribution_results.new_receiver() - ) - - self._assert_report( - await bounds_4_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_3_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_2_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_1_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - - # The target power of non-shifting battery pools should only be visible to other - # non-shifting battery pools, and vice-versa. - await battery_pool_2.propose_power( - Power.from_watts(200.0), - bounds=timeseries.Bounds( - Power.from_watts(-1000.0), Power.from_watts(1500.0) - ), - ) - self._assert_report( - await bounds_4_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_3_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_2_rx.receive(), power=200.0, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_1_rx.receive(), power=200.0, lower=-1000.0, upper=1500.0 - ) - await asyncio.sleep(0.0) # Wait for the power to be distributed. - assert set_power.call_count == 4 - assert sorted(set_power.call_args_list) == [ - mocker.call(inv_id, 50.0) for inv_id in mocks.microgrid.battery_inverter_ids - ] - set_power.reset_mock() - - # Set a power to the second non-shifting battery pool. This should also have - # no effect on the shifting battery pools. - await battery_pool_1.propose_power( - Power.from_watts(720.0), - ) - self._assert_report( - await bounds_4_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_3_rx.receive(), power=None, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_2_rx.receive(), power=720.0, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_1_rx.receive(), power=720.0, lower=-1000.0, upper=1500.0 - ) - - for _ in range(5): - await bounds_1_rx.receive() - await bounds_2_rx.receive() - await bounds_3_rx.receive() - await bounds_4_rx.receive() - dist_result = latest_dist_result_4.get() - if dist_result is None or not isinstance( - dist_result, _power_distributing.Success - ): - continue - if dist_result.succeeded_power == Power.from_watts(720.0): - break - - await asyncio.sleep(0.0) # Wait for the power to be distributed. - assert set_power.call_count == 4 - assert sorted(set_power.call_args_list) == [ - mocker.call(inv_id, 720.0 / 4) - for inv_id in mocks.microgrid.battery_inverter_ids - ] - set_power.reset_mock() - - # Setting power to a shifting battery pool should shift the bounds seen by the - # non-shifting battery pools. It would also shift the final target power sent - # in the batteries. - await battery_pool_3.propose_power( - Power.from_watts(-1000.0), - ) - - self._assert_report( - await bounds_4_rx.receive(), power=-1000.0, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_3_rx.receive(), power=-1000.0, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_2_rx.receive(), power=720.0, lower=-3000.0, upper=5000.0 - ) - self._assert_report( - await bounds_1_rx.receive(), power=720.0, lower=-1000.0, upper=1500.0 - ) - - for _ in range(5): - await bounds_1_rx.receive() - await bounds_2_rx.receive() - await bounds_3_rx.receive() - await bounds_4_rx.receive() - dist_result = latest_dist_result_4.get() - if dist_result is None or not isinstance( - dist_result, _power_distributing.Success - ): - continue - if dist_result.succeeded_power == Power.from_watts(-280.0): - break - - await asyncio.sleep(0.0) # Wait for the power to be distributed. - assert set_power.call_count == 4 - assert sorted(set_power.call_args_list) == [ - mocker.call(inv_id, -280.0 / 4) - for inv_id in mocks.microgrid.battery_inverter_ids - ] - set_power.reset_mock() - - # Creating a new non-shifting battery pool that's higher priority than the - # shifting battery pools should still be shifted by the target power of the - # shifting battery pools. - battery_pool_5 = microgrid.new_battery_pool(priority=5) - bounds_5_rx = battery_pool_5.power_status.new_receiver() - - await battery_pool_5.propose_power(None) - - self._assert_report( - await bounds_5_rx.receive(), power=720.0, lower=-3000.0, upper=5000.0 - ) - self._assert_report( - await bounds_4_rx.receive(), power=-1000.0, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_3_rx.receive(), power=-1000.0, lower=-4000.0, upper=4000.0 - ) - self._assert_report( - await bounds_2_rx.receive(), power=720.0, lower=-3000.0, upper=5000.0 - ) - self._assert_report( - await bounds_1_rx.receive(), power=720.0, lower=-1000.0, upper=1500.0 - )