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
15 changes: 3 additions & 12 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
# Frequenz Microgrid API Client Release Notes

## Summary

<!-- Here goes a general summary of what this release is about -->

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->

## Bug Fixes
* `Component` classes now expose two new functions to check the operational mode:

<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
- `provides_telemetry()`: whether the component provides telemetry data
- `accepts_control()`: whether the component accepts control commands
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ classifiers = [
]
requires-python = ">= 3.11, < 4"
dependencies = [
"frequenz-api-common >= 0.8.2, < 1.0.0",
"frequenz-api-common >= 0.8.4, < 1.0.0",
"frequenz-api-microgrid >= 0.18.0, < 1",
"frequenz-channels >= 1.6.1, < 2.0.0",
"frequenz-client-base >= 0.10.0, < 0.12.0",
Expand Down
40 changes: 40 additions & 0 deletions src/frequenz/client/microgrid/component/_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class Component: # pylint: disable=too-many-instance-attributes
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
"""The operational lifetime of this component."""

_provides_telemetry: bool | None = None
"""Whether this component provides telemetry data, or `None` if unspecified."""

_accepts_control: bool | None = None
"""Whether this component accepts control commands, or `None` if unspecified."""

rated_bounds: Mapping[Metric | int, Bounds] = dataclasses.field(
default_factory=dict,
# dict is not hashable, so we don't use this field to calculate the hash. This
Expand Down Expand Up @@ -85,6 +91,40 @@ def __new__(cls, *_: Any, **__: Any) -> Self:
raise TypeError(f"Cannot instantiate {cls.__name__} directly")
return super().__new__(cls)

def provides_telemetry(self) -> bool:
"""Check whether this component provides telemetry data.

Returns:
Whether this component provides telemetry data.

Raises:
ValueError: If the operational mode is unspecified, so whether telemetry
is provided is unknown.
"""
if self._provides_telemetry is None:
raise ValueError(
f"operational mode of {self} is unspecified; "
"telemetry availability is unknown"
)
return self._provides_telemetry

def accepts_control(self) -> bool:
"""Check whether this component accepts control commands.

Returns:
Whether this component accepts control commands.

Raises:
ValueError: If the operational mode is unspecified, so whether control
commands are accepted is unknown.
"""
if self._accepts_control is None:
raise ValueError(
f"operational mode of {self} is unspecified; "
"control availability is unknown"
)
return self._accepts_control

def is_operational_at(self, timestamp: datetime) -> bool:
"""Check whether this component is operational at a specific timestamp.

Expand Down
59 changes: 59 additions & 0 deletions src/frequenz/client/microgrid/component/_component_proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@
# pylint: disable=too-many-arguments


_BOOLS_BY_OPERATIONAL_MODE: dict[int, tuple[bool | None, bool | None]] = {
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE: (
False,
False,
),
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY: (
True,
False,
),
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_ONLY: (
False,
True,
),
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_AND_TELEMETRY: (
True,
True,
),
}


def _operational_mode_to_bools(value: int) -> tuple[bool | None, bool | None]:
"""Map a protobuf operational mode to telemetry/control booleans.

Args:
value: A protobuf operational-mode enum value (an
`ELECTRICAL_COMPONENT_OPERATIONAL_MODE_*` constant).

Returns:
A `(provides_telemetry, accepts_control)` tuple, with both elements `None`
when the operational mode is unspecified or unrecognized.
"""
return _BOOLS_BY_OPERATIONAL_MODE.get(value, (None, None))


def component_from_proto(
message: electrical_components_pb2.ElectricalComponent,
) -> ComponentTypes:
Expand Down Expand Up @@ -117,6 +151,8 @@ class ComponentBaseData(NamedTuple):
lifetime: Lifetime
rated_bounds: dict[Metric | int, Bounds]
category_specific_info: dict[str, Any]
provides_telemetry: bool | None
accepts_control: bool | None
category_mismatched: bool = False


Expand Down Expand Up @@ -197,6 +233,7 @@ def component_base_from_proto_with_issues(
lifetime,
rated_bounds,
category_specific_info,
*_operational_mode_to_bools(message.operational_mode),
category_mismatched,
)

Expand Down Expand Up @@ -231,6 +268,8 @@ def component_from_proto_with_issues(
model_name=base_data.model_name,
category=base_data.category,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
category_specific_metadata=base_data.category_specific_info,
rated_bounds=base_data.rated_bounds,
)
Expand All @@ -245,6 +284,8 @@ def component_from_proto_with_issues(
model_name=base_data.model_name,
category=base_data.category,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
)
case (
Expand All @@ -267,6 +308,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
)
case ComponentCategory.BATTERY:
Expand All @@ -291,6 +334,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
)
case int():
Expand All @@ -302,6 +347,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
type=battery_type,
)
Expand Down Expand Up @@ -338,6 +385,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
)
case int():
Expand All @@ -351,6 +400,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
type=ev_charger_type,
)
Expand All @@ -368,6 +419,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
rated_fuse_current=rated_fuse_current,
)
Expand Down Expand Up @@ -405,6 +458,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
)
case int():
Expand All @@ -418,6 +473,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
type=inverter_type,
)
Expand All @@ -433,6 +490,8 @@ def component_from_proto_with_issues(
manufacturer=base_data.manufacturer,
model_name=base_data.model_name,
operational_lifetime=base_data.lifetime,
_provides_telemetry=base_data.provides_telemetry,
_accepts_control=base_data.accepts_control,
rated_bounds=base_data.rated_bounds,
primary_voltage=message.category_specific_info.power_transformer.primary,
secondary_voltage=message.category_specific_info.power_transformer.secondary,
Expand Down
31 changes: 31 additions & 0 deletions tests/component/component_proto/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ def default_component_base_data(
lifetime=DEFAULT_LIFETIME,
rated_bounds={Metric.AC_ENERGY_ACTIVE: Bounds(lower=0, upper=100)},
category_specific_info={},
provides_telemetry=True,
accepts_control=True,
category_mismatched=False,
)

Expand All @@ -74,6 +76,32 @@ def assert_base_data(base_data: ComponentBaseData, other: Component) -> None:
assert base_data.lifetime == other.operational_lifetime
assert base_data.rated_bounds == other.rated_bounds
assert base_data.category_specific_info == other.category_specific_metadata
# pylint: disable=protected-access
assert base_data.provides_telemetry == other._provides_telemetry
assert base_data.accepts_control == other._accepts_control
# pylint: enable=protected-access


_OPERATIONAL_MODE_BY_BOOLS: dict[
tuple[bool | None, bool | None],
electrical_components_pb2.ElectricalComponentOperationalMode.ValueType,
] = {
(None, None): (
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_UNSPECIFIED
),
(False, False): (
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE
),
(True, False): (
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY
),
(False, True): (
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_ONLY
),
(True, True): (
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_AND_TELEMETRY
),
}


def base_data_as_proto(
Expand All @@ -91,6 +119,9 @@ def base_data_as_proto(
if isinstance(base_data.category, int)
else int(base_data.category.value) # type: ignore[arg-type]
),
operational_mode=_OPERATIONAL_MODE_BY_BOOLS[
(base_data.provides_telemetry, base_data.accepts_control)
],
)
if base_data.lifetime:
lifetime_dict: dict[str, Timestamp] = {}
Expand Down
43 changes: 43 additions & 0 deletions tests/component/component_proto/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

"""Tests for protobuf conversion of the base/common part of Component objects."""

import pytest
from frequenz.api.common.v1alpha8.microgrid.electrical_components import (
electrical_components_pb2,
)
Expand All @@ -12,12 +13,54 @@
from frequenz.client.microgrid.component import ComponentCategory
from frequenz.client.microgrid.component._component_proto import (
ComponentBaseData,
_operational_mode_to_bools,
component_base_from_proto_with_issues,
)

from .conftest import base_data_as_proto


@pytest.mark.parametrize(
"proto_value, expected",
[
(
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_UNSPECIFIED,
(None, None),
),
(
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE,
(False, False),
),
(
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY,
(True, False),
),
(
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_ONLY,
(False, True),
),
(
electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_AND_TELEMETRY,
(True, True),
),
(999, (None, None)),
],
ids=[
"unspecified",
"inactive",
"telemetry-only",
"control-only",
"control-and-telemetry",
"unknown-int",
],
)
def test_operational_mode_to_bools(
proto_value: int, expected: tuple[bool | None, bool | None]
) -> None:
"""Test proto operational-mode maps to (provides_telemetry, accepts_control)."""
assert _operational_mode_to_bools(proto_value) == expected


def test_complete(default_component_base_data: ComponentBaseData) -> None:
"""Test parsing of a complete base component proto."""
major_issues: list[str] = []
Expand Down
30 changes: 30 additions & 0 deletions tests/component/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,36 @@ def test_creation_full() -> None:
assert component.category_specific_metadata == metadata


def test_accessors_return_values_when_set() -> None:
"""Test that telemetry/control accessors return the stored booleans."""
component = _TestComponent(
id=ComponentId(1),
microgrid_id=MicrogridId(2),
category=ComponentCategory.UNSPECIFIED,
_provides_telemetry=True,
_accepts_control=False,
)

assert component.provides_telemetry() is True
assert component.accepts_control() is False


def test_accessors_raise_when_unspecified() -> None:
"""Test that accessors raise ValueError when the value is unknown."""
component = _TestComponent(
id=ComponentId(1),
microgrid_id=MicrogridId(2),
category=ComponentCategory.UNSPECIFIED,
_provides_telemetry=None,
_accepts_control=None,
)

with pytest.raises(ValueError):
component.provides_telemetry()
with pytest.raises(ValueError):
component.accepts_control()


@pytest.mark.parametrize(
"name,expected_str",
[
Expand Down
Loading