Skip to content

Commit fcf0484

Browse files
committed
Test EV Charger Pool control methods
Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 4dcac87 commit fcf0484

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed

tests/microgrid/fixtures.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ async def new(
6868
streamer,
6969
dp._battery_power_wrapper.status_channel.new_sender(),
7070
)
71+
if component_category == ComponentCategory.EV_CHARGER:
72+
return cls(
73+
mockgrid,
74+
streamer,
75+
dp._ev_power_wrapper.status_channel.new_sender(),
76+
)
7177
raise ValueError(f"Unsupported component category: {component_category}")
7278

7379
async def stop(self) -> None:
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test the EV charger pool control methods."""
5+
6+
import asyncio
7+
import typing
8+
from datetime import datetime, timedelta, timezone
9+
from unittest.mock import AsyncMock, MagicMock
10+
11+
import pytest
12+
from frequenz.channels import Receiver
13+
from frequenz.client.microgrid import EVChargerCableState, EVChargerComponentState
14+
from pytest_mock import MockerFixture
15+
16+
from frequenz.sdk import microgrid
17+
from frequenz.sdk.actor import ResamplerConfig, power_distributing
18+
from frequenz.sdk.actor.power_distributing import (
19+
ComponentPoolStatus,
20+
PowerDistributingActor,
21+
)
22+
from frequenz.sdk.actor.power_distributing._component_managers import EVChargerManager
23+
from frequenz.sdk.actor.power_distributing._component_managers._ev_charger_manager._config import (
24+
EVDistributionConfig,
25+
)
26+
from frequenz.sdk.actor.power_distributing._component_pool_status_tracker import (
27+
ComponentPoolStatusTracker,
28+
)
29+
from frequenz.sdk.microgrid._data_pipeline import _DataPipeline
30+
from frequenz.sdk.timeseries import Power, Sample3Phase, Voltage
31+
from frequenz.sdk.timeseries.ev_charger_pool import EVChargerPool, EVChargerPoolReport
32+
33+
from ...microgrid.fixtures import _Mocks
34+
from ...utils.component_data_streamer import MockComponentDataStreamer
35+
from ...utils.component_data_wrapper import EvChargerDataWrapper, MeterDataWrapper
36+
from ..mock_microgrid import MockMicrogrid
37+
38+
# pylint: disable=protected-access
39+
40+
41+
@pytest.fixture
42+
async def mocks(mocker: MockerFixture) -> typing.AsyncIterator[_Mocks]:
43+
"""Create the mocks."""
44+
mockgrid = MockMicrogrid(grid_meter=True)
45+
mockgrid.add_ev_chargers(4)
46+
await mockgrid.start(mocker)
47+
48+
# pylint: disable=protected-access
49+
if microgrid._data_pipeline._DATA_PIPELINE is not None:
50+
microgrid._data_pipeline._DATA_PIPELINE = None
51+
await microgrid._data_pipeline.initialize(
52+
ResamplerConfig(resampling_period=timedelta(seconds=0.1))
53+
)
54+
streamer = MockComponentDataStreamer(mockgrid.mock_client)
55+
56+
dp = typing.cast(_DataPipeline, microgrid._data_pipeline._DATA_PIPELINE)
57+
58+
yield _Mocks(
59+
mockgrid,
60+
streamer,
61+
dp._ev_power_wrapper.status_channel.new_sender(),
62+
)
63+
64+
65+
class TestEVChargerPoolControl:
66+
"""Test the EV charger pool control methods."""
67+
68+
async def _patch_ev_pool_status(
69+
self,
70+
mocks: _Mocks,
71+
mocker: MockerFixture,
72+
component_ids: list[int] | None = None,
73+
) -> None:
74+
"""Patch the EV charger pool status.
75+
76+
If `component_ids` is not None, the mock will always return `component_ids`.
77+
Otherwise, it will return the requested components.
78+
"""
79+
if component_ids:
80+
mock = MagicMock(spec=ComponentPoolStatusTracker)
81+
mock.get_working_components.return_value = component_ids
82+
mocker.patch(
83+
"frequenz.sdk.actor.power_distributing._component_managers"
84+
"._ev_charger_manager._ev_charger_manager.ComponentPoolStatusTracker",
85+
return_value=mock,
86+
)
87+
else:
88+
mock = MagicMock(spec=ComponentPoolStatusTracker)
89+
mock.get_working_components.side_effect = set
90+
mocker.patch(
91+
"frequenz.sdk.actor.power_distributing._component_managers"
92+
"._ev_charger_manager._ev_charger_manager.ComponentPoolStatusTracker",
93+
return_value=mock,
94+
)
95+
await mocks.component_status_sender.send(
96+
ComponentPoolStatus(working=set(mocks.microgrid.evc_ids), uncertain=set())
97+
)
98+
99+
async def _patch_data_pipeline(self, mocker: MockerFixture) -> None:
100+
mocker.patch(
101+
"frequenz.sdk.microgrid._data_pipeline._DATA_PIPELINE._ev_power_wrapper"
102+
"._pd_wait_for_data_sec",
103+
0.1,
104+
)
105+
106+
async def _patch_power_distributing_actor(
107+
self,
108+
mocker: MockerFixture,
109+
) -> None:
110+
dp = typing.cast(_DataPipeline, microgrid._data_pipeline._DATA_PIPELINE)
111+
pda = typing.cast(
112+
PowerDistributingActor, dp._ev_power_wrapper._power_distributing_actor
113+
)
114+
cm = typing.cast(
115+
EVChargerManager,
116+
pda._component_manager,
117+
)
118+
mocker.patch(
119+
"frequenz.sdk.microgrid._data_pipeline._DATA_PIPELINE._ev_power_wrapper"
120+
"._power_distributing_actor._component_manager._config",
121+
EVDistributionConfig(
122+
component_ids=cm._config.component_ids,
123+
initial_current=cm._config.initial_current,
124+
min_current=cm._config.min_current,
125+
increase_power_interval=timedelta(seconds=0.12),
126+
),
127+
)
128+
mocker.patch(
129+
"frequenz.sdk.microgrid._data_pipeline._DATA_PIPELINE._ev_power_wrapper"
130+
"._power_distributing_actor._component_manager._voltage_cache.get",
131+
return_value=Sample3Phase(
132+
timestamp=datetime.now(tz=timezone.utc),
133+
value_p1=Voltage.from_volts(220.0),
134+
value_p2=Voltage.from_volts(220.0),
135+
value_p3=Voltage.from_volts(220.0),
136+
),
137+
)
138+
139+
async def _init_ev_chargers(self, mocks: _Mocks) -> None:
140+
now = datetime.now(tz=timezone.utc)
141+
for evc_id in mocks.microgrid.evc_ids:
142+
mocks.streamer.start_streaming(
143+
EvChargerDataWrapper(
144+
evc_id,
145+
now,
146+
cable_state=EVChargerCableState.EV_PLUGGED,
147+
component_state=EVChargerComponentState.READY,
148+
active_power=0.0,
149+
active_power_inclusion_lower_bound=0.0,
150+
active_power_inclusion_upper_bound=16.0 * 230.0 * 3,
151+
voltage_per_phase=(230.0, 230.0, 230.0),
152+
),
153+
0.05,
154+
)
155+
156+
for meter_id in mocks.microgrid.meter_ids:
157+
mocks.streamer.start_streaming(
158+
MeterDataWrapper(
159+
meter_id,
160+
now,
161+
voltage_per_phase=(230.0, 230.0, 230.0),
162+
),
163+
0.05,
164+
)
165+
166+
await asyncio.sleep(1)
167+
168+
def _assert_report( # pylint: disable=too-many-arguments
169+
self,
170+
report: EVChargerPoolReport,
171+
*,
172+
power: float | None,
173+
lower: float,
174+
upper: float,
175+
expected_result_pred: (
176+
typing.Callable[[power_distributing.Result], bool] | None
177+
) = None,
178+
) -> None:
179+
assert report.target_power == (
180+
Power.from_watts(power) if power is not None else None
181+
)
182+
assert report.bounds is not None
183+
assert report.bounds.lower == Power.from_watts(lower)
184+
assert report.bounds.upper == Power.from_watts(upper)
185+
if expected_result_pred is not None:
186+
assert report.distribution_result is not None
187+
assert expected_result_pred(report.distribution_result)
188+
189+
async def _get_bounds_receiver(
190+
self, ev_charger_pool: EVChargerPool
191+
) -> Receiver[EVChargerPoolReport]:
192+
bounds_rx = ev_charger_pool.power_status.new_receiver()
193+
194+
# Consume initial reports as chargers are initialized
195+
expected_upper_bounds = 44160.0
196+
max_reports = 10
197+
ctr = 0
198+
while ctr < max_reports:
199+
ctr += 1
200+
report = await bounds_rx.receive()
201+
assert report.bounds is not None
202+
if report.bounds.upper == Power.from_watts(expected_upper_bounds):
203+
break
204+
205+
return bounds_rx
206+
207+
async def test_setting_power(
208+
self,
209+
mocks: _Mocks,
210+
mocker: MockerFixture,
211+
) -> None:
212+
"""Test setting power."""
213+
set_power = typing.cast(
214+
AsyncMock, microgrid.connection_manager.get().api_client.set_power
215+
)
216+
217+
await self._init_ev_chargers(mocks)
218+
await self._patch_data_pipeline(mocker)
219+
ev_charger_pool = microgrid.ev_charger_pool()
220+
await self._patch_ev_pool_status(mocks, mocker)
221+
await self._patch_power_distributing_actor(mocker)
222+
223+
bounds_rx = await self._get_bounds_receiver(ev_charger_pool)
224+
225+
# Check that chargers are initialized to Power.zero()
226+
assert set_power.call_count == 4
227+
assert all(x.args[1] == 0.0 for x in set_power.call_args_list)
228+
229+
self._assert_report(
230+
await bounds_rx.receive(), power=None, lower=0.0, upper=44160.0
231+
)
232+
233+
set_power.reset_mock()
234+
await ev_charger_pool.propose_power(Power.from_watts(40000.0))
235+
# ignore one report because it is not always immediately updated.
236+
await bounds_rx.receive()
237+
self._assert_report(
238+
await bounds_rx.receive(), power=40000.0, lower=0.0, upper=44160.0
239+
)
240+
await asyncio.sleep(0.15)
241+
242+
# Components are set initial power
243+
assert set_power.call_count == 4
244+
assert all(x.args[1] == 6600.0 for x in set_power.call_args_list)
245+
246+
# All available power is allocated. 3 chargers are set to 11040.0
247+
# and the last one is set to 6880.0
248+
set_power.reset_mock()
249+
await asyncio.sleep(0.15)
250+
assert set_power.call_count == 4
251+
252+
evs_11040 = [x.args for x in set_power.call_args_list if x.args[1] == 11040.0]
253+
assert 3 == len(evs_11040)
254+
evs_6680 = [x.args for x in set_power.call_args_list if x.args[1] == 6880.0]
255+
assert 1 == len(evs_6680)
256+
257+
# Throttle the power
258+
set_power.reset_mock()
259+
await ev_charger_pool.propose_power(Power.from_watts(32000.0))
260+
await bounds_rx.receive()
261+
await asyncio.sleep(0.02)
262+
assert set_power.call_count == 1
263+
264+
stopped_evs = [x.args for x in set_power.call_args_list if x.args[1] == 0.0]
265+
assert 1 == len(stopped_evs)
266+
assert stopped_evs[0][0] in [evc[0] for evc in evs_11040]

0 commit comments

Comments
 (0)