From 8383c0fa1843640af132fb3d70c9af0e5e90f580 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 12 Jun 2026 09:05:45 +0000 Subject: [PATCH 1/5] Import component connection modules from the microgrid client Signed-off-by: Leandro Lucarella --- .../electrical_components/_connection.py | 72 +++++++ .../_connection_proto.py | 106 ++++++++++ .../electrical_components/test_connection.py | 81 ++++++++ .../test_connection_proto.py | 185 ++++++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_connection.py create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py create mode 100644 tests/microgrid/electrical_components/test_connection.py create mode 100644 tests/microgrid/electrical_components/test_connection_proto.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/_connection.py b/src/frequenz/client/common/microgrid/electrical_components/_connection.py new file mode 100644 index 00000000..83bdf481 --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_connection.py @@ -0,0 +1,72 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Component connection.""" + +import dataclasses +from datetime import datetime, timezone + +from frequenz.client.common.microgrid.components import ComponentId + +from .._lifetime import Lifetime + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class ComponentConnection: + """A single electrical link between two components within a microgrid. + + A component connection represents the physical wiring as viewed from the grid + connection point, if one exists, or from the islanding point, in case of an islanded + microgrids. + + Note: Physical Representation + This object is not about data flow but rather about the physical + electrical connections between components. Therefore, the IDs for the + source and destination components correspond to the actual setup within + the microgrid. + + Note: Direction + The direction of the connection follows the flow of current away from the + grid connection point, or in case of islands, away from the islanding + point. This direction is aligned with positive current according to the + [Passive Sign Convention] + (https://en.wikipedia.org/wiki/Passive_sign_convention). + + Note: Historical Data + The timestamps of when a connection was created and terminated allow for + tracking the changes over time to a microgrid, providing insights into + when and how the microgrid infrastructure has been modified. + """ + + source: ComponentId + """The unique identifier of the component where the connection originates. + + This is aligned with the direction of current flow away from the grid connection + point, or in case of islands, away from the islanding point. + """ + + destination: ComponentId + """The unique ID of the component where the connection terminates. + + This is the component towards which the current flows. + """ + + operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime) + """The operational lifetime of the connection.""" + + def __post_init__(self) -> None: + """Ensure that the source and destination components are different.""" + if self.source == self.destination: + raise ValueError("Source and destination components must be different") + + def is_operational_at(self, timestamp: datetime) -> bool: + """Check whether this connection is operational at a specific timestamp.""" + return self.operational_lifetime.is_operational_at(timestamp) + + def is_operational_now(self) -> bool: + """Whether this connection is currently operational.""" + return self.is_operational_at(datetime.now(timezone.utc)) + + def __str__(self) -> str: + """Return a human-readable string representation of this instance.""" + return f"{self.source}->{self.destination}" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py b/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py new file mode 100644 index 00000000..c8bb1013 --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py @@ -0,0 +1,106 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of ComponentConnection objects from protobuf messages.""" + +import logging + +from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( + electrical_components_pb2, +) +from frequenz.client.common.microgrid.components import ComponentId + +from .._lifetime import Lifetime +from .._lifetime_proto import lifetime_from_proto +from ._connection import ComponentConnection + +_logger = logging.getLogger(__name__) + + +def component_connection_from_proto( + message: electrical_components_pb2.ElectricalComponentConnection, +) -> ComponentConnection | None: + """Create a `ComponentConnection` from a protobuf message.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + + connection = component_connection_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + if major_issues: + _logger.warning( + "Found issues in component connection: %s | Protobuf message:\n%s", + ", ".join(major_issues), + message, + ) + if minor_issues: + _logger.debug( + "Found minor issues in component connection: %s | Protobuf message:\n%s", + ", ".join(minor_issues), + message, + ) + + return connection + + +def component_connection_from_proto_with_issues( + message: electrical_components_pb2.ElectricalComponentConnection, + *, + major_issues: list[str], + minor_issues: list[str], +) -> ComponentConnection | None: + """Create a `ComponentConnection` from a protobuf message collecting issues. + + This function is useful when you want to collect issues during the parsing + of multiple connections, rather than logging them immediately. + + Args: + message: The protobuf message to parse. + major_issues: A list to collect major issues found during parsing. + minor_issues: A list to collect minor issues found during parsing. + + Returns: + A `ComponentConnection` object created from the protobuf message, or + `None` if the protobuf message is completely invalid and a + `ComponentConnection` cannot be created. + """ + source_component_id = ComponentId(message.source_electrical_component_id) + destination_component_id = ComponentId(message.destination_electrical_component_id) + if source_component_id == destination_component_id: + major_issues.append( + f"connection ignored: source and destination are the same ({source_component_id})", + ) + return None + + lifetime = _get_operational_lifetime_from_proto( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + return ComponentConnection( + source=source_component_id, + destination=destination_component_id, + operational_lifetime=lifetime, + ) + + +def _get_operational_lifetime_from_proto( + message: electrical_components_pb2.ElectricalComponentConnection, + *, + major_issues: list[str], + minor_issues: list[str], +) -> Lifetime: + """Get the operational lifetime from a protobuf message.""" + if message.HasField("operational_lifetime"): + try: + return lifetime_from_proto(message.operational_lifetime) + except ValueError as exc: + major_issues.append( + f"invalid operational lifetime ({exc}), considering it as missing " + "(i.e. always operational)", + ) + else: + minor_issues.append( + "missing operational lifetime, considering it always operational", + ) + return Lifetime() diff --git a/tests/microgrid/electrical_components/test_connection.py b/tests/microgrid/electrical_components/test_connection.py new file mode 100644 index 00000000..1c4d1723 --- /dev/null +++ b/tests/microgrid/electrical_components/test_connection.py @@ -0,0 +1,81 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for ComponentConnection class and related functionality.""" + +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.component import ComponentConnection + + +def test_creation() -> None: + """Test basic ComponentConnection creation and validation.""" + now = datetime.now(timezone.utc) + lifetime = Lifetime(start=now) + connection = ComponentConnection( + source=ComponentId(1), destination=ComponentId(2), operational_lifetime=lifetime + ) + + assert connection.source == ComponentId(1) + assert connection.destination == ComponentId(2) + assert connection.operational_lifetime == lifetime + + +def test_validation() -> None: + """Test validation of source and destination components.""" + with pytest.raises( + ValueError, match="Source and destination components must be different" + ): + ComponentConnection(source=ComponentId(1), destination=ComponentId(1)) + + +def test_str() -> None: + """Test string representation of ComponentConnection.""" + connection = ComponentConnection(source=ComponentId(1), destination=ComponentId(2)) + assert str(connection) == "CID1->CID2" + + +@pytest.mark.parametrize( + "lifetime_active", [True, False], ids=["operational", "not-operational"] +) +def test_is_operational_at(lifetime_active: bool) -> None: + """Test active_at behavior with lifetime.active values.""" + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = lifetime_active + + connection = ComponentConnection( + source=ComponentId(1), + destination=ComponentId(2), + operational_lifetime=mock_lifetime, + ) + + now = datetime.now(timezone.utc) + assert connection.is_operational_at(now) == lifetime_active + mock_lifetime.is_operational_at.assert_called_once_with(now) + + +@patch("frequenz.client.microgrid.component._connection.datetime") +@pytest.mark.parametrize( + "lifetime_active", [True, False], ids=["operational", "not-operational"] +) +def test_is_operational_now(mock_datetime: Mock, lifetime_active: bool) -> None: + """Test if the connection is operational at the current time.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_datetime.now.side_effect = lambda tz: now.replace(tzinfo=tz) + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = lifetime_active + + connection = ComponentConnection( + source=ComponentId(1), + destination=ComponentId(2), + operational_lifetime=mock_lifetime, + ) + + assert connection.is_operational_now() is lifetime_active + mock_lifetime.is_operational_at.assert_called_once_with(now) + mock_datetime.now.assert_called_once_with(timezone.utc) diff --git a/tests/microgrid/electrical_components/test_connection_proto.py b/tests/microgrid/electrical_components/test_connection_proto.py new file mode 100644 index 00000000..bad7eb67 --- /dev/null +++ b/tests/microgrid/electrical_components/test_connection_proto.py @@ -0,0 +1,185 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests conversion from protobuf messages to ComponentConnection.""" + +import logging +from datetime import datetime, timezone +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from frequenz.api.common.v1alpha8.microgrid import lifetime_pb2 +from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( + electrical_components_pb2, +) +from frequenz.client.common.microgrid.components import ComponentId +from google.protobuf import timestamp_pb2 + +from frequenz.client.microgrid.component import ComponentConnection +from frequenz.client.microgrid.component._connection_proto import ( + component_connection_from_proto, + component_connection_from_proto_with_issues, +) + + +@pytest.mark.parametrize( + "proto_data, expected_minor_issues", + [ + pytest.param( + { + "source_electrical_component_id": 1, + "destination_electrical_component_id": 2, + "has_lifetime": True, + }, + [], + id="full", + ), + pytest.param( + { + "source_electrical_component_id": 1, + "destination_electrical_component_id": 2, + "has_lifetime": False, + }, + ["missing operational lifetime, considering it always operational"], + id="no_lifetime", + ), + ], +) +def test_success(proto_data: dict[str, Any], expected_minor_issues: list[str]) -> None: + """Test successful conversion from protobuf message to ComponentConnection.""" + proto = electrical_components_pb2.ElectricalComponentConnection( + source_electrical_component_id=proto_data["source_electrical_component_id"], + destination_electrical_component_id=proto_data[ + "destination_electrical_component_id" + ], + ) + + if proto_data["has_lifetime"]: + now = datetime.now(timezone.utc) + start_time = timestamp_pb2.Timestamp() + start_time.FromDatetime(now) + lifetime = lifetime_pb2.Lifetime() + lifetime.start_timestamp.CopyFrom(start_time) + proto.operational_lifetime.CopyFrom(lifetime) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + connection = component_connection_from_proto_with_issues( + proto, + major_issues=major_issues, + minor_issues=minor_issues, + ) + + assert connection is not None + assert not major_issues + assert minor_issues == expected_minor_issues + assert connection.source == ComponentId( + proto_data["source_electrical_component_id"] + ) + assert connection.destination == ComponentId( + proto_data["destination_electrical_component_id"] + ) + + +def test_error_same_ids() -> None: + """Test proto conversion with same source and destination returns None.""" + proto = electrical_components_pb2.ElectricalComponentConnection( + source_electrical_component_id=1, destination_electrical_component_id=1 + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + conn = component_connection_from_proto_with_issues( + proto, + major_issues=major_issues, + minor_issues=minor_issues, + ) + + assert conn is None + assert major_issues == [ + "connection ignored: source and destination are the same (CID1)" + ] + assert not minor_issues + + +@patch( + "frequenz.client.microgrid.component._connection_proto.lifetime_from_proto", + autospec=True, +) +def test_invalid_lifetime(mock_lifetime_from_proto: Mock) -> None: + """Test proto conversion with invalid lifetime data.""" + mock_lifetime_from_proto.side_effect = ValueError("Invalid lifetime") + + proto = electrical_components_pb2.ElectricalComponentConnection( + source_electrical_component_id=1, destination_electrical_component_id=2 + ) + now = datetime.now(timezone.utc) + start_time = timestamp_pb2.Timestamp() + start_time.FromDatetime(now) + lifetime = lifetime_pb2.Lifetime() + lifetime.start_timestamp.CopyFrom(start_time) + proto.operational_lifetime.CopyFrom(lifetime) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + connection = component_connection_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert connection is not None + assert connection.source == ComponentId(1) + assert connection.destination == ComponentId(2) + assert major_issues == [ + "invalid operational lifetime (Invalid lifetime), considering it as missing " + "(i.e. always operational)" + ] + assert not minor_issues + mock_lifetime_from_proto.assert_called_once_with(proto.operational_lifetime) + + +@patch( + "frequenz.client.microgrid.component._connection_proto." + "component_connection_from_proto_with_issues", + autospec=True, +) +def test_issues_logging( + mock_from_proto_with_issues: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test collection and logging of issues during proto conversion.""" + caplog.set_level("DEBUG") # Ensure we capture DEBUG level messages + + # mypy needs the explicit return + def _fake_from_proto_with_issues( # pylint: disable=useless-return + _: electrical_components_pb2.ElectricalComponentConnection, + *, + major_issues: list[str], + minor_issues: list[str], + ) -> ComponentConnection | None: + """Fake function to simulate conversion and logging.""" + major_issues.append("fake major issue") + minor_issues.append("fake minor issue") + return None + + mock_from_proto_with_issues.side_effect = _fake_from_proto_with_issues + + mock_proto = Mock( + name="proto", spec=electrical_components_pb2.ElectricalComponentConnection + ) + connection = component_connection_from_proto(mock_proto) + + assert connection is None + assert caplog.record_tuples == [ + ( + "frequenz.client.microgrid.component._connection_proto", + logging.WARNING, + "Found issues in component connection: fake major issue | " + f"Protobuf message:\n{mock_proto}", + ), + ( + "frequenz.client.microgrid.component._connection_proto", + logging.DEBUG, + "Found minor issues in component connection: fake minor issue | " + f"Protobuf message:\n{mock_proto}", + ), + ] From 84542fb25049b5663d6254882867a65d93bb73ae Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 12 Jun 2026 09:11:11 +0000 Subject: [PATCH 2/5] Rename component connection to electrical component connection Signed-off-by: Leandro Lucarella --- .../_connection_proto.py | 30 +++++++++---------- ...py => _electrical_component_connection.py} | 24 +++++++-------- 2 files changed, 27 insertions(+), 27 deletions(-) rename src/frequenz/client/common/microgrid/electrical_components/{_connection.py => _electrical_component_connection.py} (70%) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py b/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py index c8bb1013..018d72c9 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Loading of ComponentConnection objects from protobuf messages.""" +"""Loading of ElectricalComponentConnection objects from protobuf messages.""" import logging @@ -12,31 +12,31 @@ from .._lifetime import Lifetime from .._lifetime_proto import lifetime_from_proto -from ._connection import ComponentConnection +from ._electrical_component_connection import ElectricalComponentConnection _logger = logging.getLogger(__name__) -def component_connection_from_proto( +def electrical_component_connection_from_proto( message: electrical_components_pb2.ElectricalComponentConnection, -) -> ComponentConnection | None: - """Create a `ComponentConnection` from a protobuf message.""" +) -> ElectricalComponentConnection | None: + """Create an `ElectricalComponentConnection` from a protobuf message.""" major_issues: list[str] = [] minor_issues: list[str] = [] - connection = component_connection_from_proto_with_issues( + connection = electrical_component_connection_from_proto_with_issues( message, major_issues=major_issues, minor_issues=minor_issues ) if major_issues: _logger.warning( - "Found issues in component connection: %s | Protobuf message:\n%s", + "Found issues in electrical component connection: %s | Protobuf message:\n%s", ", ".join(major_issues), message, ) if minor_issues: _logger.debug( - "Found minor issues in component connection: %s | Protobuf message:\n%s", + "Found minor issues in electrical component connection: %s | Protobuf message:\n%s", ", ".join(minor_issues), message, ) @@ -44,13 +44,13 @@ def component_connection_from_proto( return connection -def component_connection_from_proto_with_issues( +def electrical_component_connection_from_proto_with_issues( message: electrical_components_pb2.ElectricalComponentConnection, *, major_issues: list[str], minor_issues: list[str], -) -> ComponentConnection | None: - """Create a `ComponentConnection` from a protobuf message collecting issues. +) -> ElectricalComponentConnection | None: + """Create an `ElectricalComponentConnection` from a protobuf message collecting issues. This function is useful when you want to collect issues during the parsing of multiple connections, rather than logging them immediately. @@ -61,9 +61,9 @@ def component_connection_from_proto_with_issues( minor_issues: A list to collect minor issues found during parsing. Returns: - A `ComponentConnection` object created from the protobuf message, or - `None` if the protobuf message is completely invalid and a - `ComponentConnection` cannot be created. + An `ElectricalComponentConnection` object created from the protobuf message, + or `None` if the protobuf message is completely invalid and an + `ElectricalComponentConnection` cannot be created. """ source_component_id = ComponentId(message.source_electrical_component_id) destination_component_id = ComponentId(message.destination_electrical_component_id) @@ -77,7 +77,7 @@ def component_connection_from_proto_with_issues( message, major_issues=major_issues, minor_issues=minor_issues ) - return ComponentConnection( + return ElectricalComponentConnection( source=source_component_id, destination=destination_component_id, operational_lifetime=lifetime, diff --git a/src/frequenz/client/common/microgrid/electrical_components/_connection.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py similarity index 70% rename from src/frequenz/client/common/microgrid/electrical_components/_connection.py rename to src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py index 83bdf481..2b444fac 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_connection.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Component connection.""" +"""Electrical component connection.""" import dataclasses from datetime import datetime, timezone @@ -12,17 +12,17 @@ @dataclasses.dataclass(frozen=True, kw_only=True) -class ComponentConnection: - """A single electrical link between two components within a microgrid. +class ElectricalComponentConnection: + """A single electrical link between two electrical components within a microgrid. - A component connection represents the physical wiring as viewed from the grid - connection point, if one exists, or from the islanding point, in case of an islanded - microgrids. + An electrical component connection represents the physical wiring as viewed from the + grid connection point, if one exists, or from the islanding point, in case of an + islanded microgrids. Note: Physical Representation This object is not about data flow but rather about the physical - electrical connections between components. Therefore, the IDs for the - source and destination components correspond to the actual setup within + electrical connections between electrical components. Therefore, the IDs for the + source and destination electrical components correspond to the actual setup within the microgrid. Note: Direction @@ -39,23 +39,23 @@ class ComponentConnection: """ source: ComponentId - """The unique identifier of the component where the connection originates. + """The unique identifier of the electrical component where the connection originates. This is aligned with the direction of current flow away from the grid connection point, or in case of islands, away from the islanding point. """ destination: ComponentId - """The unique ID of the component where the connection terminates. + """The unique ID of the electrical component where the connection terminates. - This is the component towards which the current flows. + This is the electrical component towards which the current flows. """ operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime) """The operational lifetime of the connection.""" def __post_init__(self) -> None: - """Ensure that the source and destination components are different.""" + """Ensure that the source and destination electrical components are different.""" if self.source == self.destination: raise ValueError("Source and destination components must be different") From 09dbea3ab061055918b8224dc9a8b8cea1eccbc2 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 12 Jun 2026 09:16:26 +0000 Subject: [PATCH 3/5] Adapt electrical component connection modules to the common client layout Signed-off-by: Leandro Lucarella --- .../microgrid/electrical_components/__init__.py | 2 ++ .../_electrical_component_connection.py | 9 ++++----- .../proto/v1alpha8/__init__.py | 6 ++++++ .../v1alpha8/_electrical_component_connection.py} | 13 +++++++------ 4 files changed, 19 insertions(+), 11 deletions(-) rename src/frequenz/client/common/microgrid/electrical_components/{_connection_proto.py => proto/v1alpha8/_electrical_component_connection.py} (90%) diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index 1189b92e..2c7ff71a 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -18,6 +18,7 @@ from ._crypto_miner import CryptoMiner from ._diagnostic_code import ElectricalComponentDiagnosticCode from ._electrical_component import ElectricalComponent +from ._electrical_component_connection import ElectricalComponentConnection from ._electrolyzer import Electrolyzer from ._ev_charger import ( AcEvCharger, @@ -75,6 +76,7 @@ "DcEvCharger", "ElectricalComponent", "ElectricalComponentCategory", + "ElectricalComponentConnection", "ElectricalComponentDiagnosticCode", "ElectricalComponentId", "ElectricalComponentStateCode", diff --git a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py index 2b444fac..44b943d4 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component_connection.py @@ -6,9 +6,8 @@ import dataclasses from datetime import datetime, timezone -from frequenz.client.common.microgrid.components import ComponentId - -from .._lifetime import Lifetime +from ...types import Lifetime +from ._ids import ElectricalComponentId @dataclasses.dataclass(frozen=True, kw_only=True) @@ -38,14 +37,14 @@ class ElectricalComponentConnection: when and how the microgrid infrastructure has been modified. """ - source: ComponentId + source: ElectricalComponentId """The unique identifier of the electrical component where the connection originates. This is aligned with the direction of current flow away from the grid connection point, or in case of islands, away from the islanding point. """ - destination: ComponentId + destination: ElectricalComponentId """The unique ID of the electrical component where the connection terminates. This is the electrical component towards which the current flows. diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py index d0f0b603..b2a2f042 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py @@ -13,10 +13,16 @@ electrical_component_state_code_from_proto, electrical_component_state_code_to_proto, ) +from ._electrical_component_connection import ( + electrical_component_connection_from_proto, + electrical_component_connection_from_proto_with_issues, +) __all__ = [ "electrical_component_category_from_proto", "electrical_component_category_to_proto", + "electrical_component_connection_from_proto", + "electrical_component_connection_from_proto_with_issues", "electrical_component_diagnostic_code_from_proto", "electrical_component_diagnostic_code_to_proto", "electrical_component_from_proto", diff --git a/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component_connection.py similarity index 90% rename from src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py rename to src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component_connection.py index 018d72c9..3c78edcb 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_connection_proto.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component_connection.py @@ -8,11 +8,10 @@ from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) -from frequenz.client.common.microgrid.components import ComponentId -from .._lifetime import Lifetime -from .._lifetime_proto import lifetime_from_proto -from ._electrical_component_connection import ElectricalComponentConnection +from .....types import Lifetime +from .....types.proto.v1alpha8 import lifetime_from_proto +from ... import ElectricalComponentConnection, ElectricalComponentId _logger = logging.getLogger(__name__) @@ -65,8 +64,10 @@ def electrical_component_connection_from_proto_with_issues( or `None` if the protobuf message is completely invalid and an `ElectricalComponentConnection` cannot be created. """ - source_component_id = ComponentId(message.source_electrical_component_id) - destination_component_id = ComponentId(message.destination_electrical_component_id) + source_component_id = ElectricalComponentId(message.source_electrical_component_id) + destination_component_id = ElectricalComponentId( + message.destination_electrical_component_id + ) if source_component_id == destination_component_id: major_issues.append( f"connection ignored: source and destination are the same ({source_component_id})", From 54220e2334f8d6a9f263c0d78eb8186615c4990c Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 12 Jun 2026 09:55:08 +0000 Subject: [PATCH 4/5] Adapt electrical component connection tests to the common client layout Signed-off-by: Leandro Lucarella --- .../test_electrical_component_connection.py} | 54 ++++++++++--------- ...> test_electrical_component_connection.py} | 51 +++++++++++------- 2 files changed, 61 insertions(+), 44 deletions(-) rename tests/microgrid/electrical_components/{test_connection_proto.py => proto/v1alpha8/test_electrical_component_connection.py} (72%) rename tests/microgrid/electrical_components/{test_connection.py => test_electrical_component_connection.py} (56%) diff --git a/tests/microgrid/electrical_components/test_connection_proto.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_connection.py similarity index 72% rename from tests/microgrid/electrical_components/test_connection_proto.py rename to tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_connection.py index bad7eb67..218dc2bc 100644 --- a/tests/microgrid/electrical_components/test_connection_proto.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_connection.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Tests conversion from protobuf messages to ComponentConnection.""" +"""Tests conversion from protobuf messages to ElectricalComponentConnection.""" import logging from datetime import datetime, timezone @@ -13,13 +13,15 @@ from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) -from frequenz.client.common.microgrid.components import ComponentId from google.protobuf import timestamp_pb2 -from frequenz.client.microgrid.component import ComponentConnection -from frequenz.client.microgrid.component._connection_proto import ( - component_connection_from_proto, - component_connection_from_proto_with_issues, +from frequenz.client.common.microgrid.electrical_components import ( + ElectricalComponentConnection, + ElectricalComponentId, +) +from frequenz.client.common.microgrid.electrical_components.proto.v1alpha8 import ( + electrical_component_connection_from_proto, + electrical_component_connection_from_proto_with_issues, ) @@ -47,7 +49,7 @@ ], ) def test_success(proto_data: dict[str, Any], expected_minor_issues: list[str]) -> None: - """Test successful conversion from protobuf message to ComponentConnection.""" + """Test successful conversion to ElectricalComponentConnection.""" proto = electrical_components_pb2.ElectricalComponentConnection( source_electrical_component_id=proto_data["source_electrical_component_id"], destination_electrical_component_id=proto_data[ @@ -65,7 +67,7 @@ def test_success(proto_data: dict[str, Any], expected_minor_issues: list[str]) - major_issues: list[str] = [] minor_issues: list[str] = [] - connection = component_connection_from_proto_with_issues( + connection = electrical_component_connection_from_proto_with_issues( proto, major_issues=major_issues, minor_issues=minor_issues, @@ -74,23 +76,23 @@ def test_success(proto_data: dict[str, Any], expected_minor_issues: list[str]) - assert connection is not None assert not major_issues assert minor_issues == expected_minor_issues - assert connection.source == ComponentId( + assert connection.source == ElectricalComponentId( proto_data["source_electrical_component_id"] ) - assert connection.destination == ComponentId( + assert connection.destination == ElectricalComponentId( proto_data["destination_electrical_component_id"] ) def test_error_same_ids() -> None: - """Test proto conversion with same source and destination returns None.""" + """Test proto conversion with the same source and destination returns None.""" proto = electrical_components_pb2.ElectricalComponentConnection( source_electrical_component_id=1, destination_electrical_component_id=1 ) major_issues: list[str] = [] minor_issues: list[str] = [] - conn = component_connection_from_proto_with_issues( + conn = electrical_component_connection_from_proto_with_issues( proto, major_issues=major_issues, minor_issues=minor_issues, @@ -104,7 +106,8 @@ def test_error_same_ids() -> None: @patch( - "frequenz.client.microgrid.component._connection_proto.lifetime_from_proto", + "frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "_electrical_component_connection.lifetime_from_proto", autospec=True, ) def test_invalid_lifetime(mock_lifetime_from_proto: Mock) -> None: @@ -123,13 +126,13 @@ def test_invalid_lifetime(mock_lifetime_from_proto: Mock) -> None: major_issues: list[str] = [] minor_issues: list[str] = [] - connection = component_connection_from_proto_with_issues( + connection = electrical_component_connection_from_proto_with_issues( proto, major_issues=major_issues, minor_issues=minor_issues ) assert connection is not None - assert connection.source == ComponentId(1) - assert connection.destination == ComponentId(2) + assert connection.source == ElectricalComponentId(1) + assert connection.destination == ElectricalComponentId(2) assert major_issues == [ "invalid operational lifetime (Invalid lifetime), considering it as missing " "(i.e. always operational)" @@ -139,8 +142,9 @@ def test_invalid_lifetime(mock_lifetime_from_proto: Mock) -> None: @patch( - "frequenz.client.microgrid.component._connection_proto." - "component_connection_from_proto_with_issues", + "frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "_electrical_component_connection." + "electrical_component_connection_from_proto_with_issues", autospec=True, ) def test_issues_logging( @@ -155,7 +159,7 @@ def _fake_from_proto_with_issues( # pylint: disable=useless-return *, major_issues: list[str], minor_issues: list[str], - ) -> ComponentConnection | None: + ) -> ElectricalComponentConnection | None: """Fake function to simulate conversion and logging.""" major_issues.append("fake major issue") minor_issues.append("fake minor issue") @@ -166,20 +170,22 @@ def _fake_from_proto_with_issues( # pylint: disable=useless-return mock_proto = Mock( name="proto", spec=electrical_components_pb2.ElectricalComponentConnection ) - connection = component_connection_from_proto(mock_proto) + connection = electrical_component_connection_from_proto(mock_proto) assert connection is None assert caplog.record_tuples == [ ( - "frequenz.client.microgrid.component._connection_proto", + "frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "_electrical_component_connection", logging.WARNING, - "Found issues in component connection: fake major issue | " + "Found issues in electrical component connection: fake major issue | " f"Protobuf message:\n{mock_proto}", ), ( - "frequenz.client.microgrid.component._connection_proto", + "frequenz.client.common.microgrid.electrical_components.proto.v1alpha8." + "_electrical_component_connection", logging.DEBUG, - "Found minor issues in component connection: fake minor issue | " + "Found minor issues in electrical component connection: fake minor issue | " f"Protobuf message:\n{mock_proto}", ), ] diff --git a/tests/microgrid/electrical_components/test_connection.py b/tests/microgrid/electrical_components/test_electrical_component_connection.py similarity index 56% rename from tests/microgrid/electrical_components/test_connection.py rename to tests/microgrid/electrical_components/test_electrical_component_connection.py index 1c4d1723..eeb519bb 100644 --- a/tests/microgrid/electrical_components/test_connection.py +++ b/tests/microgrid/electrical_components/test_electrical_component_connection.py @@ -1,42 +1,50 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Tests for ComponentConnection class and related functionality.""" +"""Tests for ElectricalComponentConnection class and related functionality.""" from datetime import datetime, timezone from unittest.mock import Mock, patch import pytest -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid import Lifetime -from frequenz.client.microgrid.component import ComponentConnection +from frequenz.client.common.microgrid.electrical_components import ( + ElectricalComponentConnection, + ElectricalComponentId, +) +from frequenz.client.common.types import Lifetime def test_creation() -> None: - """Test basic ComponentConnection creation and validation.""" + """Test basic ElectricalComponentConnection creation and validation.""" now = datetime.now(timezone.utc) lifetime = Lifetime(start=now) - connection = ComponentConnection( - source=ComponentId(1), destination=ComponentId(2), operational_lifetime=lifetime + connection = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), + operational_lifetime=lifetime, ) - assert connection.source == ComponentId(1) - assert connection.destination == ComponentId(2) + assert connection.source == ElectricalComponentId(1) + assert connection.destination == ElectricalComponentId(2) assert connection.operational_lifetime == lifetime def test_validation() -> None: - """Test validation of source and destination components.""" + """Test validation of source and destination electrical components.""" with pytest.raises( ValueError, match="Source and destination components must be different" ): - ComponentConnection(source=ComponentId(1), destination=ComponentId(1)) + ElectricalComponentConnection( + source=ElectricalComponentId(1), destination=ElectricalComponentId(1) + ) def test_str() -> None: - """Test string representation of ComponentConnection.""" - connection = ComponentConnection(source=ComponentId(1), destination=ComponentId(2)) + """Test string representation of ElectricalComponentConnection.""" + connection = ElectricalComponentConnection( + source=ElectricalComponentId(1), destination=ElectricalComponentId(2) + ) assert str(connection) == "CID1->CID2" @@ -48,9 +56,9 @@ def test_is_operational_at(lifetime_active: bool) -> None: mock_lifetime = Mock(spec=Lifetime) mock_lifetime.is_operational_at.return_value = lifetime_active - connection = ComponentConnection( - source=ComponentId(1), - destination=ComponentId(2), + connection = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), operational_lifetime=mock_lifetime, ) @@ -59,7 +67,10 @@ def test_is_operational_at(lifetime_active: bool) -> None: mock_lifetime.is_operational_at.assert_called_once_with(now) -@patch("frequenz.client.microgrid.component._connection.datetime") +@patch( + "frequenz.client.common.microgrid.electrical_components." + "_electrical_component_connection.datetime" +) @pytest.mark.parametrize( "lifetime_active", [True, False], ids=["operational", "not-operational"] ) @@ -70,9 +81,9 @@ def test_is_operational_now(mock_datetime: Mock, lifetime_active: bool) -> None: mock_lifetime = Mock(spec=Lifetime) mock_lifetime.is_operational_at.return_value = lifetime_active - connection = ComponentConnection( - source=ComponentId(1), - destination=ComponentId(2), + connection = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), operational_lifetime=mock_lifetime, ) From e817aced95878ffd0c4f673522b3beb1d4e54b24 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 12 Jun 2026 10:06:02 +0000 Subject: [PATCH 5/5] Add electrical component connection tests Signed-off-by: Leandro Lucarella --- .../test_electrical_component_connection.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/microgrid/electrical_components/test_electrical_component_connection.py b/tests/microgrid/electrical_components/test_electrical_component_connection.py index eeb519bb..0f8c2977 100644 --- a/tests/microgrid/electrical_components/test_electrical_component_connection.py +++ b/tests/microgrid/electrical_components/test_electrical_component_connection.py @@ -3,7 +3,7 @@ """Tests for ElectricalComponentConnection class and related functionality.""" -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import Mock, patch import pytest @@ -48,6 +48,52 @@ def test_str() -> None: assert str(connection) == "CID1->CID2" +def test_equality_and_hash() -> None: + """Test equality and hashing of the frozen ElectricalComponentConnection.""" + lifetime = Lifetime(start=datetime(2025, 1, 1, tzinfo=timezone.utc)) + connection = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), + operational_lifetime=lifetime, + ) + same = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), + operational_lifetime=lifetime, + ) + different = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(3), + operational_lifetime=lifetime, + ) + + assert connection == same + assert connection != different + assert hash(connection) == hash(same) + assert {connection, same} == {connection} + + +def test_is_operational_at_boundaries() -> None: + """Test is_operational_at boundary semantics with a concrete lifetime.""" + start = datetime(2025, 1, 1, tzinfo=timezone.utc) + end = datetime(2025, 12, 31, tzinfo=timezone.utc) + connection = ElectricalComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), + operational_lifetime=Lifetime(start=start, end=end), + ) + + before = start - timedelta(seconds=1) + middle = datetime(2025, 6, 1, tzinfo=timezone.utc) + after = end + timedelta(seconds=1) + + assert connection.is_operational_at(before) is False + assert connection.is_operational_at(start) is True + assert connection.is_operational_at(middle) is True + assert connection.is_operational_at(end) is True + assert connection.is_operational_at(after) is False + + @pytest.mark.parametrize( "lifetime_active", [True, False], ids=["operational", "not-operational"] )