Skip to content

Commit 5caf875

Browse files
authored
Merge pull request #50 from eduardiazf/feat/electrical-component-connections
feat(assets): Add Support for Electrical Component Connections
2 parents accf4f1 + 0fa7382 commit 5caf875

13 files changed

Lines changed: 488 additions & 7 deletions

File tree

src/frequenz/client/assets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ._client import AssetsApiClient
77
from ._delivery_area import DeliveryArea, EnergyMarketCodeType
8+
from ._lifetime import Lifetime
89
from ._location import Location
910
from ._microgrid import Microgrid, MicrogridStatus
1011

@@ -15,4 +16,5 @@
1516
"Microgrid",
1617
"MicrogridStatus",
1718
"Location",
19+
"Lifetime",
1820
]

src/frequenz/client/assets/_client.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@
99

1010
from __future__ import annotations
1111

12+
from collections.abc import Iterable
13+
1214
from frequenz.api.assets.v1 import assets_pb2, assets_pb2_grpc
1315
from frequenz.client.base import channel
1416
from frequenz.client.base.client import BaseApiClient, call_stub_method
1517

16-
from frequenz.client.assets.electrical_component._electrical_component import (
17-
ElectricalComponent,
18-
)
19-
2018
from ._microgrid import Microgrid
2119
from ._microgrid_proto import microgrid_from_proto
20+
from .electrical_component._connection import ComponentConnection
21+
from .electrical_component._connection_proto import component_connection_from_proto
22+
from .electrical_component._electrical_component import ElectricalComponent
2223
from .electrical_component._electrical_component_proto import electrical_component_proto
2324
from .exceptions import ClientNotConnected
2425

