Skip to content

Commit 00bebaf

Browse files
committed
Implement ListComponents
Implement the `ListComponents` RPC call in the `MicrogridApiClient`. To do this we also need to implement the data structures that will be used to wrap the data returned by the API. These wrappers use a more Pythonic approach than the protobuf messages, and translate most enums that encode types of components to concrete Python classes. This way, we can use the type system to ensure we are using the right type of component in the right place, and enables using match statements to handle different types of components in an exhaustive way. Most wrappers should probably be moved to `api-common` in the future. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 66ed9c1 commit 00bebaf

30 files changed

+2111
-4
lines changed

src/frequenz/client/microgrid/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@
3131
UnknownError,
3232
UnrecognizedGrpcStatus,
3333
)
34-
from ._id import EnterpriseId, MicrogridId
34+
from ._id import ComponentId, EnterpriseId, MicrogridId
35+
from ._lifetime import Lifetime
3536
from ._location import Location
3637
from ._microgrid_info import MicrogridInfo, MicrogridStatus
3738

3839
__all__ = [
3940
"ApiClientError",
4041
"ClientNotConnected",
42+
"ComponentId",
4143
"DataLoss",
4244
"DeliveryArea",
4345
"EnergyMarketCodeType",
@@ -47,6 +49,7 @@
4749
"GrpcError",
4850
"InternalError",
4951
"InvalidArgument",
52+
"Lifetime",
5053
"Location",
5154
"MicrogridApiClient",
5255
"MicrogridId",

src/frequenz/client/microgrid/_client.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,26 @@
33

44
"""Client for requests to the Microgrid API."""
55

6+
from collections.abc import Iterable
67
from dataclasses import replace
7-
from typing import Any
8+
from typing import Any, assert_never
89

9-
from frequenz.api.microgrid.v1 import microgrid_pb2_grpc
10+
from frequenz.api.common.v1.microgrid.components import components_pb2
11+
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
1012
from frequenz.client.base import channel, client, retry, streaming
1113
from google.protobuf.empty_pb2 import Empty
1214

15+
from ._id import ComponentId
1316
from ._microgrid_info import MicrogridInfo
1417
from ._microgrid_info_proto import microgrid_info_from_proto
18+
from .component._base import Component
19+
from .component._category import ComponentCategory
20+
from .component._component import ComponentTypes
21+
from .component._component_proto import component_from_proto
1522

1623
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
1724
"""The default timeout for gRPC calls made by this client (in seconds)."""
1825

19-
2026
DEFAULT_CHANNEL_OPTIONS = replace(
2127
channel.ChannelOptions(), ssl=channel.SslOptions(enabled=False)
2228
)
@@ -93,3 +99,82 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)
9399
)
94100

