Skip to content

Commit 297f9f3

Browse files
Allow optionally forcing 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 23e7012 commit 297f9f3

File tree

4 files changed

+258
-16
lines changed

4 files changed

+258
-16
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
* The `LogicalMeter` no longer takes a `component_graph` parameter.
1414

15+
* 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.
16+
1517
## New Features
1618

1719
<!-- 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: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ def __init__(
211211
max_data_age_sec=10.0,
212212
)
213213

214+
self._cached_metrics: dict[int, InvBatPair | None] = {
215+
bat_id: None for bat_id, _ in self._bat_inv_map.items()
216+
}
217+
214218
def _create_users_tasks(self) -> List[asyncio.Task[None]]:
215219
"""For each user create a task to wait for request.
216220
@@ -224,37 +228,39 @@ def _create_users_tasks(self) -> List[asyncio.Task[None]]:
224228
)
225229
return tasks
226230

227-
def _get_upper_bound(self, batteries: Set[int]) -> float:
231+
def _get_upper_bound(self, batteries: Set[int], use_all: bool) -> float:
228232
"""Get total upper bound of power to be set for given batteries.
229233
230234
Note, output of that function doesn't guarantee that this bound will be
231235
the same when the request is processed.
232236
233237
Args:
234238
batteries: List of batteries
239+
use_all: whether all batteries in the power request must be used.
235240
236241
Returns:
237242
Upper bound for `set_power` operation.
238243
"""
239-
pairs_data: List[InvBatPair] = self._get_components_data(batteries)
244+
pairs_data: List[InvBatPair] = self._get_components_data(batteries, use_all)
240245
return sum(
241246
min(battery.power_upper_bound, inverter.active_power_upper_bound)
242247
for battery, inverter in pairs_data
243248
)
244249

245-
def _get_lower_bound(self, batteries: Set[int]) -> float:
250+
def _get_lower_bound(self, batteries: Set[int], use_all: bool) -> float:
246251
"""Get total lower bound of power to be set for given batteries.
247252
248253
Note, output of that function doesn't guarantee that this bound will be
249254
the same when the request is processed.
250255
251256
Args:
252257
batteries: List of batteries
258+
use_all: whether all batteries in the power request must be used.
253259
254260
Returns:
255261
Lower bound for `set_power` operation.
256262
"""
257-
pairs_data: List[InvBatPair] = self._get_components_data(batteries)
263+
pairs_data: List[InvBatPair] = self._get_components_data(batteries, use_all)
258264
return sum(
259265
max(battery.power_lower_bound, inverter.active_power_lower_bound)
260266
for battery, inverter in pairs_data
@@ -282,21 +288,19 @@ async def run(self) -> None:
282288

283289
try:
284290
pairs_data: List[InvBatPair] = self._get_components_data(
285-
request.batteries
291+
request.batteries, request.force
286292
)
287293
except KeyError as err:
288294
await user.channel.send(Error(request=request, msg=str(err)))
289295
continue
290296

291-
if len(pairs_data) == 0:
297+
if len(pairs_data) == 0 and request.force is False:
292298
error_msg = f"No data for the given batteries {str(request.batteries)}"
293299
await user.channel.send(Error(request=request, msg=str(error_msg)))
294300
continue
295301

296302
try:
297-
distribution = self.distribution_algorithm.distribute_power(
298-
request.power, pairs_data
299-
)
303+
distribution = self._get_power_distribution(request, pairs_data)
300304
except ValueError as err:
301305
error_msg = f"Couldn't distribute power, error: {str(err)}"
302306
await user.channel.send(Error(request=request, msg=str(error_msg)))
@@ -379,6 +383,34 @@ async def _set_distributed_power(
379383

380384
return self._parse_result(tasks, distribution.distribution, timeout_sec)
381385

386+
def _get_power_distribution(
387+
self, request: Request, inv_vat_pairs: List[InvBatPair]
388+
) -> DistributionResult:
389+
"""Get power distribution result for the batteries in the request.
390+
391+
Args:
392+
request: the power request to process.
393+
inv_vat_pairs: the battery and adjacent inverter data pairs.
394+
395+
Returns:
396+
the power distribution result.
397+
"""
398+
if request.force and len(request.batteries) != len(inv_vat_pairs):
399+
# Distribute power equally for each battery in the request if
400+
# there are missing components metrics
401+
power_per_battery = request.power / len(request.batteries)
402+
return DistributionResult(
403+
distribution={
404+
self._bat_inv_map[battery_id]: power_per_battery
405+
for battery_id in request.batteries
406+
},
407+
remaining_power=0.0,
408+
)
409+
410+
return self.distribution_algorithm.distribute_power(
411+
request.power, inv_vat_pairs
412+
)
413+
382414
def _check_request(self, request: Request) -> Optional[Result]:
383415
"""Check whether the given request if correct.
384416
@@ -401,11 +433,11 @@ def _check_request(self, request: Request) -> Optional[Result]:
401433

