Skip to content

Commit c308c0d

Browse files
Define a way for constructing objects with async constructor
Add parent class that raises error if __init__ was called. Use this for implementing BatteryPoolStatus object. Signed-off-by: ela-kotulska-frequenz <[email protected]>
1 parent 1401ff2 commit c308c0d

File tree

5 files changed

+112
-76
lines changed

5 files changed

+112
-76
lines changed

src/frequenz/sdk/_internal/asyncio.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""General purpose async tools."""
55

66
import asyncio
7+
from abc import ABC
78

89

910
async def cancel_and_await(task: asyncio.Task) -> None:
@@ -19,3 +20,22 @@ async def cancel_and_await(task: asyncio.Task) -> None:
1920
await task
2021
except asyncio.CancelledError:
2122
pass
23+
24+
25+
class NotSyncConstructible(AssertionError):
26+
"""Raised when object with async constructor is created in sync way."""
27+
28+
29+
class AsyncConstructible(ABC):
30+
"""Parent class for classes where part of the constructor is async."""
31+
32+
def __init__(self) -> None:
33+
"""Raise error when object is created in sync way.
34+
35+
Raises:
36+
NotSyncConstructible: If this method is called.
37+
"""
38+
raise NotSyncConstructible(
39+
"This object shouldn't be created with default constructor. ",
40+
"Check class documentation for more information.",
41+
)

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

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,39 @@
22
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
33
"""Class that stores pool of batteries and manage them."""
44

5+
from __future__ import annotations
6+
57
import asyncio
68
import logging
7-
from typing import Set
9+
from typing import Dict, Set
810

11+
from ..._internal.asyncio import AsyncConstructible
912
from ...microgrid._battery import BatteryStatus, StatusTracker
1013
from .result import PartialFailure, Result, Success
1114

1215
_logger = logging.getLogger(__name__)
1316

1417

15-
class BatteryPoolStatus:
16-
"""Holds pool of batteries and returns data from them."""
18+
class BatteryPoolStatus(AsyncConstructible):
19+
"""Return status of batteries in the pool.
20+
21+
To create an instance of this class you should use `async_new` class method.
22+
Standard constructor (__init__) is not supported and using it will raise
23+
`NotSyncConstructible` error.
24+
"""
1725

18-
def __init__(
19-
self,
26+
# This is instance attribute.
27+
# Don't assign default value, because then it becomes class attribute.
28+
_batteries: Dict[int, StatusTracker]
29+
30+
@classmethod
31+
async def async_new(
32+
cls,
2033
battery_ids: Set[int],
2134
max_data_age_sec: float,
2235
max_blocking_duration_sec: float,
23-
) -> None:
24-
"""Create partially initialized object instance.
25-
26-
Note:
27-
Please call `async_init` method to fully initialize BatteryPoolStatus. Otherwise
28-
it is not possible to use BatteryPoolStatus.
36+
) -> BatteryPoolStatus:
37+
"""Create BatteryPoolStatus instance.
2938
3039
Args:
3140
battery_ids: set of batteries ids that should be stored in pool.
@@ -35,20 +44,21 @@ def __init__(
3544
data.
3645
max_blocking_duration_sec: This value tell what should be the maximum
3746
timeout used for blocking failing component.
47+
48+
Returns:
49+
New instance of this class.
3850
"""
51+
self: BatteryPoolStatus = BatteryPoolStatus.__new__(cls)
3952
self._batteries = {
4053
id: StatusTracker(id, max_data_age_sec, max_blocking_duration_sec)
4154
for id in battery_ids
4255
}
43-
self._init_method_called: bool = False
4456

45-
async def async_init(self) -> None:
46-
"""Init battery pool."""
4757
await asyncio.gather(
4858
*[bat.async_init() for bat in self._batteries.values()],
4959
return_exceptions=True,
5060
)
51-
self._init_method_called = True
61+
return self
5262