95101
return microgrid_info_from_proto(microgrid.microgrid)
102+
103+
async def list_components( # noqa: DOC502 (raises ApiClientError indirectly)
104+
self,
105+
*,
106+
components: Iterable[ComponentId | Component] = (),
107+
categories: Iterable[ComponentCategory | int] = (),
108+
) -> Iterable[ComponentTypes]:
109+
"""Fetch all the components present in the local microgrid.
110+
111+
Electrical components are a part of a microgrid's electrical infrastructure
112+
are can be connected to each other to form an electrical circuit, which can
113+
then be represented as a graph.
114+
115+
If provided, the filters for component and categories have an `AND`
116+
relationship with one another, meaning that they are applied serially,
117+
but the elements within a single filter list have an `OR` relationship with
118+
each other.
119+
120+
Example:
121+
If `ids = {1, 2, 3}`, and `categories = {ComponentCategory.INVERTER,
122+
ComponentCategory.BATTERY}`, then the results will consist of elements that
123+
have:
124+
125+
* The IDs 1, `OR` 2, `OR` 3; `AND`
126+
* Are of the categories `ComponentCategory.INVERTER` `OR`
127+
`ComponentCategory.BATTERY`.
128+
129+
If a filter list is empty, then that filter is not applied.
130+
131+
Args:
132+
components: The components to fetch. See the method description for details.
133+
categories: The categories of the components to fetch. See the method
134+
description for details.
135+
136+
Returns:
137+
Iterator whose elements are all the components in the local microgrid.
138+
139+
Raises:
140+
ApiClientError: If the are any errors communicating with the Microgrid API,
141+
most likely a subclass of
142+
[GrpcError][frequenz.client.microgrid.GrpcError].
143+
"""
144+
component_list = await client.call_stub_method(
145+
self,
146+
lambda: self._async_stub.ListComponents(
147+
microgrid_pb2.ListComponentsRequest(
148+
component_ids=map(_get_component_id, components),
149+
categories=map(_get_category_value, categories),
150+
),
151+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
152+
),
153+
method_name="ListComponents",
154+
)
155+
156+
return map(component_from_proto, component_list.components)
157+
158+
159+
def _get_component_id(component: ComponentId | Component) -> int:
160+
"""Get the component ID from a component or component ID."""
161+
match component:
162+
case ComponentId():
163+
return int(component)
164+
case Component():
165+
return int(component.id)
166+
case unexpected:
167+
assert_never(unexpected)
168+
169+
170+
def _get_category_value(
171+
category: ComponentCategory | int,
172+
) -> components_pb2.ComponentCategory.ValueType:
173+
"""Get the category value from a component or component category."""
174+
match category:
175+
case ComponentCategory():
176+
return components_pb2.ComponentCategory.ValueType(category.value)
177+
case int():
178+
return components_pb2.ComponentCategory.ValueType(category)
179+
case unexpected:
180+
assert_never(unexpected)

