Skip to content

Commit 24324fe

Browse files
committed
Add component_data method to EVChargerPool
This method ties the resampled 3-phase current values for an ev charger with the component state, which comes directly from the microgrid. This is done so that the state-tracking code can piggy-back on the resampler to detect if a component is missing data, and switch its state to `MISSING`. Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 0e4daca commit 24324fe

File tree

3 files changed

+237
-3
lines changed

3 files changed

+237
-3
lines changed

src/frequenz/sdk/timeseries/ev_charger_pool/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33

44
"""Interactions with EV Chargers."""
55

6-
from ._ev_charger_pool import EVChargerPool
6+
from ._ev_charger_pool import EVChargerData, EVChargerPool, EVChargerPoolError
7+
from ._state_tracker import EVChargerState
78

89
__all__ = [
910
"EVChargerPool",
11+
"EVChargerData",
12+
"EVChargerPoolError",
13+
"EVChargerState",
1014
]

src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,25 @@
55

66
from __future__ import annotations
77

8+
import asyncio
89
import logging
910
import uuid
11+
from asyncio import Task
12+
from dataclasses import dataclass
1013

11-
from frequenz.channels import Sender
14+
from frequenz.channels import Broadcast, ChannelClosedError, Receiver, Sender
1215

1316
from ...actor import ChannelRegistry, ComponentMetricRequest
1417
from ...microgrid import connection_manager
15-
from ...microgrid.component import ComponentCategory
18+
from ...microgrid.component import ComponentCategory, ComponentMetricId
19+
from .. import Sample, Sample3Phase
1620
from .._formula_engine import FormulaEnginePool, FormulaReceiver, FormulaReceiver3Phase
1721
from .._formula_engine._formula_generators import (
1822
EVChargerCurrentFormula,
1923
EVChargerPowerFormula,
2024
FormulaGeneratorConfig,
2125
)
26+
from ._state_tracker import EVChargerState, StateTracker
2227

2328
logger = logging.getLogger(__name__)
2429

@@ -27,6 +32,15 @@ class EVChargerPoolError(Exception):
2732
"""An error that occurred in any of the EVChargerPool methods."""
2833

2934

35+
@dataclass(frozen=True)
36+
class EVChargerData:
37+
"""Data for an EV Charger, including the 3-phase current and the component state."""
38+
39+
component_id: int
40+
current: Sample3Phase
41+
state: EVChargerState
42+
43+
3044
class EVChargerPool:
3145
"""Interactions with EV Chargers."""
3246

