diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 61ee6f2ad..722762767 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,4 +14,11 @@ ## Bug Fixes - +- Components used to be just forgotten by the power manager when all proposals are withdrawn, leaving them at their last set power values. This has been fixed by getting the power manager to set the components to their default powers, based on the component category (according to the table below), as the last step. + + + | component category | default power | + |--------------------|-------------------------------------------| + | Battery | 0.0 | + | PV | Minimum power (aka max production power) | + | EV Chargers | Maximum power (aka max consumption power) | diff --git a/src/frequenz/sdk/microgrid/__init__.py b/src/frequenz/sdk/microgrid/__init__.py index 633b89967..9fefde947 100644 --- a/src/frequenz/sdk/microgrid/__init__.py +++ b/src/frequenz/sdk/microgrid/__init__.py @@ -296,6 +296,21 @@ | -1 | -60 kW .. 0 kW | -40 kW .. -10 kW | -10 kW | -10 kW | 50 kW | | | | | | Target Power | 50 kW | +## Withdrawing power proposals + +An actor can withdraw its power proposal by calling `propose_power` with `None` +target_power and `None` bounds (which are the default anyway). As soon as an actor +calls `pool.propose_power(None)`, its proposal is dropped and the target power is +recalculated and the component powers are updated. + +When all the proposals for a pool are withdrawn, the components get reset to their +default powers immediately. These are: + +| component category | default power (according to Passive Sign Convention) | +|--------------------|------------------------------------------------------| +| Batteries | Zero | +| PV | Max production (Min power according to PSC) | +| EV Chargers | Max consumption (Max power according to PSC) | """ # 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 696ed32ae..9f1a2ef51 100644 --- a/src/frequenz/sdk/microgrid/_data_pipeline.py +++ b/src/frequenz/sdk/microgrid/_data_pipeline.py @@ -19,7 +19,7 @@ from frequenz.channels import Broadcast, Sender from frequenz.client.microgrid import ComponentCategory, InverterType -from frequenz.sdk.microgrid._power_managing._base_classes import Algorithm +from frequenz.sdk.microgrid._power_managing._base_classes import Algorithm, DefaultPower from .._internal._channels import ChannelRegistry from ..actor._actor import Actor @@ -107,12 +107,14 @@ def __init__( api_power_request_timeout=api_power_request_timeout, power_manager_algorithm=Algorithm.SHIFTING_MATRYOSHKA, component_category=ComponentCategory.BATTERY, + default_power=DefaultPower.ZERO, ) self._ev_power_wrapper = PowerWrapper( self._channel_registry, api_power_request_timeout=api_power_request_timeout, power_manager_algorithm=Algorithm.MATRYOSHKA, component_category=ComponentCategory.EV_CHARGER, + default_power=DefaultPower.MAX, ) self._pv_power_wrapper = PowerWrapper( self._channel_registry, @@ -120,6 +122,7 @@ def __init__( power_manager_algorithm=Algorithm.MATRYOSHKA, component_category=ComponentCategory.INVERTER, component_type=InverterType.SOLAR, + default_power=DefaultPower.MIN, ) self._logical_meter: LogicalMeter | None = None diff --git a/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py b/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py index 7783a8c3e..1e5756897 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_base_classes.py @@ -199,6 +199,19 @@ def __hash__(self) -> int: return hash((self.priority, self.source_id)) +class DefaultPower(enum.Enum): + """The default power for a component category.""" + + ZERO = "zero" + """The default power is 0 W.""" + + MIN = "min" + """The default power is the minimum power of the component.""" + + MAX = "max" + """The default power is the maximum power of the component.""" + + class Algorithm(enum.Enum): """The available algorithms for the power manager.""" @@ -215,7 +228,6 @@ def calculate_target_power( 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. @@ -224,12 +236,10 @@ def calculate_target_power( 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. + couldn't be calculated. """ # The arguments for this method are tightly coupled to the `Matryoshka` algorithm. diff --git a/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py b/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py index 14b55efb6..b74422a19 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py @@ -28,7 +28,7 @@ from ... import timeseries from . import _bounds -from ._base_classes import BaseAlgorithm, Proposal, _Report +from ._base_classes import BaseAlgorithm, DefaultPower, Proposal, _Report if typing.TYPE_CHECKING: from ...timeseries._base_types import SystemBounds @@ -39,9 +39,12 @@ class Matryoshka(BaseAlgorithm): """The matryoshka algorithm.""" - def __init__(self, max_proposal_age: timedelta) -> None: + def __init__( + self, max_proposal_age: timedelta, default_power: DefaultPower + ) -> None: """Create a new instance of the matryoshka algorithm.""" self._max_proposal_age_sec = max_proposal_age.total_seconds() + self._default_power = default_power self._component_buckets: dict[frozenset[int], set[Proposal]] = {} self._target_power: dict[frozenset[int], Power] = {} @@ -49,7 +52,7 @@ def _calc_target_power( self, proposals: set[Proposal], system_bounds: SystemBounds, - ) -> Power: + ) -> Power | None: """Calculate the target power for the given components. Args: @@ -80,7 +83,7 @@ def _calc_target_power( ): exclusion_bounds = system_bounds.exclusion_bounds - target_power = Power.zero() + target_power = None for next_proposal in sorted(proposals, reverse=True): if upper_bound < lower_bound: break @@ -158,7 +161,6 @@ def calculate_target_power( 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. @@ -167,12 +169,10 @@ def calculate_target_power( 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. + couldn't be calculated. Raises: # noqa: DOC502 NotImplementedError: When the proposal contains component IDs that are @@ -193,24 +193,35 @@ def calculate_target_power( bucket.add(proposal) elif not bucket: del self._component_buckets[component_ids] - _ = self._target_power.pop(component_ids, None) # If there has not been any proposal for the given components, don't calculate a # target power and just return `None`. proposals = self._component_buckets.get(component_ids) - if proposals is None: - return None - target_power = self._calc_target_power(proposals, system_bounds) + target_power = None + if proposals is not None: + target_power = self._calc_target_power(proposals, system_bounds) - if ( - must_return_power - or component_ids not in self._target_power - or self._target_power[component_ids] != target_power - ): + if target_power is not None: self._target_power[component_ids] = target_power - return target_power - return None + elif self._target_power.get(component_ids) is not None: + # If the target power was previously set, but is now `None`, then we send + # the default power of the component category, to reset it immediately. + del self._target_power[component_ids] + bounds = system_bounds.inclusion_bounds + if bounds is None: + return None + match self._default_power: + case DefaultPower.MIN: + return bounds.lower + case DefaultPower.MAX: + return bounds.upper + case DefaultPower.ZERO: + return Power.zero() + case other: + typing.assert_never(other) + + return target_power @override def get_status( 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 5b95d65c9..6072df600 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_power_managing_actor.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_power_managing_actor.py @@ -21,7 +21,14 @@ from ...actor import Actor from ...timeseries._base_types import SystemBounds from .. import _data_pipeline, _power_distributing -from ._base_classes import Algorithm, BaseAlgorithm, Proposal, ReportRequest, _Report +from ._base_classes import ( + Algorithm, + BaseAlgorithm, + DefaultPower, + Proposal, + ReportRequest, + _Report, +) from ._matryoshka import Matryoshka from ._shifting_matryoshka import ShiftingMatryoshka @@ -40,6 +47,7 @@ def __init__( # pylint: disable=too-many-arguments power_distributing_results_receiver: Receiver[_power_distributing.Result], channel_registry: ChannelRegistry, algorithm: Algorithm, + default_power: DefaultPower, component_category: ComponentCategory, component_type: ComponentType | None = None, ): @@ -54,6 +62,7 @@ def __init__( # pylint: disable=too-many-arguments results. channel_registry: The channel registry. algorithm: The power management algorithm to use. + default_power: The default power to use for the components. component_category: The category of the component this power manager instance is going to support. component_type: The type of the component of the given category that this @@ -66,6 +75,7 @@ def __init__( # pylint: disable=too-many-arguments """ self._component_category = component_category self._component_type = component_type + self._default_power = default_power self._bounds_subscription_receiver = bounds_subscription_receiver self._power_distributing_requests_sender = power_distributing_requests_sender self._power_distributing_results_receiver = power_distributing_results_receiver @@ -79,11 +89,13 @@ def __init__( # pylint: disable=too-many-arguments match algorithm: case Algorithm.MATRYOSHKA: self._algorithm: BaseAlgorithm = Matryoshka( - max_proposal_age=timedelta(seconds=60.0) + max_proposal_age=timedelta(seconds=60.0), + default_power=default_power, ) case Algorithm.SHIFTING_MATRYOSHKA: self._algorithm = ShiftingMatryoshka( - max_proposal_age=timedelta(seconds=60.0) + max_proposal_age=timedelta(seconds=60.0), + default_power=default_power, ) case _: assert_never(algorithm) @@ -121,7 +133,14 @@ async def _bounds_tracker( collective bounds of. bounds_receiver: The receiver for power bounds. """ + last_bounds: SystemBounds | None = None async for bounds in bounds_receiver: + if ( + last_bounds is not None + and bounds.inclusion_bounds == last_bounds.inclusion_bounds + ): + continue + last_bounds = bounds self._system_bounds[component_ids] = bounds await self._send_updated_target_power(component_ids, None) await self._send_reports(component_ids) @@ -179,13 +198,11 @@ async def _send_updated_target_power( self, component_ids: frozenset[int], proposal: Proposal | None, - must_send: bool = False, ) -> None: target_power = self._algorithm.calculate_target_power( component_ids, proposal, self._system_bounds[component_ids], - must_send, ) if target_power is not None: await self._power_distributing_requests_sender.send( @@ -221,9 +238,7 @@ async def _run(self) -> None: # This can be removed as soon as # https://github.com/frequenz-floss/frequenz-sdk-python/issues/293 is # implemented. - await self._send_updated_target_power( - proposal.component_ids, proposal, must_send=True - ) + await self._send_updated_target_power(proposal.component_ids, proposal) await self._send_reports(proposal.component_ids) elif selected_from(selected, self._bounds_subscription_receiver): @@ -258,7 +273,7 @@ async def _run(self) -> None: if not last_result_partial_failure: last_result_partial_failure = True await self._send_updated_target_power( - frozenset(request.component_ids), None, must_send=True + frozenset(request.component_ids), None ) case _power_distributing.Success(): last_result_partial_failure = False diff --git a/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py b/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py index 4c1acd285..20706d470 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py @@ -16,7 +16,7 @@ from ... import timeseries from . import _bounds -from ._base_classes import BaseAlgorithm, Proposal, _Report +from ._base_classes import BaseAlgorithm, DefaultPower, Proposal, _Report if typing.TYPE_CHECKING: from ...timeseries._base_types import SystemBounds @@ -55,8 +55,13 @@ class ShiftingMatryoshka(BaseAlgorithm): 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: + def __init__( + self, + max_proposal_age: timedelta, + default_power: DefaultPower, + ) -> None: """Create a new instance of the matryoshka algorithm.""" + self._default_power = default_power 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] = {} @@ -218,7 +223,6 @@ def calculate_target_power( 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. @@ -227,12 +231,10 @@ def calculate_target_power( 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. + couldn't be calculated. Raises: # noqa: DOC502 NotImplementedError: When the proposal contains component IDs that are @@ -253,18 +255,29 @@ def calculate_target_power( bucket.add(proposal) elif not bucket: del self._component_buckets[component_ids] - _ = self._target_power.pop(component_ids, None) target_power, _ = self._calc_targets(component_ids, 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 - ): + if target_power is not None: self._target_power[component_ids] = target_power - return target_power - return None + elif self._target_power.get(component_ids) is not None: + # If the target power was previously set, but is now `None`, then we send + # the default power of the component category, to reset it immediately. + del self._target_power[component_ids] + bounds = system_bounds.inclusion_bounds + if bounds is None: + return None + match self._default_power: + case DefaultPower.MIN: + return bounds.lower + case DefaultPower.MAX: + return bounds.upper + case DefaultPower.ZERO: + return Power.zero() + case other: + typing.assert_never(other) + + return target_power @override def get_status( # pylint: disable=too-many-locals diff --git a/src/frequenz/sdk/microgrid/_power_wrapper.py b/src/frequenz/sdk/microgrid/_power_wrapper.py index 9bd2f51bc..be9a97d4b 100644 --- a/src/frequenz/sdk/microgrid/_power_wrapper.py +++ b/src/frequenz/sdk/microgrid/_power_wrapper.py @@ -23,12 +23,12 @@ Request, Result, ) -from ._power_managing._base_classes import Algorithm +from ._power_managing._base_classes import Algorithm, DefaultPower _logger = logging.getLogger(__name__) -class PowerWrapper: +class PowerWrapper: # pylint: disable=too-many-instance-attributes """Wrapper around the power managing and power distributing actors.""" def __init__( # pylint: disable=too-many-arguments @@ -37,6 +37,7 @@ def __init__( # pylint: disable=too-many-arguments *, api_power_request_timeout: timedelta, power_manager_algorithm: Algorithm, + default_power: DefaultPower, component_category: ComponentCategory, component_type: ComponentType | None = None, ): @@ -47,6 +48,7 @@ def __init__( # pylint: disable=too-many-arguments api_power_request_timeout: Timeout to use when making power requests to the microgrid API. power_manager_algorithm: The power management algorithm to use. + default_power: The default power to use for the components. component_category: The category of the components that actors started by this instance of the PowerWrapper will be responsible for. component_type: The type of the component of the given category that this @@ -57,6 +59,7 @@ def __init__( # pylint: disable=too-many-arguments `None` when the component category is enough to uniquely identify the component. """ + self._default_power = default_power self._component_category = component_category self._component_type = component_type self._power_manager_algorithm = power_manager_algorithm @@ -103,6 +106,7 @@ def _start_power_managing_actor(self) -> None: return self._power_managing_actor = _power_managing.PowerManagingActor( + default_power=self._default_power, component_category=self._component_category, component_type=self._component_type, algorithm=self._power_manager_algorithm, diff --git a/tests/actor/_power_managing/test_matryoshka.py b/tests/actor/_power_managing/test_matryoshka.py index bd07b9ae9..3e9810332 100644 --- a/tests/actor/_power_managing/test_matryoshka.py +++ b/tests/actor/_power_managing/test_matryoshka.py @@ -12,6 +12,7 @@ from frequenz.sdk import timeseries from frequenz.sdk.microgrid._power_managing import Proposal +from frequenz.sdk.microgrid._power_managing._base_classes import DefaultPower from frequenz.sdk.microgrid._power_managing._matryoshka import Matryoshka from frequenz.sdk.timeseries import _base_types @@ -28,7 +29,9 @@ def __init__( self._call_count = 0 self._batteries = batteries self._system_bounds = system_bounds - self.algorithm = Matryoshka(max_proposal_age=timedelta(seconds=60.0)) + self.algorithm = Matryoshka( + max_proposal_age=timedelta(seconds=60.0), default_power=DefaultPower.ZERO + ) def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-arguments self, @@ -37,7 +40,6 @@ def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-argumen 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.""" @@ -60,7 +62,6 @@ def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-argumen ), ), self._system_bounds, - must_send, ) assert tgt_power == ( Power.from_watts(expected) if expected is not None else None @@ -109,10 +110,8 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme tester.bounds(priority=2, expected_power=25.0, expected_bounds=(-200.0, 200.0)) tester.bounds(priority=1, expected_power=25.0, expected_bounds=(25.0, 50.0)) - tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None) - tester.tgt_power( - priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0, must_send=True - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) tester.bounds(priority=1, expected_power=25.0, expected_bounds=(25.0, 50.0)) tester.tgt_power(priority=3, power=10.0, bounds=(10.0, 15.0), expected=15.0) @@ -125,7 +124,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme tester.bounds(priority=2, expected_power=22.0, expected_bounds=(10.0, 22.0)) tester.bounds(priority=1, expected_power=22.0, expected_bounds=(10.0, 22.0)) - tester.tgt_power(priority=1, power=30.0, bounds=(20.0, 50.0), expected=None) + tester.tgt_power(priority=1, power=30.0, bounds=(20.0, 50.0), expected=22.0) tester.bounds(priority=1, expected_power=22.0, expected_bounds=(10.0, 22.0)) tester.tgt_power(priority=3, power=10.0, bounds=(10.0, 50.0), expected=30.0) @@ -150,14 +149,14 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme tester.bounds(priority=2, expected_power=-50.0, expected_bounds=(-200.0, -50.0)) tester.bounds(priority=1, expected_power=-50.0, expected_bounds=(-200.0, -50.0)) - tester.tgt_power(priority=3, power=-0.0, bounds=(-200.0, 200.0), expected=None) + tester.tgt_power(priority=3, power=-0.0, bounds=(-200.0, 200.0), expected=-50.0) tester.bounds(priority=1, expected_power=-50.0, expected_bounds=(-200.0, -50.0)) tester.tgt_power(priority=1, power=-150.0, bounds=(-200.0, -150.0), expected=-150.0) tester.bounds(priority=2, expected_power=-150.0, expected_bounds=(-200.0, -50.0)) tester.bounds(priority=1, expected_power=-150.0, expected_bounds=(-200.0, -50.0)) - tester.tgt_power(priority=4, power=-180.0, bounds=(-200.0, -50.0), expected=None) + tester.tgt_power(priority=4, power=-180.0, bounds=(-200.0, -50.0), expected=-150.0) tester.bounds(priority=1, expected_power=-150.0, expected_bounds=(-200.0, -50.0)) tester.tgt_power(priority=4, power=50.0, bounds=(50.0, None), expected=50.0) @@ -172,7 +171,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=3, power=0.0, bounds=(-200.0, 200.0), expected=-150.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)) @@ -221,7 +220,7 @@ async def test_matryoshka_with_excl_1() -> None: tester.bounds(priority=2, expected_power=25.0, expected_bounds=(-200.0, 200.0)) tester.bounds(priority=1, expected_power=25.0, expected_bounds=(25.0, 50.0)) - tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) tester.bounds(priority=1, expected_power=25.0, expected_bounds=(25.0, 50.0)) tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=20.0) @@ -231,17 +230,17 @@ async def test_matryoshka_with_excl_1() -> None: 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, 20.0), expected=None) + 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=None) + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=0.0) tester.bounds(priority=0, expected_power=0.0, expected_bounds=(0.0, 50.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=(-200.0, -30.0)) tester.bounds(priority=0, expected_power=-30.0, expected_bounds=(-200.0, -30.0)) - tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=None) + 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=(-100.0, -30.0)) tester.tgt_power(priority=1, power=-40.0, bounds=(-100.0, -35.0), expected=-40.0) @@ -271,10 +270,10 @@ async def test_matryoshka_with_excl_2() -> None: tester.bounds(priority=2, expected_power=30.0, expected_bounds=(-200.0, 200.0)) tester.bounds(priority=1, expected_power=30.0, expected_bounds=(30.0, 50.0)) - tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=30.0) tester.bounds(priority=1, expected_power=30.0, expected_bounds=(30.0, 50.0)) - tester.tgt_power(priority=1, power=10.0, bounds=(5.0, 10.0), expected=None) + tester.tgt_power(priority=1, power=10.0, bounds=(5.0, 10.0), expected=30.0) tester.bounds(priority=0, expected_power=30.0, expected_bounds=(30, 50.0)) tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=0.0) @@ -290,17 +289,17 @@ async def test_matryoshka_with_excl_2() -> None: tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 50.0), expected=-10.0) tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(-10.0, 50.0)) - tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=None) + tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=-10.0) tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(-10.0, 0.0)) - tester.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=None) + 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=(-10.0, -5.0)) - tester.tgt_power(priority=2, power=-10.0, bounds=(-200.0, -5.0), expected=None) + tester.tgt_power(priority=2, power=-10.0, bounds=(-200.0, -5.0), expected=-10.0) tester.bounds(priority=1, expected_power=-10.0, expected_bounds=(-200.0, -5.0)) tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(-10.0, -5.0)) - tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=None) + tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=-10.0) tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(-100.0, -5.0)) tester.tgt_power(priority=1, power=-40.0, bounds=(-100.0, -35.0), expected=-40.0) @@ -328,22 +327,22 @@ async def test_matryoshka_with_excl_3() -> None: tester.tgt_power(priority=2, power=10.0, bounds=(None, None), expected=30.0) tester.tgt_power(priority=2, power=-10.0, bounds=(None, None), expected=-30.0) tester.tgt_power(priority=2, power=0.0, bounds=(None, None), expected=0.0) - tester.tgt_power(priority=3, power=20.0, bounds=(None, None), expected=None) + tester.tgt_power(priority=3, power=20.0, bounds=(None, None), expected=0.0) tester.tgt_power(priority=1, power=-20.0, bounds=(None, None), expected=-30.0) - tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=None) + tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=-30.0) tester.tgt_power(priority=1, power=None, bounds=(None, None), 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=(30.0, 50.0)) - tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=30.0) tester.bounds(priority=1, expected_power=30.0, expected_bounds=(30.0, 50.0)) - tester.tgt_power(priority=1, power=10.0, bounds=(5.0, 10.0), expected=None) + tester.tgt_power(priority=1, power=10.0, bounds=(5.0, 10.0), expected=30.0) tester.bounds(priority=0, expected_power=30.0, expected_bounds=(30, 50.0)) - tester.tgt_power(priority=2, power=-10.0, bounds=(-10.0, 50.0), expected=None) + 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=(30.0, 50.0)) tester.bounds(priority=0, expected_power=30.0, expected_bounds=(30.0, 50.0)) @@ -357,7 +356,7 @@ async def test_matryoshka_with_excl_3() -> None: tester.bounds(priority=1, expected_power=-30.0, expected_bounds=(-200.0, -30.0)) tester.bounds(priority=0, expected_power=-30.0, expected_bounds=(-200.0, -30.0)) - tester.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=None) + 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=(-100.0, -30.0)) tester.tgt_power(priority=1, power=-40.0, bounds=(-100.0, -35.0), expected=-40.0) @@ -395,13 +394,9 @@ async def test_matryoshka_drop_old_proposals() -> None: expected=25.0, ) - tester.tgt_power( - priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0, must_send=True - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) tester.algorithm.drop_old_proposals(now) - tester.tgt_power( - priority=1, power=20.0, bounds=(20.0, 50.0), expected=22.0, must_send=True - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=22.0) # When overwritten by a newer proposal, that proposal is not dropped. tester.tgt_power( @@ -417,16 +412,11 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 50.0), creation_time=now - 30.0, expected=25.0, - must_send=True, ) - tester.tgt_power( - priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0, must_send=True - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) tester.algorithm.drop_old_proposals(now) - tester.tgt_power( - priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0, must_send=True - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) # 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 @@ -443,7 +433,6 @@ async def test_matryoshka_drop_old_proposals() -> None: power=25.0, bounds=(25.0, 50.0), expected=25.0, - must_send=True, batteries=overlapping_batteries, ) @@ -453,7 +442,6 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 50.0), creation_time=now - 70.0, expected=25.0, - must_send=True, ) tester.tgt_power( priority=2, @@ -461,7 +449,6 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 50.0), creation_time=now - 70.0, expected=25.0, - must_send=True, ) tester.tgt_power( priority=3, @@ -469,7 +456,6 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 50.0), creation_time=now - 70.0, expected=25.0, - must_send=True, ) tester.algorithm.drop_old_proposals(now) @@ -479,7 +465,6 @@ async def test_matryoshka_drop_old_proposals() -> None: power=25.0, bounds=(25.0, 50.0), expected=25.0, - must_send=True, batteries=overlapping_batteries, ) @@ -514,7 +499,6 @@ def ensure_overlapping_bucket_request_fails() -> None: power=None, bounds=(20.0, 50.0), expected=None, - must_send=True, batteries=overlapping_batteries, ) @@ -522,14 +506,14 @@ def ensure_overlapping_bucket_request_fails() -> None: 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=25.0) - tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=None) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=25.0) ensure_overlapping_bucket_request_fails() - tester.tgt_power(priority=1, power=None, bounds=(None, None), expected=None) + tester.tgt_power(priority=1, power=None, bounds=(None, None), expected=25.0) ensure_overlapping_bucket_request_fails() - tester.tgt_power(priority=3, power=None, bounds=(None, None), expected=None) + 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) + tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=0.0) # Overlapping battery bucket is dropped. tester.tgt_power( diff --git a/tests/actor/_power_managing/test_shifting_matryoshka.py b/tests/actor/_power_managing/test_shifting_matryoshka.py index 10cc72664..658c46df0 100644 --- a/tests/actor/_power_managing/test_shifting_matryoshka.py +++ b/tests/actor/_power_managing/test_shifting_matryoshka.py @@ -14,6 +14,7 @@ from frequenz.sdk import timeseries from frequenz.sdk.microgrid._power_managing import Proposal +from frequenz.sdk.microgrid._power_managing._base_classes import DefaultPower from frequenz.sdk.microgrid._power_managing._shifting_matryoshka import ( ShiftingMatryoshka, ) @@ -32,7 +33,9 @@ def __init__( self._call_count = 0 self._batteries = batteries self._system_bounds = system_bounds - self.algorithm = ShiftingMatryoshka(max_proposal_age=timedelta(seconds=60.0)) + self.algorithm = ShiftingMatryoshka( + max_proposal_age=timedelta(seconds=60.0), default_power=DefaultPower.ZERO + ) def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-arguments self, @@ -41,7 +44,6 @@ def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-argumen 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.""" @@ -64,7 +66,6 @@ def tgt_power( # pylint: disable=too-many-arguments,too-many-positional-argumen ), ), self._system_bounds, - must_send, ) assert tgt_power == ( Power.from_watts(expected) if expected is not None else None @@ -114,9 +115,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.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=3, power=10.0, bounds=(10.0, 15.0), expected=15.0) @@ -129,7 +128,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=1, power=30.0, bounds=(20.0, 50.0), expected=22.0) 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) @@ -137,7 +136,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=2, power=40.0, bounds=(40.0, None), 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, 0.0)) @@ -154,14 +153,14 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=3, power=0.0, bounds=(-200.0, 200.0), expected=-50.0) 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.tgt_power(priority=4, power=-180.0, bounds=(-200.0, -50.0), expected=-200.0) 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) @@ -176,7 +175,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=3, power=0.0, bounds=(-200.0, 200.0), expected=-150.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)) @@ -189,7 +188,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=1, power=50.0, bounds=(50, 200), expected=100.0) 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) @@ -198,7 +197,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme 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.tgt_power(priority=1, power=None, bounds=(-200, 200), expected=50.0) tester.bounds(priority=1, expected_power=50.0, expected_bounds=(-150.0, 50.0)) @@ -222,12 +221,12 @@ async def test_matryoshka_simple() -> None: 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.tgt_power(priority=1, 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=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.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=-10.0) # tester.bounds(priority=0, expected_power=-10.0, expected_bounds=(0.0, 20.0)) @@ -264,7 +263,7 @@ async def test_matryoshka_with_excl_1() -> None: 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.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=0.0) 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) @@ -275,7 +274,7 @@ async def test_matryoshka_with_excl_1() -> None: 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.tgt_power(priority=1, power=-10.0, bounds=(-10.0, -5.0), expected=0.0) 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) @@ -318,7 +317,7 @@ async def test_matryoshka_with_excl_2() -> None: 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=0, power=40, bounds=(None, None), expected=0.0) 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)) @@ -333,7 +332,7 @@ async def test_matryoshka_with_excl_2() -> None: 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.tgt_power(priority=1, power=-10.0, bounds=(-100.0, -5.0), expected=-15.0) 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) @@ -380,17 +379,17 @@ async def test_matryoshka_with_excl_3() -> None: 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.tgt_power(priority=1, power=40.0, bounds=(-10.0, 50.0), expected=30.0) 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.tgt_power(priority=1, power=-10.0, bounds=(-10.0, 20.0), expected=30.0) 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.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=-50.0) @@ -428,13 +427,9 @@ async def test_matryoshka_drop_old_proposals() -> None: expected=47.0, ) - tester.tgt_power( - priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0, must_send=True - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0) 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 - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=42.0) # When overwritten by a newer proposal, that proposal is not dropped. tester.tgt_power( @@ -450,16 +445,11 @@ async def test_matryoshka_drop_old_proposals() -> None: 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.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0) 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 - ) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=67.0) # 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 @@ -476,7 +466,6 @@ async def test_matryoshka_drop_old_proposals() -> None: power=25.0, bounds=(25.0, 50.0), expected=25.0, - must_send=True, batteries=overlapping_batteries, ) @@ -486,7 +475,6 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 100.0), creation_time=now - 70.0, expected=72.0, - must_send=True, ) tester.tgt_power( priority=2, @@ -494,7 +482,6 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 100.0), creation_time=now - 70.0, expected=72.0, - must_send=True, ) tester.tgt_power( priority=3, @@ -502,7 +489,6 @@ async def test_matryoshka_drop_old_proposals() -> None: bounds=(25.0, 100.0), creation_time=now - 70.0, expected=75.0, - must_send=True, ) tester.algorithm.drop_old_proposals(now) @@ -512,7 +498,6 @@ async def test_matryoshka_drop_old_proposals() -> None: power=25.0, bounds=(25.0, 50.0), expected=25.0, - must_send=True, batteries=overlapping_batteries, ) @@ -547,7 +532,6 @@ def ensure_overlapping_bucket_request_fails() -> None: power=None, bounds=(20.0, 50.0), expected=None, - must_send=True, batteries=overlapping_batteries, ) @@ -555,14 +539,14 @@ def ensure_overlapping_bucket_request_fails() -> None: 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) + tester.tgt_power(priority=1, power=20.0, bounds=(20.0, 50.0), expected=30.0) ensure_overlapping_bucket_request_fails() - tester.tgt_power(priority=1, power=None, bounds=(None, None), expected=None) + tester.tgt_power(priority=1, power=None, bounds=(None, None), expected=30.0) 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) + tester.tgt_power(priority=2, power=None, bounds=(None, None), expected=0.0) # Overlapping battery bucket is dropped. tester.tgt_power( @@ -630,7 +614,7 @@ async def test_matryoshka_shifting_limiting() -> None: 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.tgt_power(priority=0, power=12.0, bounds=(None, None), expected=60.0) 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 340f585bb..eef6fcc58 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py +++ b/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py @@ -527,3 +527,26 @@ async def test_case_4(self, mocks: Mocks, mocker: MockerFixture) -> None: result, _power_distributing.Success ), ) + + # Resetting the power should lead to default (zero) power getting set for all + # the batteries. + set_power.reset_mock() + await battery_pool.propose_power(None) + self._assert_report( + await bounds_rx.receive(), power=None, lower=-4000.0, upper=4000.0 + ) + await asyncio.sleep(0.0) + 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 + ] + self._assert_report( + await bounds_rx.receive(), + power=None, + lower=-4000.0, + upper=4000.0, + dist_result=latest_dist_result.get(), + expected_result_pred=lambda result: isinstance( + result, _power_distributing.Success + ), + ) diff --git a/tests/timeseries/_ev_charger_pool/test_ev_charger_pool_control_methods.py b/tests/timeseries/_ev_charger_pool/test_ev_charger_pool_control_methods.py index bb720ab9d..b519bb473 100644 --- a/tests/timeseries/_ev_charger_pool/test_ev_charger_pool_control_methods.py +++ b/tests/timeseries/_ev_charger_pool/test_ev_charger_pool_control_methods.py @@ -24,7 +24,7 @@ ComponentPoolStatusTracker, ) from frequenz.sdk.timeseries import ResamplerConfig, Sample3Phase -from frequenz.sdk.timeseries.ev_charger_pool import EVChargerPool, EVChargerPoolReport +from frequenz.sdk.timeseries.ev_charger_pool import EVChargerPoolReport from ...microgrid.fixtures import _Mocks from ...utils.component_data_streamer import MockComponentDataStreamer @@ -140,9 +140,26 @@ async def _init_ev_chargers(self, mocks: _Mocks) -> None: 0.05, ) + async def _recv_reports_until( + self, + bounds_rx: Receiver[EVChargerPoolReport], + check: typing.Callable[[EVChargerPoolReport], bool], + ) -> EVChargerPoolReport | None: + """Receive reports until the given condition is met.""" + max_reports = 10 + ctr = 0 + latest_report: EVChargerPoolReport | None = None + while ctr < max_reports: + ctr += 1 + latest_report = await bounds_rx.receive() + if check(latest_report): + break + + return latest_report + def _assert_report( # pylint: disable=too-many-arguments self, - report: EVChargerPoolReport, + report: EVChargerPoolReport | None, *, power: float | None, lower: float, @@ -152,7 +169,7 @@ def _assert_report( # pylint: disable=too-many-arguments typing.Callable[[_power_distributing.Result], bool] | None ) = None, ) -> None: - assert report.target_power == ( + assert report is not None and report.target_power == ( Power.from_watts(power) if power is not None else None ) assert report.bounds is not None @@ -162,24 +179,6 @@ def _assert_report( # pylint: disable=too-many-arguments assert dist_result is not None assert expected_result_pred(dist_result) - async def _get_bounds_receiver( - self, ev_charger_pool: EVChargerPool - ) -> Receiver[EVChargerPoolReport]: - bounds_rx = ev_charger_pool.power_status.new_receiver() - - # Consume initial reports as chargers are initialized - expected_upper_bounds = 44160.0 - max_reports = 10 - ctr = 0 - while ctr < max_reports: - ctr += 1 - report = await bounds_rx.receive() - assert report.bounds is not None - if report.bounds.upper == Power.from_watts(expected_upper_bounds): - break - - return bounds_rx - async def test_setting_power( self, mocks: _Mocks, @@ -197,20 +196,21 @@ async def test_setting_power( await self._patch_ev_pool_status(mocks, mocker) await self._patch_power_distributing_actor(mocker) - bounds_rx = await self._get_bounds_receiver(ev_charger_pool) + bounds_rx = ev_charger_pool.power_status.new_receiver() + latest_report = await self._recv_reports_until( + bounds_rx, + lambda x: x.bounds is not None and x.bounds.upper.as_watts() == 44160.0, + ) + + self._assert_report(latest_report, power=None, lower=0.0, upper=44160.0) # Check that chargers are initialized to Power.zero() assert set_power.call_count == 4 assert all(x.args[1] == 0.0 for x in set_power.call_args_list) - self._assert_report( - await bounds_rx.receive(), power=None, lower=0.0, upper=44160.0 - ) - set_power.reset_mock() await ev_charger_pool.propose_power(Power.from_watts(40000.0)) # ignore one report because it is not always immediately updated. - await bounds_rx.receive() self._assert_report( await bounds_rx.receive(), power=40000.0, lower=0.0, upper=44160.0 ) diff --git a/tests/timeseries/_pv_pool/test_pv_pool_control_methods.py b/tests/timeseries/_pv_pool/test_pv_pool_control_methods.py index 24543a918..6e16b5c1d 100644 --- a/tests/timeseries/_pv_pool/test_pv_pool_control_methods.py +++ b/tests/timeseries/_pv_pool/test_pv_pool_control_methods.py @@ -95,7 +95,7 @@ async def _fail_pv_inverters(self, fail_ids: list[int], mocks: _Mocks) -> None: def _assert_report( # pylint: disable=too-many-arguments self, - report: PVPoolReport, + report: PVPoolReport | None, *, power: float | None, lower: float, @@ -105,7 +105,7 @@ def _assert_report( # pylint: disable=too-many-arguments typing.Callable[[_power_distributing.Result], bool] | None ) = None, ) -> None: - assert report.target_power == ( + assert report is not None and report.target_power == ( Power.from_watts(power) if power is not None else None ) assert report.bounds is not None @@ -119,16 +119,19 @@ async def _recv_reports_until( self, bounds_rx: Receiver[PVPoolReport], check: typing.Callable[[PVPoolReport], bool], - ) -> None: + ) -> PVPoolReport | None: """Receive reports until the given condition is met.""" max_reports = 10 ctr = 0 + latest_report: PVPoolReport | None = None while ctr < max_reports: ctr += 1 - report = await bounds_rx.receive() - if check(report): + latest_report = await bounds_rx.receive() + if check(latest_report): break + return latest_report + async def test_setting_power( # pylint: disable=too-many-statements self, mocks: _Mocks, @@ -142,13 +145,11 @@ async def test_setting_power( # pylint: disable=too-many-statements await self._init_pv_inverters(mocks) pv_pool = microgrid.new_pv_pool(priority=5) bounds_rx = pv_pool.power_status.new_receiver() - await self._recv_reports_until( + latest_report = await self._recv_reports_until( bounds_rx, lambda x: x.bounds is not None and x.bounds.lower.as_watts() == -100000.0, ) - self._assert_report( - await bounds_rx.receive(), power=None, lower=-100000.0, upper=0.0 - ) + self._assert_report(latest_report, power=None, lower=-100000.0, upper=0.0) await pv_pool.propose_power(Power.from_watts(-80000.0)) await self._recv_reports_until( bounds_rx, @@ -278,3 +279,23 @@ async def test_setting_power( # pylint: disable=too-many-statements mocker.call(inv_ids[2], 0.0), mocker.call(inv_ids[3], 0.0), ] + + # Resetting the power should lead to default (full) power getting set for all + # inverters. + set_power.reset_mock() + await pv_pool.propose_power(None) + report = await self._recv_reports_until( + bounds_rx, + lambda x: x.target_power is None, + ) + self._assert_report(report, power=None, lower=-100000.0, upper=0.0) + await asyncio.sleep(0.0) + + assert set_power.call_count == 4 + inv_ids = mocks.microgrid.pv_inverter_ids + assert sorted(set_power.call_args_list, key=lambda x: x.args[0]) == [ + mocker.call(inv_ids[0], -10_000.0), + mocker.call(inv_ids[1], -20_000.0), + mocker.call(inv_ids[2], -30_000.0), + mocker.call(inv_ids[3], -40_000.0), + ]