Skip to content

Commit 76f4c34

Browse files
committed
Add Inverter component and sub-classes/types
Like batteries and EV chargers, inverter components have an attached type. To encode this into the Python type system, we create a sub-class per each type. Special types are also used to represent a inverter with type `UNSPECIFIED` and with type we don't recognize, and we define a type alias to make it easy to type-hint function that want to return all known inverter types as a type union instead of using inheritance. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent eecd8c4 commit 76f4c34

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-0
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@
3131
from ._fuse import Fuse
3232
from ._grid_connection_point import GridConnectionPoint
3333
from ._hvac import Hvac
34+
from ._inverter import (
35+
BatteryInverter,
36+
HybridInverter,
37+
Inverter,
38+
InverterType,
39+
SolarInverter,
40+
UnrecognizedInverter,
41+
UnspecifiedInverter,
42+
)
3443
from ._meter import Meter
3544
from ._precharger import Precharger
3645
from ._relay import Relay
@@ -40,6 +49,7 @@
4049
__all__ = [
4150
"AcEvCharger",
4251
"Battery",
52+
"BatteryInverter",
4353
"BatteryType",
4454
"BatteryTypes",
4555
"Chp",
@@ -57,14 +67,20 @@
5767
"GridConnectionPoint",
5868
"Hvac",
5969
"HybridEvCharger",
70+
"HybridInverter",
71+
"Inverter",
72+
"InverterType",
6073
"LiIonBattery",
6174
"Meter",
6275
"NaIonBattery",
6376
"Precharger",
6477
"Relay",
78+
"SolarInverter",
6579
"UnrecognizedBattery",
6680
"UnrecognizedEvCharger",
81+
"UnrecognizedInverter",
6782
"UnspecifiedBattery",
6883
"UnspecifiedEvCharger",
84+
"UnspecifiedInverter",
6985
"VoltageTransformer",
7086
]
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Inverter 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 inverter_pb2
11+
12+
from ._category import ComponentCategory
13+
from ._component import Component
14+
15+
16+
@enum.unique
17+
class InverterType(enum.Enum):
18+
"""The known types of inverters."""
19+
20+
UNSPECIFIED = inverter_pb2.INVERTER_TYPE_UNSPECIFIED
21+
"""The type of the inverter is unspecified."""
22+
23+
BATTERY = inverter_pb2.INVERTER_TYPE_BATTERY
24+
"""The inverter is a battery inverter."""
25+
26+
SOLAR = inverter_pb2.INVERTER_TYPE_SOLAR
27+
"""The inverter is a solar inverter."""
28+
29+
HYBRID = inverter_pb2.INVERTER_TYPE_HYBRID
30+
"""The inverter is a hybrid inverter."""
31+
32+
33+
@dataclasses.dataclass(frozen=True, kw_only=True)
34+
class Inverter(Component):
35+
"""An abstract inverter component."""
36+
37+
category: Literal[ComponentCategory.INVERTER] = ComponentCategory.INVERTER
38+
"""The category of this component.
39+
40+
Note:
41+
This should not be used normally, you should test if a component
42+
[`isinstance`][] of a concrete component class instead.
43+
44+
It is only provided for using with a newer version of the API where the client
45+
doesn't know about a new category yet (i.e. for use with
46+
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent])
47+
and in case some low level code needs to know the category of a component.
48+
"""
49+
50+
type: InverterType | int
51+
"""The type of this inverter.
52+
53+
Note:
54+
This should not be used normally, you should test if a inverter
55+
[`isinstance`][] of a concrete inverter class instead.
56+
57+
It is only provided for using with a newer version of the API where the client
58+
doesn't know about the new inverter type yet (i.e. for use with
59+
[`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]).
60+
"""
61+
62+
# pylint: disable-next=unused-argument
63+
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
64+
"""Prevent instantiation of this class."""
65+
if cls is Inverter:
66+
raise TypeError(f"Cannot instantiate {cls.__name__} directly")
67+
return super().__new__(cls)
68+
69+
70+
@dataclasses.dataclass(frozen=True, kw_only=True)
71+
class UnspecifiedInverter(Inverter):
72+
"""An inverter of an unspecified type."""
73+
74+
type: Literal[InverterType.UNSPECIFIED] = InverterType.UNSPECIFIED
75+
"""The type of this inverter.
76+
77+
Note:
78+
This should not be used normally, you should test if a inverter
79+
[`isinstance`][] of a concrete inverter class instead.
80+
81+
It is only provided for using with a newer version of the API where the client
82+
doesn't know about the new inverter type yet (i.e. for use with
83+
[`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]).
84+
"""
85+
86+
87+
@dataclasses.dataclass(frozen=True, kw_only=True)
88+
class BatteryInverter(Inverter):
89+
"""A battery inverter."""
90+
91+
type: Literal[InverterType.BATTERY] = InverterType.BATTERY
92+
"""The type of this inverter.
93+
94+
Note:
95+
This should not be used normally, you should test if a inverter
96+
[`isinstance`][] of a concrete inverter class instead.
97+
98+
It is only provided for using with a newer version of the API where the client
99+
doesn't know about the new inverter type yet (i.e. for use with
100+
[`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]).
101+
"""
102+
103+
104+
@dataclasses.dataclass(frozen=True, kw_only=True)
105+
class SolarInverter(Inverter):
106+
"""A solar inverter."""
107+
108+
type: Literal[InverterType.SOLAR] = InverterType.SOLAR
109+
"""The type of this inverter.
110+
111+
Note:
112+
This should not be used normally, you should test if a inverter
113+
[`isinstance`][] of a concrete inverter class instead.
114+
115+
It is only provided for using with a newer version of the API where the client
116+
doesn't know about the new inverter type yet (i.e. for use with
117+
[`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]).
118+
"""
119+
120+
121+
@dataclasses.dataclass(frozen=True, kw_only=True)
122+
class HybridInverter(Inverter):
123+
"""A hybrid inverter."""
124+
125+
type: Literal[InverterType.HYBRID] = InverterType.HYBRID
126+
"""The type of this inverter.
127+
128+
Note:
129+
This should not be used normally, you should test if a inverter
130+
[`isinstance`][] of a concrete inverter class instead.
131+
132+
It is only provided for using with a newer version of the API where the client
133+
doesn't know about the new inverter type yet (i.e. for use with
134+
[`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]).
135+
"""
136+
137+
138+
@dataclasses.dataclass(frozen=True, kw_only=True)
139+
class UnrecognizedInverter(Inverter):
140+
"""An inverter component."""
141+
142+
type: int
143+
"""The unrecognized type of this inverter."""
144+
145+
146+
InverterTypes: TypeAlias = (
147+
UnspecifiedInverter
148+
| BatteryInverter
149+
| SolarInverter
150+
| HybridInverter
151+
| UnrecognizedInverter
152+
)
153+
"""All possible inverter types."""

