Skip to content

Commit 6840ced

Browse files
authored
Implement ListConnections (#167)
2 parents f6dfe48 + b1cd2c7 commit 6840ced

File tree

11 files changed

+664
-0
lines changed

11 files changed

+664
-0
lines changed

src/frequenz/client/microgrid/_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from .component._category import ComponentCategory
2929
from .component._component import Component
3030
from .component._component_proto import component_from_proto
31+
from .component._connection import ComponentConnection
32+
from .component._connection_proto import component_connection_from_proto
3133
from .component._types import ComponentTypes
3234
from .metrics._bounds import Bounds
3335
from .metrics._metric import Metric
@@ -221,6 +223,66 @@ async def list_components( # noqa: DOC502 (raises ApiClientError indirectly)
221223

222224
return map(component_from_proto, component_list.components)
223225

226+
async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly)
227+
self,
228+
*,
229+
sources: Iterable[ComponentId | Component] = (),
230+
destinations: Iterable[ComponentId | Component] = (),
231+
) -> Iterable[ComponentConnection]:
232+
"""Fetch all the connections present in the local microgrid.
233+
234+
Electrical components are a part of a microgrid's electrical infrastructure
235+
are can be connected to each other to form an electrical circuit, which can
236+
then be represented as a graph.
237+
238+
The direction of a connection is always away from the grid endpoint, i.e.
239+
aligned with the direction of positive current according to the passive sign
240+
convention: https://en.wikipedia.org/wiki/Passive_sign_convention
241+
242+
The request may be filtered by `source`/`destination` component(s) of individual
243+
connections. If provided, the `sources` and `destinations` filters have an
244+
`AND` relationship between each other, meaning that they are applied serially,
245+
but an `OR` relationship with other elements in the same list.
246+
247+
Example:
248+
If `sources = {1, 2, 3}`, and `destinations = {4,
249+
5, 6}`, then the result should have all the connections where:
250+
251+
* Each `source` component ID is either `1`, `2`, OR `3`; **AND**
252+
* Each `destination` component ID is either `4`, `5`, OR `6`.
253+
254+
Args:
255+
sources: The component from which the connections originate.
256+
destinations: The component at which the connections terminate.
257+
258+
Returns:
259+
Iterator whose elements are all the connections in the local microgrid.
260+
261+
Raises:
262+
ApiClientError: If the are any errors communicating with the Microgrid API,
263+
most likely a subclass of
264+
[GrpcError][frequenz.client.microgrid.GrpcError].
265+
"""
266+
connection_list = await client.call_stub_method(
267+
self,
268+
lambda: self.stub.ListConnections(
269+
microgrid_pb2.ListConnectionsRequest(
270+
starts=map(_get_component_id, sources),
271+
ends=map(_get_component_id, destinations),
272+
),
273+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
274+
),
275+
method_name="ListConnections",
276+
)
277+
278+
return (
279+
conn
280+
for conn in map(
281+
component_connection_from_proto, connection_list.connections
282+
)
283+
if conn is not None
284+
)
285+
224286
async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly)
225287
self,
226288
component: ComponentId | Component,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ._category import ComponentCategory
1616
from ._chp import Chp
1717
from ._component import Component
18+
from ._connection import ComponentConnection
1819
from ._converter import Converter
1920
from ._crypto_miner import CryptoMiner
2021
from ._electrolyzer import Electrolyzer
@@ -67,6 +68,7 @@
6768
"Chp",
6869
"Component",
6970
"ComponentCategory",
71+
"ComponentConnection",
7072
"ComponentStatus",
7173
"ComponentTypes",
7274
"Converter",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Component connection."""
5+
6+
import dataclasses
7+
from datetime import datetime, timezone
8+
9+
from frequenz.client.common.microgrid.components import ComponentId
10+
11+
from .._lifetime import Lifetime
12+
13+
14+
@dataclasses.dataclass(frozen=True, kw_only=True)
15+
class ComponentConnection:
16+
"""A single electrical link between two components within a microgrid.
17+
18+
A component connection represents the physical wiring as viewed from the grid
19+
connection point, if one exists, or from the islanding point, in case of an islanded
20+
microgrids.
21+
22+
Note: Physical Representation
23+
This object is not about data flow but rather about the physical
24+
electrical connections between components. Therefore, the IDs for the
25+
source and destination components correspond to the actual setup within
26+
the microgrid.
27+
28+
Note: Direction
29+
The direction of the connection follows the flow of current away from the
30+
grid connection point, or in case of islands, away from the islanding
31+
point. This direction is aligned with positive current according to the
32+
[Passive Sign Convention]
33+
(https://en.wikipedia.org/wiki/Passive_sign_convention).
34+
35+
Note: Historical Data
36+
The timestamps of when a connection was created and terminated allow for
37+
tracking the changes over time to a microgrid, providing insights into
38+
when and how the microgrid infrastructure has been modified.
39+
"""
40+
41+
source: ComponentId
42+
"""The unique identifier of the component where the connection originates.
43+
44+
This is aligned with the direction of current flow away from the grid connection
45+
point, or in case of islands, away from the islanding point.
46+
"""
47+
48+
destination: ComponentId
49+
"""The unique ID of the component where the connection terminates.
50+
51+
This is the component towards which the current flows.
52+
"""
53+
54+
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
55+
"""The operational lifetime of the connection."""
56+
57+
def __post_init__(self) -> None:
58+
"""Ensure that the source and destination components are different."""
59+
if self.source == self.destination:
60+
raise ValueError("Source and destination components must be different")
61+
62+
def is_operational_at(self, timestamp: datetime) -> bool:
63+
"""Check whether this connection is operational at a specific timestamp."""
64+
return self.operational_lifetime.is_operational_at(timestamp)
65+
66+
def is_operational_now(self) -> bool:
67+
"""Whether this connection is currently operational."""
68+
return self.is_operational_at(datetime.now(timezone.utc))
69+
70+
def __str__(self) -> str:
71+
"""Return a human-readable string representation of this instance."""
72+
return f"{self.source}->{self.destination}"
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# License: MIT
2+
# Copyright © 2025 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+
from frequenz.client.common.microgrid.components import ComponentId
10+
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 | None:
21+
"""Create a `ComponentConnection` from a protobuf message."""
22+
major_issues: list[str] = []
23+
minor_issues: list[str] = []
24+
25+
connection = component_connection_from_proto_with_issues(
26+
message, major_issues=major_issues, minor_issues=minor_issues
27+
)
28+
29+
if major_issues:
30+
_logger.warning(
31+
"Found issues in component connection: %s | Protobuf message:\n%s",
32+
", ".join(major_issues),
33+
message,
34+
)
35+
if minor_issues:
36+
_logger.debug(
37+
"Found minor issues in component connection: %s | Protobuf message:\n%s",
38+
", ".join(minor_issues),
39+
message,
40+
)
41+
42+
return connection
43+
44+
45+
def component_connection_from_proto_with_issues(
46+
message: components_pb2.ComponentConnection,
47+
*,
48+
major_issues: list[str],
49+
minor_issues: list[str],
50+
) -> ComponentConnection | None:
51+
"""Create a `ComponentConnection` from a protobuf message collecting issues.
52+
53+
This function is useful when you want to collect issues during the parsing
54+
of multiple connections, rather than logging them immediately.
55+
56+
Args:
57+
message: The protobuf message to parse.
58+
major_issues: A list to collect major issues found during parsing.
59+
minor_issues: A list to collect minor issues found during parsing.
60+
61+
Returns:
62+
A `ComponentConnection` object created from the protobuf message, or
63+
`None` if the protobuf message is completely invalid and a
64+
`ComponentConnection` cannot be created.
65+
"""
66+
source_component_id = ComponentId(message.source_component_id)
67+
destination_component_id = ComponentId(message.destination_component_id)
68+
if source_component_id == destination_component_id:
69+
major_issues.append(
70+
f"connection ignored: source and destination are the same ({source_component_id})",
71+
)
72+
return None
73+
74+
lifetime = _get_operational_lifetime_from_proto(
75+
message, major_issues=major_issues, minor_issues=minor_issues
76+
)
77+
78+
return ComponentConnection(
79+
source=source_component_id,
80+
destination=destination_component_id,
81+
operational_lifetime=lifetime,
82+
)
83+
84+
85+
def _get_operational_lifetime_from_proto(
86+
message: components_pb2.ComponentConnection,
87+
*,
88+
major_issues: list[str],
89+
minor_issues: list[str],
90+
) -> Lifetime:
91+
"""Get the operational lifetime from a protobuf message."""
92+
if message.HasField("operational_lifetime"):
93+
try:
94+
return lifetime_from_proto(message.operational_lifetime)
95+
except ValueError as exc:
96+
major_issues.append(
97+
f"invalid operational lifetime ({exc}), considering it as missing "
98+
"(i.e. always operational)",
99+
)
100+
else:
101+
minor_issues.append(
102+
"missing operational lifetime, considering it always operational",
103+
)
104+
return Lifetime()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test list_connections with no connections: result should be empty."""
5+
6+
from typing import Any
7+
8+
from frequenz.api.microgrid.v1 import microgrid_pb2
9+
10+
# No client_args or client_kwargs needed for this call
11+
12+
13+
def assert_stub_method_call(stub_method: Any) -> None:
14+
"""Assert that the gRPC request matches the expected request."""
15+
stub_method.assert_called_once_with(
16+
microgrid_pb2.ListConnectionsRequest(starts=[], ends=[]), timeout=60.0
17+
)
18+
19+
20+
grpc_response = microgrid_pb2.ListConnectionsResponse(connections=[])
21+
22+
23+
def assert_client_result(result: Any) -> None: # noqa: D103
24+
"""Assert that the client result is an empty list."""
25+
assert not list(result)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test list_connections with error."""
5+
6+
from typing import Any
7+
8+
from frequenz.api.microgrid.v1 import microgrid_pb2
9+
from grpc import StatusCode
10+
11+
from frequenz.client.microgrid import PermissionDenied
12+
from tests.util import make_grpc_error
13+
14+
# No client_args or client_kwargs needed for this call
15+
16+
17+
def assert_stub_method_call(stub_method: Any) -> None:
18+
"""Assert that the gRPC request matches the expected request."""
19+
stub_method.assert_called_once_with(
20+
microgrid_pb2.ListConnectionsRequest(starts=[], ends=[]), timeout=60.0
21+
)
22+
23+
24+
grpc_response = make_grpc_error(StatusCode.PERMISSION_DENIED)
25+
26+
27+
def assert_client_exception(exception: Exception) -> None:
28+
"""Assert that the client exception matches the expected error."""
29+
assert isinstance(exception, PermissionDenied)
30+
assert exception.grpc_error == grpc_response
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test list_connections with mixed ComponentId and Component objects."""
5+
6+
from datetime import datetime, timezone
7+
from typing import Any
8+
9+
from frequenz.api.microgrid.v1 import microgrid_pb2
10+
from frequenz.client.common.microgrid import MicrogridId
11+
from frequenz.client.common.microgrid.components import ComponentId
12+
13+
from frequenz.client.microgrid.component import (
14+
GridConnectionPoint,
15+
Meter,
16+
)
17+
18+
# Mix ComponentId and Component objects
19+
grid_component = GridConnectionPoint(
20+
id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=10_000
21+
)
22+
meter_component = Meter(id=ComponentId(4), microgrid_id=MicrogridId(1))
23+
24+
client_kwargs = {
25+
"sources": [grid_component, ComponentId(2)],
26+
"destinations": [ComponentId(3), meter_component],
27+
}
28+
29+
30+
def assert_stub_method_call(stub_method: Any) -> None:
31+
"""Assert that the gRPC request matches the expected request."""
32+
stub_method.assert_called_once_with(
33+
microgrid_pb2.ListConnectionsRequest(starts=[1, 2], ends=[3, 4]), timeout=60.0
34+
)
35+
36+
37+
lifetime_start = datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
38+
grpc_response = microgrid_pb2.ListConnectionsResponse(connections=[])
39+
40+
41+
def assert_client_result(actual_result: Any) -> None:
42+
"""Assert that the client result matches the expected connections list."""
43+
assert list(actual_result) == []

0 commit comments

Comments
 (0)