src/frequenz/client/microgrid/_id.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,45 @@ def __repr__(self) -> str:
8686
def __str__(self) -> str:
8787
"""Return the short string representation of this instance."""
8888
return f"MID{self._id}"
89+
90+
91+
class ComponentId:
92+
"""A unique identifier for a microgrid component."""
93+
94+
def __init__(self, id_: int, /) -> None:
95+
"""Initialize this instance.
96+
97+
Args:
98+
id_: The numeric unique identifier of the microgrid component.
99+
100+
Raises:
101+
ValueError: If the ID is negative.
102+
"""
103+
if id_ < 0:
104+
raise ValueError("Component ID can't be negative.")
105+
self._id = id_
106+
107+
def __int__(self) -> int:
108+
"""Return the numeric ID of this instance."""
109+
return self._id
110+
111+
def __eq__(self, other: object) -> bool:
112+
"""Check if this instance is equal to another object."""
113+
# This is not an unidiomatic typecheck, that's an odd name for the check.
114+
# isinstance() returns True for subclasses, which is not what we want here.
115+
# pylint: disable-next=unidiomatic-typecheck
116+
return type(other) is ComponentId and self._id == other._id
117+
118+
def __hash__(self) -> int:
119+
"""Return the hash of this instance."""
120+
# We include the class because we explicitly want to avoid the same ID to give
121+
# the same hash for different classes of IDs
122+
return hash((ComponentId, self._id))
123+
124+
def __repr__(self) -> str:
125+
"""Return the string representation of this instance."""
126+
return f"{type(self).__name__}({self._id!r})"
127+
128+
def __str__(self) -> str:
129+
"""Return the short string representation of this instance."""
130+
return f"CID{self._id}"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Lifetime of a microgrid asset."""
5+
6+
7+
from dataclasses import dataclass
8+
from datetime import datetime
9+
from functools import cached_property
10+
11+
12+
@dataclass(frozen=True, kw_only=True)
13+
class Lifetime:
14+
"""An active operational period of a microgrid asset.
15+
16+
Warning:
17+
The [`end`][frequenz.client.microgrid.Lifetime.end] timestamp indicates that the
18+
asset has been permanently removed from the system.
19+
"""
20+
21+
start: datetime | None = None
22+
"""The moment when the asset became operationally active.
23+
24+
If `None`, the asset is considered to be active in any past moment previous to the
25+
[`end`][frequenz.client.microgrid.Lifetime.end].
26+
"""
27+
28+
end: datetime | None = None
29+
"""The moment when the asset's operational activity ceased.
30+
31+
If `None`, the asset is considered to be active with no plans to be deactivated.
32+
"""
33+
34+
def __post_init__(self) -> None:
35+
"""Validate this lifetime."""
36+
if self.start is not None and self.end is not None and self.start > self.end:
37+
raise ValueError("Start must be before or equal to end.")
38+
39+
@cached_property
40+
def active(self) -> bool:
41+
"""Whether this lifetime is currently active."""
42+
now = datetime.now()
43+
if self.start is not None and self.start > now:
44+
return False
45+
return self.end is None or self.end >= now
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of Lifetime objects from protobuf messages."""
5+
6+
from frequenz.api.common.v1.microgrid import lifetime_pb2
7+
from frequenz.client.base.conversion import to_datetime
8+
9+
from ._lifetime import Lifetime
10+
11+
12+
def lifetime_from_proto(
13+
message: lifetime_pb2.Lifetime,
14+
) -> Lifetime:
15+
"""Create a [`Lifetime`][frequenz.client.microgrid.Lifetime] from a protobuf message."""
16+
start = (
17+
to_datetime(message.start_timestamp)
18+
if message.HasField("start_timestamp")
19+
else None
20+
)
21+
end = (
22+
to_datetime(message.end_timestamp)
23+
if message.HasField("end_timestamp")
24+
else None
25+
)
26+
return Lifetime(start=start, end=end)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""All classes and functions related to microgrid components."""
5+
6+
from ._base import Component
7+
from ._battery import (
8+
Battery,
9+
BatteryType,
10+
LiIonBattery,
11+
NaIonBattery,
12+
UnrecognizedBattery,
13+
UnspecifiedBattery,
14+
)
15+
from ._category import ComponentCategory
16+
from ._chp import Chp
17+
from ._component import ComponentTypes
18+
from ._converter import Converter
19+
from ._crypto_miner import CryptoMiner
20+
from ._electrolyzer import Electrolyzer
21+
from ._ev_charger import (
22+
AcEvCharger,
23+
DcEvCharger,
24+
EvCharger,
25+
EvChargerType,
26+
UnrecognizedEvCharger,
27+
UnspecifiedEvCharger,
28+
)
29+
from ._fuse import Fuse
30+
from ._grid_connection_point import GridConnectionPoint
31+
from ._hvac import Hvac
32+
from ._inverter import (
33+
BatteryInverter,
34+
HybridInverter,
35+
Inverter,
36+
InverterType,
37+
SolarInverter,
38+
UnrecognizedInverter,
39+
UnspecifiedInverter,
40+
)
41+
from ._meter import Meter
42+
from ._precharger import Precharger
43+
from ._problematic import (
44+
MismatchedCategoryComponent,
45+
ProblematicComponentTypes,
46+
UnrecognizedComponent,
47+
UnspecifiedComponent,
48+
)
49+
from ._relay import Relay
50+
from ._status import ComponentStatus
51+
from ._voltage_transformer import VoltageTransformer
52+
53+
__all__ = [
54+
"AcEvCharger",
55+
"Battery",
56+
"BatteryInverter",
57+
"BatteryType",
58+
"Chp",
59+
"Component",
60+
"ComponentCategory",
61+
"ComponentStatus",
62+
"ComponentTypes",
63+
"Converter",
64+
"CryptoMiner",
65+
"DcEvCharger",
66+
"Electrolyzer",
67+
"EvCharger",
68+
"EvChargerType",
69+
"Fuse",
70+
"GridConnectionPoint",
71+
"Hvac",
72+
"HybridInverter",
73+
"Inverter",
74+
"InverterType",
75+
"LiIonBattery",
76+
"Meter",
77+
"MismatchedCategoryComponent",
78+
"NaIonBattery",
79+
"Precharger",
80+
"ProblematicComponentTypes",
81+
"Relay",
82+
"SolarInverter",
83+
"UnrecognizedBattery",
84+
"UnrecognizedComponent",
85+
"UnrecognizedEvCharger",
86+
"UnrecognizedInverter",
87+
"UnspecifiedBattery",
88+
"UnspecifiedComponent",
89+
"UnspecifiedEvCharger",
90+
"UnspecifiedInverter",
91+
"VoltageTransformer",
92+
]

0 commit comments

Comments
 (0)