tests/component/test_inverter.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for Inverter 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+
BatteryInverter,
14+
ComponentCategory,
15+
ComponentStatus,
16+
HybridInverter,
17+
Inverter,
18+
InverterType,
19+
SolarInverter,
20+
UnrecognizedInverter,
21+
UnspecifiedInverter,
22+
)
23+
24+
25+
@dataclasses.dataclass(frozen=True, kw_only=True)
26+
class InverterTestCase:
27+
"""Test case for Inverter components."""
28+
29+
cls: type[UnspecifiedInverter | BatteryInverter | SolarInverter | HybridInverter]
30+
expected_type: InverterType
31+
name: str
32+
33+
34+
@pytest.fixture
35+
def component_id() -> ComponentId:
36+
"""Provide a test component ID."""
37+
return ComponentId(42)
38+
39+
40+
@pytest.fixture
41+
def microgrid_id() -> MicrogridId:
42+
"""Provide a test microgrid ID."""
43+
return MicrogridId(1)
44+
45+
46+
def test_abstract_inverter_cannot_be_instantiated(
47+
component_id: ComponentId, microgrid_id: MicrogridId
48+
) -> None:
49+
"""Test that Inverter base class cannot be instantiated."""
50+
with pytest.raises(TypeError, match="Cannot instantiate Inverter directly"):
51+
Inverter(
52+
id=component_id,
53+
microgrid_id=microgrid_id,
54+
name="test_inverter",
55+
manufacturer="test_manufacturer",
56+
model_name="test_model",
57+
status=ComponentStatus.ACTIVE,
58+
type=InverterType.BATTERY,
59+
)
60+
61+
62+
@pytest.mark.parametrize(
63+
"case",
64+
[
65+
InverterTestCase(
66+
cls=UnspecifiedInverter,
67+
expected_type=InverterType.UNSPECIFIED,
68+
name="unspecified",
69+
),
70+
InverterTestCase(
71+
cls=BatteryInverter, expected_type=InverterType.BATTERY, name="battery"
72+
),
73+
InverterTestCase(
74+
cls=SolarInverter, expected_type=InverterType.SOLAR, name="solar"
75+
),
76+
InverterTestCase(
77+
cls=HybridInverter, expected_type=InverterType.HYBRID, name="hybrid"
78+
),
79+
],
80+
ids=lambda case: case.name,
81+
)
82+
def test_recognized_inverter_types(
83+
case: InverterTestCase, component_id: ComponentId, microgrid_id: MicrogridId
84+
) -> None:
85+
"""Test initialization and properties of different recognized inverter types."""
86+
inverter = case.cls(
87+
id=component_id,
88+
microgrid_id=microgrid_id,
89+
name=case.name,
90+
manufacturer="test_manufacturer",
91+
model_name="test_model",
92+
status=ComponentStatus.ACTIVE,
93+
)
94+
95+
assert inverter.id == component_id
96+
assert inverter.microgrid_id == microgrid_id
97+
assert inverter.name == case.name
98+
assert inverter.manufacturer == "test_manufacturer"
99+
assert inverter.model_name == "test_model"
100+
assert inverter.status == ComponentStatus.ACTIVE
101+
assert inverter.category == ComponentCategory.INVERTER
102+
assert inverter.type == case.expected_type
103+
104+
105+
def test_unrecognized_inverter_type(
106+
component_id: ComponentId, microgrid_id: MicrogridId
107+
) -> None:
108+
"""Test initialization and properties of unrecognized inverter type."""
109+
inverter = UnrecognizedInverter(
110+
id=component_id,
111+
microgrid_id=microgrid_id,
112+
name="unrecognized_inverter",
113+
manufacturer="test_manufacturer",
114+
model_name="test_model",
115+
status=ComponentStatus.ACTIVE,
116+
type=999, # type is passed here for UnrecognizedInverter
117+
)
118+
119+
assert inverter.id == component_id
120+
assert inverter.microgrid_id == microgrid_id
121+
assert inverter.name == "unrecognized_inverter"
122+
assert inverter.manufacturer == "test_manufacturer"
123+
assert inverter.model_name == "test_model"
124+
assert inverter.status == ComponentStatus.ACTIVE
125+
assert inverter.category == ComponentCategory.INVERTER
126+
assert inverter.type == 999

0 commit comments

Comments
 (0)