diff --git a/pyproject.toml b/pyproject.toml index 20a6156..cdedc0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,9 @@ classifiers = [ ] requires-python = ">= 3.11, < 4" dependencies = [ - "typing-extensions >= 4.6.0, < 5", + "typing-extensions >= 4.13.0, < 5", "frequenz-api-common @ git+https://github.com/frequenz-floss/frequenz-api-common.git@2e89add6a16d42b23612f0f791a499919f3738ed", + "frequenz-core >= 1.0.2, < 2", ] dynamic = ["version"] @@ -149,8 +150,11 @@ filterwarnings = [ "error", "once::DeprecationWarning", "once::PendingDeprecationWarning", - # We use a raw string (single quote) to avoid the need to escape special - # chars as this is a regex + # We ignore warnings about protobuf gencode version being one version older + # than the current version, as this is supported by protobuf, and we expect to + # have such cases. If we go too far, we will get a proper error anyways. + # We use a raw string (single quotes) to avoid the need to escape special + # characters as this is a regex. 'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning', ] testpaths = ["tests", "src"] diff --git a/src/frequenz/client/common/enum_proto.py b/src/frequenz/client/common/enum_proto.py new file mode 100644 index 0000000..789f185 --- /dev/null +++ b/src/frequenz/client/common/enum_proto.py @@ -0,0 +1,76 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Conversion of protobuf int enums to Python enums.""" + +import enum +from typing import Literal, TypeVar, overload + +EnumT = TypeVar("EnumT", bound=enum.Enum) +"""A type variable that is bound to an enum.""" + + +@overload +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: Literal[False] +) -> EnumT: ... + + +@overload +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: Literal[True] = True +) -> EnumT | int: ... + + +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: bool = True +) -> EnumT | int: + """Convert a protobuf int enum value to a python enum. + + Example: + ```python + import enum + + from proto import proto_pb2 # Just an example. pylint: disable=import-error + + @enum.unique + class SomeEnum(enum.Enum): + # These values should match the protobuf enum values. + UNSPECIFIED = 0 + SOME_VALUE = 1 + + enum_value = enum_from_proto(proto_pb2.SomeEnum.SOME_ENUM_SOME_VALUE, SomeEnum) + # -> SomeEnum.SOME_VALUE + + enum_value = enum_from_proto(42, SomeEnum) + # -> 42 + + enum_value = enum_from_proto( + proto_pb2.SomeEnum.SOME_ENUM_UNKNOWN_VALUE, SomeEnum, allow_invalid=False + ) + # -> ValueError + ``` + + Args: + value: The protobuf int enum value. + enum_type: The python enum type to convert to. + allow_invalid: If `True`, return the value as an `int` if the value is not + a valid member of the enum (this allows for forward-compatibility with new + enum values defined in the protocol but not added to the Python enum yet). + If `False`, raise a `ValueError` if the value is not a valid member of the + enum. + + Returns: + The resulting python enum value if the protobuf value is known, otherwise + the input value converted to a plain `int`. + + Raises: + ValueError: If `allow_invalid` is `False` and the value is not a valid member + of the enum. + """ + try: + return enum_type(value) + except ValueError: + if allow_invalid: + return value + raise diff --git a/src/frequenz/client/common/metric/__init__.py b/src/frequenz/client/common/metric/__init__.py index ba276c3..168ec9f 100644 --- a/src/frequenz/client/common/metric/__init__.py +++ b/src/frequenz/client/common/metric/__init__.py @@ -3,16 +3,18 @@ """Module to define the metrics used with the common client.""" -from enum import Enum +import enum from typing import Self # pylint: disable=no-name-in-module from frequenz.api.common.v1.metrics.metric_sample_pb2 import Metric as PBMetric +from typing_extensions import deprecated # pylint: enable=no-name-in-module -class Metric(Enum): +@enum.unique +class Metric(enum.Enum): """List of supported metrics. AC energy metrics information: @@ -140,6 +142,7 @@ class Metric(Enum): SENSOR_IRRADIANCE = PBMetric.METRIC_SENSOR_IRRADIANCE @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto(cls, metric: PBMetric.ValueType) -> Self: """Convert a protobuf Metric value to Metric enum. diff --git a/src/frequenz/client/common/microgrid/__init__.py b/src/frequenz/client/common/microgrid/__init__.py index d24d3f3..0787943 100644 --- a/src/frequenz/client/common/microgrid/__init__.py +++ b/src/frequenz/client/common/microgrid/__init__.py @@ -2,3 +2,17 @@ # Copyright © 2023 Frequenz Energy-as-a-Service GmbH """Frequenz microgrid definition.""" + +from typing import final + +from frequenz.core.id import BaseId + + +@final +class EnterpriseId(BaseId, str_prefix="EID"): + """A unique identifier for an enterprise account.""" + + +@final +class MicrogridId(BaseId, str_prefix="MID"): + """A unique identifier for a microgrid.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index 4110a0d..3ea317d 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -2,9 +2,11 @@ # Copyright © 2022 Frequenz Energy-as-a-Service GmbH """Defines the electrical components that can be used in a microgrid.""" + from __future__ import annotations -from enum import Enum +import enum +from typing import final # pylint: disable=no-name-in-module from frequenz.api.common.v1.microgrid.electrical_components.electrical_components_pb2 import ( @@ -16,11 +18,19 @@ from frequenz.api.common.v1.microgrid.electrical_components.electrical_components_pb2 import ( ElectricalComponentStateCode as PBElectricalComponentStateCode, ) +from frequenz.core.id import BaseId +from typing_extensions import deprecated # pylint: enable=no-name-in-module -class ElectricalComponentCategory(Enum): +@final +class ElectricalComponentId(BaseId, str_prefix="CID"): + """A unique identifier for a microgrid electrical component.""" + + +@enum.unique +class ElectricalComponentCategory(enum.Enum): """Possible types of microgrid electrical component.""" UNSPECIFIED = ( @@ -67,20 +77,38 @@ class ElectricalComponentCategory(Enum): """A relay, used for switching electrical circuits on and off.""" PRECHARGER = PBElectricalComponentCategory.ELECTRICAL_COMPONENT_CATEGORY_PRECHARGER - """A precharger, used for preparing electrical circuits for switching on.""" + """A precharge module. + + Precharging involves gradually ramping up the DC voltage to prevent any + potential damage to sensitive electrical components like capacitors. + + While many inverters and batteries come equipped with in-built precharging + mechanisms, some may lack this feature. In such cases, we need to use + external precharging modules. + """ FUSE = PBElectricalComponentCategory.ELECTRICAL_COMPONENT_CATEGORY_FUSE """A fuse, used for protecting electrical circuits from overcurrent.""" - TRANSFORMER = ( + VOLTAGE_TRANSFORMER = ( PBElectricalComponentCategory.ELECTRICAL_COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER ) - """A transformer, used for changing the voltage of electrical circuits.""" + """A voltage transformer. + + Voltage transformers are used to step up or step down the voltage, keeping + the power somewhat constant by increasing or decreasing the current. If voltage is + stepped up, current is stepped down, and vice versa. + + Note: + Voltage transformers have efficiency losses, so the output power is + always less than the input power. + """ HVAC = PBElectricalComponentCategory.ELECTRICAL_COMPONENT_CATEGORY_HVAC """A heating, ventilation, and air conditioning (HVAC) system.""" @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto( cls, component_category: PBElectricalComponentCategory.ValueType ) -> ElectricalComponentCategory: @@ -105,7 +133,8 @@ def to_proto(self) -> PBElectricalComponentCategory.ValueType: return self.value -class ElectricalComponentStateCode(Enum): +@enum.unique +class ElectricalComponentStateCode(enum.Enum): """All possible states of a microgrid electrical component.""" UNSPECIFIED = ( @@ -207,6 +236,7 @@ class ElectricalComponentStateCode(Enum): """The precharger circuit is closed, allowing full current to flow to the main circuit.""" @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto( cls, component_state: PBElectricalComponentStateCode.ValueType ) -> ElectricalComponentStateCode: @@ -231,7 +261,8 @@ def to_proto(self) -> PBElectricalComponentStateCode.ValueType: return self.value -class ElectricalComponentDiagnosticCode(Enum): +@enum.unique +class ElectricalComponentDiagnosticCode(enum.Enum): """All diagnostics that can occur across electrical component categories.""" UNSPECIFIED = ( @@ -426,6 +457,7 @@ class ElectricalComponentDiagnosticCode(Enum): times.""" @classmethod + @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") def from_proto( cls, component_error_code: PBElectricalComponentDiagnosticCode.ValueType ) -> ElectricalComponentDiagnosticCode: diff --git a/src/frequenz/client/common/microgrid/sensors.py b/src/frequenz/client/common/microgrid/sensors.py new file mode 100644 index 0000000..0fca6a2 --- /dev/null +++ b/src/frequenz/client/common/microgrid/sensors.py @@ -0,0 +1,13 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Microgrid sensors.""" + +from typing import final + +from frequenz.core.id import BaseId + + +@final +class SensorId(BaseId, str_prefix="SID"): + """A unique identifier for a microgrid sensor.""" diff --git a/tests/microgrid/test_ids.py b/tests/microgrid/test_ids.py new file mode 100644 index 0000000..c54a8c7 --- /dev/null +++ b/tests/microgrid/test_ids.py @@ -0,0 +1,28 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for microgrid-related IDs.""" + +import pytest +from frequenz.core.id import BaseId + +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId +from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId +from frequenz.client.common.microgrid.sensors import SensorId + + +@pytest.mark.parametrize( + "id_class, prefix", + [ + (EnterpriseId, "EID"), + (MicrogridId, "MID"), + (ElectricalComponentId, "CID"), + (SensorId, "SID"), + ], +) +def test_string_representation(id_class: type[BaseId], prefix: str) -> None: + """Test string representation of IDs.""" + _id = id_class(123) + + assert str(_id) == f"{prefix}123" + assert repr(_id) == f"{id_class.__name__}(123)" diff --git a/tests/test_enum_proto.py b/tests/test_enum_proto.py new file mode 100644 index 0000000..a6ef59e --- /dev/null +++ b/tests/test_enum_proto.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for enum_from_proto utility.""" + +import enum + +import pytest + +from frequenz.client.common.enum_proto import enum_from_proto + + +class _TestEnum(enum.Enum): + """A test enum for enum_from_proto tests.""" + + ZERO = 0 + ONE = 1 + TWO = 2 + + +@pytest.mark.parametrize("enum_member", _TestEnum) +def test_valid_allow_invalid(enum_member: _TestEnum) -> None: + """Test conversion of valid enum values.""" + assert enum_from_proto(enum_member.value, _TestEnum) == enum_member + assert ( + enum_from_proto(enum_member.value, _TestEnum, allow_invalid=True) == enum_member + ) + + +@pytest.mark.parametrize("value", [42, -1]) +def test_invalid_allow_invalid(value: int) -> None: + """Test unknown values with allow_invalid=True (default).""" + assert enum_from_proto(value, _TestEnum) == value + assert enum_from_proto(value, _TestEnum, allow_invalid=True) == value + + +@pytest.mark.parametrize("enum_member", _TestEnum) +def test_valid_disallow_invalid(enum_member: _TestEnum) -> None: + """Test unknown values with allow_invalid=False (should raise ValueError).""" + assert ( + enum_from_proto(enum_member.value, _TestEnum, allow_invalid=False) + == enum_member + ) + + +@pytest.mark.parametrize("value", [42, -1]) +def test_invalid_disallow(value: int) -> None: + """Test unknown values with allow_invalid=False (should raise ValueError).""" + with pytest.raises(ValueError, match=rf"^{value} is not a valid _TestEnum$"): + enum_from_proto(value, _TestEnum, allow_invalid=False)