diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4563c5576..9c0c6df9c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -52,4 +52,4 @@ ## Bug Fixes - +- 0W power requests are now not adjusted to exclusion bounds by the `PowerManager` and `PowerDistributor`, and are sent over to the microgrid API directly. diff --git a/src/frequenz/sdk/actor/_power_managing/_bounds.py b/src/frequenz/sdk/actor/_power_managing/_bounds.py index 9cfd4d905..20b9765e3 100644 --- a/src/frequenz/sdk/actor/_power_managing/_bounds.py +++ b/src/frequenz/sdk/actor/_power_managing/_bounds.py @@ -138,7 +138,7 @@ def clamp_to_bounds( # pylint: disable=too-many-return-statements # If the given value is within the exclusion bounds and the exclusion bounds are # within the given bounds, clamp the given value to the closest exclusion bound. - if exclusion_bounds is not None: + if exclusion_bounds is not None and not value.isclose(Power.zero()): if exclusion_bounds.lower < value < exclusion_bounds.upper: return exclusion_bounds.lower, exclusion_bounds.upper diff --git a/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py b/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py index 2132bc03c..ec23bacd1 100644 --- a/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py +++ b/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py @@ -685,7 +685,16 @@ def distribute_power( Returns: Distribution result """ - if power >= 0.0: + if is_close_to_zero(power): + return DistributionResult( + distribution={ + inverter.component_id: 0.0 + for _, inverters in components + for inverter in inverters + }, + remaining_power=0.0, + ) + if power > 0.0: return self._distribute_consume_power(power, components) return self._distribute_supply_power(power, components) diff --git a/tests/actor/_power_managing/test_matryoshka.py b/tests/actor/_power_managing/test_matryoshka.py index 73277177c..f212ecaa1 100644 --- a/tests/actor/_power_managing/test_matryoshka.py +++ b/tests/actor/_power_managing/test_matryoshka.py @@ -313,6 +313,13 @@ async def test_matryoshka_with_excl_3() -> None: ) tester = StatefulTester(batteries, system_bounds) + 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=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=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)) diff --git a/tests/actor/_power_managing/test_report.py b/tests/actor/_power_managing/test_report.py index 89351ed6a..ca057a473 100644 --- a/tests/actor/_power_managing/test_report.py +++ b/tests/actor/_power_managing/test_report.py @@ -58,7 +58,7 @@ def test_adjust_to_bounds() -> None: inclusion_bounds=(-200.0, 200.0), exclusion_bounds=(-30.0, 30.0), ) - tester.case(0.0, -30.0, 30.0) + tester.case(0.0, 0.0, 0.0) tester.case(-210.0, -200.0, None) tester.case(220.0, None, 200.0) tester.case(-20.0, -30.0, 30.0) diff --git a/tests/actor/power_distributing/test_battery_distribution_algorithm.py b/tests/actor/power_distributing/test_battery_distribution_algorithm.py index 3501cc2c2..8e04bf5a8 100644 --- a/tests/actor/power_distributing/test_battery_distribution_algorithm.py +++ b/tests/actor/power_distributing/test_battery_distribution_algorithm.py @@ -1039,6 +1039,11 @@ def test_scenario_1(self) -> None: algorithm = BatteryDistributionAlgorithm() + self.assert_result( + algorithm.distribute_power(0, components), + DistributionResult({1: 0, 3: 0, 5: 0}, remaining_power=0.0), + ) + self.assert_result( algorithm.distribute_power(-300, components), DistributionResult({1: -100, 3: -100, 5: -100}, remaining_power=0.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 ed2ddebd8..1e3d9cb23 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py +++ b/tests/timeseries/_battery_pool/test_battery_pool_control_methods.py @@ -109,7 +109,11 @@ async def _patch_battery_pool_status( ) ) - async def _init_data_for_batteries(self, mocks: Mocks) -> None: + async def _init_data_for_batteries( + self, mocks: Mocks, *, exclusion_bounds: tuple[float, float] | None = None + ) -> None: + excl_lower = exclusion_bounds[0] if exclusion_bounds else 0.0 + excl_upper = exclusion_bounds[1] if exclusion_bounds else 0.0 now = datetime.now(tz=timezone.utc) for battery_id in mocks.microgrid.battery_ids: mocks.streamer.start_streaming( @@ -119,8 +123,8 @@ async def _init_data_for_batteries(self, mocks: Mocks) -> None: soc=50.0, soc_lower_bound=10.0, soc_upper_bound=90.0, - power_exclusion_lower_bound=0.0, - power_exclusion_upper_bound=0.0, + power_exclusion_lower_bound=excl_lower, + power_exclusion_upper_bound=excl_upper, power_inclusion_lower_bound=-1000.0, power_inclusion_upper_bound=1000.0, capacity=2000.0, @@ -368,3 +372,113 @@ async def test_case_3(self, mocks: Mocks, mocker: MockerFixture) -> None: assert sorted(set_power.call_args_list) == [ mocker.call(inv_id, 0.0) for inv_id in mocks.microgrid.battery_inverter_ids ] + + async def test_case_4(self, mocks: Mocks, mocker: MockerFixture) -> None: + """Test case 4. + + - single battery pool with all batteries. + - all batteries are working, but have exclusion bounds. + """ + set_power = typing.cast( + AsyncMock, microgrid.connection_manager.get().api_client.set_power + ) + await self._patch_battery_pool_status(mocks, mocker) + await self._init_data_for_batteries(mocks, exclusion_bounds=(-100.0, 100.0)) + await self._init_data_for_inverters(mocks) + + battery_pool = microgrid.battery_pool() + bounds_rx = battery_pool.power_status.new_receiver() + + self._assert_report( + await bounds_rx.receive(), power=None, lower=-4000.0, upper=4000.0 + ) + + await battery_pool.propose_power(Power.from_watts(1000.0)) + + self._assert_report( + await bounds_rx.receive(), power=1000.0, lower=-4000.0, upper=4000.0 + ) + assert set_power.call_count == 4 + assert sorted(set_power.call_args_list) == [ + mocker.call(inv_id, 250.0) + for inv_id in mocks.microgrid.battery_inverter_ids + ] + self._assert_report( + await bounds_rx.receive(), + power=1000.0, + lower=-4000.0, + upper=4000.0, + expected_result_pred=lambda result: isinstance( + result, power_distributing.Success + ), + ) + + set_power.reset_mock() + + # Non-zero power but within the exclusion bounds should get adjusted to nearest + # available power. + await battery_pool.propose_power(Power.from_watts(50.0)) + + self._assert_report( + await bounds_rx.receive(), power=400.0, lower=-4000.0, upper=4000.0 + ) + assert set_power.call_count == 4 + assert sorted(set_power.call_args_list) == [ + mocker.call(inv_id, 100.0) + for inv_id in mocks.microgrid.battery_inverter_ids + ] + self._assert_report( + await bounds_rx.receive(), + power=400.0, + lower=-4000.0, + upper=4000.0, + expected_result_pred=lambda result: isinstance( + result, power_distributing.Success + ), + ) + + set_power.reset_mock() + + # Zero power should be allowed, even if there are exclusion bounds. + await battery_pool.propose_power(Power.from_watts(0.0)) + + self._assert_report( + await bounds_rx.receive(), power=0.0, lower=-4000.0, upper=4000.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=0.0, + lower=-4000.0, + upper=4000.0, + expected_result_pred=lambda result: isinstance( + result, power_distributing.Success + ), + ) + + set_power.reset_mock() + + # Non-zero power but within the exclusion bounds should get adjusted to nearest + # available power. + await battery_pool.propose_power(Power.from_watts(-150.0)) + + self._assert_report( + await bounds_rx.receive(), power=-400.0, lower=-4000.0, upper=4000.0 + ) + assert set_power.call_count == 4 + assert sorted(set_power.call_args_list) == [ + mocker.call(inv_id, -100.0) + for inv_id in mocks.microgrid.battery_inverter_ids + ] + self._assert_report( + await bounds_rx.receive(), + power=-400.0, + lower=-4000.0, + upper=4000.0, + expected_result_pred=lambda result: isinstance( + result, power_distributing.Success + ), + )