From 92ad6820815989ed10dda43d931cc29c91bddbd7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 26 Jun 2026 12:32:42 +0200 Subject: [PATCH 01/11] Remove `ElectricalComponentOperationalMode` enum This enum and file should have been removed by #220 but it was forgotten. Signed-off-by: Leandro Lucarella --- .../_operational_mode.py | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 src/frequenz/client/common/microgrid/electrical_components/_operational_mode.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/_operational_mode.py b/src/frequenz/client/common/microgrid/electrical_components/_operational_mode.py deleted file mode 100644 index d72bff80..00000000 --- a/src/frequenz/client/common/microgrid/electrical_components/_operational_mode.py +++ /dev/null @@ -1,42 +0,0 @@ -# License: MIT -# Copyright © 2026 Frequenz Energy-as-a-Service GmbH - -"""Electrical component operational modes.""" - -import enum - - -@enum.unique -class ElectricalComponentOperationalMode(enum.Enum): - """The operational mode of an electrical component. - - This indicates whether the component is active and operational, and whether it - provides telemetry data, accepts control commands, or both. - """ - - UNSPECIFIED = 0 - """Default value when the operational mode is not explicitly set.""" - - INACTIVE = 1 - """The component is inactive and not operational. - - It does not provide telemetry data, and it does not accept control commands. - """ - - TELEMETRY_ONLY = 2 - """The component is active and operational, providing telemetry data only. - - It does not accept control commands. - """ - - CONTROL_ONLY = 3 - """The component is active and operational, accepting control commands only. - - It does not provide telemetry data. - """ - - CONTROL_AND_TELEMETRY = 4 - """The component is active and operational. - - It provides telemetry data and accepts control commands. - """ From a2fc6db8e3999dd622f58a61e68bd4801fe2d454 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 23 Jun 2026 21:41:56 +0000 Subject: [PATCH 02/11] Add missing electrical component classes Add these four concrete leaf electrical components: `Plc`, `StaticTransferSwitch`, `UninterruptiblePowerSupply` and `CapacitorBank`. Follow the same shape as the other simple leaves (e.g. `Chp`, `Hvac`): each one only fixes its `category` default and adds no extra fields. They are exported from the package and added to the `ElectricalComponentTypes` union, so they become part of the public type surface. Signed-off-by: Leandro Lucarella --- .../electrical_components/__init__.py | 8 ++++++++ .../electrical_components/_capacitor_bank.py | 20 +++++++++++++++++++ .../microgrid/electrical_components/_plc.py | 18 +++++++++++++++++ .../_static_transfer_switch.py | 20 +++++++++++++++++++ .../microgrid/electrical_components/_types.py | 8 ++++++++ .../_uninterruptible_power_supply.py | 20 +++++++++++++++++++ .../test_simple_components.py | 15 ++++++++++++++ 7 files changed, 109 insertions(+) create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_plc.py create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index d1e75c50..a368ce51 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -13,6 +13,7 @@ UnspecifiedBattery, ) from ._breaker import Breaker +from ._capacitor_bank import CapacitorBank from ._category import ElectricalComponentCategory from ._chp import Chp from ._converter import Converter @@ -45,6 +46,7 @@ UnspecifiedInverter, ) from ._meter import Meter +from ._plc import Plc from ._power_transformer import PowerTransformer from ._precharger import Precharger from ._problematic import ( @@ -54,6 +56,7 @@ UnspecifiedElectricalComponent, ) from ._state_code import ElectricalComponentStateCode +from ._static_transfer_switch import StaticTransferSwitch from ._steam_boiler import SteamBoiler from ._types import ( ElectricalComponentTypes, @@ -61,6 +64,7 @@ UnrecognizedElectricalComponentTypes, UnspecifiedElectricalComponentTypes, ) +from ._uninterruptible_power_supply import UninterruptiblePowerSupply from ._wind_turbine import WindTurbine __all__ = [ @@ -70,6 +74,7 @@ "BatteryType", "BatteryTypes", "Breaker", + "CapacitorBank", "Chp", "Converter", "CryptoMiner", @@ -97,11 +102,14 @@ "MismatchedCategoryElectricalComponent", "NaIonBattery", "PvInverter", + "Plc", "PowerTransformer", "Precharger", "ProblematicElectricalComponent", "ProblematicElectricalComponentTypes", + "StaticTransferSwitch", "SteamBoiler", + "UninterruptiblePowerSupply", "UnrecognizedBattery", "UnrecognizedElectricalComponent", "UnrecognizedElectricalComponentTypes", diff --git a/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py b/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py new file mode 100644 index 00000000..746407ab --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py @@ -0,0 +1,20 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Capacitor bank electrical component.""" + +import dataclasses +from typing import Literal + +from ._category import ElectricalComponentCategory +from ._electrical_component import ElectricalComponent + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class CapacitorBank(ElectricalComponent): + """A capacitor bank electrical component.""" + + category: Literal[ElectricalComponentCategory.CAPACITOR_BANK] = ( + ElectricalComponentCategory.CAPACITOR_BANK + ) + """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_plc.py b/src/frequenz/client/common/microgrid/electrical_components/_plc.py new file mode 100644 index 00000000..bd3d6f43 --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_plc.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""PLC electrical component.""" + +import dataclasses +from typing import Literal + +from ._category import ElectricalComponentCategory +from ._electrical_component import ElectricalComponent + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Plc(ElectricalComponent): + """A programmable logic controller (PLC) electrical component.""" + + category: Literal[ElectricalComponentCategory.PLC] = ElectricalComponentCategory.PLC + """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py b/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py new file mode 100644 index 00000000..cc4ab0b8 --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py @@ -0,0 +1,20 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Static transfer switch electrical component.""" + +import dataclasses +from typing import Literal + +from ._category import ElectricalComponentCategory +from ._electrical_component import ElectricalComponent + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class StaticTransferSwitch(ElectricalComponent): + """A static transfer switch electrical component.""" + + category: Literal[ElectricalComponentCategory.STATIC_TRANSFER_SWITCH] = ( + ElectricalComponentCategory.STATIC_TRANSFER_SWITCH + ) + """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_types.py b/src/frequenz/client/common/microgrid/electrical_components/_types.py index 018c4765..eb059006 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_types.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_types.py @@ -7,6 +7,7 @@ from ._battery import BatteryTypes, UnrecognizedBattery, UnspecifiedBattery from ._breaker import Breaker +from ._capacitor_bank import CapacitorBank from ._chp import Chp from ._converter import Converter from ._crypto_miner import CryptoMiner @@ -16,6 +17,7 @@ from ._hvac import Hvac from ._inverter import InverterTypes, UnrecognizedInverter, UnspecifiedInverter from ._meter import Meter +from ._plc import Plc from ._power_transformer import PowerTransformer from ._precharger import Precharger from ._problematic import ( @@ -23,7 +25,9 @@ UnrecognizedElectricalComponent, UnspecifiedElectricalComponent, ) +from ._static_transfer_switch import StaticTransferSwitch from ._steam_boiler import SteamBoiler +from ._uninterruptible_power_supply import UninterruptiblePowerSupply from ._wind_turbine import WindTurbine UnspecifiedElectricalComponentTypes: TypeAlias = ( @@ -52,6 +56,7 @@ ElectricalComponentTypes: TypeAlias = ( BatteryTypes | Breaker + | CapacitorBank | Chp | Converter | CryptoMiner @@ -61,10 +66,13 @@ | Hvac | InverterTypes | Meter + | Plc | PowerTransformer | Precharger | ProblematicElectricalComponentTypes + | StaticTransferSwitch | SteamBoiler + | UninterruptiblePowerSupply | WindTurbine ) """All possible electrical component types.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py b/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py new file mode 100644 index 00000000..6b08deff --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py @@ -0,0 +1,20 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""UPS electrical component.""" + +import dataclasses +from typing import Literal + +from ._category import ElectricalComponentCategory +from ._electrical_component import ElectricalComponent + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UninterruptiblePowerSupply(ElectricalComponent): + """An uninterruptible power supply (UPS) electrical component.""" + + category: Literal[ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY] = ( + ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY + ) + """The category of this electrical component.""" diff --git a/tests/microgrid/electrical_components/test_simple_components.py b/tests/microgrid/electrical_components/test_simple_components.py index f4117393..30edccb2 100644 --- a/tests/microgrid/electrical_components/test_simple_components.py +++ b/tests/microgrid/electrical_components/test_simple_components.py @@ -13,6 +13,7 @@ from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ( Breaker, + CapacitorBank, Chp, Converter, CryptoMiner, @@ -21,8 +22,11 @@ Electrolyzer, Hvac, Meter, + Plc, Precharger, + StaticTransferSwitch, SteamBoiler, + UninterruptiblePowerSupply, WindTurbine, ) @@ -43,14 +47,21 @@ def microgrid_id() -> MicrogridId: "cls, expected_category", [ (Breaker, ElectricalComponentCategory.BREAKER), + (CapacitorBank, ElectricalComponentCategory.CAPACITOR_BANK), (Chp, ElectricalComponentCategory.CHP), (Converter, ElectricalComponentCategory.CONVERTER), (CryptoMiner, ElectricalComponentCategory.CRYPTO_MINER), (Electrolyzer, ElectricalComponentCategory.ELECTROLYZER), (Hvac, ElectricalComponentCategory.HVAC), (Meter, ElectricalComponentCategory.METER), + (Plc, ElectricalComponentCategory.PLC), (Precharger, ElectricalComponentCategory.PRECHARGER), + (StaticTransferSwitch, ElectricalComponentCategory.STATIC_TRANSFER_SWITCH), (SteamBoiler, ElectricalComponentCategory.STEAM_BOILER), + ( + UninterruptiblePowerSupply, + ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY, + ), (WindTurbine, ElectricalComponentCategory.WIND_TURBINE), ], ids=lambda value: value.__name__ if isinstance(value, type) else value.name, @@ -58,14 +69,18 @@ def microgrid_id() -> MicrogridId: def test_init( cls: type[ Breaker + | CapacitorBank | Chp | Converter | CryptoMiner | Electrolyzer | Hvac | Meter + | Plc | Precharger + | StaticTransferSwitch | SteamBoiler + | UninterruptiblePowerSupply | WindTurbine ], expected_category: ElectricalComponentCategory, From 9eb05154f644cc478bc373e187d888c1e1425aef Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 24 Jun 2026 14:35:58 +0200 Subject: [PATCH 03/11] Fix `__all__` order This got unordered when `SolarInverter` was renamed to `PvInverter`. Signed-off-by: Leandro Lucarella --- .../client/common/microgrid/electrical_components/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index a368ce51..a56d16c4 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -101,12 +101,12 @@ "Meter", "MismatchedCategoryElectricalComponent", "NaIonBattery", - "PvInverter", "Plc", "PowerTransformer", "Precharger", "ProblematicElectricalComponent", "ProblematicElectricalComponentTypes", + "PvInverter", "StaticTransferSwitch", "SteamBoiler", "UninterruptiblePowerSupply", From a63b712d1174242001721c44a9e405158054ef57 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 23 Jun 2026 22:01:26 +0000 Subject: [PATCH 04/11] Support conversion for the new component classes The proto dispatcher now maps the PLC, static transfer switch, uninterruptible power supply and capacitor bank categories to their dedicated `Plc`, `StaticTransferSwitch`, `UninterruptiblePowerSupply` and `CapacitorBank` classes. Until now these four categories were added to the enum but were converted to `UnrecognizedElectricalComponent` as the classes were never added. Signed-off-by: Leandro Lucarella --- .../proto/v1alpha8/_electrical_component.py | 40 +++++++++---------- .../test_electrical_component_simple.py | 20 ++++++++++ 2 files changed, 38 insertions(+), 22 deletions(-) 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 4eabbf5c..d54f21d8 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 @@ -24,6 +24,7 @@ BatteryInverter, BatteryType, Breaker, + CapacitorBank, Chp, Converter, CryptoMiner, @@ -42,10 +43,13 @@ Meter, MismatchedCategoryElectricalComponent, NaIonBattery, + Plc, PowerTransformer, Precharger, PvInverter, + StaticTransferSwitch, SteamBoiler, + UninterruptiblePowerSupply, UnrecognizedBattery, UnrecognizedElectricalComponent, UnrecognizedEvCharger, @@ -315,6 +319,10 @@ def electrical_component_from_proto_with_issues( | ElectricalComponentCategory.BREAKER | ElectricalComponentCategory.STEAM_BOILER | ElectricalComponentCategory.WIND_TURBINE + | ElectricalComponentCategory.PLC + | ElectricalComponentCategory.STATIC_TRANSFER_SWITCH + | ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY + | ElectricalComponentCategory.CAPACITOR_BANK ): return _trivial_category_to_class(base_data.category)( id=base_data.component_id, @@ -506,28 +514,6 @@ def electrical_component_from_proto_with_issues( primary_voltage=message.category_specific_info.power_transformer.primary, secondary_voltage=message.category_specific_info.power_transformer.secondary, ) - case ( - ElectricalComponentCategory.PLC - | ElectricalComponentCategory.STATIC_TRANSFER_SWITCH - | ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY - | ElectricalComponentCategory.CAPACITOR_BANK - ): - major_issues.append( - f"category {base_data.category.name} has no specific electrical " - "component type" - ) - return UnrecognizedElectricalComponent( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - category=base_data.category.value, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) case unexpected_category: assert_never(unexpected_category) @@ -537,14 +523,18 @@ def _trivial_category_to_class( ) -> type[ UnspecifiedElectricalComponent | Breaker + | CapacitorBank | Chp | Converter | CryptoMiner | Electrolyzer | Hvac | Meter + | Plc | Precharger + | StaticTransferSwitch | SteamBoiler + | UninterruptiblePowerSupply | WindTurbine ]: """Return the class corresponding to a trivial electrical component category.""" @@ -560,6 +550,12 @@ def _trivial_category_to_class( ElectricalComponentCategory.BREAKER: Breaker, ElectricalComponentCategory.STEAM_BOILER: SteamBoiler, ElectricalComponentCategory.WIND_TURBINE: WindTurbine, + ElectricalComponentCategory.PLC: Plc, + ElectricalComponentCategory.STATIC_TRANSFER_SWITCH: StaticTransferSwitch, + ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY: ( + UninterruptiblePowerSupply + ), + ElectricalComponentCategory.CAPACITOR_BANK: CapacitorBank, }[category] 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 ba892b0f..c8a81141 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 @@ -13,6 +13,7 @@ from frequenz.client.common.microgrid.electrical_components import ( Breaker, + CapacitorBank, Chp, Converter, CryptoMiner, @@ -23,9 +24,12 @@ Hvac, Meter, MismatchedCategoryElectricalComponent, + Plc, PowerTransformer, Precharger, + StaticTransferSwitch, SteamBoiler, + UninterruptiblePowerSupply, UnrecognizedElectricalComponent, UnspecifiedElectricalComponent, WindTurbine, @@ -112,6 +116,11 @@ def test_category_mismatch( "category,component_class", [ pytest.param(ElectricalComponentCategory.BREAKER, Breaker, id="Breaker"), + pytest.param( + ElectricalComponentCategory.CAPACITOR_BANK, + CapacitorBank, + id="CapacitorBank", + ), pytest.param(ElectricalComponentCategory.CHP, Chp, id="Chp"), pytest.param(ElectricalComponentCategory.CONVERTER, Converter, id="Converter"), pytest.param( @@ -122,12 +131,23 @@ def test_category_mismatch( ), pytest.param(ElectricalComponentCategory.HVAC, Hvac, id="Hvac"), pytest.param(ElectricalComponentCategory.METER, Meter, id="Meter"), + pytest.param(ElectricalComponentCategory.PLC, Plc, id="Plc"), pytest.param( ElectricalComponentCategory.PRECHARGER, Precharger, id="Precharger" ), + pytest.param( + ElectricalComponentCategory.STATIC_TRANSFER_SWITCH, + StaticTransferSwitch, + id="StaticTransferSwitch", + ), pytest.param( ElectricalComponentCategory.STEAM_BOILER, SteamBoiler, id="SteamBoiler" ), + pytest.param( + ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY, + UninterruptiblePowerSupply, + id="UninterruptiblePowerSupply", + ), pytest.param( ElectricalComponentCategory.WIND_TURBINE, WindTurbine, id="WindTurbine" ), From 1fc89120e7de9c536e522d93ca3e54fc0c1f9541 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 24 Jun 2026 14:48:57 +0200 Subject: [PATCH 05/11] Improve `UnrecognizedElectricalComponent` docstring Signed-off-by: Leandro Lucarella --- .../common/microgrid/electrical_components/_problematic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py index b72f9c8e..aff2c2b3 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py @@ -34,7 +34,11 @@ class UnspecifiedElectricalComponent(ProblematicElectricalComponent): @dataclasses.dataclass(frozen=True, kw_only=True) class UnrecognizedElectricalComponent(ProblematicElectricalComponent): - """An electrical component of an unrecognized type.""" + """An electrical component of an unrecognized type. + + This is used for components whose category is not known to this version of + the library. + """ category: int """The category of this electrical component.""" From 0a85a717e46c5c3dce1b63b1ecc3530e6e93a608 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 23 Jun 2026 21:56:55 +0000 Subject: [PATCH 06/11] Add electrical component class to/from protobuf enum converters These converters allow mapping the `ElectricalComponent` class hierarchy to protobuf `(category, type)` enum tuples, to be able to send the protobuf enum values to the protocol without the need to expose the enums themselves, avoiding having two ways to express the same on the high-level wrappers. Signed-off-by: Leandro Lucarella --- .../proto/v1alpha8/__init__.py | 16 + .../proto/v1alpha8/_electrical_component.py | 809 +++++++++++++++++- .../proto/v1alpha8/test_class.py | 553 ++++++++++++ .../electrical_components/test_types.py | 165 ++++ 4 files changed, 1507 insertions(+), 36 deletions(-) create mode 100644 tests/microgrid/electrical_components/proto/v1alpha8/test_class.py create mode 100644 tests/microgrid/electrical_components/test_types.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py index 21a5bd1f..88372cc0 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/__init__.py @@ -13,6 +13,14 @@ electrical_component_diagnostic_code_to_proto, ) from ._electrical_component import ( + AbstractTypedTypes, + ConcreteTypedTypes, + ConcreteTypelessTypes, + ConvertibleElectricalComponentTypes, + ProtoTypeEnums, + SpecifiedConcreteTypelessTypes, + electrical_component_class_from_proto, + electrical_component_class_to_proto, electrical_component_from_proto, electrical_component_from_proto_with_issues, ) @@ -28,10 +36,18 @@ ) __all__ = [ + "AbstractTypedTypes", + "ConcreteTypedTypes", + "ConcreteTypelessTypes", + "ConvertibleElectricalComponentTypes", + "ProtoTypeEnums", + "SpecifiedConcreteTypelessTypes", "battery_type_from_proto", "battery_type_to_proto", "electrical_component_category_from_proto", "electrical_component_category_to_proto", + "electrical_component_class_from_proto", + "electrical_component_class_to_proto", "electrical_component_connection_from_proto", "electrical_component_connection_from_proto_with_issues", "electrical_component_diagnostic_code_from_proto", 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 d54f21d8..5ab6742f 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 @@ -5,70 +5,807 @@ import logging import warnings -from collections.abc import Sequence -from typing import Any, NamedTuple, assert_never +from collections.abc import Mapping, Sequence +from typing import Any, Final, NamedTuple, TypeAlias, assert_never, overload from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) from google.protobuf.json_format import MessageToDict +from ....._exception import UnrecognizedValueError from .....metrics import Bounds, Metric from .....metrics.proto.v1alpha8 import bounds_from_proto from .....proto import enum_from_proto from .....types import Lifetime from .....types.proto.v1alpha8 import lifetime_from_proto from ...._ids import MicrogridId -from ... import ( - AcEvCharger, - BatteryInverter, +from ..._battery import ( + Battery, BatteryType, - Breaker, - CapacitorBank, - Chp, - Converter, - CryptoMiner, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, +) +from ..._breaker import Breaker +from ..._capacitor_bank import CapacitorBank +from ..._category import ElectricalComponentCategory +from ..._chp import Chp +from ..._converter import Converter +from ..._crypto_miner import CryptoMiner +from ..._electrical_component import ElectricalComponent +from ..._electrolyzer import Electrolyzer +from ..._ev_charger import ( + AcEvCharger, DcEvCharger, - ElectricalComponentCategory, - ElectricalComponentId, - ElectricalComponentTypes, - Electrolyzer, + EvCharger, EvChargerType, - GridConnectionPoint, - Hvac, HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) +from ..._grid_connection_point import GridConnectionPoint +from ..._hvac import Hvac +from ..._ids import ElectricalComponentId +from ..._inverter import ( + BatteryInverter, HybridInverter, + Inverter, InverterType, - LiIonBattery, - Meter, - MismatchedCategoryElectricalComponent, - NaIonBattery, - Plc, - PowerTransformer, - Precharger, PvInverter, - StaticTransferSwitch, - SteamBoiler, - UninterruptiblePowerSupply, - UnrecognizedBattery, - UnrecognizedElectricalComponent, - UnrecognizedEvCharger, UnrecognizedInverter, - UnspecifiedBattery, - UnspecifiedElectricalComponent, - UnspecifiedEvCharger, UnspecifiedInverter, - WindTurbine, ) +from ..._meter import Meter +from ..._plc import Plc +from ..._power_transformer import PowerTransformer +from ..._precharger import Precharger +from ..._problematic import ( + MismatchedCategoryElectricalComponent, + UnrecognizedElectricalComponent, + UnspecifiedElectricalComponent, +) +from ..._static_transfer_switch import StaticTransferSwitch +from ..._steam_boiler import SteamBoiler +from ..._types import ElectricalComponentTypes +from ..._uninterruptible_power_supply import UninterruptiblePowerSupply +from ..._wind_turbine import WindTurbine _logger = logging.getLogger(__name__) -# We disable the `too-many-arguments` check in the whole file because all _from_proto -# functions are expected to take many arguments. -# pylint: disable=too-many-arguments +# We disable `too-many-arguments` in the whole file because all `_from_proto` functions +# are expected to take many arguments, and `too-many-lines` because this module bundles +# the class-level and message-level converters (which share lookup tables). +# pylint: disable=too-many-arguments,too-many-lines + + +# ============================================================================ +# Type aliases +# ============================================================================ + +ProtoTypeEnums: TypeAlias = ( + electrical_components_pb2.BatteryType.ValueType + | electrical_components_pb2.EvChargerType.ValueType + | electrical_components_pb2.InverterType.ValueType +) +"""Type alias for all protobuf type enums for electrical components.""" + +AbstractTypedTypes: TypeAlias = Battery | EvCharger | Inverter +"""Type alias for all abstract electrical component classes that have a type enum.""" + +SpecifiedConcreteTypelessTypes: TypeAlias = ( + Breaker + | CapacitorBank + | Chp + | Converter + | CryptoMiner + | Electrolyzer + | GridConnectionPoint + | Hvac + | Meter + | Plc + | PowerTransformer + | Precharger + | StaticTransferSwitch + | SteamBoiler + | UninterruptiblePowerSupply + | WindTurbine +) +"""Type alias for all specified concrete electrical component classes without a type enum.""" + +ConcreteTypelessTypes: TypeAlias = ( + SpecifiedConcreteTypelessTypes | UnspecifiedElectricalComponent +) +"""Type alias for all concrete electrical component classes that don't have a type enum.""" + +ConcreteTypedTypes: TypeAlias = ( + LiIonBattery + | NaIonBattery + | UnspecifiedBattery + | AcEvCharger + | DcEvCharger + | HybridEvCharger + | UnspecifiedEvCharger + | BatteryInverter + | PvInverter + | HybridInverter + | UnspecifiedInverter +) +"""Type alias for all concrete electrical component classes that have a type enum.""" + +ConvertibleElectricalComponentTypes: TypeAlias = ( + ConcreteTypedTypes | AbstractTypedTypes | ConcreteTypelessTypes +) +"""Type alias for all classes that can be converted to protobuf category/subtype pairs.""" + + +# ============================================================================ +# Shared class ↔ protobuf identity tables +# ============================================================================ + +_PROTO_CATEGORY_BY_TYPELESS_CLASS: Final[ + Mapping[ + type[ + AbstractTypedTypes + | ConcreteTypelessTypes + | UnrecognizedBattery + | UnrecognizedEvCharger + | UnrecognizedInverter + ], + electrical_components_pb2.ElectricalComponentCategory.ValueType, + ] +] = { + Battery: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + Breaker: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BREAKER, + CapacitorBank: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CAPACITOR_BANK + ), + Chp: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CHP, + Converter: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CONVERTER, + CryptoMiner: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CRYPTO_MINER, + Electrolyzer: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_ELECTROLYZER, + EvCharger: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + GridConnectionPoint: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_GRID_CONNECTION_POINT + ), + Hvac: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_HVAC, + Inverter: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + Meter: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER, + Plc: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_PLC, + PowerTransformer: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_POWER_TRANSFORMER + ), + Precharger: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_PRECHARGER, + StaticTransferSwitch: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_STATIC_TRANSFER_SWITCH + ), + SteamBoiler: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_STEAM_BOILER, + UninterruptiblePowerSupply: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNINTERRUPTIBLE_POWER_SUPPLY + ), + UnrecognizedBattery: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY + ), + UnrecognizedEvCharger: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER + ), + UnrecognizedInverter: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER + ), + UnspecifiedElectricalComponent: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNSPECIFIED + ), + WindTurbine: electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_WIND_TURBINE, +} +"""Class → protobuf category for components whose protobuf identity has no subtype. + +This covers four kinds of class: + +* Truly typeless concrete classes (`Breaker`, `Meter`, ..., `WindTurbine`, + `GridConnectionPoint`, `PowerTransformer`). +* The unspecified top-level marker `UnspecifiedElectricalComponent`. +* The abstract typed bases `Battery`, `EvCharger` and `Inverter` — when + converted to protobuf these emit `subtype=None` to mark "category known, + subtype not". +* The per-family unrecognized classes `UnrecognizedBattery`, + `UnrecognizedEvCharger`, `UnrecognizedInverter` when passed as classes. (As + instances, they carry the raw subtype int and are handled specially in + `electrical_component_class_to_proto`.) +""" + +_PROTO_CATEGORY_TYPE_BY_TYPED_CLASS: Final[ + Mapping[ + type[ConcreteTypedTypes], + tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + ProtoTypeEnums, + ], + ] +] = { + UnspecifiedBattery: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + electrical_components_pb2.BATTERY_TYPE_UNSPECIFIED, + ), + LiIonBattery: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + electrical_components_pb2.BATTERY_TYPE_LI_ION, + ), + NaIonBattery: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + electrical_components_pb2.BATTERY_TYPE_NA_ION, + ), + UnspecifiedEvCharger: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + electrical_components_pb2.EV_CHARGER_TYPE_UNSPECIFIED, + ), + AcEvCharger: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + electrical_components_pb2.EV_CHARGER_TYPE_AC, + ), + DcEvCharger: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + electrical_components_pb2.EV_CHARGER_TYPE_DC, + ), + HybridEvCharger: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + electrical_components_pb2.EV_CHARGER_TYPE_HYBRID, + ), + UnspecifiedInverter: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + electrical_components_pb2.INVERTER_TYPE_UNSPECIFIED, + ), + BatteryInverter: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + electrical_components_pb2.INVERTER_TYPE_BATTERY, + ), + PvInverter: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + electrical_components_pb2.INVERTER_TYPE_PV, + ), + HybridInverter: ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + electrical_components_pb2.INVERTER_TYPE_HYBRID, + ), +} +"""Concrete typed class → `(category, subtype)` pair. + +Only carries the well-formed concrete classes (no abstract bases, no +`Unrecognized*` families): unspecified and unrecognized cases are surfaced +through neighbouring tables instead. +""" + +_PROTO_BY_CLASS: Final[ + Mapping[ + type[ + ConcreteTypedTypes + | AbstractTypedTypes + | ConcreteTypelessTypes + | UnrecognizedBattery + | UnrecognizedEvCharger + | UnrecognizedInverter + ], + tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + ProtoTypeEnums | None, + ], + ] +] = { + **{cls: (cat, None) for cls, cat in _PROTO_CATEGORY_BY_TYPELESS_CLASS.items()}, + **{ + cls: (cat, sub) + for cls, (cat, sub) in _PROTO_CATEGORY_TYPE_BY_TYPED_CLASS.items() + }, +} +"""Combined class → `(category, subtype | None)` lookup used by `_class_to_proto`.""" + + +_BATTERY_CLASS_BY_PROTO_TYPE: Final[ + Mapping[ + electrical_components_pb2.BatteryType.ValueType, + type[UnspecifiedBattery | LiIonBattery | NaIonBattery], + ] +] = { + electrical_components_pb2.BATTERY_TYPE_UNSPECIFIED: UnspecifiedBattery, + electrical_components_pb2.BATTERY_TYPE_LI_ION: LiIonBattery, + electrical_components_pb2.BATTERY_TYPE_NA_ION: NaIonBattery, +} +"""Battery subtype → concrete battery class (`Unrecognized*` is the fallback).""" + +_EV_CHARGER_CLASS_BY_PROTO_TYPE: Final[ + Mapping[ + electrical_components_pb2.EvChargerType.ValueType, + type[UnspecifiedEvCharger | AcEvCharger | DcEvCharger | HybridEvCharger], + ] +] = { + electrical_components_pb2.EV_CHARGER_TYPE_UNSPECIFIED: UnspecifiedEvCharger, + electrical_components_pb2.EV_CHARGER_TYPE_AC: AcEvCharger, + electrical_components_pb2.EV_CHARGER_TYPE_DC: DcEvCharger, + electrical_components_pb2.EV_CHARGER_TYPE_HYBRID: HybridEvCharger, +} +"""EV charger subtype → concrete EV charger class (`Unrecognized*` is the fallback).""" + +_INVERTER_CLASS_BY_PROTO_TYPE: Final[ + Mapping[ + electrical_components_pb2.InverterType.ValueType, + type[UnspecifiedInverter | BatteryInverter | PvInverter | HybridInverter], + ] +] = { + electrical_components_pb2.INVERTER_TYPE_UNSPECIFIED: UnspecifiedInverter, + electrical_components_pb2.INVERTER_TYPE_BATTERY: BatteryInverter, + electrical_components_pb2.INVERTER_TYPE_PV: PvInverter, + electrical_components_pb2.INVERTER_TYPE_HYBRID: HybridInverter, +} +"""Inverter subtype → concrete inverter class (`Unrecognized*` is the fallback).""" + +_TYPELESS_CLASS_BY_PROTO_CATEGORY: Final[ + Mapping[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + type[ConcreteTypelessTypes], + ] +] = { + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNSPECIFIED: ( + UnspecifiedElectricalComponent + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BREAKER: Breaker, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CAPACITOR_BANK: ( + CapacitorBank + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CHP: Chp, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CONVERTER: Converter, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CRYPTO_MINER: CryptoMiner, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_ELECTROLYZER: Electrolyzer, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_GRID_CONNECTION_POINT: ( + GridConnectionPoint + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_HVAC: Hvac, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER: Meter, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_PLC: Plc, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_POWER_TRANSFORMER: ( + PowerTransformer + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_PRECHARGER: Precharger, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_STATIC_TRANSFER_SWITCH: ( + StaticTransferSwitch + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_STEAM_BOILER: SteamBoiler, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNINTERRUPTIBLE_POWER_SUPPLY: ( + UninterruptiblePowerSupply + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_WIND_TURBINE: WindTurbine, +} +"""Typeless category → concrete typeless class.""" + +_ABSTRACT_CLASS_BY_TYPED_PROTO_CATEGORY: Final[ + Mapping[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + type[AbstractTypedTypes], + ] +] = { + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY: Battery, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER: EvCharger, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER: Inverter, +} +"""Typed category → abstract base class (returned by `_class_from_proto` when subtype is `None`).""" + +_UNRECOGNIZED_CLASS_BY_TYPED_PROTO_CATEGORY: Final[ + Mapping[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + type[UnrecognizedBattery | UnrecognizedEvCharger | UnrecognizedInverter], + ] +] = { + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY: UnrecognizedBattery, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER: ( + UnrecognizedEvCharger + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER: UnrecognizedInverter, +} +"""Typed category → per-family unrecognized fallback class.""" + +_TYPED_CLASS_BY_PROTO: Final[ + Mapping[ + tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + ProtoTypeEnums, + ], + type[ConcreteTypedTypes], + ] +] = {proto_pair: cls for cls, proto_pair in _PROTO_CATEGORY_TYPE_BY_TYPED_CLASS.items()} +"""`(category, subtype)` → concrete typed class. + +The inverse of `_PROTO_CATEGORY_TYPE_BY_TYPED_CLASS`. +""" + +_TRIVIAL_TYPELESS_CLASS_BY_PROTO_CATEGORY: Final[ + Mapping[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + type[ + UnspecifiedElectricalComponent + | Breaker + | CapacitorBank + | Chp + | Converter + | CryptoMiner + | Electrolyzer + | Hvac + | Meter + | Plc + | Precharger + | StaticTransferSwitch + | SteamBoiler + | UninterruptiblePowerSupply + | WindTurbine + ], + ] +] = { + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNSPECIFIED: ( + UnspecifiedElectricalComponent + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BREAKER: Breaker, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CAPACITOR_BANK: ( + CapacitorBank + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CHP: Chp, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CONVERTER: Converter, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_CRYPTO_MINER: CryptoMiner, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_ELECTROLYZER: Electrolyzer, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_HVAC: Hvac, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER: Meter, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_PLC: Plc, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_PRECHARGER: Precharger, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_STATIC_TRANSFER_SWITCH: ( + StaticTransferSwitch + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_STEAM_BOILER: SteamBoiler, + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNINTERRUPTIBLE_POWER_SUPPLY: ( + UninterruptiblePowerSupply + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_WIND_TURBINE: WindTurbine, +} +"""The subset of `_TYPELESS_CLASS_BY_PROTO_CATEGORY` whose classes need no extra args.""" + + +# ============================================================================ +# Class converters +# ============================================================================ +# --- Battery overloads ------------------------------------------------------ +@overload +def electrical_component_class_to_proto( + component: ( + LiIonBattery + | NaIonBattery + | UnspecifiedBattery + | type[LiIonBattery | NaIonBattery | UnspecifiedBattery] + ), +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + electrical_components_pb2.BatteryType.ValueType, +]: ... + + +@overload +def electrical_component_class_to_proto( + component: UnrecognizedBattery, +) -> tuple[electrical_components_pb2.ElectricalComponentCategory.ValueType, int]: ... + + +@overload +def electrical_component_class_to_proto( + component: type[Battery], +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + electrical_components_pb2.BatteryType.ValueType | None, +]: ... + + +# --- EV charger overloads --------------------------------------------------- +@overload +def electrical_component_class_to_proto( + component: ( + AcEvCharger + | DcEvCharger + | HybridEvCharger + | UnspecifiedEvCharger + | type[AcEvCharger | DcEvCharger | HybridEvCharger | UnspecifiedEvCharger] + ), +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + electrical_components_pb2.EvChargerType.ValueType, +]: ... + + +@overload +def electrical_component_class_to_proto( + component: UnrecognizedEvCharger, +) -> tuple[electrical_components_pb2.ElectricalComponentCategory.ValueType, int]: ... + + +@overload +def electrical_component_class_to_proto( + component: type[EvCharger], +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + electrical_components_pb2.EvChargerType.ValueType | None, +]: ... + + +# --- Inverter overloads ----------------------------------------------------- +@overload +def electrical_component_class_to_proto( + component: ( + BatteryInverter + | PvInverter + | HybridInverter + | UnspecifiedInverter + | type[BatteryInverter | PvInverter | HybridInverter | UnspecifiedInverter] + ), +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + electrical_components_pb2.InverterType.ValueType, +]: ... + + +@overload +def electrical_component_class_to_proto( + component: UnrecognizedInverter, +) -> tuple[electrical_components_pb2.ElectricalComponentCategory.ValueType, int]: ... + + +@overload +def electrical_component_class_to_proto( + component: type[Inverter], +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType, + electrical_components_pb2.InverterType.ValueType | None, +]: ... + + +# --- Typeless overloads ----------------------------------------------------- +@overload +def electrical_component_class_to_proto( + component: ConcreteTypelessTypes | type[ConcreteTypelessTypes], +) -> tuple[electrical_components_pb2.ElectricalComponentCategory.ValueType, None]: ... + + +# --- Problematic top-level overloads ---------------------------------------- +@overload +def electrical_component_class_to_proto( + component: UnrecognizedElectricalComponent, +) -> tuple[int, None]: ... + + +@overload +def electrical_component_class_to_proto( + component: MismatchedCategoryElectricalComponent, +) -> tuple[int, None]: ... + + +def electrical_component_class_to_proto( + component: ElectricalComponentTypes | type[ConvertibleElectricalComponentTypes], +) -> tuple[ + electrical_components_pb2.ElectricalComponentCategory.ValueType | int, + ProtoTypeEnums | int | None, +]: + """Convert an electrical component class or instance to its protobuf identity. + + Returns the `(category, subtype)` pair the protobuf wire format uses for + the given component. This is the inverse of + [`electrical_component_class_from_proto`][..electrical_component_class_from_proto] + for the classes and abstract bases it knows about. + + Conversion rules (`C` = class, `I` = instance): + + * `LiIonBattery` / `NaIonBattery` / `UnspecifiedBattery` (`C` or `I`) + → `(BATTERY, )`. + * `UnrecognizedBattery` **instance** + → `(BATTERY, instance.type)` — preserves the raw int. + * `AcEvCharger` / `DcEvCharger` / `HybridEvCharger` / `UnspecifiedEvCharger` + (`C` or `I`) → `(EV_CHARGER, )`. + * `UnrecognizedEvCharger` **instance** + → `(EV_CHARGER, instance.type)`. + * `BatteryInverter` / `PvInverter` / `HybridInverter` / `UnspecifiedInverter` + (`C` or `I`) → `(INVERTER, )`. + * `UnrecognizedInverter` **instance** + → `(INVERTER, instance.type)`. + * The abstract bases `Battery` / `EvCharger` / `Inverter` (class only) + → `( , None)`. + * Any concrete typeless class — `Breaker`, `CapacitorBank`, ..., + `WindTurbine`, `GridConnectionPoint`, `PowerTransformer` — (`C` or `I`) + → `( , None)`. + * `UnspecifiedElectricalComponent` (`C` or `I`) + → `(UNSPECIFIED, None)`. + * `UnrecognizedElectricalComponent` **instance** + → `(instance.category, None)` — preserves the raw int. + * `MismatchedCategoryElectricalComponent` **instance** + → `(instance.category, None)` (any enum is normalised to its int). + * The per-family `UnrecognizedBattery` / `UnrecognizedEvCharger` / + `UnrecognizedInverter` (class only) → `( , None)` + (the raw int is unavailable). + + Key invariants: + + * The abstract typed bases `Battery`, `EvCharger`, `Inverter` are + *distinct* from their `Unspecified*` counterparts on the wire: abstract + bases emit `subtype=None`, the `Unspecified*` classes emit + `subtype=<...TYPE_UNSPECIFIED>` (the concrete protobuf 0 value). This + mirrors how + [`electrical_component_class_from_proto`][..electrical_component_class_from_proto] + reads them back. + * `Unrecognized*` and `MismatchedCategoryElectricalComponent` are only + meaningful as **instances** because the raw, possibly out-of-range + category or subtype int lives on the instance. Passing the per-family + `UnrecognizedBattery`/`UnrecognizedEvCharger`/`UnrecognizedInverter` as + classes still succeeds (returning `(category, None)`), but the raw int + is unavailable. Passing the top-level + `UnrecognizedElectricalComponent` or `MismatchedCategoryElectricalComponent` + as classes raises `TypeError` because no category is recoverable. + + Note: + Due to the way `mypy` resolves overloads, passing one of the abstract + typed bases (e.g. `Battery`) returns the static type + `tuple[category, | None]`: at runtime the subtype is + always `None`. Callers that already know they are passing an abstract + base usually just discard the subtype. + + Args: + component: An electrical component class or instance to encode. + + Returns: + The `(category, subtype)` pair encoding `component`. The subtype is + `None` for typeless categories and for the abstract typed bases. + + Raises: + TypeError: If `component` is a class this converter does not know how + to encode (e.g. `ElectricalComponent`, `ProblematicElectricalComponent`, + `UnrecognizedElectricalComponent` or + `MismatchedCategoryElectricalComponent` passed as classes — for + the latter two the raw category int lives on the instance and is + unrecoverable from the class alone). + """ + unrecognized_subtype: int | None = None + component_class: type[ + ConcreteTypedTypes + | AbstractTypedTypes + | ConcreteTypelessTypes + | UnrecognizedBattery + | UnrecognizedEvCharger + | UnrecognizedInverter + ] + + match component: + case UnrecognizedElectricalComponent(category=category): + return (category, None) + case MismatchedCategoryElectricalComponent(category=category): + match category: + case int(): + return (category, None) + case ElectricalComponentCategory(): + return (category.value, None) + case unexpected: + assert_never(unexpected) + case ( + UnrecognizedBattery(type=raw_subtype) + | UnrecognizedEvCharger(type=raw_subtype) + | UnrecognizedInverter(type=raw_subtype) + ): + component_class = type(component) + unrecognized_subtype = raw_subtype + case ElectricalComponent(): + component_class = type(component) + case type() as klass: + component_class = klass + case unexpected: + assert_never(unexpected) + + try: + category, subtype = _PROTO_BY_CLASS[component_class] + except KeyError as exc: + raise TypeError( + f"unsupported electrical component class: {component_class.__name__}" + ) from exc + + return (category, unrecognized_subtype if subtype is None else subtype) + + +def electrical_component_class_from_proto( + category: electrical_components_pb2.ElectricalComponentCategory.ValueType, + subtype: ProtoTypeEnums | None = None, +) -> type[ + AbstractTypedTypes + | ConcreteTypedTypes + | ConcreteTypelessTypes + | UnrecognizedBattery + | UnrecognizedEvCharger + | UnrecognizedInverter + | UnrecognizedElectricalComponent +]: + """Convert a protobuf `(category, subtype)` pair to an electrical component class. + + This is the inverse of + [`electrical_component_class_to_proto`][..electrical_component_class_to_proto]: + every input it can produce round-trips back to the matching class here. + Returns the class only — never an instance — because the protobuf identity + pair does not carry the rest of the component state. + + Conversion rules: + + * `(BATTERY, None)` → `Battery` (abstract). + * `(BATTERY, BATTERY_TYPE_UNSPECIFIED)` → `UnspecifiedBattery`. + * `(BATTERY, BATTERY_TYPE_LI_ION)` → `LiIonBattery`. + * `(BATTERY, BATTERY_TYPE_NA_ION)` → `NaIonBattery`. + * `(BATTERY, )` → `UnrecognizedBattery`. + * `(EV_CHARGER, None)` → `EvCharger` (abstract). + * `(EV_CHARGER, EV_CHARGER_TYPE_UNSPECIFIED)` → `UnspecifiedEvCharger`. + * `(EV_CHARGER, EV_CHARGER_TYPE_AC)` → `AcEvCharger`. + * `(EV_CHARGER, EV_CHARGER_TYPE_DC)` → `DcEvCharger`. + * `(EV_CHARGER, EV_CHARGER_TYPE_HYBRID)` → `HybridEvCharger`. + * `(EV_CHARGER, )` → `UnrecognizedEvCharger`. + * `(INVERTER, None)` → `Inverter` (abstract). + * `(INVERTER, INVERTER_TYPE_UNSPECIFIED)` → `UnspecifiedInverter`. + * `(INVERTER, INVERTER_TYPE_BATTERY)` → `BatteryInverter`. + * `(INVERTER, INVERTER_TYPE_PV)` → `PvInverter`. + * `(INVERTER, INVERTER_TYPE_HYBRID)` → `HybridInverter`. + * `(INVERTER, )` → `UnrecognizedInverter`. + * `(UNSPECIFIED, None)` → `UnspecifiedElectricalComponent`. + * `(, None)` → its concrete typeless class + (`Breaker`, `Meter`, ... one per category). + * `(, )` → raises + `UnrecognizedValueError`. + * `(, )` → `UnrecognizedElectricalComponent` + (subtype silently dropped). + + Notes: + * For typed categories the abstract base + (`Battery`/`EvCharger`/`Inverter`) is returned only when + `subtype is None`; passing the concrete `TYPE_UNSPECIFIED` + int returns the corresponding `Unspecified*` class instead. This is + the same distinction `electrical_component_class_to_proto` makes on + the way out. + * For known typeless categories any non-`None` subtype is an error + because the protobuf wire format has no such combination. + * For unknown categories the subtype is silently dropped — the integer + category alone is enough to mark the component as unrecognized, and + the caller passed the subtype in so they already have it. + + Args: + category: A protobuf electrical component category value. + subtype: A protobuf subtype value (`BatteryType`, `EvChargerType` + or `InverterType` `.ValueType`), or `None` for typeless + categories and abstract typed bases. + + Returns: + The corresponding electrical component class. + + Raises: + UnrecognizedValueError: If `subtype` is not `None` for a known + typeless category — that combination has no representation in the + protobuf wire format. + """ + abstract_base = _ABSTRACT_CLASS_BY_TYPED_PROTO_CATEGORY.get(category) + if abstract_base is not None: + if subtype is None: + return abstract_base + typed_class = _TYPED_CLASS_BY_PROTO.get((category, subtype)) + if typed_class is not None: + return typed_class + return _UNRECOGNIZED_CLASS_BY_TYPED_PROTO_CATEGORY[category] + + typeless_class = _TYPELESS_CLASS_BY_PROTO_CATEGORY.get(category) + if typeless_class is not None: + if subtype is not None: + raise UnrecognizedValueError(int(subtype)) + return typeless_class + + return UnrecognizedElectricalComponent + + +# ============================================================================ +# Message converters (full protobuf message ↔ instance) +# ============================================================================ + _BOOLS_BY_OPERATIONAL_MODE: dict[int, tuple[bool | None, bool | None]] = { electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE: ( False, diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py new file mode 100644 index 00000000..0210266f --- /dev/null +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py @@ -0,0 +1,553 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for electrical component class to/from protobuf v1alpha8 conversion.""" + +from typing import TypeAlias, cast + +import pytest +from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( + electrical_components_pb2 as ec_pb2, +) + +from frequenz.client.common import UnrecognizedValueError +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.electrical_components import ( + AcEvCharger, + Battery, + BatteryInverter, + Breaker, + CapacitorBank, + Chp, + Converter, + CryptoMiner, + DcEvCharger, + ElectricalComponent, + ElectricalComponentCategory, + ElectricalComponentId, + Electrolyzer, + EvCharger, + GridConnectionPoint, + Hvac, + HybridEvCharger, + HybridInverter, + Inverter, + LiIonBattery, + Meter, + MismatchedCategoryElectricalComponent, + NaIonBattery, + Plc, + PowerTransformer, + Precharger, + PvInverter, + StaticTransferSwitch, + SteamBoiler, + UninterruptiblePowerSupply, + UnrecognizedBattery, + UnrecognizedElectricalComponent, + UnrecognizedEvCharger, + UnrecognizedInverter, + UnspecifiedBattery, + UnspecifiedElectricalComponent, + UnspecifiedEvCharger, + UnspecifiedInverter, + WindTurbine, +) +from frequenz.client.common.microgrid.electrical_components.proto.v1alpha8 import ( + electrical_component_class_from_proto, + electrical_component_class_to_proto, +) + +_ProtoCategory: TypeAlias = ec_pb2.ElectricalComponentCategory.ValueType +"""Local alias for the protobuf electrical component category enum value.""" + +_ProtoSubtype: TypeAlias = ( + ec_pb2.BatteryType.ValueType + | ec_pb2.EvChargerType.ValueType + | ec_pb2.InverterType.ValueType +) +"""Local alias for any protobuf electrical component subtype enum value.""" + + +# --------------------------------------------------------------------------- +# Class → proto fixtures +# --------------------------------------------------------------------------- + +_CONCRETE_TYPED_CLASS_TO_PROTO: list[tuple[type[ElectricalComponent], int, int]] = [ + ( + LiIonBattery, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + ec_pb2.BATTERY_TYPE_LI_ION, + ), + ( + NaIonBattery, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + ec_pb2.BATTERY_TYPE_NA_ION, + ), + ( + UnspecifiedBattery, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + ec_pb2.BATTERY_TYPE_UNSPECIFIED, + ), + ( + AcEvCharger, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + ec_pb2.EV_CHARGER_TYPE_AC, + ), + ( + DcEvCharger, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + ec_pb2.EV_CHARGER_TYPE_DC, + ), + ( + HybridEvCharger, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + ec_pb2.EV_CHARGER_TYPE_HYBRID, + ), + ( + UnspecifiedEvCharger, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + ec_pb2.EV_CHARGER_TYPE_UNSPECIFIED, + ), + ( + BatteryInverter, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + ec_pb2.INVERTER_TYPE_BATTERY, + ), + ( + PvInverter, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + ec_pb2.INVERTER_TYPE_PV, + ), + ( + HybridInverter, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + ec_pb2.INVERTER_TYPE_HYBRID, + ), + ( + UnspecifiedInverter, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + ec_pb2.INVERTER_TYPE_UNSPECIFIED, + ), +] +"""Concrete typed classes that round-trip with a specific ``(category, subtype)`` pair.""" + +_ABSTRACT_TYPED_CLASS_TO_PROTO: list[tuple[type[ElectricalComponent], int]] = [ + (Battery, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY), + (EvCharger, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER), + (Inverter, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER), +] +"""Abstract typed bases that round-trip with ``subtype=None``.""" + +_TYPELESS_CLASS_TO_PROTO: list[tuple[type[ElectricalComponent], int]] = [ + (Breaker, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BREAKER), + (CapacitorBank, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_CAPACITOR_BANK), + (Chp, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_CHP), + (Converter, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_CONVERTER), + (CryptoMiner, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_CRYPTO_MINER), + (Electrolyzer, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_ELECTROLYZER), + (GridConnectionPoint, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_GRID_CONNECTION_POINT), + (Hvac, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_HVAC), + (Meter, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER), + (Plc, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_PLC), + (PowerTransformer, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_POWER_TRANSFORMER), + (Precharger, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_PRECHARGER), + (StaticTransferSwitch, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_STATIC_TRANSFER_SWITCH), + (SteamBoiler, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_STEAM_BOILER), + ( + UninterruptiblePowerSupply, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNINTERRUPTIBLE_POWER_SUPPLY, + ), + ( + UnspecifiedElectricalComponent, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_UNSPECIFIED, + ), + (WindTurbine, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_WIND_TURBINE), +] +"""Typeless classes that round-trip with ``subtype=None``.""" + +_TO_PROTO_CASES: list[tuple[type[ElectricalComponent], int, int | None]] = [ + *_CONCRETE_TYPED_CLASS_TO_PROTO, + *[(cls, cat, None) for cls, cat in _ABSTRACT_TYPED_CLASS_TO_PROTO], + *[(cls, cat, None) for cls, cat in _TYPELESS_CLASS_TO_PROTO], +] +"""All round-trippable classes and their ``(category, subtype)`` proto identity.""" + + +# --------------------------------------------------------------------------- +# Instance helpers +# --------------------------------------------------------------------------- + +_BASE_KWARGS: dict[str, object] = { + "id": ElectricalComponentId(1), + "microgrid_id": MicrogridId(1), + "_provides_telemetry": True, + "_accepts_control": True, + "_allow_construction": True, +} +"""Common base kwargs to instantiate any electrical component without runtime guard rails.""" + +_EXTRA_KWARGS_BY_CLASS: dict[type[ElectricalComponent], dict[str, object]] = { + GridConnectionPoint: {"rated_fuse_current": 100}, + PowerTransformer: {"primary_voltage": 400.0, "secondary_voltage": 230.0}, +} +"""Per-class extra required kwargs for classes that have category-specific fields.""" + + +# --------------------------------------------------------------------------- +# `_to_proto` tests — classes +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("component_class", "category", "subtype"), + _TO_PROTO_CASES, + ids=lambda value: value.__name__ if isinstance(value, type) else None, +) +def test_class_to_proto_accepts_classes( + component_class: type[ElectricalComponent], category: int, subtype: int | None +) -> None: + """Test every round-trippable class encodes to its ``(category, subtype)`` pair.""" + # Given: a round-trippable electrical component class. + # When: it is converted to its protobuf identity pair. + result = electrical_component_class_to_proto( + component_class # type: ignore[arg-type] + ) + + # Then: the raw protobuf category and subtype match the class identity. + assert result == (category, subtype) + + +# --------------------------------------------------------------------------- +# `_to_proto` tests — instances +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("component_class", "category", "subtype"), + [ + *_CONCRETE_TYPED_CLASS_TO_PROTO, + *[(cls, cat, None) for cls, cat in _TYPELESS_CLASS_TO_PROTO], + ], + ids=lambda value: value.__name__ if isinstance(value, type) else None, +) +def test_class_to_proto_accepts_concrete_instances( + component_class: type[ElectricalComponent], category: int, subtype: int | None +) -> None: + """Test every concrete instance encodes to the same ``(category, subtype)`` as its class.""" + # Given: an instance of a concrete electrical component class. + instance = component_class( + **_BASE_KWARGS, # type: ignore[arg-type] + **_EXTRA_KWARGS_BY_CLASS.get(component_class, {}), # type: ignore[arg-type] + ) + + # When: it is converted to its protobuf identity pair. + result = electrical_component_class_to_proto( + instance # type: ignore[call-overload] + ) + + # Then: the encoding matches the class encoding. + assert result == (category, subtype) + + +@pytest.mark.parametrize( + ("component_class", "category"), + [ + (UnrecognizedBattery, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY), + (UnrecognizedEvCharger, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER), + (UnrecognizedInverter, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER), + ], + ids=lambda value: value.__name__ if isinstance(value, type) else None, +) +def test_class_to_proto_unrecognized_typed_instance_preserves_subtype( + component_class: ( + type[UnrecognizedBattery] + | type[UnrecognizedEvCharger] + | type[UnrecognizedInverter] + ), + category: int, +) -> None: + """Test the raw `type=` int from a per-family unrecognized instance is preserved.""" + # Given: an Unrecognized* instance whose `type` is an arbitrary out-of-range int. + instance = component_class(**_BASE_KWARGS, type=999) # type: ignore[arg-type] + + # When: it is converted to its protobuf identity pair. + result = electrical_component_class_to_proto(instance) + + # Then: the family category is returned with the raw int subtype intact. + assert result == (category, 999) + + +@pytest.mark.parametrize( + "component_class", + [UnrecognizedBattery, UnrecognizedEvCharger, UnrecognizedInverter], + ids=lambda value: value.__name__, +) +def test_class_to_proto_unrecognized_typed_class_returns_none_subtype( + component_class: ( + type[UnrecognizedBattery] + | type[UnrecognizedEvCharger] + | type[UnrecognizedInverter] + ), +) -> None: + """Test passing an `Unrecognized*` class loses the raw subtype int.""" + # Given: an `Unrecognized*` family class (no instance, so no `type` int). + # When: it is converted to its protobuf identity pair. + category, subtype = electrical_component_class_to_proto(component_class) + + # Then: the family category is returned with `subtype=None` (the raw int is unavailable). + assert category in { + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + } + assert subtype is None + + +def test_class_to_proto_unrecognized_top_level_instance_preserves_category() -> None: + """Test `UnrecognizedElectricalComponent` instance returns its raw category int.""" + # Given: an `UnrecognizedElectricalComponent` instance with an unrecognized category. + instance = UnrecognizedElectricalComponent( + **_BASE_KWARGS, # type: ignore[arg-type] + category=999, + ) + + # When: it is converted to its protobuf identity pair. + result = electrical_component_class_to_proto(instance) + + # Then: the raw int category is preserved, with `subtype=None`. + assert result == (999, None) + + +@pytest.mark.parametrize( + ("instance_category", "expected_category"), + [ + (ElectricalComponentCategory.METER, ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER), + (999, 999), + ], + ids=["enum-category", "unrecognized-int-category"], +) +def test_class_to_proto_mismatched_instance_returns_category_int( + instance_category: ElectricalComponentCategory | int, expected_category: int +) -> None: + """Test `MismatchedCategoryElectricalComponent` returns its category as a raw int.""" + # Given: a `MismatchedCategoryElectricalComponent` instance. + instance = MismatchedCategoryElectricalComponent( + **_BASE_KWARGS, # type: ignore[arg-type] + category=instance_category, + ) + + # When: it is converted to its protobuf identity pair. + result = electrical_component_class_to_proto(instance) + + # Then: the category is normalised to its int form, with `subtype=None`. + assert result == (expected_category, None) + + +# --------------------------------------------------------------------------- +# `_to_proto` rejection cases +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "component_class", + [UnrecognizedElectricalComponent, MismatchedCategoryElectricalComponent], + ids=lambda value: value.__name__, +) +def test_class_to_proto_rejects_top_level_problematic_classes( + component_class: type[ElectricalComponent], +) -> None: + """Test that top-level problematic classes can't be converted as classes.""" + # Given: a top-level problematic class with no recoverable category. + # When/Then: converting it raises `TypeError`. + with pytest.raises(TypeError, match="unsupported electrical component class"): + electrical_component_class_to_proto(component_class) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# `_from_proto` tests — round-trip with `_to_proto` +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("component_class", "category", "subtype"), + _TO_PROTO_CASES, + ids=lambda value: value.__name__ if isinstance(value, type) else None, +) +def test_class_from_proto_round_trips_to_proto( + component_class: type[ElectricalComponent], category: int, subtype: int | None +) -> None: + """Test every ``(category, subtype)`` `_to_proto` emits round-trips back to its class.""" + # Given: a class and its protobuf identity pair. + # When: the protobuf pair is converted back via `_from_proto`. + result = electrical_component_class_from_proto( + cast(_ProtoCategory, category), + cast(_ProtoSubtype | None, subtype), + ) + + # Then: the original class is recovered. + assert result is component_class + + +# --------------------------------------------------------------------------- +# `_from_proto` tests — abstract vs unspecified distinction (the new behaviour) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("category", "abstract_class", "unspecified_class"), + [ + ( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + Battery, + UnspecifiedBattery, + ), + ( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + EvCharger, + UnspecifiedEvCharger, + ), + ( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + Inverter, + UnspecifiedInverter, + ), + ], + ids=["BATTERY", "EV_CHARGER", "INVERTER"], +) +def test_class_from_proto_distinguishes_abstract_from_unspecified( + category: int, + abstract_class: type[ElectricalComponent], + unspecified_class: type[ElectricalComponent], +) -> None: + """Test that ``None`` resolves to the abstract base and ``...TYPE_UNSPECIFIED`` to Unspecified*. + + This is the behavioural mirror of `_to_proto` distinguishing + `Battery` → `(BATTERY, None)` from `UnspecifiedBattery` → `(BATTERY, 0)`. + """ + # Given: a typed family category. + # When: the category is queried with `subtype=None` then with `...TYPE_UNSPECIFIED`. + proto_category = cast(_ProtoCategory, category) + none_result = electrical_component_class_from_proto(proto_category, None) + unspecified_subtype = cast( + _ProtoSubtype, + { + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY: ( + ec_pb2.BATTERY_TYPE_UNSPECIFIED + ), + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER: ( + ec_pb2.EV_CHARGER_TYPE_UNSPECIFIED + ), + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER: ( + ec_pb2.INVERTER_TYPE_UNSPECIFIED + ), + }[proto_category], + ) + unspecified_result = electrical_component_class_from_proto( + proto_category, unspecified_subtype + ) + + # Then: ``None`` returns the abstract base, ``...TYPE_UNSPECIFIED`` returns Unspecified*. + assert none_result is abstract_class + assert unspecified_result is unspecified_class + assert none_result is not unspecified_result + + +# --------------------------------------------------------------------------- +# `_from_proto` tests — unrecognized handling +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("category", "subtype", "expected_class"), + [ + ( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + 999, + UnrecognizedBattery, + ), + ( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_EV_CHARGER, + 999, + UnrecognizedEvCharger, + ), + ( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_INVERTER, + 999, + UnrecognizedInverter, + ), + ], + ids=["BATTERY", "EV_CHARGER", "INVERTER"], +) +def test_class_from_proto_unknown_subtype_returns_per_family_unrecognized( + category: int, subtype: int, expected_class: type[ElectricalComponent] +) -> None: + """Test unknown subtypes in known typed categories return the family `Unrecognized*` class.""" + # Given: a known typed category with an unknown subtype int. + # When: the protobuf pair is converted to a component class. + result = electrical_component_class_from_proto( + cast(_ProtoCategory, category), + cast(_ProtoSubtype, subtype), + ) + + # Then: the per-family unrecognized class is returned. + assert result is expected_class + + +def test_class_from_proto_unknown_category_returns_top_level_unrecognized() -> None: + """Test unknown categories with no subtype return `UnrecognizedElectricalComponent`.""" + # Given: a category int with no Python class mapping. + # When: the protobuf pair is converted to a component class. + result = electrical_component_class_from_proto(cast(_ProtoCategory, 999), None) + + # Then: the top-level unrecognized class is returned. + assert result is UnrecognizedElectricalComponent + + +def test_class_from_proto_unknown_category_silently_drops_subtype() -> None: + """Test unknown categories with a subtype silently drop the subtype int.""" + # Given: a category int with no Python class mapping and a (spurious) subtype int. + # When: the protobuf pair is converted to a component class. + result = electrical_component_class_from_proto( + cast(_ProtoCategory, 999), + cast(_ProtoSubtype, 5), + ) + + # Then: the top-level unrecognized class is returned (subtype is dropped). + assert result is UnrecognizedElectricalComponent + + +# --------------------------------------------------------------------------- +# `_from_proto` rejection cases +# --------------------------------------------------------------------------- + + +def test_class_from_proto_rejects_typeless_subtype() -> None: + """Test known typeless categories reject spurious subtype values.""" + # Given: a known typeless category with an impossible subtype. + # When: the protobuf values are converted to a component class. + with pytest.raises(UnrecognizedValueError) as exc_info: + electrical_component_class_from_proto( + ec_pb2.ELECTRICAL_COMPONENT_CATEGORY_METER, + cast(_ProtoSubtype, 1), + ) + + # Then: the failing raw protobuf subtype is exposed on the typed error. + assert exc_info.value.value == 1 + + +# --------------------------------------------------------------------------- +# Coverage tests — every category appears in the conversion table +# --------------------------------------------------------------------------- + + +def test_every_category_appears_in_to_proto_cases() -> None: + """Test every `ElectricalComponentCategory` member is covered by the to-proto cases.""" + # Given: the full set of categories and the categories present in the round-trip cases. + all_categories = frozenset( + category.value for category in ElectricalComponentCategory + ) + covered_categories = frozenset(category for _, category, _ in _TO_PROTO_CASES) + + # Then: every category appears at least once in the round-trip cases. + assert covered_categories == all_categories diff --git a/tests/microgrid/electrical_components/test_types.py b/tests/microgrid/electrical_components/test_types.py new file mode 100644 index 00000000..4738d8eb --- /dev/null +++ b/tests/microgrid/electrical_components/test_types.py @@ -0,0 +1,165 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the electrical component type aliases.""" + +from typing import get_args + +from frequenz.client.common.microgrid.electrical_components import ( + AcEvCharger, + Battery, + BatteryInverter, + Breaker, + CapacitorBank, + Chp, + Converter, + CryptoMiner, + DcEvCharger, + ElectricalComponentTypes, + Electrolyzer, + EvCharger, + GridConnectionPoint, + Hvac, + HybridEvCharger, + HybridInverter, + Inverter, + LiIonBattery, + Meter, + MismatchedCategoryElectricalComponent, + NaIonBattery, + Plc, + PowerTransformer, + Precharger, + ProblematicElectricalComponentTypes, + PvInverter, + StaticTransferSwitch, + SteamBoiler, + UninterruptiblePowerSupply, + UnrecognizedBattery, + UnrecognizedElectricalComponent, + UnrecognizedElectricalComponentTypes, + UnrecognizedEvCharger, + UnrecognizedInverter, + UnspecifiedBattery, + UnspecifiedElectricalComponent, + UnspecifiedElectricalComponentTypes, + UnspecifiedEvCharger, + UnspecifiedInverter, + WindTurbine, +) + +_EXPECTED_ELECTRICAL_COMPONENT_TYPES = frozenset( + { + Breaker, + CapacitorBank, + Chp, + Converter, + CryptoMiner, + Electrolyzer, + GridConnectionPoint, + Hvac, + Meter, + MismatchedCategoryElectricalComponent, + Plc, + PowerTransformer, + Precharger, + StaticTransferSwitch, + SteamBoiler, + UninterruptiblePowerSupply, + UnrecognizedElectricalComponent, + UnspecifiedElectricalComponent, + WindTurbine, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, + AcEvCharger, + DcEvCharger, + HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, + BatteryInverter, + HybridInverter, + PvInverter, + UnrecognizedInverter, + UnspecifiedInverter, + } +) +"""The concrete typed-family classes (batteries, EV chargers, inverters).""" + +_EXPECTED_ABSTRACT_BASES = frozenset({Battery, EvCharger, Inverter}) +"""The abstract typed family bases — never members of `ElectricalComponentTypes`.""" + +_EXPECTED_UNSPECIFIED_TYPES = frozenset( + { + UnspecifiedBattery, + UnspecifiedElectricalComponent, + UnspecifiedEvCharger, + UnspecifiedInverter, + } +) +"""The unspecified concrete markers (`Unspecified*`).""" + +_EXPECTED_UNRECOGNIZED_TYPES = frozenset( + { + UnrecognizedBattery, + UnrecognizedElectricalComponent, + UnrecognizedEvCharger, + UnrecognizedInverter, + } +) +"""The unrecognized concrete markers (`Unrecognized*`).""" + +_EXPECTED_PROBLEMATIC_TYPES = ( + _EXPECTED_UNSPECIFIED_TYPES + | _EXPECTED_UNRECOGNIZED_TYPES + | {MismatchedCategoryElectricalComponent} +) +"""All problem markers (unspecified, unrecognized and mismatched).""" + + +def test_electrical_component_types_unions_simple_and_typed() -> None: + """Test `ElectricalComponentTypes` is exactly the simple set ∪ the typed-family classes.""" + members = frozenset(get_args(ElectricalComponentTypes)) + assert members == _EXPECTED_ELECTRICAL_COMPONENT_TYPES + + +def test_electrical_component_types_exclude_abstract_bases() -> None: + """Test that the abstract typed bases never appear in `ElectricalComponentTypes`.""" + members = frozenset(get_args(ElectricalComponentTypes)) + assert members.isdisjoint(_EXPECTED_ABSTRACT_BASES) + + +def test_unspecified_alias_matches_expected_set() -> None: + """Test that `UnspecifiedElectricalComponentTypes` matches the expected set.""" + assert ( + frozenset(get_args(UnspecifiedElectricalComponentTypes)) + == _EXPECTED_UNSPECIFIED_TYPES + ) + + +def test_unrecognized_alias_matches_expected_set() -> None: + """Test that `UnrecognizedElectricalComponentTypes` matches the expected set.""" + assert ( + frozenset(get_args(UnrecognizedElectricalComponentTypes)) + == _EXPECTED_UNRECOGNIZED_TYPES + ) + + +def test_problematic_alias_unions_all_problem_markers() -> None: + """Test `ProblematicElectricalComponentTypes` is the union of every problem marker. + + The alias collapses through `UnspecifiedElectricalComponentTypes` and + `UnrecognizedElectricalComponentTypes` and adds + `MismatchedCategoryElectricalComponent` on top — so collecting `get_args` + transitively must equal the full problem-marker set. + """ + args = get_args(ProblematicElectricalComponentTypes) + flattened: set[type[object]] = set() + for arg in args: + nested = get_args(arg) + if nested: + flattened.update(nested) + else: + flattened.add(arg) + assert flattened == _EXPECTED_PROBLEMATIC_TYPES From cab8348431a21ee8422857fcfbfcecf1ad0a5ab4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 25 Jun 2026 15:01:37 +0200 Subject: [PATCH 07/11] Use non-overlapping types in `ElectricalComponentTypes` The type alias was using `ProblematicElectricalComponentTypes` but that also includes problematic batteries, EV chargers and inverters, which are included by BatteryTypes, EvChargerTypes and InverterTypes too. Signed-off-by: Leandro Lucarella --- .../microgrid/electrical_components/_types.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_types.py b/src/frequenz/client/common/microgrid/electrical_components/_types.py index eb059006..0ba21bb0 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_types.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_types.py @@ -12,10 +12,18 @@ from ._converter import Converter from ._crypto_miner import CryptoMiner from ._electrolyzer import Electrolyzer -from ._ev_charger import EvChargerTypes, UnrecognizedEvCharger, UnspecifiedEvCharger +from ._ev_charger import ( + EvChargerTypes, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) from ._grid_connection_point import GridConnectionPoint from ._hvac import Hvac -from ._inverter import InverterTypes, UnrecognizedInverter, UnspecifiedInverter +from ._inverter import ( + InverterTypes, + UnrecognizedInverter, + UnspecifiedInverter, +) from ._meter import Meter from ._plc import Plc from ._power_transformer import PowerTransformer @@ -66,13 +74,18 @@ | Hvac | InverterTypes | Meter + | MismatchedCategoryElectricalComponent | Plc | PowerTransformer | Precharger - | ProblematicElectricalComponentTypes | StaticTransferSwitch | SteamBoiler | UninterruptiblePowerSupply + | UnrecognizedElectricalComponent + | UnspecifiedElectricalComponent | WindTurbine ) -"""All possible electrical component types.""" +"""All concrete electrical component types. + +These are the concrete leaf types of electrical components than can be actually instantiated. +""" From 89bcb62c69ab925f7bea090929436ed5cc93694f Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 25 Jun 2026 14:50:10 +0200 Subject: [PATCH 08/11] Deprecate category and type attributes The next commit will deprecate the enums, so we need to deprecate the attributes using them. To do this we rename attributes to make them private and to represent them with raw `int`s, and add a deprecated property to access them returning the deprecated enum, so we can silence the internal deprecation message when we do the conversion. Signed-off-by: Leandro Lucarella --- .../electrical_components/_battery.py | 43 +- .../electrical_components/_breaker.py | 8 +- .../electrical_components/_capacitor_bank.py | 8 +- .../microgrid/electrical_components/_chp.py | 6 +- .../electrical_components/_converter.py | 8 +- .../electrical_components/_crypto_miner.py | 8 +- .../_electrical_component.py | 20 +- .../electrical_components/_electrolyzer.py | 8 +- .../electrical_components/_ev_charger.py | 45 +- .../_grid_connection_point.py | 8 +- .../microgrid/electrical_components/_hvac.py | 8 +- .../electrical_components/_inverter.py | 45 +- .../microgrid/electrical_components/_meter.py | 8 +- .../microgrid/electrical_components/_plc.py | 6 +- .../_power_transformer.py | 8 +- .../electrical_components/_precharger.py | 8 +- .../electrical_components/_problematic.py | 27 +- .../_static_transfer_switch.py | 8 +- .../electrical_components/_steam_boiler.py | 8 +- .../_uninterruptible_power_supply.py | 8 +- .../electrical_components/_wind_turbine.py | 8 +- .../proto/v1alpha8/_electrical_component.py | 626 +++++++++--------- .../proto/v1alpha8/conftest.py | 15 +- .../proto/v1alpha8/test_class.py | 8 +- .../v1alpha8/test_deprecated_attributes.py | 169 +++++ .../test_electrical_component_simple.py | 4 +- .../electrical_components/test_battery.py | 4 +- .../test_electrical_component_base.py | 36 +- .../electrical_components/test_ev_charger.py | 4 +- .../electrical_components/test_inverter.py | 4 +- .../electrical_components/test_problematic.py | 10 +- 31 files changed, 723 insertions(+), 461 deletions(-) create mode 100644 tests/microgrid/electrical_components/proto/v1alpha8/test_deprecated_attributes.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/_battery.py index f72af5fc..5a41d47d 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_battery.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_battery.py @@ -5,9 +5,11 @@ import dataclasses import enum -from typing import Any, Literal, Self, TypeAlias +import warnings +from typing import Any, Self, TypeAlias + +import typing_extensions -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -29,9 +31,9 @@ class BatteryType(enum.Enum): class Battery(ElectricalComponent): """An abstract battery electrical component.""" - category: Literal[ElectricalComponentCategory.BATTERY] = ( - ElectricalComponentCategory.BATTERY - ) + _category: int = dataclasses.field( + default=5, repr=False + ) # ElectricalComponentCategory.BATTERY """The category of this electrical component. Note: @@ -46,7 +48,7 @@ class Battery(ElectricalComponent): component. """ - type: BatteryType | int + _type: int = dataclasses.field(repr=False) """The type of this battery. Note: @@ -65,12 +67,27 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: raise TypeError(f"Cannot instantiate {cls.__name__} directly") return super().__new__(cls) + @property + @typing_extensions.deprecated( + "BatteryType is deprecated; identify batteries via isinstance() on the " + "class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) + def type(self) -> BatteryType | int: + """The deprecated type of this battery.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + try: + return BatteryType(self._type) + except ValueError: + return self._type + @dataclasses.dataclass(frozen=True, kw_only=True) class UnspecifiedBattery(Battery): """A battery of an unspecified type.""" - type: Literal[BatteryType.UNSPECIFIED] = BatteryType.UNSPECIFIED + _type: int = dataclasses.field(default=0, repr=False) # BatteryType.UNSPECIFIED """The type of this battery. Note: @@ -87,7 +104,7 @@ class UnspecifiedBattery(Battery): class LiIonBattery(Battery): """A Li-ion battery.""" - type: Literal[BatteryType.LI_ION] = BatteryType.LI_ION + _type: int = dataclasses.field(default=1, repr=False) # BatteryType.LI_ION """The type of this battery. Note: @@ -104,7 +121,7 @@ class LiIonBattery(Battery): class NaIonBattery(Battery): """A Na-ion battery.""" - type: Literal[BatteryType.NA_ION] = BatteryType.NA_ION + _type: int = dataclasses.field(default=2, repr=False) # BatteryType.NA_ION """The type of this battery. Note: @@ -121,9 +138,15 @@ class NaIonBattery(Battery): class UnrecognizedBattery(Battery): """A battery of an unrecognized type.""" - type: int + _type: int = dataclasses.field(repr=False) """The unrecognized type of this battery.""" + @property + @typing_extensions.override + def type(self) -> int: + """The deprecated type of this battery.""" + return self._type + BatteryTypes: TypeAlias = ( LiIonBattery | NaIonBattery | UnrecognizedBattery | UnspecifiedBattery diff --git a/src/frequenz/client/common/microgrid/electrical_components/_breaker.py b/src/frequenz/client/common/microgrid/electrical_components/_breaker.py index b148ac67..2a7817bc 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_breaker.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_breaker.py @@ -4,9 +4,7 @@ """Breaker electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class Breaker(ElectricalComponent): """A breaker electrical component.""" - category: Literal[ElectricalComponentCategory.BREAKER] = ( - ElectricalComponentCategory.BREAKER - ) + _category: int = dataclasses.field( + default=7, repr=False + ) # ElectricalComponentCategory.BREAKER """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py b/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py index 746407ab..452644a0 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_capacitor_bank.py @@ -4,9 +4,7 @@ """Capacitor bank electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class CapacitorBank(ElectricalComponent): """A capacitor bank electrical component.""" - category: Literal[ElectricalComponentCategory.CAPACITOR_BANK] = ( - ElectricalComponentCategory.CAPACITOR_BANK - ) + _category: int = dataclasses.field( + default=17, repr=False + ) # ElectricalComponentCategory.CAPACITOR_BANK """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_chp.py b/src/frequenz/client/common/microgrid/electrical_components/_chp.py index dc3e5152..7675eb13 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_chp.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_chp.py @@ -4,9 +4,7 @@ """CHP electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,5 +12,7 @@ class Chp(ElectricalComponent): """A combined heat and power (CHP) electrical component.""" - category: Literal[ElectricalComponentCategory.CHP] = ElectricalComponentCategory.CHP + _category: int = dataclasses.field( + default=9, repr=False + ) # ElectricalComponentCategory.CHP """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_converter.py b/src/frequenz/client/common/microgrid/electrical_components/_converter.py index 529490a8..56789d76 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_converter.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_converter.py @@ -4,9 +4,7 @@ """Converter electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class Converter(ElectricalComponent): """An AC-DC converter electrical component.""" - category: Literal[ElectricalComponentCategory.CONVERTER] = ( - ElectricalComponentCategory.CONVERTER - ) + _category: int = dataclasses.field( + default=4, repr=False + ) # ElectricalComponentCategory.CONVERTER """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_crypto_miner.py b/src/frequenz/client/common/microgrid/electrical_components/_crypto_miner.py index 5fc42cfd..448e10ad 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_crypto_miner.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_crypto_miner.py @@ -4,9 +4,7 @@ """Crypto miner electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class CryptoMiner(ElectricalComponent): """A crypto miner electrical component.""" - category: Literal[ElectricalComponentCategory.CRYPTO_MINER] = ( - ElectricalComponentCategory.CRYPTO_MINER - ) + _category: int = dataclasses.field( + default=14, repr=False + ) # ElectricalComponentCategory.CRYPTO_MINER """The category of this electrical component.""" 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 bcccdd70..0135b7a9 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py @@ -4,10 +4,13 @@ """Base electrical component from which all other electrical components inherit.""" import dataclasses +import warnings from collections.abc import Mapping from datetime import datetime, timezone from typing import Any, Self +import typing_extensions + from ..._exception import UnspecifiedValueError from ...metrics import Bounds, Metric from ...types import Lifetime @@ -26,7 +29,7 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes microgrid_id: MicrogridId """The ID of the microgrid this electrical component belongs to.""" - category: ElectricalComponentCategory | int + _category: int = dataclasses.field(repr=False) """The category of this electrical component. Note: @@ -116,6 +119,21 @@ def __post_init__(self) -> None: "instances via the corresponding *_from_proto converter." ) + @property + @typing_extensions.deprecated( + "ElectricalComponentCategory is deprecated; identify components via " + "isinstance() on the class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) + def category(self) -> ElectricalComponentCategory | int: + """The deprecated category of this electrical component.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + try: + return ElectricalComponentCategory(self._category) + except ValueError: + return self._category + def provides_telemetry(self) -> bool: """Check whether this electrical component provides telemetry data. diff --git a/src/frequenz/client/common/microgrid/electrical_components/_electrolyzer.py b/src/frequenz/client/common/microgrid/electrical_components/_electrolyzer.py index 9958e900..1df7df1b 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_electrolyzer.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_electrolyzer.py @@ -4,9 +4,7 @@ """Electrolyzer electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class Electrolyzer(ElectricalComponent): """An electrolyzer electrical component.""" - category: Literal[ElectricalComponentCategory.ELECTROLYZER] = ( - ElectricalComponentCategory.ELECTROLYZER - ) + _category: int = dataclasses.field( + default=10, repr=False + ) # ElectricalComponentCategory.ELECTROLYZER """The category of this electrical component.""" 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 f80675b8..ca277d10 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py @@ -5,9 +5,11 @@ import dataclasses import enum -from typing import Any, Literal, Self, TypeAlias +import warnings +from typing import Any, Self, TypeAlias + +import typing_extensions -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -32,9 +34,9 @@ class EvChargerType(enum.Enum): class EvCharger(ElectricalComponent): """An abstract EV charger electrical component.""" - category: Literal[ElectricalComponentCategory.EV_CHARGER] = ( - ElectricalComponentCategory.EV_CHARGER - ) + _category: int = dataclasses.field( + default=6, repr=False + ) # ElectricalComponentCategory.EV_CHARGER """The category of this electrical component. Note: @@ -47,7 +49,7 @@ class EvCharger(ElectricalComponent): case some low level code needs to know the category of an electrical component. """ - type: EvChargerType | int + _type: int = dataclasses.field(repr=False) """The type of this EV charger. Note: @@ -66,12 +68,27 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: raise TypeError(f"Cannot instantiate {cls.__name__} directly") return super().__new__(cls) + @property + @typing_extensions.deprecated( + "EvChargerType is deprecated; identify EV chargers via isinstance() on the " + "class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) + def type(self) -> EvChargerType | int: + """The deprecated type of this EV charger.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + try: + return EvChargerType(self._type) + except ValueError: + return self._type + @dataclasses.dataclass(frozen=True, kw_only=True) class UnspecifiedEvCharger(EvCharger): """An EV charger of an unspecified type.""" - type: Literal[EvChargerType.UNSPECIFIED] = EvChargerType.UNSPECIFIED + _type: int = dataclasses.field(default=0, repr=False) # EvChargerType.UNSPECIFIED """The type of this EV charger. Note: @@ -88,7 +105,7 @@ class UnspecifiedEvCharger(EvCharger): class AcEvCharger(EvCharger): """An EV charger that supports AC charging only.""" - type: Literal[EvChargerType.AC] = EvChargerType.AC + _type: int = dataclasses.field(default=1, repr=False) # EvChargerType.AC """The type of this EV charger. Note: @@ -105,7 +122,7 @@ class AcEvCharger(EvCharger): class DcEvCharger(EvCharger): """An EV charger that supports DC charging only.""" - type: Literal[EvChargerType.DC] = EvChargerType.DC + _type: int = dataclasses.field(default=2, repr=False) # EvChargerType.DC """The type of this EV charger. Note: @@ -122,7 +139,7 @@ class DcEvCharger(EvCharger): class HybridEvCharger(EvCharger): """An EV charger that supports both AC and DC charging.""" - type: Literal[EvChargerType.HYBRID] = EvChargerType.HYBRID + _type: int = dataclasses.field(default=3, repr=False) # EvChargerType.HYBRID """The type of this EV charger. Note: @@ -139,9 +156,15 @@ class HybridEvCharger(EvCharger): class UnrecognizedEvCharger(EvCharger): """An EV charger of an unrecognized type.""" - type: int + _type: int = dataclasses.field(repr=False) """The unrecognized type of this EV charger.""" + @property + @typing_extensions.override + def type(self) -> int: + """The deprecated type of this EV charger.""" + return self._type + EvChargerTypes: TypeAlias = ( UnspecifiedEvCharger diff --git a/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py b/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py index 054f2ce5..43163e36 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_grid_connection_point.py @@ -4,9 +4,7 @@ """Grid connection point electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -34,9 +32,9 @@ class GridConnectionPoint(ElectricalComponent): Note that this may also be the PCC in some cases. """ - category: Literal[ElectricalComponentCategory.GRID_CONNECTION_POINT] = ( - ElectricalComponentCategory.GRID_CONNECTION_POINT - ) + _category: int = dataclasses.field( + default=1, repr=False + ) # ElectricalComponentCategory.GRID_CONNECTION_POINT """The category of this electrical component.""" rated_fuse_current: int diff --git a/src/frequenz/client/common/microgrid/electrical_components/_hvac.py b/src/frequenz/client/common/microgrid/electrical_components/_hvac.py index db506666..b93bb1b5 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_hvac.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_hvac.py @@ -4,9 +4,7 @@ """HVAC electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class Hvac(ElectricalComponent): """A heating, ventilation, and air conditioning (HVAC) electrical component.""" - category: Literal[ElectricalComponentCategory.HVAC] = ( - ElectricalComponentCategory.HVAC - ) + _category: int = dataclasses.field( + default=12, repr=False + ) # ElectricalComponentCategory.HVAC """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py index f5c881e6..0ef1af7b 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py @@ -5,9 +5,11 @@ import dataclasses import enum -from typing import Any, Literal, Self, TypeAlias +import warnings +from typing import Any, Self, TypeAlias + +import typing_extensions -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -32,9 +34,9 @@ class InverterType(enum.Enum): class Inverter(ElectricalComponent): """An abstract inverter electrical component.""" - category: Literal[ElectricalComponentCategory.INVERTER] = ( - ElectricalComponentCategory.INVERTER - ) + _category: int = dataclasses.field( + default=3, repr=False + ) # ElectricalComponentCategory.INVERTER """The category of this electrical component. Note: @@ -47,7 +49,7 @@ class Inverter(ElectricalComponent): case some low level code needs to know the category of an electrical component. """ - type: InverterType | int + _type: int = dataclasses.field(repr=False) """The type of this inverter. Note: @@ -66,12 +68,27 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: raise TypeError(f"Cannot instantiate {cls.__name__} directly") return super().__new__(cls) + @property + @typing_extensions.deprecated( + "InverterType is deprecated; identify inverters via isinstance() on the " + "class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) + def type(self) -> InverterType | int: + """The deprecated type of this inverter.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + try: + return InverterType(self._type) + except ValueError: + return self._type + @dataclasses.dataclass(frozen=True, kw_only=True) class UnspecifiedInverter(Inverter): """An inverter of an unspecified type.""" - type: Literal[InverterType.UNSPECIFIED] = InverterType.UNSPECIFIED + _type: int = dataclasses.field(default=0, repr=False) # InverterType.UNSPECIFIED """The type of this inverter. Note: @@ -88,7 +105,7 @@ class UnspecifiedInverter(Inverter): class BatteryInverter(Inverter): """A battery inverter.""" - type: Literal[InverterType.BATTERY] = InverterType.BATTERY + _type: int = dataclasses.field(default=1, repr=False) # InverterType.BATTERY """The type of this inverter. Note: @@ -105,7 +122,7 @@ class BatteryInverter(Inverter): class PvInverter(Inverter): """A PV inverter.""" - type: Literal[InverterType.PV] = InverterType.PV + _type: int = dataclasses.field(default=2, repr=False) # InverterType.PV """The type of this inverter. Note: @@ -122,7 +139,7 @@ class PvInverter(Inverter): class HybridInverter(Inverter): """A hybrid inverter.""" - type: Literal[InverterType.HYBRID] = InverterType.HYBRID + _type: int = dataclasses.field(default=3, repr=False) # InverterType.HYBRID """The type of this inverter. Note: @@ -139,9 +156,15 @@ class HybridInverter(Inverter): class UnrecognizedInverter(Inverter): """An inverter of an unrecognized type.""" - type: int + _type: int = dataclasses.field(repr=False) """The unrecognized type of this inverter.""" + @property + @typing_extensions.override + def type(self) -> int: + """The deprecated type of this inverter.""" + return self._type + InverterTypes: TypeAlias = ( UnspecifiedInverter diff --git a/src/frequenz/client/common/microgrid/electrical_components/_meter.py b/src/frequenz/client/common/microgrid/electrical_components/_meter.py index 0c703c20..1bb336a5 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_meter.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_meter.py @@ -4,9 +4,7 @@ """Meter electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class Meter(ElectricalComponent): """A measuring meter electrical component.""" - category: Literal[ElectricalComponentCategory.METER] = ( - ElectricalComponentCategory.METER - ) + _category: int = dataclasses.field( + default=2, repr=False + ) # ElectricalComponentCategory.METER """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_plc.py b/src/frequenz/client/common/microgrid/electrical_components/_plc.py index bd3d6f43..29d29b15 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_plc.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_plc.py @@ -4,9 +4,7 @@ """PLC electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,5 +12,7 @@ class Plc(ElectricalComponent): """A programmable logic controller (PLC) electrical component.""" - category: Literal[ElectricalComponentCategory.PLC] = ElectricalComponentCategory.PLC + _category: int = dataclasses.field( + default=13, repr=False + ) # ElectricalComponentCategory.PLC """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py b/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py index 98841503..01641ef7 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_power_transformer.py @@ -4,9 +4,7 @@ """Power transformer electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -24,9 +22,9 @@ class PowerTransformer(ElectricalComponent): than the input power. """ - category: Literal[ElectricalComponentCategory.POWER_TRANSFORMER] = ( - ElectricalComponentCategory.POWER_TRANSFORMER - ) + _category: int = dataclasses.field( + default=11, repr=False + ) # ElectricalComponentCategory.POWER_TRANSFORMER """The category of this electrical component.""" primary_voltage: float diff --git a/src/frequenz/client/common/microgrid/electrical_components/_precharger.py b/src/frequenz/client/common/microgrid/electrical_components/_precharger.py index 6d566f1b..8c5c9536 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_precharger.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_precharger.py @@ -4,9 +4,7 @@ """Precharger electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class Precharger(ElectricalComponent): """A precharger electrical component.""" - category: Literal[ElectricalComponentCategory.PRECHARGER] = ( - ElectricalComponentCategory.PRECHARGER - ) + _category: int = dataclasses.field( + default=8, repr=False + ) # ElectricalComponentCategory.PRECHARGER """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py index aff2c2b3..acb0979c 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_problematic.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_problematic.py @@ -4,9 +4,10 @@ """Problematic electrical components.""" import dataclasses -from typing import Any, Literal, Self +from typing import Any, Self + +from typing_extensions import override -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -26,9 +27,9 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: class UnspecifiedElectricalComponent(ProblematicElectricalComponent): """An electrical component of unspecified type.""" - category: Literal[ElectricalComponentCategory.UNSPECIFIED] = ( - ElectricalComponentCategory.UNSPECIFIED - ) + _category: int = dataclasses.field( + default=0, repr=False + ) # ElectricalComponentCategory.UNSPECIFIED """The category of this electrical component.""" @@ -40,9 +41,15 @@ class UnrecognizedElectricalComponent(ProblematicElectricalComponent): the library. """ - category: int + _category: int = dataclasses.field(repr=False) """The category of this electrical component.""" + @property + @override + def category(self) -> int: + """The deprecated category of this electrical component.""" + return self._category + @dataclasses.dataclass(frozen=True, kw_only=True) class MismatchedCategoryElectricalComponent(ProblematicElectricalComponent): @@ -52,5 +59,11 @@ class MismatchedCategoryElectricalComponent(ProblematicElectricalComponent): metadata that doesn't match the declared category. """ - category: ElectricalComponentCategory | int + _category: int = dataclasses.field(repr=False) """The category of this electrical component.""" + + @property + @override + def category(self) -> int: + """The deprecated category of this electrical component.""" + return self._category diff --git a/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py b/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py index cc4ab0b8..616ce086 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_static_transfer_switch.py @@ -4,9 +4,7 @@ """Static transfer switch electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class StaticTransferSwitch(ElectricalComponent): """A static transfer switch electrical component.""" - category: Literal[ElectricalComponentCategory.STATIC_TRANSFER_SWITCH] = ( - ElectricalComponentCategory.STATIC_TRANSFER_SWITCH - ) + _category: int = dataclasses.field( + default=15, repr=False + ) # ElectricalComponentCategory.STATIC_TRANSFER_SWITCH """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_steam_boiler.py b/src/frequenz/client/common/microgrid/electrical_components/_steam_boiler.py index e26aebbc..10527047 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_steam_boiler.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_steam_boiler.py @@ -4,9 +4,7 @@ """Steam boiler electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class SteamBoiler(ElectricalComponent): """A steam boiler electrical component.""" - category: Literal[ElectricalComponentCategory.STEAM_BOILER] = ( - ElectricalComponentCategory.STEAM_BOILER - ) + _category: int = dataclasses.field( + default=19, repr=False + ) # ElectricalComponentCategory.STEAM_BOILER """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py b/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py index 6b08deff..c6bb8a8a 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_uninterruptible_power_supply.py @@ -4,9 +4,7 @@ """UPS electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class UninterruptiblePowerSupply(ElectricalComponent): """An uninterruptible power supply (UPS) electrical component.""" - category: Literal[ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY] = ( - ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY - ) + _category: int = dataclasses.field( + default=16, repr=False + ) # ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY """The category of this electrical component.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_wind_turbine.py b/src/frequenz/client/common/microgrid/electrical_components/_wind_turbine.py index 629c592b..e51f21e8 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_wind_turbine.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_wind_turbine.py @@ -4,9 +4,7 @@ """Wind turbine electrical component.""" import dataclasses -from typing import Literal -from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -14,7 +12,7 @@ class WindTurbine(ElectricalComponent): """A wind turbine electrical component.""" - category: Literal[ElectricalComponentCategory.WIND_TURBINE] = ( - ElectricalComponentCategory.WIND_TURBINE - ) + _category: int = dataclasses.field( + default=18, repr=False + ) # ElectricalComponentCategory.WIND_TURBINE """The category of this electrical component.""" 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 5ab6742f..b8d83124 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 @@ -679,13 +679,15 @@ def electrical_component_class_to_proto( case UnrecognizedElectricalComponent(category=category): return (category, None) case MismatchedCategoryElectricalComponent(category=category): - match category: - case int(): - return (category, None) - case ElectricalComponentCategory(): - return (category.value, None) - case unexpected: - assert_never(unexpected) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + match category: + case int(): + return (category, None) + case ElectricalComponentCategory(): + return (category.value, None) + case unexpected_category: + assert_never(unexpected_category) case ( UnrecognizedBattery(type=raw_subtype) | UnrecognizedEvCharger(type=raw_subtype) @@ -697,8 +699,8 @@ def electrical_component_class_to_proto( component_class = type(component) case type() as klass: component_class = klass - case unexpected: - assert_never(unexpected) + case unexpected_component: + assert_never(unexpected_component) try: category, subtype = _PROTO_BY_CLASS[component_class] @@ -928,70 +930,72 @@ def _electrical_component_base_from_proto_with_issues( Returns: An `_ElectricalComponentBaseData` named tuple containing the extracted data. """ - component_id = ElectricalComponentId(message.id) - microgrid_id = MicrogridId(message.microgrid_id) - - name = message.name or None - if name is None: - minor_issues.append("name is empty") + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + component_id = ElectricalComponentId(message.id) + microgrid_id = MicrogridId(message.microgrid_id) - model = message.model or None - if model is None: - minor_issues.append("model is empty") + name = message.name or None + if name is None: + minor_issues.append("name is empty") - provides_telemetry, accepts_control = _operational_mode_to_bools( - message.operational_mode - ) + model = message.model or None + if model is None: + minor_issues.append("model is empty") - lifetime = _get_operational_lifetime_from_proto( - message, major_issues=major_issues, minor_issues=minor_issues - ) + provides_telemetry, accepts_control = _operational_mode_to_bools( + message.operational_mode + ) - metric_config_bounds = _metric_config_bounds_from_proto( - message.metric_config_bounds, - major_issues=major_issues, - minor_issues=minor_issues, - ) + lifetime = _get_operational_lifetime_from_proto( + message, major_issues=major_issues, minor_issues=minor_issues + ) - category = enum_from_proto(message.category, ElectricalComponentCategory) - if category is ElectricalComponentCategory.UNSPECIFIED: - major_issues.append("category is unspecified") - elif isinstance(category, int): - major_issues.append(f"category {category} is unrecognized") - - category_specific_info_kind = message.category_specific_info.WhichOneof("kind") - category_specific_info: dict[str, Any] = {} - 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, + metric_config_bounds = _metric_config_bounds_from_proto( + message.metric_config_bounds, + major_issues=major_issues, + minor_issues=minor_issues, ) - category_mismatched = False - if ( - category_specific_info_kind - and isinstance(category, ElectricalComponentCategory) - and category.name.lower() != category_specific_info_kind - ): - major_issues.append( - f"category_specific_info.kind ({category_specific_info_kind}) does not " - f"match the category ({category.name.lower()})", + category = enum_from_proto(message.category, ElectricalComponentCategory) + if category is ElectricalComponentCategory.UNSPECIFIED: + major_issues.append("category is unspecified") + elif isinstance(category, int): + major_issues.append(f"category {category} is unrecognized") + + category_specific_info_kind = message.category_specific_info.WhichOneof("kind") + category_specific_info: dict[str, Any] = {} + 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_mismatched = False + if ( + category_specific_info_kind + and isinstance(category, ElectricalComponentCategory) + and category.name.lower() != category_specific_info_kind + ): + major_issues.append( + f"category_specific_info.kind ({category_specific_info_kind}) does not " + f"match the category ({category.name.lower()})", + ) + category_mismatched = True + + return _ElectricalComponentBaseData( + component_id, + microgrid_id, + name, + model, + category, + lifetime, + metric_config_bounds, + category_specific_info, + provides_telemetry, + accepts_control, + category_mismatched, ) - category_mismatched = True - - return _ElectricalComponentBaseData( - component_id, - microgrid_id, - name, - model, - category, - lifetime, - metric_config_bounds, - category_specific_info, - provides_telemetry, - accepts_control, - category_mismatched, - ) # pylint: disable-next=too-many-locals, too-many-branches @@ -1011,253 +1015,265 @@ def electrical_component_from_proto_with_issues( Returns: The resulting electrical component instance. """ - base_data = _electrical_component_base_from_proto_with_issues( - message, major_issues=major_issues, minor_issues=minor_issues - ) - - if base_data.category_mismatched: - return MismatchedCategoryElectricalComponent( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - category=base_data.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, - metric_config_bounds=base_data.metric_config_bounds, + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + base_data = _electrical_component_base_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues ) - match base_data.category: - case int(): - return UnrecognizedElectricalComponent( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - category=base_data.category, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) - case ( - ElectricalComponentCategory.UNSPECIFIED - | ElectricalComponentCategory.CHP - | ElectricalComponentCategory.CONVERTER - | ElectricalComponentCategory.CRYPTO_MINER - | ElectricalComponentCategory.ELECTROLYZER - | ElectricalComponentCategory.HVAC - | ElectricalComponentCategory.METER - | ElectricalComponentCategory.PRECHARGER - | ElectricalComponentCategory.BREAKER - | ElectricalComponentCategory.STEAM_BOILER - | ElectricalComponentCategory.WIND_TURBINE - | ElectricalComponentCategory.PLC - | ElectricalComponentCategory.STATIC_TRANSFER_SWITCH - | ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY - | ElectricalComponentCategory.CAPACITOR_BANK - ): - return _trivial_category_to_class(base_data.category)( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) - case ElectricalComponentCategory.BATTERY: - battery_enum_to_class: dict[ - BatteryType, type[UnspecifiedBattery | LiIonBattery | NaIonBattery] - ] = { - BatteryType.UNSPECIFIED: UnspecifiedBattery, - BatteryType.LI_ION: LiIonBattery, - BatteryType.NA_ION: NaIonBattery, - } - battery_type = enum_from_proto( - message.category_specific_info.battery.type, BatteryType - ) - match battery_type: - case BatteryType.UNSPECIFIED | BatteryType.LI_ION | BatteryType.NA_ION: - if battery_type is BatteryType.UNSPECIFIED: - major_issues.append("battery type is unspecified") - return battery_enum_to_class[battery_type]( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) - case int(): - major_issues.append(f"battery type {battery_type} is unrecognized") - return UnrecognizedBattery( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - type=battery_type, - ) - case unexpected_battery_type: - assert_never(unexpected_battery_type) - case ElectricalComponentCategory.EV_CHARGER: - ev_charger_enum_to_class: dict[ - EvChargerType, - type[ - UnspecifiedEvCharger | AcEvCharger | DcEvCharger | HybridEvCharger - ], - ] = { - EvChargerType.UNSPECIFIED: UnspecifiedEvCharger, - EvChargerType.AC: AcEvCharger, - EvChargerType.DC: DcEvCharger, - EvChargerType.HYBRID: HybridEvCharger, - } - ev_charger_type = enum_from_proto( - message.category_specific_info.ev_charger.type, EvChargerType - ) - match ev_charger_type: - case ( - EvChargerType.UNSPECIFIED - | EvChargerType.AC - | EvChargerType.DC - | EvChargerType.HYBRID - ): - if ev_charger_type is EvChargerType.UNSPECIFIED: - major_issues.append("ev_charger type is unspecified") - return ev_charger_enum_to_class[ev_charger_type]( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) - case int(): - major_issues.append( - f"ev_charger type {ev_charger_type} is unrecognized" - ) - return UnrecognizedEvCharger( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - type=ev_charger_type, - ) - case unexpected_ev_charger_type: - assert_never(unexpected_ev_charger_type) - case ElectricalComponentCategory.GRID_CONNECTION_POINT: - rated_fuse_current = ( - message.category_specific_info.grid_connection_point.rated_fuse_current - ) - # No need to check for negatives because the protobuf type is uint32. - return GridConnectionPoint( + if base_data.category_mismatched: + return MismatchedCategoryElectricalComponent( id=base_data.component_id, microgrid_id=base_data.microgrid_id, name=base_data.name, model=base_data.model, + _category=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, metric_config_bounds=base_data.metric_config_bounds, - rated_fuse_current=rated_fuse_current, - ) - case ElectricalComponentCategory.INVERTER: - inverter_enum_to_class: dict[ - InverterType, - type[ - UnspecifiedInverter | BatteryInverter | PvInverter | HybridInverter - ], - ] = { - InverterType.UNSPECIFIED: UnspecifiedInverter, - InverterType.BATTERY: BatteryInverter, - InverterType.PV: PvInverter, - InverterType.HYBRID: HybridInverter, - } - inverter_type = enum_from_proto( - message.category_specific_info.inverter.type, InverterType ) - match inverter_type: - case ( - InverterType.UNSPECIFIED - | InverterType.BATTERY - | InverterType.PV - | InverterType.HYBRID - ): - if inverter_type is InverterType.UNSPECIFIED: - major_issues.append("inverter type is unspecified") - return inverter_enum_to_class[inverter_type]( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) - case int(): - major_issues.append( - f"inverter type {inverter_type} is unrecognized" - ) - return UnrecognizedInverter( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - type=inverter_type, - ) - case unexpected_inverter_type: - assert_never(unexpected_inverter_type) - case ElectricalComponentCategory.POWER_TRANSFORMER: - return PowerTransformer( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - 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, - ) - case unexpected_category: - assert_never(unexpected_category) - -def _trivial_category_to_class( - category: ElectricalComponentCategory, -) -> type[ + match base_data.category: + case int(): + return UnrecognizedElectricalComponent( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + _category=message.category, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) + case ( + ElectricalComponentCategory.UNSPECIFIED + | ElectricalComponentCategory.CHP + | ElectricalComponentCategory.CONVERTER + | ElectricalComponentCategory.CRYPTO_MINER + | ElectricalComponentCategory.ELECTROLYZER + | ElectricalComponentCategory.HVAC + | ElectricalComponentCategory.METER + | ElectricalComponentCategory.PRECHARGER + | ElectricalComponentCategory.BREAKER + | ElectricalComponentCategory.STEAM_BOILER + | ElectricalComponentCategory.WIND_TURBINE + | ElectricalComponentCategory.PLC + | ElectricalComponentCategory.STATIC_TRANSFER_SWITCH + | ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY + | ElectricalComponentCategory.CAPACITOR_BANK + ): + return _trivial_category_to_class(base_data.category)( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) + case ElectricalComponentCategory.BATTERY: + battery_enum_to_class: dict[ + BatteryType, type[UnspecifiedBattery | LiIonBattery | NaIonBattery] + ] = { + BatteryType.UNSPECIFIED: UnspecifiedBattery, + BatteryType.LI_ION: LiIonBattery, + BatteryType.NA_ION: NaIonBattery, + } + battery_type = enum_from_proto( + message.category_specific_info.battery.type, BatteryType + ) + match battery_type: + case ( + BatteryType.UNSPECIFIED + | BatteryType.LI_ION + | BatteryType.NA_ION + ): + if battery_type is BatteryType.UNSPECIFIED: + major_issues.append("battery type is unspecified") + return battery_enum_to_class[battery_type]( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) + case int(): + major_issues.append( + f"battery type {battery_type} is unrecognized" + ) + return UnrecognizedBattery( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + _type=message.category_specific_info.battery.type, + ) + case unexpected_battery_type: + assert_never(unexpected_battery_type) + case ElectricalComponentCategory.EV_CHARGER: + ev_charger_enum_to_class: dict[ + EvChargerType, + type[ + UnspecifiedEvCharger + | AcEvCharger + | DcEvCharger + | HybridEvCharger + ], + ] = { + EvChargerType.UNSPECIFIED: UnspecifiedEvCharger, + EvChargerType.AC: AcEvCharger, + EvChargerType.DC: DcEvCharger, + EvChargerType.HYBRID: HybridEvCharger, + } + ev_charger_type = enum_from_proto( + message.category_specific_info.ev_charger.type, EvChargerType + ) + match ev_charger_type: + case ( + EvChargerType.UNSPECIFIED + | EvChargerType.AC + | EvChargerType.DC + | EvChargerType.HYBRID + ): + if ev_charger_type is EvChargerType.UNSPECIFIED: + major_issues.append("ev_charger type is unspecified") + return ev_charger_enum_to_class[ev_charger_type]( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) + case int(): + major_issues.append( + f"ev_charger type {ev_charger_type} is unrecognized" + ) + return UnrecognizedEvCharger( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + _type=message.category_specific_info.ev_charger.type, + ) + case unexpected_ev_charger_type: + assert_never(unexpected_ev_charger_type) + case ElectricalComponentCategory.GRID_CONNECTION_POINT: + rated_fuse_current = ( + message.category_specific_info.grid_connection_point.rated_fuse_current + ) + # No need to check for negatives because the protobuf type is uint32. + return GridConnectionPoint( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + rated_fuse_current=rated_fuse_current, + ) + case ElectricalComponentCategory.INVERTER: + inverter_enum_to_class: dict[ + InverterType, + type[ + UnspecifiedInverter + | BatteryInverter + | PvInverter + | HybridInverter + ], + ] = { + InverterType.UNSPECIFIED: UnspecifiedInverter, + InverterType.BATTERY: BatteryInverter, + InverterType.PV: PvInverter, + InverterType.HYBRID: HybridInverter, + } + inverter_type = enum_from_proto( + message.category_specific_info.inverter.type, InverterType + ) + match inverter_type: + case ( + InverterType.UNSPECIFIED + | InverterType.BATTERY + | InverterType.PV + | InverterType.HYBRID + ): + if inverter_type is InverterType.UNSPECIFIED: + major_issues.append("inverter type is unspecified") + return inverter_enum_to_class[inverter_type]( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) + case int(): + major_issues.append( + f"inverter type {inverter_type} is unrecognized" + ) + return UnrecognizedInverter( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + _type=message.category_specific_info.inverter.type, + ) + case unexpected_inverter_type: + assert_never(unexpected_inverter_type) + case ElectricalComponentCategory.POWER_TRANSFORMER: + return PowerTransformer( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + 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, + ) + case unexpected_category: + assert_never(unexpected_category) + + +_TrivialCategoryClass: TypeAlias = ( UnspecifiedElectricalComponent | Breaker | CapacitorBank @@ -1273,9 +1289,14 @@ def _trivial_category_to_class( | SteamBoiler | UninterruptiblePowerSupply | WindTurbine -]: +) + + +def _trivial_category_to_class( + category: ElectricalComponentCategory, +) -> type[_TrivialCategoryClass]: """Return the class corresponding to a trivial electrical component category.""" - return { + mapping: dict[ElectricalComponentCategory, type[_TrivialCategoryClass]] = { ElectricalComponentCategory.UNSPECIFIED: UnspecifiedElectricalComponent, ElectricalComponentCategory.CHP: Chp, ElectricalComponentCategory.CONVERTER: Converter, @@ -1293,7 +1314,8 @@ def _trivial_category_to_class( UninterruptiblePowerSupply ), ElectricalComponentCategory.CAPACITOR_BANK: CapacitorBank, - }[category] + } + return mapping[category] def _metric_config_bounds_from_proto( diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py b/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py index 1875f891..9a3af9a6 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/conftest.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone import pytest -from frequenz.api.common.v1alpha8.metrics import bounds_pb2 +from frequenz.api.common.v1alpha8.metrics import bounds_pb2, metrics_pb2 from frequenz.api.common.v1alpha8.microgrid import lifetime_pb2 from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, @@ -76,9 +76,14 @@ def assert_base_data( assert base_data.microgrid_id == other.microgrid_id assert base_data.name == other.name assert base_data.model == other.model - assert base_data.category == other.category + expected_category = ( + int(base_data.category.value) + if isinstance(base_data.category, ElectricalComponentCategory) + else base_data.category + ) assert base_data.lifetime == other.operational_lifetime # pylint: disable=protected-access + assert expected_category == other._category assert base_data.provides_telemetry == other._provides_telemetry assert base_data.accepts_control == other._accepts_control # pylint: enable=protected-access @@ -117,10 +122,10 @@ def base_data_as_proto( microgrid_id=int(base_data.microgrid_id), name=base_data.name or "", model=base_data.model or "", - category=( + category=electrical_components_pb2.ElectricalComponentCategory.ValueType( base_data.category if isinstance(base_data.category, int) - else int(base_data.category.value) # type: ignore[arg-type] + else int(base_data.category.value) ), operational_mode=_OPERATIONAL_MODE_BY_BOOLS[ (base_data.provides_telemetry, base_data.accepts_control) @@ -147,7 +152,7 @@ def base_data_as_proto( metric_value = metric.value if isinstance(metric, Metric) else metric proto.metric_config_bounds.append( electrical_components_pb2.MetricConfigBounds( - metric=metric_value, # type: ignore[arg-type] + metric=metrics_pb2.Metric.ValueType(metric_value), config_bounds=bounds_pb2.Bounds(**bounds_dict), ) ) diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py index 0210266f..9285b81e 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_class.py @@ -269,7 +269,7 @@ def test_class_to_proto_unrecognized_typed_instance_preserves_subtype( ) -> None: """Test the raw `type=` int from a per-family unrecognized instance is preserved.""" # Given: an Unrecognized* instance whose `type` is an arbitrary out-of-range int. - instance = component_class(**_BASE_KWARGS, type=999) # type: ignore[arg-type] + instance = component_class(**_BASE_KWARGS, _type=999) # type: ignore[arg-type] # When: it is converted to its protobuf identity pair. result = electrical_component_class_to_proto(instance) @@ -309,7 +309,7 @@ def test_class_to_proto_unrecognized_top_level_instance_preserves_category() -> # Given: an `UnrecognizedElectricalComponent` instance with an unrecognized category. instance = UnrecognizedElectricalComponent( **_BASE_KWARGS, # type: ignore[arg-type] - category=999, + _category=999, ) # When: it is converted to its protobuf identity pair. @@ -328,13 +328,13 @@ def test_class_to_proto_unrecognized_top_level_instance_preserves_category() -> ids=["enum-category", "unrecognized-int-category"], ) def test_class_to_proto_mismatched_instance_returns_category_int( - instance_category: ElectricalComponentCategory | int, expected_category: int + instance_category: int, expected_category: int ) -> None: """Test `MismatchedCategoryElectricalComponent` returns its category as a raw int.""" # Given: a `MismatchedCategoryElectricalComponent` instance. instance = MismatchedCategoryElectricalComponent( **_BASE_KWARGS, # type: ignore[arg-type] - category=instance_category, + _category=instance_category, ) # When: it is converted to its protobuf identity pair. diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_deprecated_attributes.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_deprecated_attributes.py new file mode 100644 index 00000000..19a0ef53 --- /dev/null +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_deprecated_attributes.py @@ -0,0 +1,169 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the deprecated ``category``/``type`` attributes and raw int storage.""" + +import dataclasses +import warnings + +import pytest +from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( + electrical_components_pb2, +) + +from frequenz.client.common.microgrid.electrical_components import ( + BatteryType, + Chp, + ElectricalComponentCategory, + LiIonBattery, + UnrecognizedBattery, + UnrecognizedElectricalComponent, +) +from frequenz.client.common.microgrid.electrical_components.proto.v1alpha8 import ( + electrical_component_from_proto, +) +from frequenz.client.common.microgrid.electrical_components.proto.v1alpha8._electrical_component import ( # noqa: E501 + _ElectricalComponentBaseData, +) + +from .conftest import base_data_as_proto + + +def _li_ion_battery( + default_component_base_data: _ElectricalComponentBaseData, +) -> LiIonBattery: + """Build a `LiIonBattery` through the protobuf converter.""" + base_data = default_component_base_data._replace( + category=ElectricalComponentCategory.BATTERY + ) + 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, LiIonBattery) + return component + + +def _chp(default_component_base_data: _ElectricalComponentBaseData) -> Chp: + """Build a `Chp` through the protobuf converter.""" + base_data = default_component_base_data._replace( + category=ElectricalComponentCategory.CHP + ) + component = electrical_component_from_proto(base_data_as_proto(base_data)) + assert isinstance(component, Chp) + return component + + +def test_category_property_reconstructs_member( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """The deprecated `category` property warns once and rebuilds the member.""" + chp = _chp(default_component_base_data) + with pytest.warns(DeprecationWarning) as record: + category = chp.category + assert category is ElectricalComponentCategory.CHP + assert len(record) == 1 + + +def test_type_property_reconstructs_member( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """The deprecated `type` property warns once and rebuilds the member.""" + battery = _li_ion_battery(default_component_base_data) + with pytest.warns(DeprecationWarning) as record: + battery_type = battery.type + assert battery_type is BatteryType.LI_ION + assert len(record) == 1 + + +def test_category_property_returns_raw_int_for_unknown( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """An unknown category is exposed as the raw int via the property.""" + base_data = default_component_base_data._replace(category=999) + component = electrical_component_from_proto(base_data_as_proto(base_data)) + assert isinstance(component, UnrecognizedElectricalComponent) + assert component.category == 999 + + +def test_type_property_returns_raw_int_for_unknown( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """An unknown battery type is exposed as the raw int via the property.""" + component = UnrecognizedBattery( + id=default_component_base_data.component_id, + microgrid_id=default_component_base_data.microgrid_id, + _type=999, + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + assert component.type == 999 + + +def test_raw_storage_not_in_repr( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """Neither the public nor the private category/type names appear in repr().""" + battery = _li_ion_battery(default_component_base_data) + text = repr(battery) + assert "category=" not in text + assert "_category=" not in text + assert "type=" not in text + assert "_type=" not in text + + +def test_type_participates_in_equality_and_hash( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """The raw type is part of equality and hashing.""" + + def _unrecognized_battery(battery_type: int) -> UnrecognizedBattery: + return UnrecognizedBattery( + id=default_component_base_data.component_id, + microgrid_id=default_component_base_data.microgrid_id, + _type=battery_type, + _provides_telemetry=True, + _accepts_control=True, + _allow_construction=True, + ) + + first = _unrecognized_battery(998) + second = _unrecognized_battery(999) + third = _unrecognized_battery(998) + + assert first != second + assert first == third + assert hash(first) == hash(third) + + +def test_replace_preserves_raw_storage( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """`dataclasses.replace` keeps the raw category/type and the class.""" + battery = _li_ion_battery(default_component_base_data) + replaced = dataclasses.replace(battery, name="renamed") + assert isinstance(replaced, LiIonBattery) + assert replaced.name == "renamed" + with pytest.deprecated_call(): + assert replaced.category is ElectricalComponentCategory.BATTERY + with pytest.deprecated_call(): + assert replaced.type is BatteryType.LI_ION + + +def test_from_proto_emits_no_deprecation_warning( + default_component_base_data: _ElectricalComponentBaseData, +) -> None: + """Converting a protobuf message must not emit a `DeprecationWarning`.""" + base_data = default_component_base_data._replace( + category=ElectricalComponentCategory.BATTERY + ) + proto = base_data_as_proto(base_data) + proto.category_specific_info.battery.type = ( + electrical_components_pb2.BATTERY_TYPE_LI_ION + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + component = electrical_component_from_proto(proto) + assert isinstance(component, LiIonBattery) 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 c8a81141..c643a717 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 @@ -89,7 +89,7 @@ def test_category_mismatch( major_issues: list[str] = [] minor_issues: list[str] = [] base_data = default_component_base_data._replace( - category=ElectricalComponentCategory.GRID_CONNECTION_POINT, + category=1, # GRID_CONNECTION_POINT category_specific_info={"type": "BATTERY_TYPE_LI_ION"}, category_mismatched=True, ) @@ -109,7 +109,7 @@ def test_category_mismatch( assert not minor_issues assert isinstance(component, MismatchedCategoryElectricalComponent) assert_base_data(base_data, component) - assert component.category == ElectricalComponentCategory.GRID_CONNECTION_POINT + assert component.category == 1 @pytest.mark.parametrize( diff --git a/tests/microgrid/electrical_components/test_battery.py b/tests/microgrid/electrical_components/test_battery.py index 86600aee..466c7663 100644 --- a/tests/microgrid/electrical_components/test_battery.py +++ b/tests/microgrid/electrical_components/test_battery.py @@ -50,7 +50,7 @@ def test_abstract_battery_cannot_be_instantiated( id=component_id, microgrid_id=microgrid_id, name="test_battery", - type=BatteryType.LI_ION, + _type=1, _provides_telemetry=True, _accepts_control=True, ) @@ -103,7 +103,7 @@ def test_unrecognized_battery_type( id=component_id, microgrid_id=microgrid_id, name="unrecognized_battery", - type=999, + _type=999, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, diff --git a/tests/microgrid/electrical_components/test_electrical_component_base.py b/tests/microgrid/electrical_components/test_electrical_component_base.py index e7fb7e88..0c6ba192 100644 --- a/tests/microgrid/electrical_components/test_electrical_component_base.py +++ b/tests/microgrid/electrical_components/test_electrical_component_base.py @@ -4,7 +4,6 @@ """Tests for the ElectricalComponent base class and its functionality.""" from datetime import datetime, timezone -from typing import Literal from unittest.mock import Mock, patch import pytest @@ -14,7 +13,6 @@ from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ( ElectricalComponent, - ElectricalComponentCategory, ElectricalComponentId, ) from frequenz.client.common.types import Lifetime @@ -23,10 +21,6 @@ class _TestElectricalComponent(ElectricalComponent): """A simple electrical component implementation for testing.""" - category: Literal[ElectricalComponentCategory.UNSPECIFIED] = ( - ElectricalComponentCategory.UNSPECIFIED - ) - def test_base_creation_fails() -> None: """Test that ElectricalComponent base class cannot be instantiated directly.""" @@ -36,7 +30,7 @@ def test_base_creation_fails() -> None: _ = ElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(1), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, _provides_telemetry=True, _accepts_control=True, ) @@ -48,7 +42,7 @@ def test_direct_construction_without_flag_raises() -> None: _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(2), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, _provides_telemetry=True, _accepts_control=True, ) @@ -59,7 +53,7 @@ def test_creation_with_defaults() -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(2), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -81,7 +75,7 @@ def test_creation_full() -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(2), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, name="test-component", model="Test Manufacturer Test Model", metric_config_bounds=metric_config_bounds, @@ -102,7 +96,7 @@ def test_accessors_return_values_when_set() -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(2), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, _provides_telemetry=True, _accepts_control=False, _allow_construction=True, @@ -117,7 +111,7 @@ def test_accessors_raise_when_unspecified() -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(2), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, _provides_telemetry=None, _accepts_control=None, _allow_construction=True, @@ -142,7 +136,7 @@ def test_str(name: str | None, expected_str: str) -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(2), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, name=name, _provides_telemetry=True, _accepts_control=True, @@ -162,7 +156,7 @@ def test_operational_at(is_operational: bool) -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(1), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, operational_lifetime=mock_lifetime, _provides_telemetry=True, _accepts_control=True, @@ -188,7 +182,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: component = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(1), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, operational_lifetime=mock_lifetime, _provides_telemetry=True, _accepts_control=True, @@ -203,7 +197,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: COMPONENT = _TestElectricalComponent( id=ElectricalComponentId(1), microgrid_id=MicrogridId(1), - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, name="test", metric_config_bounds={Metric.AC_POWER_ACTIVE: Bounds(lower=-100.0, upper=100.0)}, category_specific_metadata={"key": "value"}, @@ -215,7 +209,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: DIFFERENT_NONHASHABLE = _TestElectricalComponent( id=COMPONENT.id, microgrid_id=COMPONENT.microgrid_id, - category=COMPONENT.category, + _category=0, name=COMPONENT.name, metric_config_bounds={Metric.AC_POWER_ACTIVE: Bounds(lower=-200.0, upper=200.0)}, category_specific_metadata={"different": "metadata"}, @@ -227,7 +221,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: DIFFERENT_NAME = _TestElectricalComponent( id=COMPONENT.id, microgrid_id=COMPONENT.microgrid_id, - category=COMPONENT.category, + _category=0, name="different", metric_config_bounds=COMPONENT.metric_config_bounds, category_specific_metadata=COMPONENT.category_specific_metadata, @@ -239,7 +233,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: DIFFERENT_ID = _TestElectricalComponent( id=ElectricalComponentId(2), microgrid_id=COMPONENT.microgrid_id, - category=COMPONENT.category, + _category=0, name=COMPONENT.name, metric_config_bounds=COMPONENT.metric_config_bounds, category_specific_metadata=COMPONENT.category_specific_metadata, @@ -251,7 +245,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: DIFFERENT_MICROGRID_ID = _TestElectricalComponent( id=COMPONENT.id, microgrid_id=MicrogridId(2), - category=COMPONENT.category, + _category=0, name=COMPONENT.name, metric_config_bounds=COMPONENT.metric_config_bounds, category_specific_metadata=COMPONENT.category_specific_metadata, @@ -263,7 +257,7 @@ def test_is_operational_now(mock_datetime: Mock) -> None: DIFFERENT_BOTH_ID = _TestElectricalComponent( id=ElectricalComponentId(2), microgrid_id=MicrogridId(2), - category=COMPONENT.category, + _category=0, name=COMPONENT.name, metric_config_bounds=COMPONENT.metric_config_bounds, category_specific_metadata=COMPONENT.category_specific_metadata, diff --git a/tests/microgrid/electrical_components/test_ev_charger.py b/tests/microgrid/electrical_components/test_ev_charger.py index 48c45d96..9d1a1324 100644 --- a/tests/microgrid/electrical_components/test_ev_charger.py +++ b/tests/microgrid/electrical_components/test_ev_charger.py @@ -51,7 +51,7 @@ def test_abstract_ev_charger_cannot_be_instantiated( id=component_id, microgrid_id=microgrid_id, name="test_charger", - type=EvChargerType.AC, + _type=1, _provides_telemetry=True, _accepts_control=True, ) @@ -105,7 +105,7 @@ def test_unrecognized_ev_charger_type( id=component_id, microgrid_id=microgrid_id, name="unrecognized_charger", - type=999, # type is passed here for UnrecognizedEvCharger + _type=999, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, diff --git a/tests/microgrid/electrical_components/test_inverter.py b/tests/microgrid/electrical_components/test_inverter.py index 4e03eb66..276f6bfe 100644 --- a/tests/microgrid/electrical_components/test_inverter.py +++ b/tests/microgrid/electrical_components/test_inverter.py @@ -51,7 +51,7 @@ def test_abstract_inverter_cannot_be_instantiated( id=component_id, microgrid_id=microgrid_id, name="test_inverter", - type=InverterType.BATTERY, + _type=1, _provides_telemetry=True, _accepts_control=True, ) @@ -105,7 +105,7 @@ def test_unrecognized_inverter_type( id=component_id, microgrid_id=microgrid_id, name="unrecognized_inverter", - type=999, # type is passed here for UnrecognizedInverter + _type=999, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, diff --git a/tests/microgrid/electrical_components/test_problematic.py b/tests/microgrid/electrical_components/test_problematic.py index 2a06c0cc..c8889991 100644 --- a/tests/microgrid/electrical_components/test_problematic.py +++ b/tests/microgrid/electrical_components/test_problematic.py @@ -39,7 +39,7 @@ def test_abstract_problematic_electrical_component_cannot_be_instantiated( id=component_id, microgrid_id=microgrid_id, name="test_problematic", - category=ElectricalComponentCategory.UNSPECIFIED, + _category=0, _provides_telemetry=True, _accepts_control=True, ) @@ -68,12 +68,12 @@ def test_mismatched_category_component_with_known_category( component_id: ElectricalComponentId, microgrid_id: MicrogridId ) -> None: """Test MismatchedCategoryElectricalComponent with a known category.""" - expected_category = ElectricalComponentCategory.BATTERY + expected_category = 5 # Battery component = MismatchedCategoryElectricalComponent( id=component_id, microgrid_id=microgrid_id, name="mismatched_battery", - category=expected_category, + _category=expected_category, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -94,7 +94,7 @@ def test_mismatched_category_component_with_unrecognized_category( id=component_id, microgrid_id=microgrid_id, name="mismatched_unrecognized", - category=expected_category, + _category=expected_category, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, @@ -114,7 +114,7 @@ def test_unrecognized_component_type( id=component_id, microgrid_id=microgrid_id, name="unrecognized_component", - category=999, + _category=999, _provides_telemetry=True, _accepts_control=True, _allow_construction=True, From 0789c9b28986a56ae9edf93742f8dcdac897bf3e Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 24 Jun 2026 11:36:09 +0000 Subject: [PATCH 09/11] Deprecate the electrical component category and type enums `ElectricalComponentCategory`, `BatteryType`, `InverterType` and `EvChargerType` are all replaced by the electrical component class hierarchy plus the typed `electrical_component_class_to_proto()` / `electrical_component_class_from_proto()` converters added earlier in this phase. Callers should use `isinstance` checks on concrete classes (e.g. `LiIonBattery`, `PvInverter`, `AcEvCharger`) instead of comparing to a category/type enum member. Mark each enum and its members as deprecated: * Each class switches from `enum.Enum` to `frequenz.core.enum.Enum` and gains `@typing_extensions.deprecated(...)` so calling `XxxType(value)` or `XxxType['NAME']` warns. * Every member is wrapped in `core_enum.deprecated_member(value, msg)` so attribute and subscript lookups warn too. Iteration is left warning-free (a verified property of `frequenz.core.enum`) so the parity tests can list all member names without spurious noise. Mark the matching `*_from_proto` / `*_to_proto` converter pairs as deprecated too. Their bodies wrap the inner enum lookup or `enum_from_proto()` call in `warnings.catch_warnings()` with a `DeprecationWarning` filter so the converter itself only emits the function-level warning the caller sees; the internal enum-member access stays silent. The parity test subclasses pin `deprecated_members = frozenset(m.name for m in )`, which makes `EnumParityTest._maybe_ignore_deprecation` silence the parity checks while `test_deprecated_members_warn` keeps asserting each member still warns. A new `test_enum_deprecation.py` locks in the public contract: member access warns, the deprecated converters warn, and `electrical_component_class_to_proto()` (the replacement) stays warning-clean. Signed-off-by: Leandro Lucarella --- .../electrical_components/_battery.py | 37 ++++- .../electrical_components/_category.py | 78 +++++++---- .../electrical_components/_ev_charger.py | 39 +++++- .../electrical_components/_inverter.py | 39 +++++- .../proto/v1alpha8/_battery.py | 19 ++- .../proto/v1alpha8/_category.py | 23 +++- .../proto/v1alpha8/_ev_charger.py | 19 ++- .../proto/v1alpha8/_inverter.py | 19 ++- .../client/common/test/enum_parity.py | 41 +++++- .../proto/v1alpha8/test_battery.py | 2 + .../proto/v1alpha8/test_category.py | 2 + .../proto/v1alpha8/test_enum_deprecation.py | 127 ++++++++++++++++++ .../proto/v1alpha8/test_ev_charger.py | 2 + .../proto/v1alpha8/test_inverter.py | 2 + 14 files changed, 393 insertions(+), 56 deletions(-) create mode 100644 tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/_battery.py index 5a41d47d..4d3afaf1 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_battery.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_battery.py @@ -4,26 +4,51 @@ """Battery electrical component.""" import dataclasses -import enum import warnings from typing import Any, Self, TypeAlias import typing_extensions +from frequenz.core import enum as core_enum from ._electrical_component import ElectricalComponent +_BATTERY_TYPE_DEPRECATION_MESSAGE = ( + "BatteryType is deprecated; identify batteries via isinstance() on the " + "class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." +) + + +def _battery_type_member_message(name: str) -> str: + """Build the deprecation message for a specific `BatteryType` member. + + Args: + name: The enum member name. + + Returns: + The full deprecation message for that member. + """ + return ( + f"BatteryType.{name} is deprecated; identify batteries via isinstance() " + "on the class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) -@enum.unique -class BatteryType(enum.Enum): + +@typing_extensions.deprecated(_BATTERY_TYPE_DEPRECATION_MESSAGE) +@core_enum.unique +class BatteryType(core_enum.Enum): """The known types of batteries.""" - UNSPECIFIED = 0 + UNSPECIFIED = core_enum.deprecated_member( + 0, _battery_type_member_message("UNSPECIFIED") + ) """The battery type is unspecified.""" - LI_ION = 1 + LI_ION = core_enum.deprecated_member(1, _battery_type_member_message("LI_ION")) """Lithium-ion (Li-ion) battery.""" - NA_ION = 2 + NA_ION = core_enum.deprecated_member(2, _battery_type_member_message("NA_ION")) """Sodium-ion (Na-ion) battery.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_category.py b/src/frequenz/client/common/microgrid/electrical_components/_category.py index 8f8dbbb0..0b2227a8 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_category.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_category.py @@ -3,72 +3,104 @@ """Electrical component categories.""" -import enum +import typing_extensions +from frequenz.core import enum as core_enum +_DEPRECATION_MESSAGE = ( + "ElectricalComponentCategory is deprecated; use the ElectricalComponent class " + "hierarchy (isinstance) or electrical_component_class_to_proto()/" + "electrical_component_class_from_proto()." +) -@enum.unique -class ElectricalComponentCategory(enum.Enum): + +def _member_message(name: str) -> str: + """Build the deprecation message for a specific enum member. + + Args: + name: The enum member name. + + Returns: + The full deprecation message for that member. + """ + return ( + f"ElectricalComponentCategory.{name} is deprecated; use the " + "ElectricalComponent class hierarchy (isinstance) or " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) + + +@typing_extensions.deprecated(_DEPRECATION_MESSAGE) +@core_enum.unique +class ElectricalComponentCategory(core_enum.Enum): """Possible types of microgrid electrical component.""" - UNSPECIFIED = 0 + UNSPECIFIED = core_enum.deprecated_member(0, _member_message("UNSPECIFIED")) """The component category is unspecified. This should not be used.""" - GRID_CONNECTION_POINT = 1 + GRID_CONNECTION_POINT = core_enum.deprecated_member( + 1, _member_message("GRID_CONNECTION_POINT") + ) """The point where the local microgrid is connected to the grid.""" - METER = 2 + METER = core_enum.deprecated_member(2, _member_message("METER")) """A meter, for measuring electrical metrics, e.g., current, voltage, etc.""" - INVERTER = 3 + INVERTER = core_enum.deprecated_member(3, _member_message("INVERTER")) """An inverter that converts DC to AC power and vice versa.""" - CONVERTER = 4 + CONVERTER = core_enum.deprecated_member(4, _member_message("CONVERTER")) """An electricity converter, e.g., a DC-DC converter.""" - BATTERY = 5 + BATTERY = core_enum.deprecated_member(5, _member_message("BATTERY")) """A battery energy storage system.""" - EV_CHARGER = 6 + EV_CHARGER = core_enum.deprecated_member(6, _member_message("EV_CHARGER")) """A station for charging electrical vehicles.""" - BREAKER = 7 + BREAKER = core_enum.deprecated_member(7, _member_message("BREAKER")) """A circuit breaker, providing protection and switching by disconnecting circuits.""" - PRECHARGER = 8 + PRECHARGER = core_enum.deprecated_member(8, _member_message("PRECHARGER")) """A precharger, used for preparing electrical circuits for switching on.""" - CHP = 9 + CHP = core_enum.deprecated_member(9, _member_message("CHP")) """A combined heat and power (CHP) plant. It generates electricity and useful heat from a single energy source. """ - ELECTROLYZER = 10 + ELECTROLYZER = core_enum.deprecated_member(10, _member_message("ELECTROLYZER")) """A device for splitting water into hydrogen and oxygen using electricity.""" - POWER_TRANSFORMER = 11 + POWER_TRANSFORMER = core_enum.deprecated_member( + 11, _member_message("POWER_TRANSFORMER") + ) """A transformer, used for changing the voltage of electrical circuits.""" - HVAC = 12 + HVAC = core_enum.deprecated_member(12, _member_message("HVAC")) """A heating, ventilation, and air conditioning (HVAC) system.""" - PLC = 13 + PLC = core_enum.deprecated_member(13, _member_message("PLC")) """A programmable logic controller (PLC).""" - CRYPTO_MINER = 14 + CRYPTO_MINER = core_enum.deprecated_member(14, _member_message("CRYPTO_MINER")) """A device for mining cryptocurrencies.""" - STATIC_TRANSFER_SWITCH = 15 + STATIC_TRANSFER_SWITCH = core_enum.deprecated_member( + 15, _member_message("STATIC_TRANSFER_SWITCH") + ) """A static transfer switch, used for switching between power sources.""" - UNINTERRUPTIBLE_POWER_SUPPLY = 16 + UNINTERRUPTIBLE_POWER_SUPPLY = core_enum.deprecated_member( + 16, _member_message("UNINTERRUPTIBLE_POWER_SUPPLY") + ) """An uninterruptible power supply (UPS), used to provide backup power.""" - CAPACITOR_BANK = 17 + CAPACITOR_BANK = core_enum.deprecated_member(17, _member_message("CAPACITOR_BANK")) """A capacitor bank, used for power factor correction and reactive power compensation.""" - WIND_TURBINE = 18 + WIND_TURBINE = core_enum.deprecated_member(18, _member_message("WIND_TURBINE")) """A wind turbine, used to generate electricity from wind energy.""" - STEAM_BOILER = 19 + STEAM_BOILER = core_enum.deprecated_member(19, _member_message("STEAM_BOILER")) """A steam boiler, used to generate steam for heating or industrial processes.""" 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 ca277d10..43b335d5 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py @@ -4,29 +4,54 @@ """Electric vehicle (EV) charger electrical component.""" import dataclasses -import enum import warnings from typing import Any, Self, TypeAlias import typing_extensions +from frequenz.core import enum as core_enum from ._electrical_component import ElectricalComponent +_EV_CHARGER_TYPE_DEPRECATION_MESSAGE = ( + "EvChargerType is deprecated; identify EV chargers via isinstance() on the " + "class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." +) + + +def _ev_charger_type_member_message(name: str) -> str: + """Build the deprecation message for a specific `EvChargerType` member. + + Args: + name: The enum member name. + + Returns: + The full deprecation message for that member. + """ + return ( + f"EvChargerType.{name} is deprecated; identify EV chargers via isinstance() " + "on the class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) -@enum.unique -class EvChargerType(enum.Enum): + +@typing_extensions.deprecated(_EV_CHARGER_TYPE_DEPRECATION_MESSAGE) +@core_enum.unique +class EvChargerType(core_enum.Enum): """The known types of electric vehicle (EV) chargers.""" - UNSPECIFIED = 0 + UNSPECIFIED = core_enum.deprecated_member( + 0, _ev_charger_type_member_message("UNSPECIFIED") + ) """The type of the EV charger is unspecified.""" - AC = 1 + AC = core_enum.deprecated_member(1, _ev_charger_type_member_message("AC")) """The EV charging station supports AC charging only.""" - DC = 2 + DC = core_enum.deprecated_member(2, _ev_charger_type_member_message("DC")) """The EV charging station supports DC charging only.""" - HYBRID = 3 + HYBRID = core_enum.deprecated_member(3, _ev_charger_type_member_message("HYBRID")) """The EV charging station supports both AC and DC.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py index 0ef1af7b..0c7aafde 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py @@ -4,29 +4,54 @@ """Inverter electrical component.""" import dataclasses -import enum import warnings from typing import Any, Self, TypeAlias import typing_extensions +from frequenz.core import enum as core_enum from ._electrical_component import ElectricalComponent +_INVERTER_TYPE_DEPRECATION_MESSAGE = ( + "InverterType is deprecated; identify inverters via isinstance() on the " + "class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." +) + + +def _inverter_type_member_message(name: str) -> str: + """Build the deprecation message for a specific `InverterType` member. + + Args: + name: The enum member name. + + Returns: + The full deprecation message for that member. + """ + return ( + f"InverterType.{name} is deprecated; identify inverters via isinstance() " + "on the class hierarchy, or convert with " + "electrical_component_class_to_proto()/electrical_component_class_from_proto()." + ) -@enum.unique -class InverterType(enum.Enum): + +@typing_extensions.deprecated(_INVERTER_TYPE_DEPRECATION_MESSAGE) +@core_enum.unique +class InverterType(core_enum.Enum): """The known types of inverters.""" - UNSPECIFIED = 0 + UNSPECIFIED = core_enum.deprecated_member( + 0, _inverter_type_member_message("UNSPECIFIED") + ) """The type of the inverter is unspecified.""" - BATTERY = 1 + BATTERY = core_enum.deprecated_member(1, _inverter_type_member_message("BATTERY")) """The inverter is a battery inverter.""" - PV = 2 + PV = core_enum.deprecated_member(2, _inverter_type_member_message("PV")) """The inverter is a PV inverter.""" - HYBRID = 3 + HYBRID = core_enum.deprecated_member(3, _inverter_type_member_message("HYBRID")) """The inverter is a hybrid inverter.""" diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_battery.py index 4634cb44..7bff12d8 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_battery.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_battery.py @@ -3,6 +3,9 @@ """Conversion of battery types to/from protobuf v1alpha8.""" +import warnings + +import typing_extensions from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) @@ -11,6 +14,10 @@ from ... import BatteryType +@typing_extensions.deprecated( + "battery_type_from_proto() is deprecated; use " + "electrical_component_class_from_proto() instead." +) def battery_type_from_proto( message: electrical_components_pb2.BatteryType.ValueType, ) -> BatteryType | int: @@ -23,9 +30,15 @@ def battery_type_from_proto( The corresponding [`BatteryType`][....BatteryType] enum member, or the raw [`int`][] if the protobuf value is not recognized. """ - return enum_from_proto(message, BatteryType) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return enum_from_proto(message, BatteryType) +@typing_extensions.deprecated( + "battery_type_to_proto() is deprecated; use " + "electrical_component_class_to_proto() instead." +) def battery_type_to_proto( battery_type: BatteryType, ) -> electrical_components_pb2.BatteryType.ValueType: @@ -37,4 +50,6 @@ def battery_type_to_proto( Returns: The corresponding protobuf `BatteryType` value. """ - return electrical_components_pb2.BatteryType.ValueType(battery_type.value) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return electrical_components_pb2.BatteryType.ValueType(battery_type.value) diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py index f57d2e53..1abc3305 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_category.py @@ -3,6 +3,9 @@ """Conversion of electrical component categories to/from protobuf v1alpha8.""" +import warnings + +import typing_extensions from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) @@ -11,6 +14,10 @@ from ... import ElectricalComponentCategory +@typing_extensions.deprecated( + "electrical_component_category_from_proto() is deprecated; use " + "electrical_component_class_from_proto() instead." +) def electrical_component_category_from_proto( message: electrical_components_pb2.ElectricalComponentCategory.ValueType, ) -> ElectricalComponentCategory | int: @@ -24,9 +31,15 @@ def electrical_component_category_from_proto( [`ElectricalComponentCategory`][....ElectricalComponentCategory] enum member, or the raw [`int`][] if the protobuf value is not recognized. """ - return enum_from_proto(message, ElectricalComponentCategory) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return enum_from_proto(message, ElectricalComponentCategory) +@typing_extensions.deprecated( + "electrical_component_category_to_proto() is deprecated; use " + "electrical_component_class_to_proto() instead." +) def electrical_component_category_to_proto( category: ElectricalComponentCategory, ) -> electrical_components_pb2.ElectricalComponentCategory.ValueType: @@ -40,6 +53,8 @@ def electrical_component_category_to_proto( Returns: The corresponding protobuf `ElectricalComponentCategory` value. """ - return electrical_components_pb2.ElectricalComponentCategory.ValueType( - category.value - ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return electrical_components_pb2.ElectricalComponentCategory.ValueType( + category.value + ) diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_ev_charger.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_ev_charger.py index 867f7681..b42a2ab8 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_ev_charger.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_ev_charger.py @@ -3,6 +3,9 @@ """Conversion of EV charger types to/from protobuf v1alpha8.""" +import warnings + +import typing_extensions from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) @@ -11,6 +14,10 @@ from ... import EvChargerType +@typing_extensions.deprecated( + "ev_charger_type_from_proto() is deprecated; use " + "electrical_component_class_from_proto() instead." +) def ev_charger_type_from_proto( message: electrical_components_pb2.EvChargerType.ValueType, ) -> EvChargerType | int: @@ -23,9 +30,15 @@ def ev_charger_type_from_proto( The corresponding [`EvChargerType`][....EvChargerType] enum member, or the raw [`int`][] if the protobuf value is not recognized. """ - return enum_from_proto(message, EvChargerType) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return enum_from_proto(message, EvChargerType) +@typing_extensions.deprecated( + "ev_charger_type_to_proto() is deprecated; use " + "electrical_component_class_to_proto() instead." +) def ev_charger_type_to_proto( ev_charger_type: EvChargerType, ) -> electrical_components_pb2.EvChargerType.ValueType: @@ -37,4 +50,6 @@ def ev_charger_type_to_proto( Returns: The corresponding protobuf `EvChargerType` value. """ - return electrical_components_pb2.EvChargerType.ValueType(ev_charger_type.value) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return electrical_components_pb2.EvChargerType.ValueType(ev_charger_type.value) diff --git a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_inverter.py b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_inverter.py index 9a606bfc..187dff88 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_inverter.py +++ b/src/frequenz/client/common/microgrid/electrical_components/proto/v1alpha8/_inverter.py @@ -3,6 +3,9 @@ """Conversion of inverter types to/from protobuf v1alpha8.""" +import warnings + +import typing_extensions from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) @@ -11,6 +14,10 @@ from ... import InverterType +@typing_extensions.deprecated( + "inverter_type_from_proto() is deprecated; use " + "electrical_component_class_from_proto() instead." +) def inverter_type_from_proto( message: electrical_components_pb2.InverterType.ValueType, ) -> InverterType | int: @@ -23,9 +30,15 @@ def inverter_type_from_proto( The corresponding [`InverterType`][....InverterType] enum member, or the raw [`int`][] if the protobuf value is not recognized. """ - return enum_from_proto(message, InverterType) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return enum_from_proto(message, InverterType) +@typing_extensions.deprecated( + "inverter_type_to_proto() is deprecated; use " + "electrical_component_class_to_proto() instead." +) def inverter_type_to_proto( inverter_type: InverterType, ) -> electrical_components_pb2.InverterType.ValueType: @@ -37,4 +50,6 @@ def inverter_type_to_proto( Returns: The corresponding protobuf `InverterType` value. """ - return electrical_components_pb2.InverterType.ValueType(inverter_type.value) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return electrical_components_pb2.InverterType.ValueType(inverter_type.value) diff --git a/src/frequenz/client/common/test/enum_parity.py b/src/frequenz/client/common/test/enum_parity.py index 44e82fcb..9e3c2e9f 100644 --- a/src/frequenz/client/common/test/enum_parity.py +++ b/src/frequenz/client/common/test/enum_parity.py @@ -58,6 +58,11 @@ class EnumParityTest: that warning and otherwise treat the member like any known value. * A name listed in `absent_members` is expected to be missing from the Python enum while the protobuf enum still defines it. + * Set `silence_deprecations` to `True` when the ``from_proto`` / + ``to_proto`` converters are themselves deprecated (the whole enum is + being retired). The parity checks then suppress the + `DeprecationWarning` those converters emit, leaving the member-level + deprecation checks (`deprecated_members`) untouched. Subclasses are free to add further `test_*` methods. @@ -122,6 +127,13 @@ class TestEventParity(EnumParityTest): defined in the protobuf enum. """ + silence_deprecations: ClassVar[bool] = False + """Whether the [`from_proto`][..from_proto]/[`to_proto`][..to_proto] converters are deprecated. + + When `True`, the parity checks suppress the `DeprecationWarning` emitted by + calling them (member-level deprecation checks are unaffected). + """ + def pytest_generate_tests(self, metafunc: pytest.Metafunc) -> None: """Parametrize `pb_name` and `member` from the configured enums. @@ -156,6 +168,26 @@ def _maybe_ignore_deprecation(self, name: str) -> Iterator[None]: else: yield + @contextlib.contextmanager + def _maybe_silence_converter_deprecation(self) -> Iterator[None]: + """Suppress converter `DeprecationWarning`s when `silence_deprecations` is set. + + Some enums expose `from_proto` / `to_proto` converters that are + themselves deprecated (the whole enum is being retired). Calling them in + the parity checks emits a `DeprecationWarning` unrelated to member + deprecation, which would otherwise fail the warning-clean checks. + + Yields: + Control to the wrapped block, with `DeprecationWarning` suppressed + when `silence_deprecations` is `True`. + """ + if self.silence_deprecations: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + yield + else: + yield + def test_proto_enum_matches_enum_name(self, pb_name: str) -> None: """Test that all known protobuf enum names match a Python member. @@ -226,7 +258,8 @@ def test_from_proto(self, pb_name: str) -> None: assert result.value == pb_value assert result.name == stripped return - result = self.from_proto(pb_value) + with self._maybe_silence_converter_deprecation(): + result = self.from_proto(pb_value) if pb_value in [m.value for m in self.python_enum]: assert result is self.python_enum(pb_value) else: @@ -236,7 +269,8 @@ def test_from_proto_unknown(self) -> None: """Test conversion from protobuf for unknown values returns the int.""" max_value = max(m.value for m in self.python_enum) unknown_pb_value = self.proto_enum.ValueType(max_value + 1) - result = self.from_proto(unknown_pb_value) + with self._maybe_silence_converter_deprecation(): + result = self.from_proto(unknown_pb_value) assert isinstance(result, int) assert result == unknown_pb_value @@ -246,7 +280,8 @@ def test_to_proto(self, member: Enum) -> None: Args: member: The Python enum member to convert. """ - pb_value = self.to_proto(member) + with self._maybe_silence_converter_deprecation(): + pb_value = self.to_proto(member) assert pb_value == member.value def test_deprecated_members_warn(self) -> None: diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_battery.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_battery.py index e6c85f1a..dab26f9d 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_battery.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_battery.py @@ -23,3 +23,5 @@ class TestBatteryTypeParity(EnumParityTest): name_prefix = "BATTERY_TYPE_" from_proto = staticmethod(battery_type_from_proto) to_proto = staticmethod(battery_type_to_proto) + deprecated_members = frozenset(m.name for m in BatteryType) + silence_deprecations = True diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_category.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_category.py index 27dc7271..1848e0a6 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_category.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_category.py @@ -25,3 +25,5 @@ class TestElectricalComponentCategoryParity(EnumParityTest): name_prefix = "ELECTRICAL_COMPONENT_CATEGORY_" from_proto = staticmethod(electrical_component_category_from_proto) to_proto = staticmethod(electrical_component_category_to_proto) + deprecated_members = frozenset(m.name for m in ElectricalComponentCategory) + silence_deprecations = True diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py new file mode 100644 index 00000000..3ac2fab3 --- /dev/null +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_enum_deprecation.py @@ -0,0 +1,127 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for deprecation of the category/type enums and their proto converters.""" + +import warnings + +import pytest +from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( + electrical_components_pb2, +) + +from frequenz.client.common.microgrid.electrical_components import ( + BatteryType, + ElectricalComponentCategory, + EvChargerType, + InverterType, + LiIonBattery, +) +from frequenz.client.common.microgrid.electrical_components.proto.v1alpha8 import ( + battery_type_from_proto, + battery_type_to_proto, + electrical_component_category_from_proto, + electrical_component_category_to_proto, + electrical_component_class_to_proto, + ev_charger_type_from_proto, + ev_charger_type_to_proto, + inverter_type_from_proto, + inverter_type_to_proto, +) + + +def test_electrical_component_category_member_warns() -> None: + """Accessing an `ElectricalComponentCategory` member must warn.""" + with pytest.deprecated_call(): + _ = ElectricalComponentCategory.BATTERY + + +def test_battery_type_member_warns() -> None: + """Accessing a `BatteryType` member must warn.""" + with pytest.deprecated_call(): + _ = BatteryType.LI_ION + + +def test_inverter_type_member_warns() -> None: + """Accessing an `InverterType` member must warn.""" + with pytest.deprecated_call(): + _ = InverterType.PV + + +def test_ev_charger_type_member_warns() -> None: + """Accessing an `EvChargerType` member must warn.""" + with pytest.deprecated_call(): + _ = EvChargerType.AC + + +def test_electrical_component_category_to_proto_warns() -> None: + """Calling `electrical_component_category_to_proto` must warn.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + member = ElectricalComponentCategory.BATTERY + with pytest.deprecated_call(): + _ = electrical_component_category_to_proto(member) + + +def test_electrical_component_category_from_proto_warns() -> None: + """Calling `electrical_component_category_from_proto` must warn.""" + with pytest.deprecated_call(): + _ = electrical_component_category_from_proto( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY + ) + + +def test_battery_type_to_proto_warns() -> None: + """Calling `battery_type_to_proto` must warn.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + member = BatteryType.LI_ION + with pytest.deprecated_call(): + _ = battery_type_to_proto(member) + + +def test_battery_type_from_proto_warns() -> None: + """Calling `battery_type_from_proto` must warn.""" + with pytest.deprecated_call(): + _ = battery_type_from_proto(electrical_components_pb2.BATTERY_TYPE_LI_ION) + + +def test_inverter_type_to_proto_warns() -> None: + """Calling `inverter_type_to_proto` must warn.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + member = InverterType.PV + with pytest.deprecated_call(): + _ = inverter_type_to_proto(member) + + +def test_inverter_type_from_proto_warns() -> None: + """Calling `inverter_type_from_proto` must warn.""" + with pytest.deprecated_call(): + _ = inverter_type_from_proto(electrical_components_pb2.INVERTER_TYPE_PV) + + +def test_ev_charger_type_to_proto_warns() -> None: + """Calling `ev_charger_type_to_proto` must warn.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + member = EvChargerType.AC + with pytest.deprecated_call(): + _ = ev_charger_type_to_proto(member) + + +def test_ev_charger_type_from_proto_warns() -> None: + """Calling `ev_charger_type_from_proto` must warn.""" + with pytest.deprecated_call(): + _ = ev_charger_type_from_proto(electrical_components_pb2.EV_CHARGER_TYPE_AC) + + +def test_class_to_proto_does_not_warn() -> None: + """The non-deprecated `electrical_component_class_to_proto` must NOT warn.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + result = electrical_component_class_to_proto(LiIonBattery) + assert result == ( + electrical_components_pb2.ELECTRICAL_COMPONENT_CATEGORY_BATTERY, + electrical_components_pb2.BATTERY_TYPE_LI_ION, + ) diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_ev_charger.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_ev_charger.py index c55d6b97..5fdf31a5 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_ev_charger.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_ev_charger.py @@ -23,3 +23,5 @@ class TestEvChargerTypeParity(EnumParityTest): name_prefix = "EV_CHARGER_TYPE_" from_proto = staticmethod(ev_charger_type_from_proto) to_proto = staticmethod(ev_charger_type_to_proto) + deprecated_members = frozenset(m.name for m in EvChargerType) + silence_deprecations = True diff --git a/tests/microgrid/electrical_components/proto/v1alpha8/test_inverter.py b/tests/microgrid/electrical_components/proto/v1alpha8/test_inverter.py index bb693f47..602959c1 100644 --- a/tests/microgrid/electrical_components/proto/v1alpha8/test_inverter.py +++ b/tests/microgrid/electrical_components/proto/v1alpha8/test_inverter.py @@ -23,3 +23,5 @@ class TestInverterTypeParity(EnumParityTest): name_prefix = "INVERTER_TYPE_" from_proto = staticmethod(inverter_type_from_proto) to_proto = staticmethod(inverter_type_to_proto) + deprecated_members = frozenset(m.name for m in InverterType) + silence_deprecations = True From c4136ac06cc6d40d20a68d604a056e5f60facd07 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 25 Jun 2026 14:27:55 +0200 Subject: [PATCH 10/11] Reuse new mappings for `electrical_component_from_proto_with_issues()` The previous commit introduced some mappings that were useful not only for the new class converters but are also useful for the existing message converters. We reuse them and remove the old similar mapping we had for the message converter. Signed-off-by: Leandro Lucarella --- .../proto/v1alpha8/_electrical_component.py | 286 +++++++----------- 1 file changed, 102 insertions(+), 184 deletions(-) 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 b8d83124..17998519 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 @@ -998,7 +998,7 @@ def _electrical_component_base_from_proto_with_issues( ) -# pylint: disable-next=too-many-locals, too-many-branches +# pylint: disable-next=too-many-locals,too-many-branches,too-many-return-statements def electrical_component_from_proto_with_issues( message: electrical_components_pb2.ElectricalComponent, *, @@ -1035,7 +1035,6 @@ def electrical_component_from_proto_with_issues( category_specific_metadata=base_data.category_specific_info, metric_config_bounds=base_data.metric_config_bounds, ) - match base_data.category: case int(): return UnrecognizedElectricalComponent( @@ -1067,7 +1066,9 @@ def electrical_component_from_proto_with_issues( | ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY | ElectricalComponentCategory.CAPACITOR_BANK ): - return _trivial_category_to_class(base_data.category)( + return _TRIVIAL_TYPELESS_CLASS_BY_PROTO_CATEGORY[ + base_data.category.value + ]( id=base_data.component_id, microgrid_id=base_data.microgrid_id, name=base_data.name, @@ -1079,109 +1080,87 @@ def electrical_component_from_proto_with_issues( metric_config_bounds=base_data.metric_config_bounds, ) case ElectricalComponentCategory.BATTERY: - battery_enum_to_class: dict[ - BatteryType, type[UnspecifiedBattery | LiIonBattery | NaIonBattery] - ] = { - BatteryType.UNSPECIFIED: UnspecifiedBattery, - BatteryType.LI_ION: LiIonBattery, - BatteryType.NA_ION: NaIonBattery, - } - battery_type = enum_from_proto( - message.category_specific_info.battery.type, BatteryType - ) + raw_battery_type = message.category_specific_info.battery.type + battery_type = enum_from_proto(raw_battery_type, BatteryType) match battery_type: - case ( - BatteryType.UNSPECIFIED - | BatteryType.LI_ION - | BatteryType.NA_ION - ): - if battery_type is BatteryType.UNSPECIFIED: - major_issues.append("battery type is unspecified") - return battery_enum_to_class[battery_type]( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) + case BatteryType.UNSPECIFIED: + major_issues.append("battery type is unspecified") case int(): major_issues.append( f"battery type {battery_type} is unrecognized" ) - return UnrecognizedBattery( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - _type=message.category_specific_info.battery.type, - ) + case BatteryType.LI_ION | BatteryType.NA_ION: + pass case unexpected_battery_type: + # New type needs implementation assert_never(unexpected_battery_type) - case ElectricalComponentCategory.EV_CHARGER: - ev_charger_enum_to_class: dict[ - EvChargerType, - type[ - UnspecifiedEvCharger - | AcEvCharger - | DcEvCharger - | HybridEvCharger - ], - ] = { - EvChargerType.UNSPECIFIED: UnspecifiedEvCharger, - EvChargerType.AC: AcEvCharger, - EvChargerType.DC: DcEvCharger, - EvChargerType.HYBRID: HybridEvCharger, - } - ev_charger_type = enum_from_proto( - message.category_specific_info.ev_charger.type, EvChargerType + battery_class = _BATTERY_CLASS_BY_PROTO_TYPE.get(raw_battery_type) + if battery_class is None: + return UnrecognizedBattery( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + _type=raw_battery_type, + ) + return battery_class( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, ) + case ElectricalComponentCategory.EV_CHARGER: + raw_ev_charger_type = message.category_specific_info.ev_charger.type + ev_charger_type = enum_from_proto(raw_ev_charger_type, EvChargerType) match ev_charger_type: - case ( - EvChargerType.UNSPECIFIED - | EvChargerType.AC - | EvChargerType.DC - | EvChargerType.HYBRID - ): - if ev_charger_type is EvChargerType.UNSPECIFIED: - major_issues.append("ev_charger type is unspecified") - return ev_charger_enum_to_class[ev_charger_type]( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) + case EvChargerType.UNSPECIFIED: + major_issues.append("ev_charger type is unspecified") case int(): major_issues.append( f"ev_charger type {ev_charger_type} is unrecognized" ) - return UnrecognizedEvCharger( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - _type=message.category_specific_info.ev_charger.type, - ) + case EvChargerType.AC | EvChargerType.DC | EvChargerType.HYBRID: + pass case unexpected_ev_charger_type: + # New type needs implementation assert_never(unexpected_ev_charger_type) + ev_charger_class = _EV_CHARGER_CLASS_BY_PROTO_TYPE.get( + raw_ev_charger_type + ) + if ev_charger_class is None: + return UnrecognizedEvCharger( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + _type=raw_ev_charger_type, + ) + return ev_charger_class( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) case ElectricalComponentCategory.GRID_CONNECTION_POINT: rated_fuse_current = ( message.category_specific_info.grid_connection_point.rated_fuse_current @@ -1200,61 +1179,45 @@ def electrical_component_from_proto_with_issues( rated_fuse_current=rated_fuse_current, ) case ElectricalComponentCategory.INVERTER: - inverter_enum_to_class: dict[ - InverterType, - type[ - UnspecifiedInverter - | BatteryInverter - | PvInverter - | HybridInverter - ], - ] = { - InverterType.UNSPECIFIED: UnspecifiedInverter, - InverterType.BATTERY: BatteryInverter, - InverterType.PV: PvInverter, - InverterType.HYBRID: HybridInverter, - } - inverter_type = enum_from_proto( - message.category_specific_info.inverter.type, InverterType - ) + raw_inverter_type = message.category_specific_info.inverter.type + inverter_type = enum_from_proto(raw_inverter_type, InverterType) match inverter_type: - case ( - InverterType.UNSPECIFIED - | InverterType.BATTERY - | InverterType.PV - | InverterType.HYBRID - ): - if inverter_type is InverterType.UNSPECIFIED: - major_issues.append("inverter type is unspecified") - return inverter_enum_to_class[inverter_type]( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - ) + case InverterType.UNSPECIFIED: + major_issues.append("inverter type is unspecified") case int(): major_issues.append( f"inverter type {inverter_type} is unrecognized" ) - return UnrecognizedInverter( - id=base_data.component_id, - microgrid_id=base_data.microgrid_id, - name=base_data.name, - model=base_data.model, - operational_lifetime=base_data.lifetime, - _provides_telemetry=base_data.provides_telemetry, - _accepts_control=base_data.accepts_control, - _allow_construction=True, - metric_config_bounds=base_data.metric_config_bounds, - _type=message.category_specific_info.inverter.type, - ) + case InverterType.BATTERY | InverterType.PV | InverterType.HYBRID: + pass case unexpected_inverter_type: + # New type needs implementation assert_never(unexpected_inverter_type) + inverter_class = _INVERTER_CLASS_BY_PROTO_TYPE.get(raw_inverter_type) + if inverter_class is None: + return UnrecognizedInverter( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + _type=raw_inverter_type, + ) + return inverter_class( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + model=base_data.model, + operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, + _allow_construction=True, + metric_config_bounds=base_data.metric_config_bounds, + ) case ElectricalComponentCategory.POWER_TRANSFORMER: return PowerTransformer( id=base_data.component_id, @@ -1273,51 +1236,6 @@ def electrical_component_from_proto_with_issues( assert_never(unexpected_category) -_TrivialCategoryClass: TypeAlias = ( - UnspecifiedElectricalComponent - | Breaker - | CapacitorBank - | Chp - | Converter - | CryptoMiner - | Electrolyzer - | Hvac - | Meter - | Plc - | Precharger - | StaticTransferSwitch - | SteamBoiler - | UninterruptiblePowerSupply - | WindTurbine -) - - -def _trivial_category_to_class( - category: ElectricalComponentCategory, -) -> type[_TrivialCategoryClass]: - """Return the class corresponding to a trivial electrical component category.""" - mapping: dict[ElectricalComponentCategory, type[_TrivialCategoryClass]] = { - ElectricalComponentCategory.UNSPECIFIED: UnspecifiedElectricalComponent, - ElectricalComponentCategory.CHP: Chp, - ElectricalComponentCategory.CONVERTER: Converter, - ElectricalComponentCategory.CRYPTO_MINER: CryptoMiner, - ElectricalComponentCategory.ELECTROLYZER: Electrolyzer, - ElectricalComponentCategory.HVAC: Hvac, - ElectricalComponentCategory.METER: Meter, - ElectricalComponentCategory.PRECHARGER: Precharger, - ElectricalComponentCategory.BREAKER: Breaker, - ElectricalComponentCategory.STEAM_BOILER: SteamBoiler, - ElectricalComponentCategory.WIND_TURBINE: WindTurbine, - ElectricalComponentCategory.PLC: Plc, - ElectricalComponentCategory.STATIC_TRANSFER_SWITCH: StaticTransferSwitch, - ElectricalComponentCategory.UNINTERRUPTIBLE_POWER_SUPPLY: ( - UninterruptiblePowerSupply - ), - ElectricalComponentCategory.CAPACITOR_BANK: CapacitorBank, - } - return mapping[category] - - def _metric_config_bounds_from_proto( message: Sequence[electrical_components_pb2.MetricConfigBounds], *, From 559a364a620460b128a8a99d861d5136b7b634a8 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 24 Jun 2026 11:49:45 +0000 Subject: [PATCH 11/11] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5400b4be..55b6f062 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,38 @@ ## Upgrading +* The following enums are now deprecated and will be removed in a future release: + + * `frequenz.client.common.microgrid.electrical_components.ElectricalComponentCategory` + * `frequenz.client.common.microgrid.electrical_components.BatteryType` + * `frequenz.client.common.microgrid.electrical_components.InverterType` + * `frequenz.client.common.microgrid.electrical_components.EvChargerType` + + Accessing any member of these enums will emit a `DeprecationWarning`. Users are encouraged to switch to the `ElectricalComponent` class hierarchy (using `match` expressions or `isinstance()`) to identify components. + + Client implementers: To convert a component class to the protobuf enum values the server expects, use the new `electrical_component_class_to_proto()` / `electrical_component_class_from_proto()` converters (see New Features). + + For example, instead of: + + ```text + def filter_by(category: ElectricalComponentCategory) -> ...: + ... + ``` + + Use: + + ```text + def filter_by(component: ElectricalComponentTypes | type[ConvertibleElectricalComponentTypes]) -> ...: + category_value, sub_type_value = electrical_component_class_to_proto(component) + ... + ``` + + The related proto-layer converters (`electrical_component_category_to_proto`, `electrical_component_category_from_proto`, `battery_type_to_proto`, `battery_type_from_proto`, `inverter_type_to_proto`, `inverter_type_from_proto`, `ev_charger_type_to_proto`, `ev_charger_type_from_proto`) are also deprecated. + +* The `category` attribute of `ElectricalComponent` and the `type` attribute of `Battery`, `Inverter`, and `EvCharger` are now deprecated properties. Accessing them emits a `DeprecationWarning`. They no longer appear in `repr()`. + + Use `match` or `isinstance()` on the class hierarchy to identify components instead. + * The `UNSPECIFIED` members in the following enums are now deprecated: * `frequenz.client.common.grid.EnergyMarketCodeType` @@ -20,6 +52,20 @@ ## New Features +* Added 4 new electrical component classes for categories that previously collapsed into `UnrecognizedElectricalComponent`: + + * `frequenz.client.common.microgrid.electrical_components.Plc` (PLC, category 13) + * `frequenz.client.common.microgrid.electrical_components.StaticTransferSwitch` (category 15) + * `frequenz.client.common.microgrid.electrical_components.UninterruptiblePowerSupply` (UPS, category 16) + * `frequenz.client.common.microgrid.electrical_components.CapacitorBank` (category 17) + +* Added two new proto-layer converters in `frequenz.client.common.microgrid.electrical_components.proto.v1alpha8`: + + * `electrical_component_class_to_proto(component_class)` — converts a `ValidElectricalComponentTypes` class to the `(category, sub_type)` protobuf enum value tuple the server expects. Implemented with raw proto constants only (no deprecated wrapper enums), so it will continue to work after the wrapper enums are removed. + * `electrical_component_class_from_proto(category, sub_type=None)` — converts a raw `(category, sub_type)` protobuf enum value pair back to the corresponding `ConcreteElectricalComponentTypes` class. + + Added a few new type aliases to support them. In particular `frequenz.client.common.microgrid.electrical_components.proto.v1alpha8.ConvertibleElectricalComponentTypes` is the most useful (see Upgrading section above). + * Added new exceptions: * `frequenz.client.common.ClientCommonError` as a base exception for the package.