Skip to content

Commit 3ccecd8

Browse files
Change PowerDistributor to ignore components with NaN metrics (#247)
Each float metric returned from api can be float("NaN"). If that happen PowerDistributor would fail because it is impossible to make math calcuation on NaN value. This PR is just a quick fix of this problem. * if power bounds are NaN, then it tries to replace them with corresponding power bounds from adjacent component. If these components are also NaN, then it ignores battery. * if other metrics metrics are NaN then it ignores battery. More sophisticated solution should be done in the future, maybe use resampler.
2 parents a83b53b + af12a29 commit 3ccecd8

File tree

3 files changed

+280
-19
lines changed

3 files changed

+280
-19
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
## Upgrading
88

99
* Remove `_soc` formula from the LogicalMeter. This feature has been moved to the BatteryPool.
10+
* Upgrade PowerDistributingActor to handle components with the NaN metrics (#247):
11+
* if power bounds are NaN, then it tries to replace them with corresponding power bounds from adjacent component. If these components are also NaN, then it ignores battery.
12+
* if other metrics metrics are NaN then it ignores battery.
1013

1114
## New Features
1215

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

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
import asyncio
1717
import logging
1818
from asyncio.tasks import ALL_COMPLETED
19-
from dataclasses import dataclass
20-
from math import ceil, floor
19+
from dataclasses import dataclass, replace
20+
from math import ceil, floor, isnan
2121
from typing import ( # pylint: disable=unused-import
2222
Any,
2323
Dict,
@@ -266,7 +266,7 @@ async def run(self) -> None:
266266

267267
try:
268268
pairs_data: List[InvBatPair] = self._get_components_data(
269-
self._all_battery_status.get_working_batteries(request.batteries)
269+
request.batteries
270270
)
271271
except KeyError as err:
272272
await user.channel.send(Error(request, str(err)))
@@ -548,8 +548,8 @@ def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
548548
Pairs of battery and adjacent inverter data.
549549
"""
550550
pairs_data: List[InvBatPair] = []
551-
552-
for battery_id in batteries:
551+
working_batteries = self._all_battery_status.get_working_batteries(batteries)
552+
for battery_id in working_batteries:
553553
if battery_id not in self._battery_receivers:
554554
raise KeyError(
555555
f"No battery {battery_id}, "
@@ -558,24 +558,100 @@ def _get_components_data(self, batteries: Set[int]) -> List[InvBatPair]:
558558

559559
inverter_id: int = self._bat_inv_map[battery_id]
560560

561-
battery_data: Optional[BatteryData] = self._battery_receivers[
562-
battery_id
563-
].peek()
564-
565-
if battery_data is None:
566-
_logger.warning("None returned from battery receiver %d.", battery_id)
561+
data = self._get_battery_inverter_data(battery_id, inverter_id)
562+
if data is None:
563+
_logger.warning(
564+
"Skipping battery %d because its message isn't correct.",
565+
battery_id,
566+
)
567567
continue
568568

569-
inverter_data: Optional[InverterData] = self._inverter_receivers[
570-
inverter_id
571-
].peek()
569+
pairs_data.append(data)
570+
return pairs_data
572571

573-
if inverter_data is None:
574-
_logger.warning("None returned from inverter receiver %d.", inverter_id)
575-
continue
572+
def _get_battery_inverter_data(
573+
self, battery_id: int, inverter_id: int
574+
) -> Optional[InvBatPair]:
575+
"""Get battery and inverter data if they are correct.
576576
577-
pairs_data.append(InvBatPair(battery_data, inverter_data))
578-
return pairs_data
577+
Each float data from the microgrid can be "NaN".
578+
We can't do math operations on "NaN".
579+
So check all the metrics and:
580+
* if power bounds are NaN, then try to replace it with the corresponding
581+
power bounds from the adjacent component. If metric in the adjacent component
582+
is also NaN, then return None.
583+
* if other metrics are NaN then return None. We can't assume anything for other
584+
metrics.
585+
586+
Args:
587+
battery_id: battery id
588+
inverter_id: inverter id
589+
590+
Returns:
591+
Data for the battery and adjacent inverter without NaN values.
592+
Return None if we could not replace NaN values.
593+
"""
594+
battery_data = self._battery_receivers[battery_id].peek()
595+
inverter_data = self._inverter_receivers[inverter_id].peek()
596+
597+
# It means that nothing has been send on this channels, yet.
598+
# This should be handled by BatteryStatus. BatteryStatus should not return
599+
# this batteries as working.
600+
if battery_data is None or inverter_data is None:
601+
_logger.error(
602+
"Battery %d or inverter %d send no data, yet. They should be not used.",
603+
battery_id,
604+
inverter_id,
605+
)
606+
return None
607+
608+
not_replaceable_metrics = [
609+
battery_data.soc,
610+
battery_data.soc_lower_bound,
611+
battery_data.soc_upper_bound,
612+
# We could replace capacity with 0, but it won't change distribution.
613+
# This battery will be ignored in distribution anyway.
614+
battery_data.capacity,
615+
]
616+
if any(map(isnan, not_replaceable_metrics)):
617+
_logger.debug("Some metrics for battery %d are NaN", battery_id)
618+
return None
619+
620+
replaceable_metrics = [
621+
battery_data.power_lower_bound,
622+
battery_data.power_upper_bound,
623+
inverter_data.active_power_lower_bound,
624+
inverter_data.active_power_upper_bound,
625+
]
626+
627+
# If all values are ok then return them.
628+
if not any(map(isnan, replaceable_metrics)):
629+
return InvBatPair(battery_data, inverter_data)
630+
631+
# Replace NaN with the corresponding value in the adjacent component.
632+
# If both metrics are None, return None to ignore this battery.
633+
replaceable_pairs = [
634+
("power_lower_bound", "active_power_lower_bound"),
635+
("power_upper_bound", "active_power_upper_bound"),
636+
]
637+
638+
battery_new_metrics = {}
639+
inverter_new_metrics = {}
640+
for bat_attr, inv_attr in replaceable_pairs:
641+
bat_bound = getattr(battery_data, bat_attr)
642+
inv_bound = getattr(inverter_data, inv_attr)
643+
if isnan(bat_bound) and isnan(inv_bound):
644+
_logger.debug("Some metrics for battery %d are NaN", battery_id)
645+
return None
646+
if isnan(bat_bound):
647+
battery_new_metrics[bat_attr] = inv_bound
648+
elif isnan(inv_bound):
649+
inverter_new_metrics[inv_attr] = bat_bound
650+
651+
return InvBatPair(
652+
replace(battery_data, **battery_new_metrics),
653+
replace(inverter_data, **inverter_new_metrics),
654+
)
579655

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

tests/actor/test_power_distributing.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,188 @@ async def test_power_distributor_one_user(self, mocker: MockerFixture) -> None:
183183
assert result.excess_power == 200
184184
assert result.request == request
185185

186+
async def test_battery_soc_nan(self, mocker: MockerFixture) -> None:
187+
# pylint: disable=too-many-locals
188+
"""Test if battery with SoC==NaN is not used."""
189+
mock_microgrid = await self.init_mock_microgrid(mocker)
190+
191+
await mock_microgrid.send(
192+
battery_msg(
193+
106,
194+
soc=Metric(float("NaN"), Bound(20, 80)),
195+
capacity=Metric(98000),
196+
power=Bound(-1000, 1000),
197+
)
198+
)
199+
200+
channel = Bidirectional[Request, Result]("user1", "power_distributor")
201+
202+
request = Request(
203+
power=1200,
204+
batteries={106, 206},
205+
request_timeout_sec=SAFETY_TIMEOUT,
206+
)
207+
208+
attrs = {"get_working_batteries.return_value": request.batteries}
209+
mocker.patch(
210+
"frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus",
211+
return_value=MagicMock(spec=BatteryPoolStatus, **attrs),
212+
)
213+
214+
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
215+
battery_status_channel = Broadcast[BatteryStatus]("battery_status")
216+
distributor = PowerDistributingActor(
217+
{"user1": channel.service_handle},
218+
battery_status_sender=battery_status_channel.new_sender(),
219+
)
220+
221+
attrs = {"get_working_batteries.return_value": request.batteries}
222+
mocker.patch(
223+
"frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus",
224+
return_value=MagicMock(spec=BatteryPoolStatus, **attrs),
225+
)
226+
227+
client_handle = channel.client_handle
228+
await client_handle.send(request)
229+
230+
done, pending = await asyncio.wait(
231+
[asyncio.create_task(client_handle.receive())],
232+
timeout=SAFETY_TIMEOUT,
233+
)
234+
await distributor._stop_actor()
235+
236+
assert len(pending) == 0
237+
assert len(done) == 1
238+
239+
result: Result = done.pop().result()
240+
assert isinstance(result, Success)
241+
assert result.used_batteries == {206}
242+
assert result.succeed_power == 500
243+
assert result.excess_power == 700
244+
assert result.request == request
245+
246+
async def test_battery_capacity_nan(self, mocker: MockerFixture) -> None:
247+
# pylint: disable=too-many-locals
248+
"""Test if power distribution works with single user works."""
249+
mock_microgrid = await self.init_mock_microgrid(mocker)
250+
251+
await mock_microgrid.send(
252+
battery_msg(
253+
106,
254+
soc=Metric(40, Bound(20, 80)),
255+
capacity=Metric(float("NaN")),
256+
power=Bound(-1000, 1000),
257+
)
258+
)
259+
260+
channel = Bidirectional[Request, Result]("user1", "power_distributor")
261+
262+
request = Request(
263+
power=1200,
264+
batteries={106, 206},
265+
request_timeout_sec=SAFETY_TIMEOUT,
266+
)
267+
attrs = {"get_working_batteries.return_value": request.batteries}
268+
mocker.patch(
269+
"frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus",
270+
return_value=MagicMock(spec=BatteryPoolStatus, **attrs),
271+
)
272+
273+
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
274+
battery_status_channel = Broadcast[BatteryStatus]("battery_status")
275+
distributor = PowerDistributingActor(
276+
{"user1": channel.service_handle},
277+
battery_status_sender=battery_status_channel.new_sender(),
278+
)
279+
280+
client_handle = channel.client_handle
281+
await client_handle.send(request)
282+
283+
done, pending = await asyncio.wait(
284+
[asyncio.create_task(client_handle.receive())],
285+
timeout=SAFETY_TIMEOUT,
286+
)
287+
await distributor._stop_actor()
288+
289+
assert len(pending) == 0
290+
assert len(done) == 1
291+
292+
result: Result = done.pop().result()
293+
assert isinstance(result, Success)
294+
assert result.used_batteries == {206}
295+
assert result.succeed_power == 500
296+
assert result.excess_power == 700
297+
assert result.request == request
298+
299+
async def test_battery_power_bounds_nan(self, mocker: MockerFixture) -> None:
300+
# pylint: disable=too-many-locals
301+
"""Check if missign"""
302+
mock_microgrid = await self.init_mock_microgrid(mocker)
303+
304+
# Battery 206 should work even if his inverter sends NaN
305+
await mock_microgrid.send(
306+
inverter_msg(
307+
205,
308+
power=Bound(float("NaN"), float("NaN")),
309+
)
310+
)
311+
312+
# Battery 106 should not work because both battery and inverter sends NaN
313+
await mock_microgrid.send(
314+
inverter_msg(
315+
105,
316+
power=Bound(-1000, float("NaN")),
317+
)
318+
)
319+
320+
await mock_microgrid.send(
321+
battery_msg(
322+
106,
323+
soc=Metric(40, Bound(20, 80)),
324+
capacity=Metric(float(98000)),
325+
power=Bound(float("NaN"), float("NaN")),
326+
)
327+
)
328+
329+
channel = Bidirectional[Request, Result]("user1", "power_distributor")
330+
331+
request = Request(
332+
power=1200,
333+
batteries={106, 206},
334+
request_timeout_sec=SAFETY_TIMEOUT,
335+
)
336+
attrs = {"get_working_batteries.return_value": request.batteries}
337+
mocker.patch(
338+
"frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus",
339+
return_value=MagicMock(spec=BatteryPoolStatus, **attrs),
340+
)
341+
342+
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
343+
battery_status_channel = Broadcast[BatteryStatus]("battery_status")
344+
distributor = PowerDistributingActor(
345+
{"user1": channel.service_handle},
346+
battery_status_sender=battery_status_channel.new_sender(),
347+
)
348+
349+
client_handle = channel.client_handle
350+
await client_handle.send(request)
351+
352+
done, pending = await asyncio.wait(
353+
[asyncio.create_task(client_handle.receive())],
354+
timeout=SAFETY_TIMEOUT,
355+
)
356+
await distributor._stop_actor()
357+
358+
assert len(pending) == 0
359+
assert len(done) == 1
360+
361+
result: Result = done.pop().result()
362+
assert isinstance(result, Success)
363+
assert result.used_batteries == {206}
364+
assert result.succeed_power == 1000
365+
assert result.excess_power == 200
366+
assert result.request == request
367+
186368
async def test_power_distributor_two_users(self, mocker: MockerFixture) -> None:
187369
# pylint: disable=too-many-locals
188370
"""Test if power distribution works with two users."""

0 commit comments

Comments
 (0)