Skip to content

Commit 2299ebf

Browse files
committed
Implement ListConnections
Signed-off-by: Leandro Lucarella <[email protected]>
1 parent d6dda43 commit 2299ebf

File tree

6 files changed

+230
-0
lines changed

6 files changed

+230
-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,
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) == []
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test data for successful connection listing."""
5+
6+
from datetime import datetime, timezone
7+
from typing import Any
8+
9+
from frequenz.api.common.v1.microgrid import lifetime_pb2
10+
from frequenz.api.common.v1.microgrid.components import components_pb2
11+
from frequenz.api.microgrid.v1 import microgrid_pb2
12+
from frequenz.client.base.conversion import to_timestamp
13+
from frequenz.client.common.microgrid.components import ComponentId
14+
15+
from frequenz.client.microgrid import Lifetime
16+
from frequenz.client.microgrid.component import ComponentConnection
17+
18+
# No client_args or client_kwargs needed for this call
19+
20+
21+
def assert_stub_method_call(stub_method: Any) -> None:
22+
"""Assert that the gRPC request matches the expected request."""
23+
stub_method.assert_called_once_with(
24+
microgrid_pb2.ListConnectionsRequest(starts=[], ends=[]), timeout=60.0
25+
)
26+
27+
28+
lifetime_start = datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
29+
grpc_response = microgrid_pb2.ListConnectionsResponse(
30+
connections=[
31+
components_pb2.ComponentConnection(
32+
source_component_id=1, destination_component_id=2
33+
),
34+
components_pb2.ComponentConnection(
35+
source_component_id=2,
36+
destination_component_id=3,
37+
operational_lifetime=lifetime_pb2.Lifetime(
38+
start_timestamp=to_timestamp(lifetime_start)
39+
),
40+
),
41+
]
42+
)
43+
44+
45+
def assert_client_result(actual_result: Any) -> None:
46+
"""Assert that the client result matches the expected connections list."""
47+
assert list(actual_result) == [
48+
ComponentConnection(
49+
source=ComponentId(1),
50+
destination=ComponentId(2),
51+
),
52+
ComponentConnection(
53+
source=ComponentId(2),
54+
destination=ComponentId(3),
55+
operational_lifetime=Lifetime(start=lifetime_start),
56+
),
57+
]

tests/test_client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ async def test_list_components(
111111
await spec.test_unary_unary_call(client, "ListComponents")
112112

113113

114+
@pytest.mark.asyncio
115+
@pytest.mark.parametrize(
116+
"spec",
117+
get_test_specs("list_connections", tests_dir=TESTS_DIR),
118+
ids=str,
119+
)
120+
async def test_list_connections(
121+
client: MicrogridApiClient, spec: ApiClientTestCaseSpec
122+
) -> None:
123+
"""Test list_connections method."""
124+
await spec.test_unary_unary_call(client, "ListConnections")
125+
126+
114127
@pytest.mark.asyncio
115128
@pytest.mark.parametrize(
116129
"spec",

0 commit comments

Comments
 (0)