From 7b0867ea812df8112f92a9eb92d31962d1f73df2 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 21 Jul 2026 10:47:20 +0200 Subject: [PATCH 1/5] Remove `None` from `MetricConnection.name` The protobuf message field is required, so the string will always come. Adding `None` as a possibility in the type system forces user code to deal with `None`, and adds an ambiguity between `None` and `""`. So, as we did with other `name` or similar string fields, it is better to just leave it as a pure `str` and let users deal with the empty string more easily, without the need to special-case `None`. Signed-off-by: Leandro Lucarella --- src/frequenz/client/common/metrics/_sample.py | 4 ++-- .../client/common/metrics/proto/v1alpha8/_sample.py | 2 +- .../proto/v1alpha8/test_sample_metric_connection.py | 2 +- tests/metrics/test_sample_metric_connection.py | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) 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/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: From 4908e6c7bca3098dcccc30e9da074335c7c93b2b Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 21 Jul 2026 09:37:29 +0000 Subject: [PATCH 2/5] Add `CategorySpecificInfo` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a small frozen dataclass pairing the protobuf `category_specific_info` variant name (`kind`) with the leftover fields that this client version did not translate into typed attributes. An electrical component may carry category-specific info on the wire. Known fields are decoded into typed attributes on the concrete component, but anything left over — because the category is unrecognized, or because a newer API version added fields this client doesn't know yet — was either dropped or kept as a bare `dict` with no indication of which variant it came from. This type gives that leftover data a documented home together with the variant name needed to interpret it. It is hashable like the components that will carry it. The leftover `fields` mapping may hold arbitrary, possibly unhashable values, and folding them into the hash — even through `repr()` — is unsafe: values that compare equal can hash or repr differently (e.g. `1 == 1.0 == True`), which would break the invariant that equal objects hash equally. So `fields` is excluded from the hash and instances hash on `kind` alone, mirroring `metric_config_bounds` on the component. Collisions between instances that share a `kind` but differ in `fields` are then possible, but harmless and highly unlikely in practice. A follow-up commit adopts it as the `category_specific_info` field on every electrical component. Signed-off-by: Leandro Lucarella --- .../electrical_components/__init__.py | 2 + .../_category_specific_info.py | 38 +++++++++++ .../test_category_specific_info.py | 68 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_category_specific_info.py create mode 100644 tests/microgrid/electrical_components/test_category_specific_info.py 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/_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/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) From 12716843f5f7d00386efb19c7bf5613040ae02aa Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 21 Jul 2026 10:07:26 +0000 Subject: [PATCH 3/5] Store carried category specific info on every component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt `CategorySpecificInfo | None` as the `category_specific_info` field on `ElectricalComponent`, replacing the bare `Mapping[str, Any]` that only the mismatched component ever populated. The protobuf `category_specific_info` oneof is now decoded once into its `kind` plus a dict of its fields, then each concrete component drops the fields it translated into typed attributes (e.g. a battery's `type`), keeping the `kind` and any leftover. The result: * recognized components keep the variant `kind` with an empty leftover; * `UnrecognizedElectricalComponent` now preserves the carried info instead of dropping it — previously the escape hatch was empty for the very class that needs it most; * `MismatchedCategoryElectricalComponent` keeps the full info (nothing was translated), recording exactly which variant disagreed with the category; * components carrying no variant get `None`. Because `CategorySpecificInfo` is hashable, the field no longer needs `hash=False` and now participates in the component hash like the other fields. Signed-off-by: Leandro Lucarella --- .../_electrical_component.py | 29 ++++---- .../proto/v1alpha8/_electrical_component.py | 68 ++++++++++++++++--- .../proto/v1alpha8/conftest.py | 3 +- .../test_electrical_component_base.py | 7 +- .../test_electrical_component_simple.py | 8 ++- .../proto/v1alpha8/test_raw_storage.py | 29 ++++++++ .../test_electrical_component_base.py | 23 ++++--- 7 files changed, 127 insertions(+), 40 deletions(-) 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..037f00f8 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: 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..e004f46a 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,31 @@ 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.""" + + +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 +977,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 @@ -1020,7 +1050,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_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 +1065,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 +1099,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 +1120,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 +1133,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 +1162,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 +1175,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 +1195,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 +1221,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 +1234,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 +1250,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/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..6ac3ba44 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,9 @@ 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 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_electrical_component_base.py b/tests/microgrid/electrical_components/test_electrical_component_base.py index b04ad6e5..c626613c 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: @@ -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, From 7976d441fa30bbb4daaa56a61035c6b6c82293f7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 21 Jul 2026 10:26:02 +0000 Subject: [PATCH 4/5] Rework `ElectricalComponent.__str__` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `{id}<{Class}>{name}` rendering with `{id}:{name}:{Class}`, and override `__str__` on the problematic subclasses so the raw wire value that makes them problematic stays visible in the marker: * recognized components render as `CID42:bat1:LiIonBattery` (the name is always literal, so an empty name reads as `CID42::LiIonBattery`) * `UnrecognizedBattery`/`UnrecognizedEvCharger`/`UnrecognizedInverter` render as `CID42:bat1:Battery:type=99` — the hardcoded base label plus the raw type * `UnrecognizedElectricalComponent` renders as `CID42:comp1:category=999` — the raw, unrecognized category * `MismatchedCategoryElectricalComponent` renders as `CID42:comp1:mismatched:category=5:kind=inverter` — the declared category (as its enum name) against the carried variant kind. Each subclass overrides `__str__` in full rather than sharing a `__str__` hook on the base: the format is a single short f-string, the tests pin it down, and the flat overrides read better than the indirection a hook would need. Signed-off-by: Leandro Lucarella --- .../electrical_components/_battery.py | 4 ++ .../_electrical_component.py | 3 +- .../electrical_components/_ev_charger.py | 4 ++ .../electrical_components/_inverter.py | 4 ++ .../electrical_components/_problematic.py | 14 ++++++- .../electrical_components/test_battery.py | 18 +++++++++ .../test_electrical_component_base.py | 4 +- .../electrical_components/test_ev_charger.py | 18 +++++++++ .../electrical_components/test_inverter.py | 18 +++++++++ .../electrical_components/test_problematic.py | 38 +++++++++++++++++++ 10 files changed, 119 insertions(+), 6 deletions(-) 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/_electrical_component.py b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py index 037f00f8..bbde0005 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -324,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..10ec6318 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py @@ -37,17 +37,27 @@ 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. """ + + 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 "" + return f"{self.id}:{self.name}:mismatched:category={self.category}:kind={kind}" 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_electrical_component_base.py b/tests/microgrid/electrical_components/test_electrical_component_base.py index c626613c..c75edc0e 100644 --- a/tests/microgrid/electrical_components/test_electrical_component_base.py +++ b/tests/microgrid/electrical_components/test_electrical_component_base.py @@ -297,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"], ) 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..fb32a92e 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,40 @@ 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_specific_info=CategorySpecificInfo(kind="inverter", fields={}), + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + assert str(component) == "CID42:comp1:mismatched:category=5:kind=inverter" From 1ac23b9df2d4bf0086bacec222b2441d45e4a0e3 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 21 Jul 2026 10:44:32 +0000 Subject: [PATCH 5/5] Store the declared category name on mismatched components `MismatchedCategoryElectricalComponent.__str__` could only show the declared category as its raw int (`category=5`), because resolving the name would mean using the deprecated `ElectricalComponentCategory` wrapper enum. Store a `category_name` alongside the raw `category`, resolved by the converter from the protobuf enum descriptor (which is not deprecated) with the long `ELECTRICAL_COMPONENT_CATEGORY_` prefix stripped, so `__str__` renders the short, readable name: `CID42:comp1:mismatched:category=BATTERY:kind=inverter` Only the mismatched component needs it: its declared category is a recognized value that its class name hides. Unrecognized components have no name to show (that is what makes them unrecognized), and every other component encodes its category in its class name. Signed-off-by: Leandro Lucarella --- .../electrical_components/_problematic.py | 11 ++++++++- .../proto/v1alpha8/_electrical_component.py | 23 +++++++++++++++++++ .../test_electrical_component_simple.py | 1 + .../electrical_components/test_problematic.py | 3 ++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py index 10ec6318..8ffa03fb 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py @@ -56,8 +56,17 @@ class MismatchedCategoryElectricalComponent(ProblematicElectricalComponent): 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 "" - return f"{self.id}:{self.name}:mismatched:category={self.category}:kind={kind}" + 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 e004f46a..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 @@ -914,6 +914,28 @@ class _ElectricalComponentBaseData(NamedTuple): """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: @@ -1046,6 +1068,7 @@ 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, 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 6ac3ba44..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 @@ -119,6 +119,7 @@ def test_category_mismatch( 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/test_problematic.py b/tests/microgrid/electrical_components/test_problematic.py index fb32a92e..df8704fd 100644 --- a/tests/microgrid/electrical_components/test_problematic.py +++ b/tests/microgrid/electrical_components/test_problematic.py @@ -157,10 +157,11 @@ def test_mismatched_category_component_str( 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=5:kind=inverter" + assert str(component) == "CID42:comp1:mismatched:category=BATTERY:kind=inverter"