diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py index d00de53c..a22fc409 100644 --- a/src/frequenz/client/common/metrics/_sample.py +++ b/src/frequenz/client/common/metrics/_sample.py @@ -117,7 +117,7 @@ class MetricConnection: `MetricConnection.get_category()` to obtain a known member or a clear error. """ - name: str | None = None + name: str = "" """The name of the specific connection from which the metric was obtained. This is expected to be populated when the same [`Metric`][...Metric] variant @@ -133,7 +133,7 @@ def __str__(self) -> str: if isinstance(self.category, int) else f"" ) - if self.name is not None: + if self.name: return f"{category_name}({self.name})" return category_name diff --git a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py index 6b8a28cc..d86ffc07 100644 --- a/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py +++ b/src/frequenz/client/common/metrics/proto/v1alpha8/_sample.py @@ -69,7 +69,7 @@ def metric_connection_from_proto_with_issues( return MetricConnection( category=category, - name=message.name or None, + name=message.name, ) diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index 5f4484bc..79165ef2 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -14,6 +14,7 @@ from ._breaker import Breaker from ._capacitor_bank import CapacitorBank from ._category import ElectricalComponentCategory +from ._category_specific_info import CategorySpecificInfo from ._chp import Chp from ._converter import Converter from ._crypto_miner import CryptoMiner @@ -81,6 +82,7 @@ "BatteryTypes", "Breaker", "CapacitorBank", + "CategorySpecificInfo", "Chp", "Converter", "CryptoMiner", diff --git a/src/frequenz/client/common/microgrid/electrical_components/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/_battery.py index bddc90c8..99a8621f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_battery.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_battery.py @@ -44,6 +44,10 @@ class UnrecognizedBattery(Battery, ProblematicElectricalComponent): type: int """The raw type of this battery, not recognized by this library version.""" + def __str__(self) -> str: + """Return a string representation exposing the raw type.""" + return f"{self.id}:{self.name}:Battery:type={self.type}" + BatteryTypes: TypeAlias = ( LiIonBattery | NaIonBattery | UnrecognizedBattery | UnspecifiedBattery diff --git a/src/frequenz/client/common/microgrid/electrical_components/_category_specific_info.py b/src/frequenz/client/common/microgrid/electrical_components/_category_specific_info.py new file mode 100644 index 00000000..41f65320 --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_category_specific_info.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Category specific info carried by an electrical component.""" + +import dataclasses +from collections.abc import Mapping +from typing import Any + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class CategorySpecificInfo: + """The category specific info carried by an electrical component. + + A protobuf electrical component may carry a `category_specific_info` variant + with extra fields tied to its category. Fields this library version + understands are translated into typed attributes on the concrete component + (e.g. the battery type). Anything left over — either because the component's + category is not recognized, or because a newer API version added fields this + client doesn't know yet — is preserved here so callers can still inspect the + raw values. + """ + + kind: str + """The name of the info variant carried on the wire (e.g. `"battery"`).""" + + fields: Mapping[str, Any] = dataclasses.field( + default_factory=dict, + # Excluded from the hash: values may be unhashable (e.g. lists), and even + # repr()-folding them breaks the eq/hash invariant since values that + # compare equal can differ under repr()/hash() (e.g. 1 == 1.0 == True). + # Instances hash on `kind` alone, mirroring `metric_config_bounds`. + hash=False, + ) + """The leftover fields not translated into typed attributes. + + The keys are the protobuf field names and the values their decoded content. + """ diff --git a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py index 2b225cbe..bbde0005 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -12,6 +12,7 @@ from ...metrics import Bounds, InvalidBounds, InvalidBoundsError, Metric from .. import MicrogridId from .._lifetime import InvalidLifetime, InvalidLifetimeError, Lifetime +from ._category_specific_info import CategorySpecificInfo from ._ids import ElectricalComponentId DefaultT = TypeVar("DefaultT") @@ -81,7 +82,7 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes # dict is not hashable, so we don't use this field to calculate the hash. # This shouldn't be a problem since it is very unlikely that two components # with all other attributes being equal would have different category - # specific metadata, so hash collisions should be still very unlikely. + # specific info, so hash collisions should be still very unlikely. hash=False, ) ) @@ -103,21 +104,17 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes when a valid [`Bounds`][.....metrics.Bounds] is required. """ - category_specific_metadata: Mapping[str, Any] = dataclasses.field( - default_factory=dict, - # dict is not hashable, so we don't use this field to calculate the hash. This - # shouldn't be a problem since it is very unlikely that two components with all - # other attributes being equal would have different category specific metadata, - # so hash collisions should be still very unlikely. - hash=False, - ) - """The category specific metadata of this electrical component. - - Note: - This should not be used normally, it is only useful when accessing a newer - version of the API where the client doesn't know about the new metadata fields - yet (i.e. for use with - [`UnrecognizedElectricalComponent`][...UnrecognizedElectricalComponent]). + category_specific_info: CategorySpecificInfo | None = None + """The category specific info carried by this component, if any. + + This is `None` when the wire carried no category-specific info variant. + Otherwise it holds a + [`CategorySpecificInfo`][...CategorySpecificInfo] recording the variant + `kind` together with any fields that were not translated into typed + attributes on this component. The leftover fields are empty when everything + was translated, and non-empty when the category or its variant is not + recognized, or when a newer API version added fields this client version + doesn't know yet. """ def __new__(cls, *_: Any, **__: Any) -> Self: @@ -327,5 +324,4 @@ def identity(self) -> tuple[ElectricalComponentId, MicrogridId]: def __str__(self) -> str: """Return a human-readable string representation of this instance.""" - name = f":{self.name}" if self.name else "" - return f"{self.id}<{type(self).__name__}>{name}" + return f"{self.id}:{self.name}:{type(self).__name__}" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py b/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py index 5deaaa30..feb4d281 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py @@ -49,6 +49,10 @@ class UnrecognizedEvCharger(EvCharger, ProblematicElectricalComponent): type: int """The raw type of this EV charger, not recognized by this library version.""" + def __str__(self) -> str: + """Return a string representation exposing the raw type.""" + return f"{self.id}:{self.name}:EvCharger:type={self.type}" + EvChargerTypes: TypeAlias = ( UnspecifiedEvCharger diff --git a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py index 260b3304..d4cb7db5 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py @@ -49,6 +49,10 @@ class UnrecognizedInverter(Inverter, ProblematicElectricalComponent): type: int """The raw type of this inverter, not recognized by this library version.""" + def __str__(self) -> str: + """Return a string representation exposing the raw type.""" + return f"{self.id}:{self.name}:Inverter:type={self.type}" + InverterTypes: TypeAlias = ( UnspecifiedInverter diff --git a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py index 91f1e176..8ffa03fb 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py @@ -37,17 +37,36 @@ class UnrecognizedElectricalComponent(ProblematicElectricalComponent): category: int """The raw category of this component, not recognized by this library version.""" + def __str__(self) -> str: + """Return a string representation exposing the raw category.""" + return f"{self.id}:{self.name}:category={self.category}" + @dataclasses.dataclass(frozen=True, kw_only=True) class MismatchedCategoryElectricalComponent(ProblematicElectricalComponent): """An electrical component with a mismatch in the category. This electrical component declared a category but carries category specific - metadata that doesn't match the declared category. + info that doesn't match the declared category. """ category: int """The raw category declared by this component. - It doesn't match the carried category specific metadata. + It doesn't match the carried category specific info. """ + + category_name: str | None = None + """The short protobuf name of the declared category, or `None` if unknown. + + This is the protobuf enum name without its long prefix (e.g. `"BATTERY"`). + It is normally set, since a mismatched component declares a recognized + category. + """ + + def __str__(self) -> str: + """Return a string representation exposing the category mismatch.""" + info = self.category_specific_info + kind = info.kind if info is not None else "" + category = self.category_name or self.category + return f"{self.id}:{self.name}:mismatched:category={category}:kind={kind}" diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py index 8bc40b6e..bdb65ba3 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_electrical_component.py @@ -6,7 +6,7 @@ import logging import warnings from collections.abc import Mapping, Sequence -from typing import Any, Final, NamedTuple, TypeAlias, assert_never, overload +from typing import Final, NamedTuple, TypeAlias, assert_never, overload from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, @@ -29,6 +29,7 @@ from ..._breaker import Breaker from ..._capacitor_bank import CapacitorBank from ..._category import ElectricalComponentCategory +from ..._category_specific_info import CategorySpecificInfo from ..._chp import Chp from ..._converter import Converter from ..._crypto_miner import CryptoMiner @@ -900,8 +901,8 @@ class _ElectricalComponentBaseData(NamedTuple): [`Bounds`][frequenz.client.common.metrics.Bounds]. """ - category_specific_info: dict[str, Any] - """The category-specific metadata extracted from the protobuf message.""" + category_specific_info: CategorySpecificInfo | None + """The category specific info extracted from the protobuf message, if any.""" provides_telemetry: bool | int """Whether the electrical component provides telemetry, or `None` if unknown.""" @@ -910,7 +911,53 @@ class _ElectricalComponentBaseData(NamedTuple): """Whether the electrical component accepts control, or `None` if unknown.""" category_mismatched: bool = False - """Whether the declared category and the carried metadata disagree.""" + """Whether the declared category and the carried info disagree.""" + + +_CATEGORY_NAME_PREFIX = "ELECTRICAL_COMPONENT_CATEGORY_" + + +def _category_name(category: int) -> str | None: + """Return the short protobuf enum name for a category, or `None` if unknown. + + Args: + category: The raw protobuf category value. + + Returns: + The protobuf enum name without its `ELECTRICAL_COMPONENT_CATEGORY_` + prefix (e.g. `"BATTERY"`), or `None` when the value is not a known + protobuf enum value. + """ + proto_enum = electrical_components_pb2.ElectricalComponentCategory + try: + name = proto_enum.Name(proto_enum.ValueType(category)) + except ValueError: + return None + return name.removeprefix(_CATEGORY_NAME_PREFIX) + + +def _leftover_info( + info: CategorySpecificInfo | None, *translated_keys: str +) -> CategorySpecificInfo | None: + """Return the info without the fields translated into typed attributes. + + The variant `kind` is preserved whenever info was carried, so an empty + result still records which variant the wire carried. + + Args: + info: The full info carried on the wire, or `None` if none was. + *translated_keys: The field names already translated into typed + attributes on the target component. + + Returns: + The info without `translated_keys`, or `None` if no info was carried. + """ + if info is None: + return None + leftover = { + key: value for key, value in info.fields.items() if key not in translated_keys + } + return CategorySpecificInfo(kind=info.kind, fields=leftover) # pylint: disable-next=too-many-locals @@ -952,11 +999,16 @@ def _electrical_component_base_from_proto_with_issues( major_issues.append(f"category {category} is unrecognized") category_specific_info_kind = message.category_specific_info.WhichOneof("kind") - category_specific_info: dict[str, Any] = {} + category_specific_info: CategorySpecificInfo | None = None if category_specific_info_kind is not None: - category_specific_info = MessageToDict( - getattr(message.category_specific_info, category_specific_info_kind), - always_print_fields_with_no_presence=True, + category_specific_info = CategorySpecificInfo( + kind=category_specific_info_kind, + fields=MessageToDict( + getattr( + message.category_specific_info, category_specific_info_kind + ), + always_print_fields_with_no_presence=True, + ), ) category_mismatched = False @@ -1016,11 +1068,12 @@ def electrical_component_from_proto_with_issues( name=base_data.name, model=base_data.model, category=message.category, + category_name=_category_name(message.category), operational_lifetime=base_data.lifetime, _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, - category_specific_metadata=base_data.category_specific_info, + category_specific_info=base_data.category_specific_info, metric_config_bounds=base_data.metric_config_bounds, ) match base_data.category: @@ -1035,6 +1088,7 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=base_data.category_specific_info, metric_config_bounds=base_data.metric_config_bounds, ) case ( @@ -1068,6 +1122,7 @@ def electrical_component_from_proto_with_issues( metric_config_bounds=base_data.metric_config_bounds, ) case ElectricalComponentCategory.BATTERY: + battery_info = _leftover_info(base_data.category_specific_info, "type") raw_battery_type = message.category_specific_info.battery.type battery_class = _BATTERY_CLASS_BY_PROTO_TYPE.get(raw_battery_type) if raw_battery_type == ( @@ -1088,6 +1143,7 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=battery_info, metric_config_bounds=base_data.metric_config_bounds, type=raw_battery_type, ) @@ -1100,9 +1156,13 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=battery_info, metric_config_bounds=base_data.metric_config_bounds, ) case ElectricalComponentCategory.EV_CHARGER: + ev_charger_info = _leftover_info( + base_data.category_specific_info, "type" + ) raw_ev_charger_type = message.category_specific_info.ev_charger.type ev_charger_class = _EV_CHARGER_CLASS_BY_PROTO_TYPE.get( raw_ev_charger_type @@ -1125,6 +1185,7 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=ev_charger_info, metric_config_bounds=base_data.metric_config_bounds, type=raw_ev_charger_type, ) @@ -1137,9 +1198,13 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=ev_charger_info, metric_config_bounds=base_data.metric_config_bounds, ) case ElectricalComponentCategory.GRID_CONNECTION_POINT: + grid_info = _leftover_info( + base_data.category_specific_info, "ratedFuseCurrent" + ) rated_fuse_current = ( message.category_specific_info.grid_connection_point.rated_fuse_current ) @@ -1153,10 +1218,12 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=grid_info, metric_config_bounds=base_data.metric_config_bounds, rated_fuse_current=rated_fuse_current, ) case ElectricalComponentCategory.INVERTER: + inverter_info = _leftover_info(base_data.category_specific_info, "type") raw_inverter_type = message.category_specific_info.inverter.type inverter_class = _INVERTER_CLASS_BY_PROTO_TYPE.get(raw_inverter_type) if raw_inverter_type == ( @@ -1177,6 +1244,7 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=inverter_info, metric_config_bounds=base_data.metric_config_bounds, type=raw_inverter_type, ) @@ -1189,9 +1257,13 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=inverter_info, metric_config_bounds=base_data.metric_config_bounds, ) case ElectricalComponentCategory.POWER_TRANSFORMER: + power_transformer_info = _leftover_info( + base_data.category_specific_info, "primary", "secondary" + ) return PowerTransformer( id=base_data.component_id, microgrid_id=base_data.microgrid_id, @@ -1201,6 +1273,7 @@ def electrical_component_from_proto_with_issues( _provides_telemetry=base_data.provides_telemetry, _accepts_control=base_data.accepts_control, _allow_construction=True, + category_specific_info=power_transformer_info, metric_config_bounds=base_data.metric_config_bounds, primary_voltage=message.category_specific_info.power_transformer.primary, secondary_voltage=message.category_specific_info.power_transformer.secondary, diff --git a/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py b/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py index 1df6e096..9a4b7501 100644 --- a/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py +++ b/tests/metrics/proto/v1alpha8/test_sample_metric_connection.py @@ -95,6 +95,6 @@ def test_with_empty_name() -> None: ) assert connection.category == MetricConnectionCategory.PV - assert connection.name is None + assert not connection.name assert not major_issues assert not minor_issues diff --git a/tests/metrics/test_sample_metric_connection.py b/tests/metrics/test_sample_metric_connection.py index 092ef7d3..d6888c9a 100644 --- a/tests/metrics/test_sample_metric_connection.py +++ b/tests/metrics/test_sample_metric_connection.py @@ -17,9 +17,9 @@ [ pytest.param( MetricConnectionCategory.BATTERY, - None, + "", "", - id="enum_category_no_name", + id="enum_category_empty_name", ), pytest.param( MetricConnectionCategory.PV, @@ -29,9 +29,9 @@ ), pytest.param( 999, - None, + "", "999", - id="int_category_no_name", + id="int_category_empty_name", ), pytest.param( 999, @@ -43,7 +43,7 @@ ) def test_str_representation( category: MetricConnectionCategory | int, - name: str | None, + name: str, expected_str: str, ) -> None: """Test string representation of MetricConnection.""" @@ -75,7 +75,7 @@ def test_creation_default_name() -> None: """Test MetricConnection creation with default name.""" connection = MetricConnection(category=MetricConnectionCategory.AMBIENT) assert connection.category == MetricConnectionCategory.AMBIENT - assert connection.name is None + assert not connection.name def test_equality() -> None: diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py b/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py index 1d611a6c..10dcbddf 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py @@ -60,7 +60,7 @@ def default_component_base_data( category=ElectricalComponentCategory.UNSPECIFIED, lifetime=DEFAULT_LIFETIME, metric_config_bounds={Metric.AC_ENERGY_ACTIVE: Bounds(lower=0, upper=100)}, - category_specific_info={}, + category_specific_info=None, provides_telemetry=True, accepts_control=True, category_mismatched=False, @@ -81,7 +81,6 @@ def assert_base_data( assert base_data.accepts_control == other._accepts_control # pylint: enable=protected-access assert base_data.metric_config_bounds == other.metric_config_bounds - assert base_data.category_specific_info == other.category_specific_metadata _OPERATIONAL_MODE_BY_BOOLS: dict[ diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py index e94a1a1b..34bf6843 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_base.py @@ -15,6 +15,7 @@ from frequenz.client.common.metrics import Bounds, InvalidBounds, Metric from frequenz.client.common.microgrid import InvalidLifetime, Lifetime from frequenz.client.common.microgrid.electrical_components import ( + CategorySpecificInfo, ElectricalComponentCategory, ) from frequenz.client.common.microgrid.electrical_components.proto.v1alpha8._electrical_component import ( # noqa: E501 @@ -102,7 +103,7 @@ def test_missing_category_specific_info( category=ElectricalComponentCategory.UNSPECIFIED, lifetime=Lifetime(), metric_config_bounds={}, - category_specific_info={}, + category_specific_info=None, ) proto = base_data_as_proto(base_data) proto.ClearField("operational_lifetime") @@ -147,7 +148,9 @@ def test_category_specific_info_mismatch( minor_issues: list[str] = [] base_data = default_component_base_data._replace( category=ElectricalComponentCategory.GRID_CONNECTION_POINT, - category_specific_info={"type": "BATTERY_TYPE_LI_ION"}, + category_specific_info=CategorySpecificInfo( + kind="battery", fields={"type": "BATTERY_TYPE_LI_ION"} + ), category_mismatched=True, ) proto = base_data_as_proto(base_data) diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py index 2a6de99d..0d04d921 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_electrical_component_simple.py @@ -14,6 +14,7 @@ from frequenz.client.common.microgrid.electrical_components import ( Breaker, CapacitorBank, + CategorySpecificInfo, Chp, Converter, CryptoMiner, @@ -94,7 +95,9 @@ def test_category_mismatch( minor_issues: list[str] = [] base_data = default_component_base_data._replace( category=1, # GRID_CONNECTION_POINT - category_specific_info={"type": "BATTERY_TYPE_LI_ION"}, + category_specific_info=CategorySpecificInfo( + kind="battery", fields={"type": "BATTERY_TYPE_LI_ION"} + ), category_mismatched=True, ) proto = base_data_as_proto(base_data) @@ -113,6 +116,10 @@ def test_category_mismatch( assert not minor_issues assert isinstance(component, MismatchedCategoryElectricalComponent) assert_base_data(base_data, component) + assert component.category_specific_info == CategorySpecificInfo( + kind="battery", fields={"type": "BATTERY_TYPE_LI_ION"} + ) + assert component.category_name == "GRID_CONNECTION_POINT" assert electrical_component_class_to_proto(component) == (1, None) diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py index 8efcc6ac..b21cbb6e 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_raw_storage.py @@ -11,6 +11,7 @@ ) from frequenz.client.common.microgrid.electrical_components import ( + CategorySpecificInfo, ElectricalComponentCategory, LiIonBattery, UnrecognizedBattery, @@ -87,6 +88,34 @@ def test_recognized_classes_carry_no_category_or_type( assert "type=" not in text +def test_recognized_class_keeps_info_kind_with_empty_fields( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """A recognized component keeps the info kind with empty leftover fields.""" + battery = _li_ion_battery(default_component_base_data) + assert battery.category_specific_info == CategorySpecificInfo( + kind="battery", fields={} + ) + + +def test_unrecognized_category_preserves_info( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """An unrecognized category preserves the carried info verbatim.""" + base_data = default_component_base_data._replace(category=999) + proto = base_data_as_proto(base_data) + proto.category_specific_info.battery.type = ( + electrical_components_pb2.BATTERY_TYPE_LI_ION + ) + + component = electrical_component_from_proto(proto) + + assert isinstance(component, UnrecognizedElectricalComponent) + assert component.category_specific_info == CategorySpecificInfo( + kind="battery", fields={"type": "BATTERY_TYPE_LI_ION"} + ) + + def test_unrecognized_type_shows_in_repr( default_component_base_data: _ElectricalComponentBaseData, ) -> None: diff --git a/tests/microgrid/electrical_components/test_battery.py b/tests/microgrid/electrical_components/test_battery.py index 6cca936b..badea6e7 100644 --- a/tests/microgrid/electrical_components/test_battery.py +++ b/tests/microgrid/electrical_components/test_battery.py @@ -147,3 +147,21 @@ def test_recognized_battery_types_are_not_problematic( assert not isinstance(battery, ProblematicElectricalComponent) assert isinstance(battery, Battery) + + +def test_unrecognized_battery_str( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """`UnrecognizedBattery.__str__` exposes the raw type after the base label.""" + battery = UnrecognizedBattery( + id=component_id, + microgrid_id=microgrid_id, + name="bat1", + model="Test Model", + type=999, + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + assert str(battery) == "CID42:bat1:Battery:type=999" diff --git a/tests/microgrid/electrical_components/test_category_specific_info.py b/tests/microgrid/electrical_components/test_category_specific_info.py new file mode 100644 index 00000000..4433cc5b --- /dev/null +++ b/tests/microgrid/electrical_components/test_category_specific_info.py @@ -0,0 +1,68 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the category specific info carried by electrical components.""" + +from frequenz.client.common.microgrid.electrical_components import ( + CategorySpecificInfo, +) + + +def test_construction() -> None: + """The kind and leftover fields are exposed as given.""" + info = CategorySpecificInfo(kind="battery", fields={"foo": "bar"}) + + assert info.kind == "battery" + assert info.fields == {"foo": "bar"} + + +def test_fields_default_to_empty() -> None: + """The leftover fields default to an empty mapping.""" + info = CategorySpecificInfo(kind="inverter") + + assert info.fields == {} + + +def test_equality() -> None: + """Equality considers both the kind and the leftover fields.""" + a = CategorySpecificInfo(kind="battery", fields={"x": 1}) + b = CategorySpecificInfo(kind="battery", fields={"x": 1}) + c = CategorySpecificInfo(kind="battery", fields={"x": 2}) + d = CategorySpecificInfo(kind="inverter", fields={"x": 1}) + + assert a == b + assert a != c + assert a != d + + +def test_equal_instances_hash_equally() -> None: + """Instances equal by content hash equally and dedupe in a set.""" + a = CategorySpecificInfo(kind="battery", fields={"x": 1, "y": 2}) + b = CategorySpecificInfo(kind="battery", fields={"y": 2, "x": 1}) + + assert a == b + assert hash(a) == hash(b) + assert len({a, b}) == 1 + + +def test_equal_values_hash_equally_regardless_of_type() -> None: + """Values that compare equal but repr differently still hash equally. + + Guards against deriving the hash from ``repr(value)``: ``1``, ``1.0`` and + ``True`` compare equal, so instances carrying them must hash equally and + deduplicate in a set (equal objects are required to have equal hashes). + """ + a = CategorySpecificInfo(kind="battery", fields={"x": 1}) + b = CategorySpecificInfo(kind="battery", fields={"x": 1.0}) + c = CategorySpecificInfo(kind="battery", fields={"x": True}) + + assert a == b == c + assert hash(a) == hash(b) == hash(c) + assert len({a, b, c}) == 1 + + +def test_hashable_with_unhashable_values() -> None: + """Fields carrying unhashable values can still be hashed.""" + info = CategorySpecificInfo(kind="battery", fields={"x": [1, 2]}) + + assert isinstance(hash(info), int) diff --git a/tests/microgrid/electrical_components/test_electrical_component_base.py b/tests/microgrid/electrical_components/test_electrical_component_base.py index b04ad6e5..c75edc0e 100644 --- a/tests/microgrid/electrical_components/test_electrical_component_base.py +++ b/tests/microgrid/electrical_components/test_electrical_component_base.py @@ -25,6 +25,7 @@ MicrogridId, ) from frequenz.client.common.microgrid.electrical_components import ( + CategorySpecificInfo, ElectricalComponent, ElectricalComponentId, ) @@ -99,14 +100,14 @@ def test_creation_with_defaults() -> None: assert component.model == "Test Model" assert component.operational_lifetime == Lifetime() assert component.metric_config_bounds == {} - assert component.category_specific_metadata == {} + assert component.category_specific_info is None def test_creation_full() -> None: """Test electrical component creation with all attributes.""" bounds = Bounds(lower=-100.0, upper=100.0) metric_config_bounds: dict[Metric | int, Bounds] = {Metric.AC_POWER_ACTIVE: bounds} - metadata = {"key1": "value1", "key2": 42} + info = CategorySpecificInfo(kind="battery", fields={"key1": "value1", "key2": 42}) component = _TestElectricalComponent( id=ElectricalComponentId(1), @@ -114,7 +115,7 @@ def test_creation_full() -> None: name="test-component", model="Test Manufacturer Test Model", metric_config_bounds=metric_config_bounds, - category_specific_metadata=metadata, + category_specific_info=info, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -123,7 +124,7 @@ def test_creation_full() -> None: assert component.name == "test-component" assert component.model == "Test Manufacturer Test Model" assert component.metric_config_bounds == metric_config_bounds - assert component.category_specific_metadata == metadata + assert component.category_specific_info == info def test_accessors_return_values_when_set() -> None: @@ -296,8 +297,8 @@ def test_get_metric_config_bounds_invalid_raises_despite_default() -> None: @pytest.mark.parametrize( "name,expected_str", [ - ("", "CID1<_TestElectricalComponent>"), - ("test-component", "CID1<_TestElectricalComponent>:test-component"), + ("", "CID1::_TestElectricalComponent"), + ("test-component", "CID1:test-component:_TestElectricalComponent"), ], ids=["no-name", "with-name"], ) @@ -372,7 +373,9 @@ def test_is_operational_now(mock_datetime: Mock) -> None: name="test", model="Test Model", metric_config_bounds={Metric.AC_POWER_ACTIVE: Bounds(lower=-100.0, upper=100.0)}, - category_specific_metadata={"key": "value"}, + category_specific_info=CategorySpecificInfo( + kind="battery", fields={"key": "value"} + ), _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -384,7 +387,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: name=COMPONENT.name, model=COMPONENT.model, metric_config_bounds={Metric.AC_POWER_ACTIVE: Bounds(lower=-200.0, upper=200.0)}, - category_specific_metadata={"different": "metadata"}, + category_specific_info=COMPONENT.category_specific_info, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -396,7 +399,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: name="different", model=COMPONENT.model, metric_config_bounds=COMPONENT.metric_config_bounds, - category_specific_metadata=COMPONENT.category_specific_metadata, + category_specific_info=COMPONENT.category_specific_info, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -408,7 +411,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: name=COMPONENT.name, model=COMPONENT.model, metric_config_bounds=COMPONENT.metric_config_bounds, - category_specific_metadata=COMPONENT.category_specific_metadata, + category_specific_info=COMPONENT.category_specific_info, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -420,7 +423,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: name=COMPONENT.name, model=COMPONENT.model, metric_config_bounds=COMPONENT.metric_config_bounds, - category_specific_metadata=COMPONENT.category_specific_metadata, + category_specific_info=COMPONENT.category_specific_info, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -432,7 +435,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: name=COMPONENT.name, model=COMPONENT.model, metric_config_bounds=COMPONENT.metric_config_bounds, - category_specific_metadata=COMPONENT.category_specific_metadata, + category_specific_info=COMPONENT.category_specific_info, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, diff --git a/tests/microgrid/electrical_components/test_ev_charger.py b/tests/microgrid/electrical_components/test_ev_charger.py index b1a50e1a..9d480c34 100644 --- a/tests/microgrid/electrical_components/test_ev_charger.py +++ b/tests/microgrid/electrical_components/test_ev_charger.py @@ -148,3 +148,21 @@ def test_recognized_ev_charger_types_are_not_problematic( assert not isinstance(charger, ProblematicElectricalComponent) assert isinstance(charger, EvCharger) + + +def test_unrecognized_ev_charger_str( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """`UnrecognizedEvCharger.__str__` exposes the raw type after the base label.""" + charger = UnrecognizedEvCharger( + id=component_id, + microgrid_id=microgrid_id, + name="evc1", + model="Test Model", + type=999, + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + assert str(charger) == "CID42:evc1:EvCharger:type=999" diff --git a/tests/microgrid/electrical_components/test_inverter.py b/tests/microgrid/electrical_components/test_inverter.py index 92a1b261..3207c56a 100644 --- a/tests/microgrid/electrical_components/test_inverter.py +++ b/tests/microgrid/electrical_components/test_inverter.py @@ -148,3 +148,21 @@ def test_recognized_inverter_types_are_not_problematic( assert not isinstance(inverter, ProblematicElectricalComponent) assert isinstance(inverter, Inverter) + + +def test_unrecognized_inverter_str( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """`UnrecognizedInverter.__str__` exposes the raw type after the base label.""" + inverter = UnrecognizedInverter( + id=component_id, + microgrid_id=microgrid_id, + name="inv1", + model="Test Model", + type=999, + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + assert str(inverter) == "CID42:inv1:Inverter:type=999" diff --git a/tests/microgrid/electrical_components/test_problematic.py b/tests/microgrid/electrical_components/test_problematic.py index c814ff26..df8704fd 100644 --- a/tests/microgrid/electrical_components/test_problematic.py +++ b/tests/microgrid/electrical_components/test_problematic.py @@ -7,6 +7,7 @@ from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ( + CategorySpecificInfo, ElectricalComponentId, MismatchedCategoryElectricalComponent, ProblematicElectricalComponent, @@ -126,3 +127,41 @@ def test_unrecognized_component_type( assert component.microgrid_id == microgrid_id assert component.name == "unrecognized_component" assert component.category == 999 + + +def test_unrecognized_component_str( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """`UnrecognizedElectricalComponent.__str__` exposes the raw category.""" + component = UnrecognizedElectricalComponent( + id=component_id, + microgrid_id=microgrid_id, + name="comp1", + model="Test Model", + category=999, + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + assert str(component) == "CID42:comp1:category=999" + + +def test_mismatched_category_component_str( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """`MismatchedCategoryElectricalComponent.__str__` exposes the mismatch.""" + component = MismatchedCategoryElectricalComponent( + id=component_id, + microgrid_id=microgrid_id, + name="comp1", + model="Test Model", + category=5, # BATTERY + category_name="BATTERY", + category_specific_info=CategorySpecificInfo(kind="inverter", fields={}), + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + assert str(component) == "CID42:comp1:mismatched:category=BATTERY:kind=inverter"