Skip to content

Commit 6fd0ff3

Browse files
committed
Implement ListConnections
Signed-off-by: Leandro Lucarella <[email protected]>
1 parent f85abb5 commit 6fd0ff3

File tree

4 files changed

+201
-0
lines changed

4 files changed

+201
-0
lines changed

src/frequenz/client/microgrid/_client.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from .component._category import ComponentCategory
2323
from .component._component import ComponentTypes
2424
from .component._component_proto import component_from_proto
25+
from .component._connection import ComponentConnection
26+
from .component._connection_proto import component_connection_from_proto
2527

2628
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
2729
"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -170,6 +172,60 @@ async def list_components( # noqa: DOC502 (raises ApiClientError indirectly)
170172

171173
return map(component_from_proto, component_list.components)
172174

175+
async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly)
176+
self,
177+
*,
178+
sources: Iterable[ComponentId | Component] = (),
179+
destinations: Iterable[ComponentId | Component] = (),
180+
) -> Iterable[ComponentConnection]:
181+
"""Fetch all the connections present in the local microgrid.
182+
183+
Electrical components are a part of a microgrid's electrical infrastructure
184+
are can be connected to each other to form an electrical circuit, which can
185+
then be represented as a graph.
186+
187+
The direction of a connection is always away from the grid endpoint, i.e.
188+
aligned with the direction of positive current according to the passive sign
189+
convention: https://en.wikipedia.org/wiki/Passive_sign_convention
190+
191+
The request may be filtered by `source`/`destination` component(s) of individual
192+
connections. If provided, the `sources` and `destinations` filters have an
193+
`AND` relationship between each other, meaning that they are applied serially,
194+
but an `OR` relationship with other elements in the same list.
195+
196+
Example:
197+
If `sources = {1, 2, 3}`, and `destinations = {4,
198+
5, 6}`, then the result should have all the connections where:
199+
200+
* Each `source` component ID is either `1`, `2`, OR `3`; **AND**
201+
* Each `destination` component ID is either `4`, `5`, OR `6`.
202+
203+
Args:
204+
sources: The component from which the connections originate.
205+
destinations: The component at which the connections terminate.
206+
207+
Returns:
208+
Iterator whose elements are all the connections in the local microgrid.
209+
210+
Raises:
211+
ApiClientError: If the are any errors communicating with the Microgrid API,
212+
most likely a subclass of
213+
[GrpcError][frequenz.client.microgrid.GrpcError].
214+
"""
215+
connection_list = await client.call_stub_method(
216+
self,
217+
lambda: self.stub.ListConnections(
218+
microgrid_pb2.ListConnectionsRequest(
219+
starts=map(_get_component_id, sources),
220+
ends=map(_get_component_id, destinations),
221+
),
222+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
223+
),
224+
method_name="ListConnections",
225+
)
226+
227+
return map(component_connection_from_proto, connection_list.connections)
228+
173229

174230
def _get_component_id(component: ComponentId | Component) -> int:
175231
"""Get the component ID from a component or component ID."""