5363
def get_working_batteries(self, battery_ids: Set[int]) -> Set[int]:
5464
"""Get subset of battery_ids with working batteries.
@@ -64,11 +74,6 @@ def get_working_batteries(self, battery_ids: Set[int]) -> Set[int]:
6474
Returns:
6575
Subset of given batteries with working batteries.
6676
"""
67-
if not self._init_method_called:
68-
raise RuntimeError(
69-
"`async_init` method not called or not awaited. Run it before first use"
70-
)
71-
7277
working: Set[int] = set()
7378
uncertain: Set[int] = set()
7479
for bat_id in battery_ids:
@@ -101,11 +106,6 @@ def update_last_request_status(self, result: Result):
101106
RuntimeError: If `async_init` method was not called at the beginning to
102107
initialize object.
103108
"""
104-
if not self._init_method_called:
105-
raise RuntimeError(
106-
"`async_init` method not called or not awaited. Run it before first use"
107-
)
108-
109109
if isinstance(result, Success):
110110
for bat_id in result.used_batteries:
111111
self._batteries[bat_id].unblock()

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,11 @@ def __init__(
161161
self.power_distributor_exponent
162162
)
163163

164-
graph = microgrid.get().component_graph
165-
batteries = graph.components(component_category={ComponentCategory.BATTERY})
164+
self._battery_pool: BatteryPoolStatus
166165

167-
self._battery_pool = BatteryPoolStatus(
168-
battery_ids={battery.component_id for battery in batteries},
169-
max_blocking_duration_sec=30.0,
170-
max_data_age_sec=10.0,
166+
self._bat_inv_map, self._inv_bat_map = self._get_components_pairs(
167+
microgrid.get().component_graph
171168
)
172-
173-
self._bat_inv_map, self._inv_bat_map = self._get_components_pairs(graph)
174169
self._battery_receivers: Dict[int, Peekable[BatteryData]] = {}
175170
self._inverter_receivers: Dict[int, Peekable[InverterData]] = {}
176171

@@ -251,11 +246,17 @@ async def run(self) -> None:
251246
as broken for some time.
252247
"""
253248
await self._create_channels()
254-
await self._battery_pool.async_init()
249+
255250
api = microgrid.get().api_client
251+
self._battery_pool = await BatteryPoolStatus.async_new(
252+
battery_ids=set(self._bat_inv_map.keys()),
253+
max_blocking_duration_sec=30.0,
254+
max_data_age_sec=10.0,
255+
)
256256

257257
# Wait few seconds to get data from the channels created above.
258258
await asyncio.sleep(self._wait_for_data_sec)
259+
259260
self._started.set()
260261
while True:
261262
request, user = await self._request_queue.get()

tests/actor/test_battery_pool.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Callable, Iterable, List, Optional, Set
88

99
import frequenz.api.microgrid.microgrid_pb2 as microgrid_pb
10+
import pytest
1011
import pytz
1112
import time_machine
1213
from frequenz.api.microgrid.battery_pb2 import Battery as PbBattery
@@ -25,6 +26,7 @@
2526
from pytest_mock import MockerFixture
2627

2728
from frequenz.sdk import microgrid
29+
from frequenz.sdk._internal.asyncio import NotSyncConstructible
2830
from frequenz.sdk.actor.power_distributing import PartialFailure, Request, Success
2931
from frequenz.sdk.actor.power_distributing._battery_pool_status import BatteryPoolStatus
3032
from frequenz.sdk.microgrid.component import BatteryData, InverterData
@@ -453,6 +455,11 @@ def set_battery_error(
453455
),
454456
)
455457

458+
def test_create_sync_pool(self) -> None:
459+
"""Test if error is raised after calling default constructor."""
460+
with pytest.raises(NotSyncConstructible):
461+
BatteryPoolStatus()
462+
456463
async def test_scenario_one_battery(self, mocker: MockerFixture) -> None:
457464
"""Test scenario with one battery.
458465
@@ -462,10 +469,9 @@ async def test_scenario_one_battery(self, mocker: MockerFixture) -> None:
462469
pairs = await self.my_setup(mocker=mocker, batteries_num=1)
463470

464471
batteries = {pair.bat_id for pair in pairs}
465-
battery_pool = BatteryPoolStatus(
472+
battery_pool = await BatteryPoolStatus.async_new(
466473
batteries, max_data_age_sec=30, max_blocking_duration_sec=30
467474
)
468-
await battery_pool.async_init()
469475

470476
self.scenario_all_msg_correct(battery_pool, pairs)
471477
self.scenario_no_message(battery_pool, pairs)
@@ -484,10 +490,9 @@ async def test_scenario_five_batteries(self, mocker: MockerFixture) -> None:
484490
"""
485491
pairs = await self.my_setup(mocker=mocker, batteries_num=5)
486492
batteries = {pair.bat_id for pair in pairs}
487-
battery_pool = BatteryPoolStatus(
493+
battery_pool = await BatteryPoolStatus.async_new(
488494
batteries, max_data_age_sec=30, max_blocking_duration_sec=30
489495
)
490-
await battery_pool.async_init()
491496

492497
# Starting server takes long time, so it is better to start it once and
493498
# then run tests.

tests/actor/test_power_distributing.py

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,15 @@ async def test_power_distributor_one_user(self, mocker: MockerFixture) -> None:
138138
request_timeout_sec=SAFETY_TIMEOUT,
139139
)
140140

141+
attrs = {"get_working_batteries.return_value": request.batteries}
142+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
143+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
144+
return_value=battery_pool_mock
145+
)
146+
141147
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
142148
distributor = PowerDistributingActor({"user1": channel.service_handle})
143149

144-
# Mock that all requested batteries are working.
145-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
146-
distributor._battery_pool.get_working_batteries.return_value = request.batteries
147-
148150
client_handle = channel.client_handle
149151
await client_handle.send(request)
150152

@@ -175,14 +177,16 @@ async def test_power_distributor_two_users(self, mocker: MockerFixture) -> None:
175177
"user2": channel2.service_handle,
176178
}
177179

