diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index 724ee2ec..84a2b0f3 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -15,6 +15,16 @@ from ._category import ElectricalComponentCategory from ._diagnostic_code import ElectricalComponentDiagnosticCode from ._electrical_component import ElectricalComponent +from ._ev_charger import ( + AcEvCharger, + DcEvCharger, + EvCharger, + EvChargerType, + EvChargerTypes, + HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) from ._ids import ElectricalComponentId from ._inverter import ( BatteryInverter, @@ -35,15 +45,21 @@ from ._state_code import ElectricalComponentStateCode __all__ = [ + "AcEvCharger", "Battery", "BatteryInverter", "BatteryType", "BatteryTypes", + "DcEvCharger", "ElectricalComponent", "ElectricalComponentCategory", "ElectricalComponentDiagnosticCode", "ElectricalComponentId", "ElectricalComponentStateCode", + "EvCharger", + "EvChargerType", + "EvChargerTypes", + "HybridEvCharger", "HybridInverter", "Inverter", "InverterType", @@ -55,8 +71,10 @@ "SolarInverter", "UnrecognizedBattery", "UnrecognizedComponent", + "UnrecognizedEvCharger", "UnrecognizedInverter", "UnspecifiedBattery", "UnspecifiedComponent", + "UnspecifiedEvCharger", "UnspecifiedInverter", ] diff --git a/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py b/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py new file mode 100644 index 00000000..b5ff1e5c --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_ev_charger.py @@ -0,0 +1,157 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Electric vehicle (EV) charger 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 EvChargerType(enum.Enum): + """The known types of electric vehicle (EV) chargers.""" + + UNSPECIFIED = electrical_components_pb2.EV_CHARGER_TYPE_UNSPECIFIED + """The type of the EV charger is unspecified.""" + + AC = electrical_components_pb2.EV_CHARGER_TYPE_AC + """The EV charging station supports AC charging only.""" + + DC = electrical_components_pb2.EV_CHARGER_TYPE_DC + """The EV charging station supports DC charging only.""" + + HYBRID = electrical_components_pb2.EV_CHARGER_TYPE_HYBRID + """The EV charging station supports both AC and DC.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EvCharger(ElectricalComponent): + """An abstract EV charger electrical component.""" + + category: Literal[ElectricalComponentCategory.EV_CHARGER] = ( + ElectricalComponentCategory.EV_CHARGER + ) + """The category of this electrical component. + + Note: + This should not be used normally, you should test if an electrical component + [`isinstance`][] of a concrete EV charger 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: EvChargerType | int + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][...UnrecognizedEvCharger]). + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is EvCharger: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedEvCharger(EvCharger): + """An EV charger of an unspecified type.""" + + type: Literal[EvChargerType.UNSPECIFIED] = EvChargerType.UNSPECIFIED + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][...UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class AcEvCharger(EvCharger): + """An EV charger that supports AC charging only.""" + + type: Literal[EvChargerType.AC] = EvChargerType.AC + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][...UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class DcEvCharger(EvCharger): + """An EV charger that supports DC charging only.""" + + type: Literal[EvChargerType.DC] = EvChargerType.DC + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][...UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class HybridEvCharger(EvCharger): + """An EV charger that supports both AC and DC charging.""" + + type: Literal[EvChargerType.HYBRID] = EvChargerType.HYBRID + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][...UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedEvCharger(EvCharger): + """An EV charger of an unrecognized type.""" + + type: int + """The unrecognized type of this EV charger.""" + + +EvChargerTypes: TypeAlias = ( + UnspecifiedEvCharger + | AcEvCharger + | DcEvCharger + | HybridEvCharger + | UnrecognizedEvCharger +) +"""All possible EV charger types.""" diff --git a/tests/microgrid/electrical_components/test_ev_charger.py b/tests/microgrid/electrical_components/test_ev_charger.py new file mode 100644 index 00000000..f9351909 --- /dev/null +++ b/tests/microgrid/electrical_components/test_ev_charger.py @@ -0,0 +1,120 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for EV charger components.""" + +import dataclasses + +import pytest + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.electrical_components import ( + AcEvCharger, + DcEvCharger, + ElectricalComponentCategory, + ElectricalComponentId, + EvCharger, + EvChargerType, + HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EvChargerTestCase: + """Test case for EV charger components.""" + + cls: type[UnspecifiedEvCharger | AcEvCharger | DcEvCharger | HybridEvCharger] + expected_type: EvChargerType + 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_ev_charger_cannot_be_instantiated( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """Test that EvCharger base class cannot be instantiated.""" + with pytest.raises(TypeError, match="Cannot instantiate EvCharger directly"): + EvCharger( + id=component_id, + microgrid_id=microgrid_id, + name="test_charger", + manufacturer="test_manufacturer", + model_name="test_model", + type=EvChargerType.AC, + ) + + +@pytest.mark.parametrize( + "case", + [ + EvChargerTestCase( + cls=UnspecifiedEvCharger, + expected_type=EvChargerType.UNSPECIFIED, + name="unspecified", + ), + EvChargerTestCase(cls=AcEvCharger, expected_type=EvChargerType.AC, name="ac"), + EvChargerTestCase(cls=DcEvCharger, expected_type=EvChargerType.DC, name="dc"), + EvChargerTestCase( + cls=HybridEvCharger, + expected_type=EvChargerType.HYBRID, + name="hybrid", + ), + ], + ids=lambda case: case.name, +) +def test_recognized_ev_charger_types( # Renamed from test_ev_charger_types + case: EvChargerTestCase, + component_id: ElectricalComponentId, + microgrid_id: MicrogridId, +) -> None: + """Test initialization and properties of different recognized EV charger types.""" + charger = case.cls( + id=component_id, + microgrid_id=microgrid_id, + name=case.name, + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert charger.id == component_id + assert charger.microgrid_id == microgrid_id + assert charger.name == case.name + assert charger.manufacturer == "test_manufacturer" + assert charger.model_name == "test_model" + assert charger.category == ElectricalComponentCategory.EV_CHARGER + assert charger.type == case.expected_type + + +def test_unrecognized_ev_charger_type( + component_id: ElectricalComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of unrecognized EV charger type.""" + charger = UnrecognizedEvCharger( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_charger", + manufacturer="test_manufacturer", + model_name="test_model", + type=999, # type is passed here for UnrecognizedEvCharger + ) + + assert charger.id == component_id + assert charger.microgrid_id == microgrid_id + assert charger.name == "unrecognized_charger" + assert charger.manufacturer == "test_manufacturer" + assert charger.model_name == "test_model" + assert charger.category == ElectricalComponentCategory.EV_CHARGER + assert charger.type == 999