src/frequenz/client/microgrid/component/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
UnrecognizedComponentTypes,
2222
UnspecifiedComponentTypes,
2323
)
24+
from ._connection import ComponentConnection
2425
from ._converter import Converter
2526
from ._crypto_miner import CryptoMiner
2627
from ._electrolyzer import Electrolyzer
@@ -67,6 +68,7 @@
6768
"Chp",
6869
"Component",
6970
"ComponentCategory",
71+
"ComponentConnection",
7072
"ComponentStatus",
7173
"ComponentTypes",
7274
"Converter",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Component connection."""
5+
6+
import dataclasses
7+
from functools import cached_property
8+
9+
from .._id import ComponentId
10+
from .._lifetime import Lifetime
11+
12+
13+
@dataclasses.dataclass(frozen=True, kw_only=True)
14+
class ComponentConnection:
15+
"""A single electrical link between two components within a microgrid.
16+
17+
A component connection represents the physical wiring as viewed from the grid
18+
connection point, if one exists, or from the islanding point, in case of an islanded
19+
microgrids.
20+
21+
Note: Physical Representation
22+
This message is not about data flow but rather about the physical
23+
electrical connections between components. Therefore, the IDs for the
24+
source and destination components correspond to the actual setup within
25+
the microgrid.
26+
27+
Note: Direction
28+
The direction of the connection follows the flow of current away from the
29+
grid connection point, or in case of islands, away from the islanding
30+
point. This direction is aligned with positive current according to the
31+
[Passive Sign Convention]
32+
(https://en.wikipedia.org/wiki/Passive_sign_convention).
33+
34+
Note: Historical Data
35+
The timestamps of when a connection was created and terminated allows for
36+
tracking the changes over time to a microgrid, providing insights into
37+
when and how the microgrid infrastructure has been modified.
38+
"""
39+
40+
source: ComponentId
41+
"""The unique identifier of the component where the connection originates.
42+
43+
This is aligned with the direction of current flow away from the grid connection
44+
point, or in case of islands, away from the islanding point.
45+
"""
46+
47+
destination: ComponentId
48+
"""The unique ID of the component where the connection terminates.
49+
50+
This is the component towards which the current flows.
51+
"""
52+
53+
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
54+
"""The operational lifetime of the connection."""
55+
56+
def __post_init__(self) -> None:
57+
"""Ensure that the source and destination components are different."""
58+
if self.source == self.destination:
59+
raise ValueError("Source and destination components must be different")
60+
61+
@cached_property
62+
def active(self) -> bool:
63+
"""Whether this connection is currently active."""
64+
return self.operational_lifetime.active
65+
66+
def __str__(self) -> str:
67+
"""Return a human-readable string representation of this instance."""
68+
return f"{self.source}->{self.destination}"
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of ComponentConnection objects from protobuf messages."""
5+
6+
import logging
7+
8+
from frequenz.api.common.v1.microgrid.components import components_pb2
9+
10+
from .._id import ComponentId
11+
from .._lifetime import Lifetime
12+
from .._lifetime_proto import lifetime_from_proto
13+
from ._connection import ComponentConnection
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
def component_connection_from_proto(
19+
message: components_pb2.ComponentConnection,
20+
) -> ComponentConnection:
21+
"""Create a `ComponentConnection` from a protobuf message."""
22+
major_issues: list[str] = []
23+
minor_issues: list[str] = []
24+
25+
source_component_id = ComponentId(message.source_component_id)
26+
destination_component_id = ComponentId(message.destination_component_id)
27+
if source_component_id == destination_component_id:
28+
major_issues.append(
29+
f"source and destination are the same: {source_component_id}",
30+
)
31+
32+
lifetime = _get_operational_lifetime_from_proto(
33+
message, major_issues=major_issues, minor_issues=minor_issues
34+
)
35+
36+
if major_issues:
37+
_logger.warning(
38+
"Found issues in component connection: %s | Protobuf message:\n%s",
39+
", ".join(major_issues),
40+
message,
41+
)
42+
if minor_issues:
43+
_logger.debug(
44+
"Found minor issues in component connection: %s | Protobuf message:\n%s",
45+
", ".join(minor_issues),
46+
message,
47+
)
48+
49+
return ComponentConnection(
50+
source=source_component_id,
51+
destination=destination_component_id,
52+
operational_lifetime=lifetime,
53+
)
54+
55+
56+
def _get_operational_lifetime_from_proto(
57+
message: components_pb2.ComponentConnection,
58+
*,
59+
major_issues: list[str],
60+
minor_issues: list[str],
61+
) -> Lifetime:
62+
"""Get the operational lifetime from a protobuf message."""
63+
if message.HasField("operational_lifetime"):
64+
try:
65+
return lifetime_from_proto(message.operational_lifetime)
66+
except ValueError as exc:
67+
major_issues.append(
68+
f"invalid operational lifetime ({exc}), considering it as missing "
69+
"(i.e. always operational)",
70+
)
71+
else:
72+
minor_issues.append(
73+
"missing operational lifetime, considering it always operational",
74+
)
75+
return Lifetime()

0 commit comments

Comments
 (0)