180+
attrs = {"get_working_batteries.return_value": {106, 206}}
181+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
182+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
183+
return_value=battery_pool_mock
184+
)
185+
178186
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
179187

180188
distributor = PowerDistributingActor(service_channels)
181189

182-
# Mock that all requested batteries are working.
183-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
184-
distributor._battery_pool.get_working_batteries.return_value = {106, 206}
185-
186190
user1_handle = channel1.client_handle
187191
task1 = user1_handle.send(
188192
Request(
@@ -230,14 +234,15 @@ async def test_power_distributor_invalid_battery_id(
230234
power=1200, batteries={106, 208}, request_timeout_sec=SAFETY_TIMEOUT
231235
)
232236

237+
attrs = {"get_working_batteries.return_value": request.batteries}
238+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
239+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
240+
return_value=battery_pool_mock
241+
)
233242
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
234243

235244
distributor = PowerDistributingActor(service_channels)
236245

237-
# Mock that all requested batteries are working.
238-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
239-
distributor._battery_pool.get_working_batteries.return_value = request.batteries
240-
241246
user1_handle = channel1.client_handle
242247
await user1_handle.send(request)
243248

@@ -271,17 +276,16 @@ async def test_power_distributor_overlapping_batteries(
271276
}
272277

273278
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
279+
attrs = {
280+
"get_working_batteries.side_effect": [{106, 206}, {106, 306}, {106, 206}]
281+
}
282+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
283+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
284+
return_value=battery_pool_mock
285+
)
274286

275287
distributor = PowerDistributingActor(service_channels)
276288

277-
# Mock that all requested batteries are working.
278-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
279-
distributor._battery_pool.get_working_batteries.side_effect = [
280-
{106, 206},
281-
{106, 306},
282-
{106, 206},
283-
]
284-
285289
user1_handle = channel1.client_handle
286290
task1 = user1_handle.send(
287291
Request(
@@ -349,14 +353,16 @@ async def test_power_distributor_one_user_adjust_power_consume(
349353
adjust_power=False,
350354
)
351355

356+
attrs = {"get_working_batteries.return_value": request.batteries}
357+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
358+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
359+
return_value=battery_pool_mock
360+
)
361+
352362
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
353363

354364
distributor = PowerDistributingActor(service_channels)
355365

356-
# Mock that all requested batteries are working.
357-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
358-
distributor._battery_pool.get_working_batteries.return_value = request.batteries
359-
360366
user1_handle = channel1.client_handle
361367
await user1_handle.send(request)
362368

@@ -394,14 +400,16 @@ async def test_power_distributor_one_user_adjust_power_supply(
394400
adjust_power=False,
395401
)
396402

403+
attrs = {"get_working_batteries.return_value": request.batteries}
404+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
405+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
406+
return_value=battery_pool_mock
407+
)
408+
397409
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
398410

399411
distributor = PowerDistributingActor(service_channels)
400412

401-
# Mock that all requested batteries are working.
402-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
403-
distributor._battery_pool.get_working_batteries.return_value = request.batteries
404-
405413
user1_handle = channel1.client_handle
406414
await user1_handle.send(request)
407415

@@ -439,14 +447,16 @@ async def test_power_distributor_one_user_adjust_power_success(
439447
adjust_power=False,
440448
)
441449

450+
attrs = {"get_working_batteries.return_value": request.batteries}
451+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
452+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
453+
return_value=battery_pool_mock
454+
)
455+
442456
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
443457

444458
distributor = PowerDistributingActor(service_channels)
445459

446-
# Mock that all requested batteries are working.
447-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
448-
distributor._battery_pool.get_working_batteries.return_value = request.batteries
449-
450460
user1_handle = channel1.client_handle
451461
await user1_handle.send(request)
452462

@@ -476,16 +486,16 @@ async def test_not_all_batteries_are_working(self, mocker: MockerFixture) -> Non
476486
power=1200, batteries={106, 206}, request_timeout_sec=SAFETY_TIMEOUT
477487
)
478488

489+
attrs = {"get_working_batteries.return_value": request.batteries - {106}}
490+
battery_pool_mock = MagicMock(spec=BatteryPoolStatus, **attrs)
491+
BatteryPoolStatus.async_new = AsyncMock( # type: ignore
492+
return_value=battery_pool_mock
493+
)
494+
479495
mocker.patch("asyncio.sleep", new_callable=AsyncMock)
480496

481497
distributor = PowerDistributingActor({"user1": channel.service_handle})
482498

483-
# Mock that all requested batteries are working.
484-
distributor._battery_pool = MagicMock(spec=BatteryPoolStatus)
485-
distributor._battery_pool.get_working_batteries.return_value = (
486-
request.batteries - {106}
487-
)
488-
489499
client_handle = channel.client_handle
490500
await client_handle.send(request)
491501

0 commit comments

Comments
 (0)