diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 80e5f0cb..31e5b495 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,8 @@ # Frequenz Microgrid API Client Release Notes -## Summary - - - -## Upgrading - - - ## New Features - - -## Bug Fixes +* `Component` classes now expose two new functions to check the operational mode: - + - `provides_telemetry()`: whether the component provides telemetry data + - `accepts_control()`: whether the component accepts control commands diff --git a/pyproject.toml b/pyproject.toml index 9ecf5723..b1cc8eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/frequenz/client/microgrid/component/_component.py b/src/frequenz/client/microgrid/component/_component.py index 40954b20..0faad65e 100644 --- a/src/frequenz/client/microgrid/component/_component.py +++ b/src/frequenz/client/microgrid/component/_component.py @@ -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 @@ -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. diff --git a/src/frequenz/client/microgrid/component/_component_proto.py b/src/frequenz/client/microgrid/component/_component_proto.py index b6acebd5..6d8d7f72 100644 --- a/src/frequenz/client/microgrid/component/_component_proto.py +++ b/src/frequenz/client/microgrid/component/_component_proto.py @@ -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: @@ -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 @@ -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, ) @@ -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, ) @@ -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 ( @@ -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: @@ -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(): @@ -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, ) @@ -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(): @@ -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, ) @@ -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, ) @@ -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(): @@ -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, ) @@ -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, diff --git a/tests/component/component_proto/conftest.py b/tests/component/component_proto/conftest.py index 2e9d4a75..a313b0a5 100644 --- a/tests/component/component_proto/conftest.py +++ b/tests/component/component_proto/conftest.py @@ -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, ) @@ -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( @@ -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] = {} diff --git a/tests/component/component_proto/test_base.py b/tests/component/component_proto/test_base.py index 80257d1e..f7b44c62 100644 --- a/tests/component/component_proto/test_base.py +++ b/tests/component/component_proto/test_base.py @@ -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, ) @@ -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] = [] diff --git a/tests/component/test_component.py b/tests/component/test_component.py index 9fcd5ea1..8ef16b23 100644 --- a/tests/component/test_component.py +++ b/tests/component/test_component.py @@ -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", [