@@ -138,3 +139,45 @@ async def list_microgrid_electrical_components(
138139
return [
139140
electrical_component_proto(component) for component in response.components
140141
]
142+
143+
async def list_microgrid_electrical_component_connections(
144+
self,
145+
microgrid_id: int,
146+
source_component_ids: Iterable[int] = (),
147+
destination_component_ids: Iterable[int] = (),
148+
) -> list[ComponentConnection | None]:
149+
"""
150+
Get the electrical component connections of a microgrid.
151+
152+
Args:
153+
microgrid_id: The ID of the microgrid to get the electrical
154+
component connections of.
155+
source_component_ids: Only return connections that originate from
156+
these component IDs. If None or empty, no filtering is applied.
157+
destination_component_ids: Only return connections that terminate at
158+
these component IDs. If None or empty, no filtering is applied.
159+
160+
Returns:
161+
The electrical component connections of the microgrid.
162+
"""
163+
request = assets_pb2.ListMicrogridElectricalComponentConnectionsRequest(
164+
microgrid_id=microgrid_id,
165+
source_component_ids=source_component_ids,
166+
destination_component_ids=destination_component_ids,
167+
)
168+
169+
response = await call_stub_method(
170+
self,
171+
lambda: self.stub.ListMicrogridElectricalComponentConnections(
172+
request,
173+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
174+
),
175+
method_name="ListMicrogridElectricalComponentConnections",
176+
)
177+
178+
return list(
179+
map(
180+
component_connection_from_proto,
181+
filter(bool, response.connections),
182+
)
183+
)

src/frequenz/client/assets/_lifetime.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ class Lifetime:
1313
"""An active operational period of a microgrid asset.
1414
1515
Warning:
16-
The [`end`][frequenz.client.microgrid.Lifetime.end] timestamp indicates that the
16+
The [`end`][frequenz.client.assets.Lifetime.end] timestamp indicates that the
1717
asset has been permanently removed from the system.
1818
"""
1919

2020
start: datetime | None = None
2121
"""The moment when the asset became operationally active.
2222
2323
If `None`, the asset is considered to be active in any past moment previous to the
24-
[`end`][frequenz.client.microgrid.Lifetime.end].
24+
[`end`][frequenz.client.assets.Lifetime.end].
2525
"""
2626

2727
end: datetime | None = None

src/frequenz/client/assets/cli/__main__.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@
4040
from frequenz.client.assets._client import AssetsApiClient
4141
from frequenz.client.assets.exceptions import ApiClientError
4242

43-
from ._utils import print_electrical_components, print_microgrid_details
43+
from ._utils import (
44+
print_component_connections,
45+
print_electrical_components,
46+
print_microgrid_details,
47+
)
4448

4549

4650
@click.group(invoke_without_command=True)
@@ -201,6 +205,91 @@ async def list_microgrid_electrical_components(
201205
raise click.Abort()
202206

203207

208+
@cli.command("component-connections")
209+
@click.pass_context
210+
@click.argument("microgrid-id", required=True, type=int)
211+
@click.option(
212+
"--source",
213+
"source_component_ids",
214+
help="Filter connections by source component ID(s). Can be specified multiple times.",
215+
type=int,
216+
multiple=True,
217+
required=False,
218+
)
219+
@click.option(
220+
"--destination",
221+
"destination_component_ids",
222+
help="Filter connections by destination component ID(s). Can be specified multiple times.",
223+
type=int,
224+
multiple=True,
225+
required=False,
226+
)
227+
async def list_microgrid_electrical_component_connections(
228+
ctx: click.Context,
229+
microgrid_id: int,
230+
source_component_ids: tuple[int, ...],
231+
destination_component_ids: tuple[int, ...],
232+
) -> None:
233+
"""
234+
Get and display electrical component connections by microgrid ID.
235+
236+
This command fetches detailed information about all electrical component connections
237+
in a specific microgrid from the Assets API and displays it in JSON format.
238+
The output can be piped to other tools for further processing.
239+
240+
Args:
241+
ctx: Click context object containing the initialized API client.
242+
microgrid_id: The unique identifier of the microgrid to retrieve.
243+
source_component_ids: Optional filter for connections from specific
244+
source component IDs.
245+
destination_component_ids: Optional filter for connections to specific
246+
destination component IDs.
247+
248+
Raises:
249+
click.Abort: If there is an error printing the electrical component connections.
250+
251+
Example:
252+
```bash
253+
# Get all connections for microgrid with ID 123
254+
assets-cli component-connections 123
255+
256+
# Filter by source component
257+
assets-cli component-connections 123 --source 5
258+
259+
# Filter by destination component
260+
assets-cli component-connections 123 --destination 10
261+
262+
# Filter by both source and destination
263+
assets-cli component-connections 123 --source 5 --destination 10
264+
265+
# Filter by multiple source components
266+
assets-cli component-connections 123 --source 5 --source 6 --source 7
267+
268+
# Pipe output to jq for filtering
269+
assets-cli component-connections 123 | jq ".[]"
270+
```
271+
"""
272+
try:
273+
client = ctx.obj["client"]
274+
component_connections = (
275+
await client.list_microgrid_electrical_component_connections(
276+
microgrid_id,
277+
source_component_ids=source_component_ids,
278+
destination_component_ids=destination_component_ids,
279+
)
280+
)
281+
print_component_connections(component_connections)
282+
except ApiClientError as e:
283+
error_dict = {
284+
"error_type": type(e).__name__,
285+
"server_url": e.server_url,
286+
"operation": e.operation,
287+
"description": e.description,
288+
}
289+
click.echo(json.dumps(error_dict, indent=2))
290+
raise click.Abort()
291+
292+
204293
def main() -> None:
205294
"""
206295
Initialize and run the CLI application.

src/frequenz/client/assets/cli/_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
from frequenz.client.assets._microgrid import Microgrid
66
from frequenz.client.assets._microgrid_json import microgrid_to_json
7+
from frequenz.client.assets.electrical_component._connection import ComponentConnection
8+
from frequenz.client.assets.electrical_component._connection_json import (
9+
component_connections_to_json,
10+
)
711
from frequenz.client.assets.electrical_component._electrical_component import (
812
ElectricalComponent,
913
)
@@ -42,3 +46,17 @@ def print_electrical_components(
4246
electrical_components: The list of ElectricalComponent instances to print to console.
4347
"""
4448
click.echo(electrical_components_to_json(electrical_components))
49+
50+
51+
def print_component_connections(
52+
component_connections: list[ComponentConnection],
53+
) -> None:
54+
"""
55+
Print electrical component connections to console in JSON format using custom encoder.
56+
57+
This function converts the ComponentConnection instances to JSON using a custom
58+
encoder and outputs it as formatted JSON to the console. The output is
59+
designed to be machine-readable and can be piped to tools like jq for
60+
further processing.
61+
"""
62+
click.echo(component_connections_to_json(component_connections))

src/frequenz/client/assets/electrical_component/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ._capacitor_bank import CapacitorBank
1515
from ._category import ElectricalComponentCategory
1616
from ._chp import Chp
17+
from ._connection import ComponentConnection
1718
from ._converter import Converter
1819
from ._crypto_miner import CryptoMiner
1920
from ._electrical_component import ElectricalComponent
@@ -87,4 +88,5 @@
8788
"StaticTransferSwitch",
8889
"UninterruptiblePowerSupply",
8990
"WindTurbine",
91+
"ComponentConnection",
9092
]
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""JSON encoder for ComponentConnection objects."""
5+
6+
import json
7+
from dataclasses import asdict
8+
9+
from .._utils import AssetsJSONEncoder
10+
from ._connection import ComponentConnection
11+
12+
13+
def component_connections_to_json(
14+
component_connections: list[ComponentConnection],
15+
) -> str:
16+
"""Convert a list of ElectricalComponent objects to a JSON string."""
17+
return json.dumps(
18+
[asdict(connection) for connection in component_connections],
19+
cls=AssetsJSONEncoder,
20+
indent=2,
21+
)

0 commit comments

Comments
 (0)