Skip to content

Commit 5a8f8ba

Browse files
committed
Add Battery component and sub-classes/types
Battery components have an attached battery type. To encode this into the Python type system, we create a sub-class per each battery type, similar to what we do with components. The special types `UnspecifiedBattery` and `UnrecognizedBattery` are added to represent a battery with type `UNSPECIFIED` and a battery with a battery type we don't recognize (this could happen if using a newer server providing new battery types) respectively. On top of that, we define a `BatteryTypes` type alias to make it easy to type-hint function that want to return all known battery types as a type union instead of using inheritance. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 052d90e commit 5a8f8ba

File tree

3 files changed

+267
-0
lines changed

3 files changed

+267
-0
lines changed

src/frequenz/client/microgrid/component/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33

44
"""All classes and functions related to microgrid components."""
55

6+
from ._battery import (
7+
Battery,
8+
BatteryType,
9+
BatteryTypes,
10+
LiIonBattery,
11+
NaIonBattery,
12+
UnrecognizedBattery,
13+
UnspecifiedBattery,
14+
)
615
from ._category import ComponentCategory
716
from ._chp import Chp
817
from ._component import Component
@@ -19,6 +28,9 @@
1928
from ._voltage_transformer import VoltageTransformer
2029

2130
__all__ = [
31+
"Battery",
32+
"BatteryType",
33+
"BatteryTypes",
2234
"Chp",
2335
"Component",
2436
"ComponentCategory",
@@ -29,8 +41,12 @@
2941
"Fuse",
3042
"GridConnectionPoint",
3143
"Hvac",
44+
"LiIonBattery",
3245
"Meter",
46+
"NaIonBattery",
3347
"Precharger",
3448
"Relay",
49+
"UnrecognizedBattery",
50+
"UnspecifiedBattery",
3551
"VoltageTransformer",
3652
]
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Battery component."""
5+
6+
import dataclasses
7+
import enum
8+
from typing import Any, Literal, Self, TypeAlias
9+
10+
from frequenz.api.common.v1.microgrid.components import battery_pb2
11+
12+
from ._category import ComponentCategory
13+
from ._component import Component
14+
15+
16+
@enum.unique
17+
class BatteryType(enum.Enum):
18+
"""The known types of batteries."""
19+
20+
UNSPECIFIED = battery_pb2.BATTERY_TYPE_UNSPECIFIED
21+
"""The battery type is unspecified."""
22+
23+
LI_ION = battery_pb2.BATTERY_TYPE_LI_ION
24+
"""Lithium-ion (Li-ion) battery."""
25+
26+
NA_ION = battery_pb2.BATTERY_TYPE_NA_ION
27+
"""Sodium-ion (Na-ion) battery."""
28+
29+
30+
@dataclasses.dataclass(frozen=True, kw_only=True)
31+
class Battery(Component):
32+
"""An abstract battery component."""
33+
34+
category: Literal[ComponentCategory.BATTERY] = ComponentCategory.BATTERY
35+
"""The category of this component.
36+
37+
Note:
38+
This should not be used normally, you should test if a component
39+
[`isinstance`][] of a concrete component class instead.
40+
41+
It is only provided for using with a newer version of the API where the client
42+
doesn't know about a new category yet (i.e. for use with
43+
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent])
44+
and in case some low level code needs to know the category of a component.
45+
"""
46+
47+
type: BatteryType | int
48+
"""The type of this battery.
49+
50+
Note:
51+
This should not be used normally, you should test if a battery
52+
[`isinstance`][] of a concrete battery class instead.
53+
54+
It is only provided for using with a newer version of the API where the client
55+
doesn't know about the new battery type yet (i.e. for use with
56+
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
57+
"""
58+
59+
# pylint: disable-next=unused-argument
60+
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
61+
"""Prevent instantiation of this class."""
62+
if cls is Battery:
63+
raise TypeError(f"Cannot instantiate {cls.__name__} directly")
64+
return super().__new__(cls)
65+
66+
67+
@dataclasses.dataclass(frozen=True, kw_only=True)
68+
class UnspecifiedBattery(Battery):
69+
"""A battery of a unspecified type."""
70+
71+
type: Literal[BatteryType.UNSPECIFIED] = BatteryType.UNSPECIFIED
72+
"""The type of this battery.
73+
74+
Note:
75+
This should not be used normally, you should test if a battery
76+
[`isinstance`][] of a concrete battery class instead.
77+
78+
It is only provided for using with a newer version of the API where the client
79+
doesn't know about the new battery type yet (i.e. for use with
80+
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
81+
"""
82+
83+
84+
@dataclasses.dataclass(frozen=True, kw_only=True)
85+
class LiIonBattery(Battery):
86+
"""A Li-ion battery."""
87+
88+
type: Literal[BatteryType.LI_ION] = BatteryType.LI_ION
89+
"""The type of this battery.
90+
91+
Note:
92+
This should not be used normally, you should test if a battery
93+
[`isinstance`][] of a concrete battery class instead.
94+
95+
It is only provided for using with a newer version of the API where the client
96+
doesn't know about the new battery type yet (i.e. for use with
97+
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
98+
"""
99+
100+
101+
@dataclasses.dataclass(frozen=True, kw_only=True)
102+
class NaIonBattery(Battery):
103+
"""A Na-ion battery."""
104+
105+
type: Literal[BatteryType.NA_ION] = BatteryType.NA_ION
106+
"""The type of this battery.
107+
108+
Note:
109+
This should not be used normally, you should test if a battery
110+
[`isinstance`][] of a concrete battery class instead.
111+
112+
It is only provided for using with a newer version of the API where the client
113+
doesn't know about the new battery type yet (i.e. for use with
114+
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
115+
"""
116+
117+
118+
@dataclasses.dataclass(frozen=True, kw_only=True)
119+
class UnrecognizedBattery(Battery):
120+
"""A battery of an unrecognized type."""
121+
122+
type: int
123+
"""The unrecognized type of this battery."""
124+
125+
126+
BatteryTypes: TypeAlias = (
127+
LiIonBattery | NaIonBattery | UnrecognizedBattery | UnspecifiedBattery
128+
)
129+
"""All possible battery types."""

tests/component/test_battery.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for Battery components."""
5+
6+
import dataclasses
7+
8+
import pytest
9+
from frequenz.client.common.microgrid import MicrogridId
10+
from frequenz.client.common.microgrid.components import ComponentId
11+
12+
from frequenz.client.microgrid.component import (
13+
Battery,
14+
BatteryType,
15+
ComponentCategory,
16+
ComponentStatus,
17+
LiIonBattery,
18+
NaIonBattery,
19+
UnrecognizedBattery,
20+
UnspecifiedBattery,
21+
)
22+
23+
24+
@dataclasses.dataclass(frozen=True, kw_only=True)
25+
class BatteryTestCase:
26+
"""Test case for battery components."""
27+
28+
cls: type[UnspecifiedBattery | LiIonBattery | NaIonBattery]
29+
expected_type: BatteryType
30+
name: str
31+
32+
33+
@pytest.fixture
34+
def component_id() -> ComponentId:
35+
"""Provide a test component ID."""
36+
return ComponentId(42)
37+
38+
39+
@pytest.fixture
40+
def microgrid_id() -> MicrogridId:
41+
"""Provide a test microgrid ID."""
42+
return MicrogridId(1)
43+
44+
45+
def test_abstract_battery_cannot_be_instantiated(
46+
component_id: ComponentId, microgrid_id: MicrogridId
47+
) -> None:
48+
"""Test that Battery base class cannot be instantiated."""
49+
with pytest.raises(TypeError, match="Cannot instantiate Battery directly"):
50+
Battery(
51+
id=component_id,
52+
microgrid_id=microgrid_id,
53+
name="test_battery",
54+
manufacturer="test_manufacturer",
55+
model_name="test_model",
56+
status=ComponentStatus.ACTIVE,
57+
type=BatteryType.LI_ION,
58+
)
59+
60+
61+
@pytest.mark.parametrize(
62+
"case",
63+
[
64+
BatteryTestCase(
65+
cls=UnspecifiedBattery,
66+
expected_type=BatteryType.UNSPECIFIED,
67+
name="unspecified",
68+
),
69+
BatteryTestCase(
70+
cls=LiIonBattery, expected_type=BatteryType.LI_ION, name="li_ion"
71+
),
72+
BatteryTestCase(
73+
cls=NaIonBattery, expected_type=BatteryType.NA_ION, name="na_ion"
74+
),
75+
],
76+
ids=lambda case: case.name,
77+
)
78+
def test_recognized_battery_types(
79+
case: BatteryTestCase, component_id: ComponentId, microgrid_id: MicrogridId
80+
) -> None:
81+
"""Test initialization and properties of different battery types."""
82+
battery = case.cls(
83+
id=component_id,
84+
microgrid_id=microgrid_id,
85+
name=case.name,
86+
manufacturer="test_manufacturer",
87+
model_name="test_model",
88+
status=ComponentStatus.ACTIVE,
89+
)
90+
91+
assert battery.id == component_id
92+
assert battery.microgrid_id == microgrid_id
93+
assert battery.name == case.name
94+
assert battery.manufacturer == "test_manufacturer"
95+
assert battery.model_name == "test_model"
96+
assert battery.status == ComponentStatus.ACTIVE
97+
assert battery.category == ComponentCategory.BATTERY
98+
assert battery.type == case.expected_type
99+
100+
101+
def test_unrecognized_battery_type(
102+
component_id: ComponentId, microgrid_id: MicrogridId
103+
) -> None:
104+
"""Test initialization and properties of different battery types."""
105+
battery = UnrecognizedBattery(
106+
id=component_id,
107+
microgrid_id=microgrid_id,
108+
name="unrecognized_battery",
109+
manufacturer="test_manufacturer",
110+
model_name="test_model",
111+
status=ComponentStatus.ACTIVE,
112+
type=999,
113+
)
114+
115+
assert battery.id == component_id
116+
assert battery.microgrid_id == microgrid_id
117+
assert battery.name == "unrecognized_battery"
118+
assert battery.manufacturer == "test_manufacturer"
119+
assert battery.model_name == "test_model"
120+
assert battery.status == ComponentStatus.ACTIVE
121+
assert battery.category == ComponentCategory.BATTERY
122+
assert battery.type == 999

0 commit comments

Comments
 (0)