Skip to content

Commit d6dda43

Browse files
committed
Add ComponentConnection
Signed-off-by: Leandro Lucarella <[email protected]>
1 parent f6dfe48 commit d6dda43

File tree

5 files changed

+434
-0
lines changed

5 files changed

+434
-0
lines changed

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 © 2024 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 message 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 allows 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 © 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+
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()

tests/component/test_connection.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for ComponentConnection class and related functionality."""
5+
6+
from datetime import datetime, timezone
7+
from unittest.mock import Mock, patch
8+
9+
import pytest
10+
from frequenz.client.common.microgrid.components import ComponentId
11+
12+
from frequenz.client.microgrid import Lifetime
13+
from frequenz.client.microgrid.component import ComponentConnection
14+
15+
16+
def test_creation() -> None:
17+
"""Test basic ComponentConnection creation and validation."""
18+
now = datetime.now(timezone.utc)
19+
lifetime = Lifetime(start=now)
20+
connection = ComponentConnection(
21+
source=ComponentId(1), destination=ComponentId(2), operational_lifetime=lifetime
22+
)
23+
24+
assert connection.source == ComponentId(1)
25+
assert connection.destination == ComponentId(2)
26+
assert connection.operational_lifetime == lifetime
27+
28+
29+
def test_validation() -> None:
30+
"""Test validation of source and destination components."""
31+
with pytest.raises(
32+
ValueError, match="Source and destination components must be different"
33+
):
34+
ComponentConnection(source=ComponentId(1), destination=ComponentId(1))
35+
36+
37+
def test_str() -> None:
38+
"""Test string representation of ComponentConnection."""
39+
connection = ComponentConnection(source=ComponentId(1), destination=ComponentId(2))
40+
assert str(connection) == "CID1->CID2"
41+
42+
43+
@pytest.mark.parametrize(
44+
"lifetime_active", [True, False], ids=["operational", "not-operational"]
45+
)
46+
def test_is_operational_at(lifetime_active: bool) -> None:
47+
"""Test active_at behavior with lifetime.active values."""
48+
mock_lifetime = Mock(spec=Lifetime)
49+
mock_lifetime.is_operational_at.return_value = lifetime_active
50+
51+
connection = ComponentConnection(
52+
source=ComponentId(1),
53+
destination=ComponentId(2),
54+
operational_lifetime=mock_lifetime,
55+
)
56+
57+
now = datetime.now(timezone.utc)
58+
assert connection.is_operational_at(now) == lifetime_active
59+
mock_lifetime.is_operational_at.assert_called_once_with(now)
60+
61+
62+
@patch("frequenz.client.microgrid.component._connection.datetime")
63+
@pytest.mark.parametrize(
64+
"lifetime_active", [True, False], ids=["operational", "not-operational"]
65+
)
66+
def test_is_operational_now(mock_datetime: Mock, lifetime_active: bool) -> None:
67+
"""Test if the connection is operational at the current time."""
68+
now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
69+
mock_datetime.now.side_effect = lambda tz: now.replace(tzinfo=tz)
70+
mock_lifetime = Mock(spec=Lifetime)
71+
mock_lifetime.is_operational_at.return_value = lifetime_active
72+
73+
connection = ComponentConnection(
74+
source=ComponentId(1),
75+
destination=ComponentId(2),
76+
operational_lifetime=mock_lifetime,
77+
)
78+
79+
assert connection.is_operational_now() is lifetime_active
80+
mock_lifetime.is_operational_at.assert_called_once_with(now)
81+
mock_datetime.now.assert_called_once_with(timezone.utc)

0 commit comments

Comments
 (0)