@@ -62,6 +76,10 @@ def __init__(
6276
component_category={ComponentCategory.EV_CHARGER}
6377
)
6478
}
79+
self._state_tracker: StateTracker | None = None
80+
self._status_streams: dict[
81+
int, tuple[Task[None], Broadcast[EVChargerData]]
82+
] = {}
6583
self._namespace: str = f"ev-charger-pool-{uuid.uuid4()}"
6684
self._formula_pool: FormulaEnginePool = FormulaEnginePool(
6785
self._namespace,
@@ -148,3 +166,115 @@ async def power(self, component_id: int) -> FormulaReceiver:
148166
EVChargerPowerFormula,
149167
FormulaGeneratorConfig(component_ids={component_id}),
150168
)
169+
170+
async def component_data(self, component_id: int) -> Receiver[EVChargerData]:
171+
"""Stream 3-phase current values and state of an EV Charger.
172+
173+
Args:
174+
component_id: id of the EV Charger for which data is requested.
175+
176+
Returns:
177+
A receiver that streams objects containing 3-phase current and state of
178+
an EV Charger.
179+
"""
180+
if recv := self._status_streams.get(component_id, None):
181+
task, output_chan = recv
182+
if not task.done():
183+
return output_chan.new_receiver()
184+
logger.warning("Restarting component_status for id: %s", component_id)
185+
else:
186+
output_chan = Broadcast[EVChargerData](
187+
f"evpool-component_status-{component_id}"
188+
)
189+
190+
task = asyncio.create_task(
191+
self._stream_component_data(component_id, output_chan.new_sender())
192+
)
193+
194+
self._status_streams[component_id] = (task, output_chan)
195+
196+
return output_chan.new_receiver()
197+
198+
async def _get_current_streams(
199+
self, component_id: int
200+
) -> tuple[Receiver[Sample], Receiver[Sample], Receiver[Sample]]:
201+
"""Fetch current streams from the resampler for each phase.
202+
203+
Args:
204+
component_id: id of EV Charger for which current streams are being fetched.
205+
206+
Returns:
207+
A tuple of 3 receivers stream resampled current values for the given
208+
component id, one for each phase.
209+
"""
210+
211+
async def resampler_subscribe(metric_id: ComponentMetricId) -> Receiver[Sample]:
212+
request = ComponentMetricRequest(
213+
namespace="ev-pool",
214+
component_id=component_id,
215+
metric_id=metric_id,
216+
start_time=None,
217+
)
218+
await self._resampler_subscription_sender.send(request)
219+
return self._channel_registry.new_receiver(request.get_channel_name())
220+
221+
return (
222+
await resampler_subscribe(ComponentMetricId.CURRENT_PHASE_1),
223+
await resampler_subscribe(ComponentMetricId.CURRENT_PHASE_2),
224+
await resampler_subscribe(ComponentMetricId.CURRENT_PHASE_3),
225+
)
226+
227+
async def _stream_component_data(
228+
self,
229+
component_id: int,
230+
sender: Sender[EVChargerData],
231+
) -> None:
232+
"""Stream 3-phase current values and state of an EV Charger.
233+
234+
Args:
235+
component_id: id of the EV Charger for which data is requested.
236+
sender: A sender to stream EV Charger data to.
237+
238+
Raises:
239+
ChannelClosedError: If the channels from the resampler are closed.
240+
"""
241+
if not self._state_tracker:
242+
self._state_tracker = StateTracker(self._component_ids)
243+
244+
(phase_1_rx, phase_2_rx, phase_3_rx) = await self._get_current_streams(
245+
component_id
246+
)
247+
while True:
248+
try:
249+
(phase_1, phase_2, phase_3) = (
250+
await phase_1_rx.receive(),
251+
await phase_2_rx.receive(),
252+
await phase_3_rx.receive(),
253+
)
254+
except ChannelClosedError:
255+
logger.exception("Streams closed for component_id=%s.", component_id)
256+
raise
257+
258+
sample = Sample3Phase(
259+
timestamp=phase_1.timestamp,
260+
value_p1=phase_1.value,
261+
value_p2=phase_2.value,
262+
value_p3=phase_3.value,
263+
)
264+
265+
if (
266+
phase_1.value is None
267+
and phase_2.value is None
268+
and phase_3.value is None
269+
):
270+
state = EVChargerState.MISSING
271+
else:
272+
state = self._state_tracker.get(component_id)
273+
274+
await sender.send(
275+
EVChargerData(
276+
component_id=component_id,
277+
current=sample,
278+
state=state,
279+
)
280+
)

tests/timeseries/test_ev_charger_pool.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
from __future__ import annotations
77

88
import asyncio
9+
from datetime import datetime
910
from math import isclose
11+
from typing import Any
1012

13+
from frequenz.channels import Broadcast, Receiver
1114
from pytest_mock import MockerFixture
1215

