Skip to content

Commit 4eff68f

Browse files
Add an EVChargerPool implementation (#194)
Currently just has a method for streaming state changes for ev chargers. The ev charger power and current formulas will get moved from the logical meter to here, in a subsequent PR.
2 parents 63411e6 + 5e0e9a6 commit 4eff68f

File tree

8 files changed

+348
-7
lines changed

8 files changed

+348
-7
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
InverterData,
1515
MeterData,
1616
)
17-
from ._component_states import EVChargerCableState
17+
from ._component_states import EVChargerCableState, EVChargerComponentState
1818

1919
__all__ = [
2020
"BatteryData",
@@ -23,6 +23,7 @@
2323
"ComponentCategory",
2424
"ComponentMetricId",
2525
"EVChargerCableState",
26+
"EVChargerComponentState",
2627
"EVChargerData",
2728
"InverterData",
2829
"InverterType",

src/frequenz/sdk/microgrid/component/_component_data.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import frequenz.api.microgrid.microgrid_pb2 as microgrid_pb
1515
import pytz
1616

17-
from ._component_states import EVChargerCableState
17+
from ._component_states import EVChargerCableState, EVChargerComponentState
1818

1919

2020
@dataclass(frozen=True)
@@ -257,6 +257,9 @@ class EVChargerData(ComponentData):
257257
cable_state: EVChargerCableState
258258
"""The state of the ev charger's cable."""
259259

260+
component_state: EVChargerComponentState
261+
"""The state of the ev charger."""
262+
260263
@classmethod
261264
def from_proto(cls, raw: microgrid_pb.ComponentData) -> EVChargerData:
262265
"""Create EVChargerData from a protobuf message.
@@ -282,6 +285,9 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> EVChargerData:
282285
raw.ev_charger.data.ac.phase_3.voltage.value,
283286
),
284287
cable_state=EVChargerCableState.from_pb(raw.ev_charger.state.cable_state),
288+
component_state=EVChargerComponentState.from_pb(
289+
raw.ev_charger.state.component_state
290+
),
285291
)
286292
ev_charger_data._set_raw(raw=raw)
287293
return ev_charger_data

src/frequenz/sdk/microgrid/component/_component_states.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,36 @@ def from_pb(
3939
return cls.UNSPECIFIED
4040

4141
return EVChargerCableState(evc_state)
42+
43+
44+
class EVChargerComponentState(Enum):
45+
"""Component State of an EV Charger."""
46+
47+
UNSPECIFIED = ev_charger_pb.ComponentState.COMPONENT_STATE_UNSPECIFIED
48+
STARTING = ev_charger_pb.ComponentState.COMPONENT_STATE_STARTING
49+
NOT_READY = ev_charger_pb.ComponentState.COMPONENT_STATE_NOT_READY
50+
READY = ev_charger_pb.ComponentState.COMPONENT_STATE_READY
51+
CHARGING = ev_charger_pb.ComponentState.COMPONENT_STATE_CHARGING
52+
DISCHARGING = ev_charger_pb.ComponentState.COMPONENT_STATE_DISCHARGING
53+
ERROR = ev_charger_pb.ComponentState.COMPONENT_STATE_ERROR
54+
AUTHORIZATION_REJECTED = (
55+
ev_charger_pb.ComponentState.COMPONENT_STATE_AUTHORIZATION_REJECTED
56+
)
57+
INTERRUPTED = ev_charger_pb.ComponentState.COMPONENT_STATE_INTERRUPTED
58+
59+
@classmethod
60+
def from_pb(
61+
cls, evc_state: ev_charger_pb.ComponentState.ValueType
62+
) -> EVChargerComponentState:
63+
"""Convert a protobuf ComponentState value to EVChargerComponentState enum.
64+
65+
Args:
66+
evc_state: protobuf component state to convert.
67+
68+
Returns:
69+
Enum value corresponding to the protobuf message.
70+
"""
71+
if not any(t.value == evc_state for t in EVChargerComponentState):
72+
return cls.UNSPECIFIED
73+
74+
return EVChargerComponentState(evc_state)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Interactions with EV Chargers."""
5+
6+
from ._ev_charger_pool import EVChargerPool, EVChargerPoolStates, EVChargerState
7+
8+
__all__ = [
9+
"EVChargerPool",
10+
"EVChargerPoolStates",
11+
"EVChargerState",
12+
]
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# License: MIT
2+
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Interactions with pools of ev chargers."""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
import logging
10+
from collections.abc import Iterator
11+
from dataclasses import dataclass
12+
from enum import Enum
13+
from typing import Optional
14+
15+
from frequenz.channels import Broadcast, Receiver
16+
from frequenz.channels.util import Merge
17+
18+
from ... import microgrid
19+
from ..._internal.asyncio import cancel_and_await
20+
from ...microgrid.component import (
21+
ComponentCategory,
22+
EVChargerCableState,
23+
EVChargerComponentState,
24+
EVChargerData,
25+
)
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class EVChargerState(Enum):
31+
"""State of individual ev charger."""
32+
33+
UNSPECIFIED = "UNSPECIFIED"
34+
IDLE = "IDLE"
35+
EV_PLUGGED = "EV_PLUGGED"
36+
EV_LOCKED = "EV_LOCKED"
37+
ERROR = "ERROR"
38+
39+
@classmethod
40+
def from_ev_charger_data(cls, data: EVChargerData) -> EVChargerState:
41+
"""Create an `EVChargerState` instance from component data.
42+
43+
Args:
44+
data: ev charger data coming from microgrid.
45+
46+
Returns:
47+
An `EVChargerState` instance.
48+
"""
49+
if data.component_state in (
50+
EVChargerComponentState.AUTHORIZATION_REJECTED,
51+
EVChargerComponentState.ERROR,
52+
):
53+
return EVChargerState.ERROR
54+
if data.cable_state == EVChargerCableState.EV_LOCKED:
55+
return EVChargerState.EV_LOCKED
56+
if data.cable_state == EVChargerCableState.EV_PLUGGED:
57+
return EVChargerState.EV_PLUGGED
58+
return EVChargerState.IDLE
59+
60+
61+
@dataclass(frozen=True)
62+
class EVChargerPoolStates:
63+
"""States of all ev chargers in the pool."""
64+
65+
_states: dict[int, EVChargerState]
66+
_changed_component: Optional[int] = None
67+
68+
def __iter__(self) -> Iterator[tuple[int, EVChargerState]]:
69+
"""Iterate over states of all ev chargers.
70+
71+
Returns:
72+
An iterator over all ev charger states.
73+
"""
74+
return iter(self._states.items())
75+
76+
def latest_change(self) -> Optional[tuple[int, EVChargerState]]:
77+
"""Return the most recent ev charger state change.
78+
79+
Returns:
80+
A tuple with the component ID of an ev charger that just had a state
81+
change, and its new state.
82+
"""
83+
if self._changed_component is None:
84+
return None
85+
return (
86+
self._changed_component,
87+
self._states.setdefault(
88+
self._changed_component, EVChargerState.UNSPECIFIED
89+
),
90+
)
91+
92+
93+
class _StateTracker:
94+
"""A class for keeping track of the states of all ev chargers in a pool."""
95+
96+
def __init__(self, comp_states: dict[int, EVChargerState]) -> None:
97+
"""Create a `_StateTracker` instance.
98+
99+
Args:
100+
comp_states: initial states of all ev chargers in the pool.
101+
"""
102+
self._states = comp_states
103+
104+
def get(self) -> EVChargerPoolStates:
105+
"""Get a representation of the current states of all ev chargers.
106+
107+
Returns:
108+
An `EVChargerPoolStates` instance.
109+
"""
110+
return EVChargerPoolStates(self._states)
111+
112+
def update(
113+
self,
114+
data: EVChargerData,
115+
) -> Optional[EVChargerPoolStates]:
116+
"""Update the state of an ev charger, from a new data point.
117+
118+
Args:
119+
data: component data from the microgrid, for an ev charger in the pool.
120+
121+
Returns:
122+
A new `EVChargerPoolStates` instance representing all the ev chargers in
123+
the pool, in case there has been a state change for any of the ev
124+
chargers, or `None` otherwise.
125+
"""
126+
evc_id = data.component_id
127+
new_state = EVChargerState.from_ev_charger_data(data)
128+
if evc_id not in self._states or self._states[evc_id] != new_state:
129+
self._states[evc_id] = new_state
130+
return EVChargerPoolStates(self._states, evc_id)
131+
return None
132+
133+
134+
class EVChargerPool:
135+
"""Interactions with EV Chargers."""
136+
137+
def __init__(
138+
self,
139+
component_ids: Optional[set[int]] = None,
140+
) -> None:
141+
"""Create an `EVChargerPool` instance.
142+
143+
Args:
144+
component_ids: An optional list of component_ids belonging to this pool. If
145+
not specified, IDs of all ev chargers in the microgrid will be fetched
146+
from the component graph.
147+
"""
148+
self._component_ids = set()
149+
if component_ids is not None:
150+
self._component_ids = component_ids
151+
else:
152+
graph = microgrid.get().component_graph
153+
self._component_ids = {
154+
evc.component_id
155+
for evc in graph.components(
156+
component_category={ComponentCategory.EV_CHARGER}
157+
)
158+
}
159+
self._channel = Broadcast[EVChargerPoolStates](
160+
"EVCharger States", resend_latest=True
161+
)
162+
self._task: Optional[asyncio.Task[None]] = None
163+
self._merged_stream: Optional[Merge] = None
164+
165+
async def _run(self) -> None:
166+
logger.debug("Starting EVChargerPool for components: %s", self._component_ids)
167+
api_client = microgrid.get().api_client
168+
streams: list[Receiver[EVChargerData]] = await asyncio.gather(
169+
*[api_client.ev_charger_data(cid) for cid in self._component_ids]
170+
)
171+
172+
latest_messages: list[EVChargerData] = await asyncio.gather(
173+
*[stream.receive() for stream in streams]
174+
)
175+
states = {
176+
msg.component_id: EVChargerState.from_ev_charger_data(msg)
177+
for msg in latest_messages
178+
}
179+
state_tracker = _StateTracker(states)
180+
self._merged_stream = Merge(*streams)
181+
sender = self._channel.new_sender()
182+
await sender.send(state_tracker.get())
183+
async for data in self._merged_stream:
184+
if updated_states := state_tracker.update(data):
185+
await sender.send(updated_states)
186+
187+
async def _stop(self) -> None:
188+
if self._task:
189+
await cancel_and_await(self._task)
190+
if self._merged_stream:
191+
await self._merged_stream.stop()
192+
193+
def states(self) -> Receiver[EVChargerPoolStates]:
194+
"""Return a receiver that streams ev charger states.
195+
196+
Returns:
197+
A receiver that streams the states of all ev chargers in the pool, every
198+
time the states of any of them change.
199+
"""
200+
if self._task is None or self._task.done():
201+
self._task = asyncio.create_task(self._run())
202+
return self._channel.new_receiver()

src/frequenz/sdk/timeseries/logical_meter/_formula_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async def _synchronize_metric_timestamps(
8585
RuntimeError: when some streams have no value, or when the synchronization
8686
of timestamps fails.
8787
"""
88-
metrics_by_ts: Dict[datetime, str] = {}
88+
metrics_by_ts: Dict[datetime, list[str]] = {}
8989
for metric in metrics:
9090
result = metric.result()
9191
name = metric.get_name()

tests/timeseries/mock_microgrid.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def __init__(self, grid_side_meter: bool, sample_rate_s: float = 0.01):
7979
self.evc_ids: list[int] = []
8080
self.meter_ids: list[int] = [4]
8181

82+
self.evc_states: dict[int, ev_charger_pb2.State] = {}
83+
8284
self._streaming_coros: list[typing.Coroutine[None, None, None]] = []
8385
self._streaming_tasks: list[asyncio.Task[None]] = []
8486
self._actors: list[typing.Any] = []
@@ -206,11 +208,12 @@ def _start_ev_charger_streaming(self, evc_id: int) -> None:
206208
current=common_pb2.Metric(value=value + 12.0)
207209
),
208210
)
209-
)
211+
),
212+
state=self.evc_states[evc_id],
210213
),
211-
)
214+
),
212215
),
213-
)
216+
),
214217
)
215218

216219
def add_batteries(self, count: int) -> None:
@@ -294,7 +297,10 @@ def add_ev_chargers(self, count: int) -> None:
294297
self._id_increment += 1
295298

296299
self.evc_ids.append(evc_id)
297-
300+
self.evc_states[evc_id] = ev_charger_pb2.State(
301+
cable_state=ev_charger_pb2.CABLE_STATE_UNPLUGGED,
302+
component_state=ev_charger_pb2.COMPONENT_STATE_READY,
303+
)
298304
self._components.add(
299305
Component(
300306
evc_id,

0 commit comments

Comments
 (0)