diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index ce462b36..724ee2ec 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -16,6 +16,16 @@ from ._diagnostic_code import ElectricalComponentDiagnosticCode from ._electrical_component import ElectricalComponent from ._ids import ElectricalComponentId +from ._inverter import ( + BatteryInverter, + HybridInverter, + Inverter, + InverterType, + InverterTypes, + SolarInverter, + UnrecognizedInverter, + UnspecifiedInverter, +) from ._problematic import ( MismatchedCategoryComponent, ProblematicComponent, @@ -26,6 +36,7 @@ __all__ = [ "Battery", + "BatteryInverter", "BatteryType", "BatteryTypes", "ElectricalComponent", @@ -33,12 +44,19 @@ "ElectricalComponentDiagnosticCode", "ElectricalComponentId", "ElectricalComponentStateCode", + "HybridInverter", + "Inverter", + "InverterType", + "InverterTypes", "LiIonBattery", "MismatchedCategoryComponent", "NaIonBattery", "ProblematicComponent", + "SolarInverter", "UnrecognizedBattery", "UnrecognizedComponent", + "UnrecognizedInverter", "UnspecifiedBattery", "UnspecifiedComponent", + "UnspecifiedInverter", ] diff --git a/src/frequenz/client/common/microgrid/electrical_components/_inverter.py b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py new file mode 100644 index 00000000..3bcc1782 --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_inverter.py @@ -0,0 +1,157 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Inverter electrical component.""" + +import dataclasses +import enum +from typing import Any, Literal, Self, TypeAlias + +from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( + electrical_components_pb2, +) + +from ._category import ElectricalComponentCategory +from ._electrical_component import ElectricalComponent + + +@enum.unique +class InverterType(enum.Enum): + """The known types of inverters.""" + + UNSPECIFIED = electrical_components_pb2.INVERTER_TYPE_UNSPECIFIED + """The type of the inverter is unspecified.""" + + BATTERY = electrical_components_pb2.INVERTER_TYPE_BATTERY + """The inverter is a battery inverter.""" + + SOLAR = electrical_components_pb2.INVERTER_TYPE_PV + """The inverter is a solar inverter.""" + + HYBRID = electrical_components_pb2.INVERTER_TYPE_HYBRID + """The inverter is a hybrid inverter.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Inverter(ElectricalComponent): + """An abstract inverter electrical component.""" + + category: Literal[ElectricalComponentCategory.INVERTER] = ( + ElectricalComponentCategory.INVERTER + ) + """The category of this electrical component. + + Note: + This should not be used normally, you should test if an electrical component + [`isinstance`][] of a concrete electrical component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about a new category yet (i.e. for use with + [`UnrecognizedComponent`][...UnrecognizedComponent]) and in case some low level + code needs to know the category of an electrical component. + """ + + type: InverterType | int + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][...UnrecognizedInverter]). + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is Inverter: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedInverter(Inverter): + """An inverter of an unspecified type.""" + + type: Literal[InverterType.UNSPECIFIED] = InverterType.UNSPECIFIED + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][...UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class BatteryInverter(Inverter): + """A battery inverter.""" + + type: Literal[InverterType.BATTERY] = InverterType.BATTERY + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][...UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class SolarInverter(Inverter): + """A solar inverter.""" + + type: Literal[InverterType.SOLAR] = InverterType.SOLAR + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][...UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class HybridInverter(Inverter): + """A hybrid inverter.""" + + type: Literal[InverterType.HYBRID] = InverterType.HYBRID + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][...UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedInverter(Inverter): + """An inverter of an unrecognized type.""" + + type: int + """The unrecognized type of this inverter.""" + + +InverterTypes: TypeAlias = ( + UnspecifiedInverter + | BatteryInverter + | SolarInverter + | HybridInverter + | UnrecognizedInverter +) +"""All possible inverter types.""" diff --git a/tests/microgrid/electrical_components/test_inverter.py b/tests/microgrid/electrical_components/test_inverter.py new file mode 100644 index 00000000..ccddaa13 --- /dev/null +++ b/tests/microgrid/electrical_components/test_inverter.py @@ -0,0 +1,122 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Inverter components.""" + +import dataclasses + +import pytest + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.electrical_components import ( + BatteryInverter, + ElectricalComponentCategory, + ElectricalComponentId, + HybridInverter, + Inverter, + InverterType, + SolarInverter, + UnrecognizedInverter, + UnspecifiedInverter, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class InverterTestCase: + """Test case for Inverter components.""" + + cls: type[UnspecifiedInverter | BatteryInverter | SolarInverter | HybridInverter] + expected_type: InverterType + name: str + + +@pytest.fixture +def component_id() -> ElectricalComponentId: + """Provide a test component ID.""" + return ElectricalComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +def test_abstract_inverter_cannot_be_instantiated( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """Test that Inverter base class cannot be instantiated.""" + with pytest.raises(TypeError, match="Cannot instantiate Inverter directly"): + Inverter( + id=component_id, + microgrid_id=microgrid_id, + name="test_inverter", + manufacturer="test_manufacturer", + model_name="test_model", + type=InverterType.BATTERY, + ) + + +@pytest.mark.parametrize( + "case", + [ + InverterTestCase( + cls=UnspecifiedInverter, + expected_type=InverterType.UNSPECIFIED, + name="unspecified", + ), + InverterTestCase( + cls=BatteryInverter, expected_type=InverterType.BATTERY, name="battery" + ), + InverterTestCase( + cls=SolarInverter, expected_type=InverterType.SOLAR, name="solar" + ), + InverterTestCase( + cls=HybridInverter, expected_type=InverterType.HYBRID, name="hybrid" + ), + ], + ids=lambda case: case.name, +) +def test_recognized_inverter_types( + case: InverterTestCase, + component_id: ElectricalComponentId, + microgrid_id: MicrogridId, +) -> None: + """Test initialization and properties of different recognized inverter types.""" + inverter = case.cls( + id=component_id, + microgrid_id=microgrid_id, + name=case.name, + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert inverter.id == component_id + assert inverter.microgrid_id == microgrid_id + assert inverter.name == case.name + assert inverter.manufacturer == "test_manufacturer" + assert inverter.model_name == "test_model" + assert inverter.category == ElectricalComponentCategory.INVERTER + assert inverter.type == case.expected_type + + +def test_unrecognized_inverter_type( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of unrecognized inverter type.""" + inverter = UnrecognizedInverter( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_inverter", + manufacturer="test_manufacturer", + model_name="test_model", + type=999, # type is passed here for UnrecognizedInverter + ) + + assert inverter.id == component_id + assert inverter.microgrid_id == microgrid_id + assert inverter.name == "unrecognized_inverter" + assert inverter.manufacturer == "test_manufacturer" + assert inverter.model_name == "test_model" + assert inverter.category == ElectricalComponentCategory.INVERTER + assert inverter.type == 999