1316
from frequenz.sdk import microgrid
@@ -16,6 +19,7 @@
1619
EVChargerCableState,
1720
EVChargerComponentState,
1821
)
22+
from frequenz.sdk.timeseries import Sample
1923
from frequenz.sdk.timeseries.ev_charger_pool._state_tracker import (
2024
EVChargerState,
2125
StateTracker,
@@ -112,3 +116,99 @@ async def test_ev_power( # pylint: disable=too-many-locals
112116

113117
await mockgrid.cleanup()
114118
assert len(ev_results) == 10
119+
120+
async def test_ev_component_data(self, mocker: MockerFixture) -> None:
121+
"""Test the component_data method of EVChargerPool."""
122+
mockgrid = MockMicrogrid(grid_side_meter=False)
123+
mockgrid.add_ev_chargers(1)
124+
await mockgrid.start(mocker)
125+
evc_id = mockgrid.evc_ids[0]
126+
127+
ev_pool = microgrid.ev_charger_pool()
128+
129+
resampled_p1_channel = Broadcast[Sample]("resampled-current-phase-1")
130+
resampled_p2_channel = Broadcast[Sample]("resampled-current-phase-2")
131+
resampled_p3_channel = Broadcast[Sample]("resampled-current-phase-3")
132+
133+
async def send_resampled_current(
134+
phase_1: float | None, phase_2: float | None, phase_3: float | None
135+
) -> None:
136+
sender_p1 = resampled_p1_channel.new_sender()
137+
sender_p2 = resampled_p2_channel.new_sender()
138+
sender_p3 = resampled_p3_channel.new_sender()
139+
140+
now = datetime.now()
141+
asyncio.gather(
142+
sender_p1.send(Sample(now, phase_1)),
143+
sender_p2.send(Sample(now, phase_2)),
144+
sender_p3.send(Sample(now, phase_3)),
145+
)
146+
147+
async def mock_current_streams(
148+
_1: Any, _2: int
149+
) -> tuple[Receiver[Sample], Receiver[Sample], Receiver[Sample]]:
150+
return (
151+
resampled_p1_channel.new_receiver(),
152+
resampled_p2_channel.new_receiver(),
153+
resampled_p3_channel.new_receiver(),
154+
)
155+
156+
mocker.patch(
157+
"frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool._get_current_streams",
158+
mock_current_streams,
159+
)
160+
161+
recv = await ev_pool.component_data(evc_id)
162+
163+
await send_resampled_current(2, 3, 5)
164+
await asyncio.sleep(0.02)
165+
status = await recv.receive()
166+
assert (
167+
status.current.value_p1,
168+
status.current.value_p2,
169+
status.current.value_p3,
170+
) == (2, 3, 5)
171+
assert status.state == EVChargerState.MISSING
172+
173+
await send_resampled_current(2, 3, None)
174+
await asyncio.sleep(0.02)
175+
status = await recv.receive()
176+
assert (
177+
status.current.value_p1,
178+
status.current.value_p2,
179+
status.current.value_p3,
180+
) == (2, 3, None)
181+
assert status.state == EVChargerState.IDLE
182+
183+
await send_resampled_current(None, None, None)
184+
await asyncio.sleep(0.02)
185+
status = await recv.receive()
186+
assert (
187+
status.current.value_p1,
188+
status.current.value_p2,
189+
status.current.value_p3,
190+
) == (None, None, None)
191+
assert status.state == EVChargerState.MISSING
192+
193+
await send_resampled_current(None, None, None)
194+
mockgrid.evc_cable_states[evc_id] = EVChargerCableState.EV_PLUGGED
195+
await asyncio.sleep(0.02)
196+
status = await recv.receive()
197+
assert (
198+
status.current.value_p1,
199+
status.current.value_p2,
200+
status.current.value_p3,
201+
) == (None, None, None)
202+
assert status.state == EVChargerState.MISSING
203+
204+
await send_resampled_current(4, None, None)
205+
await asyncio.sleep(0.02)
206+
status = await recv.receive()
207+
assert (
208+
status.current.value_p1,
209+
status.current.value_p2,
210+
status.current.value_p3,
211+
) == (4, None, None)
212+
assert status.state == EVChargerState.EV_PLUGGED
213+
214+
await mockgrid.cleanup()

0 commit comments

Comments
 (0)