Skip to content

Commit 1a64f55

Browse files
Add functionality to force power requests
A power request might need to be forced to implement safety mechanisms, even when some components might be seemingly failing (i.e. when there is not proper consumption information, the user wants to slowly discharge batteries to prevent potential peak breaches). Signed-off-by: Daniel Zullo <[email protected]>
1 parent 577cc0c commit 1a64f55

File tree

4 files changed

+160
-14
lines changed

4 files changed

+160
-14
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* `frequenz.sdk.actor.power_distributing`: the `power` attribute of the `Request` class has been updated from `int` to a `float`.
1111
* `frequenz.sdk.microgrid`: the `set_power()` method of both the `MicrogridApiClient` and `MicrogridGrpcClient` classes now expect a `float` value for the `power_w` parameter instead of `int`.
1212

13+
* A power request can now be forced by setting the `force` attribute. This is helpful as a safety mechanism when some components might be seemingly failing, for instance, there is not proper battery metrics information.
14+
1315
## New Features
1416

1517
<!-- Here goes the main new features and examples or instructions on how to use them -->

src/frequenz/sdk/actor/power_distributing/power_distributing.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ def __init__(
195195
max_data_age_sec=10.0,
196196
)
197197

198+
self._cached_metrics: dict[int, InvBatPair | None] = {
199+
bat_id: None for bat_id, _ in self._bat_inv_map.items()
200+
}
201+
198202
def _create_users_tasks(self) -> List[asyncio.Task[None]]:
199203
"""For each user create a task to wait for request.
200204
@@ -208,37 +212,39 @@ def _create_users_tasks(self) -> List[asyncio.Task[None]]:
208212
)
209213
return tasks
210214

211-
def _get_upper_bound(self, batteries: Set[int]) -> float:
215+
def _get_upper_bound(self, batteries: Set[int], use_all: bool) -> float:
212216
"""Get total upper bound of power to be set for given batteries.
213217
214218
Note, output of that function doesn't guarantee that this bound will be
215219
the same when the request is processed.
216220
217221
Args:
218222
batteries: List of batteries
223+
use_all: flag whether all batteries must be used for the power request.
219224
220225
Returns:
221226
Upper bound for `set_power` operation.
222227
"""
223-
pairs_data: List[InvBatPair] = self._get_components_data(batteries)
228+
pairs_data: List[InvBatPair] = self._get_components_data(batteries, use_all)
224229
return sum(
225230
min(battery.power_upper_bound, inverter.active_power_upper_bound)
226231
for battery, inverter in pairs_data
227232
)
228233

229-
def _get_lower_bound(self, batteries: Set[int]) -> float:
234+
def _get_lower_bound(self, batteries: Set[int], use_all: bool) -> float:
230235
"""Get total lower bound of power to be set for given batteries.
231236
232237
Note, output of that function doesn't guarantee that this bound will be
233238
the same when the request is processed.
234239
235240
Args:
236241
batteries: List of batteries
242+
use_all: flag whether all batteries must be used for the power request.
237243
238244
Returns:
239245
Lower bound for `set_power` operation.
240246
"""
241-
pairs_data: List[InvBatPair] = self._get_components_data(batteries)
247+
pairs_data: List[InvBatPair] = self._get_components_data(batteries, use_all)
242248
return sum(
243249
max(battery.power_lower_bound, inverter.active_power_lower_bound)
244250
for battery, inverter in pairs_data
@@ -266,7 +272,7 @@ async def run(self) -> None:
266272

267273
try:
268274
pairs_data: List[InvBatPair] = self._get_components_data(
269-
request.batteries
275+
request.batteries, request.force
270276
)
271277
except KeyError as err:
272278
await user.channel.send(Error(request=request, msg=str(err)))
@@ -373,7 +379,7 @@ def _check_request(self, request: Request) -> Optional[Result]:
373379
Result for the user if the request is wrong, None otherwise.
374380
"""
375381
for battery in request.batteries:
376-
if battery not in self._battery_receivers:
382+
if battery not in self._battery_receivers and request.force is False:
377383
msg = (
378384
f"No battery {battery}, available batteries: "
379385
f"{list(self._battery_receivers.keys())}"
@@ -382,11 +388,11 @@ def _check_request(self, request: Request) -> Optional[Result]:
382388

383389
if not request.adjust_power:
384390
if request.power < 0:
385-
bound = self._get_lower_bound(request.batteries)
391+
bound = self._get_lower_bound(request.batteries, request.force)
386392
if request.power < bound:
387393
return OutOfBound(request=request, bound=bound)
388394
else:
389-
bound = self._get_upper_bound(request.batteries)
395+
bound = self._get_upper_bound(request.batteries, request.force)
390396
if request.power > bound:
391397
return OutOfBound(request=request, bound=bound)
392398

@@ -535,11 +541,14 @@ def _get_components_pairs(
535541

536542
return bat_inv_map, inv_bat_map
537543

538-
def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
544+
def _get_components_data(
545+
self, batteries: Set[int], use_all: bool
546+
) -> List[InvBatPair]:
539547
"""Get data for the given batteries and adjacent inverters.
540548
541549
Args:
542550
batteries: Batteries that needs data.
551+
use_all: flag whether all batteries must be used for the power request.
543552
544553
Raises:
545554
KeyError: If any battery in the given list doesn't exists in microgrid.
@@ -549,11 +558,13 @@ def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
549558
"""
550559
pairs_data: List[InvBatPair] = []
551560
working_batteries = (
552-
self._all_battery_status.get_working_batteries(batteries) or batteries
561+
batteries
562+
if use_all
563+
else self._all_battery_status.get_working_batteries(batteries) or batteries
553564
)
554565

555566
for battery_id in working_batteries:
556-
if battery_id not in self._battery_receivers:
567+
if battery_id not in self._battery_receivers and use_all is False:
557568
raise KeyError(
558569
f"No battery {battery_id}, "
559570
f"available batteries: {list(self._battery_receivers.keys())}"
@@ -562,6 +573,8 @@ def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
562573
inverter_id: int = self._bat_inv_map[battery_id]
563574

564575
data = self._get_battery_inverter_data(battery_id, inverter_id)
576+
if data is None and use_all is True:
577+
data = self._cached_metrics[battery_id]
565578
if data is None:
566579
_logger.warning(
567580
"Skipping battery %d because its message isn't correct.",
@@ -629,7 +642,8 @@ def _get_battery_inverter_data(
629642

630643
# If all values are ok then return them.
631644
if not any(map(isnan, replaceable_metrics)):
632-
return InvBatPair(battery_data, inverter_data)
645+
self._cached_metrics[battery_id] = InvBatPair(battery_data, inverter_data)
646+
return self._cached_metrics[battery_id]
633647

634648
# Replace NaN with the corresponding value in the adjacent component.
635649
# If both metrics are None, return None to ignore this battery.
@@ -651,10 +665,11 @@ def _get_battery_inverter_data(
651665
elif isnan(inv_bound):
652666
inverter_new_metrics[inv_attr] = bat_bound
653667

654-
return InvBatPair(
668+
self._cached_metrics[battery_id] = InvBatPair(
655669
replace(battery_data, **battery_new_metrics),
656670
replace(inverter_data, **inverter_new_metrics),
657671
)
672+
return self._cached_metrics[battery_id]
658673

659674
async def _create_channels(self) -> None:
660675
"""Create channels to get data of components in microgrid."""

src/frequenz/sdk/actor/power_distributing/request.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ class Request:
2929
If `False` and the power is outside the batteries' bounds, the request will
3030
fail and be replied to with an `OutOfBound` result.
3131
"""
32+
33+
force: bool = False
34+
"""Whether to force the power request regardless the status of components."""

tests/actor/test_power_distributing.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,10 +765,61 @@ async def test_use_all_batteries_none_is_working(
765765
battery_status_sender=battery_status_channel.new_sender(),
766766
)
767767

768+
for force in (False, True):
769+
request = Request(
770+
power=1200.0,
771+
batteries={106, 206},
772+
request_timeout_sec=SAFETY_TIMEOUT,
773+
force=force,
774+
)
775+
776+
await channel.client_handle.send(request)
777+
778+
done, pending = await asyncio.wait(
779+
[asyncio.create_task(channel.client_handle.receive())],
780+
timeout=SAFETY_TIMEOUT,
781+
)
782+
783+
assert len(pending) == 0
784+
assert len(done) == 1
785+
786+
result = done.pop().result()
787+
assert isinstance(result, Success)
788+
assert result.excess_power == approx(200.0)
789+
assert result.succeeded_power == approx(1000.0)
790+
assert result.request == request
791+
792+
await distributor._stop_actor()
793+
794+
async def test_force_request_a_battery_is_not_working(
795+
self, mocker: MockerFixture
796+
) -> None:
797+
"""Test force request when a battery is not working."""
798+
await self.init_mock_microgrid(mocker)
799+
800+
channel = Bidirectional[Request, Result]("user1", "power_distributor")
801+
802+
batteries = {106, 206}
803+
804+
attrs = {"get_working_batteries.return_value": batteries - {106}}
805+
mocker.patch(
806+
"frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus",
807+
return_value=MagicMock(spec=BatteryPoolStatus, **attrs),
808+
)
809+
810+
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
811+
812+
battery_status_channel = Broadcast[BatteryStatus]("battery_status")
813+
distributor = PowerDistributingActor(
814+
users_channels={"user1": channel.service_handle},
815+
battery_status_sender=battery_status_channel.new_sender(),
816+
)
817+
768818
request = Request(
769819
power=1200.0,
770-
batteries={106, 206},
820+
batteries=batteries,
771821
request_timeout_sec=SAFETY_TIMEOUT,
822+
force=True,
772823
)
773824

774825
await channel.client_handle.send(request)
@@ -788,3 +839,78 @@ async def test_use_all_batteries_none_is_working(
788839
assert result.request == request
789840

790841
await distributor._stop_actor()
842+
843+
async def test_battery_force_request_nan(self, mocker: MockerFixture) -> None:
844+
"""Test battery with NaN in SoC, capacity or power is used if request is forced."""
845+
mock_microgrid = await self.init_mock_microgrid(mocker)
846+
847+
channel = Bidirectional[Request, Result]("user1", "power_distributor")
848+
849+
batteries = {106, 206, 306}
850+
851+
attrs = {"get_working_batteries.return_value": batteries}
852+
mocker.patch(
853+
"frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus",
854+
return_value=MagicMock(spec=BatteryPoolStatus, **attrs),
855+
)
856+
857+
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
858+
battery_status_channel = Broadcast[BatteryStatus]("battery_status")
859+
distributor = PowerDistributingActor(
860+
{"user1": channel.service_handle},
861+
battery_status_sender=battery_status_channel.new_sender(),
862+
)
863+
864+
# The initial request is needed to set the battery metrics cache
865+
request = Request(
866+
power=1200.0,
867+
batteries=batteries,
868+
request_timeout_sec=SAFETY_TIMEOUT,
869+
force=True,
870+
)
871+
872+
await channel.client_handle.send(request)
873+
874+
async def test_result() -> None:
875+
done, pending = await asyncio.wait(
876+
[asyncio.create_task(channel.client_handle.receive())],
877+
timeout=SAFETY_TIMEOUT,
878+
)
879+
assert len(pending) == 0
880+
assert len(done) == 1
881+
result: Result = done.pop().result()
882+
assert isinstance(result, Success)
883+
assert result.succeeded_batteries == {106, 206, 306}
884+
assert result.succeeded_power == approx(1199.9999)
885+
assert result.excess_power == approx(0.0)
886+
assert result.request == request
887+
888+
await test_result()
889+
890+
batteries_data = (
891+
battery_msg(
892+
106,
893+
soc=Metric(float("NaN"), Bound(20, 80)),
894+
capacity=Metric(98000),
895+
power=Bound(-1000, 1000),
896+
),
897+
battery_msg(
898+
206,
899+
soc=Metric(40, Bound(20, 80)),
900+
capacity=Metric(float("NaN")),
901+
power=Bound(-1000, 1000),
902+
),
903+
battery_msg(
904+
306,
905+
soc=Metric(40, Bound(20, 80)),
906+
capacity=Metric(float(98000)),
907+
power=Bound(float("NaN"), float("NaN")),
908+
),
909+
)
910+
911+
for battery in batteries_data:
912+
await mock_microgrid.send(battery)
913+
await channel.client_handle.send(request)
914+
await test_result()
915+
916+
await distributor._stop_actor()

0 commit comments

Comments
 (0)