diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 61ee6f2ad..1d5e31867 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,7 +6,7 @@ ## Upgrading - +- The `BatteryPool.power_status` method now streams objects of type `BatteryPoolReport`. They're identical to the previous `Report` objects, except for the name of the class. ## New Features diff --git a/src/frequenz/sdk/actor/_power_managing/__init__.py b/src/frequenz/sdk/actor/_power_managing/__init__.py index e6de1a61a..171de279e 100644 --- a/src/frequenz/sdk/actor/_power_managing/__init__.py +++ b/src/frequenz/sdk/actor/_power_managing/__init__.py @@ -3,13 +3,13 @@ """A power manager implementation.""" -from ._base_classes import Algorithm, Proposal, Report, ReportRequest +from ._base_classes import Algorithm, Proposal, ReportRequest, _Report from ._power_managing_actor import PowerManagingActor __all__ = [ "Algorithm", "PowerManagingActor", "Proposal", - "Report", + "_Report", "ReportRequest", ] diff --git a/src/frequenz/sdk/actor/_power_managing/_base_classes.py b/src/frequenz/sdk/actor/_power_managing/_base_classes.py index f4fcb1baa..f5a11f1ba 100644 --- a/src/frequenz/sdk/actor/_power_managing/_base_classes.py +++ b/src/frequenz/sdk/actor/_power_managing/_base_classes.py @@ -16,7 +16,7 @@ from . import _bounds if typing.TYPE_CHECKING: - from ...timeseries.battery_pool import PowerMetrics + from ...timeseries._base_types import SystemBounds from .. import power_distributing @@ -27,8 +27,8 @@ class ReportRequest: source_id: str """The source ID of the actor sending the request.""" - battery_ids: frozenset[int] - """The battery IDs to report on.""" + component_ids: frozenset[int] + """The component IDs to report on.""" priority: int """The priority of the actor .""" @@ -40,15 +40,67 @@ def get_channel_name(self) -> str: The channel name to use to identify the corresponding report channel from the channel registry. """ - return f"power_manager.report.{self.battery_ids=}.{self.priority=}" + return f"power_manager.report.{self.component_ids=}.{self.priority=}" + + +class Report(typing.Protocol): + """Current PowerManager report for a set of components. + + This protocol can be specialized by different component pools to provide more + specific details and documentation for the reports. + """ + + @property + def bounds(self) -> timeseries.Bounds[Power] | None: + """The bounds for the components. + + These bounds are adjusted to any restrictions placed by actors with higher + priorities. + + There might be exclusion zones within these bounds. If necessary, the + `adjust_to_bounds` method may be used to check if a desired power value fits the + bounds, or to get the closest possible power values that do fit the bounds. + """ + + @abc.abstractmethod + def adjust_to_bounds(self, power: Power) -> tuple[Power | None, Power | None]: + """Adjust a power value to the bounds. + + This method can be used to adjust a desired power value to the power bounds + available to the actor. + + If the given power value falls within the usable bounds, it will be returned + unchanged. + + If it falls outside the usable bounds, the closest possible value on the + corresponding side will be returned. For example, if the given power is lower + than the lowest usable power, only the lowest usable power will be returned, and + similarly for the highest usable power. + + If the given power falls within an exclusion zone that's contained within the + usable bounds, the closest possible power values on both sides will be returned. + + !!! note + It is completely optional to use this method to adjust power values before + proposing them, because the PowerManager will do this automatically. This + method is provided for convenience, and for granular control when there are + two possible power values, both of which fall within the available bounds. + + Args: + power: The power value to adjust. + + Returns: + A tuple of the closest power values to the desired power that fall within + the available bounds for the actor. + """ @dataclasses.dataclass(frozen=True, kw_only=True) -class Report: - """Current PowerManager report for a set of batteries.""" +class _Report(Report): + """Current PowerManager report for a set of components.""" target_power: Power | None - """The currently set power for the batteries.""" + """The currently set power for the components.""" distribution_result: power_distributing.Result | None """The result of the last power distribution. @@ -57,14 +109,14 @@ class Report: """ _inclusion_bounds: timeseries.Bounds[Power] | None - """The available inclusion bounds for the batteries, for the actor's priority. + """The available inclusion bounds for the components, for the actor's priority. These bounds are adjusted to any restrictions placed by actors with higher priorities. """ _exclusion_bounds: timeseries.Bounds[Power] | None - """The exclusion bounds for the batteries. + """The exclusion bounds for the components. The power manager doesn't manage exclusion bounds, so these are aggregations of values reported by the microgrid API. @@ -75,15 +127,14 @@ class Report: @property def bounds(self) -> timeseries.Bounds[Power] | None: - """The bounds for the batteries. + """The bounds for the components. These bounds are adjusted to any restrictions placed by actors with higher priorities. There might be exclusion zones within these bounds. If necessary, the - [`adjust_to_bounds`][frequenz.sdk.timeseries.battery_pool.Report.adjust_to_bounds] - method may be used to check if a desired power value fits the bounds, or to get - the closest possible power values that do fit the bounds. + `adjust_to_bounds` method may be used to check if a desired power value fits the + bounds, or to get the closest possible power values that do fit the bounds. """ return self._inclusion_bounds @@ -106,29 +157,9 @@ def adjust_to_bounds(self, power: Power) -> tuple[Power | None, Power | None]: !!! note It is completely optional to use this method to adjust power values before - proposing them through the battery pool, because the battery pool will do - this automatically. This method is provided for convenience, and for - granular control when there are two possible power values, both of which - fall within the available bounds. - - Example: - ```python - from frequenz.sdk import microgrid - - power_status_rx = microgrid.battery_pool().power_status.new_receiver() - power_status = await power_status_rx.receive() - desired_power = Power.from_watts(1000.0) - - match power_status.adjust_to_bounds(desired_power): - case (power, _) if power == desired_power: - print("Desired power is available.") - case (None, power) | (power, None) if power: - print(f"Closest available power is {power}.") - case (lower, upper) if lower and upper: - print(f"Two options {lower}, {upper} to propose to battery pool.") - case (None, None): - print("No available power") - ``` + proposing them, because the PowerManager will do this automatically. This + method is provided for convenience, and for granular control when there are + two possible power values, both of which fall within the available bounds. Args: power: The power value to adjust. @@ -150,13 +181,13 @@ def adjust_to_bounds(self, power: Power) -> tuple[Power | None, Power | None]: @dataclasses.dataclass(frozen=True, kw_only=True) class Proposal: - """A proposal for a battery to be charged or discharged.""" + """A proposal for a set of components to be charged or discharged.""" source_id: str """The source ID of the actor sending the request.""" preferred_power: Power | None - """The preferred power to be distributed to the batteries. + """The preferred power to be distributed to the components. If `None`, the preferred power of higher priority actors will get precedence. """ @@ -166,12 +197,12 @@ class 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 an experimental + the maximum power of the components in the pool. This is currently an experimental feature. """ - battery_ids: frozenset[int] - """The battery IDs to distribute the power to.""" + component_ids: frozenset[int] + """The component IDs to distribute the power to.""" priority: int """The priority of the actor sending the proposal.""" @@ -207,23 +238,23 @@ class BaseAlgorithm(abc.ABC): @abc.abstractmethod def calculate_target_power( self, - battery_ids: frozenset[int], + component_ids: frozenset[int], proposal: Proposal | None, - system_bounds: PowerMetrics, + system_bounds: SystemBounds, must_return_power: bool = False, ) -> Power | None: - """Calculate and return the target power for the given batteries. + """Calculate and return the target power for the given components. Args: - battery_ids: The battery IDs to calculate the target power for. + 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 batteries in the proposal. + 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 batteries, or `None` if the target power + The new target power for the components, or `None` if the target power didn't change. """ @@ -232,19 +263,19 @@ def calculate_target_power( @abc.abstractmethod def get_status( self, - battery_ids: frozenset[int], + component_ids: frozenset[int], priority: int, - system_bounds: PowerMetrics, + system_bounds: SystemBounds, distribution_result: power_distributing.Result | None, - ) -> Report: - """Get the bounds for a set of batteries, for the given priority. + ) -> _Report: + """Get the bounds for a set of components, for the given priority. Args: - battery_ids: The IDs of the batteries to get the bounds for. + 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 batteries. + system_bounds: The system bounds for the components. distribution_result: The result of the last power distribution. Returns: - The bounds for the batteries. + The bounds for the components. """ diff --git a/src/frequenz/sdk/actor/_power_managing/_matryoshka.py b/src/frequenz/sdk/actor/_power_managing/_matryoshka.py index b799e459b..7dac119a7 100644 --- a/src/frequenz/sdk/actor/_power_managing/_matryoshka.py +++ b/src/frequenz/sdk/actor/_power_managing/_matryoshka.py @@ -3,18 +3,18 @@ """A power manager implementation that uses the matryoshka algorithm. -When there are multiple proposals from different actors for the same set of batteries, +When there are multiple proposals from different actors for the same set of components, the matryoshka algorithm will consider the priority of the actors, the bounds they set -and their preferred power to determine the target power for the batteries. +and their preferred power to determine the target power for the components. 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 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 is only a single proposal for a set of batteries, its preferred power would +When there is only a single proposal for a set of components, its preferred power would be the target power, as long as it falls within the system power bounds for the -batteries. +components. """ from __future__ import annotations @@ -27,11 +27,11 @@ from ... import timeseries from ...timeseries import Power from . import _bounds -from ._base_classes import BaseAlgorithm, Proposal, Report +from ._base_classes import BaseAlgorithm, Proposal, _Report from ._sorted_set import SortedSet if typing.TYPE_CHECKING: - from ...timeseries.battery_pool import PowerMetrics + from ...timeseries._base_types import SystemBounds from .. import power_distributing _logger = logging.getLogger(__name__) @@ -42,22 +42,22 @@ class Matryoshka(BaseAlgorithm): def __init__(self) -> None: """Create a new instance of the matryoshka algorithm.""" - self._battery_buckets: dict[frozenset[int], SortedSet[Proposal]] = {} + self._component_buckets: dict[frozenset[int], SortedSet[Proposal]] = {} self._target_power: dict[frozenset[int], Power] = {} def _calc_target_power( self, proposals: SortedSet[Proposal], - system_bounds: PowerMetrics, + system_bounds: SystemBounds, ) -> Power: - """Calculate the target power for the given batteries. + """Calculate the target power for the given components. Args: - proposals: The proposals for the given batteries. - system_bounds: The system bounds for the batteries in the proposal. + proposals: The proposals for the given components. + system_bounds: The system bounds for the components in the proposal. Returns: - The new target power for the batteries. + The new target power for the components. """ lower_bound = ( system_bounds.inclusion_bounds.lower @@ -120,13 +120,13 @@ def _calc_target_power( return target_power - def _validate_battery_ids( + def _validate_component_ids( self, - battery_ids: frozenset[int], + component_ids: frozenset[int], proposal: Proposal | None, - system_bounds: PowerMetrics, + system_bounds: SystemBounds, ) -> bool: - if battery_ids not in self._battery_buckets: + 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 ( @@ -135,57 +135,59 @@ def _validate_battery_ids( ): if proposal is not None: _logger.warning( - "PowerManagingActor: No system bounds available for battery " + "PowerManagingActor: No system bounds available for component " "IDs %s, but a proposal was given. The proposal will be " "ignored.", - battery_ids, + component_ids, ) return False - for bucket in self._battery_buckets: - if any(battery_id in bucket for battery_id in battery_ids): + for bucket in self._component_buckets: + if any(component_id in bucket for component_id in component_ids): raise NotImplementedError( - f"PowerManagingActor: Battery IDs {battery_ids} are already " - "part of another bucket. Overlapping buckets are not " - "yet supported." + 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, - battery_ids: frozenset[int], + component_ids: frozenset[int], proposal: Proposal | None, - system_bounds: PowerMetrics, + system_bounds: SystemBounds, must_return_power: bool = False, ) -> Power | None: - """Calculate and return the target power for the given batteries. + """Calculate and return the target power for the given components. Args: - battery_ids: The battery IDs to calculate the target power for. + 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 batteries in the proposal. + 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 batteries, or `None` if the target power + The new target power for the components, or `None` if the target power didn't change. Raises: # noqa: DOC502 - NotImplementedError: When the proposal contains battery IDs that are + NotImplementedError: When the proposal contains component IDs that are already part of another bucket. """ - if not self._validate_battery_ids(battery_ids, proposal, system_bounds): + if not self._validate_component_ids(component_ids, proposal, system_bounds): return None if proposal is not None: - self._battery_buckets.setdefault(battery_ids, SortedSet()).insert(proposal) + self._component_buckets.setdefault(component_ids, SortedSet()).insert( + proposal + ) - # If there has not been any proposal for the given batteries, don't calculate a + # If there has not been any proposal for the given components, don't calculate a # target power and just return `None`. - proposals = self._battery_buckets.get(battery_ids) + proposals = self._component_buckets.get(component_ids) if proposals is None: return None @@ -193,36 +195,36 @@ def calculate_target_power( if ( must_return_power - or battery_ids not in self._target_power - or self._target_power[battery_ids] != target_power + or component_ids not in self._target_power + or self._target_power[component_ids] != target_power ): - self._target_power[battery_ids] = target_power + self._target_power[component_ids] = target_power return target_power return None @override def get_status( self, - battery_ids: frozenset[int], + component_ids: frozenset[int], priority: int, - system_bounds: PowerMetrics, + system_bounds: SystemBounds, distribution_result: power_distributing.Result | None, - ) -> Report: + ) -> _Report: """Get the bounds for the algorithm. Args: - battery_ids: The IDs of the batteries to get the bounds for. + 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 batteries. + system_bounds: The system bounds for the components. distribution_result: The result of the last power distribution. Returns: - The target power and the available bounds for the given batteries, for + The target power and the available bounds for the given components, for the given priority. """ - target_power = self._target_power.get(battery_ids) + target_power = self._target_power.get(component_ids) if system_bounds.inclusion_bounds is None: - return Report( + return _Report( target_power=target_power, _inclusion_bounds=None, _exclusion_bounds=system_bounds.exclusion_bounds, @@ -239,7 +241,7 @@ def get_status( ): exclusion_bounds = system_bounds.exclusion_bounds - for next_proposal in reversed(self._battery_buckets.get(battery_ids, [])): + for next_proposal in reversed(self._component_buckets.get(component_ids, [])): if next_proposal.priority <= priority: break proposal_lower = next_proposal.bounds.lower or lower_bound @@ -257,7 +259,7 @@ def get_status( ) else: break - return Report( + return _Report( target_power=target_power, _inclusion_bounds=timeseries.Bounds[Power]( lower=lower_bound, upper=upper_bound diff --git a/src/frequenz/sdk/actor/_power_managing/_power_managing_actor.py b/src/frequenz/sdk/actor/_power_managing/_power_managing_actor.py index 826dfa16d..3e9b50419 100644 --- a/src/frequenz/sdk/actor/_power_managing/_power_managing_actor.py +++ b/src/frequenz/sdk/actor/_power_managing/_power_managing_actor.py @@ -14,15 +14,15 @@ from frequenz.channels.util import select, selected_from from typing_extensions import override +from ...timeseries._base_types import PoolType, SystemBounds from .._actor import Actor from .._channel_registry import ChannelRegistry -from ._base_classes import Algorithm, BaseAlgorithm, Proposal, Report, ReportRequest +from ._base_classes import Algorithm, BaseAlgorithm, Proposal, ReportRequest, _Report from ._matryoshka import Matryoshka _logger = logging.getLogger(__name__) if typing.TYPE_CHECKING: - from ...timeseries.battery_pool import PowerMetrics from .. import power_distributing @@ -31,6 +31,7 @@ class PowerManagingActor(Actor): def __init__( # pylint: disable=too-many-arguments self, + pool_type: PoolType, proposals_receiver: Receiver[Proposal], bounds_subscription_receiver: Receiver[ReportRequest], power_distributing_requests_sender: Sender[power_distributing.Request], @@ -43,6 +44,8 @@ def __init__( # pylint: disable=too-many-arguments """Create a new instance of the power manager. Args: + pool_type: The type of the component pool this power manager instance is + going to support. proposals_receiver: The receiver for proposals. bounds_subscription_receiver: The receiver for bounds subscriptions. power_distributing_requests_sender: The sender for power distribution @@ -60,98 +63,105 @@ def __init__( # pylint: disable=too-many-arguments f"PowerManagingActor: Unknown algorithm: {algorithm}" ) + self._pool_type = pool_type 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 self._channel_registry = channel_registry self._proposals_receiver = proposals_receiver - self._system_bounds: dict[frozenset[int], PowerMetrics] = {} + self._system_bounds: dict[frozenset[int], SystemBounds] = {} self._bound_tracker_tasks: dict[frozenset[int], asyncio.Task[None]] = {} - self._subscriptions: dict[frozenset[int], dict[int, Sender[Report]]] = {} + self._subscriptions: dict[frozenset[int], dict[int, Sender[_Report]]] = {} self._distribution_results: dict[frozenset[int], power_distributing.Result] = {} self._algorithm: BaseAlgorithm = Matryoshka() super().__init__() - async def _send_reports(self, battery_ids: frozenset[int]) -> None: - """Send reports for a set of batteries, to all subscribers. + async def _send_reports(self, component_ids: frozenset[int]) -> None: + """Send reports for a set of components, to all subscribers. Args: - battery_ids: The battery IDs. + component_ids: The component IDs for which a collective report should be + sent. """ - bounds = self._system_bounds.get(battery_ids) + bounds = self._system_bounds.get(component_ids) if bounds is None: - _logger.warning("PowerManagingActor: No bounds for %s", battery_ids) + _logger.warning("PowerManagingActor: No bounds for %s", component_ids) return - for priority, sender in self._subscriptions.get(battery_ids, {}).items(): + for priority, sender in self._subscriptions.get(component_ids, {}).items(): status = self._algorithm.get_status( - battery_ids, + component_ids, priority, bounds, - self._distribution_results.get(battery_ids), + self._distribution_results.get(component_ids), ) await sender.send(status) async def _bounds_tracker( self, - battery_ids: frozenset[int], - bounds_receiver: Receiver[PowerMetrics], + component_ids: frozenset[int], + bounds_receiver: Receiver[SystemBounds], ) -> None: - """Track the power bounds of a set of batteries and update the cache. + """Track the power bounds of a set of components and update the cache. Args: - battery_ids: The battery IDs. + component_ids: The component IDs for which this task should track the + collective bounds of. bounds_receiver: The receiver for power bounds. """ async for bounds in bounds_receiver: - self._system_bounds[battery_ids] = bounds - await self._send_updated_target_power(battery_ids, None) - await self._send_reports(battery_ids) + self._system_bounds[component_ids] = bounds + await self._send_updated_target_power(component_ids, None) + await self._send_reports(component_ids) - def _add_bounds_tracker(self, battery_ids: frozenset[int]) -> None: + def _add_bounds_tracker(self, component_ids: frozenset[int]) -> None: """Add a bounds tracker. Args: - battery_ids: The battery IDs. + component_ids: The component IDs for which to add a bounds tracker. + + Raises: + NotImplementedError: When the pool type is not supported. """ # Pylint assumes that this import is cyclic, but it's not. from ... import ( # pylint: disable=import-outside-toplevel,cyclic-import microgrid, ) - from ...timeseries.battery_pool import ( # pylint: disable=import-outside-toplevel - PowerMetrics, - ) - battery_pool = microgrid.battery_pool(battery_ids) + if self._pool_type is not PoolType.BATTERY_POOL: + err = f"PowerManagingActor: Unsupported pool type: {self._pool_type}" + _logger.error(err) + raise NotImplementedError(err) + battery_pool = microgrid.battery_pool(component_ids) # pylint: disable=protected-access bounds_receiver = battery_pool._system_power_bounds.new_receiver() # pylint: enable=protected-access - self._system_bounds[battery_ids] = PowerMetrics( + self._system_bounds[component_ids] = SystemBounds( timestamp=datetime.now(tz=timezone.utc), inclusion_bounds=None, exclusion_bounds=None, ) # Start the bounds tracker, for ongoing updates. - self._bound_tracker_tasks[battery_ids] = asyncio.create_task( - self._bounds_tracker(battery_ids, bounds_receiver) + self._bound_tracker_tasks[component_ids] = asyncio.create_task( + self._bounds_tracker(component_ids, bounds_receiver) ) async def _send_updated_target_power( self, - battery_ids: frozenset[int], + component_ids: frozenset[int], proposal: Proposal | None, must_send: bool = False, ) -> None: from .. import power_distributing # pylint: disable=import-outside-toplevel target_power = self._algorithm.calculate_target_power( - battery_ids, + component_ids, proposal, - self._system_bounds[battery_ids], + self._system_bounds[component_ids], must_send, ) request_timeout = ( @@ -161,7 +171,7 @@ async def _send_updated_target_power( await self._power_distributing_requests_sender.send( power_distributing.Request( power=target_power, - batteries=battery_ids, + batteries=component_ids, request_timeout=request_timeout, adjust_power=True, ) @@ -177,8 +187,8 @@ async def _run(self) -> None: ): if selected_from(selected, self._proposals_receiver): proposal = selected.value - if proposal.battery_ids not in self._bound_tracker_tasks: - self._add_bounds_tracker(proposal.battery_ids) + if proposal.component_ids not in self._bound_tracker_tasks: + self._add_bounds_tracker(proposal.component_ids) # TODO: must_send=True forces a new request to # pylint: disable=fixme # be sent to the PowerDistributor, even if there's no change in power. @@ -190,28 +200,28 @@ async def _run(self) -> None: # https://github.com/frequenz-floss/frequenz-sdk-python/issues/293 is # implemented. await self._send_updated_target_power( - proposal.battery_ids, proposal, must_send=True + proposal.component_ids, proposal, must_send=True ) - await self._send_reports(proposal.battery_ids) + await self._send_reports(proposal.component_ids) elif selected_from(selected, self._bounds_subscription_receiver): sub = selected.value - battery_ids = sub.battery_ids + component_ids = sub.component_ids priority = sub.priority - if battery_ids not in self._subscriptions: - self._subscriptions[battery_ids] = { + if component_ids not in self._subscriptions: + self._subscriptions[component_ids] = { priority: self._channel_registry.new_sender( sub.get_channel_name() ) } - elif priority not in self._subscriptions[battery_ids]: - self._subscriptions[battery_ids][ + elif priority not in self._subscriptions[component_ids]: + self._subscriptions[component_ids][ priority ] = self._channel_registry.new_sender(sub.get_channel_name()) - if sub.battery_ids not in self._bound_tracker_tasks: - self._add_bounds_tracker(sub.battery_ids) + if sub.component_ids not in self._bound_tracker_tasks: + self._add_bounds_tracker(sub.component_ids) elif selected_from(selected, self._power_distributing_results_receiver): from .. import ( # pylint: disable=import-outside-toplevel diff --git a/src/frequenz/sdk/microgrid/_data_pipeline.py b/src/frequenz/sdk/microgrid/_data_pipeline.py index 4b1e0aed4..0f5612d86 100644 --- a/src/frequenz/sdk/microgrid/_data_pipeline.py +++ b/src/frequenz/sdk/microgrid/_data_pipeline.py @@ -20,6 +20,7 @@ from ..actor._actor import Actor from ..microgrid.component import Component +from ..timeseries._base_types import PoolType from ..timeseries._grid_frequency import GridFrequency from ..timeseries.grid import Grid from ..timeseries.grid import get as get_grid @@ -276,6 +277,7 @@ def _start_power_managing_actor(self) -> None: from ..actor._power_managing._power_managing_actor import PowerManagingActor self._power_managing_actor = PowerManagingActor( + pool_type=PoolType.BATTERY_POOL, proposals_receiver=self._power_management_proposals_channel.new_receiver(), bounds_subscription_receiver=( self._power_manager_bounds_subscription_channel.new_receiver() diff --git a/src/frequenz/sdk/timeseries/_base_types.py b/src/frequenz/sdk/timeseries/_base_types.py index a722181d4..7b205e6de 100644 --- a/src/frequenz/sdk/timeseries/_base_types.py +++ b/src/frequenz/sdk/timeseries/_base_types.py @@ -3,6 +3,8 @@ """Timeseries basic types.""" +import dataclasses +import enum import functools import typing from collections.abc import Callable, Iterator @@ -10,7 +12,7 @@ from datetime import datetime, timezone from typing import Generic, Self, overload -from ._quantities import QuantityT +from ._quantities import Power, QuantityT UNIX_EPOCH = datetime.fromtimestamp(0.0, tz=timezone.utc) """The UNIX epoch (in UTC).""" @@ -150,3 +152,37 @@ class Bounds(Generic[_T]): upper: _T """Upper bound.""" + + +@dataclass(frozen=True, kw_only=True) +class SystemBounds: + """Internal representation of system bounds for groups of components.""" + + # compare = False tells the dataclass to not use name for comparison methods + timestamp: datetime = dataclasses.field(compare=False) + """Timestamp of the metrics.""" + + inclusion_bounds: Bounds[Power] | None + """Total inclusion power bounds for all components of a pool. + + This is the range within which power requests would be allowed by the pool. + + When exclusion bounds are present, they will exclude a subset of the inclusion + bounds. + """ + + exclusion_bounds: Bounds[Power] | None + """Total exclusion power bounds for all components of a pool. + + This is the range within which power requests are NOT allowed by the pool. + If present, they will be a subset of the inclusion bounds. + """ + + +class PoolType(enum.Enum): + """Enumeration of component pool types.""" + + BATTERY_POOL = "BATTERY_POOL" + EV_CHARGER_POOL = "EV_CHARGER_POOL" + PV_POOL = "PV_POOL" + CHP_POOL = "CHP_POOL" diff --git a/src/frequenz/sdk/timeseries/battery_pool/__init__.py b/src/frequenz/sdk/timeseries/battery_pool/__init__.py index d5428b62e..b9d2a710c 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/__init__.py +++ b/src/frequenz/sdk/timeseries/battery_pool/__init__.py @@ -3,12 +3,12 @@ """Manage a pool of batteries.""" -from ...actor._power_managing import Report +from ...actor._power_managing import _Report from ._battery_pool import BatteryPool -from ._result_types import PowerMetrics +from ._result_types import BatteryPoolReport __all__ = [ "BatteryPool", - "PowerMetrics", - "Report", + "BatteryPoolReport", + "_Report", ] diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py index cbd61ddcb..3872482b7 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py @@ -17,6 +17,7 @@ from ..._internal._channels import ReceiverFetcher from ...actor import _power_managing from ...timeseries import Energy, Percentage, Power, Sample, Temperature +from .._base_types import SystemBounds from ..formula_engine import FormulaEngine from ..formula_engine._formula_generators import ( BatteryPowerFormula, @@ -30,7 +31,7 @@ SoCCalculator, TemperatureCalculator, ) -from ._result_types import PowerMetrics +from ._result_types import BatteryPoolReport # pylint: disable=protected-access @@ -128,7 +129,7 @@ async def propose_power( source_id=self._source_id, preferred_power=power, bounds=bounds, - battery_ids=self._battery_pool._batteries, + component_ids=self._battery_pool._batteries, priority=self._priority, request_timeout=request_timeout, ) @@ -173,7 +174,7 @@ async def propose_charge( source_id=self._source_id, preferred_power=power, bounds=timeseries.Bounds(None, None), - battery_ids=self._battery_pool._batteries, + component_ids=self._battery_pool._batteries, priority=self._priority, request_timeout=request_timeout, ) @@ -218,7 +219,7 @@ async def propose_discharge( source_id=self._source_id, preferred_power=power, bounds=timeseries.Bounds(None, None), - battery_ids=self._battery_pool._batteries, + component_ids=self._battery_pool._batteries, priority=self._priority, request_timeout=request_timeout, ) @@ -364,7 +365,7 @@ def capacity(self) -> ReceiverFetcher[Sample[Energy]]: return self._battery_pool._active_methods[method_name] @property - def power_status(self) -> ReceiverFetcher[_power_managing.Report]: + def power_status(self) -> ReceiverFetcher[BatteryPoolReport]: """Get a receiver to receive new power status reports when they change. These include @@ -378,7 +379,7 @@ def power_status(self) -> ReceiverFetcher[_power_managing.Report]: sub = _power_managing.ReportRequest( source_id=self._source_id, priority=self._priority, - battery_ids=self._battery_pool._batteries, + component_ids=self._battery_pool._batteries, ) self._battery_pool._power_bounds_subs[ sub.get_channel_name() @@ -393,7 +394,7 @@ def power_status(self) -> ReceiverFetcher[_power_managing.Report]: ) @property - def _system_power_bounds(self) -> ReceiverFetcher[PowerMetrics]: + def _system_power_bounds(self) -> ReceiverFetcher[SystemBounds]: """Get receiver to receive new power bounds when they change. Power bounds refer to the min and max power that a battery can diff --git a/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py b/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py index 990e48b9c..592bea8e8 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py @@ -19,10 +19,9 @@ ) from ...actor.power_distributing.result import PowerBounds from ...microgrid.component import ComponentMetricId -from ...timeseries import Sample +from .._base_types import Sample, SystemBounds from .._quantities import Energy, Percentage, Power, Temperature from ._component_metrics import ComponentMetricsData -from ._result_types import PowerMetrics _logger = logging.getLogger(__name__) @@ -32,7 +31,7 @@ # Formula output types class have no common interface # Print all possible types here. -T = TypeVar("T", Sample[Percentage], Sample[Energy], PowerMetrics, Sample[Temperature]) +T = TypeVar("T", Sample[Percentage], Sample[Energy], SystemBounds, Sample[Temperature]) """Type variable of the formula output.""" @@ -419,7 +418,7 @@ def calculate( ) -class PowerBoundsCalculator(MetricCalculator[PowerMetrics]): +class PowerBoundsCalculator(MetricCalculator[SystemBounds]): """Define how to calculate PowerBounds metrics.""" def __init__( @@ -504,7 +503,7 @@ def calculate( self, metrics_data: dict[int, ComponentMetricsData], working_batteries: set[int], - ) -> PowerMetrics: + ) -> SystemBounds: """Aggregate the metrics_data and calculate high level metric. Missing components will be ignored. Formula will be calculated for all @@ -605,13 +604,13 @@ def get_bounds_list( ) if timestamp == _MIN_TIMESTAMP: - return PowerMetrics( + return SystemBounds( timestamp=datetime.now(tz=timezone.utc), inclusion_bounds=None, exclusion_bounds=None, ) - return PowerMetrics( + return SystemBounds( timestamp=timestamp, inclusion_bounds=timeseries.Bounds( Power.from_watts(inclusion_bounds_lower), diff --git a/src/frequenz/sdk/timeseries/battery_pool/_result_types.py b/src/frequenz/sdk/timeseries/battery_pool/_result_types.py index 00d7dbe02..d0dbf5614 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_result_types.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_result_types.py @@ -1,45 +1,90 @@ # License: MIT # Copyright © 2023 Frequenz Energy-as-a-Service GmbH -"""Methods for processing battery-inverter data.""" +"""Types for exposing battery pool reports.""" -from dataclasses import dataclass, field -from datetime import datetime +import abc -from .. import _base_types +from ...actor import power_distributing +from ...actor._power_managing._base_classes import Report +from .._base_types import Bounds from .._quantities import Power -@dataclass -class PowerMetrics: - """Power bounds metrics.""" +# This class is used to expose the generic reports from the PowerManager with specific +# documentation for the battery pool. +class BatteryPoolReport(Report): + """A status report for a battery pool.""" - # compare = False tells the dataclass to not use name for comparison methods - timestamp: datetime = field(compare=False) - """Timestamp of the metrics.""" + target_power: Power | None + """The currently set power for the batteries.""" - # pylint: disable=line-too-long - inclusion_bounds: _base_types.Bounds[Power] | None - """Inclusion power bounds for all batteries in the battery pool instance. + distribution_result: power_distributing.Result | None + """The result of the last power distribution. - This is the range within which power requests are allowed by the battery pool. + This is `None` if no power distribution has been performed yet. + """ - When exclusion bounds are present, they will exclude a subset of the inclusion - bounds. + @property + def bounds(self) -> Bounds[Power] | None: + """The usable bounds for the batteries. - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ + These bounds are adjusted to any restrictions placed by actors with higher + priorities. - exclusion_bounds: _base_types.Bounds[Power] | None - """Exclusion power bounds for all batteries in the battery pool instance. + There might be exclusion zones within these bounds. If necessary, the + [`adjust_to_bounds`][frequenz.sdk.timeseries.battery_pool.BatteryPoolReport.adjust_to_bounds] + method may be used to check if a desired power value fits the bounds, or to get + the closest possible power values that do fit the bounds. + """ - This is the range within which power requests are NOT allowed by the battery pool. - If present, they will be a subset of the inclusion bounds. + @abc.abstractmethod + def adjust_to_bounds(self, power: Power) -> tuple[Power | None, Power | None]: + """Adjust a power value to the bounds. - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - # pylint: enable=line-too-long + This method can be used to adjust a desired power value to the power bounds + available to the actor. + + If the given power value falls within the usable bounds, it will be returned + unchanged. + + If it falls outside the usable bounds, the closest possible value on the + corresponding side will be returned. For example, if the given power is lower + than the lowest usable power, only the lowest usable power will be returned, and + similarly for the highest usable power. + + If the given power falls within an exclusion zone that's contained within the + usable bounds, the closest possible power values on both sides will be returned. + + !!! note + It is completely optional to use this method to adjust power values before + proposing them, because the battery pool will do this automatically. This + method is provided for convenience, and for granular control when there are + two possible power values, both of which fall within the available bounds. + + Example: + ```python + from frequenz.sdk import microgrid + + power_status_rx = microgrid.battery_pool().power_status.new_receiver() + power_status = await power_status_rx.receive() + desired_power = Power.from_watts(1000.0) + + match power_status.adjust_to_bounds(desired_power): + case (power, _) if power == desired_power: + print("Desired power is available.") + case (None, power) | (power, None) if power: + print(f"Closest available power is {power}.") + case (lower, upper) if lower and upper: + print(f"Two options {lower}, {upper} to propose to battery pool.") + case (None, None): + print("No available power") + ``` + + Args: + power: The power value to adjust. + + Returns: + A tuple of the closest power values to the desired power that fall within + the available bounds for the actor. + """ diff --git a/tests/actor/_power_managing/test_matryoshka.py b/tests/actor/_power_managing/test_matryoshka.py index df64360c9..73277177c 100644 --- a/tests/actor/_power_managing/test_matryoshka.py +++ b/tests/actor/_power_managing/test_matryoshka.py @@ -8,7 +8,7 @@ from frequenz.sdk import timeseries from frequenz.sdk.actor._power_managing import Proposal from frequenz.sdk.actor._power_managing._matryoshka import Matryoshka -from frequenz.sdk.timeseries import Power, battery_pool +from frequenz.sdk.timeseries import Power, _base_types class StatefulTester: @@ -17,7 +17,7 @@ class StatefulTester: def __init__( self, batteries: frozenset[int], - system_bounds: battery_pool.PowerMetrics, + system_bounds: _base_types.SystemBounds, ) -> None: """Create a new instance of the stateful tester.""" self._call_count = 0 @@ -38,7 +38,7 @@ def tgt_power( # pylint: disable=too-many-arguments tgt_power = self._algorithm.calculate_target_power( self._batteries, Proposal( - battery_ids=self._batteries, + component_ids=self._batteries, source_id=f"actor-{priority}", preferred_power=None if power is None else Power.from_watts(power), bounds=timeseries.Bounds( @@ -83,7 +83,7 @@ async def test_matryoshka_no_excl() -> None: # pylint: disable=too-many-stateme """ batteries = frozenset({2, 5}) - system_bounds = battery_pool.PowerMetrics( + 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) @@ -193,7 +193,7 @@ async def test_matryoshka_with_excl_1() -> None: """ batteries = frozenset({2, 5}) - system_bounds = battery_pool.PowerMetrics( + 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) @@ -243,7 +243,7 @@ async def test_matryoshka_with_excl_2() -> None: """ batteries = frozenset({2, 5}) - system_bounds = battery_pool.PowerMetrics( + 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) @@ -302,7 +302,7 @@ async def test_matryoshka_with_excl_3() -> None: """ batteries = frozenset({2, 5}) - system_bounds = battery_pool.PowerMetrics( + 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) diff --git a/tests/actor/_power_managing/test_report.py b/tests/actor/_power_managing/test_report.py index e6a057144..89351ed6a 100644 --- a/tests/actor/_power_managing/test_report.py +++ b/tests/actor/_power_managing/test_report.py @@ -3,7 +3,7 @@ """Tests for methods provided by the PowerManager's reports.""" -from frequenz.sdk.actor._power_managing import Report +from frequenz.sdk.actor._power_managing import _Report from frequenz.sdk.timeseries import Bounds, Power @@ -16,7 +16,7 @@ def __init__( exclusion_bounds: tuple[float, float] | None, ) -> None: """Initialize the tester.""" - self._report = Report( + self._report = _Report( target_power=None, _inclusion_bounds=Bounds( # pylint: disable=protected-access Power.from_watts(inclusion_bounds[0]), diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index ee5146c14..5e660bc5f 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -39,7 +39,8 @@ Sample, Temperature, ) -from frequenz.sdk.timeseries.battery_pool import BatteryPool, PowerMetrics +from frequenz.sdk.timeseries._base_types import SystemBounds +from frequenz.sdk.timeseries.battery_pool import BatteryPool from ...timeseries.mock_microgrid import MockMicrogrid from ...utils.component_data_streamer import MockComponentDataStreamer @@ -448,7 +449,7 @@ async def run_test_battery_status_channel( # pylint: disable=too-many-arguments msg = await asyncio.wait_for( battery_pool_metric_receiver.receive(), timeout=waiting_time_sec ) - if isinstance(msg, PowerMetrics): + if isinstance(msg, SystemBounds): assert msg.inclusion_bounds is None assert msg.exclusion_bounds is None elif isinstance(msg, Sample): @@ -873,7 +874,7 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals receiver.receive(), timeout=WAIT_FOR_COMPONENT_DATA_SEC + 0.2 ) now = datetime.now(tz=timezone.utc) - expected = PowerMetrics( + expected = SystemBounds( timestamp=now, inclusion_bounds=Bounds(Power.from_watts(-1800), Power.from_watts(10000)), exclusion_bounds=Bounds(Power.from_watts(-600), Power.from_watts(600)), @@ -881,17 +882,19 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals compare_messages(msg, expected, WAIT_FOR_COMPONENT_DATA_SEC + 0.2) batteries_in_pool = list(battery_pool.battery_ids) - scenarios: list[Scenario[PowerMetrics]] = [ + scenarios: list[Scenario[SystemBounds]] = [ Scenario( next(iter(bat_invs_map[batteries_in_pool[0]])), { "active_power_inclusion_lower_bound": -100, "active_power_exclusion_lower_bound": -400, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-1000), Power.from_watts(10000)), - Bounds(Power.from_watts(-700), Power.from_watts(600)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds( + Power.from_watts(-1000), Power.from_watts(10000) + ), + exclusion_bounds=Bounds(Power.from_watts(-700), Power.from_watts(600)), ), ), # Inverter bound changed, but metric result should not change. @@ -901,10 +904,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "active_power_inclusion_upper_bound": 9000, "active_power_exclusion_upper_bound": 250, }, - expected_result=PowerMetrics( - now, - None, - None, + expected_result=SystemBounds( + timestamp=now, + inclusion_bounds=None, + exclusion_bounds=None, ), wait_for_result=False, ), @@ -916,10 +919,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "power_exclusion_lower_bound": 0, "power_exclusion_upper_bound": 100, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-900), Power.from_watts(9000)), - Bounds(Power.from_watts(-700), Power.from_watts(550)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-900), Power.from_watts(9000)), + exclusion_bounds=Bounds(Power.from_watts(-700), Power.from_watts(550)), ), ), Scenario( @@ -930,10 +933,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "power_exclusion_lower_bound": -5, "power_exclusion_upper_bound": 5, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-10), Power.from_watts(4200)), - Bounds(Power.from_watts(-600), Power.from_watts(450)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-10), Power.from_watts(4200)), + exclusion_bounds=Bounds(Power.from_watts(-600), Power.from_watts(450)), ), ), Scenario( @@ -944,10 +947,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "active_power_exclusion_lower_bound": math.nan, "active_power_exclusion_upper_bound": math.nan, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-10), Power.from_watts(200)), - Bounds(Power.from_watts(-200), Power.from_watts(200)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-10), Power.from_watts(200)), + exclusion_bounds=Bounds(Power.from_watts(-200), Power.from_watts(200)), ), ), Scenario( @@ -958,10 +961,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "power_exclusion_lower_bound": -50, "power_exclusion_upper_bound": 50, }, - PowerMetrics( - now, - None, - None, + SystemBounds( + timestamp=now, + inclusion_bounds=None, + exclusion_bounds=None, ), ), Scenario( @@ -972,10 +975,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "power_exclusion_lower_bound": -20, "power_exclusion_upper_bound": 20, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-100), Power.from_watts(100)), - Bounds(Power.from_watts(-70), Power.from_watts(70)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-100), Power.from_watts(100)), + exclusion_bounds=Bounds(Power.from_watts(-70), Power.from_watts(70)), ), wait_for_result=False, ), @@ -987,10 +990,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "active_power_exclusion_lower_bound": -100, "active_power_exclusion_upper_bound": 100, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-500), Power.from_watts(500)), - Bounds(Power.from_watts(-120), Power.from_watts(120)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-500), Power.from_watts(500)), + exclusion_bounds=Bounds(Power.from_watts(-120), Power.from_watts(120)), ), wait_for_result=False, ), @@ -1002,10 +1005,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "power_exclusion_lower_bound": -130, "power_exclusion_upper_bound": 130, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-300), Power.from_watts(400)), - Bounds(Power.from_watts(-130), Power.from_watts(130)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-300), Power.from_watts(400)), + exclusion_bounds=Bounds(Power.from_watts(-130), Power.from_watts(130)), ), ), Scenario( @@ -1016,10 +1019,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals "active_power_exclusion_lower_bound": -80, "active_power_exclusion_upper_bound": 80, }, - PowerMetrics( - now, - Bounds(Power.from_watts(-400), Power.from_watts(450)), - Bounds(Power.from_watts(-210), Power.from_watts(210)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-400), Power.from_watts(450)), + exclusion_bounds=Bounds(Power.from_watts(-210), Power.from_watts(210)), ), ), ] @@ -1032,15 +1035,15 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals all_batteries=all_batteries, batteries_in_pool=batteries_in_pool, waiting_time_sec=waiting_time_sec, - all_pool_result=PowerMetrics( - now, - Bounds(Power.from_watts(-400), Power.from_watts(450)), - Bounds(Power.from_watts(-210), Power.from_watts(210)), + all_pool_result=SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-400), Power.from_watts(450)), + exclusion_bounds=Bounds(Power.from_watts(-210), Power.from_watts(210)), ), - only_first_battery_result=PowerMetrics( - now, - Bounds(Power.from_watts(-100), Power.from_watts(50)), - Bounds(Power.from_watts(-80), Power.from_watts(80)), + only_first_battery_result=SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-100), Power.from_watts(50)), + exclusion_bounds=Bounds(Power.from_watts(-80), Power.from_watts(80)), ), ) @@ -1050,10 +1053,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) compare_messages( msg, - PowerMetrics( - now, - Bounds(Power.from_watts(-300), Power.from_watts(400)), - Bounds(Power.from_watts(-130), Power.from_watts(130)), + SystemBounds( + timestamp=now, + inclusion_bounds=Bounds(Power.from_watts(-300), Power.from_watts(400)), + exclusion_bounds=Bounds(Power.from_watts(-130), Power.from_watts(130)), ), 0.2, ) @@ -1063,7 +1066,7 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) assert ( - isinstance(msg, PowerMetrics) + isinstance(msg, SystemBounds) and msg.inclusion_bounds is None and msg.exclusion_bounds is None ) 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 36e2da289..2b121c362 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py +++ b/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py @@ -14,10 +14,11 @@ from pytest_mock import MockerFixture from frequenz.sdk import microgrid, timeseries -from frequenz.sdk.actor import ResamplerConfig, _power_managing, power_distributing +from frequenz.sdk.actor import ResamplerConfig, power_distributing from frequenz.sdk.actor.power_distributing import BatteryStatus from frequenz.sdk.actor.power_distributing._battery_pool_status import BatteryPoolStatus from frequenz.sdk.timeseries import Power +from frequenz.sdk.timeseries.battery_pool import BatteryPoolReport from ...utils.component_data_streamer import MockComponentDataStreamer from ...utils.component_data_wrapper import BatteryDataWrapper, InverterDataWrapper @@ -138,7 +139,7 @@ async def _init_data_for_inverters(self, mocks: Mocks) -> None: def _assert_report( self, - report: _power_managing.Report, + report: BatteryPoolReport, *, power: float | None, lower: float, @@ -149,16 +150,9 @@ def _assert_report( assert report.target_power == ( Power.from_watts(power) if power is not None else None ) - # pylint: disable=protected-access - assert ( - report._inclusion_bounds is not None - and report._exclusion_bounds is not None - ) - assert report._inclusion_bounds.lower == Power.from_watts(lower) - assert report._inclusion_bounds.upper == Power.from_watts(upper) - assert report._exclusion_bounds.lower == Power.from_watts(0.0) - assert report._exclusion_bounds.upper == Power.from_watts(0.0) - # pylint: enable=protected-access + assert report.bounds is not None + assert report.bounds.lower == Power.from_watts(lower) + assert report.bounds.upper == Power.from_watts(upper) if expected_result_pred is not None: assert report.distribution_result is not None assert expected_result_pred(report.distribution_result)