402434
if not request.adjust_power:
403435
if request.power < 0:
404-
bound = self._get_lower_bound(request.batteries)
436+
bound = self._get_lower_bound(request.batteries, request.force)
405437
if request.power < bound:
406438
return OutOfBound(request=request, bound=bound)
407439
else:
408-
bound = self._get_upper_bound(request.batteries)
440+
bound = self._get_upper_bound(request.batteries, request.force)
409441
if request.power > bound:
410442
return OutOfBound(request=request, bound=bound)
411443

@@ -554,11 +586,14 @@ def _get_components_pairs(
554586

555587
return bat_inv_map, inv_bat_map
556588

557-
def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
589+
def _get_components_data(
590+
self, batteries: Set[int], use_all: bool
591+
) -> List[InvBatPair]:
558592
"""Get data for the given batteries and adjacent inverters.
559593
560594
Args:
561595
batteries: Batteries that needs data.
596+
use_all: whether all batteries in the power request must be used.
562597
563598
Raises:
564599
KeyError: If any battery in the given list doesn't exists in microgrid.
@@ -568,7 +603,9 @@ def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
568603
"""
569604
pairs_data: List[InvBatPair] = []
570605
working_batteries = (
571-
self._all_battery_status.get_working_batteries(batteries) or batteries
606+
batteries
607+
if use_all
608+
else self._all_battery_status.get_working_batteries(batteries) or batteries
572609
)
573610

574611
for battery_id in working_batteries:
@@ -581,6 +618,8 @@ def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
581618
inverter_id: int = self._bat_inv_map[battery_id]
582619

583620
data = self._get_battery_inverter_data(battery_id, inverter_id)
621+
if data is None and use_all is True:
622+
data = self._cached_metrics[battery_id]
584623
if data is None:
585624
_logger.warning(
586625
"Skipping battery %d because its message isn't correct.",
@@ -648,7 +687,8 @@ def _get_battery_inverter_data(
648687

649688
# If all values are ok then return them.
650689
if not any(map(isnan, replaceable_metrics)):
651-
return InvBatPair(battery_data, inverter_data)
690+
self._cached_metrics[battery_id] = InvBatPair(battery_data, inverter_data)
691+
return self._cached_metrics[battery_id]
652692

653693
# Replace NaN with the corresponding value in the adjacent component.
654694
# If both metrics are None, return None to ignore this battery.
@@ -670,10 +710,11 @@ def _get_battery_inverter_data(
670710
elif isnan(inv_bound):
671711
inverter_new_metrics[inv_attr] = bat_bound
672712

673-
return InvBatPair(
713+
self._cached_metrics[battery_id] = InvBatPair(
674714
replace(battery_data, **battery_new_metrics),
675715
replace(inverter_data, **inverter_new_metrics),
676716
)
717+
return self._cached_metrics[battery_id]
677718

678719
async def _create_channels(self) -> None:
679720
"""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."""

0 commit comments

Comments
 (0)