From e6c12e84347a1acf2fe4ab5ff7e0029d63225608 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 11 Jun 2026 08:56:28 +0000 Subject: [PATCH 1/4] Import battery component module from the microgrid client Signed-off-by: Leandro Lucarella --- .../electrical_components/_battery.py | 131 ++++++++++++++++++ .../electrical_components/test_battery.py | 117 ++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 src/frequenz/client/common/microgrid/electrical_components/_battery.py create mode 100644 tests/microgrid/electrical_components/test_battery.py diff --git a/src/frequenz/client/common/microgrid/electrical_components/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/_battery.py new file mode 100644 index 00000000..72c52eeb --- /dev/null +++ b/src/frequenz/client/common/microgrid/electrical_components/_battery.py @@ -0,0 +1,131 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Battery 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 ComponentCategory +from ._component import Component + + +@enum.unique +class BatteryType(enum.Enum): + """The known types of batteries.""" + + UNSPECIFIED = electrical_components_pb2.BATTERY_TYPE_UNSPECIFIED + """The battery type is unspecified.""" + + LI_ION = electrical_components_pb2.BATTERY_TYPE_LI_ION + """Lithium-ion (Li-ion) battery.""" + + NA_ION = electrical_components_pb2.BATTERY_TYPE_NA_ION + """Sodium-ion (Na-ion) battery.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Battery(Component): + """An abstract battery component.""" + + category: Literal[ComponentCategory.BATTERY] = ComponentCategory.BATTERY + """The category of this component. + + Note: + This should not be used normally, you should test if a component + [`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 a new category yet (i.e. for use with + [`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]) + and in case some low level code needs to know the category of a component. + """ + + type: BatteryType | int + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is Battery: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedBattery(Battery): + """A battery of a unspecified type.""" + + type: Literal[BatteryType.UNSPECIFIED] = BatteryType.UNSPECIFIED + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LiIonBattery(Battery): + """A Li-ion battery.""" + + type: Literal[BatteryType.LI_ION] = BatteryType.LI_ION + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class NaIonBattery(Battery): + """A Na-ion battery.""" + + type: Literal[BatteryType.NA_ION] = BatteryType.NA_ION + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedBattery(Battery): + """A battery of an unrecognized type.""" + + type: int + """The unrecognized type of this battery.""" + + +BatteryTypes: TypeAlias = ( + LiIonBattery | NaIonBattery | UnrecognizedBattery | UnspecifiedBattery +) +"""All possible battery types.""" diff --git a/tests/microgrid/electrical_components/test_battery.py b/tests/microgrid/electrical_components/test_battery.py new file mode 100644 index 00000000..f96f9e39 --- /dev/null +++ b/tests/microgrid/electrical_components/test_battery.py @@ -0,0 +1,117 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Battery components.""" + +import dataclasses + +import pytest + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + Battery, + BatteryType, + ComponentCategory, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class BatteryTestCase: + """Test case for battery components.""" + + cls: type[UnspecifiedBattery | LiIonBattery | NaIonBattery] + expected_type: BatteryType + name: str + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +def test_abstract_battery_cannot_be_instantiated( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test that Battery base class cannot be instantiated.""" + with pytest.raises(TypeError, match="Cannot instantiate Battery directly"): + Battery( + id=component_id, + microgrid_id=microgrid_id, + name="test_battery", + manufacturer="test_manufacturer", + model_name="test_model", + type=BatteryType.LI_ION, + ) + + +@pytest.mark.parametrize( + "case", + [ + BatteryTestCase( + cls=UnspecifiedBattery, + expected_type=BatteryType.UNSPECIFIED, + name="unspecified", + ), + BatteryTestCase( + cls=LiIonBattery, expected_type=BatteryType.LI_ION, name="li_ion" + ), + BatteryTestCase( + cls=NaIonBattery, expected_type=BatteryType.NA_ION, name="na_ion" + ), + ], + ids=lambda case: case.name, +) +def test_recognized_battery_types( + case: BatteryTestCase, component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of different battery types.""" + battery = case.cls( + id=component_id, + microgrid_id=microgrid_id, + name=case.name, + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert battery.id == component_id + assert battery.microgrid_id == microgrid_id + assert battery.name == case.name + assert battery.manufacturer == "test_manufacturer" + assert battery.model_name == "test_model" + assert battery.category == ComponentCategory.BATTERY + assert battery.type == case.expected_type + + +def test_unrecognized_battery_type( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of different battery types.""" + battery = UnrecognizedBattery( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_battery", + manufacturer="test_manufacturer", + model_name="test_model", + type=999, + ) + + assert battery.id == component_id + assert battery.microgrid_id == microgrid_id + assert battery.name == "unrecognized_battery" + assert battery.manufacturer == "test_manufacturer" + assert battery.model_name == "test_model" + assert battery.category == ComponentCategory.BATTERY + assert battery.type == 999 From 54ee6194bab55b64694ea83a1c56b1390849bb3c Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 11 Jun 2026 09:00:07 +0000 Subject: [PATCH 2/4] Generalize battery component docstrings Signed-off-by: Leandro Lucarella --- .../microgrid/electrical_components/_battery.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/_battery.py index 72c52eeb..5a8f980f 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_battery.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_battery.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2024 Frequenz Energy-as-a-Service GmbH -"""Battery component.""" +"""Battery electrical component.""" import dataclasses import enum @@ -12,7 +12,7 @@ ) from ._category import ComponentCategory -from ._component import Component +from ._electrical_component import ElectricalComponent @enum.unique @@ -30,20 +30,21 @@ class BatteryType(enum.Enum): @dataclasses.dataclass(frozen=True, kw_only=True) -class Battery(Component): - """An abstract battery component.""" +class Battery(ElectricalComponent): + """An abstract battery electrical component.""" category: Literal[ComponentCategory.BATTERY] = ComponentCategory.BATTERY - """The category of this component. + """The category of this electrical component. Note: - This should not be used normally, you should test if a component - [`isinstance`][] of a concrete component class instead. + 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`][frequenz.client.microgrid.component.UnrecognizedComponent]) - and in case some low level code needs to know the category of a component. + and in case some low level code needs to know the category of an electrical + component. """ type: BatteryType | int From bdfd76379cc04dc2f2f3037068b8a1ac5b65eb07 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 11 Jun 2026 09:05:20 +0000 Subject: [PATCH 3/4] Adapt battery component module to the common client layout Signed-off-by: Leandro Lucarella --- .../electrical_components/__init__.py | 16 +++++++ .../electrical_components/_battery.py | 46 ++++++++++--------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index fb6d9f21..ce462b36 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -3,6 +3,15 @@ """Defines the electrical components that can be used in a microgrid.""" +from ._battery import ( + Battery, + BatteryType, + BatteryTypes, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, +) from ._category import ElectricalComponentCategory from ._diagnostic_code import ElectricalComponentDiagnosticCode from ._electrical_component import ElectricalComponent @@ -16,13 +25,20 @@ from ._state_code import ElectricalComponentStateCode __all__ = [ + "Battery", + "BatteryType", + "BatteryTypes", "ElectricalComponent", "ElectricalComponentCategory", "ElectricalComponentDiagnosticCode", "ElectricalComponentId", "ElectricalComponentStateCode", + "LiIonBattery", "MismatchedCategoryComponent", + "NaIonBattery", "ProblematicComponent", + "UnrecognizedBattery", "UnrecognizedComponent", + "UnspecifiedBattery", "UnspecifiedComponent", ] diff --git a/src/frequenz/client/common/microgrid/electrical_components/_battery.py b/src/frequenz/client/common/microgrid/electrical_components/_battery.py index 5a8f980f..5f65e0ad 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/_battery.py +++ b/src/frequenz/client/common/microgrid/electrical_components/_battery.py @@ -11,7 +11,7 @@ electrical_components_pb2, ) -from ._category import ComponentCategory +from ._category import ElectricalComponentCategory from ._electrical_component import ElectricalComponent @@ -33,18 +33,20 @@ class BatteryType(enum.Enum): class Battery(ElectricalComponent): """An abstract battery electrical component.""" - category: Literal[ComponentCategory.BATTERY] = ComponentCategory.BATTERY + category: Literal[ElectricalComponentCategory.BATTERY] = ( + ElectricalComponentCategory.BATTERY + ) """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`][frequenz.client.microgrid.component.UnrecognizedComponent]) - and in case some low level code needs to know the category of an electrical - component. + 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: BatteryType | int @@ -54,9 +56,9 @@ class Battery(ElectricalComponent): This should not be used normally, you should test if a battery [`isinstance`][] of a concrete battery class instead. - It is only provided for using with a newer version of the API where the client - doesn't know about the new battery type yet (i.e. for use with - [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + It is only provided for using with a newer version of the API where + the client doesn't know about the new battery type yet (i.e. for use + with [`UnrecognizedBattery`][...UnrecognizedBattery]). """ # pylint: disable-next=unused-argument @@ -78,9 +80,9 @@ class UnspecifiedBattery(Battery): This should not be used normally, you should test if a battery [`isinstance`][] of a concrete battery class instead. - It is only provided for using with a newer version of the API where the client - doesn't know about the new battery type yet (i.e. for use with - [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + It is only provided for using with a newer version of the API where + the client doesn't know about the new battery type yet (i.e. for use + with [`UnrecognizedBattery`][...UnrecognizedBattery]). """ @@ -95,9 +97,9 @@ class LiIonBattery(Battery): This should not be used normally, you should test if a battery [`isinstance`][] of a concrete battery class instead. - It is only provided for using with a newer version of the API where the client - doesn't know about the new battery type yet (i.e. for use with - [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + It is only provided for using with a newer version of the API where + the client doesn't know about the new battery type yet (i.e. for use + with [`UnrecognizedBattery`][...UnrecognizedBattery]). """ @@ -112,9 +114,9 @@ class NaIonBattery(Battery): This should not be used normally, you should test if a battery [`isinstance`][] of a concrete battery class instead. - It is only provided for using with a newer version of the API where the client - doesn't know about the new battery type yet (i.e. for use with - [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + It is only provided for using with a newer version of the API where + the client doesn't know about the new battery type yet (i.e. for use + with [`UnrecognizedBattery`][...UnrecognizedBattery]). """ From c3e630a9ce654b3951282a2d5257ea615e2b3f3d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 11 Jun 2026 10:08:20 +0000 Subject: [PATCH 4/4] Adapt battery component tests to the common client layout Signed-off-by: Leandro Lucarella --- .../electrical_components/test_battery.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/microgrid/electrical_components/test_battery.py b/tests/microgrid/electrical_components/test_battery.py index f96f9e39..f692997b 100644 --- a/tests/microgrid/electrical_components/test_battery.py +++ b/tests/microgrid/electrical_components/test_battery.py @@ -8,12 +8,11 @@ import pytest from frequenz.client.common.microgrid import MicrogridId -from frequenz.client.common.microgrid.components import ComponentId - -from frequenz.client.microgrid.component import ( +from frequenz.client.common.microgrid.electrical_components import ( Battery, BatteryType, - ComponentCategory, + ElectricalComponentCategory, + ElectricalComponentId, LiIonBattery, NaIonBattery, UnrecognizedBattery, @@ -31,9 +30,9 @@ class BatteryTestCase: @pytest.fixture -def component_id() -> ComponentId: +def component_id() -> ElectricalComponentId: """Provide a test component ID.""" - return ComponentId(42) + return ElectricalComponentId(42) @pytest.fixture @@ -43,7 +42,7 @@ def microgrid_id() -> MicrogridId: def test_abstract_battery_cannot_be_instantiated( - component_id: ComponentId, microgrid_id: MicrogridId + component_id: ElectricalComponentId, microgrid_id: MicrogridId ) -> None: """Test that Battery base class cannot be instantiated.""" with pytest.raises(TypeError, match="Cannot instantiate Battery directly"): @@ -75,7 +74,9 @@ def test_abstract_battery_cannot_be_instantiated( ids=lambda case: case.name, ) def test_recognized_battery_types( - case: BatteryTestCase, component_id: ComponentId, microgrid_id: MicrogridId + case: BatteryTestCase, + component_id: ElectricalComponentId, + microgrid_id: MicrogridId, ) -> None: """Test initialization and properties of different battery types.""" battery = case.cls( @@ -91,12 +92,12 @@ def test_recognized_battery_types( assert battery.name == case.name assert battery.manufacturer == "test_manufacturer" assert battery.model_name == "test_model" - assert battery.category == ComponentCategory.BATTERY + assert battery.category == ElectricalComponentCategory.BATTERY assert battery.type == case.expected_type def test_unrecognized_battery_type( - component_id: ComponentId, microgrid_id: MicrogridId + component_id: ElectricalComponentId, microgrid_id: MicrogridId ) -> None: """Test initialization and properties of different battery types.""" battery = UnrecognizedBattery( @@ -113,5 +114,5 @@ def test_unrecognized_battery_type( assert battery.name == "unrecognized_battery" assert battery.manufacturer == "test_manufacturer" assert battery.model_name == "test_model" - assert battery.category == ComponentCategory.BATTERY + assert battery.category == ElectricalComponentCategory.BATTERY assert battery.type == 999