Skip to content

Commit ea742d7

Browse files
authored
Refactor power distributor's Results (#305)
We make `Result` and its subclasses dataclasses so they have a proper `__str__()` method that shows the details. We also make some property renaming to make it more consistent and improve documentation. Fixes #275.
2 parents af8facb + 0447fff commit ea742d7

File tree

5 files changed

+123
-132
lines changed

5 files changed

+123
-132
lines changed

RELEASE_NOTES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
* Update BatteryStatus to mark battery with unknown capacity as not working (#263)
1010
* The channels dependency was updated to v0.14.0 (#292)
11+
* Some properties for `PowerDistributingActor` results were renamed to be more consistent between `Success` and `PartialFailure`:
12+
* The `Success.used_batteries` property was renamed to `succeeded_batteries`.
13+
* The `PartialFailure.success_batteries` property was renamed to `succeeded_batteries`.
14+
* The `succeed_power` property was renamed to `succeeded_power` for both `Success` and `PartialFailure`.
1115

1216
## New Features
1317

@@ -22,6 +26,7 @@
2226
)
2327
grid_power = microgrid.logical_meter().grid_power()
2428
```
29+
* The `Result` class (and subclasses) for the `PowerDistributingActor` are now dataclasses, so logging them will produce a more detailed output.
2530

2631
## Bug Fixes
2732

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,12 @@ async def run(self) -> None:
271271
request.batteries
272272
)
273273
except KeyError as err:
274-
await user.channel.send(Error(request, str(err)))
274+
await user.channel.send(Error(request=request, msg=str(err)))
275275
continue
276276

277277
if len(pairs_data) == 0:
278278
error_msg = f"No data for the given batteries {str(request.batteries)}"
279-
await user.channel.send(Error(request, str(error_msg)))
279+
await user.channel.send(Error(request=request, msg=str(error_msg)))
280280
continue
281281

282282
try:
@@ -285,7 +285,7 @@ async def run(self) -> None:
285285
)
286286
except ValueError as err:
287287
error_msg = f"Couldn't distribute power, error: {str(err)}"
288-
await user.channel.send(Error(request, str(error_msg)))
288+
await user.channel.send(Error(request=request, msg=str(error_msg)))
289289
continue
290290

291291
distributed_power_value = request.power - distribution.remaining_power
@@ -309,8 +309,8 @@ async def run(self) -> None:
309309
succeed_batteries = set(battery_distribution.keys()) - failed_batteries
310310
response = PartialFailure(
311311
request=request,
312-
succeed_power=distributed_power_value,
313-
succeed_batteries=succeed_batteries,
312+
succeeded_power=distributed_power_value,
313+
succeeded_batteries=succeed_batteries,
314314
failed_power=failed_power,
315315
failed_batteries=failed_batteries,
316316
excess_power=distribution.remaining_power,
@@ -319,8 +319,8 @@ async def run(self) -> None:
319319
succeed_batteries = set(battery_distribution.keys())
320320
response = Success(
321321
request=request,
322-
succeed_power=distributed_power_value,
323-
used_batteries=succeed_batteries,
322+
succeeded_power=distributed_power_value,
323+
succeeded_batteries=succeed_batteries,
324324
excess_power=distribution.remaining_power,
325325
)
326326

@@ -380,17 +380,17 @@ def _check_request(self, request: Request) -> Optional[Result]:
380380
f"No battery {battery}, available batteries: "
381381
f"{list(self._battery_receivers.keys())}"
382382
)
383-
return Error(request, msg)
383+
return Error(request=request, msg=msg)
384384

385385
if not request.adjust_power:
386386
if request.power < 0:
387387
bound = self._get_lower_bound(request.batteries)
388388
if request.power < bound:
389-
return OutOfBound(request, bound)
389+
return OutOfBound(request=request, bound=bound)
390390
else:
391391
bound = self._get_upper_bound(request.batteries)
392392
if request.power > bound:
393-
return OutOfBound(request, bound)
393+
return OutOfBound(request=request, bound=bound)
394394

395395
return None
396396

@@ -420,7 +420,7 @@ def _remove_duplicated_requests(
420420
# Generators seems to be the fastest
421421
if prev_request.batteries == batteries:
422422
task = asyncio.create_task(
423-
prev_user.channel.send(Ignored(prev_request))
423+
prev_user.channel.send(Ignored(request=prev_request))
424424
)
425425
to_ignore.append(task)
426426
# Use generators as generators seems to be the fastest.
@@ -490,7 +490,7 @@ async def _wait_for_request(self, user: _User) -> None:
490490
"Consider increasing size of the queue."
491491
)
492492
_logger.error(msg)
493-
await user.channel.send(Error(request, str(msg)))
493+
await user.channel.send(Error(request=request, msg=str(msg)))
494494
else:
495495
self._request_queue.put_nowait((request, user))
496496
await asyncio.gather(*tasks)

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@
22
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
33
"""Definition of the user request."""
44

5-
from dataclasses import dataclass
6-
from typing import Set
5+
from __future__ import annotations
76

7+
import dataclasses
88

9-
@dataclass
9+
10+
@dataclasses.dataclass
1011
class Request:
11-
"""Request from the user."""
12+
"""Request to set power to the `PowerDistributingActor`."""
1213

13-
# How much power to set
1414
power: int
15-
# In which batteries the power should be set
16-
batteries: Set[int]
17-
# Timeout for the server to respond on the requests.
15+
"""The requested power in watts."""
16+
17+
batteries: set[int]
18+
"""The component ids of the batteries to be used for this request."""
19+
1820
request_timeout_sec: float = 5.0
19-
# If True and requested power value is above upper bound, then the power will be
20-
# decreased to match the bounds. Only the decreased power will be set.
21-
# If False and the requested power is above upper bound, then request won't
22-
# be processed. result.OutOfBound message will be send back to the user.
21+
"""The maximum amount of time to wait for the request to be fulfilled."""
22+
2323
adjust_power: bool = True
24+
"""Whether to adjust the power to match the bounds.
25+
26+
If `True`, the power will be adjusted (lowered) to match the bounds, so
27+
only the reduced power will be set.
28+
29+
If `False` and the power is outside the batteries' bounds, the request will
30+
fail and be replied to with an `OutOfBound` result.
31+
"""

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

Lines changed: 77 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,122 +3,100 @@
33

44
"""Results from PowerDistributingActor."""
55

6-
from abc import ABC
7-
from typing import Set
6+
from __future__ import annotations
7+
8+
import dataclasses
89

910
from .request import Request
1011

1112

12-
class Result(ABC):
13-
"""Base class for the power distributor result."""
14-
15-
def __init__(self, request: Request) -> None:
16-
"""Create class instance.
17-
18-
Args:
19-
request: The user's request to which this message responds.
20-
"""
21-
self.request: Request = request
22-
23-
24-
class Success(Result):
25-
"""Send if setting power for all batteries succeed."""
26-
27-
def __init__(
28-
self,
29-
request: Request,
30-
succeed_power: int,
31-
used_batteries: Set[int],
32-
excess_power: int,
33-
) -> None:
34-
"""Create class instance.
35-
36-
Args:
37-
request: The user's request to which this message responds.
38-
succeed_power: Part of the requested power that was successfully set.
39-
used_batteries: Subset of the requested batteries, that were used to
40-
realize the request.
41-
excess_power: Part of the requested power that could not be fulfilled,
42-
because it was outside available power bounds.
43-
"""
44-
super().__init__(request)
45-
self.succeed_power: int = succeed_power
46-
self.used_batteries: Set[int] = used_batteries
47-
self.excess_power: int = excess_power
48-
49-
50-
class PartialFailure(Result):
51-
"""Send if any battery failed and didn't perform the request."""
52-
53-
# It is very simple class with only data so it should be ok to disable pylint.
54-
# All these results should be dataclass but in python < 3.10 it is risky
55-
# to derive after dataclass.
56-
def __init__( # pylint: disable=too-many-arguments
57-
self,
58-
request: Request,
59-
succeed_power: int,
60-
succeed_batteries: Set[int],
61-
failed_power: int,
62-
failed_batteries: Set[int],
63-
excess_power: int,
64-
) -> None:
65-
"""Create class instance.
66-
67-
Args:
68-
request: The user's request to which this message responds.
69-
succeed_power: Part of the requested power that was successfully set.
70-
succeed_batteries: Subset of the requested batteries for which the request
71-
succeed.
72-
failed_power: Part of the requested power that failed.
73-
failed_batteries: Subset of the requested batteries for which the request
74-
failed.
75-
excess_power: Part of the requested power that could not be fulfilled,
76-
because it was outside available power bounds.
77-
"""
78-
super().__init__(request)
79-
self.succeed_power: int = succeed_power
80-
self.succeed_batteries: Set[int] = succeed_batteries
81-
self.failed_power: int = failed_power
82-
self.failed_batteries: Set[int] = failed_batteries
83-
self.excess_power: int = excess_power
13+
@dataclasses.dataclass
14+
class _BaseResultMixin:
15+
"""Base mixin class for reporting power distribution results."""
8416

17+
request: Request
18+
"""The user's request to which this message responds."""
19+
20+
21+
# When moving to Python 3.10+ we should replace this with an union type:
22+
# Result = Success | PartialFailure | Error | OutOfBound | Ignored
23+
# For now it can't be done because before 3.10 isinstance(result, Success)
24+
# doesn't work, so it is hard to figure out what type of result you got in
25+
# a forward compatible way.
26+
# When moving we should use the _BaseResultMixin as a base class for all
27+
# results.
28+
@dataclasses.dataclass
29+
class Result(_BaseResultMixin):
30+
"""Power distribution result."""
31+
32+
33+
@dataclasses.dataclass
34+
class _BaseSuccessMixin:
35+
"""Result returned when setting the power succeed for all batteries."""
36+
37+
succeeded_power: int
38+
"""The part of the requested power that was successfully set."""
39+
40+
succeeded_batteries: set[int]
41+
"""The subset of batteries for which power was set successfully."""
42+
43+
excess_power: int
44+
"""The part of the requested power that could not be fulfilled.
45+
46+
This happens when the requested power is outside the available power bounds.
47+
"""
8548

86-
class Error(Result):
87-
"""Error occurred and power was not set."""
8849

89-
def __init__(self, request: Request, msg: str) -> None:
90-
"""Create class instance.
50+
# We need to put the _BaseSuccessMixin before Result in the inheritance list to
51+
# make sure that the Result attributes appear before the _BaseSuccessMixin,
52+
# otherwise the request attribute will be last in the dataclass constructor
53+
# because of how MRO works.
9154

92-
Args:
93-
request: The user's request to which this message responds.
94-
msg: Error message explaining why error happened.
95-
"""
96-
super().__init__(request)
97-
self.msg: str = msg
9855

56+
@dataclasses.dataclass
57+
class Success(_BaseSuccessMixin, Result): # Order matters here. See above.
58+
"""Result returned when setting the power succeeded for all batteries."""
9959

60+
61+
@dataclasses.dataclass
62+
class PartialFailure(_BaseSuccessMixin, Result):
63+
"""Result returned when any battery failed to perform the request."""
64+
65+
failed_power: int
66+
"""The part of the requested power that failed to be set."""
67+
68+
failed_batteries: set[int]
69+
"""The subset of batteries for which the request failed."""
70+
71+
72+
@dataclasses.dataclass
73+
class Error(Result):
74+
"""Result returned when an error occurred and power was not set at all."""
75+
76+
msg: str
77+
"""The error message explaining why error happened."""
78+
79+
80+
@dataclasses.dataclass
10081
class OutOfBound(Result):
101-
"""Send if power was not set because requested power was not within bounds.
82+
"""Result returned when the power was not set because it was out of bounds.
10283
103-
This message is send only if Request.adjust_power = False.
84+
This result happens when the originating request was done with
85+
`adjust_power = False` and the requested power is not within the batteries bounds.
10486
"""
10587

106-
def __init__(self, request: Request, bound: int) -> None:
107-
"""Create class instance.
88+
bound: int
89+
"""The total power bound for the requested batteries.
10890
109-
Args:
110-
request: The user's request to which this message responds.
111-
bound: Total power bound for the requested batteries.
112-
If requested power < 0, then this value is lower bound.
113-
Otherwise it is upper bound.
114-
"""
115-
super().__init__(request)
116-
self.bound: int = bound
91+
If the requested power negative, then this value is the lower bound.
92+
Otherwise it is upper bound.
93+
"""
11794

11895

96+
@dataclasses.dataclass
11997
class Ignored(Result):
120-
"""Send if request was ignored.
98+
"""Result returned when the request was ignored.
12199
122-
Request was ignored because new request for the same subset of batteries
123-
was received.
100+
The request can be ignored when a new request for the same subset of
101+
batteries was received.
124102
"""

0 commit comments

Comments
 (0)