Skip to content

Commit eecd8c4

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

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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
from ._converter import Converter
1919
from ._crypto_miner import CryptoMiner
2020
from ._electrolyzer import Electrolyzer
21+
from ._ev_charger import (
22+
AcEvCharger,
23+
DcEvCharger,
24+
EvCharger,
25+
EvChargerType,
26+
EvChargerTypes,
27+
HybridEvCharger,
28+
UnrecognizedEvCharger,
29+
UnspecifiedEvCharger,
30+
)
2131
from ._fuse import Fuse
2232
from ._grid_connection_point import GridConnectionPoint
2333
from ._hvac import Hvac
@@ -28,6 +38,7 @@
2838
from ._voltage_transformer import VoltageTransformer
2939

3040
__all__ = [
41+
"AcEvCharger",
3142
"Battery",
3243
"BatteryType",
3344
"BatteryTypes",
@@ -37,16 +48,23 @@
3748
"ComponentStatus",
3849
"Converter",
3950
"CryptoMiner",
51+
"DcEvCharger",
4052
"Electrolyzer",
53+
"EvCharger",
54+
"EvChargerType",
55+
"EvChargerTypes",
4156
"Fuse",
4257
"GridConnectionPoint",
4358
"Hvac",
59+
"HybridEvCharger",
4460
"LiIonBattery",
4561
"Meter",
4662
"NaIonBattery",
4763
"Precharger",
4864
"Relay",
4965
"UnrecognizedBattery",
66+
"UnrecognizedEvCharger",
5067
"UnspecifiedBattery",
68+
"UnspecifiedEvCharger",
5169
"VoltageTransformer",
5270
]
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+
"""Electric vehicle (EV) charger 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 ev_charger_pb2
11+
12+
from ._category import ComponentCategory
13+
from ._component import Component
14+
15+
16+
@enum.unique
17+
class EvChargerType(enum.Enum):
18+
"""The known types of electric vehicle (EV) chargers."""
19+
20+
UNSPECIFIED = ev_charger_pb2.EV_CHARGER_TYPE_UNSPECIFIED
21+
"""The type of the EV charger is unspecified."""
22+
23+
AC = ev_charger_pb2.EV_CHARGER_TYPE_AC
24+
"""The EV charging station supports AC charging only."""
25+
26+
DC = ev_charger_pb2.EV_CHARGER_TYPE_DC
27+
"""The EV charging station supports DC charging only."""
28+
29+
HYBRID = ev_charger_pb2.EV_CHARGER_TYPE_HYBRID
30+
"""The EV charging station supports both AC and DC."""
31+
32+
33+
@dataclasses.dataclass(frozen=True, kw_only=True)
34+
class EvCharger(Component):
35+
"""An abstract EV charger component."""
36+
37+
category: Literal[ComponentCategory.EV_CHARGER] = ComponentCategory.EV_CHARGER
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 EV charger 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: EvChargerType | int
51+
"""The type of this EV charger.
52+
53+
Note:
54+
This should not be used normally, you should test if a EV charger
55+
[`isinstance`][] of a concrete component 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 EV charger type yet (i.e. for use with
59+
[`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]).
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 EvCharger:
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 UnspecifiedEvCharger(EvCharger):
72+
"""An EV charger of an unspecified type."""
73+
74+
type: Literal[EvChargerType.UNSPECIFIED] = EvChargerType.UNSPECIFIED
75+
"""The type of this EV charger.
76+
77+
Note:
78+
This should not be used normally, you should test if a EV charger
79+
[`isinstance`][] of a concrete component 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 EV charger type yet (i.e. for use with
83+
[`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]).
84+
"""
85+
86+
87+
@dataclasses.dataclass(frozen=True, kw_only=True)
88+
class AcEvCharger(EvCharger):
89+
"""An EV charger that supports AC charging only."""
90+
91+
type: Literal[EvChargerType.AC] = EvChargerType.AC
92+
"""The type of this EV charger.
93+
94+
Note:
95+
This should not be used normally, you should test if a EV charger
96+
[`isinstance`][] of a concrete component 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 EV charger type yet (i.e. for use with
100+
[`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]).
101+
"""
102+
103+
104+
@dataclasses.dataclass(frozen=True, kw_only=True)
105+
class DcEvCharger(EvCharger):
106+
"""An EV charger that supports DC charging only."""
107+
108+
type: Literal[EvChargerType.DC] = EvChargerType.DC
109+
"""The type of this EV charger.
110+
111+
Note:
112+
This should not be used normally, you should test if a EV charger
113+
[`isinstance`][] of a concrete component 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 EV charger type yet (i.e. for use with
117+
[`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]).
118+
"""
119+
120+
121+
@dataclasses.dataclass(frozen=True, kw_only=True)
122+
class HybridEvCharger(EvCharger):
123+
"""An EV charger that supports both AC and DC charging."""
124+
125+
type: Literal[EvChargerType.HYBRID] = EvChargerType.HYBRID
126+
"""The type of this EV charger.
127+
128+
Note:
129+
This should not be used normally, you should test if a EV charger
130+
[`isinstance`][] of a concrete component 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 EV charger type yet (i.e. for use with
134+
[`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]).
135+
"""
136+
137+
138+
@dataclasses.dataclass(frozen=True, kw_only=True)
139+
class UnrecognizedEvCharger(EvCharger):
140+
"""An EV charger of an unrecognized type."""
141+
142+
type: int
143+
"""The unrecognized type of this EV charger."""
144+
145+
146+
EvChargerTypes: TypeAlias = (
147+
UnspecifiedEvCharger
148+
| AcEvCharger
149+
| DcEvCharger
150+
| HybridEvCharger
151+
| UnrecognizedEvCharger
152+
)
153+
"""All possible EV charger types."""

tests/component/test_ev_charger.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for EV charger 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+
AcEvCharger,
14+
ComponentCategory,
15+
ComponentStatus,
16+
DcEvCharger,
17+
EvCharger,
18+
EvChargerType,
19+
HybridEvCharger,
20+
UnrecognizedEvCharger,
21+
UnspecifiedEvCharger,
22+
)
23+
24+
25+
@dataclasses.dataclass(frozen=True, kw_only=True)
26+
class EvChargerTestCase:
27+
"""Test case for EV charger components."""
28+
29+
cls: type[UnspecifiedEvCharger | AcEvCharger | DcEvCharger | HybridEvCharger]
30+
expected_type: EvChargerType
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_ev_charger_cannot_be_instantiated(
47+
component_id: ComponentId, microgrid_id: MicrogridId
48+
) -> None:
49+
"""Test that EvCharger base class cannot be instantiated."""
50+
with pytest.raises(TypeError, match="Cannot instantiate EvCharger directly"):
51+
EvCharger(
52+
id=component_id,
53+
microgrid_id=microgrid_id,
54+
name="test_charger",
55+
manufacturer="test_manufacturer",
56+
model_name="test_model",
57+
status=ComponentStatus.ACTIVE,
58+
type=EvChargerType.AC,
59+
)
60+
61+
62+
@pytest.mark.parametrize(
63+
"case",
64+
[
65+
EvChargerTestCase(
66+
cls=UnspecifiedEvCharger,
67+
expected_type=EvChargerType.UNSPECIFIED,
68+
name="unspecified",
69+
),
70+
EvChargerTestCase(cls=AcEvCharger, expected_type=EvChargerType.AC, name="ac"),
71+
EvChargerTestCase(cls=DcEvCharger, expected_type=EvChargerType.DC, name="dc"),
72+
EvChargerTestCase(
73+
cls=HybridEvCharger,
74+
expected_type=EvChargerType.HYBRID,
75+
name="hybrid",
76+
),
77+
],
78+
ids=lambda case: case.name,
79+
)
80+
def test_recognized_ev_charger_types( # Renamed from test_ev_charger_types
81+
case: EvChargerTestCase, component_id: ComponentId, microgrid_id: MicrogridId
82+
) -> None:
83+
"""Test initialization and properties of different recognized EV charger types."""
84+
charger = case.cls(
85+
id=component_id,
86+
microgrid_id=microgrid_id,
87+
name=case.name,
88+
manufacturer="test_manufacturer",
89+
model_name="test_model",
90+
status=ComponentStatus.ACTIVE,
91+
)
92+
93+
assert charger.id == component_id
94+
assert charger.microgrid_id == microgrid_id
95+
assert charger.name == case.name
96+
assert charger.manufacturer == "test_manufacturer"
97+
assert charger.model_name == "test_model"
98+
assert charger.status == ComponentStatus.ACTIVE
99+
assert charger.category == ComponentCategory.EV_CHARGER
100+
assert charger.type == case.expected_type
101+
102+
103+
def test_unrecognized_ev_charger_type(
104+
component_id: ComponentId, microgrid_id: MicrogridId
105+
) -> None:
106+
"""Test initialization and properties of unrecognized EV charger type."""
107+
charger = UnrecognizedEvCharger(
108+
id=component_id,
109+
microgrid_id=microgrid_id,
110+
name="unrecognized_charger",
111+
manufacturer="test_manufacturer",
112+
model_name="test_model",
113+
status=ComponentStatus.ACTIVE,
114+
type=999, # type is passed here for UnrecognizedEvCharger
115+
)
116+
117+
assert charger.id == component_id
118+
assert charger.microgrid_id == microgrid_id
119+
assert charger.name == "unrecognized_charger"
120+
assert charger.manufacturer == "test_manufacturer"
121+
assert charger.model_name == "test_model"
122+
assert charger.status == ComponentStatus.ACTIVE
123+
assert charger.category == ComponentCategory.EV_CHARGER
124+
assert charger.type == 999

0 commit comments

Comments
 (0)