Skip to content

Commit 48a9d00

Browse files
Change PowerDistributor to ignore components with NaN metrics
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. Signed-off-by: ela-kotulska-frequenz <[email protected]>
1 parent a83b53b commit 48a9d00

File tree

3 files changed

+277
-16
lines changed

3 files changed

+277
-16
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: 92 additions & 16 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,
@@ -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)