Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- The metrics and components enums `.from_proto()` are deprecated, please use the new `enum_from_proto()` instead.
- Some minimum dependencies have been bumped, you might need to update your minimum dependencies too:

* `frequenz-api-common` to 0.6.1
* `frequenz-core` to 1.0.2

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
- A new module `frequenz.client.common.enum_proto` has been added, which provides a generic `enum_from_proto()` function to convert protobuf enums to Python enums.
- The `frequenz.client.common.microgrid.ComponentCategory` was extended to include the missing categories.

## Bug Fixes

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ classifiers = [
requires-python = ">= 3.11, < 4"
dependencies = [
"typing-extensions >= 4.13.0, < 5",
"frequenz-api-common >= 0.6.0, < 7",
"frequenz-core >= 1.0.0, < 2",
"frequenz-api-common >= 0.6.1, < 7",
"frequenz-core >= 1.0.2, < 2",
]
dynamic = ["version"]

Expand Down
76 changes: 76 additions & 0 deletions src/frequenz/client/common/enum_proto.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions src/frequenz/client/common/metric/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
61 changes: 57 additions & 4 deletions src/frequenz/client/common/microgrid/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from enum import Enum
import enum
from typing import final

# pylint: disable=no-name-in-module
Expand All @@ -19,6 +19,7 @@
ComponentStateCode as PBComponentStateCode,
)
from frequenz.core.id import BaseId
from typing_extensions import deprecated

# pylint: enable=no-name-in-module

Expand All @@ -28,7 +29,8 @@ class ComponentId(BaseId, str_prefix="CID"):
"""A unique identifier for a microgrid component."""


class ComponentCategory(Enum):
@enum.unique
class ComponentCategory(enum.Enum):
"""Possible types of microgrid component."""

UNSPECIFIED = PBComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED
Expand All @@ -47,16 +49,63 @@ class ComponentCategory(Enum):
INVERTER = PBComponentCategory.COMPONENT_CATEGORY_INVERTER
"""An electricity generator, with batteries or solar energy."""

CONVERTER = PBComponentCategory.COMPONENT_CATEGORY_CONVERTER
"""A DC-DC converter."""

BATTERY = PBComponentCategory.COMPONENT_CATEGORY_BATTERY
"""A storage system for electrical energy, used by inverters."""

EV_CHARGER = PBComponentCategory.COMPONENT_CATEGORY_EV_CHARGER
"""A station for charging electrical vehicles."""

CRYPTO_MINER = PBComponentCategory.COMPONENT_CATEGORY_CRYPTO_MINER
"""A crypto miner."""

ELECTROLYZER = PBComponentCategory.COMPONENT_CATEGORY_ELECTROLYZER
"""An electrolyzer for converting water into hydrogen and oxygen."""

CHP = PBComponentCategory.COMPONENT_CATEGORY_CHP
"""A heat and power combustion plant (CHP stands for combined heat and power)."""

RELAY = PBComponentCategory.COMPONENT_CATEGORY_RELAY
"""A relay.

Relays generally have two states: open (connected) and closed (disconnected).
They are generally placed in front of a component, e.g., an inverter, to
control whether the component is connected to the grid or not.
"""

PRECHARGER = PBComponentCategory.COMPONENT_CATEGORY_PRECHARGER
"""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 = PBComponentCategory.COMPONENT_CATEGORY_FUSE
"""A fuse."""

VOLTAGE_TRANSFORMER = PBComponentCategory.COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER
"""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 = PBComponentCategory.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: PBComponentCategory.ValueType
) -> ComponentCategory:
Expand All @@ -81,7 +130,8 @@ def to_proto(self) -> PBComponentCategory.ValueType:
return self.value


class ComponentStateCode(Enum):
@enum.unique
class ComponentStateCode(enum.Enum):
"""All possible states of a microgrid component."""

UNSPECIFIED = PBComponentStateCode.COMPONENT_STATE_CODE_UNSPECIFIED
Expand Down Expand Up @@ -161,6 +211,7 @@ class ComponentStateCode(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: PBComponentStateCode.ValueType
) -> ComponentStateCode:
Expand All @@ -185,7 +236,8 @@ def to_proto(self) -> PBComponentStateCode.ValueType:
return self.value


class ComponentErrorCode(Enum):
@enum.unique
class ComponentErrorCode(enum.Enum):
"""All possible errors that can occur across all microgrid component categories."""

UNSPECIFIED = PBComponentErrorCode.COMPONENT_ERROR_CODE_UNSPECIFIED
Expand Down Expand Up @@ -338,6 +390,7 @@ class ComponentErrorCode(Enum):
times."""

@classmethod
@deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.")
def from_proto(
cls, component_error_code: PBComponentErrorCode.ValueType
) -> ComponentErrorCode:
Expand Down
50 changes: 50 additions & 0 deletions tests/test_enum_proto.py
Original file line number Diff line number Diff line change
@@ -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)
Loading