Skip to content

Commit 6b74f3a

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 a33e6f1 commit 6b74f3a

30 files changed

+2349
-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
@@ -5,21 +5,27 @@
55

66
from __future__ import annotations
77

8+
from collections.abc import Iterable
89
from dataclasses import replace
9-
from typing import Any
10+
from typing import Any, assert_never
1011

11-
from frequenz.api.microgrid.v1 import microgrid_pb2_grpc
12+
from frequenz.api.common.v1.microgrid.components import components_pb2
13+
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
1214
from frequenz.client.base import channel, client, retry, streaming
1315
from google.protobuf.empty_pb2 import Empty
1416

1517
from ._exception import ClientNotConnected
18+
from ._id import ComponentId
1619
from ._microgrid_info import MicrogridInfo
1720
from ._microgrid_info_proto import microgrid_info_from_proto
21+
from .component._base import Component
22+
from .component._category import ComponentCategory
23+
from .component._component import ComponentTypes
24+
from .component._component_proto import component_from_proto
1825

1926
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
2027
"""The default timeout for gRPC calls made by this client (in seconds)."""
2128

22-
2329
DEFAULT_CHANNEL_OPTIONS = replace(
2430
channel.ChannelOptions(), ssl=channel.SslOptions(enabled=False)
2531
)
@@ -106,3 +112,82 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)
106112
)
107113

108114
return microgrid_info_from_proto(microgrid.microgrid)
115+
116+
async def list_components( # noqa: DOC502 (raises ApiClientError indirectly)
117+
self,
118+
*,
119+
components: Iterable[ComponentId | Component] = (),
120+
categories: Iterable[ComponentCategory | int] = (),
121+
) -> Iterable[ComponentTypes]:
122+
"""Fetch all the components present in the local microgrid.
123+
124+
Electrical components are a part of a microgrid's electrical infrastructure
125+
are can be connected to each other to form an electrical circuit, which can
126+
then be represented as a graph.
127+
128+
If provided, the filters for component and categories have an `AND`
129+
relationship with one another, meaning that they are applied serially,
130+
but the elements within a single filter list have an `OR` relationship with
131+
each other.
132+
133+
Example:
134+
If `ids = {1, 2, 3}`, and `categories = {ComponentCategory.INVERTER,
135+
ComponentCategory.BATTERY}`, then the results will consist of elements that
136+
have:
137+
138+
* The IDs 1, `OR` 2, `OR` 3; `AND`
139+
* Are of the categories `ComponentCategory.INVERTER` `OR`
140+
`ComponentCategory.BATTERY`.
141+
142+
If a filter list is empty, then that filter is not applied.
143+
144+
Args:
145+
components: The components to fetch. See the method description for details.
146+
categories: The categories of the components to fetch. See the method
147+
description for details.
148+
149+
Returns:
150+
Iterator whose elements are all the components in the local microgrid.
151+
152+
Raises:
153+
ApiClientError: If the are any errors communicating with the Microgrid API,
154+
most likely a subclass of
155+
[GrpcError][frequenz.client.microgrid.GrpcError].
156+
"""
157+
component_list = await client.call_stub_method(
158+
self,
159+
lambda: self.stub.ListComponents(
160+
microgrid_pb2.ListComponentsRequest(
161+
component_ids=map(_get_component_id, components),
162+
categories=map(_get_category_value, categories),
163+
),
164+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
165+
),
166+
method_name="ListComponents",
167+
)
168+
169+
return map(component_from_proto, component_list.components)
170+
171+
172+
def _get_component_id(component: ComponentId | Component) -> int:
173+
"""Get the component ID from a component or component ID."""
174+
match component:
175+
case ComponentId():
176+
return int(component)
177+
case Component():
178+
return int(component.id)
179+
case unexpected:
180+
assert_never(unexpected)
181+
182+
183+
def _get_category_value(
184+
category: ComponentCategory | int,
185+
) -> components_pb2.ComponentCategory.ValueType:
186+
"""Get the category value from a component or component category."""
187+
match category:
188+
case ComponentCategory():
189+
return components_pb2.ComponentCategory.ValueType(category.value)
190+
case int():
191+
return components_pb2.ComponentCategory.ValueType(category)
192+
case unexpected:
193+
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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
BatteryTypes,
11+
LiIonBattery,
12+
NaIonBattery,
13+
UnrecognizedBattery,
14+
UnspecifiedBattery,
15+
)
16+
from ._category import ComponentCategory
17+
from ._chp import Chp
18+
from ._component import (
19+
ComponentTypes,
20+
ProblematicComponentTypes,
21+
UnrecognizedComponentTypes,
22+
UnspecifiedComponentTypes,
23+
)
24+
from ._converter import Converter
25+
from ._crypto_miner import CryptoMiner
26+
from ._electrolyzer import Electrolyzer
27+
from ._ev_charger import (
28+
AcEvCharger,
29+
DcEvCharger,
30+
EvCharger,
31+
EvChargerType,
32+
EvChargerTypes,
33+
HybridEvCharger,
34+
UnrecognizedEvCharger,
35+
UnspecifiedEvCharger,
36+
)
37+
from ._fuse import Fuse
38+
from ._grid_connection_point import GridConnectionPoint
39+
from ._hvac import Hvac
40+
from ._inverter import (
41+
BatteryInverter,
42+
HybridInverter,
43+
Inverter,
44+
InverterType,
45+
SolarInverter,
46+
UnrecognizedInverter,
47+
UnspecifiedInverter,
48+
)
49+
from ._meter import Meter
50+
from ._precharger import Precharger
51+
from ._problematic import (
52+
MismatchedCategoryComponent,
53+
ProblematicComponent,
54+
UnrecognizedComponent,
55+
UnspecifiedComponent,
56+
)
57+
from ._relay import Relay
58+
from ._status import ComponentStatus
59+
from ._voltage_transformer import VoltageTransformer
60+
61+
__all__ = [
62+
"AcEvCharger",
63+
"Battery",
64+
"BatteryInverter",
65+
"BatteryType",
66+
"BatteryTypes",
67+
"Chp",
68+
"Component",
69+
"ComponentCategory",
70+
"ComponentStatus",
71+
"ComponentTypes",
72+
"Converter",
73+
"CryptoMiner",
74+
"DcEvCharger",
75+
"Electrolyzer",
76+
"EvCharger",
77+
"EvChargerType",
78+
"EvChargerTypes",
79+
"Fuse",
80+
"GridConnectionPoint",
81+
"Hvac",
82+
"HybridEvCharger",
83+
"HybridInverter",
84+
"Inverter",
85+
"InverterType",
86+
"LiIonBattery",
87+
"Meter",
88+
"MismatchedCategoryComponent",
89+
"NaIonBattery",
90+
"Precharger",
91+
"ProblematicComponent",
92+
"ProblematicComponentTypes",
93+
"Relay",
94+
"SolarInverter",
95+
"UnrecognizedBattery",
96+
"UnrecognizedComponent",
97+
"UnrecognizedComponentTypes",
98+
"UnrecognizedEvCharger",
99+
"UnrecognizedInverter",
100+
"UnspecifiedBattery",
101+
"UnspecifiedComponent",
102+
"UnspecifiedComponentTypes",
103+
"UnspecifiedEvCharger",
104+
"UnspecifiedInverter",
105+
"VoltageTransformer",
106+
]

0 commit comments

Comments
 (0)