diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4c24dc22d..85a36f477 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -8,6 +8,8 @@ - Upgrade to microgrid API v0.15.1. If you're using any of the lower level microgrid interfaces, you will need to upgrade your code. +- The `BatteryPool.power_bounds` method now streams inclusion/exclusion bounds. The bounds are now represented by `Power` objects and not `float`s. + ## New Features diff --git a/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py b/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py index f110aabd8..196c56a30 100644 --- a/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py +++ b/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py @@ -85,6 +85,12 @@ def get_channel_name(self) -> str: ComponentMetricId.POWER_INCLUSION_LOWER_BOUND: lambda msg: ( msg.power_inclusion_lower_bound ), + ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND: lambda msg: ( + msg.power_exclusion_lower_bound + ), + ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND: lambda msg: ( + msg.power_exclusion_upper_bound + ), ComponentMetricId.POWER_INCLUSION_UPPER_BOUND: lambda msg: ( msg.power_inclusion_upper_bound ), @@ -96,6 +102,12 @@ def get_channel_name(self) -> str: ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND: lambda msg: ( msg.active_power_inclusion_lower_bound ), + ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND: lambda msg: ( + msg.active_power_exclusion_lower_bound + ), + ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND: lambda msg: ( + msg.active_power_exclusion_upper_bound + ), ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND: lambda msg: ( msg.active_power_inclusion_upper_bound ), diff --git a/src/frequenz/sdk/microgrid/component/_component.py b/src/frequenz/sdk/microgrid/component/_component.py index 5dc903154..3a3c24673 100644 --- a/src/frequenz/sdk/microgrid/component/_component.py +++ b/src/frequenz/sdk/microgrid/component/_component.py @@ -145,9 +145,13 @@ class ComponentMetricId(Enum): CAPACITY = "capacity" POWER_INCLUSION_LOWER_BOUND = "power_inclusion_lower_bound" + POWER_EXCLUSION_LOWER_BOUND = "power_exclusion_lower_bound" + POWER_EXCLUSION_UPPER_BOUND = "power_exclusion_upper_bound" POWER_INCLUSION_UPPER_BOUND = "power_inclusion_upper_bound" ACTIVE_POWER_INCLUSION_LOWER_BOUND = "active_power_inclusion_lower_bound" + ACTIVE_POWER_EXCLUSION_LOWER_BOUND = "active_power_exclusion_lower_bound" + ACTIVE_POWER_EXCLUSION_UPPER_BOUND = "active_power_exclusion_upper_bound" ACTIVE_POWER_INCLUSION_UPPER_BOUND = "active_power_inclusion_upper_bound" TEMPERATURE = "temperature" diff --git a/src/frequenz/sdk/timeseries/battery_pool/__init__.py b/src/frequenz/sdk/timeseries/battery_pool/__init__.py index 56f07eda2..1550595b4 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/__init__.py +++ b/src/frequenz/sdk/timeseries/battery_pool/__init__.py @@ -3,11 +3,11 @@ """Manage a pool of batteries.""" -from ._result_types import Bound, PowerMetrics +from ._result_types import Bounds, PowerMetrics from .battery_pool import BatteryPool __all__ = [ "BatteryPool", "PowerMetrics", - "Bound", + "Bounds", ] diff --git a/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py b/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py index 4908b6d70..ceb730e0d 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py @@ -12,9 +12,10 @@ from ...microgrid import connection_manager from ...microgrid.component import ComponentCategory, ComponentMetricId, InverterType -from ...timeseries import Energy, Percentage, Sample, Temperature +from ...timeseries import Sample +from .._quantities import Energy, Percentage, Power, Temperature from ._component_metrics import ComponentMetricsData -from ._result_types import Bound, PowerMetrics +from ._result_types import Bounds, PowerMetrics _logger = logging.getLogger(__name__) _MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc) @@ -479,11 +480,15 @@ def __init__( super().__init__(used_batteries) self._battery_metrics = [ ComponentMetricId.POWER_INCLUSION_LOWER_BOUND, + ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND, + ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND, ComponentMetricId.POWER_INCLUSION_UPPER_BOUND, ] self._inverter_metrics = [ ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND, + ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND, + ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND, ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND, ] @@ -514,6 +519,84 @@ def inverter_metrics(self) -> Mapping[int, list[ComponentMetricId]]: """ return {cid: self._inverter_metrics for cid in set(self._bat_inv_map.values())} + def _fetch_inclusion_bounds( + self, + battery_id: int, + inverter_id: int, + metrics_data: dict[int, ComponentMetricsData], + ) -> tuple[datetime, list[float], list[float]]: + timestamp = _MIN_TIMESTAMP + inclusion_lower_bounds: list[float] = [] + inclusion_upper_bounds: list[float] = [] + + # Inclusion upper and lower bounds are not related. + # If one is missing, then we can still use the other. + if battery_id in metrics_data: + data = metrics_data[battery_id] + value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + inclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + inclusion_lower_bounds.append(value) + + if inverter_id in metrics_data: + data = metrics_data[inverter_id] + + value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + inclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + inclusion_lower_bounds.append(value) + + return (timestamp, inclusion_lower_bounds, inclusion_upper_bounds) + + def _fetch_exclusion_bounds( + self, + battery_id: int, + inverter_id: int, + metrics_data: dict[int, ComponentMetricsData], + ) -> tuple[datetime, list[float], list[float]]: + timestamp = _MIN_TIMESTAMP + exclusion_lower_bounds: list[float] = [] + exclusion_upper_bounds: list[float] = [] + + # Exclusion upper and lower bounds are not related. + # If one is missing, then we can still use the other. + if battery_id in metrics_data: + data = metrics_data[battery_id] + value = data.get(ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + exclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + exclusion_lower_bounds.append(value) + + if inverter_id in metrics_data: + data = metrics_data[inverter_id] + + value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + exclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + exclusion_lower_bounds.append(value) + + return (timestamp, exclusion_lower_bounds, exclusion_upper_bounds) + def calculate( self, metrics_data: dict[int, ComponentMetricsData], @@ -533,53 +616,45 @@ def calculate( High level metric calculated from the given metrics. Return None if there are no component metrics. """ - # In the future we will have lower bound, too. - - result = PowerMetrics( - timestamp=_MIN_TIMESTAMP, - supply_bound=Bound(0, 0), - consume_bound=Bound(0, 0), - ) + timestamp = _MIN_TIMESTAMP + inclusion_bounds_lower = 0.0 + inclusion_bounds_upper = 0.0 + exclusion_bounds_lower = 0.0 + exclusion_bounds_upper = 0.0 for battery_id in working_batteries: - supply_upper_bounds: list[float] = [] - consume_upper_bounds: list[float] = [] - - if battery_id in metrics_data: - data = metrics_data[battery_id] - - # Consume and supply bounds are not related. - # If one is missing, then we can still use the other. - value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND) - if value is not None: - result.timestamp = max(result.timestamp, data.timestamp) - consume_upper_bounds.append(value) - - value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND) - if value is not None: - result.timestamp = max(result.timestamp, data.timestamp) - supply_upper_bounds.append(value) - inverter_id = self._bat_inv_map[battery_id] - if inverter_id in metrics_data: - data = metrics_data[inverter_id] - - value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND) - if value is not None: - result.timestamp = max(data.timestamp, result.timestamp) - consume_upper_bounds.append(value) - - value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND) - if value is not None: - result.timestamp = max(data.timestamp, result.timestamp) - supply_upper_bounds.append(value) - - if len(consume_upper_bounds) > 0: - result.consume_bound.upper += min(consume_upper_bounds) - if len(supply_upper_bounds) > 0: - result.supply_bound.lower += max(supply_upper_bounds) + ( + _ts, + inclusion_lower_bounds, + inclusion_upper_bounds, + ) = self._fetch_inclusion_bounds(battery_id, inverter_id, metrics_data) + timestamp = max(timestamp, _ts) + ( + _ts, + exclusion_lower_bounds, + exclusion_upper_bounds, + ) = self._fetch_exclusion_bounds(battery_id, inverter_id, metrics_data) + if len(inclusion_upper_bounds) > 0: + inclusion_bounds_upper += min(inclusion_upper_bounds) + if len(inclusion_lower_bounds) > 0: + inclusion_bounds_lower += max(inclusion_lower_bounds) + if len(exclusion_upper_bounds) > 0: + exclusion_bounds_upper += max(exclusion_upper_bounds) + if len(exclusion_lower_bounds) > 0: + exclusion_bounds_lower += min(exclusion_lower_bounds) - if result.timestamp == _MIN_TIMESTAMP: + if timestamp == _MIN_TIMESTAMP: return None - return result + return PowerMetrics( + timestamp=timestamp, + inclusion_bounds=Bounds( + Power.from_watts(inclusion_bounds_lower), + Power.from_watts(inclusion_bounds_upper), + ), + exclusion_bounds=Bounds( + Power.from_watts(exclusion_bounds_lower), + Power.from_watts(exclusion_bounds_upper), + ), + ) diff --git a/src/frequenz/sdk/timeseries/battery_pool/_result_types.py b/src/frequenz/sdk/timeseries/battery_pool/_result_types.py index 7c969c179..9215ad442 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_result_types.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_result_types.py @@ -6,15 +6,17 @@ from dataclasses import dataclass, field from datetime import datetime +from .._quantities import Power + @dataclass -class Bound: +class Bounds: """Lower and upper bound values.""" - lower: float + lower: Power """Lower bound.""" - upper: float + upper: Power """Upper bound.""" @@ -26,38 +28,24 @@ class PowerMetrics: timestamp: datetime = field(compare=False) """Timestamp of the metrics.""" - supply_bound: Bound - """Supply power bounds. - - Upper bound is always 0 and will be supported later. - Lower bound is negative number calculated with with the formula: - ```python - working_pairs: Set[BatteryData, InverterData] # working batteries from the battery - pool and adjacent inverters - - supply_bound.lower = sum( - max( - battery.power_inclusion_lower_bound, inverter.active_power_inclusion_lower_bound) - for each working battery in battery pool - ) - ) - ``` + # pylint: disable=line-too-long + inclusion_bounds: Bounds + """Inclusion power bounds for all batteries in the battery pool instance. + + This is the range within which power requests are allowed by the battery pool. + + When exclusion bounds are present, they will exclude a subset of the inclusion + bounds. + + More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91). """ - consume_bound: Bound - """Consume power bounds. - - Lower bound is always 0 and will be supported later. - Upper bound is positive number calculated with with the formula: - ```python - working_pairs: Set[BatteryData, InverterData] # working batteries from the battery - pool and adjacent inverters - - consume_bound.upper = sum( - min( - battery.power_inclusion_upper_bound, inverter.active_power_inclusion_upper_bound) - for each working battery in battery pool - ) - ) - ``` + exclusion_bounds: Bounds + """Exclusion power bounds for all batteries in the battery pool instance. + + 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. + + More details [here](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.3.0/proto/frequenz/api/common/metrics.proto#L37-L91). """ + # pylint: enable=line-too-long diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index f1aa70598..72f41e3bf 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -3,6 +3,8 @@ """Tests for battery pool.""" +# pylint: disable=too-many-lines + from __future__ import annotations import asyncio @@ -27,7 +29,7 @@ from frequenz.sdk.actor.power_distributing import BatteryStatus from frequenz.sdk.microgrid.component import ComponentCategory from frequenz.sdk.timeseries import Energy, Percentage, Power, Sample, Temperature -from frequenz.sdk.timeseries.battery_pool import BatteryPool, Bound, PowerMetrics +from frequenz.sdk.timeseries.battery_pool import BatteryPool, Bounds, PowerMetrics from frequenz.sdk.timeseries.battery_pool._metric_calculator import ( battery_inverter_mapping, ) @@ -831,6 +833,8 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals timestamp=datetime.now(tz=timezone.utc), power_inclusion_lower_bound=-1000, power_inclusion_upper_bound=5000, + power_exclusion_lower_bound=-300, + power_exclusion_upper_bound=300, ), sampling_rate=0.05, ) @@ -840,6 +844,8 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals timestamp=datetime.now(tz=timezone.utc), active_power_inclusion_lower_bound=-900, active_power_inclusion_upper_bound=6000, + active_power_exclusion_lower_bound=-200, + active_power_exclusion_upper_bound=200, ), sampling_rate=0.1, ) @@ -853,8 +859,8 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals now = datetime.now(tz=timezone.utc) expected = PowerMetrics( timestamp=now, - supply_bound=Bound(-1800, 0), - consume_bound=Bound(0, 10000), + inclusion_bounds=Bounds(Power.from_watts(-1800), Power.from_watts(10000)), + exclusion_bounds=Bounds(Power.from_watts(-600), Power.from_watts(600)), ) compare_messages(msg, expected, WAIT_FOR_COMPONENT_DATA_SEC + 0.2) @@ -862,25 +868,53 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals scenarios: list[Scenario[PowerMetrics]] = [ Scenario( bat_inv_map[batteries_in_pool[0]], - {"active_power_inclusion_lower_bound": -100}, - PowerMetrics(now, Bound(-1000, 0), Bound(0, 10000)), + { + "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)), + ), ), # Inverter bound changed, but metric result should not change. Scenario( component_id=bat_inv_map[batteries_in_pool[0]], - new_metrics={"active_power_inclusion_upper_bound": 9000}, + new_metrics={ + "active_power_inclusion_upper_bound": 9000, + "active_power_exclusion_upper_bound": 250, + }, expected_result=None, wait_for_result=False, ), Scenario( batteries_in_pool[0], - {"power_inclusion_lower_bound": 0, "power_inclusion_upper_bound": 4000}, - PowerMetrics(now, Bound(-900, 0), Bound(0, 9000)), + { + "power_inclusion_lower_bound": 0, + "power_inclusion_upper_bound": 4000, + "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)), + ), ), Scenario( batteries_in_pool[1], - {"power_inclusion_lower_bound": -10, "power_inclusion_upper_bound": 200}, - PowerMetrics(now, Bound(-10, 0), Bound(0, 4200)), + { + "power_inclusion_lower_bound": -10, + "power_inclusion_upper_bound": 200, + "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)), + ), ), # Test 2 things: # 1. Battery is sending upper bounds=NaN, use only inverter upper bounds @@ -891,37 +925,68 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals { "power_inclusion_lower_bound": -50, "power_inclusion_upper_bound": math.nan, + "power_exclusion_lower_bound": -30, + "power_exclusion_upper_bound": 300, }, - PowerMetrics(now, Bound(-60, 0), Bound(0, 9200)), + PowerMetrics( + now, + Bounds(Power.from_watts(-60), Power.from_watts(9200)), + Bounds(Power.from_watts(-600), Power.from_watts(500)), + ), ), Scenario( bat_inv_map[batteries_in_pool[0]], { "active_power_inclusion_lower_bound": math.nan, "active_power_inclusion_upper_bound": math.nan, + "active_power_exclusion_lower_bound": math.nan, + "active_power_exclusion_upper_bound": math.nan, }, - PowerMetrics(now, Bound(-60, 0), Bound(0, 200)), + PowerMetrics( + now, + Bounds(Power.from_watts(-60), Power.from_watts(200)), + Bounds(Power.from_watts(-230), Power.from_watts(500)), + ), ), Scenario( batteries_in_pool[0], - {"power_inclusion_lower_bound": math.nan}, - PowerMetrics(now, Bound(-10, 0), Bound(0, 200)), + { + "power_inclusion_lower_bound": math.nan, + "power_exclusion_lower_bound": math.nan, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-10), Power.from_watts(200)), + Bounds(Power.from_watts(-200), Power.from_watts(500)), + ), ), Scenario( batteries_in_pool[1], { "power_inclusion_lower_bound": -100, "power_inclusion_upper_bound": math.nan, + "power_exclusion_lower_bound": -50, + "power_exclusion_upper_bound": 50, }, - PowerMetrics(now, Bound(-100, 0), Bound(0, 6000)), + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.from_watts(6000)), + Bounds(Power.from_watts(-200), Power.from_watts(500)), + ), ), Scenario( bat_inv_map[batteries_in_pool[1]], { "active_power_inclusion_lower_bound": math.nan, "active_power_inclusion_upper_bound": math.nan, + "active_power_exclusion_lower_bound": math.nan, + "active_power_exclusion_upper_bound": math.nan, }, - PowerMetrics(now, Bound(-100, 0), Bound(0, 0)), + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.zero()), + Bounds(Power.from_watts(-50), Power.from_watts(350)), + ), ), # All components are sending NaN, can't calculate bounds Scenario( @@ -934,35 +999,61 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals ), Scenario( batteries_in_pool[0], - {"power_inclusion_lower_bound": -100, "power_inclusion_upper_bound": 100}, - PowerMetrics(now, Bound(-100, 0), Bound(0, 100)), + { + "power_inclusion_lower_bound": -100, + "power_inclusion_upper_bound": 100, + "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)), + ), ), Scenario( bat_inv_map[batteries_in_pool[1]], { "active_power_inclusion_lower_bound": -400, "active_power_inclusion_upper_bound": 400, + "active_power_exclusion_lower_bound": -100, + "active_power_exclusion_upper_bound": 100, }, - PowerMetrics(now, Bound(-500, 0), Bound(0, 500)), + PowerMetrics( + now, + Bounds(Power.from_watts(-500), Power.from_watts(500)), + Bounds(Power.from_watts(-120), Power.from_watts(120)), + ), ), Scenario( batteries_in_pool[1], { "power_inclusion_lower_bound": -300, "power_inclusion_upper_bound": 700, + "power_exclusion_lower_bound": -130, + "power_exclusion_upper_bound": 130, }, - PowerMetrics(now, Bound(-400, 0), Bound(0, 500)), + PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(500)), + Bounds(Power.from_watts(-150), Power.from_watts(150)), + ), ), Scenario( bat_inv_map[batteries_in_pool[0]], { "active_power_inclusion_lower_bound": -200, "active_power_inclusion_upper_bound": 50, + "active_power_exclusion_lower_bound": -80, + "active_power_exclusion_upper_bound": 80, }, - PowerMetrics(now, Bound(-400, 0), Bound(0, 450)), + PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(450)), + Bounds(Power.from_watts(-210), Power.from_watts(210)), + ), ), ] - waiting_time_sec = setup_args.min_update_interval + 0.02 await run_scenarios(scenarios, streamer, receiver, waiting_time_sec) @@ -972,27 +1063,59 @@ 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, Bound(-400, 0), Bound(0, 450)), - only_first_battery_result=PowerMetrics(now, Bound(-100, 0), Bound(0, 50)), + all_pool_result=PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(450)), + 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)), + ), ) # One battery stopped sending data, inverter data should be used. await streamer.stop_streaming(batteries_in_pool[1]) await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-500, 0), Bound(0, 450)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-500), Power.from_watts(450)), + Bounds(Power.from_watts(-180), Power.from_watts(180)), + ), + 0.2, + ) # All batteries stopped sending data, use inverters only. await streamer.stop_streaming(batteries_in_pool[0]) await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-600, 0), Bound(0, 450)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-600), Power.from_watts(450)), + Bounds(Power.from_watts(-180), Power.from_watts(180)), + ), + 0.2, + ) # One inverter stopped sending data, use one remaining inverter await streamer.stop_streaming(bat_inv_map[batteries_in_pool[0]]) await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-400, 0), Bound(0, 400)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(400)), + Bounds(Power.from_watts(-100), Power.from_watts(100)), + ), + 0.2, + ) # All components stopped sending data, we can assume that power bounds are 0 await streamer.stop_streaming(bat_inv_map[batteries_in_pool[1]]) @@ -1004,7 +1127,15 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals latest_data = streamer.get_current_component_data(batteries_in_pool[0]) streamer.start_streaming(latest_data, sampling_rate=0.1) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-100, 0), Bound(0, 100)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.from_watts(100)), + Bounds(Power.from_watts(-20), Power.from_watts(20)), + ), + 0.2, + ) async def run_temperature_test( # pylint: disable=too-many-locals